티스토리 뷰

RAG라고 하면 보통 임베딩과 벡터 검색을 먼저 떠올린다. 의미가 비슷한 문서를 찾아주는 그 능력이 RAG의 핵심처럼 보이니까. 그런데 막상 실제 문서 검색에 붙여보면, 벡터만으로는 자꾸 새는 구멍이 있다.
예를 들어 "IITP"나 "국가연구개발혁신법" 같은 정확한 고유명사·법령명을 검색할 때다. 이런 건 의미가 비슷한 걸 찾으면 곤란하다. 글자 그대로 들어맞는 문서를 찾아야 한다. 벡터 검색은 "비슷함"에 강하지만 "정확히 같음"에는 의외로 약하다. 철자 하나 다른 약어, 학습 때 못 본 신조어, 숫자가 섞인 코드 같은 건 임베딩이 뭉뚱그려버린다. 그래서 키워드(lexical) 검색이 한 축으로 필요했고, 그 표준이 BM25였다.
직접 구현해서 붙여보며 정리해둔다. 솔직히 처음엔 나도 "그냥 질의 단어가 문서에 많이 겹치면 점수 높은 거 아냐?" 정도로만 알고 있었다. 막상 제대로 파보니 그 단순한 직관 위에 꽤 정교한 보정이 얹혀 있었다.
1. BM25는 TF-IDF의 진화형이다
출발점은 단순하다. 질의의 단어가 문서에 많이 나오면 관련 있을 것이다(TF, term frequency). 그런데 "그리고·합니다·자료" 같은 단어는 어느 문서에나 나오니 많이 겹쳐도 의미가 없다. 그래서 코퍼스 전체에서 흔한 단어는 가중치를 깎고, 희귀한 단어는 키운다(IDF, inverse document frequency). 이게 고전적인 TF-IDF다.
TF-IDF만으로도 그럴듯하지만, 두 가지 약점이 있다.
첫째, 단어 빈도가 점수에 선형으로 들어간다. 그래서 같은 단어를 50번 도배한 문서가, 핵심어를 정확히 한 번 담은 문서를 빈도만으로 눌러버릴 수 있다.
둘째, 문서 길이를 고려하지 않는다. 긴 문서는 그냥 길어서 단어가 많이 나오니 부당하게 유리하다. BM25는 바로 이 두 약점을 TF 포화와 길이 정규화로 메운 것이다.
2. 세 가지 직관으로 보는 BM25
공식은 이렇게 생겼다. 겁먹을 필요 없이 세 조각으로 나눠 보면 된다.
각 질의 단어 q에 대해
점수 += IDF(q) × tf · (k1 + 1)
─────────────────────────────────
tf + k1 · (1 − b + b · 문서길이/평균길이)
tf = 그 문서에서 q가 나온 횟수
전체 점수 = 질의 단어들의 점수 합 (k1 = 1.5, b = 0.75)
(1) TF 포화 — k1. 단어가 많이 나올수록 점수가 오르긴 하는데, 무한정 오르지는 않는다. 어떤 단어가 2번 나온 문서와 20번 나온 문서가 있을 때, 20번이 10배 더 관련 있진 않다. k1은 이 "어느 정도 나오면 그만 오르는" 포화 지점을 정한다.
위 그래프처럼 단순 빈도는 단어가 나올수록 끝없이 오르지만, BM25는 k1+1(여기선 2.5) 근처로 수렴한다. 그래서 단어를 도배해도 점수가 천장에 막힌다. TF-IDF에는 이 천장이 없어서, 키워드를 반복한 문서가 부당하게 1등을 먹곤 했다.

(2) IDF — 희귀할수록 무겁게. 코퍼스 전체에서 그 단어를 담은 문서가 적을수록 IDF가 커진다. "보고서"는 흔해서 가볍고, "국가연구개발혁신법"은 희귀해서 무겁다. (분모·분자에 0.5를 더하는 평활화가 들어가, 한 번도 안 나온 단어나 너무 흔한 단어에서 점수가 튀지 않게 한다.) 덕분에 희귀한 핵심어 하나가 흔한 단어 여러 개를 이긴다.
(3) 문서 길이 정규화 — b. 긴 문서는 그냥 길어서 단어가 많이 나온다. 보정 없이 두면 긴 문서가 항상 유리하다. b는 "이 문서가 평균보다 얼마나 긴가"로 점수를 깎아, 짧고 핵심만 담은 문서가 손해 보지 않게 한다. b=0(길이 무시)에서 b=1(완전 정규화) 사이를 고르는데, 보통 0.75가 무난했다.
예시로 보면
말로만 보면 와닿지 않으니, "스마트공장 지원사업"으로 검색했다고 해보자.
문서 A (짧은 공고) "스마트공장 지원사업 모집 공고"
문서 B (긴 안내문) "지원사업 안내 … 지원사업 신청 … 지원사업 마감 …" (길고, '지원사업' 5회, '스마트공장' 0회)
직관적으로는 "지원사업"을 5번이나 가진 B가 이길 것 같다. 하지만 BM25는 A를 올린다. "스마트공장"은 희귀어라 IDF가 크고 A만 가졌다. 반면 B의 "지원사업"은 흔해서 IDF가 작은 데다, 5번 반복도 TF 포화로 2~3번 수준으로 눌리고, 문서가 길어 길이 정규화로 또 깎인다. 결국 핵심 희귀어를 정확히 가진 짧은 문서가 흔한 단어를 도배한 긴 문서를 이긴다. TF-IDF였다면 B의 반복이 선형으로 점수를 부풀려 거꾸로 갔을 수도 있다. 세 보정이 같이 맞물려야 나오는 결과다.
3. 한국어에서 진짜 문제는 토큰화였다
공식을 코드로 옮기는 건 어렵지 않았다. 정작 발목을 잡은 건 "단어를 어떻게 자르느냐" 였다.
BM25는 질의의 단어와 문서의 단어가 정확히 같은 형태로 맞아떨어져야 점수가 붙는다. 그런데 한국어는 조사·어미가 붙어 다닌다. 질의에선 "제안서를"로 자르고 문서에선 "제안서"로 잘리면, 둘은 다른 토큰이라 매칭이 안 된다. 그래서 질의측과 코퍼스측이 반드시 같은 토크나이저를 써야 한다. 양쪽 다 Kiwi 형태소 분석으로 명사만 뽑아 같은 형태로 맞췄다. 이게 안 맞으면 점수 공식이 아무리 정확해도 애초에 매칭이 0이 된다.
그다음 복병이 복합명사였다. Kiwi가 한 단어로 봐야 할 복합명사를 둘로 쪼개버리면(이를테면 "검색엔진"을 "검색"+"엔진"으로), 그 복합어로 검색했을 때 문서 쪽과 안 맞는다. 그래서 자주 쪼개지는 도메인 복합명사는 사용자사전에 등록해 통째로 유지되게 했다. 반대로 "C++"이나 "R&D"처럼 기호가 의미인 토큰은 살려야 했다. 그래서 한글과 한글 사이의 '+'만 공백으로 바꾸고, 영어 토큰의 기호는 보존하는 식으로 갈랐다.
정리하면, BM25에서 점수 공식은 교과서대로면 되고, 실제 성능을 가른 건 그 앞단의 토큰화를 도메인에 맞게 다듬는 일이었다. 신조어나 약어처럼 형태소 분석기가 모르는 말은 결국 사전으로 보강해줘야 했고, 이게 규칙 기반의 숙명이기도 하다.
4. 단발, 인메모리, 그리고 청크
검색기 자체는 일부러 단순하게 짰다. 질의를 한 번 토큰화하고, 후보 문서를 추리고, BM25 점수를 한 번 매겨 정렬한다. 그게 끝이다. LLM 호출도, 여러 번 되묻는 멀티홉도 없다.
여기서 두 가지 실무적인 장치가 들어간다.
하나는 역색인이다. 모든 문서에 매번 점수를 매기면 느리니, "단어 → 그 단어가 든 문서 목록"을 미리 만들어두고, 질의 단어가 들어간 문서만 후보로 추려 그 후보에만 점수를 매긴다. 흔한 패턴이지만 속도를 좌우한다.
다른 하나는 청크 단위 인덱싱이다. 문서를 통째로 한 덩어리로 색인하지 않고 조각(청크)으로 나눠 색인한 뒤, 한 문서의 여러 청크 중 가장 높은 점수로 그 문서의 점수를 집계한다.
긴 문서에서 한 단락만 질의에 딱 맞아도 그 문서가 올라오게 하려는 것이다. 여기에 파일명에서 온 청크에는 가중치를 더 줬다. 사람은 파일명에 핵심어를 박아두는 경우가 많아서, 제목 매칭을 본문 매칭보다 우대하는 게 체감 품질에 좋았다.
인덱스를 인메모리로 둔 데에는 이유가 하나 더 있다. 오프라인 평가와 실제 서버가 완전히 같은 코드로 같은 점수를 내게 하려는 것이다. 평가에서 좋았던 설정이 서버에서 그대로 재현돼야, 측정을 믿고 튜닝할 수 있다. 평가와 운영이 다른 코드로 돌면 "평가에선 좋았는데 실서비스에선 다르다"가 반복된다.
5. 벡터와 함께 — 결국은 하이브리드
BM25가 만능은 아니다. "예산 관련 자료"를 "재정 보고서"라고 바꿔 물으면, 글자가 안 겹쳐서 BM25는 약하다. 이건 의미로 찾는 벡터 검색이 강한 지점이다. 반대로 정확한 약어·고유명사는 벡터가 약하고 BM25가 강하다.
그래서 실제로는 둘을 같이 쓴다. 정확한 단어 매칭은 BM25가, 의미가 비슷한 변형은 벡터가 맡아서 점수를 합치는 하이브리드 구조다. 어느 한쪽이 놓치는 걸 다른 쪽이 줍는 식이라, 둘을 합치면 각각보다 안정적이었다.
BM25는 그 하이브리드의 한쪽 기둥이고, 이 글은 그 기둥을 들여다본 셈이다.
마치며
BM25는 화려한 최신 기법이 아니다. 수식의 뿌리는 30년 가까이 됐다. 그런데 막상 한국어 문서 검색에 제대로 붙여보니,
"단어 많이 겹치면 점수 높다"는 내 막연한 이해보다 훨씬 정교했다. TF는 포화시키고, 희귀어는 키우고, 긴 문서는 깎는다 — 세 보정이 맞물려야 짧고 정확한 문서가 도배 문서를 이긴다.
그리고 정작 성능을 가른 건 그 수식이 아니라, 한국어를 어떻게 같은 단어로 맞춰 자르느냐는 토큰화였다. 기본기일수록 대충 알고 가져다 쓰기 쉬운데, 직접 만들어보고 나서야 그 안의 디테일이 보였다.
새 모델을 얹기 전에, 이런 바탕부터 제대로 이해하고 있었나를 다시 묻게 됐다.
'개발 > AI' 카테고리의 다른 글
| RAG 검색 개선기: 검색기보다 먼저 봐야 했던 질의처리 (0) | 2026.04.28 |
|---|---|
| RAG 검색 개선기: RAG 검색 평가셋을 고를 때 고민한 것들 (1) | 2026.04.28 |
| RAG 구축 시 고려해야 할 것들 (1) | 2026.01.31 |
| 벡터데이터베이스 pgvector 사용해보기 (with. 랭체인) (0) | 2025.11.04 |
| RAG와 임베딩 (1) — Embedding 모델 이해하기 (0) | 2025.10.27 |
- Total
- Today
- Yesterday
- elasticsearch
- AWS EC2
- 오블완
- ChatGPT
- 스프링부트
- java
- OpenAI
- 람다
- terraform
- lambda
- Log
- 티스토리챌린지
- serverless
- JWT
- AWS
- Redis
- GIT
- 후쿠오카
- EKS
- 인프런
- 온디바이스 AI
- rag
- CORS
- docker
- Kotlin
- Spring
- CloudFront
- S3
- cache
- springboot
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |

