- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 순수 벡터 검색이 실패하는 순간
- 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 평가 방법을 상세히 다룬다.