"RAG 핵심 학습 (11/26) — Sparse Retrieval과 BM25 깊이 다이브"

Dense가 잘 못하는 정확 매칭·고유명사·코드를 채우는 검색이 Sparse — 그 표준이 BM25다.

Sparse Retrieval은 문서를 단어 출현 빈도 벡터로 표현하고 키워드 일치로 답을 찾는다. 단순해 보이지만 TF-IDF의 정보량 가중, BM25의 길이 정규화, Inverted Index의 검색 속도가 어우러져 고유명사·코드·약어Dense보다 강함. 본 편은 Robertson 2009 BM25 식을 풀고, Elasticsearch/OpenSearch와 RAG의 결합 패턴, 그리고 한국어 형태소 분석기가 결정적인 이유까지 다룬다.


0. Prerequisites

  • 10편 Dense Retrieval — 두 검색의 상보성.
  • 5편 청킹 — 청크가 BM25 토큰화 단위와 어떻게 만나는지.
  • 7편 메타데이터 — pre-filter 통합.

1. 학습 목표

  1. TF, IDF, BM25를 식과 직관으로 안다.
  2. Inverted Index가 빠른 이유를 안다.
  3. 한국어 형태소 분석기가 BM25에 결정적인 이유를 안다.
  4. BM25의 한계와 12편 Hybrid의 자리를 안다.

2. 핵심 요약

Sparse Retrieval은 벡터의 대부분이 0인 단어-빈도 벡터로 검색. TF-IDF빈도(TF) × 정보량(IDF). BM25(Robertson & Zaragoza 2009)는 TF-IDF에 문서 길이 정규화TF 포화를 추가한 사실상의 표준. Inverted Index는 단어 → 문서 ID 리스트의 사전으로 O(query 토큰 수) 속도. Elasticsearch / OpenSearch가 운영 표준 — RAG와는 BM25 candidate → Reranker 또는 Dense와 Hybrid. 한국어는 형태소 분석기(Mecab, Kiwi, Nori) 없이 BM25를 쓰면 조사·어미가 본문에서 잘려 매칭이 깨진다. 조사 분리 + 어간 추출이 한국어 BM25의 최소 위생.


3. 직관 — 같은 query, BM25가 잡고 Dense가 놓침

질문: "PR-2024-Q3 보고서의 5장 핵심 결론?"

같은 코퍼스에서:

  • Dense: "PR-2024-Q3"의 임베딩이 흐림(희귀 토큰). top-K가 비슷한 주제의 다른 보고서로 흩어짐.
  • BM25: "PR-2024-Q3"라는 정확한 토큰이 있는 청크에 매우 높은 점수. top-1이 목표 청크 자체.
diagram-1

반대 케이스: "통화 환산 기준" → BM25는 "환율 적용" 청크를 못 잡는다(10편 3장). 두 방식은 서로 다른 종류의 query에 강함.


4. 정의 — Sparse의 핵심 용어

용어 정의
TF (Term Frequency) 문서 내 단어의 출현 횟수
IDF (Inverse Document Frequency) 전체 코퍼스에서 드물수록 가중 높음
TF-IDF \(\text{TF} \times \text{IDF}\). 자주 등장하면서 코퍼스에선 드문 단어가 핵심
BM25 TF-IDF + 문서 길이 정규화 + TF 포화 곡선. 1990년대 후반부터 표준
Inverted Index 단어 → \([(doc\_id, tf), ...]\) 사전. 검색 시 query 토큰만 lookup
Stemming / Lemmatization 어간 추출 / 표제어 변환 (영어: running → run, 한국어: 갑니다 → 가다)
Morphological Analyzer 형태소 분석. 한국어·일본어 BM25의 전제
Stopwords "는", "이", "그", "the", "of" 등 정보량 낮은 단어. 보통 제거

5. 식 — BM25를 그대로 풀기

문서 \(D\)에 query \(Q = \{q_1, q_2, ..., q_n\}\)의 BM25 점수:

$$\text{BM25}(D, Q) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}$$

각 항의 의미:

  • \(f(q_i, D)\) = 문서 \(D\) 내 토큰 \(q_i\)의 빈도 (TF)
  • \(\text{IDF}(q_i) = \log\left(\frac{N - n_i + 0.5}{n_i + 0.5} + 1\right)\), \(N\) = 전체 문서 수, \(n_i\) = \(q_i\)가 등장하는 문서 수
  • \(|D|\) = 문서의 토큰 수, \(\text{avgdl}\) = 코퍼스 평균 토큰 수
  • \(k_1\) = TF 포화 파라미터 (보통 1.2~2.0). 클수록 TF가 점수에 더 크게 기여.
  • \(b\) = 길이 정규화 파라미터 (보통 0.75). 1에 가까울수록 짧은 문서 가중.

TF 포화의 직관: 같은 단어가 10번 → 11번이 되는 효과가 1번 → 2번보다 훨씬 작음. 무한 반복 spam을 막는다.

길이 정규화의 직관: 짧은 문서에서 단어가 1번 나오는 게 긴 문서에서 1번 나오는 것보다 더 중요.


6. 원리 워크스루 — BM25 직접 구현부터 ES 통합까지

6.1 rank_bm25로 한 줄 검색 (Python, 영어)

from rank_bm25 import BM25Okapi

docs = [
    "The quarterly sales report consolidates in USD after applying FX rates.",
    "Inventory SKU-2024-04: 30 units in the Incheon warehouse.",
    "Exception filings require department-head approval and form SEC-EX-04.",
]
tokenized = [d.lower().split() for d in docs]
bm25 = BM25Okapi(tokenized)

query = "SKU-2024-04 inventory".lower().split()
scores = bm25.get_scores(query)

6.2 Elasticsearch로 동일 검색

from elasticsearch import Elasticsearch
es = Elasticsearch("http://localhost:9200")

es.indices.create(index="rag", body={
    "mappings": {
        "properties": {
            "chunk_text": {"type": "text", "analyzer": "standard"},   # 영어 standard
            "version": {"type": "keyword"},
            "security_level": {"type": "keyword"},
        }
    }
})

for i, d in enumerate(docs):
    es.index(index="rag", id=f"c{i:03d}", document={
        "chunk_text": d,
        "version": "3.2",
        "security_level": "internal",
    })

results = es.search(index="rag", body={
    "query": {
        "bool": {
            "must": [{"match": {"chunk_text": "SKU-2024-04 inventory"}}],
            "filter": [
                {"term": {"version": "3.2"}},
                {"terms": {"security_level": ["public", "internal"]}},
            ]
        }
    },
    "size": 5
})

6.3 한국어 — Nori analyzer가 결정적

es.indices.create(index="rag_ko", body={
    "settings": {"analysis": {"analyzer": {
        "ko_analyzer": {
            "type": "custom",
            "tokenizer": "nori_tokenizer",
            "filter": ["nori_part_of_speech", "lowercase"],
        }
    }}},
    "mappings": {"properties": {
        "chunk_text": {"type": "text", "analyzer": "ko_analyzer"},
        "version": {"type": "keyword"},
    }}
})

es.index(index="rag_ko", id="c001", document={
    "chunk_text": "분기 매출 보고는 환율 적용 후 USD로 통합한다.",
    "version": "3.2",
})

results = es.search(index="rag_ko", body={
    "query": {"match": {"chunk_text": "환율 적용 통화 환산"}}
})

Nori는 내장 형태소 분석기nori_part_of_speech 필터로 조사·어미를 자동 제거. 이 한 줄이 한국어 BM25 품질의 절반 이상.

6.4 BM25를 Dense와 결합 (Hybrid 미리보기)

bm25_hits = es.search(...)
dense_hits = vectordb.similarity_search(...)

def rrf(rankings, k=60):
    scores = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
    return sorted(scores.items(), key=lambda x: -x[1])

7. 변형과 사례

7.1 BM25F — 다중 필드 가중

  • 무엇이 바뀌나: 한 문서가 제목·본문·태그 여러 필드를 가질 때 필드별 가중치.
  • 왜 쓰나: 제목 매칭이 본문 매칭보다 훨씬 강한 신호.
  • 무엇이 가능해졌나: 정밀한 필드 가중 검색.
  • 어디에 적합한가: 정형화된 문서(상품·블로그·논문).
  • 한계: 가중치 튜닝 필요. 평가셋(14편) 동반.

7.2 SPLADE — 학습 기반 sparse

  • 무엇이 바뀌나: 학습된 토큰 가중치가 BM25의 통계 가중을 대체.
  • 왜 쓰나: BM25보다 의미 신호를 일부 포착. 그러면서 sparse의 해석 가능성 유지.
  • 무엇이 가능해졌나: BM25와 Dense의 중간 지대.
  • 어디에 적합한가: 도구 통합(BGE-M3의 sparse 모드, SPLADE++).
  • 한계: 학습된 가중치라 Inverted Index가 가변 — 일반 ES보다 운영 복잡.

7.3 Elasticsearch + vector field — 단일 시스템 통합

  • 무엇이 바뀌나: ES 8.x부터 dense_vector 필드 + BM25를 같은 인덱스에서 동시 운영.
  • 왜 쓰나: 두 시스템 운영 부담 회피.
  • 무엇이 가능해졌나: BM25와 Dense를 한 query DSL로.
  • 어디에 적합한가: 기존 ES 운영 + 새 RAG 통합.
  • 한계: ES의 HNSW는 전용 벡터DB(Qdrant 등)보다 약간 약함. 대규모(\(>\) 100M)에선 분리 권장.

7.4 한국어 형태소 분석기 비교 — Mecab vs Kiwi vs Nori

  • 무엇이 바뀌나: 분석 정확도·속도·관리 부담.
  • 왜 쓰나: BM25의 토큰 단위가 분석기에 의존.
  • 무엇이 가능해졌나: 한국어 RAG의 검색 품질 결정.
  • 어디에 적합한가:
  • Mecab: 가장 빠름, C++. 사용자 사전 관리 부담.
  • Kiwi: Python 친화, 한국어 신조어·고유명사에 강함.
  • Nori: Elasticsearch 내장, 운영 단순.
  • 한계: 분석기 변경 시 전체 재인덱싱.

7.5 BM25 + 도메인 사전 — 고유명사 사전 등록

  • 무엇이 바뀌나: SKU 코드, 약어, 사내 용어를 형태소 분석기의 사용자 사전에 등록.
  • 왜 쓰나: 기본 분석기가 고유명사를 잘못 분리 막음.
  • 무엇이 가능해졌나: PR-2024-Q3 같은 코드가 하나의 토큰으로 유지.
  • 어디에 적합한가: 사내 RAG.
  • 한계: 사전 관리 부담. 신조어 발생 시 수동 업데이트.

8. 한계와 실패 양상

8.1 유사어·의미 매칭 부족

  • 왜 본질적인가: BM25는 문자열 일치. "환율 적용"과 "통화 환산"의 의미적 연결을 모름.
  • 진단: 유사어 query의 recall이 Dense보다 현저히 낮음.
  • 완화: Dense와 Hybrid(12편). 또는 동의어 사전을 ES analyzer에 등록.
  • 다음 편: 12편.

8.2 한국어 조사·어미로 토큰 깨짐

  • 왜 본질적인가: 분석기 없이는 "환율을"·"환율이"·"환율은"이 모두 다른 토큰. BM25가 세 토큰을 다른 단어로 셈.
  • 진단: 같은 명사의 다양한 활용형이 top-K를 분산.
  • 완화: 형태소 분석기로 어간 추출 (Mecab/Kiwi/Nori).
  • 다음 편: 외전 D(한국어 RAG).

8.3 고빈도 stopword 점수 오염

  • 왜 본질적인가: "the", "of", "는", "이" 같은 단어가 IDF로 어느 정도 억제되지만 완전 0은 아님. 짧은 query에서 잡음.
  • 진단: top-K가 stopword 매칭으로 흩어짐.
  • 완화: stopword 사전을 analyzer에 등록(ES stop filter).
  • 다음 편: 운영 위생 — 16편.

8.4 문서 길이 편차 큰 코퍼스

  • 왜 본질적인가: BM25의 \(b\) 파라미터가 길이 정규화하지만, 편차가 극단적(10토큰 vs 10K토큰 혼재)이면 정규화 곡선이 어느 쪽도 만족 못함.
  • 진단: 짧은 청크가 일관되게 top-K 상위 또는 하위로 쏠림.
  • 완화: 청크 크기를 균일화(5편), 또는 BM25 파라미터를 코퍼스별 튜닝.
  • 다음 편: 5편 cross-ref, 16편(튜닝).

8.5 오탈자·표기 차이 누락

  • 왜 본질적인가: "Pinecone"과 "pinecone"은 lowercase로 같지만 "AI"와 "에이아이"는 다름. BM25는 표면형 일치만.
  • 진단: 같은 의미의 다른 표기에서 누락.
  • 완화: analyzer의 folding / normalization 필터. 또는 ngram("sku20240Q3" 같은 매칭).
  • 다음 편: 12편 Hybrid.

8.5 Common Pitfalls

  • "BM25는 옛날 거니까 안 써도 된다." — 10편 3장, 본 편 3장. 고유명사·코드 query에 결정적.
  • "한국어도 ES default analyzer로." — 8.2절. Nori/Mecab/Kiwi 필수.
  • "BM25는 튜닝이 필요 없다." — \(k_1\), \(b\), stopword, analyzer 모두 코퍼스별 튜닝.
  • "Hybrid는 Dense + BM25 가중 합이면 끝." — 12편 RRF가 표준. 단순 가중 합은 점수 스케일 차이로 깨짐.
  • "형태소 사용자 사전 없이도 사내 약어 잘 잡힌다." — 7.5절. 사전 등록이 매칭률 결정.

9. 정리된 결론

Q1. BM25가 TF-IDF에 더한 두 가지 개선은?

문서 길이 정규화 (\(b\)), TF 포화 곡선 (\(k_1\)). Chapter: 5장.

Q2. BM25가 Dense보다 강한 query 종류는?

고유명사, 코드, 약어, 정확 표현, 희귀 어휘. 임베딩이 흐려지는 토큰들. Chapter: 3장, 8.1절(반대 케이스), 10편 8.1절.

Q3. Inverted Index가 빠른 이유는?

단어 → 문서 ID 리스트의 사전 구조. 검색이 query 토큰 수에 비례하고 전체 문서 수와 무관. Chapter: 4장, 5장.

Q4. 한국어 BM25의 최소 위생 두 가지는?

형태소 분석기로 조사 분리 + 어간 추출. 사용자 사전에 사내 고유명사 등록. Chapter: 6.3절, 7.5절, 8.2절.

Q5. BM25와 Dense를 결합하는 표준 방식은?

각자 top-K' 추출 후 RRF(Reciprocal Rank Fusion). 단순 가중 합은 점수 스케일 차이로 불안정. Chapter: 6.4절, 12편 cross-ref.


10. 추가 학습

1차 자료

  • Robertson, S., Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in IR 2009. (BM25 표준 정리)
  • Robertson, S. et al. Okapi at TREC-3. NIST Special Publication 1994. (BM25 원안)
  • Formal, T. et al. SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking. SIGIR 2021. arXiv:2107.05720.
  • Lin, J. A Few Brief Notes on DeepImpact, COIL, and a Conceptual Framework for Information Retrieval Techniques. arXiv:2106.14807 (2021). (Sparse-Dense 통합 관점)
  • Park, E. Mecab-ko: Korean fork of MeCab. (한국어 형태소 표준)

공식 docs

  • Elasticsearch BM25: https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-similarity.html
  • OpenSearch BM25: https://opensearch.org/docs/latest/query-dsl/full-text/match/
  • Elasticsearch Nori: https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-nori.html
  • rank-bm25 (Python): https://github.com/dorianbrown/rank_bm25
  • Kiwi: https://github.com/bab2min/Kiwi

보조 자료

  • 사용자 노트 10장 — BM25.
  • 사용자 노트 35장 6절 — Retriever Routing (BM25와 Dense의 분리·결합).

Cheat Sheet

항목 표준값 / 권장
\(k_1\) 1.2 ~ 2.0 (Elasticsearch 기본 1.2)
\(b\) 0.75 (대부분 코퍼스에 안정)
Stopword 영어 standard, 한국어는 analyzer 내장 사전
Analyzer (한국어) Nori (ES 내장) > Kiwi (Python 친화) > Mecab (속도 최고)
사용자 사전 사내 약어·고유명사 반드시 등록
candidate 수 top 50~200 (Hybrid·Reranker 이전)
Hybrid 결합 RRF (k=60 기본) — 12편

원칙 한 줄: BM25는 고유명사·코드의 정확 매칭, Dense는 의미 유사도 — 한국어는 형태소 분석기 + 사용자 사전이 BM25 품질의 절반.


Bridge — 다음 편

다음 — RAG 핵심 학습 (12/26) Hybrid Search와 Score Fusion.

Dense와 BM25를 어떻게 결합할 것인가. Reciprocal Rank Fusion(Cormack 2009), Weighted Fusion, Score Normalization, 그리고 가중치 실험의 표준 패턴까지. 두 검색의 분업이 RAG 정확도를 한 단계 끌어올린다.

시리즈 전체 안내: 시리즈 목차

댓글

이 블로그의 인기 게시물

"LLM 핵심 학습 (1/6) — 기본: 토큰화·임베딩·어텐션·위치 인코딩"

"LLM 핵심 학습 (2/6) — 파인튜닝: LoRA·QLoRA·증류·Adapter"

"ML 기초 학습 (1/9) — 머신러닝과 sklearn: 학습의 좌표계"