학습 자료

글자 대신 뜻을 잰다 — 임베딩으로 검색기를 갈아끼운다


Article

11강 마지막에서 갈 곳을 정해뒀다. 글자를 세는 방식을 버린다.

우리 점수는 「질문 글자가 조각 안에 있나」를 셌다. 그래서 조각이 길수록 점수가 올랐고, 단어로 바꿔 세니 이번엔 「짜는」 같은 활용형을 놓쳤다. 둘 다 같은 한계였다 — 글자가 같은지만 볼 뿐 뜻을 모른다.

이번 강의에서 문장을 숫자로 바꾼다. 문장 하나가 숫자 384개가 되고, 그 숫자들이 가리키는 방향으로 비슷함을 잰다.

이번 강의의 흐름
  1. 모델을 내려받는다한 번만. 465MB
  2. 문장을 숫자 384개로encode()
  3. 방향이 얼마나 같나 잰다cos_sim()
  4. 11강의 질문 여섯 개로 다시 잰다4/6 → 6/6

파일은 계속 ai-course 폴더에 넣는다. 이번 강의는 121번부터다.

1. 설치하고 모델을 받는다

엑셀 때 openpyxl 을 깐 것과 같다. 다만 이번 것은 무겁다.

python -m pip install sentence-transformers

깔았으면 모델을 받는다. 다운로드 버튼을 따로 누르는 게 아니라, 처음 불러올 때 없으면 받는다.

ai-course/121_모델받기.py
from sentence_transformers import SentenceTransformer

# "만든사람/모델이름" 형식이다. 깃허브 저장소 주소와 같은 규칙이다.
# 처음 실행할 때만 인터넷에서 받고, 두 번째부터는 디스크에서 바로 읽는다.
model = SentenceTransformer("intfloat/multilingual-e5-small")

print("차원:", len(model.encode("아무 문장이나")))
# ↑ 문장 하나를 넣어보고 숫자가 몇 개 나오는지 센다
print("한 번에 읽는 최대 길이:", model.max_seq_length)
터미널
  • model.safetensors: 100%|██████████| 471M/471M [00:41<00:00, 11.4MB/s]
  • tokenizer.json: 100%|██████████| 17.1M/17.1M [00:01<00:00, 9.7MB/s]
  • 차원: 384
  • 한 번에 읽는 최대 길이: 512

384. 11강 마지막에 예고한 그 숫자다. 이 모델은 문장 하나를 숫자 384개로 표현한다.

512 는 한 번에 소화할 수 있는 길이다. 이 길이를 넘는 문장은 뒤가 조용히 잘린다. 우리 조각은 60자라 한참 남는다 — 12강에서 조각을 60자로 잡아둔 것이 여기서 값을 한다.

두 번째 실행부터는 진행 막대 없이 두 줄만 나온다. 이미 디스크에 있기 때문이다.

2. 문장이 숫자 384개가 된다

ai-course/122_첫벡터.py
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("intfloat/multilingual-e5-small")

v = model.encode("환불은 어떻게 하나요")
# ↑ 문장을 넣으면 숫자 목록이 나온다. 이 목록을 벡터라고 부른다

print(v.shape)          # (384,) - 숫자 384개짜리 목록 하나
print(v[:6])            # 앞 6개만. 384개를 다 찍으면 화면이 넘친다

texts = ["환불은 어떻게 하나요", "결제를 취소하고 싶어요", "오늘 점심 뭐 먹지"]
vs = model.encode(texts)
# ↑ 목록으로 넣으면 한 번에 처리한다. 하나씩 넣는 것보다 훨씬 빠르다

print(vs.shape)         # (3, 384) - 3줄 384칸
터미널
  • (384,)
  • [ 0.08457814 -0.04076726 -0.08963352 -0.05305965 0.0329268 0.00353629]
  • (3, 384)

.shape 는 「몇 줄 몇 칸인가」다. 문장 하나면 (384,), 세 개를 한 번에 넣으면 (3, 384) 다.

숫자 하나하나에는 뜻이 없다. 첫 칸이 0.0846 이라는 것에서 알아낼 수 있는 건 없다. 384개가 모여서 만드는 방향이 뜻이다.

문장 세 개가 벡터로 바뀐 뒤

문장마다 384칸짜리 숫자 목록 하나를 갖는다

환불은 어떻게 하나요
[0.085, -0.041, -0.090, ...]
384칸
결제를 취소하고 싶어요
[0.077, -0.038, -0.085, ...]
384칸 · 위와 방향이 비슷하다
오늘 점심 뭐 먹지
[-0.021, 0.064, 0.012, ...]
384칸 · 방향이 다르다

3. 글자가 안 겹쳐도 찾는다

이제 본론이다. 방향이 얼마나 같은지를 재본다.

ai-course/123_가까움재기.py
from sentence_transformers import SentenceTransformer, util

model = SentenceTransformer("intfloat/multilingual-e5-small")

question = "돈 돌려받고 싶다"
docs = [
    "주문 후 7일 이내에 환불 신청이 가능하다.",
    "배송은 결제일 기준 2~3일 걸린다.",
    "회원 등급은 매월 1일에 갱신된다.",
    "포인트는 적립일로부터 1년간 유효하다.",
]

scores = util.cos_sim(model.encode(question), model.encode(docs))[0]
# ↑ 두 벡터가 이루는 각도를 -1 ~ 1 사이 숫자로 바꾼다. 같은 방향이면 1
# [0] 은 결과가 표 모양으로 나와서 첫 줄만 꺼내는 것이다. 3강의 docs[0] 과 같다

print("질문:", question)

# sorted 의 key 로 점수를 주고 큰 것부터 - 3강에서 한 그대로다
for i in sorted(range(len(docs)), key=lambda i: -float(scores[i])):
    # 질문과 문서에 같은 글자가 몇 개나 들어 있는지도 같이 센다
    same = set(question.replace(" ", "")) & set(docs[i].replace(" ", ""))
    print(f"{float(scores[i]):.3f}  겹치는 글자 {len(same)}{docs[i]}")
터미널
  • 질문: 돈 돌려받고 싶다
  • 0.844 겹치는 글자 1개 주문 후 7일 이내에 환불 신청이 가능하다.
  • 0.831 겹치는 글자 1개 포인트는 적립일로부터 1년간 유효하다.
  • 0.827 겹치는 글자 1개 배송은 결제일 기준 2~3일 걸린다.
  • 0.799 겹치는 글자 1개 회원 등급은 매월 1일에 갱신된다.

여기가 핵심이다. 질문에 「환불」이라는 글자가 한 자도 없다. 겹치는 글자는 네 문서 모두 1개로 똑같다.

우리가 11강까지 쓰던 점수로는 넷을 구분할 방법이 없다. 그런데 환불 문서가 1위로 올라왔다. 글자를 맞춘 게 아니라 뜻을 맞춘 것이다.

4. 그런데 점수를 믿으면 안 된다

1위가 0.844, 꼴찌가 0.799 다. 차이가 0.045 밖에 안 난다.

실제로 두 문장만 떼어놓고 재면 이상한 일이 생긴다.

두 문장씩만 재봤을 때
  • 0.888 돈 돌려받고 싶다 / 환불 신청이 가능하다
  • 0.892 돈 돌려받고 싶다 / 배송비가 무료다
  • 0.863 돈 돌려받고 싶다 / 오늘 점심 뭐 먹지

무관한 쪽이 더 높게 나왔다. 그런데 앞의 실습에서는 순위가 맞았다. 후보를 여럿 놓고 같이 비교하면 맞고, 둘만 떼어놓고 절대값을 보면 틀린다.

기억할 것은 하나다 — 임베딩은 줄 세우는 데 쓴다. 합격선을 긋는 데 쓰지 않는다.

5. search.py 에 반영한다

11강까지 search.py 는 문서를 읽고 조각으로 만드는 일까지만 했다. 점수 매기는 일을 여기로 옮긴다. 파일 맨 아래에 아래를 덧붙인다. 위쪽은 손대지 않는다.

ai-course/search.py
# ... 여기까지는 11강 그대로 (load_documents · chunk_text · chunk_smart · build_chunks · build_chunks_plain)

from sentence_transformers import SentenceTransformer, util

_model = None       # 앞에 _ 를 붙인 것은 「바깥에서 직접 쓰지 말라」는 관례다


def get_model():
    """모델을 한 번만 불러 두고 계속 돌려쓴다."""
    global _model                    # 함수 밖 변수를 바꾸겠다는 선언
    if _model is None:               # 처음 부를 때만 실제로 불러온다
        _model = SentenceTransformer("intfloat/multilingual-e5-small")
    return _model


def embed_rank(chunks, query, answer_file):
    """정답 문서에서 나온 조각이 몇 위에 오는지 - 임베딩 판.

    11강의 rank_of_answer 와 하는 일이 같다. 점수 내는 방법만 다르다.
    """
    model = get_model()
    texts = [c["text"] for c in chunks]

    scores = util.cos_sim(model.encode(query), model.encode(texts))[0]

    ranked = sorted(range(len(texts)), key=lambda i: -float(scores[i]))
    for rank, i in enumerate(ranked, start=1):
        if chunks[i]["source"] == answer_file:
            return rank
    return len(ranked) + 1
새로 나온 것 셋
  1. 01

    _model = Noneglobal

    모델을 매번 부르면 매번 몇 초가 걸린다. 한 번 불러 변수에 담아두고 다음부터는 그걸 준다. global 은 「함수 안에서 만든 새 변수가 아니라 밖의 그 변수를 바꾸겠다」는 뜻이다. 이게 없으면 함수 안에서만 담기고 밖에는 남지 않는다.

  2. 02

    이름 앞의 _

    문법이 아니라 약속이다. 11강의 대문자 QUESTIONS 와 같은 종류다. _model 은 「이 파일 안에서만 쓰는 것」이라는 표시라, 다른 파일에서 search._model 을 직접 만지면 안 된다.

  3. 03

    key=lambda i: -float(scores[i])

    인덱스를 정렬하되 기준은 그 자리의 점수다. 마이너스를 붙여 큰 것부터 세운다. 11강 도전 문제에서 쓴 요령 그대로다.

6. 같은 질문 여섯 개로 다시 잰다

11강에서 쓴 질문표를 그대로 쓴다. 재는 방법을 안 바꾸고 점수만 갈아끼웠으니, 결과 차이는 온전히 점수 탓이다.

ai-course/124_다시재기.py
import search

QUESTIONS = [
    ("인덱스가 뭔지 물어본다",        "backend.txt"),
    ("화면이 느리면 어떻게 되나",      "frontend.txt"),
    ("데이터가 틀리면 어떻게 되나",    "data.txt"),
    ("타입스크립트를 쓰나",           "frontend.txt"),
    ("파이프라인을 짜는 일",          "data.txt"),
    ("서버와 데이터베이스를 다루는 일", "backend.txt"),
]


def score_char(query, text):
    """11강의 글자 세기."""
    return sum(1 for ch in query if ch in text) / len(query)


def score_word(query, text):
    """11강 응용 문제의 단어 세기."""
    words = query.split()
    return sum(1 for w in words if w in text) / len(words)


def rank_by(chunks, query, answer_file, score_fn):
    """11강의 rank_of_answer 그대로."""
    ranked = sorted(
        [{"s": score_fn(query, c["text"]), "src": c["source"]} for c in chunks],
        key=lambda r: r["s"],
        reverse=True,
    )
    for i, r in enumerate(ranked, start=1):
        if r["src"] == answer_file:
            return i
    return len(ranked) + 1


chunks = search.build_chunks(size=60, min_size=20)

cases = [
    ("글자 세기",   lambda q, a: rank_by(chunks, q, a, score_char)),
    ("단어 세기",   lambda q, a: rank_by(chunks, q, a, score_word)),
    ("임베딩",     lambda q, a: search.embed_rank(chunks, q, a)),
]

for name, rank_fn in cases:
    ranks = [rank_fn(q, a) for q, a in QUESTIONS]
    top1 = sum(1 for r in ranks if r == 1)
    print(f"{name:8s} 1위 {top1}/{len(QUESTIONS)}  평균 {sum(ranks) / len(ranks):.2f}  {ranks}")
터미널
  • 문서 3건을 읽었다 (폴더: docs)
  • 글자 세기 1위 4/6 평균 1.33 [1, 2, 2, 1, 1, 1]
  • 단어 세기 1위 5/6 평균 2.00 [1, 1, 1, 1, 7, 1]
  • 임베딩 1위 6/6 평균 1.00 [1, 1, 1, 1, 1, 1]

6/6 이다. 평균 순위도 1.00 — 여섯 질문 모두 정답이 1위로 왔다.

특히 다섯 번째를 보자. [1, 1, 1, 1, 7, 1] 에서 그 7 이 11강 응용 문제에서 못 풀고 넘어간 것이다.

「파이프라인을 짜는 일」 하나만 따라가면
점수 방식순위
글자 세기1위우연히 맞았다. 긴 조각이 유리한 덕을 봤다
단어 세기7위「짜는」 이 문서엔 「짠다」 로 있다. 글자가 달라 못 잡는다
임베딩1위「짜는」 과 「짠다」 를 같은 뜻으로 본다
0.865 로 1위, 2위는 0.843 이었다. 점수 차이는 작지만 순위는 맞다.

11강에서 「한 편향을 지우면 다른 편향이 나온다」고 했다. 글자 세기는 길이에 속고, 단어 세기는 활용형에 약했다. 임베딩은 둘 다 안 겪는다. 애초에 글자를 비교하지 않기 때문이다.

7. 모델이 어디 있고, 인터넷 없이 도나

받은 파일이 어디 앉았는지 본다. 용량을 비우거나 다른 컴퓨터로 옮길 때 알아야 한다.

ai-course/125_저장위치.py
from pathlib import Path
from huggingface_hub import constants

cache = Path(constants.HF_HUB_CACHE)
print("캐시 폴더:", cache)

# 모델 하나당 폴더 하나다. 이름의 / 가 -- 로 바뀐다
for folder in sorted(cache.glob("models--*")):
    size = sum(f.stat().st_size for f in folder.rglob("*") if f.is_file())
    print(f"{folder.name:45s} {size / 1024 / 1024:8.1f} MB")
터미널
  • 캐시 폴더: C:\Users\사용자\.cache\huggingface\hub
  • models--intfloat--multilingual-e5-small 465.6 MB

받은 모델이 여럿이면 여러 줄이 나온다. 다른 실습에서 받아둔 것이 있으면 여기에 다 보인다 — 용량이 모자랄 때 지울 후보를 여기서 고른다.

운영체제별 기본 위치
운영체제경로
윈도우C:\Users\사용자\.cache\huggingface\hub
/Users/사용자/.cache/huggingface/hub
리눅스/home/사용자/.cache/huggingface/hub
C 드라이브가 빠듯하면 HF_HOME 환경변수에 다른 경로를 넣어둔다. 5강에서 배운 환경변수를 여기서 또 쓴다.

한 번 받았으면 인터넷이 없어도 돈다. 와이파이를 끄고 124_다시재기.py 를 다시 돌려보면 그대로 동작한다.

ai-course/126_오프라인.py
import os

os.environ["HF_HUB_OFFLINE"] = "1"
# ↑ 반드시 아래 import 보다 먼저 와야 한다. 라이브러리가 켜질 때 이 값을 읽는다

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("intfloat/multilingual-e5-small")
print("인터넷 없이 불러오기 성공", model.encode("잘 되나").shape)
터미널
  • 인터넷 없이 불러오기 성공 (384,)

이 상태에서 아직 안 받은 모델 이름을 넣으면 곧바로 에러가 난다. 그게 정상이다. 모르는 사이에 모델을 새로 받아버리는 일을 막아준다.

이번 강의에 나온 것

정리
쓴 것하는 일
pip install sentence-transformers문장 → 벡터 도구를 깐다 (1~2GB)
SentenceTransformer("이름")모델을 부른다. 없으면 받는다
model.encode("문장")숫자 384개짜리 벡터 하나
model.encode([...])목록을 통째로. 반복문보다 빠르다
.shape몇 줄 몇 칸인가
util.cos_sim(a, b)두 벡터의 방향이 얼마나 같나
model.max_seq_length이 길이를 넘으면 조용히 잘린다
global _model함수 밖 변수를 바꾸겠다는 선언
HF_HUB_OFFLINE=1인터넷을 아예 안 쓰게 못박는다

미션

미션
  1. 01

    [필수] 임베딩이 지는 질문을 찾는다

    ai-course/127_미션필수.py. 질문 여섯 개로는 6/6 이라 더 잴 수가 없다. 임베딩이 1위를 못 맞히는 질문을 찾아 QUESTIONS 에 더해본다. 못 찾으면 그것도 결과다 — 몇 개 시도했는지 적는다.

  2. 02

    [응용] 조각 크기를 바꿔가며 잰다

    ai-course/128_미션응용.py. build_chunks(size=...) 를 30 · 60 · 120 · 250 으로 바꿔 각각 재본다. 11강에서는 못 하던 비교다 — 그때는 점수가 길이에 속아서 크기를 바꾸면 결과를 믿을 수 없었다.

  3. 03

    [도전] 매번 인코딩하지 않게 만든다

    ai-course/129_미션도전.py. embed_rank 는 질문 하나마다 조각 13개를 전부 다시 인코딩한다. 여섯 질문이면 78번이다. 조각 벡터는 한 번만 만들어 두고 돌려쓰도록 고쳐본다. 시간이 얼마나 줄어드나.

[필수] 임베딩이 지는 질문 찾기충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

고유명사와 숫자를 노리면 잘 걸린다. 임베딩은 뜻이 비슷한 것을 묶는데, 「자바」와 「파이썬」은 뜻이 비슷해서 오히려 구분을 못 한다.

질문을 아주 짧게 만드는 것도 방법이다. 글자가 적으면 방향이 흐려진다.

EXTRA = [
    ("자바", "backend.txt"),          # 언어 이름끼리 뜻이 비슷하다
    ("SQL", "data.txt"),             # 짧고 영문이다
    ("뷰", "frontend.txt"),           # 한 글자
]

chunks = search.build_chunks(size=60, min_size=20)
for q, a in EXTRA:
    print(search.embed_rank(chunks, q, a), q)

「뷰」 처럼 한 글자짜리 질문이 잘 밀린다. 짧은 질문은 임베딩의 약점이다 — 방향을 정할 재료가 없다.

반대로 「자바」는 의외로 1위가 나오기도 한다. 이 문서가 세 개뿐이라 경쟁이 없어서다. 문서가 늘면 이런 질문부터 무너진다.

못 찾았어도 좋다. 「여덟 개 만들어봤는데 다 1위였다」가 정직한 결과고, 그건 이 질문표로는 더 못 잰다는 뜻이다.

[응용] 조각 크기를 바꿔가며충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

build_chunkssize 만 바꿔 같은 질문을 다시 재면 된다. 재는 코드는 손대지 않는다.

for size in [30, 60, 120, 250]:
    chunks = search.build_chunks(size=size, min_size=size // 3)
    ranks = [search.embed_rank(chunks, q, a) for q, a in QUESTIONS]
    top1 = sum(1 for r in ranks if r == 1)
    print(f"size {size:3d}  조각 {len(chunks):2d}개  1위 {top1}/6  평균 {sum(ranks) / 6:.2f}")

작게 자를수록 조각이 늘고, 크게 자를수록 한 조각에 주제가 여러 개 섞인다. 250 쯤 가면 문서 하나가 조각 하나가 되어 버려 어느 문서인지만 맞히는 문제가 된다.

숫자가 안 움직이면 그것도 결과다. 문서가 세 개뿐이라 웬만해선 안 틀린다 — 재는 대상이 너무 쉬우면 무엇을 바꿔도 티가 안 난다.

[도전] 벡터를 미리 만들어 두기충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

조각은 안 변하는데 질문마다 다시 인코딩하는 게 낭비다. 조각 벡터를 한 번 만들어 두고, 질문 벡터만 그때그때 만든다.

이게 다음 강의들에서 「색인」이라고 부를 것의 씨앗이다. 미리 만들어 둔 벡터 뭉치가 곧 색인이다.

import time

model = search.get_model()
chunks = search.build_chunks(size=60, min_size=20)

# 조각 벡터는 딱 한 번만 만든다
t = time.perf_counter()
chunk_vectors = model.encode([c["text"] for c in chunks])
print(f"조각 {len(chunks)}개 인코딩 {(time.perf_counter() - t) * 1000:.0f}ms")


def embed_rank_fast(query, answer_file):
    scores = util.cos_sim(model.encode(query), chunk_vectors)[0]   # 질문만 새로
    ranked = sorted(range(len(chunks)), key=lambda i: -float(scores[i]))
    for rank, i in enumerate(ranked, start=1):
        if chunks[i]["source"] == answer_file:
            return rank
    return len(ranked) + 1


t = time.perf_counter()
ranks = [embed_rank_fast(q, a) for q, a in QUESTIONS]
print(f"질문 6개 {(time.perf_counter() - t) * 1000:.0f}ms  {ranks}")

조각 13개를 인코딩하는 데 88ms 쯤 걸린다. 원래는 질문마다 이걸 다시 했으니 여섯 번이면 500ms 가 넘는다. 미리 만들어 두면 질문당 몇 ms 로 끝난다.

조각이 13개라 체감이 작다. 5만 개면 매번 다시 만드는 건 아예 불가능하다. 그래서 만들어 둔 벡터를 파일로 저장하는 일이 따라온다 — 다음 강의들의 주제다.

순위 결과는 똑같이 [1, 1, 1, 1, 1, 1] 이어야 한다. 빨라지기만 하고 결과가 바뀌면 안 된다. 다르게 나왔다면 조각 순서가 어긋난 것이다.

정리하면

글자 세기를 버리고 문장을 숫자 384개로 바꿨다. 같은 질문 여섯 개, 같은 조각, 같은 재는 방법으로 4/6 이 6/6 이 됐다. 바뀐 것은 점수 하나뿐이다.

11강에서 못 잡던 「짜는」과 「짠다」를 임베딩은 같은 뜻으로 본다. 글자를 비교하지 않으니 활용형에도 길이에도 안 속는다.

대신 새 제약이 생겼다. 점수는 절대 기준으로 못 쓰고 순위로만 쓴다. 모델을 바꾸면 만들어 둔 벡터가 전부 쓸모없어진다. 그리고 조각이 늘면 매번 인코딩하는 게 감당이 안 된다.

마지막 문제가 다음으로 이어진다. 한 번 만든 벡터를 어떻게 남겨둘 것인가. 그 전에 자르기부터 한 번 더 손본다 — 지금까지 손으로 짠 자르기를 검증된 라이브러리로 갈아끼운다.