AI Sparkup

최신 AI 쉽게 깊게 따라잡기⚡

RAG 튜토리얼 – Python으로 BM25와 임베딩을 결합한 하이브리드 검색 구현하기

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로 가는 현실적인 순서다.

참고 자료



AI Sparkup 구독하기

최신 게시물 요약과 더 심층적인 정보를 이메일로 받아 보세요! (무료)