학습 자료

뜻을 숫자로 바꾼다


Article

오전 마지막에 이걸 봤다.

3교시 터미널
  • '환불' 가 든 조각: 0개 / 1,560개
  • '교환' 가 든 조각: 200개 / 1,560개
  • '반품' 가 든 조각: 200개 / 1,560개

「환불하고 싶은데 어떻게 하나요」에 우리는 아무것도 못 준다. 답이 문서에 있는데도 그렇다.

이 시간에 그걸 푸는 방법의 원리를 본다. 조각 1,560개에 적용하는 건 다음 시간이다.


0. 🔴 오늘 파일이 어떻게 바뀌나 — 지도

오늘은 02_prepare.py 를 안 건드린다. 늘어나는 건 설정 한 줄뿐이다.

지도 · app/config.py
 EMBED_TOKENIZER = "intfloat/multilingual-e5-small"
 EMBED_MAX_TOKENS = 512
+
+# 뜻을 숫자로 바꾸는 모델
+EMBED_MODEL = "intfloat/multilingual-e5-small"

1. 먼저 순진한 해법 — 동의어 사전

제일 먼저 떠오르는 건 이거다.

안 쓸 코드
SYNONYM = {
    "환불": ["교환", "반품"],
    "배송": ["출고", "발송"],
    "성분": ["원료", "함유"],
}

질문에 「환불」이 있으면 「교환」·「반품」도 같이 LIKE 로 찾는다. 실제로 된다. 그리고 이 방식으로 돌아가는 검색 시스템이 세상에 아주 많다.

그런데 우리 데이터에서 이걸로 끝까지 가 보면 이렇게 된다.

사전이 감당해야 하는 것
  1. 01

    「돈 돌려받고 싶어요」

    「환불」이라는 낱말조차 없다. 사전에 이 문장을 어떻게 넣나

  2. 02

    「트러블」 · 「뾰루지」 · 「여드름」 · 「피부 뒤집어짐」

    화장품 도메인 하나에서만 이 정도다. 사람이 손으로 적는다

  3. 03

    「무름」

    고객이 실제로 쓴 말이다. 사전에 없으면 그날 못 찾는다

  4. 04

    새 제품이 나온다

    새 성분 이름 · 새 유행어. 사전은 관리 대상이 된다


2. 다른 생각 — 자리를 준다

낱말을 낱말로 잇는 대신, 모든 문장에 자리를 하나씩 준다. 뜻이 비슷한 문장은 가까운 자리에 놓는다.

찾는 방식이 통째로 바뀐다
  1. 질문도 자리를 받는다「환불하고 싶은데」 → 어떤 지점
  2. 가까운 조각을 찾는다글자를 안 본다. 거리만 본다
  3. 「교환·반품」 조각이 걸린다글자가 하나도 안 겹치는데

자리를 숫자 몇 개로 적나

숫자 하나로 적어 보자. 1차원 직선 위의 한 점이다.

1차원에 놓아 본다면
  • 0.1 ──────── 0.5 ──────── 0.9
  • 배송 환불 성분
  • 그런데 '가격 문의' 는 어디에?
  • '배송이 늦어 환불하고 싶다' 는?
1차원에는 「순서」밖에 없다충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

직선 위에서는 모든 것이 한 줄로 세워진다. 배송보다 환불이 크고, 환불보다 성분이 크다 — 그런 관계밖에 못 적는다.

그런데 「배송」과 「환불」은 「크다·작다」로 이어진 사이가 아니다. 주제가 다를 뿐이다.

차원을 늘리면 방향이 생긴다. 2차원이면 「배송 축」과 「성분 축」을 따로 쓸 수 있다. 384차원이면 그런 축을 384개 쓸 수 있다.


3. config.py 에 모델 이름을 넣는다

1교시에는 (EMBED_TOKENIZER)만 넣었다. 이제 계산기가 필요하다. app/config.py 의 2일차 구역 맨 아래에 이어 붙인다.

app/config.py
# 뜻을 숫자로 바꾸는 모델. 384개짜리 실수 목록을 돌려준다
EMBED_MODEL = "intfloat/multilingual-e5-small"
🔴 EMBED_TOKENIZER 와 값이 같은데, 왜 변수를 둘로 두나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

하는 일이 다르고, 6일차에 실제로 갈라지기 때문이다.

EMBED_TOKENIZER다 ― 글을 자를 때 토큰을 센다. EMBED_MODEL계산기다 ― 글을 벡터로 바꾼다. 지금은 같은 물건이 둘 다 하고 있을 뿐이다.

6일차에 상용을 켜면 이렇게 갈린다 ― EMBED_MODEL`text-embedding-3-small` 로 바뀌는데, `EMBED_TOKENIZER` 는 그대로 남는다.

🔴 왜 자는 안 바꾸나 ― 조각 경계가 양쪽에서 같아야 「로컬 vs 상용」을 비교할 수 있기 때문이다. 자를 같이 바꾸면 조각이 달라지고, 그러면 점수 차이가 모델 때문인지 조각 때문인지 못 가른다. 6일차 3교시가 정확히 그 비교를 한다.

안전한가 ― 재 봤다. 우리 조각 1,560개를 상용 모델의 자(cl100k)로 세면 최대 616토큰이다. 상용 상한 8,191 의 7.5% 라 넘는 조각이 0개다. (같은 글인데 자가 달라 e5 로는 최대 338 · 상용 자로는 616 ― 1.77배다.)

「값이 같다」와 「같은 것이다」는 다르다. 지금 같다고 하나로 합치면, 갈라지는 날 합쳐 둔 자리를 다시 뜯어야 한다.


4. 직접 만들어 본다

langchain-huggingface 는 1교시에 이미 깔았다. 여기서 가중치 449MB 를 실제로 쓴다.

아래는 02_prepare.py 가 아니다. try_embed.py 라는 새 파일을 하나 만들어 이 시간 것만 거기에 친다. 다 보고 나면 지워도 되는 파일이다.

실습용 · try_embed.py (다 보고 지운다)
from langchain_huggingface import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-small",
    # 🔴 CPU 로 계산한다고 못 박는다. 왜 그러는지는 바로 아래에서
    model_kwargs={"device": "cpu"},
    # 길이를 1 로 맞춘다. 왜 그러는지는 아래 6번에서 나온다
    encode_kwargs={"normalize_embeddings": True},
)
python try_embed.py
터미널
  • 모델 올리는 데 6.9초

문장 네 개를 벡터로

실습용 · try_embed.py
texts = [
    "환불하고 싶은데 어떻게 하나요",                        # 0  질문
    "수령일로부터 7일 이내에 교환 및 반품이 가능합니다",       # 1  답이 있는 문장
    "평일 오후 2시 이전 결제 건은 당일 출고됩니다",           # 2  같은 섹션의 다른 문장
    "고농도 비타민C 유도체가 함유되어 있습니다",              # 3  전혀 다른 얘기
]

vectors = embeddings.embed_documents(texts)   # 글 목록 -> 벡터 목록
터미널
  • 문장 4개 벡터로 만드는 데 0.05초

0.05초. 올리는 데 6.9초, 쓰는 데 0.05초다. 100배가 넘는다.


5. 벡터 하나를 들여다본다

실습용 · try_embed.py
v = vectors[0]
print(f"숫자 {len(v)}개")
print("  앞 8개:", [round(x, 4) for x in v[:8]])
print(f"  길이(제곱합의 제곱근): {sum(x * x for x in v) ** 0.5:.6f}")
터미널
  • 숫자 384개
  • 앞 8개: [0.0596, -0.0368, -0.0817, -0.0633, 0.0628, -0.0052, 0.0083, 0.032]
  • 길이(제곱합의 제곱근): 1.000000

6. 가까운지 어떻게 재나

두 벡터가 얼마나 같은 방향을 보는지 잰다. 코사인 유사도다.

왜 거리가 아니라 각도인가

직선 거리

안 쓴다

  • 긴 글은 벡터가 커지는 경향이 있다
  • 그러면 글 길이가 점수에 섞인다
  • 「짧은 질문」과 「긴 문서」를 비교하는 우리 상황에 나쁘다

각도 (코사인)

이걸 쓴다

  • 방향만 본다. 크기는 무시한다
  • 길이 1 로 맞춰 두면(normalize_embeddings=True)
  • 곱해서 더하기만 하면 끝난다 — 계산이 싸다
실습용 · try_embed.py
def cos(a, b):
    return sum(x * y for x, y in zip(a, b))   # 길이가 1 이라 내적 = 코사인

def overlap(a, b):
    """글자가 얼마나 겹치나 (비교용). 두 글의 글자 집합을 나눈다"""
    return len(set(a) & set(b)) / len(set(a) | set(b))
normalize_embeddings=True 를 켠 이유가 여기 있다충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

코사인은 원래 「내적 ÷ (길이 × 길이)」다. 그런데 두 길이가 모두 1 이면 나눌 게 없다.

미리 길이를 1 로 맞춰 두면, 그다음부터 유사도 계산이 곱셈과 덧셈뿐이다. 384번 곱하고 더하면 끝이다.

고객 300명 × 상품 200개를 비교해도 순식간인 이유가 이것이다.


7. 글자 겹침과 나란히 놓는다

실습용 · try_embed.py
for i, j, label in [(0, 1, "환불 질문 ↔ 교환·반품 문장"),
                    (0, 2, "환불 질문 ↔ 배송 문장"),
                    (0, 3, "환불 질문 ↔ 비타민C 문장"),
                    (1, 2, "교환·반품 문장 ↔ 배송 문장")]:
    print(f"  {label:40s} {overlap(texts[i], texts[j]):>8.2f} {cos(vectors[i], vectors[j]):>8.4f}")
터미널
  • 두 문장 글자겹침 코사인
  • 환불 질문 ↔ 교환·반품 문장 0.06 0.8597
  • 환불 질문 ↔ 배송 문장 0.10 0.8451
  • 환불 질문 ↔ 비타민C 문장 0.11 0.8142
  • 교환·반품 문장 ↔ 배송 문장 0.14 0.9094

🔴 그런데 그 차이가 얼마인가

「환불하고 싶은데 어떻게 하나요」 와의 코사인
교환·반품 문장 (정답)0.8597
배송 문장0.8451
비타민C 문장 (무관)0.8142
왜 다 0.8 대인가충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

같은 언어 · 같은 도메인 · 비슷한 길이의 문장들이라 그렇다. 모델 입장에서는 넷 다 「한국어로 쓴 화장품 쇼핑몰 문장」이다.

모델마다 이 폭이 다르다. 어떤 모델은 0.2~0.9 로 넓게 벌리고, e5 계열은 좁게 모인다.

그래서 모델을 바꾸면 점수를 다시 봐야 한다. 오늘 쓰던 문턱값(예: 0.7 이상만 채택)이 새 모델에서는 아무 의미가 없다.


이 시간에 한 것

4교시 요점
주제핵심
동의어 사전정확하지만 끝이 없다. 「무름」 같은 말이 나오는 날 진다
자리를 준다낱말을 잇는 대신 문장마다 자리를 준다. 가까우면 비슷한 것
왜 384개인가1차원엔 순서밖에 없다. 차원이 있어야 「주제가 다르다」를 적을 수 있다
모델 로딩 6.9초쓰는 건 0.05초. 올리는 비용과 쓰는 비용이 다르다 (5일차 서버 시작)
🔴 device="cpu"늘 참이어야 하는 조건이라 config 로 안 뺐다. 안 적으면 장비마다 다르게 터진다
벡터 하나384개 · 길이 1 · 글자는 안 남는다. 되돌릴 수 없다 → 마스킹은 그 전에 (3일차)
코사인각도만 본다. 길이를 1 로 맞춰 두면 곱하고 더하면 끝
✅ 되는 것글자 겹침으로는 순위가 거꾸로, 벡터로는 바로 나왔다
🔴 안심하면 안 되는 것1등과 3등 차이가 0.045, 1등과 2등은 0.0146. 무관한 문장도 0.81
읽는 법절대값은 뜻이 없다. 순위만 본다. 모델을 바꾸면 문턱값도 버린다

다음 시간에 할 것

원리는 봤다. 이제 조각 1,560개를 전부 벡터로 만들어 DB 에 넣는다.

문장 4개에 0.05초였으니 1,560개면 20초쯤 걸릴 것이다. 그건 예상대로다.

문제는 저장이다.

Share
  • 파이썬
  • 임베딩
  • RAG
  • LangChain