RAG 심화 #3 하이브리드 검색 — 벡터와 키워드 결합

4 분 소요

2편에서 조각을 다듬었으니 이제 찾는 방법 차례입니다. 지금까지의 검색은 임베딩 유사도, 즉 의미 검색 하나였습니다. 의미 검색은 “환불"과 “결제 취소"를 같은 뜻으로 잇는 강점이 있지만, 약점도 뚜렷합니다. ORD-2026-0001 같은 코드, 제품명, 사람 이름처럼 정확히 그 문자열을 찾아야 하는 질문에서 빗나갑니다. 1편의 진단에서 고유 명사 질문만 유독 실패했다면, 이번 글이 그 처방입니다.

두 검색의 성격 #

벡터(의미) 검색키워드 검색
강점동의어, 다른 표현(“환불”=“결제 취소”)정확한 일치(코드, 고유 명사, 약어)
약점희귀 토큰이 임베딩에 묻힘표현이 다르면 못 찾음
대표 알고리즘임베딩 + 코사인 유사도BM25

하이브리드 검색(hybrid search)은 이 둘을 같이 돌려서 결과를 합치는 방식입니다. 어느 한쪽이 놓쳐도 다른 쪽이 잡아 주므로, 질문 유형에 따라 들쭉날쭉하던 검색이 고르게 안정됩니다.

BM25 키워드 검색 만들기 #

BM25는 키워드 검색의 표준 알고리즘입니다. 단어가 문서에 얼마나 자주 나오는지(빈도)와 그 단어가 얼마나 희귀한지(역문서빈도)를 함께 반영해 점수를 매깁니다. rank_bm25 패키지로 바로 쓸 수 있습니다.

terminal
pip install rank_bm25
bm25_search.py
from rank_bm25 import BM25Okapi

# 토큰화: 예제는 공백 분리. 한국어는 형태소 분석기(kiwipiepy 등)를 쓰면 품질이 올라간다.
tokenized = [chunk["text"].split() for chunk in chunks]
bm25 = BM25Okapi(tokenized)

def keyword_search(question: str, top_k: int = 20) -> list:
    scores = bm25.get_scores(question.split())
    ranked = sorted(range(len(chunks)), key=lambda i: scores[i], reverse=True)
    return ranked[:top_k]   # 조각 인덱스 목록

한국어에서 공백 분리는 “환불했어요"와 “환불"을 다른 토큰으로 봅니다. 데모는 이대로 동작하지만, 실제 서비스에서는 형태소 분석기로 토큰화하는 것이 BM25 품질에 큰 차이를 만듭니다.

RRF로 결과 합치기 #

벡터 검색과 BM25는 점수의 단위가 달라서(코사인 유사도 0〜1, BM25는 상한 없음) 점수를 직접 더할 수 없습니다. 그래서 점수 대신 순위로 합칩니다. 표준 방법이 RRF(Reciprocal Rank Fusion, 상호 순위 융합)입니다. 각 검색에서의 순위를 역수로 바꿔 더하는, 단순하지만 검증된 방식입니다.

rrf_fusion.py
def rrf(rankings: list, k: int = 60, top_k: int = 5) -> list:
    """rankings: 각 검색기가 반환한 조각 인덱스 목록들."""
    scores = {}
    for ranked in rankings:
        for rank, idx in enumerate(ranked):
            scores[idx] = scores.get(idx, 0) + 1 / (k + rank + 1)
    fused = sorted(scores, key=scores.get, reverse=True)
    return fused[:top_k]

def hybrid_search(question: str, top_k: int = 5) -> list:
    vec = vector_search_ids(question, top_k=20)     # 벡터 검색 상위 20
    kw = keyword_search(question, top_k=20)         # BM25 상위 20
    return [chunks[i] for i in rrf([vec, kw], top_k=top_k)]

상수 k=60은 관례적인 기본값으로, 상위권 순위 차이를 완만하게 반영하는 역할입니다. 대부분 그대로 두면 됩니다. 눈여겨볼 부분은 각 검색에서 최종 개수보다 훨씬 많은 20개씩을 가져온 뒤 융합한다는 점입니다. 양쪽 모두에서 중위권인 조각이 융합 후 상위로 올라올 수 있기 때문입니다.

언제 효과가 있고, 언제 없는가 #

하이브리드 검색의 효과는 질문 분포에 달려 있습니다.

  • 효과가 큰 경우 — 제품 코드, 에러 코드, 사람·제품 이름, 사내 약어가 들어간 질문이 많을 때. 이런 토큰은 임베딩 공간에서 변별력이 약해 벡터 검색이 자주 놓칩니다.
  • 효과가 작은 경우 — 질문이 대부분 서술형이고 문서와 어휘가 다를 때. 이때는 키워드 검색이 보태는 것이 적고, 오히려 4편의 쿼리 변환이 더 큰 처방입니다.

그래서 도입 판단도 1편의 골든셋으로 합니다. 골든셋을 “고유 명사형 질문"과 “서술형 질문"으로 나눠 측정하면, 하이브리드가 어느 질문군을 얼마나 끌어올렸는지가 그대로 보입니다.

벡터 데이터베이스를 쓰고 있다면 #

위 구현은 원리를 보여 주는 코드지만, 실제로는 직접 만들 필요가 없는 경우도 많습니다. LLM 앱 개발 실전 7편에서 소개한 벡터 데이터베이스 다수(Qdrant, pgvector 조합 등)가 키워드 검색이나 하이브리드 검색을 기능으로 제공합니다. 원리를 알고 있으면 그 기능들의 옵션이 무엇을 조절하는지 읽을 수 있고, 직접 구현과 내장 기능 중 무엇을 쓸지도 판단할 수 있습니다.

흔히 걸려 넘어지는 곳 #

  • 점수를 그대로 더한다 — 코사인 유사도와 BM25 점수는 단위가 달라서 직접 더하면 한쪽이 지배합니다. 순위 기반(RRF)으로 합치거나 정규화를 거칩니다.
  • 융합 전 후보를 좁게 가져온다 — 각 검색에서 최종 개수만큼만 가져와 융합하면 융합의 의미가 줄어듭니다. 후보는 넉넉히(3〜4배) 가져와서 합칩니다.
  • 토큰화를 잊는다 — BM25의 품질은 토큰화가 절반입니다. 한국어를 공백으로만 자르면 조사가 붙은 단어를 못 찾습니다. 형태소 분석기를 검토합니다.

마무리 #

이번 글에서는 벡터 검색과 키워드 검색을 결합했습니다.

  • 의미 검색은 동의어에 강하고 고유 명사에 약하며, BM25는 그 반대입니다. 하이브리드로 결합하면 두 방식이 서로 부족한 부분을 보완합니다.
  • 점수 단위가 다른 두 결과는 RRF로, 순위 기반으로 융합합니다. 후보는 넉넉히 가져와 합칩니다.
  • 효과는 질문 분포에 달려 있으니, 골든셋을 질문 유형별로 나눠 측정해 도입을 판단합니다.

검색이 가져오는 후보의 질은 올라갔습니다. 다음은 질문 자체와 후보의 순서를 다듬을 차례입니다. 다음 글인 “RAG 심화 #4 쿼리 변환과 리랭킹"에서 검색의 앞단과 뒷단을 마저 보강합니다.

X