[RAG Playground] Quest 5: Re-ranker 도입으로 검색 품질 한 단계 더
RAG Playground (9부작)
- 1
- 2
- 3
- 4
- 5
- 6 [RAG Playground] Quest 5: Re-ranker 도입으로 검색 품질 한 단계 더 (현재 글)
- 7
- 8
- 9
지난 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/Mt | 32,768 | 더 큰 모델, 긴 문서에 유리 |
| baai/bge-reranker-v2-m3 | $0.01/Mt | 8,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.py에 answer_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.py에 rerank 모드를 추가했습니다. 이제 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 | 해운대구 음식점 중심 재정렬, 병원·태권도 제거 |
| 미용실 저렴한 곳 | bm25 | rerank relevance 0.19 — 데이터에 “저렴” 표현이 없어서 전 모드 약함 |
| 동래구 목욕탕 | rerank | 압도적. 0.99 / 0.98 / 0.91 — 동래구 목욕탕 3개 상위권 |
| 아이스크림 가게 | 없음 | 4개 모드 전부 실패. 데이터에 아이스크림 업체가 없음 |
“부산진구 맛집” 상세 비교:
| 모드 | 결과 특징 | 문제점 |
|---|---|---|
| naive | 맛집 의미는 잡음, 금정구 식당이 1위 | 지역 필터 약함 |
| bm25 | 5건 전부 부산진구 | ”맛집” 무시 — 미용실·철물점·주차장이 상위권 |
| hybrid | BM25+Dense 결합 | 미용실이 여전히 상위 |
| rerank | 5건 전부 부산진구 음식점, relevance 0.86~0.92 | — |
모드별 총평
- naive — 의미 파악은 되지만 지역 필터 약함
- bm25 — 지역 키워드는 강력, 카테고리 의미 무시
- hybrid — 두 모드의 장점보다 단점이 합쳐지는 경우가 종종 있음
- rerank — 명확한 쿼리엔 압도적. 데이터에 없는 개념(저렴, 아이스크림)엔 relevance 점수 자체가 낮아져서 신뢰도 표시 역할도 함
마지막 포인트가 가장 흥미로웠습니다. relevance score가 0.5 미만이면 “관련 데이터 없음” 경고로 활용할 수 있다는 거예요. 단순히 순위를 바꾸는 게 아니라, 검색 결과의 신뢰도를 수치로 표현해준다는 점에서 Re-ranker의 가치가 더 있다고 느꼈습니다.
다음 퀘스트
이번 테스트에서 “아이스크림 가게”는 4개 모드 전부 실패하는걸 볼 수 있었습니다. 데이터에 아이스크림 업체 자체가 없으니 어떤 검색 방식도 없는 걸 만들어낼 수 없죠. 이건 검색 품질의 문제가 아니라 데이터 커버리지의 문제입니다.
지금까지 Naive → Hybrid → Re-ranking으로 검색 품질을 높여왔는데, 사실 “어떤 결과가 좋은 결과인지”를 정량적으로 측정하지 않았습니다. 다음 퀘스트에서는 Evaluation을 도입해서 각 단계의 개선 효과를 수치로 확인해볼 예정입니다. (이제 진짜 RAG 연구 흉내를 내볼 차례..)
공공데이터로 RAG 뚝딱거려보기 시리즈
- 프롤로그
- Quest 1: 프로젝트 환경 세팅
- Quest 2: 공공데이터 API 연동
- Quest 3: Naive RAG 파이프라인 구축
- Quest 4: Hybrid Search
- Quest 5: Re-ranking
- Quest 6: Query Rewriting ← 현재