RAG Python Qdrant BM25 Hybrid Search RRF

[RAG Playground] Quest 4: Hybrid Search로 검색 품질 끌어올리기

지난 Quest 3에서 Naive RAG 파이프라인을 완성하고, “서면에서 할인되는 병원”을 검색했을 때 “서면”이라는 지역명을 완전히 무시하는 문제를 확인했습니다. 하지만 Dense 검색만으로는 고유명사나 키워드를 정확히 잡아내기 어렵다는 한계가 명확했어요

이번 퀘스트에서는 BM25 키워드 검색 + 벡터 검색을 결합한 Hybrid Search를 구현해서 이 한계를 넘어보겠습니다 🫡

왜 Hybrid Search인가?

Quest 3에서 확인한 문제를 다시 짚어보면:

검색 방식장점한계
Dense (벡터)“할인되는 병원” 같은 의미적 유사성 포착”서면” 같은 정확한 키워드 매칭 실패
Sparse (BM25)“서면”, “부산진구” 같은 키워드 정확히 매칭”의료기관” → “병원” 같은 동의어 처리 불가

둘 다 각자의 강점이 명확하기 때문에, 결합하면 서로의 약점을 보완할 수 있습니다. 이걸 Hybrid Search라고 부르고, 실제로 프로덕션 RAG 시스템에서 거의 표준처럼 쓰이고 있어요. 맞죠..? 저도 열심히 찾아보고 공부한 건데..

다행히 Qdrant는 Sparse VectorSparse Vector대부분의 차원이 0이고 일부 차원만 값을 갖는 벡터입니다. BM25 같은 키워드 기반 검색에서 단어 등장 여부와 빈도를 표현할 때 사용됩니다.를 네이티브로 지원하기 때문에, 별도의 Elasticsearch 같은 인프라 없이 하나의 DB에서 dense + sparse 검색을 동시에 처리할 수 있습니다 👍


Step 1: BM25 키워드 검색 인덱스 구축

BM25란?

BM25BM25Best Match 25의 약자로, TF-IDF를 개선한 키워드 기반 문서 랭킹 알고리즘입니다. 단어 빈도(TF), 역문서 빈도(IDF), 문서 길이 정규화를 결합해 검색 관련성을 계산합니다.는 전통적인 키워드 기반 검색 알고리즘입니다. 핵심 아이디어는 단순해요:

  • 문서에 쿼리 단어가 많이 등장할수록 점수가 높음 ⬆️ (TF)
  • 전체 문서에서 드물게 등장하는 단어일수록 가중치가 높음 ⬆️ (IDF)
  • 문서가 너무 길면 패널티를 줌 ⬇️ (Length Normalization)

이걸 벡터로 표현하면 Sparse Vector가 됩니다 ! 대부분의 차원이 0이고, 쿼리에 매칭되는 단어 차원만 값이 있는 겁니다

Qdrant Cloud Server-Side Inference

처음엔 클라이언트에서 직접 BM25 인코딩을 해야 하나 싶었는데, 다행히 Qdrant Cloud는 서버 사이드 inference를 지원합니다. cloud_inference=True 옵션 하나면 BM25 토크나이징을 클라이언트가 아닌 Qdrant Cloud 서버에서 알아서 처리해줘요. (로컬에 fastembed 같은 거 안 깔아도 됩니다 🎉)

def get_qdrant_client() -> QdrantClient:
    return QdrantClient(
        url=QDRANT_URL,
        api_key=QDRANT_API_KEY,
        timeout=60,
        cloud_inference=True,  # 서버 사이드 BM25 활성화
    )

하이브리드 컬렉션 생성

기존 family_card_shops 컬렉션은 그대로 두고, hybrid search 전용 컬렉션 family_card_shops_hybrid를 별도로 만들었습니다. Named Vector를 사용해서 "dense""sparse" 두 벡터를 하나의 포인트에 담겠습니다 :)

DENSE_VECTOR_NAME = "dense"
SPARSE_VECTOR_NAME = "sparse"
BM25_OPTIONS = {"tokenizer": "multilingual", "language": "none"}

BM25_OPTIONS는 Qdrant Cloud BM25 엔진 설정입니다:

  • tokenizer: "multilingual" — 기본값은 영어용(공백 기반) 토크나이저인데, 한국어는 단어 사이 공백이 불규칙해서 CJK 문자 단위로 분할하는 multilingual을 사용해야 합니다
  • language: "none" — 영어 전용 스테밍(어간 추출)과 불용어 제거를 끕니다. 영어면 “running” → “run”으로 변환하는데, 한국어엔 이런 규칙이 없어서 오히려 방해됩니다

한 줄 요약: 한국어 텍스트를 제대로 토큰화하되, 영어용 전처리는 끄는 설정입니다 👍

def get_or_create_hybrid_collection(client, collection_name):
    client.create_collection(
        collection_name=collection_name,
        vectors_config={
            DENSE_VECTOR_NAME: VectorParams(
                size=EMBEDDING_DIMENSION,
                distance=Distance.COSINE,
            ),
        },
        sparse_vectors_config={
            SPARSE_VECTOR_NAME: SparseVectorParams(modifier=Modifier.IDF),
        },
    )

modifier=Modifier.IDF가 포인트입니다. 이걸 설정해야 쿼리 타임에 IDF 가중치가 적용되어 희귀 단어에 더 높은 점수를 줍니다.

인덱싱

index_documents_hybrid()는 dense vector(OpenAI)와 sparse vector(BM25)를 동시에 저장합니다. BM25 sparse vector는 Document(text=..., model="qdrant/bm25") 형태로 넘기면 Qdrant Cloud가 알아서 인코딩해줍니다.

def index_documents_hybrid(documents, collection_name, batch_size=100):
    total = len(documents)
    for start in range(0, total, batch_size):
        batch = documents[start : start + batch_size]
        texts = [doc.page_content for doc in batch]
        dense_vectors = embed_texts(texts)  # OpenAI 임베딩

        points = [
            PointStruct(
                id=start + idx,
                vector={
                    DENSE_VECTOR_NAME: dense_vectors[idx],
                    SPARSE_VECTOR_NAME: Document(  # 서버 사이드 BM25 인코딩
                        text=texts[idx],
                        model="qdrant/bm25",
                        options=BM25_OPTIONS,
                    ),
                },
                payload={"page_content": texts[idx], **batch[idx].metadata},
            )
            for idx in range(len(batch))
        ]
        client.upsert(collection_name=collection_name, points=points)
    return total

BM25 단독 검색

search_bm25()는 sparse vector만 사용해서 키워드 검색을 수행합니다. 쿼리도 동일하게 Document(text=query, model="qdrant/bm25")로 넘기면 서버에서 처리해줍니다

def search_bm25(query, collection_name, n_results=5):
    results = client.query_points(
        collection_name=collection_name,
        query=Document(text=query, model="qdrant/bm25", options=BM25_OPTIONS),
        using=SPARSE_VECTOR_NAME,
        limit=n_results,
        with_payload=True,
    )
    return hits

Step 2: Hybrid Search 결합 로직 구현

Prefetch + RRF Fusion

search_hybrid()는 Qdrant의 Prefetch 패턴을 활용합니다. query_points 한 번 호출 안에 dense와 sparse 검색을 동시에 날리고, Qdrant가 내장 RRF로 결과를 병합해서 반환합니다. RRF를 직접 구현할 필요가 없어요.

Prefetch + FusionQuery는 Qdrant 전용 기능입니다. 이 기능이 없는 벡터 DB(Chroma, Weaviate 등)를 쓴다면 아래처럼 애플리케이션 레이어에서 직접 처리해야 했을 거예요:

# Qdrant Prefetch 없이 직접 구현하는 경우
def search_hybrid_manual(query, n_results=5):
    # 1. Dense 검색 / Sparse 검색 각각 별도 호출
    dense_hits = search_dense(query, limit=20)
    sparse_hits = search_sparse(query, limit=20)

    # 2. RRF 직접 구현
    scores = {}
    for rank, hit in enumerate(dense_hits):
        scores[hit.id] = scores.get(hit.id, 0) + 1 / (60 + rank + 1)
    for rank, hit in enumerate(sparse_hits):
        scores[hit.id] = scores.get(hit.id, 0) + 1 / (60 + rank + 1)

    # 3. 정렬 후 상위 N개 반환
    sorted_ids = sorted(scores, key=lambda x: scores[x], reverse=True)
    return sorted_ids[:n_results]

Qdrant는 이 흐름을 DB 레벨에서 처리해 주기 때문에, 네트워크 왕복이 1회로 줄고 코드도 훨씬 단순해지니까 넘무 좋죠 😃

def search_hybrid(
    query: str,
    collection_name: str = HYBRID_COLLECTION_NAME,
    n_results: int = 5,
    prefetch_limit: int = 20,
) -> list[dict[str, Any]]:
    """Dense + BM25 하이브리드 검색을 RRF로 결합한다."""
    query_vector = embed_texts([query])[0]

    results = client.query_points(
        collection_name=collection_name,
        prefetch=[
            Prefetch(
                query=query_vector,
                using=DENSE_VECTOR_NAME,
                limit=prefetch_limit,
            ),
            Prefetch(
                query=Document(text=query, model="qdrant/bm25", options=BM25_OPTIONS),
                using=SPARSE_VECTOR_NAME,
                limit=prefetch_limit,
            ),
        ],
        query=FusionQuery(fusion=Fusion.RRF),  # Qdrant 내장 RRF
        limit=n_results,
        with_payload=True,
    )
    ...

Reciprocal Rank Fusion (RRF)

RRF는 두 검색 결과를 결합하는 알고리즘입니다. 핵심 공식:

RRF_score(d) = Σ 1 / (k + rank_i(d))
  • k: 상수 (기본값 60)
  • rank_i(d): i번째 검색에서 문서 d의 순위

점수가 아닌 순위만 사용하기 때문에, dense(cosine similarity)와 sparse(BM25 score)의 스케일이 달라도 공정하게 결합됩니다. 양쪽 검색에서 모두 상위에 등장한 문서일수록 최종 점수가 높아지는 구조예요 👀

Qdrant가 FusionQuery(fusion=Fusion.RRF)로 이걸 내장 지원하기 때문에, 직접 구현할 필요 없이 그냥 쓰면 됩니다.


Step 3: CLI에 Hybrid Search 모드 추가

총 3가지 파일을 수정했습니다.

index.py--mode hybrid 플래그 추가

# Naive RAG 인덱싱 (기존)
uv run python -m rag_playground.application.index

# Hybrid 인덱싱
uv run python -m rag_playground.application.index --mode hybrid

--mode hybrid를 넘기면 run_index_hybrid()가 호출되어 dense + sparse 벡터를 함께 저장합니다.

2026-04-05 10:57:09 - INFO - 하이브리드 Qdrant 컬렉션 'family_card_shops_hybrid' 생성 완료
2026-04-05 10:57:09 - INFO - 임베딩 & 하이브리드 인덱싱 시작...
  하이브리드 인덱싱 진행: 100/2667
  ...
  하이브리드 인덱싱 진행: 2667/2667
=== 인덱싱 완료: 2667건, 56.7초 소요 ===

Quest 3 Naive RAG 인덱싱(64.1초)보다 오히려 빠릅니다. BM25 인코딩을 Qdrant Cloud 서버가 처리해줘서 클라이언트 부담이 늘지 않았기 때문이에요 👍

cli.py — 대화형 모드 선택 메뉴 추가

플래그 방식 대신 실행 시 대화형 메뉴로 모드를 선택하도록 구현했습니다.

📋 검색 모드를 선택하세요:
  1. Naive RAG (벡터 검색)
  2. BM25 (키워드 검색)
  3. Hybrid (벡터 + 키워드 RRF)

선택 (1/2/3) [1]:

answer.py — BM25 / Hybrid 답변 함수 추가

def answer_query_bm25(query, collection_name, n_results=5):
    hits = search_bm25(query, collection_name=collection_name, n_results=n_results)
    return hits, generate_answer(query, hits)

def answer_query_hybrid(query, collection_name, n_results=5):
    hits = search_hybrid(query, collection_name=collection_name, n_results=n_results)
    return hits, generate_answer(query, hits)

세 함수(answer_query, answer_query_bm25, answer_query_hybrid)를 딕셔너리로 매핑해서 모드에 따라 분기합니다.

answer_fn = {
    "naive": answer_query,
    "bm25": answer_query_bm25,
    "hybrid": answer_query_hybrid,
}[mode]

Step 4: Naive RAG vs Hybrid Search 비교

compare.py로 5개 기본 쿼리를 세 모드(Naive / BM25 / Hybrid)로 돌려봤습니다.

uv run python -m rag_playground.application.compare

결과 요약

질의Naive (Dense)BM25 (키워드)Hybrid (RRF)
부산진구 한식 맛집식당은 잘 찾음구 이름만 매칭, 식당 아님두 결과 혼합
미용실 저렴한 곳미용실 정확히미용실 키워드 정확두 결과 자연스럽게 섞임 ✅
동래구 목욕탕목욕탕 정확 ✅동래구만 매칭, 목욕탕 누락의미 결과가 희석됨
아이스크림 가게의미 연관 결과결과 없음Dense로 자연스럽게 폴백 ✅

인사이트

Hybrid가 빛난 경우는 “미용실 저렴한 곳”처럼 키워드와 의미가 둘 다 중요할 때입니다. 그리고 “아이스크림 가게”처럼 BM25가 매칭을 못 찾을 때는 RRF 특성상 Dense 결과로 자연스럽게 폴백됩니다 😃

예상과 달랐던 경우도 있었습니다. “동래구 목욕탕”은 Dense만으로도 충분히 정확했는데, Hybrid에서 BM25가 “동래구”에만 강하게 매칭되면서 오히려 결과가 희석됐습니다. Hybrid가 항상 최선이 아니라는 걸 데이터로 확인했어요ㅠ

예상과 달랐던 결과

BM25의 한계도 명확하게 드러났습니다. “부산진구 한식 맛집”에서 BM25는 “부산진구”라는 지역명은 잡았지만 “한식 맛집”의 의미를 전혀 이해하지 못해서 식당과 무관한 결과를 내놨습니다. BM25도 Dense 없이는 의미를 파악하기 어렵습니다.

현재 RRF는 두 검색에 동일한 가중치를 주는데, 쿼리 성격에 따라 가중치를 조정하면 더 나은 결과를 얻을 수 있을 것 같습니다^^7 (이건 다음 퀘스트에서…)


배운 점

  1. Dense 검색만으로는 부족하다: “서면”, “동래구” 같은 지역명은 Dense가 무시하는 경향이 있고, BM25가 훨씬 정확하게 잡아냅니다
  2. Hybrid가 항상 최선은 아니다: BM25가 키워드를 잘못 잡으면 오히려 Dense 결과를 희석시킵니다. 쿼리 성격에 따라 단일 모드가 더 나을 수 있어요
  3. BM25는 한국어 의미 이해에 약하다: 키워드 자체가 문서에 없으면 결과가 아예 없거나 전혀 관계없는 결과를 냅니다
  4. Qdrant Cloud의 서버 사이드 inference가 편하다: cloud_inference=True 하나로 클라이언트에서 fastembed 같은 의존성 없이 BM25를 쓸 수 있어서, 인덱싱 속도도 Naive RAG(64.1초)와 거의 차이가 없었습니다(56.7초)
  5. RRF 가중치 튜닝이 다음 과제: 현재는 dense:sparse를 동일 비중으로 결합하는데, 쿼리 유형별로 가중치를 조정하면 더 나은 결과를 기대할 수 있습니다

다음 퀘스트

처음엔 “Dense + Sparse = 완벽한 보완”을 기대했는데, 실제로 돌려보니 그렇게 단순하지 않았습니다. BM25가 엉뚱한 키워드에 과하게 반응하면 오히려 Dense의 좋은 결과를 끌어내리기도 했어요. 결국 두 검색을 합친다고 약점이 사라지는 게 아니라, 어떻게 합칠지가 중요하다는 걸 배웠습니다.

다음은 RRF 가중치 튜닝이나 Reranker 도입으로 이 균형을 더 잘 잡아보는 방향으로 가볼 것 같습니다 🤔


공공데이터로 RAG 뚝딱거려보기 시리즈

  1. 프롤로그
  2. Quest 1: 프로젝트 환경 세팅
  3. Quest 2: 공공데이터 API 연동
  4. Quest 3: Naive RAG 파이프라인 구축
  5. Quest 4: Hybrid Search ← 현재