왜 벡터 검색만으로는 부족한가

RAG 파이프라인의 검색 품질은 대부분 임베딩 기반 벡터 검색에 의존한다. 벡터 검색은 의미가 비슷한 문장을 잘 찾지만, 정확한 키워드 일치에는 약하다. 제품 코드(SKU-90213), 에러 코드, 사람 이름, 법조문 번호처럼 "글자 그대로" 매칭돼야 하는 질의에서 임베딩은 오히려 엉뚱한 유사 문서를 상위로 올린다.

반대로 BM25 같은 어휘 기반 검색은 키워드 일치에 강하지만, 동의어나 문맥적 의미를 못 잡는다. "해지 방법"으로 검색하면 "구독 취소 절차" 문서를 놓친다. 두 방식은 실패 지점이 서로 다르기 때문에, 결합하면 서로의 빈틈을 메운다. 이것이 하이브리드 검색의 핵심 동기다.

두 검색 방식의 특성 비교

항목BM25 (어휘)벡터 (의미)
강점정확한 키워드, 희귀 토큰동의어, 문맥, 의역
약점동의어·오탈자고유명사·코드값
인덱싱 비용낮음(역색인)높음(임베딩 생성)
점수 척도비정규화(무한대)0~1 유사도

점수 결합 전략: RRF vs 가중합

두 검색 결과를 합칠 때 가장 큰 함정은 점수 척도가 다르다는 점이다. BM25 점수는 상한이 없고, 코사인 유사도는 0~1이다. 이를 그대로 더하면 한쪽이 지배한다. 정규화 후 가중합(α·vec + (1-α)·bm25)도 가능하지만, 척도 튜닝이 까다롭다.

실무에서는 순위(rank)만 사용하는 RRF(Reciprocal Rank Fusion)가 안정적이다. 점수 절댓값을 무시하고 각 리스트에서의 등수만 보므로 척도 문제가 사라진다.

from collections import defaultdict

def reciprocal_rank_fusion(result_lists, k=60):
    # result_lists: 각 검색기의 [doc_id, ...] (점수순 정렬)
    scores = defaultdict(float)
    for docs in result_lists:
        for rank, doc_id in enumerate(docs):
            scores[doc_id] += 1.0 / (k + rank + 1)
    return sorted(scores, key=scores.get, reverse=True)

bm25_top = ["d3", "d7", "d1", "d9"]
vec_top  = ["d1", "d3", "d5", "d7"]
print(reciprocal_rank_fusion([bm25_top, vec_top]))
# d3, d1 이 양쪽 상위 → 최종 상위로 수렴

k는 상위권 문서의 영향력을 조절하는 상수로, 60이 관례적 기본값이다. 값이 작을수록 1~2위 문서에 더 큰 가중치가 실린다.

Elasticsearch/OpenSearch로 구현하기

별도 오케스트레이션 없이 검색 엔진 한 곳에서 처리하고 싶다면, BM25 match와 벡터 knn을 하나의 쿼리로 묶고 엔진이 RRF로 융합하게 할 수 있다.

POST /docs/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        { "standard": {
            "query": { "match": { "content": "구독 해지 방법" } } } },
        { "knn": {
            "field": "content_vector",
            "query_vector": [0.12, -0.03, ...],
            "k": 50, "num_candidates": 200 } }
      ],
      "rank_window_size": 50,
      "rank_constant": 60
    }
  }
}

이 방식은 두 후보 집합을 각각 rank_window_size만큼 뽑은 뒤 rank_constant(위 코드의 k)로 융합한다. 애플리케이션 코드가 단순해지지만, 융합 로직을 커스터마이징하기 어렵다는 제약이 있다.

재순위(Reranker)와의 조합

하이브리드 검색은 "리콜(누락 없이 후보 확보)"을 담당하고, 정밀도는 크로스 인코더 리랭커에게 맡기는 2단계 구성이 효과적이다. 하이브리드로 상위 50개를 넉넉히 뽑고, 리랭커로 상위 5~8개만 추려 LLM 컨텍스트에 넣는다.

# 1단계: 하이브리드로 넓게 회수
candidates = reciprocal_rank_fusion([bm25_top50, vec_top50])[:50]

# 2단계: 크로스 인코더로 정밀 재정렬
pairs = [(query, doc_text[d]) for d in candidates]
rerank_scores = cross_encoder.predict(pairs)
final = [d for _, d in sorted(zip(rerank_scores, candidates), reverse=True)]
context = final[:6]

주의점과 튜닝 포인트

  • 토크나이저 일치: 한국어는 형태소 분석기(nori 등) 설정에 따라 BM25 품질이 크게 갈린다. 색인과 질의의 분석기를 반드시 동일하게 맞춘다.
  • 후보 수 균형: 한쪽에서만 100개, 다른 쪽에서 10개를 뽑으면 융합이 편향된다. 두 검색기의 top-N을 비슷하게 유지한다.
  • 평가 데이터 확보: α나 k를 감으로 정하지 말고, 실제 질의-정답 쌍으로 Recall@k, nDCG를 측정해 결정한다. 도메인마다 최적값이 다르다.
  • 지연 시간: 벡터 검색 + BM25 + 리랭커는 각 단계가 지연을 더한다. num_candidates와 후보 수를 실측 기반으로 조정한다.

하이브리드 검색은 마법이 아니라 두 실패 모드를 상쇄하는 공학적 타협이다. 도입 후에는 반드시 평가셋으로 이득을 수치로 확인하는 습관이 중요하다.