RAG Python Qdrant Reranker Cross-Encoder

[RAG Playground] Quest 5: Re-ranker 도입으로 검색 품질 한 단계 더

지난 Quest 4에서 Hybrid Search를 구현하면서 한 가지 교훈을 얻었죠… 검색은 두 단계로 나눌 때 더 잘 동작한다 ! — 빠른 검색으로 후보를 넉넉히 뽑고, 그 다음에 더 정밀한 방법으로 순위를 다시 매기는 방법을 사용했지만 100% 완벽할 순 없었죠

이번 퀘스트에서는 Re-ranker를 도입해서 이 2단계 파이프라인을 완성해 더욱 RAG 성능을 업그레이드 해보겠습니다 💪

Re-ranking이 뭔가요?

벡터 검색이나 BM25는 빠른 대신 정확도가 아쉬울 때가 있습니다. 수천 건의 문서 중에서 후보를 뽑는 데는 탁월하지만, 상위 5~10개를 정밀하게 재정렬하는 데는 한계가 있는거 같아요

Re-ranker는 이 문제를 해결합니다. 검색으로 뽑힌 상위 N개 문서에 대해, 쿼리와 각 문서를 쌍으로 함께 입력해서 관련성을 다시 채점합니다. Bi-encoder(임베딩)보다 훨씬 정밀하지만 느리기 때문에, 전체 문서가 아닌 후보군에만 적용하는 게 핵심입니다.

[쿼리] ──→ 1단계: Hybrid Search (빠름, 상위 20개 후보)

               2단계: Re-ranker (정밀, 상위 5개 선정)

Step 1: Re-ranker 모델 선택 & 어댑터 구현

마침 novita.ai API 키가 있었고, novita.ai에서 Reranker 모델을 두 개 지원하고 있어서 그 중에서 골랐습니다.

모델가격Context비고
Qwen3 Reranker 8B$0.05/Mt32,768더 큰 모델, 긴 문서에 유리
baai/bge-reranker-v2-m3$0.01/Mt8,000다국어 지원, 가성비

실험용 프로젝트이기도 하고 업체 정보가 짧은 텍스트라 Context 8,000이면 충분하다고 판단해서 baai/bge-reranker-v2-m3 를 선택했습니다. 비용도 5배 저렴해서 굳 👍

API 스펙은 OpenAI 스타일과 유사합니다:

POST https://api.novita.ai/openai/v1/rerank

{
  "model": "baai/bge-reranker-v2-m3",
  "query": "동래구 목욕탕",
  "documents": ["문서1 텍스트", "문서2 텍스트", ...]
}

응답:

{
  "results": [
    {"index": 2, "relevance_score": 0.91},
    {"index": 0, "relevance_score": 0.64},
    ...
  ]
}

index는 입력 문서 순서, relevance_score는 쿼리와의 관련도입니다. 이걸로 원본 문서를 재정렬하면 됩니다

어댑터 구현 (adapters/reranker/novita.py)

src/rag_playground/
├── adapters/
│   ├── data_go_kr/
│   ├── llm/
│   ├── vectorstore/
│   └── reranker/          ← 신규
│       ├── __init__.py
│       └── novita.py

함수는 ✌️ 개로 분리했습니다.

rerank() — raw API 호출. 문서 텍스트 리스트를 받아서 {index, relevance_score} 리스트를 반환합니다.

RERANK_URL = "https://api.novita.ai/openai/v1/rerank"
RERANK_MODEL = "baai/bge-reranker-v2-m3"

def rerank(query: str, documents: list[str], top_n: int = 5) -> list[dict]:
    response = httpx.post(
        RERANK_URL,
        headers={"Authorization": f"Bearer {NOVITA_API_KEY}"},
        json={
            "model": RERANK_MODEL,
            "query": query,
            "documents": documents,
            "top_n": top_n,
        },
        timeout=30,
    )
    response.raise_for_status()
    return [
        {"index": r["index"], "relevance_score": r["relevance_score"]}
        for r in response.json().get("results", [])
    ]

rerank_hits() — 검색 결과 리스트를 바로 받아서 재정렬까지 처리하는 편의 함수입니다. answer.py에서 이 함수를 직접 호출합니다.

def rerank_hits(query: str, hits: list[dict], top_n: int = 5) -> list[dict]:
    documents = [hit["document"] for hit in hits]
    ranked = rerank(query, documents, top_n=top_n)

    return [
        {
            "document": hits[item["index"]]["document"],
            "metadata": hits[item["index"]]["metadata"],
            "relevance_score": item["relevance_score"],
        }
        for item in ranked
    ]

rerank()가 반환한 index로 원본 hit을 다시 참조해서 메타데이터와 함께 재조립합니다. 기존 검색 점수 대신 relevance_score가 새 기준이 됩니다.


Step 2: Re-ranker 어댑터 구현

기존 어댑터 패턴에 맞게 adapters/reranker/ 디렉토리를 새로 추가할 예정입니다.

src/rag_playground/
├── adapters/
│   ├── data_go_kr/
│   ├── llm/
│   ├── vectorstore/
│   └── reranker/          ← 신규
│       ├── __init__.py
│       └── cohere.py      ← (또는 jina.py / cross_encoder.py)

인터페이스는 단순하게 잡을 예정입니다 — 쿼리와 문서 리스트를 받아서, 재정렬된 문서 리스트를 돌려주는 함수 하나면 충분합니다.

def rerank(
    query: str,
    documents: list[dict],
    top_n: int = 5,
) -> list[dict]:
    """쿼리와 후보 문서를 받아 re-ranking 후 top_n개를 반환한다."""
    ...

Step 3: Re-ranking 파이프라인 연결

answer.pyanswer_query_rerank()를 추가했습니다. 구조는 단순합니다 — Hybrid Search로 후보를 넉넉히 뽑고, Re-ranker가 그 중에서 최종 top-N을 선별하면 됩니다 :)

def answer_query_rerank(
    query: str,
    collection_name: str,
    n_results: int = 5,
    fetch_multiplier: int = 4,
) -> tuple[list[dict], str]:
    """Hybrid 검색 → Re-rank 2단계 파이프라인."""
    fetch_n = n_results * fetch_multiplier      # 기본 5 * 4 = 20개 후보
    hits = search_hybrid(query, collection_name=collection_name, n_results=fetch_n)
    reranked = rerank_hits(query, hits, top_n=n_results)
    answer = generate_answer(query, reranked)
    return reranked, answer

fetch_multiplier=4가 포인트입니다 ﹗ 최종 5개를 뽑기 위해 Hybrid Search로 20개를 먼저 가져오려는 건데요 Re-ranker는 후보가 많을수록 더 정밀하게 고를 수 있지만, 그만큼 API 호출 비용과 응답 시간도 늘어납니다. 그래서 테스트용으로는 4배(=20개)가 적절한 균형이라고 판단했습니다 🤔


Step 3: CLI 모드 추가

cli.py에 3가지를 추가했습니다.

선택지 추가:

SEARCH_MODES = {
    "1": ("naive",   "Naive RAG (벡터 검색)"),
    "2": ("bm25",    "BM25 (키워드 검색)"),
    "3": ("hybrid",  "Hybrid (벡터 + 키워드 RRF)"),
    "4": ("rerank",  "Hybrid + Re-rank (BGE-reranker-v2-m3)"),  # 신규
}

함수 라우팅 추가:

answer_fn = {
    "naive":   answer_query,
    "bm25":    answer_query_bm25,
    "hybrid":  answer_query_hybrid,
    "rerank":  answer_query_rerank,  # 신규
}[mode]

사용자가 4를 선택하면 answer_query_rerank() → Hybrid 검색(20개) → Novita Re-rank → 상위 5개 순으로 자동 연결됩니다.


Step 4: 비교 테스트

compare.pyrerank 모드를 추가했습니다. 이제 4개 모드를 한 번에 비교할 수 있습니다.

MODE_SEARCH = {
    "naive":  lambda q, c: search(q, collection_name=c),
    "bm25":   lambda q, c: search_bm25(q, collection_name=c),
    "hybrid": lambda q, c: search_hybrid(q, collection_name=c),
    "rerank": lambda q, c: rerank_hits(
        q, search_hybrid(q, collection_name=c, n_results=20), top_n=5
    ),  # 신규
}
uv run python -m rag_playground.application.compare

점수 표시도 모드에 따라 분기합니다 — rerank 모드는 relevance_score, 나머지는 기존 score를 표시합니다.

비교 결과

5개 기본 쿼리로 4개 모드를 한 번에 비교해 보았습니다 !

쿼리별 승자:

질의승자이유
부산진구 한식 맛집rerank음식점만 5건 반환, relevance 0.86~0.92
해운대구에서 양식 할인rerank해운대구 음식점 중심 재정렬, 병원·태권도 제거
미용실 저렴한 곳bm25rerank relevance 0.19 — 데이터에 “저렴” 표현이 없어서 전 모드 약함
동래구 목욕탕rerank압도적. 0.99 / 0.98 / 0.91 — 동래구 목욕탕 3개 상위권
아이스크림 가게없음4개 모드 전부 실패. 데이터에 아이스크림 업체가 없음

“부산진구 맛집” 상세 비교:

모드결과 특징문제점
naive맛집 의미는 잡음, 금정구 식당이 1위지역 필터 약함
bm255건 전부 부산진구”맛집” 무시 — 미용실·철물점·주차장이 상위권
hybridBM25+Dense 결합미용실이 여전히 상위
rerank5건 전부 부산진구 음식점, relevance 0.86~0.92

모드별 총평

  • naive — 의미 파악은 되지만 지역 필터 약함
  • bm25 — 지역 키워드는 강력, 카테고리 의미 무시
  • hybrid — 두 모드의 장점보다 단점이 합쳐지는 경우가 종종 있음
  • rerank — 명확한 쿼리엔 압도적. 데이터에 없는 개념(저렴, 아이스크림)엔 relevance 점수 자체가 낮아져서 신뢰도 표시 역할도 함

마지막 포인트가 가장 흥미로웠습니다. relevance score가 0.5 미만이면 “관련 데이터 없음” 경고로 활용할 수 있다는 거예요. 단순히 순위를 바꾸는 게 아니라, 검색 결과의 신뢰도를 수치로 표현해준다는 점에서 Re-ranker의 가치가 더 있다고 느꼈습니다.


다음 퀘스트

이번 테스트에서 “아이스크림 가게”는 4개 모드 전부 실패하는걸 볼 수 있었습니다. 데이터에 아이스크림 업체 자체가 없으니 어떤 검색 방식도 없는 걸 만들어낼 수 없죠. 이건 검색 품질의 문제가 아니라 데이터 커버리지의 문제입니다.

지금까지 Naive → Hybrid → Re-ranking으로 검색 품질을 높여왔는데, 사실 “어떤 결과가 좋은 결과인지”를 정량적으로 측정하지 않았습니다. 다음 퀘스트에서는 Evaluation을 도입해서 각 단계의 개선 효과를 수치로 확인해볼 예정입니다. (이제 진짜 RAG 연구 흉내를 내볼 차례..)


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

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