Split View: Hybrid Search 완전 가이드: BM25와 벡터 검색을 합치면 RAG가 달라진다
Hybrid Search 완전 가이드: BM25와 벡터 검색을 합치면 RAG가 달라진다
- 순수 벡터 검색이 실패하는 순간
- BM25란 무엇인가
- 두 검색의 약점
- RRF: 두 검색을 합치는 방법
- LangChain으로 실전 구현
- 성능 비교 벤치마크
- 언제 어떤 검색이 더 유리한가
- 실전 팁: BM25 토크나이저 최적화
- 마무리
순수 벡터 검색이 실패하는 순간
RAG를 처음 구축했을 때 가장 당황스러운 순간 중 하나는, 벡터 검색이 명백히 맞는 문서를 못 찾아오는 경우다.
예를 들어보자. 제품 DB에 "iPhone 15 Pro Max 256GB 저장 용량" 문서가 있고, 사용자가 "iPhone 15 Pro Max 256GB 재고 있어요?"라고 물었다. 이 쿼리는 벡터 검색으로는 의외로 잘 못 찾는다.
왜? 의미적으로 "iPhone 14 Pro Max 512GB"도 비슷하게 가깝기 때문이다. 벡터 공간에서 "Pro Max 스마트폰"이라는 의미 클러스터에 모두 들어가 있다.
하지만 사용자는 정확히 256GB짜리 15를 물어본 것이다. 이 숫자와 모델명이 정확히 매칭되어야 한다.
이게 순수 벡터 검색의 근본적인 한계다: exact keyword match를 요구하는 경우에 약하다. 제품 코드, 모델 번호, 고유명사, 날짜, 버전 번호 등이 모두 이 범주에 들어간다.
BM25란 무엇인가
BM25(Okapi BM25)는 1994년 Robertson et al.이 개발한 키워드 검색 알고리즘이다. 30년이 지난 지금도 여전히 최강의 키워드 검색으로 통한다. Elasticsearch, Solr, Apache Lucene의 기본 검색 엔진이 BM25 기반이다.
수식을 보면 이해가 빠르다:
score(D, Q) = Σ IDF(qi) × [f(qi, D) × (k1 + 1)] / [f(qi, D) + k1 × (1 - b + b × |D| / avgdl)]
여기서:
qi = 쿼리의 각 단어
f(qi, D) = 문서 D에서 단어 qi의 출현 빈도 (Term Frequency)
|D| = 문서 D의 길이
avgdl = 전체 문서의 평균 길이
k1, b = 조율 파라미터 (일반적으로 k1=1.5, b=0.75)
IDF(qi) = Inverse Document Frequency = log((N - df + 0.5) / (df + 0.5))
BM25의 핵심은 두 가지다:
1. TF Saturation: 단어가 많이 나올수록 점수가 오르지만, 로그 곡선처럼 포화된다. 1번 나오는 것과 100번 나오는 것의 차이가 선형적이지 않다.
2. Document Length Normalization: 긴 문서에서 단어가 많이 나오는 건 당연하다. BM25는 문서 길이로 정규화해서 불공평한 이점을 제거한다.
이 두 가지가 단순한 TF-IDF보다 BM25가 훨씬 잘 작동하는 이유다.
두 검색의 약점
벡터 검색의 약점:
- 정확한 키워드 매칭에 약함 (모델 번호, 제품 코드)
- "semantic drift": 유사하지만 다른 개념을 가져옴
- 희귀어, 신조어, 약어에 취약
BM25의 약점:
- 동의어를 이해 못 함 ("automobile" vs "car")
- 문맥을 이해 못 함 (단어 순서 무시)
- 오타에 취약
- 다국어 처리에 약함
이 약점이 서로 보완된다. 벡터 검색이 의미를 잡고, BM25가 정확한 키워드를 잡는다. 두 결과를 합치면 둘 중 하나만 쓸 때보다 훨씬 좋은 결과가 나온다.
RRF: 두 검색을 합치는 방법
두 검색 결과를 합치는 가장 간단하고 효과적인 방법이 **RRF(Reciprocal Rank Fusion)**이다.
RRF의 아이디어는 단순하다: 각 결과 목록에서의 순위를 역수로 변환해서 더한다.
def reciprocal_rank_fusion(results_list: list, k: int = 60) -> list:
"""
여러 순위 결과 목록을 RRF로 결합.
RRF 점수 = Σ 1/(k + rank_i)
k=60은 낮은 순위 아이템의 영향을 완화하는 상수
Args:
results_list: 각 검색 결과 목록의 리스트 (doc_id 순서)
k: 순위 안정화 상수 (기본값 60)
Returns:
RRF 점수 기준 정렬된 doc_id 목록
"""
scores: dict = {}
for results in results_list:
for rank, doc_id in enumerate(results, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
# 구체적인 예시로 이해하기
vector_results = ["doc_A", "doc_C", "doc_B"] # 벡터 검색 결과
bm25_results = ["doc_B", "doc_A", "doc_D"] # BM25 키워드 검색 결과
# 점수 계산:
# doc_A: 1/(60+1) + 1/(60+2) = 0.01639 + 0.01613 = 0.03252
# doc_B: 1/(60+3) + 1/(60+1) = 0.01587 + 0.01639 = 0.03226
# doc_C: 1/(60+2) = 0.01613
# doc_D: 1/(60+2) = 0.01613
fused = reciprocal_rank_fusion([vector_results, bm25_results])
print(fused)
# ['doc_A', 'doc_B', 'doc_C', 'doc_D']
# doc_A가 두 검색 모두에서 상위권이라 1위
# doc_B는 BM25에서 1위지만 벡터에서 3위 → 2위
k=60인 이유: 이 값은 실험적으로 결정된 것으로, 낮은 순위에 있는 아이템의 영향을 적절히 줄여준다. 너무 작으면 낮은 순위 아이템이 과도한 점수를 받고, 너무 크면 모든 아이템이 비슷해진다.
LangChain으로 실전 구현
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
# 가정: docs는 langchain Document 객체 리스트
embedding_model = OpenAIEmbeddings()
# BM25 retriever 생성
bm25_retriever = BM25Retriever.from_documents(
docs,
k=5 # 상위 5개 반환
)
# 벡터 스토어 생성 및 retriever 설정
vectorstore = FAISS.from_documents(docs, embedding_model)
vector_retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5}
)
# 앙상블 리트리버: 두 검색을 가중치로 결합
hybrid_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.4, 0.6] # BM25 40%, 벡터 60%
)
# 사용
query = "iPhone 15 Pro Max 256GB 재고"
results = hybrid_retriever.get_relevant_documents(query)
가중치 선택 기준:
- 정확한 키워드 중요 (제품 코드, 모델명): BM25 비중 높임 (0.5-0.6)
- 의미적 이해 중요 (고객 질의, 자연어): 벡터 비중 높임 (0.6-0.7)
- 일반적인 기업 문서: 0.4/0.6 또는 0.5/0.5 시작
Elasticsearch로 프로덕션 구현
LangChain의 EnsembleRetriever는 간단하지만, 대규모 프로덕션에서는 Elasticsearch가 더 적합하다. ES는 BM25와 벡터 검색을 모두 네이티브로 지원한다.
from elasticsearch import Elasticsearch
from langchain_community.vectorstores import ElasticsearchStore
es_client = Elasticsearch(["http://localhost:9200"])
# Elasticsearch에서 hybrid search 쿼리
def hybrid_search_es(query: str, query_embedding: list, k: int = 5):
response = es_client.search(
index="documents",
body={
"query": {
"bool": {
"should": [
# BM25 키워드 검색
{
"match": {
"content": {
"query": query,
"boost": 0.4
}
}
}
]
}
},
# KNN 벡터 검색 (ES 8.x+)
"knn": {
"field": "content_vector",
"query_vector": query_embedding,
"k": k,
"num_candidates": 100,
"boost": 0.6
},
"size": k
}
)
return response["hits"]["hits"]
성능 비교 벤치마크
BEIR(Benchmarking IR) 데이터셋 기준 연구 결과:
| 검색 방법 | nDCG@10 (평균) | 특히 강한 영역 |
|---|---|---|
| BM25만 | 43.0 | 키워드 매칭, 사실 검색 |
| 벡터만 | 47.8 | 의미 유사성, 다국어 |
| Hybrid (RRF) | 52.1 | 전반적으로 균형잡힘 |
Hybrid가 평균 21% 더 높은 nDCG@10을 보인다. 특히 어떤 쿼리가 들어올지 예측하기 어려운 일반 목적 RAG 시스템에서 차이가 크다.
개인적 경험으로는, 프로덕션 서비스에서 벡터 단독에서 Hybrid로 전환했을 때 "관련 없는 답변" 고객 불만이 약 30% 줄었다.
언제 어떤 검색이 더 유리한가
| 상황 | 권장 방법 | 이유 |
|---|---|---|
| 제품 카탈로그 검색 | Hybrid (BM25 강조) | 모델 번호, 사양이 정확해야 함 |
| FAQ 챗봇 | Hybrid (벡터 강조) | 다양한 표현으로 질문 |
| 법률 문서 검색 | Hybrid (BM25 강조) | 정확한 법률 용어 매칭 필요 |
| 감성 분석 기반 검색 | 벡터 단독 | 키워드보다 의미가 중요 |
| 코드 검색 | Hybrid (BM25 강조) | 함수명, 변수명 정확 매칭 |
| 다국어 문서 | 벡터 단독 또는 다국어 BM25 | 언어간 의미 검색 필요 |
실전 팁: BM25 토크나이저 최적화
한국어 문서를 BM25로 검색할 때 중요한 점: BM25의 기본 토크나이저는 공백 기반이라 한국어에 약하다.
# 한국어 형태소 분석기와 BM25 결합
from konlpy.tag import Okt
okt = Okt()
def korean_tokenizer(text: str) -> list:
# 형태소 분석으로 토크나이징
tokens = okt.morphs(text, stem=True) # 어간 추출
return tokens
# BM25 생성 시 커스텀 토크나이저 적용
bm25_retriever = BM25Retriever.from_documents(
docs,
preprocess_func=korean_tokenizer,
k=5
)
영어 문서라면 기본 토크나이저도 괜찮지만, 기술 문서라면 nltk의 word_tokenize나 도메인 특화 토크나이저를 고려하자.
마무리
Hybrid Search는 RAG 시스템에서 가장 빠르게 성능을 높일 수 있는 방법 중 하나다. 구현 복잡도 대비 성능 향상이 크다.
단계적 접근:
- 먼저 벡터 검색으로 기본 RAG 구축
- RAGAS로 현재 성능 측정
- Hybrid Search(BM25 + 벡터)로 전환
- 다시 측정해서 개선 확인
측정 없이 "느낌"으로 좋아졌다고 판단하면 안 된다. 다음 포스트에서 RAGAS를 사용한 정량적 RAG 평가 방법을 상세히 다룬다.
Hybrid Search Guide: Combining BM25 and Vector Search for Better RAG
- When Pure Vector Search Fails
- What Is BM25?
- The Weaknesses of Each Approach
- RRF: The Method for Combining Both Results
- LangChain Implementation
- Performance Benchmark
- When Each Approach Wins
- Production Tip: Tuning the Tokenizer
- Full Hybrid RAG Pipeline
- Conclusion
When Pure Vector Search Fails
One of the most disorienting moments when you first build a RAG system is watching vector search fail to find a document that is obviously correct.
Here's a real example. Your product database has a document titled "iPhone 15 Pro Max 256GB Storage Capacity." A user asks: "Is the iPhone 15 Pro Max 256GB in stock?"
Vector search might not rank this as the top result. Why? Because "iPhone 14 Pro Max 512GB" is semantically very close in the embedding space. Both are in the "Pro Max smartphone" meaning cluster.
But the user asked specifically about the 256GB model of the 15. Those exact numbers and model identifiers need to match precisely.
This is the fundamental limitation of pure vector search: it's weak at exact keyword matching. Product codes, model numbers, proper nouns, dates, version numbers — all of these fall into this category.
What Is BM25?
BM25 (Okapi BM25) is a keyword search algorithm developed by Robertson et al. in 1994. Thirty years later, it remains the gold standard for keyword retrieval. Elasticsearch, Solr, and Apache Lucene all use BM25 as their default search algorithm.
The formula makes the logic clear:
score(D, Q) = Σ IDF(qi) × [f(qi, D) × (k1 + 1)] / [f(qi, D) + k1 × (1 - b + b × |D| / avgdl)]
where:
qi = each query term
f(qi, D) = term frequency of qi in document D
|D| = document length
avgdl = average document length across corpus
k1, b = tuning parameters (typically k1=1.5, b=0.75)
IDF(qi) = log((N - df + 0.5) / (df + 0.5))
BM25 has two key innovations:
1. TF Saturation: More occurrences increase the score, but with diminishing returns (a logarithmic curve). The difference between appearing once and 100 times is not linear.
2. Document Length Normalization: It's obvious that longer documents will have higher term frequencies. BM25 normalizes by document length to remove this unfair advantage.
These two properties explain why BM25 significantly outperforms simple TF-IDF scoring.
The Weaknesses of Each Approach
Vector search weaknesses:
- Poor at exact keyword matching (model numbers, product codes)
- "Semantic drift": retrieves semantically similar but incorrect content
- Vulnerable to rare terms, neologisms, abbreviations
BM25 weaknesses:
- No synonym understanding ("automobile" vs "car")
- No contextual understanding (word order ignored)
- Vulnerable to typos
- Weak multilingual support
These weaknesses complement each other perfectly. Vector search captures meaning; BM25 captures exact keywords. Combining the two results are significantly better than either alone.
RRF: The Method for Combining Both Results
The simplest and most effective way to combine two ranked result lists is RRF (Reciprocal Rank Fusion).
The idea is elegant: convert each result's rank to its reciprocal and sum them up.
def reciprocal_rank_fusion(results_list: list, k: int = 60) -> list:
"""
Combine multiple ranked result lists using RRF.
RRF score = Σ 1/(k + rank_i)
k=60 is a constant that dampens the impact of low-ranked items.
Args:
results_list: list of result lists (each is a list of doc IDs in rank order)
k: rank stabilization constant (default 60)
Returns:
List of doc IDs sorted by descending RRF score
"""
scores: dict = {}
for results in results_list:
for rank, doc_id in enumerate(results, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
# Concrete example
vector_results = ["doc_A", "doc_C", "doc_B"] # from embedding search
bm25_results = ["doc_B", "doc_A", "doc_D"] # from BM25 keyword search
# Score calculation:
# doc_A: 1/(60+1) + 1/(60+2) = 0.01639 + 0.01613 = 0.03252
# doc_B: 1/(60+3) + 1/(60+1) = 0.01587 + 0.01639 = 0.03226
# doc_C: 1/(60+2) = 0.01613
# doc_D: 1/(60+2) = 0.01613
fused = reciprocal_rank_fusion([vector_results, bm25_results])
print(fused)
# ['doc_A', 'doc_B', 'doc_C', 'doc_D']
# doc_A ranks first because it's highly ranked in both lists
# doc_B ranks second: #1 in BM25 but #3 in vector
Why k=60? This value was determined experimentally. It's designed to appropriately dampen the contribution of lower-ranked items. Too small and low-ranked items get excessive credit; too large and all items become indistinguishable.
LangChain Implementation
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
# Assume: docs is a list of LangChain Document objects
embedding_model = OpenAIEmbeddings()
# Create BM25 retriever
bm25_retriever = BM25Retriever.from_documents(
docs,
k=5 # return top 5
)
# Create vector store and retriever
vectorstore = FAISS.from_documents(docs, embedding_model)
vector_retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5}
)
# EnsembleRetriever: combines both searches with weights
hybrid_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.4, 0.6] # BM25 40%, Vector 60%
)
# Usage
query = "iPhone 15 Pro Max 256GB in stock"
results = hybrid_retriever.get_relevant_documents(query)
Weight selection guidelines:
- Exact keywords matter (product codes, model numbers): increase BM25 weight (0.5-0.6)
- Semantic understanding matters (customer queries, natural language): increase vector weight (0.6-0.7)
- General enterprise documents: start with 0.4/0.6 or 0.5/0.5
Production Implementation with Elasticsearch
LangChain's EnsembleRetriever is convenient but runs both searches locally. For large-scale production, Elasticsearch is more appropriate — it natively supports both BM25 and vector search.
from elasticsearch import Elasticsearch
es_client = Elasticsearch(["http://localhost:9200"])
def hybrid_search_es(query: str, query_embedding: list, k: int = 5):
response = es_client.search(
index="documents",
body={
"query": {
"bool": {
"should": [
# BM25 keyword search
{
"match": {
"content": {
"query": query,
"boost": 0.4
}
}
}
]
}
},
# KNN vector search (ES 8.x+)
"knn": {
"field": "content_vector",
"query_vector": query_embedding,
"k": k,
"num_candidates": 100,
"boost": 0.6
},
"size": k
}
)
return response["hits"]["hits"]
Performance Benchmark
Research results based on the BEIR (Benchmarking IR) dataset:
| Search Method | nDCG@10 (average) | Strongest Domain |
|---|---|---|
| BM25 only | 43.0 | Keyword matching, factual retrieval |
| Vector only | 47.8 | Semantic similarity, multilingual |
| Hybrid (RRF) | 52.1 | Balanced across all types |
Hybrid achieves roughly 21% higher nDCG@10 on average. The gap is especially large for general-purpose RAG systems where you can't predict what queries will come in.
From personal production experience: switching from vector-only to hybrid search reduced "irrelevant answer" complaints from customers by roughly 30%.
When Each Approach Wins
| Situation | Recommended | Reason |
|---|---|---|
| Product catalog search | Hybrid (BM25-heavy) | Model numbers and specs must be exact |
| FAQ chatbot | Hybrid (vector-heavy) | Questions come in diverse phrasings |
| Legal document search | Hybrid (BM25-heavy) | Exact legal terminology matching required |
| Sentiment-based search | Vector only | Meaning matters more than keywords |
| Code search | Hybrid (BM25-heavy) | Function names and variables need exact matching |
| Cross-language documents | Vector or multilingual BM25 | Cross-lingual semantic search needed |
Production Tip: Tuning the Tokenizer
BM25's default tokenizer is whitespace-based. For technical documentation, you can significantly improve results with a better tokenizer:
import nltk
from nltk.tokenize import word_tokenize
from nltk.stem import PorterStemmer
from nltk.corpus import stopwords
nltk.download('punkt')
nltk.download('stopwords')
stemmer = PorterStemmer()
stop_words = set(stopwords.words('english'))
def technical_tokenizer(text: str) -> list:
# Tokenize
tokens = word_tokenize(text.lower())
# Remove stopwords and apply stemming
tokens = [
stemmer.stem(token)
for token in tokens
if token.isalnum() and token not in stop_words
]
return tokens
# Apply custom tokenizer when creating BM25 retriever
bm25_retriever = BM25Retriever.from_documents(
docs,
preprocess_func=technical_tokenizer,
k=5
)
For code search specifically, you might want to preserve camelCase and snake_case splitting: getUserById → ["get", "user", "by", "id"].
Full Hybrid RAG Pipeline
Putting it all together in a complete RAG pipeline:
from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
# Initialize all components
bm25_retriever = BM25Retriever.from_documents(docs, k=5)
vectorstore = FAISS.from_documents(docs, embedding_model)
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
hybrid_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.4, 0.6]
)
# Build the RAG chain
llm = ChatOpenAI(model="gpt-4o", temperature=0)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=hybrid_retriever,
return_source_documents=True
)
# Run a query
response = qa_chain.invoke({"query": "iPhone 15 Pro Max 256GB price"})
print(response["result"])
print("\nSources:")
for doc in response["source_documents"]:
print(f" - {doc.metadata.get('source', 'unknown')}")
Conclusion
Hybrid Search is one of the highest-ROI improvements you can make to a RAG system. The performance gain relative to implementation complexity is substantial.
Recommended approach:
- Build baseline RAG with vector search
- Measure with RAGAS (context precision, context recall)
- Switch to Hybrid Search (BM25 + vector)
- Measure again and verify improvement
Don't rely on gut feeling to determine if things improved. The next post covers quantitative RAG evaluation with RAGAS in detail.