264편에서 답을 찾는다 — 진짜 변수는 따로 있었다
Article
앞 편에서 문장 네 개로 원리를 봤다. 이제 9,005조각에 통째로 걸어본다.
- ①② 웹에서 글을 가져온다끝남
- ③④ 토막으로 자른다끝남
- ⑤ 문장을 숫자로 바꾼다끝남
- ⑥ 264편을 검색한다지금 여기 — 마지막
1. 9,005조각을 전부 벡터로 만든다
조각을 미리 벡터로 만들어 둔 뭉치를 색인(index) 이라고 부른다. 질문이 들어올 때마다 90초를 기다릴 수는 없으니 한 번만 만들어 파일로 저장한다.
import time
import pathlib
import numpy as np
from sentence_transformers import SentenceTransformer
from langchain_text_splitters import RecursiveCharacterTextSplitter
# --- 1) 글을 읽어 조각으로 자른다 (앞 편에서 한 것) ---
texts = {
p.stem: p.read_text(encoding="utf-8")
for p in sorted(pathlib.Path("posts").glob("*.md"))
}
splitter = RecursiveCharacterTextSplitter(
chunk_size=200, chunk_overlap=20,
separators=["\n\n", "\n", ". ", " ", ""],
keep_separator="end",
)
chunks = [
{"slug": slug, "text": c}
for slug, t in texts.items()
for c in splitter.split_text(t) if c.strip()
]
# --- 2) 조각을 전부 벡터로 바꾼다 ---
model = SentenceTransformer("intfloat/multilingual-e5-small")
t = time.perf_counter()
vecs = model.encode(
[c["text"] for c in chunks],
batch_size=32,
normalize_embeddings=True, # 길이를 1로 맞춘다 (아래 설명)
show_progress_bar=True,
)
sec = time.perf_counter() - t
print(f"조각 {len(chunks)}개 {sec:.1f}초 ({len(chunks) / sec:.0f}조각/초)")
print("벡터:", vecs.shape, f"{vecs.nbytes / 1024 / 1024:.1f} MB")
# --- 3) 파일로 저장한다 ---
# 벡터만 저장하면 몇 위가 어느 글인지 알 수 없다. 셋을 같이 저장한다.
np.savez(
"index.npz",
vecs=vecs.astype("float32"),
slugs=np.array([c["slug"] for c in chunks]),
texts=np.array([c["text"] for c in chunks], dtype=object),
)
- 조각 9005개 90.5초 (100조각/초)
- 벡터: (9005, 384) 13.2 MB
132만 자가 13.2MB 짜리 숫자 덩어리가 됐다. (9005, 384) 는 9,005줄 × 384칸이다.
normalize_embeddings=True 는 벡터 길이를 전부 1로 맞춘다. 그러면 코사인 유사도가 그냥 곱셈이 되어서, 아래 검색 코드가 vecs @ qv 한 줄로 끝난다.
2. 내 블로그가 검색된다
import numpy as np
from sentence_transformers import SentenceTransformer
# allow_pickle=True — 안에 글자가 섞여 있어서 필요하다
data = np.load("index.npz", allow_pickle=True)
vecs, slugs, texts = data["vecs"], data["slugs"], data["texts"]
model = SentenceTransformer("intfloat/multilingual-e5-small")
print(f"조각 {len(vecs):,}개 준비됨\n")
def search(question, k=5):
"""질문과 가까운 글 k 개를 찾는다."""
qv = model.encode([question], normalize_embeddings=True)[0]
# 조각 벡터 전부와 한 번에 곱한다 -> 조각 수만큼 점수가 나온다
scores = vecs @ qv
best = {}
# argsort() 는 정렬 순서(번호)를 준다. [::-1] 로 뒤집어 큰 것부터
for i in scores.argsort()[::-1]:
slug = str(slugs[i])
# 글마다 "제일 잘 맞은 조각 하나"만 남긴다
if slug not in best:
best[slug] = (float(scores[i]), str(texts[i]))
if len(best) >= k:
break
return best
for slug, (score, text) in search("청킹할 때 겹침을 왜 주나").items():
print(f"{score:.3f} {slug}")
- 0.871 ai-eng-04-overlap
- 0.870 ai-lab-17-embeddings
- 0.870 git-14-first-push
- 0.868 ai-lab-24-production-deploy
- 0.866 git-16-windows-credentials
질문에 「오버랩」도 「chunk」도 안 넣었는데 겹침 강의가 1위로 왔다. 하나 더.
- 0.890 git-21-branch
- 0.884 git-20-github-pages
- 0.879 ai-eng-03-files-and-env
- 0.879 git-22-merge
- 0.875 git-19-github-profile
만드는 데 90초가 걸렸지만 질문 하나를 처리하는 데는 10ms 다.
3. 그런데 하나는 2위였다
- 0.895 git-25-team-practice
- 0.891 git-23-conflict <- 이게 충돌 강의다
- 0.889 spring-16-jpa-repository
- 0.885 git-17-clone-and-pull
- 0.884 git-07-log-and-diff
1위 조각의 본문은 「겹치는 줄을 고쳤다면 충돌이 나고, 그건 이미 풀어봤습니다」처럼 충돌 강의를 가리키기만 하는 대목이었다. 충돌을 설명하는 글이 아니라 언급하는 글인데, 200자 조각만 봐서는 기계가 구분할 수 없다.
4. 느낌 말고 숫자로 잰다
답을 아는 질문 여섯 개를 만들어 정답 글이 몇 위에 오는지 센다.
import numpy as np
from sentence_transformers import SentenceTransformer
data = np.load("index.npz", allow_pickle=True)
vecs, slugs = data["vecs"], data["slugs"]
model = SentenceTransformer("intfloat/multilingual-e5-small")
# (질문, 이 질문의 답이 있는 글) 짝
QUESTIONS = [
("청킹할 때 겹침을 왜 주나", "ai-eng-04-overlap"),
("브랜치를 왜 따로 만드나", "git-21-branch"),
("같은 줄을 두 사람이 고치면", "git-23-conflict"),
("조각이 잘 잘렸는지 어떻게 재나", "ai-eng-06-measuring-chunks"),
("코드 리뷰를 요청하는 절차", "git-24-pull-request"),
("엑셀 파일을 파이썬으로 다루기", "ai-eng-07-excel-report"),
]
def rank_of(question, answer_slug):
"""정답 글이 몇 위에 오나. 끝까지 못 찾으면 999."""
qv = model.encode([question], normalize_embeddings=True)[0]
scores = vecs @ qv
seen = []
for i in scores.argsort()[::-1]:
slug = str(slugs[i])
if slug in seen: # 같은 글의 다른 조각이면 건너뛴다
continue
seen.append(slug)
if slug == answer_slug:
return len(seen)
return 999
ranks = [rank_of(q, a) for q, a in QUESTIONS]
print("1위", sum(1 for r in ranks if r == 1), "/", len(ranks))
print("평균", sum(ranks) / len(ranks))
print(ranks)
- 1위 4 / 6
- 평균 1.33
- [1, 1, 2, 1, 2, 1]
264편 중에서 고른 결과다. 2위로 밀린 둘은 앞에서 본 것처럼 정답 글을 가리키는 다른 글에 밀린 경우다.
5. 진짜 중요했던 것 — 조각 크기
모델도 질문도 재는 방법도 그대로 두고 chunk_size 만 바꿔 다시 쟀다.
| 조각 크기 | 조각 수 | 1위 | 평균 순위 |
|---|---|---|---|
| 1000 | 1,609 | 4/6 | 17.83 |
| 500 | 3,255 | 3/6 | 3.50 |
| 200 | 9,005 | 4/6 | 1.33 |
1위 개수는 4/6 으로 같다. 평균을 같이 보지 않으면 이 차이를 놓친다.
이유는 둘이다. 조각이 크면 주제가 여러 개 섞여 벡터 방향이 뭉개진다. 그리고 이 모델의 최대 토큰이 512 인데, 한국어는 대략 1000자에서 거기 닿는다.
- 200자 조각 9005개 토큰 평균 71 최대 135 512초과 0개
- 500자 조각 3255개 토큰 평균 201 최대 333 512초과 0개
- 1000자 조각 1609개 토큰 평균 414 최대 589 512초과 131개
1000자로 자르면 조각 131개의 뒤가 잘려 나간다. 경고도 에러도 없이 앞부분만 벡터가 된다. 영어는 토큰당 담기는 글자가 2.6배라 같은 1000자여도 문제가 안 생긴다.
작을수록 좋은 것도 아니다. 100자로 줄이면 조각이 19,686개가 되고 앞뒤 맥락이 사라진다. 어느 크기가 맞는지는 문서마다 다르고, 재보기 전에는 모른다.
6. 모델은 어디 있나
from pathlib import Path
from huggingface_hub import constants # sentence-transformers 를 깔 때 같이 깔린다
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 940.9 MB
받은 파일은 471MB 였는데 폴더는 940MB 다. 저장소에 같은 모델의 여러 형식이 함께 들어 있어서 기본으로 다 받기 때문이다.
한 번 받았으면 인터넷 없이도 돈다.
import os
# 반드시 아래 import 보다 먼저 와야 한다
os.environ["HF_HUB_OFFLINE"] = "1"
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("intfloat/multilingual-e5-small")
print("인터넷 없이 불러오기 성공", model.encode("잘 되나").shape)
이 상태에서 아직 안 받은 모델 이름을 넣으면 곧바로 에러가 난다. 모르는 사이에 몇 GB 를 받아버리는 일을 막아준다.
이번 편에 나온 것
| 쓴 것 | 하는 일 |
|---|---|
normalize_embeddings=True | 길이를 1로. 내적이 곧 코사인 유사도 |
vecs @ qv | 9,005개와 한 번에 비교. @ 는 행렬 곱셈 |
.argsort()[::-1] | 점수 큰 것부터 순서를 얻는다 |
batch_size=32 | 한 번에 묶어서 처리할 개수 |
time.perf_counter() | 시간을 잰다. 두 번 불러서 빼면 걸린 시간 |
np.savez(...) | 만든 벡터를 파일로 남긴다 |
np.load(..., allow_pickle=True) | 글자가 섞여 있으면 이 옵션이 필요하다 |
HF_HUB_OFFLINE=1 | 인터넷을 아예 안 쓰게 못박는다 |
.rglob("*") | 하위 폴더까지 전부 훑는다 |
여섯 편을 통틀어
- 사이트맵 → 264편①②
- 132만 자 → 9,005조각③④
- 조각 → 벡터 13.2MB⑤⑥
- 질문 → 관련 글 (10ms)완성
조각 크기를 1000에서 200으로 바꿨더니 평균 순위가 17.83에서 1.33이 됐다. 모델도 코드도 그대로였다. 좋은 모델을 고르는 것보다 잘 자르는 게 먼저다.