LangChain 챗봇6장 · 문서 기반 챗봇8 / 21← 이전목차다음 →MetaContents

6장. 문서 기반 챗봇

연습용 임베딩과 진짜 임베딩

낱말 퍼즐에서 "글자가 많이 겹치면 비슷한 낱말"이라고 정하면, "환불 기간"과 "환불은 언제"는 "환불"이 겹쳐 비슷하다고 칩니다. 하지만 "강아지"와 "반려동물"은 한 글자도 겹치지 않아 남남이 됩니다. 사람은 뜻을 알아서 둘이 가깝다는 것을 압니다. 이 장의 연습용 임베딩은 글자만 보는 퍼즐 방식이고, 진짜 임베딩은 뜻을 아는 사람 쪽입니다.

글자 두 개 묶음을 세는 연습용 BigramEmbeddings 와 뜻을 배운 진짜 OpenAIEmbeddings 를 나란히 견준 비교 그림

왼쪽 파랑 상자가 이 교안이 만든 BigramEmbeddings, 오른쪽 보라 상자가 OpenAI 의 임베딩 모델입니다. 왼쪽은 키도 요금도 필요 없고 언제나 같은 숫자를 내지만, 글자가 겹쳐야만 가깝다고 봅니다. 오른쪽은 키와 요금이 들지만 뜻이 같으면 글자가 달라도 가깝게 놓습니다. 여기서 글자 퍼즐이 BigramEmbeddings, 뜻을 아는 사람이 OpenAIEmbeddings 입니다. 비유와 다른 점은, 진짜 임베딩도 사람처럼 뜻을 "이해"하는 것은 아니고, 많은 글에서 배운 좌표로 가깝고 먼 것을 정할 뿐이라는 것입니다.

두 임베딩 견주기

견줄 것BigramEmbeddings(연습용)OpenAIEmbeddings(진짜)
만드는 법이웃한 글자 두 개마다 칸 하나를 1 올린다많은 글로 배운 모델이 숫자를 정한다
숫자 개수5121536(text-embedding-3-small)
키와 요금필요 없다필요하다
강아지 ↔ 반려동물글자가 겹치지 않아 멀다뜻이 같아 가깝다
같은 글을 다시 넣으면언제나 같은 숫자거의 같은 숫자
이 장에서 쓰는 곳본문 예시, 실습 2~4실습 5, 프로젝트

BigramEmbeddings 가 하는 일

LangChain 의 임베딩은 Embeddings 를 물려받아 embed_documents(조각 여러 개)와 embed_query(질문 하나) 두 메서드만 채우면 어디에나 끼울 수 있습니다. 앞 절의 FAISS.from_texts 가 이 두 메서드를 불렀습니다. 연습용 임베딩은 다음 차례로 숫자를 만듭니다.

  1. 빈칸과 줄바꿈을 지운다. "환불 기간" → "환불기간".
  2. 이웃한 글자 두 개씩 묶는다. "환불", "불기", "기간".
  3. 묶음마다 zlib.crc32(글을 정해진 숫자로 바꾸는 계산. CRC 는 Cyclic Redundancy Check, 순환 중복 검사)로 0~511 사이의 칸 번호를 정해 그 칸을 1 올린다.
  4. 화살표의 길이가 1 이 되도록 모든 칸을 길이로 나눈다.

칸 번호를 정할 때 파이썬의 hash() 를 쓰지 않은 까닭은, hash() 가 글자열에 대해 실행할 때마다 다른 값을 내기 때문입니다. 그러면 저장한 색인과 다음 실행의 질문이 다른 지도를 쓰게 됩니다. crc32 는 언제나 같은 값을 냅니다.

짧은 예시

질문 둘과 약관 조각 하나로 겹치는 바이그램과 방향 닮음을 셉니다. 길이를 1 로 맞췄으므로 칸마다 곱해 더한 값이 곧 방향 닮음입니다.

Python
import zlib

from langchain_core.embeddings import Embeddings


class BigramEmbeddings(Embeddings):
    """글자 두 개 묶음(바이그램)을 세어 숫자 목록을 만드는 연습용 임베딩"""

    def __init__(self, size=512):
        self.size = size

    def _vector(self, text):
        text = text.replace(" ", "").replace("\n", "")
        vec = [0.0] * self.size
        for i in range(len(text) - 1):
            # 글자 두 개를 crc32 로 0~511 사이의 칸 번호로 바꿔 그 칸을 1 올린다
            vec[zlib.crc32(text[i:i + 2].encode("utf-8")) % self.size] += 1.0
        norm = sum(v * v for v in vec) ** 0.5 or 1.0
        return [v / norm for v in vec]

    def embed_documents(self, texts):
        return [self._vector(t) for t in texts]

    def embed_query(self, text):
        return self._vector(text)


def bigrams(text):
    text = text.replace(" ", "")
    return {text[i:i + 2] for i in range(len(text) - 1)}


emb = BigramEmbeddings()
doc = "반려동물은 몸무게 7kg 이하의 개와 고양이만 된다."
doc_vec = emb.embed_documents([doc])[0]
print("숫자 개수:", len(doc_vec), "/ 0 이 아닌 칸:", sum(1 for v in doc_vec if v))

for q in ["반려동물 데려가도 돼요?", "강아지 데려가도 돼요?"]:
    q_vec = emb.embed_query(q)
    shared = sorted(bigrams(q) & bigrams(doc))
    # 길이가 1 인 두 화살표의 방향 닮음 = 칸마다 곱한 값의 합
    cos = sum(a * b for a, b in zip(q_vec, doc_vec))
    print(f"{q} → 겹친 묶음 {shared}, 방향 닮음 {cos:.3f}")
출력
숫자 개수: 512 / 0 이 아닌 칸: 21
반려동물 데려가도 돼요? → 겹친 묶음 ['동물', '려동', '반려'], 방향 닮음 0.258
강아지 데려가도 돼요? → 겹친 묶음 [], 방향 닮음 0.000

약관 조각의 숫자 512 개 가운데 0 이 아닌 칸은 21 개뿐입니다. 글이 짧아서 묶음 수만큼만 칸이 찹니다. "반려동물 데려가도 돼요?"는 "반려", "려동", "동물" 세 묶음이 겹쳐 방향 닮음이 0.258 입니다. "강아지 데려가도 돼요?"는 겹친 묶음이 하나도 없어 0.000 입니다. 사람에게는 같은 질문이지만, 이 임베딩에게 "강아지"와 "반려동물"은 아무 관계가 없습니다. 실습 3에서 이 약점이 검색 결과에 그대로 드러납니다.

코드가 의미하는 것

이 코드는 글자 퍼즐 방식으로 질문 두 개와 약관 조각 하나가 얼마나 닮았는지 채점하는 일입니다. 퍼즐 규칙을 만들고(BigramEmbeddings), 조각과 질문에 숫자를 붙이고, 겹친 묶음과 방향 닮음을 셉니다.

코드하는 일챗봇에서 뜻하는 것
zlib.crc32(...) % self.size글자 두 개 묶음을 0~511 사이의 칸 번호로 바꾼다실행할 때마다 같은 칸 번호가 나오므로, 오늘 저장한 색인을 내일의 질문과 같은 지도에서 견줄 수 있습니다
v / norm모든 칸을 화살표 길이로 나눈다화살표 길이를 1 로 맞춥니다. 그래서 곱해 더한 값이 곧 방향 닮음이 되고, 글이 길다고 점수가 커지지 않습니다
embed_documents([doc]) 와 embed_query(q)조각 목록과 질문 하나를 각각 숫자 목록으로 바꾼다벡터DB 가 색인할 때와 검색할 때 부르는 두 입구입니다. 이 클래스는 두 입구에 같은 계산을 씁니다
bigrams(q) & bigrams(doc)질문과 조각에 함께 든 묶음을 모은다방향 닮음이 어디서 왔는지 눈으로 보는 장치입니다. 겹친 묶음이 없으면 점수도 없습니다

결과에서 읽을 것은 두 가지입니다.

  • 512칸 가운데 21칸만 찹니다. 짧은 조각은 대부분의 칸이 0 입니다. 이 임베딩은 글에 실제로 나온 묶음만 기록합니다.
  • 같은 뜻이어도 점수는 0.258 과 0.000 입니다. "반려동물"이 그대로 든 질문만 점수를 받았습니다. 손님이 약관과 다른 낱말로 물으면 이 임베딩으로 만든 챗봇은 근거를 찾지 못합니다.

주의할 점

  • BigramEmbeddings 는 원리를 보려고 이 교안이 만든 연습용입니다. 실제 서비스에는 진짜 임베딩 모델을 씁니다.
  • 7장의 웹 화면도 이 클래스를 글자 하나 바꾸지 않고 다시 씁니다. 크기(512)나 계산을 바꾸면 이 장에서 저장한 색인과 맞지 않게 됩니다.
  • 진짜 임베딩의 모델 이름은 .env 의 OPENAI_EMBED_MODEL 로 바꿀 수 있게 하고, 없으면 text-embedding-3-small 을 씁니다. 요금은 OpenAI 가격 페이지에서 확인합니다.
← 이전 절6장 · 임베딩: 글을 숫자 목록으로다음 절 →6장 · FAISS 벡터DB 에 저장하고 검색하기