글자 대신 뜻을 잰다 — 임베딩으로 검색기를 갈아끼운다
Article
11강 마지막에서 갈 곳을 정해뒀다. 글자를 세는 방식을 버린다.
우리 점수는 「질문 글자가 조각 안에 있나」를 셌다. 그래서 조각이 길수록 점수가 올랐고, 단어로 바꿔 세니 이번엔 「짜는」 같은 활용형을 놓쳤다. 둘 다 같은 한계였다 — 글자가 같은지만 볼 뿐 뜻을 모른다.
이번 강의에서 문장을 숫자로 바꾼다. 문장 하나가 숫자 384개가 되고, 그 숫자들이 가리키는 방향으로 비슷함을 잰다.
- 모델을 내려받는다한 번만. 465MB
- 문장을 숫자 384개로encode()
- 방향이 얼마나 같나 잰다cos_sim()
- 11강의 질문 여섯 개로 다시 잰다4/6 → 6/6
파일은 계속 ai-course 폴더에 넣는다. 이번 강의는 121번부터다.
1. 설치하고 모델을 받는다
엑셀 때 openpyxl 을 깐 것과 같다. 다만 이번 것은 무겁다.
python -m pip install sentence-transformers
깔았으면 모델을 받는다. 다운로드 버튼을 따로 누르는 게 아니라, 처음 불러올 때 없으면 받는다.
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개가 된다
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. 글자가 안 겹쳐도 찾는다
이제 본론이다. 방향이 얼마나 같은지를 재본다.
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 는 문서를 읽고 조각으로 만드는 일까지만 했다. 점수 매기는 일을 여기로 옮긴다. 파일 맨 아래에 아래를 덧붙인다. 위쪽은 손대지 않는다.
# ... 여기까지는 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
- 01
_model = None과global모델을 매번 부르면 매번 몇 초가 걸린다. 한 번 불러 변수에 담아두고 다음부터는 그걸 준다.
global은 「함수 안에서 만든 새 변수가 아니라 밖의 그 변수를 바꾸겠다」는 뜻이다. 이게 없으면 함수 안에서만 담기고 밖에는 남지 않는다. - 02
이름 앞의
_문법이 아니라 약속이다. 11강의 대문자
QUESTIONS와 같은 종류다._model은 「이 파일 안에서만 쓰는 것」이라는 표시라, 다른 파일에서search._model을 직접 만지면 안 된다. - 03
key=lambda i: -float(scores[i])인덱스를 정렬하되 기준은 그 자리의 점수다. 마이너스를 붙여 큰 것부터 세운다. 11강 도전 문제에서 쓴 요령 그대로다.
6. 같은 질문 여섯 개로 다시 잰다
11강에서 쓴 질문표를 그대로 쓴다. 재는 방법을 안 바꾸고 점수만 갈아끼웠으니, 결과 차이는 온전히 점수 탓이다.
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위 | 「짜는」 과 「짠다」 를 같은 뜻으로 본다 |
11강에서 「한 편향을 지우면 다른 편향이 나온다」고 했다. 글자 세기는 길이에 속고, 단어 세기는 활용형에 약했다. 임베딩은 둘 다 안 겪는다. 애초에 글자를 비교하지 않기 때문이다.
7. 모델이 어디 있고, 인터넷 없이 도나
받은 파일이 어디 앉았는지 본다. 용량을 비우거나 다른 컴퓨터로 옮길 때 알아야 한다.
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 |
한 번 받았으면 인터넷이 없어도 돈다. 와이파이를 끄고 124_다시재기.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 | 인터넷을 아예 안 쓰게 못박는다 |
미션
- 01
[필수] 임베딩이 지는 질문을 찾는다
ai-course/127_미션필수.py. 질문 여섯 개로는 6/6 이라 더 잴 수가 없다. 임베딩이 1위를 못 맞히는 질문을 찾아 QUESTIONS 에 더해본다. 못 찾으면 그것도 결과다 — 몇 개 시도했는지 적는다. - 02
[응용] 조각 크기를 바꿔가며 잰다
ai-course/128_미션응용.py.build_chunks(size=...)를 30 · 60 · 120 · 250 으로 바꿔 각각 재본다. 11강에서는 못 하던 비교다 — 그때는 점수가 길이에 속아서 크기를 바꾸면 결과를 믿을 수 없었다. - 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_chunks 의 size 만 바꿔 같은 질문을 다시 재면 된다. 재는 코드는 손대지 않는다.
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강에서 못 잡던 「짜는」과 「짠다」를 임베딩은 같은 뜻으로 본다. 글자를 비교하지 않으니 활용형에도 길이에도 안 속는다.
대신 새 제약이 생겼다. 점수는 절대 기준으로 못 쓰고 순위로만 쓴다. 모델을 바꾸면 만들어 둔 벡터가 전부 쓸모없어진다. 그리고 조각이 늘면 매번 인코딩하는 게 감당이 안 된다.
마지막 문제가 다음으로 이어진다. 한 번 만든 벡터를 어떻게 남겨둘 것인가. 그 전에 자르기부터 한 번 더 손본다 — 지금까지 손으로 짠 자르기를 검증된 라이브러리로 갈아끼운다.