rag의 검색기가 임베딩(embedding) 유사도에만 의존하면 의미가 비슷한 문서는 잘 찾지만 제품명, 오류 코드, 고유 명칭처럼 철자가 중요한 단서를 놓칠 수 있다. 반대로 BM25 키워드 검색은 정확한 표현에 강하지만 동의어나 맥락을 충분히 잡지 못한다. 이 글은 두 검색 결과의 순위를 상호 순위 융합(Reciprocal Rank Fusion, RRF)으로 합치는 최소 Python 구현을 만든다.
준비
pip install rank-bm25 sentence-transformers torch예제에서는 작은 문서 목록을 메모리에서 검색한다. 실제 서비스에서는 문서 청크와 임베딩을 색인 시점에 저장하고, 질의 시점에는 쿼리 벡터만 계산한다.
documents = [
"OAuth refresh token expires after 30 days.",
"Reset the password when login attempts are blocked.",
"Rotate API keys after a credential exposure incident.",
"Authentication uses an access token and refresh token.",
]BM25 검색기
BM25는 쿼리에 등장한 단어가 문서에서 얼마나 중요한지 계산한다. 오류 코드나 API 이름처럼 정확한 문자열이 검색 성패를 좌우할 때 유용하다.
from rank_bm25 import BM25Okapi
tokens = [doc.lower().split() for doc in documents]
bm25 = BM25Okapi(tokens)
def search_bm25(query, top_k=None):
scores = bm25.get_scores(query.lower().split())
order = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
return order[:top_k] if top_k else order의미 검색기
의미 검색은 문서와 쿼리를 벡터로 바꾼 뒤 코사인 유사도로 정렬한다. 사용자가 “로그인 인증 정보 갱신”이라고 물어도 refresh token 문서를 찾는 식의 어휘 간극을 메운다.
from sentence_transformers import SentenceTransformer, util
model = SentenceTransformer("all-MiniLM-L6-v2")
doc_vectors = model.encode(documents, convert_to_tensor=True)
def search_semantic(query, top_k=None):
query_vector = model.encode(query, convert_to_tensor=True)
scores = util.cos_sim(query_vector, doc_vectors)[0]
order = scores.argsort(descending=True).tolist()
return order[:top_k] if top_k else order점수가 아니라 순위를 합친다
BM25 점수와 코사인 유사도는 척도가 다르므로 그대로 더하면 한 검색기가 부당하게 우세해진다. RRF는 각 결과 목록에서 문서의 순위만 사용한다.
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.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
def hybrid_search(query, top_k=3):
bm25_order = search_bm25(query)
semantic_order = search_semantic(query)
fused = rrf([bm25_order, semantic_order])
return [(documents[i], i) for i in fused[:top_k]]k=60은 상위 한두 결과가 전체 순위를 과도하게 지배하지 않도록 완화하는 일반적인 시작값이다. 운영 데이터에서는 정답 문서가 있는 평가 질의 세트를 만들고 k, top_k, 청크 크기를 함께 검증해야 한다.
RAG 파이프라인에 넣는 위치
질문
|-- BM25 검색 --------\
|-- 임베딩 검색 -------+--> RRF 후보 순위 --> 선택적 리랭커 --> LLM 컨텍스트하이브리드 검색은 생성 모델을 호출하기 전에 후보 문서를 찾는 단계에 들어간다. 문서 수가 늘면 다음 보강이 필요하다.
| 요구 사항 | 보강 방법 |
|---|---|
| 권한·기간별 검색 | rag-tutorial-metadata-search-python처럼 후보 생성 전에 필터 적용 |
| 후보가 너무 많음 | cross-encoder 또는 API 리랭커로 RRF 상위 결과 재정렬 |
| 정확한 출처 검증 | 응답에 사용한 청크 ID와 원문 URL을 함께 기록 |
| 대규모 색인 | OpenSearch, PostgreSQL/pgvector 또는 전용 벡터 DB에 검색기 분리 |
언제 하이브리드 검색이 특히 필요한가
- 사내 문서에서 티켓 ID, 오류 코드, 제품 버전을 찾아야 할 때
- 정책 문서처럼 표현은 조금 달라도 같은 의미를 묻는 질의가 많을 때
- 벡터 검색 결과가 그럴듯하지만 정확한 근거를 자주 놓칠 때
임베딩과 BM25는 경쟁 선택지가 아니다. 서로 다른 실패 모드를 가진 두 검색 신호를 먼저 융합하고, 필요하면 리랭킹과 근거 검증을 더하는 것이 프로덕션 RAG로 가는 현실적인 순서다.
참고 자료
- Implementing Hybrid Semantic-Lexical Search in RAG — Machine Learning Mastery (2026-05-25)