TF-IDF·BM25 검색 엔진 직접 만들기: 블로그 글 86개로 실측
Python 표준 라이브러리만으로 역색인, TF-IDF, BM25를 구현하고 형태소 분석기 없이 문자 바이그램으로 한국어를 검색했다. 블로그 글 86개로 MRR, k1·b 효과, 속도를 재고 임베딩과 나눠 쓰는 기준도 정리했다.
RAG나 벡터 검색을 붙이기 전에, 그 바닥에 있는 키워드 검색이 실제로 얼마나 맞히는지 자기 데이터로 확인해 보고 싶은 개발자를 위한 글입니다. 이 블로그의 글(언어별 86개)을 대상으로 Python 3.9 표준 라이브러리만 써서 역색인, TF-IDF, BM25를 구현했고, 형태소 분석기 없이 한국어와 일본어도 검색되게 했습니다. 결과부터 말하면 BM25는 라벨을 붙인 한국어 질문 20개 모두에서 정답 글을 상위 3위 안에 넣었고 검색 한 번은 중앙값 0.057밀리초였지만, 글과 다른 단어로 물으면 MRR이 0.967에서 0.594로 떨어졌습니다.
이 글은 연재 「밑바닥부터 만드는 머신러닝(Python 표준 라이브러리만)」의 다섯 번째 강의이자 RAG로 넘어가는 다리입니다. 앞에서 경사 하강법 선형 회귀, k-평균 군집화, 나이브 베이즈 문서 분류, 퍼셉트론에서 신경망까지를 만들었습니다. 나이브 베이즈는 단어를 세어 라벨을 골랐고, 이번에는 같은 셈으로 문서를 고릅니다.
환경: Python 3.9.6 표준 라이브러리만, Apple Silicon Mac(M1 Pro). 아무것도 설치하지 않았고 모델도 내려받지 않았습니다. 대상은 2026년 11월 11일 시점 블로그의 Markdown 파일이며, 이 연재 글들은 아직 들어 있지 않습니다.
임베딩과 RAG 전에 키워드 검색부터 만드는 이유
RAG는 "관련 문단을 찾는다"와 "언어 모델이 그걸 읽고 답한다"의 두 단계입니다. 앞 단계가 검색이고, 검색은 신경망보다 오래된 기술입니다. 키워드 검색을 먼저 만드는 이유는 세 가지입니다.
- 기준선이 된다. 임베딩 모델이 내 질문에서 BM25를 못 이기면 비용값을 못 하는 것입니다. BM25 숫자가 없으면 그 판단조차 못 합니다.
- 식별자에 정확하다. 개발자는 에러 메시지를 그대로 붙여 넣습니다.
CA1416,WM_COPYDATA,max_connections_per_hour. 키워드 색인에서 이것들은 드물고 무거운 단어지만, 임베딩 모델에서는 서브워드 조각 몇 개라서 비슷한 다른 에러 근처에 놓일 수 있습니다. - 하이브리드 검색의 절반이다. 실무 RAG 상당수는 BM25와 벡터 검색을 나란히 돌리고 두 순위 목록을 합칩니다. 어느 쪽이든 키워드 쪽은 필요합니다.
임베딩은 구현하지 않습니다. 사전 학습 모델이 필요한데, 이 연재는 모델을 내려받지 않는다는 원칙으로 진행하기 때문입니다. 언제 임베딩을 더할지는 뒤쪽 결정표에 정리했습니다.
1단계: 역색인
단순한 검색은 질문마다 모든 문서를 읽습니다. 역색인은 이걸 뒤집어서, 단어마다 "어느 문서에 몇 번 나왔는지"를 저장합니다. 검색할 때는 질문에 든 단어의 목록만 읽으면 됩니다.
엔진 전체가 파일 하나입니다. load_posts는 프런트매터에서 제목과 설명만 남기고 날짜·태그는 버린 뒤, 본문을 코드 블록째 붙입니다. 식별자는 코드 안에 있기 때문입니다.
# search.py - inverted index + TF-IDF (cosine) + BM25, Python standard library only
import math, re, sys, unicodedata
from collections import Counter, defaultdict
from pathlib import Path
WORD = re.compile(r"[a-z0-9_]+|[-ヿ㐀-鿿가-]+")
def tok_bigram(text):
"""Latin/digit runs become words; CJK/Hangul runs become character bigrams."""
out = []
for w in WORD.findall(unicodedata.normalize("NFKC", text).lower()):
if w[0] < "":
out.append(w)
elif len(w) == 1:
out.append(w)
else:
out.extend(w[i:i + 2] for i in range(len(w) - 1))
return out
def tok_space(text):
"""The naive tokenizer: lowercase and split on whitespace."""
return unicodedata.normalize("NFKC", text).lower().split()
def load_posts(folder):
"""Return {slug: (title, text)}; front matter is dropped except title and description."""
posts = {}
for p in sorted(Path(folder).glob("*.md")):
raw = p.read_text(encoding="utf-8")
_, fm, body = raw.split("---", 2)
meta = dict(re.findall(r"^(title|description): *['\"]?(.*?)['\"]?$", fm, re.M))
posts[p.stem] = (meta["title"], meta["title"] + "\n" + meta.get("description", "") + "\n" + body)
return posts
class Index:
def __init__(self, docs, tokenize=tok_bigram):
self.tokenize = tokenize
self.ids = list(docs)
self.postings = defaultdict(dict) # term -> {doc number: term frequency}
self.length = [] # tokens per document
for n, slug in enumerate(self.ids):
tf = Counter(tokenize(docs[slug]))
self.length.append(sum(tf.values()))
for term, c in tf.items():
self.postings[term][n] = c
self.N = len(self.ids)
self.avgdl = sum(self.length) / self.N
# TF-IDF document vector norms, computed once at build time
self.norm = [0.0] * self.N
for term, plist in self.postings.items():
idf = math.log(self.N / len(plist))
for n, c in plist.items():
self.norm[n] += ((1 + math.log(c)) * idf) ** 2
self.norm = [math.sqrt(x) or 1.0 for x in self.norm]
def tfidf(self, query, k=10):
q = Counter(t for t in self.tokenize(query) if t in self.postings)
scores = defaultdict(float)
for term, qc in q.items():
plist = self.postings[term]
idf = math.log(self.N / len(plist))
wq = (1 + math.log(qc)) * idf
for n, c in plist.items():
scores[n] += wq * (1 + math.log(c)) * idf
qn = math.sqrt(sum(((1 + math.log(c)) * math.log(self.N / len(self.postings[t]))) ** 2
for t, c in q.items())) or 1.0
return self._top({n: s / (self.norm[n] * qn) for n, s in scores.items()}, k)
def bm25(self, query, k=10, k1=1.2, b=0.75):
scores = defaultdict(float)
for term in set(self.tokenize(query)):
plist = self.postings.get(term)
if not plist:
continue
df = len(plist)
idf = math.log(1 + (self.N - df + 0.5) / (df + 0.5))
for n, c in plist.items():
norm = k1 * (1 - b + b * self.length[n] / self.avgdl)
scores[n] += idf * c * (k1 + 1) / (c + norm)
return self._top(scores, k)
def _top(self, scores, k):
best = sorted((x for x in scores.items() if x[1] > 0), key=lambda x: -x[1])[:k]
return [(self.ids[n], round(s, 3)) for n, s in best]
if __name__ == "__main__":
folder, query = sys.argv[1], " ".join(sys.argv[2:])
posts = load_posts(folder)
idx = Index({s: t for s, (_, t) in posts.items()})
for name, hits in (("TF-IDF", idx.tfidf(query, 3)), ("BM25", idx.bm25(query, 3))):
print(name)
for slug, s in hits:
print(f" {s:7.3f} {posts[slug][0]}")
색인의 실체는 dict[str, dict[int, int]]입니다. 한국어 글에서는 서로 다른 단어 13,818개, 포스팅 합계 78,244개였습니다.
띄어쓰기가 있어도 조사가 문제다: 문자 바이그램
한국어는 어절 단위로 띄어 쓰니 split()으로 충분해 보입니다. 문제는 조사입니다. "방침은", "방침을", "방침이"는 공백 기준으로 전부 다른 단어입니다. 한국어 글에서 "개인정보처리방침"을 포함한 공백 토큰을 세고, 같은 질문을 두 토크나이저로 검색해 봤습니다.
# tokdemo.py - whitespace tokens vs character bigrams on real posts
import sys
from collections import Counter
from search import Index, load_posts, tok_bigram, tok_space
folder, word, query = sys.argv[1], sys.argv[2], sys.argv[3]
posts = load_posts(folder)
docs = {s: t for s, (_, t) in posts.items()}
forms = Counter(t for text in docs.values() for t in tok_space(text) if word in t)
print(f"whitespace tokens containing {word!r}: {len(forms)} distinct forms")
for t, c in forms.most_common(5):
print(f" {c:3} {t[:40]}")
for name, tok in (("whitespace", tok_space), ("bigram", tok_bigram)):
idx = Index(docs, tok)
print(f"{name}: {tok(query)}")
for slug, s in idx.bm25(query, 3) or [("(no results)", 0)]:
print(f" {s:6.2f} {slug}")
$ python3 tokdemo.py content/ko 개인정보처리방침 "앱 개인정보처리방침 작성"
whitespace tokens containing '개인정보처리방침': 3 distinct forms
3 개인정보처리방침은
3 개인정보처리방침
1 개인정보처리방침,
whitespace: ['앱', '개인정보처리방침', '작성']
9.12 wordpress-to-static-without-breaking-store-urls
4.63 winforms-ok-cancel-button
2.86 flutter-migration-16-apps-what-broke
bigram: ['앱', '개인', '인정', '정보', '보처', '처리', '리방', '방침', '작성']
29.55 app-privacy-policy-from-manifests
29.33 wordpress-to-static-without-breaking-store-urls
26.56 ai-coding-agent-live-server-guardrails
정답인 앱 개인정보처리방침 글은 이 단어를 "개인정보처리방침은" 형태로만 씁니다. 그래서 공백 분리 색인에서는 질문의 "개인정보처리방침"과 한 번도 일치하지 않고 상위 3위에서 빠집니다(평가에서는 6위). 조사 없는 형태를 쓴 WordPress 이전 글이 대신 1위가 됩니다.
문자 바이그램은 사전 없이 이걸 피합니다. 한글·가나·한자의 연속을 두 글자 창으로 한 글자씩 밀며 자릅니다. "개인정보처리방침은"에도 "개인", "정보", "방침"이 그대로 들어 있으니 조사가 붙어도 대부분의 조각이 일치합니다. Elasticsearch의 cjk_bigram 필터와 같은 발상입니다. 대가는 잡음("보처"는 단어가 아님)과 색인 크기로, 서로 다른 단어 수가 영어 8,412개에 비해 한국어 13,818개, 일본어 21,457개였습니다. Mecab-ko나 KoNLPy 같은 형태소 분석기를 쓰면 더 깔끔하지만 사전이 딸린 의존성이 늘어납니다. 블로그 규모에서는 바이그램으로 충분했습니다.
일본어는 사정이 더 나쁩니다. 띄어쓰기가 아예 없어서 공백 분리를 하면 문장 전체가 토큰 하나가 됩니다. 같은 실험에서 일본어 라벨 질문의 MRR은 공백 분리 0.338, 바이그램 0.975였습니다.
2단계: TF-IDF와 코사인 유사도
TF-IDF는 문서 속 단어에 (1 + log tf) × log(N / df)의 가중치를 줍니다.
- tf: 그 문서에서의 출현 횟수. 로그를 씌우니 30번 나온 단어가 1번 나온 단어의 30배가 되지 않습니다.
- df: 그 단어가 들어 있는 문서 수. 모든 글에 나오는 단어는
log(N / df)가 0이고, 드문 단어일수록 커집니다. "합니다", "있습니다"는 무게 0,ca1416은 무겁습니다. 핵심은 이게 전부입니다.
질문에도 같은 가중치를 주고, 두 벡터 사이 각도의 코사인을 점수로 씁니다. 벡터 길이로 나누기 때문에(문서 쪽은 색인할 때 self.norm에 미리 계산) 긴 글이 단어가 많다는 이유만으로 이기지는 않습니다.
3단계: BM25, 그리고 k1과 b가 하는 일
BM25는 같은 생각을 이어받되 두 가지를 바꿉니다. idf는 log(1 + (N − df + 0.5) / (df + 0.5))로 음수가 되지 않습니다. 더 큰 차이는 횟수와 길이를 다루는 방식입니다.
score += idf × tf × (k1 + 1) / (tf + k1 × (1 − b + b × 문서 길이 / 평균 문서 길이))
k1은 포화 속도, 즉 같은 단어가 여러 번 나와도 점수가 얼마나 빨리 멈추는지입니다. b는 문서 길이 보정의 세기로, 0이면 길이를 무시하고 1이면 평균 대비 길이로 완전히 깎습니다. knobs.py로 둘 다 확인했습니다.
# knobs.py - what k1 and b actually do
import math, sys
from search import Index, load_posts
# 1) k1: contribution of one term as its count grows, in a document of average length (idf left out)
print("tf " + " ".join(f"k1={k1:<4}" for k1 in (0.3, 1.2, 3.0)) + " TF-IDF 1+log(tf)")
for tf in (1, 2, 5, 10, 30):
row = [tf * (k1 + 1) / (tf + k1) for k1 in (0.3, 1.2, 3.0)]
print(f"{tf:<4} " + " ".join(f"{v:7.2f}" for v in row) + f" {1 + math.log(tf):7.2f}")
# 2) b: the same query with length normalisation off, default and full
folder, query = sys.argv[1], " ".join(sys.argv[2:])
posts = load_posts(folder)
idx = Index({s: t for s, (_, t) in posts.items()})
length = dict(zip(idx.ids, idx.length))
print(f"\nquery: {query} (average length {idx.avgdl:.0f} tokens)")
for b in (0.0, 0.75, 1.0):
print(f"b={b}")
for slug, s in idx.bm25(query, 3, 1.2, b):
print(f" {s:6.2f} {length[slug]:5} tokens {slug}")
$ python3 knobs.py content/ko "앱 개인정보처리방침 작성"
tf k1=0.3 k1=1.2 k1=3.0 TF-IDF 1+log(tf)
1 1.00 1.00 1.00 1.00
2 1.13 1.38 1.60 1.69
5 1.23 1.77 2.50 2.61
10 1.26 1.96 3.08 3.30
30 1.29 2.12 3.64 4.40
query: 앱 개인정보처리방침 작성 (average length 2241 tokens)
b=0.0
28.23 1746 tokens wordpress-to-static-without-breaking-store-urls
27.41 1330 tokens app-privacy-policy-from-manifests
25.17 1727 tokens ai-coding-agent-live-server-guardrails
b=0.75
29.55 1330 tokens app-privacy-policy-from-manifests
29.33 1746 tokens wordpress-to-static-without-breaking-store-urls
26.56 1727 tokens ai-coding-agent-live-server-guardrails
b=1.0
30.37 1330 tokens app-privacy-policy-from-manifests
29.72 1746 tokens wordpress-to-static-without-breaking-store-urls
27.06 1727 tokens ai-coding-agent-live-server-guardrails
k1 = 0.3이면 30번째 출현도 첫 출현과 거의 같고(1.29 대 1.00), k1 = 3.0이면 TF-IDF에 꽤 가까워집니다. 기본값 1.2는 그 중간입니다.
b의 효과는 위 질문에서 보입니다. 정답 글은 1,330토큰으로 평균(2,241)보다 짧고, WordPress 이전 글도 이 단어들을 많이 씁니다. b = 0에서는 2위였다가, 기본값 0.75에서 짧은 글의 일치가 더 무겁게 평가되며 1위로 올라갑니다. 반대 방향도 있습니다. 일본어판에서 정답 글이 긴 경우를 보면, b = 1.0에서 길이 때문에 1위에서 2위로 내려갔습니다. b는 켜면 좋아지는 스위치가 아니라 짧은 글과 긴 글 중 어느 쪽을 밀어줄지 정하는 손잡이입니다.
조정해야 할까요? 뒤에서 설명할 28개 질문으로 k1과 b를 전부 돌려 봤습니다.
# sweep.py - how k1 and b change MRR@10 (labelled + paraphrase queries)
import sys
from search import Index, load_posts
from queries import QUERIES, PARAPHRASE
from evaluate import metrics
root = sys.argv[1]
K1 = (0.3, 0.6, 0.9, 1.2, 1.5, 2.0, 3.0)
B = (0.0, 0.25, 0.5, 0.75, 1.0)
for lang in ("en", "ja", "ko"):
posts = load_posts(f"{root}/{lang}")
idx = Index({s: t for s, (_, t) in posts.items()})
qs = QUERIES[lang] + PARAPHRASE[lang]
print(f"[{lang}] MRR@10, n={len(qs)} b=" + " ".join(f"{b:5}" for b in B))
for k1 in K1:
row = [metrics(lambda q: idx.bm25(q, 10, k1, b), qs)[0] for b in B]
print(f" k1={k1:<4}" + " " * 17 + " ".join(f"{m:.3f}" for m in row))
$ python3 sweep.py content
…
[ko] MRR@10, n=28 b= 0.0 0.25 0.5 0.75 1.0
k1=0.3 0.827 0.851 0.833 0.851 0.833
k1=0.6 0.824 0.824 0.845 0.863 0.869
k1=0.9 0.823 0.824 0.823 0.857 0.863
k1=1.2 0.820 0.819 0.821 0.860 0.863
k1=1.5 0.822 0.816 0.816 0.840 0.861
k1=2.0 0.817 0.820 0.838 0.841 0.860
k1=3.0 0.808 0.830 0.854 0.858 0.858
한국어 최고값은 k1 = 0.6, b = 1.0의 0.869, 기본값(k1 = 1.2, b = 0.75)은 0.860입니다. 표 전체가 0.808~0.869 사이이고, 28개 질문에서는 한 질문이 1위에서 2위로만 내려가도 MRR이 0.018 움직입니다. 차이 대부분이 질문 한두 개 몫이라는 뜻입니다. 영어(최고 0.932, 기본값 0.905)와 일본어(기본값이 곧 최고인 0.848)도 비슷했습니다. 다만 한국어에서는 어떤 k1에서도 b = 0 열이 b = 0.75 열보다 낮았으니, 길이 보정을 끄는 건 좋은 선택이 아닙니다. 이 정도 크기의 질문 세트로 k1과 b를 맞추면 문제가 아니라 질문에 맞추게 됩니다. 판정된 질문이 수백 개가 되기 전까지는 기본값이면 충분합니다.
에러 코드와 식별자에는 키워드 검색이 강하다
토크나이저는 [a-z0-9_]+를 한 덩어리로 두기 때문에 식별자가 드문 단어로 살아남습니다.
$ python3 search.py content/ko CA1416
TF-IDF
0.145 C# 레지스트리 읽기·쓰기: .NET 10의 CA1416과 RegistryView
BM25
7.994 C# 레지스트리 읽기·쓰기: .NET 10의 CA1416과 RegistryView
$ python3 search.py content/ko "1226 에러"
TF-IDF
0.119 MySQL 1226 max_connections_per_hour: 공유 호스팅 대처법
0.037 C# 프로퍼티 기초: get/set, 자동 구현, init, 세터 검증
0.031 C# 프로퍼티 심화: field 키워드, required, 변경 알림
BM25
8.279 MySQL 1226 max_connections_per_hour: 공유 호스팅 대처법
4.348 C# 프로퍼티 심화: field 키워드, required, 변경 알림
4.335 C# 프로퍼티 기초: get/set, 자동 구현, init, 세터 검증
CA1416은 글 하나에만 나오니 결과도 하나입니다. "1226 에러"에서는 MySQL 1226 글이 1위이고 2·3위는 "에러"만 일치한 글이라 점수가 절반 아래입니다. 점수 차이 자체가 "확실한 정답은 하나"라는 신호가 됩니다.
평가: 라벨 붙인 질문 세트와 그 편향
재 보지 않은 검색 엔진은 데모일 뿐입니다. 언어별로 질문 20개를 만들고 각각 정답 글을 하나씩 정했습니다. 여기에 제목 단어를 피한 "바꿔 말하기" 질문 8개를 더했습니다.
# queries.py (excerpt) - (query, the one slug that counts as the right answer)
"ko": [
("MySQL 1226 에러 시간당 접속", "mysql-max-connections-per-hour-shared-hosting"),
("퀵 정렬 스택 오버플로", "algorithm-sorting"),
("htaccess 한글 URL 리라이트", "litespeed-htaccess-rewrite-non-ascii-urls"),
("요세푸스 문제", "data-structure-circular-linked-list"),
("앱 개인정보처리방침 작성", "app-privacy-policy-from-manifests"),
…
],
# PARAPHRASE
"ko": [
("DB 접속 한도 초과", "mysql-max-connections-per-hour-shared-hosting"),
("https 전환 후 전송 데이터가 사라짐", "dart-http-post-redirect-not-followed"),
("앱에 예전 데이터가 내려옴", "cdn-caches-api-responses-and-redirects"),
("프로그램 두 번 실행 막기", "winforms-single-instance-form"),
…
만든 방법과 편향을 솔직하게 적습니다.
- 라벨 질문 20개는 글 제목을 보면서 썼습니다. 그래서 정답 글과 어휘가 많이 겹치고 키워드 방식에 유리합니다. 점수가 0.9를 넘는 이유입니다. 이미 아는 에러 메시지로 검색하는 상황에는 가깝지만 일반적인 검색 정확도는 아닙니다.
- 바꿔 말하기 8개는 제목 단어를 일부러 피해서 증상을 적었습니다(CDN 캐시 글에 "앱에 예전 데이터가 내려옴"). 용어를 모르는 사람의 검색에 가깝지만 8개뿐이라 질문 하나가 재현율을 0.125씩 움직입니다.
- 질문마다 정답은 하나. 정답이 둘일 수 있는 질문도 하나만 세므로 모든 방식이 조금씩 낮게 나옵니다.
- 한국어 질문은 영어 목록을 번역한 게 아니라 한국어 글을 보고 따로 썼기 때문에, 언어 간 숫자를 그대로 비교할 수는 없습니다.
지표는 MRR@10(정답 순위의 역수 평균, 상위 10위 밖이면 0)과 recall@3(정답이 상위 3위 안에 든 비율)입니다.
# evaluate.py - MRR@10 and recall@3 for TF-IDF vs BM25, plus build/query cost
import pickle, statistics, sys, time
from search import Index, load_posts, tok_space
from queries import QUERIES, PARAPHRASE
def metrics(rank_fn, qs):
rr, hit3, ranks = 0.0, 0, []
for q, want in qs:
ids = [s for s, _ in rank_fn(q)] # top 10
r = ids.index(want) + 1 if want in ids else None
ranks.append(r)
rr += 1 / r if r else 0
hit3 += bool(r and r <= 3)
return rr / len(qs), hit3 / len(qs), ranks
def main(root):
for lang in ("en", "ja", "ko"):
posts = load_posts(f"{root}/{lang}")
docs = {s: t for s, (_, t) in posts.items()}
builds = []
for _ in range(5):
t0 = time.perf_counter(); idx = Index(docs); builds.append(time.perf_counter() - t0)
size = len(pickle.dumps((dict(idx.postings), idx.length, idx.norm)))
npost = sum(len(p) for p in idx.postings.values())
print(f"[{lang}] docs={idx.N} chars={sum(map(len, docs.values())):,} terms={len(idx.postings):,} "
f"postings={npost:,} pickle={size/1024:.0f}KiB build={statistics.median(builds)*1000:.0f}ms")
ws = Index(docs, tok_space)
for setname, qs in (("labelled", QUERIES[lang]), ("paraphrase", PARAPHRASE[lang])):
for name, fn in (("TF-IDF", idx.tfidf), ("BM25", idx.bm25), ("BM25/space", ws.bm25)):
mrr, r3, ranks = metrics(fn, qs)
print(f" {setname:10} {name:10} MRR@10={mrr:.3f} recall@3={r3:.2f} ranks={ranks}")
for name, fn in (("TF-IDF", idx.tfidf), ("BM25", idx.bm25)):
lat = []
for _ in range(20):
for q, _ in QUERIES[lang] + PARAPHRASE[lang]:
t0 = time.perf_counter(); fn(q); lat.append(time.perf_counter() - t0)
lat.sort()
print(f" latency {name:6} median={statistics.median(lat)*1000:.3f}ms "
f"p95={lat[int(len(lat) * .95)]*1000:.3f}ms (n={len(lat)})")
if __name__ == "__main__":
main(sys.argv[1])
$ python3 evaluate.py content
…
[ko] docs=86 chars=699,588 terms=13,818 postings=78,244 pickle=480KiB build=118ms
labelled TF-IDF MRR@10=0.925 recall@3=1.00 ranks=[1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 2, 1, 1, 1, 1, 2, 1, 1, 1, 1]
labelled BM25 MRR@10=0.967 recall@3=1.00 ranks=[1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
labelled BM25/space MRR@10=0.933 recall@3=0.95 ranks=[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 6, 1, 1, 1, 1]
paraphrase TF-IDF MRR@10=0.557 recall@3=0.50 ranks=[1, 1, 8, 6, None, 1, 1, 6]
paraphrase BM25 MRR@10=0.594 recall@3=0.50 ranks=[1, 1, 4, 4, None, 1, 1, 4]
paraphrase BM25/space MRR@10=0.420 recall@3=0.38 ranks=[1, 4, None, None, None, 1, 1, 9]
latency TF-IDF median=0.055ms p95=0.099ms (n=560)
latency BM25 median=0.057ms p95=0.132ms (n=560)
세 언어를 모으면 이렇습니다.
| MRR@10 | TF-IDF | BM25 | BM25 + 공백 분리 |
|---|---|---|---|
| 영어 라벨 20개 | 0.933 | 0.942 | 0.942 |
| 일본어 라벨 20개 | 0.942 | 0.975 | 0.338 |
| 한국어 라벨 20개 | 0.925 | 0.967 | 0.933 |
| 영어 바꿔 말하기 8개 | 0.806 | 0.812 | 0.781 |
| 일본어 바꿔 말하기 8개 | 0.521 | 0.531 | 0.000 |
| 한국어 바꿔 말하기 8개 | 0.557 | 0.594 | 0.420 |
읽을 수 있는 것:
- BM25가 모든 줄에서 TF-IDF를 조금 앞선다. 라벨 세트의 recall@3은 둘 다 1.00이고, 차이는 2·3위였던 몇 개가 1위로 올라간 만큼입니다.
- 진짜 약점은 바꿔 말하기. 한국어는 0.967에서 0.594로 떨어졌고, "프로그램 두 번 실행 막기"는 상위 10위에도 못 들었습니다. 정답 글은 이 내용을 주로 "Mutex"(31회)와 "중복 실행"(3회)으로 설명하고, 질문에 쓴 "두 번 실행"은 한 번뿐입니다. 1위는 다른 프로그램의 창을 찾는 FindWindow 글이었습니다. 임베딩이 필요한 곳이 바로 여기입니다.
- 한국어 공백 분리는 라벨 세트에서는 버티지만(0.933) 바꿔 말하기에서 무너진다(0.420). 질문을 자연스러운 문장으로 쓸수록 조사가 붙은 형태가 늘어나기 때문입니다.
속도와 색인 크기
| 영어 | 일본어 | 한국어 | |
|---|---|---|---|
| 글 수 / 글자 수 | 86 / 967,286 | 86 / 676,624 | 86 / 699,588 |
| 서로 다른 단어 | 8,412 | 21,457 | 13,818 |
| 포스팅 수 | 47,875 | 114,836 | 78,244 |
| pickle로 저장한 색인 | 301 KiB | 717 KiB | 480 KiB |
| 색인 시간(5회 중앙값) | 76 ms | 166 ms | 118 ms |
| BM25 검색 중앙값 / p95 | 0.086 / 0.195 ms | 0.075 / 0.186 ms | 0.057 / 0.132 ms |
색인 시간에는 토큰화가 포함되고 파일 읽기는 빠져 있습니다. 검색 시간은 28개 질문 × 20회 = 560회를 잰 값입니다. 한국어 색인은 영어의 약 1.6배인데, 바이그램 때문에 단어 종류가 늘어서입니다. 그래도 0.5MB 정도라서 정적 사이트라면 JSON으로 내보내 브라우저에서 검색하는 것도 가능한 크기입니다.
BM25, 임베딩, 하이브리드: 언제 무엇을
| 상황 | BM25(키워드) | 임베딩 | 하이브리드 |
|---|---|---|---|
| 에러 코드, API 이름, 파일 이름, 버전 문자열 | 최선: 드문 단어가 높은 점수 | "비슷한 다른 에러"로 갈 수 있음 | 좋음 |
| 사용자가 증상을 자기 말로 설명 | 약함(바꿔 말하기 MRR 0.53~0.81) | 최선 | 최선 |
| GPU·모델·추가 서비스 없음 | 표준 라이브러리로 동작 | 모델과 벡터 저장소 필요 | 둘 다 필요 |
| 형태소 분석기 없는 한국어·일본어 | 바이그램으로 동작 | 모델의 언어 지원에 달림 | 동작 |
| 왜 1위인지 설명해야 함 | 쉬움: 일치한 단어를 보여 주면 됨 | 어려움 | 일부 가능 |
| 글 100개 안팎, 찾는 글이 정해져 있음 | 충분 | 대개 불필요 | 과함 |
| 수천 건의 잡다한 문서로 RAG | 기준선 | 강함 | 일반적인 선택 |
기준: 먼저 BM25를 만들고, 라벨 붙인 질문으로 잰다. 놓치는 질문이 이번 바꿔 말하기처럼 "단어가 달라서" 생긴 것이라면 그때 임베딩을 더합니다.
솔직한 한계
- 질문 세트가 작고, 글을 쓴 쪽이 직접 만들었다. 28개는 공백 분리 대 바이그램(일본어)처럼 큰 차이를 보이기에는 충분하지만, TF-IDF 대 BM25나 k1·b 설정처럼 작은 차이의 순위를 매기기에는 부족합니다. 라벨 절반은 제목 단어에 치우쳐 있습니다.
- 질문마다 정답 하나라서 정답이 둘인 경우 모든 방식이 낮게 나옵니다.
- 바이그램은 거칠다. "보처" 같은 의미 없는 조각이 생기고, "자바"로 찾으면 "자바스크립트" 속의 "자바"까지 맞습니다. 형태소 분석으로 고칠 수 있지만 의존성이 늘어납니다.
- 어형 변화·동의어·표기 차이에 약하다. "예전 데이터"와 "캐시"는 이어지지 않습니다. 영어에서도 "redirect"와 "redirects"는 다른 단어입니다.
- 글 전체가 단위. RAG에서는 수백 토큰짜리 문단마다 색인하므로 문서 길이 통계도, 알맞은 b도 달라집니다.
- 측정은 한 대의 기계, 메모리 위 색인, CPython 3.9 기준입니다. 수백만 문서라면 진짜 검색 엔진을 씁니다.
다음: 여기서 RAG로
연재는 여기서 RAG가 시작되는 지점에 닿았습니다. 이런 검색기가 상위 문단을 돌려주면 언어 모델이 그걸 읽고 답합니다. 하이브리드라면 임베딩 검색을 더해 두 순위를 합칩니다. 무엇을 더하든 evaluate.py는 남겨 두세요. 새 부품이 내 문서에서 정말 효과가 있는지 알려 주는 건 라벨 붙인 질문뿐입니다.
연재의 다른 강의: 선형 회귀, k-평균, 나이브 베이즈, 퍼셉트론에서 신경망으로. 한글 텍스트를 다루는 다른 예로는 Pillow로 OG 이미지를 만들며 한·일 제목 줄바꿈을 처리한 글도 있습니다.
자주 묻는 질문
한국어는 띄어쓰기가 있는데 공백으로 나눠도 되지 않나요?
라벨 세트 20개에서는 공백 분리도 MRR 0.933으로 버텼습니다. 하지만 조사가 붙은 형태(개인정보처리방침은)만 쓰는 글은 놓쳤고, 제목 단어를 피한 바꿔 말하기 질문에서는 공백 분리 0.420, 문자 바이그램 0.594로 차이가 났습니다.
BM25가 TF-IDF보다 얼마나 나은가요?
우리 블로그 글에서는 조금 나았습니다. 한국어 20개 질문의 MRR@10이 TF-IDF 0.925, BM25 0.967이었습니다. 2위나 3위였던 몇 개가 1위로 올라간 차이라서, 꾸준하지만 작은 우위로 보는 게 맞습니다.
임베딩을 쓰면 키워드 검색은 필요 없나요?
대개 필요합니다. CA1416이나 1226 같은 에러 코드와 식별자에는 키워드 검색이 정확하고, 하이브리드 검색의 키워드 쪽이 됩니다. 약한 곳은 바꿔 말하기로, 글의 단어를 피한 질문에서 MRR이 0.594까지 떨어졌습니다.