RAG 파이프라인에 리랭커(Reranker)를 추가했더니 모든 것이 망가졌습니다 — 그리고 해결했습니다
요약
RAG 파이프라인의 정밀도를 높이기 위해 교차 인코더(Cross-encoder) 리랭커를 도입했으나, 특정 데이터 형식에서 성능이 저하되는 문제를 분석하고 해결 과정을 다룹니다.
핵심 포인트
- 교차 인코더는 바이 인코더보다 정확하지만 연산 비용이 높음
- 하이브리드 검색과 리랭킹을 결합한 2단계 패턴이 표준적임
- 표(Table) 형식의 밀집된 데이터는 리랭커의 성능을 저하시킬 수 있음
- 리랭킹 도입 시 기존 검색 성능이 유지되는지 반드시 검증 필요
v2에서 저는 키워드 사각지대를 해결하기 위해 하이브리드 검색 (Hybrid Retrieval, FAISS + BM25)을 추가했습니다. 19개의 테스트 질문 모두 통과했습니다. 제 리스트의 다음 항목은 더 나은 정밀도를 위한 **교차 인코더 리랭커 (Cross-encoder Reranker)**였습니다.
아이디어는 표준적입니다: 후보군을 과잉 추출(Over-fetch)하고, 더 똑똑한 모델로 리랭킹(Rerank)한 뒤, 상위 k개(top-k)를 유지하는 것입니다. 모든 RAG 튜토리얼이 이를 권장합니다. 구현하는 데 20분이 걸렸고, 즉시 19개의 테스트 중 2개가 깨졌습니다.
무엇이 잘못되었는지, 그리고 제가 도달한 전략은 다음과 같습니다.
교차 인코더(Cross-encoder)가 하는 일 (그리고 왜 더 나은가)
v2에서 검색(Retrieval)은 **바이 인코더 (Bi-encoders)**를 사용합니다. 즉, 쿼리(Query)와 각 청크(Chunk)가 독립적으로 임베딩(Embedding)된 후 코사인 유사도(Cosine Similarity)로 비교됩니다. 빠르지만, 모델이 쿼리와 청크를 동시에 보지는 못합니다.
**교차 인코더 (Cross-encoder)**는 다릅니다. (쿼리, 청크) 쌍을 단일 입력으로 받아 관련성 점수(Relevance Score)를 출력합니다. 단어 수준의 상호작용, 부정(Negation), 의역(Paraphrasing) 등 두 요소를 동시에 주의(Attend)할 수 있습니다. 훨씬 더 정확하지만, 인덱스의 모든 청크에 대해 점수를 매겨야 하므로 1단계 검색(First-stage retrieval)용으로는 너무 느립니다.
표준적인 2단계 패턴:
1단계: 저렴한 검색 (FAISS + BM25) → 광범위한 후보군 설정
2단계: 교차 인코더가 후보군 리랭킹 → 정밀한 상위 k개(top-k) 추출 → LLM
구현 (쉬운 부분)
새 파일 — app/reranker.py:
from sentence_transformers import CrossEncoder
RERANKER_MODEL_NAME = "cross-encoder/ms-marco-MiniLM-L-6-v2"
...
그리고 main.py에서, 과잉 추출 후 리랭킹:
# 이전 (v2): top_k를 직접 검색
retrieved = store.search(query_vec, top_k=req.top_k, query_text=req.question)
...
새로운 의존성(Dependency)은 없습니다 — cross-encoder/ms-marco-MiniLM-L-6-v2는 이미 설치되어 있던 sentence-transformers를 통해 작동합니다. 모델 크기는 약 80MB이며, CPU에서 실행됩니다.
평가(Eval)를 실행했습니다. 두 개의 테스트가 깨졌습니다.
무엇이 깨졌는가
질문: Zentara Robotics의 CEO는 누구입니까?
기대값: ['Iris Kallas']
결과값: 문서에서 해당 내용을 찾을 수 없습니다.
...
v1에서 순수 FAISS를 사용했을 때 실패했던 것과 정확히 동일한 두 질문이 실패했습니다. 하이브리드 검색(Hybrid retrieval)은 이를 해결했습니다. 하지만 리랭커(Reranker)가 이를 다시 망가뜨렸습니다.
왜 크로스 인코더(Cross-encoder)는 표(Table)를 싫어하는가
CEO 정보가 담긴 청크(Chunk)는 다음과 같이 생겼습니다:
Company: Zentara Robotics | CEO: Iris Kallas | Employees: 287 | Founded: 2018 ...
밀집되어 있고, 표 형식이며, 8개의 사실이 한데 뭉쳐 있습니다.
크로스 인코더(ms-marco-MiniLM-L-6-v2)는 구절(Passage)이 자연어 문단으로 이루어진 웹 검색 데이터셋인 MS MARCO로 학습되었습니다. 이 모델이 "Who is the CEO?"라는 쿼리에 대해 사실이 빽빽하게 담긴 표 행을 "구절"로 마주하면, 점수를 낮게 부여합니다. 정답을 포함하고 있음에도 불구하고, 모델 눈에는 좋은 답변처럼 보이지 않는 것입니다.
반면, 하이브리드 검색(Hybrid retrieval)은 이 청크를 1위로 랭킹했습니다. BM25가 "CEO"라는 단어를 정확히 매칭했고, RRF(Reciprocal Rank Fusion)가 이를 끌어올렸기 때문입니다. 하지만 크로스 인코더가 이를 버려버렸습니다.
시도했던 것들 (그리고 실패한 이유)
작동하는 방법을 찾기까지 7가지 접근 방식을 거쳤습니다. 그 과정은 다음과 같습니다:
| # | 접근 방식 | 결과 |
|---|---|---|
| 1 | 순수 CE 리랭크 (Pure CE rerank) | CE가 표 청크를 묻어버림 |
| ... |
핵심 문제는 표 청크에 대한 크로스 인코더의 점수가 너무 낮아서, 어떤 점수 블렌딩(Score blending)이나 랭크 퓨전(Rank fusion)으로도 보완할 수 없다는 점이었습니다. 이것은 "이 청크의 순위가 약간 낮아지는" 문제가 아니라, "모델이 이 형식을 적극적으로 거부하는" 문제입니다.
실제로 작동한 방법: 보장된 슬롯 (Guaranteed slots)
통찰: 1단계(First-stage) 결과는 이미 충분히 좋습니다. 하이브리드 검색은 19개의 테스트를 모두 통과했습니다. 리랭커는 이 결과들을 덮어쓰는 것이 아니라 개선해야 합니다.
전략:
top_k = 3: 보장된 슬롯 = 2 (1단계 결과에서) + 1 CE 선택
top_k = 5: 보장된 슬롯 = 4 (1단계 결과에서) + 1 CE 선택
1단계의 상위 결과들을 보존합니다. 크로스 인코더는 남은 후보들 중에서 마지막 슬롯만 채울 수 있게 됩니다. 최종 구현은 다음과 같습니다:
def rerank(query, retrievals, top_k):
if not retrievals or top_k >= len(retrievals):
return retrievals
...
CEO 청크(1단계 #1)는 항상 보장됩니다. 직원 청크(top_k=5일 때 순위 ~3-4) 또한 보존됩니다. CE(Cross-Encoder)는 마지막 슬롯에 가장 관련성이 높은 후보를 선택함으로써 여전히 가치를 더합니다.
결과: 19/19 통과.
현재의 파이프라인
PDF ─► 텍스트 추출 (extract text) ─► 청킹 (chunk) ─► 임베딩 (embed) (MiniLM-L6-v2)
│
▼
...
이제 세 단계의 검색(retrieval) 과정을 거칩니다: 벡터 검색 (vector search), 키워드 검색 (keyword search), 크로스 인코더 (cross-encoder). 각 단계는 다른 단계가 놓치는 것을 포착합니다.
배운 점
-
리랭커(Reranker)는 단순히 끼워 넣는다고 개선되는 것이 아닙니다. 모든 RAG 튜토리얼은 "크로스 인코더를 추가하면 더 나은 결과를 얻을 수 있다"라고 말합니다. 하지만 실제로 자연어 구절(natural language passages)로 학습된 크로스 인코더는 구조화된 데이터나 표 형식의 콘텐츠(structured or tabular content)에 대한 검색 품질을 적극적으로 저하시킬 수 있습니다.
-
평가 세트(eval set)는 안전망입니다. 19개의 질문으로 구성된 평가 하네스(eval harness)가 없었다면, 저는 이것을 배포하고도 2개의 질문에서 성능이 퇴보했다는 사실을 전혀 몰랐을 것입니다. 평가 시스템이 몇 초 만에 이를 잡아냈습니다.
-
보장된 슬롯(Guaranteed slots) > 점수 혼합(score blending). CE와 1단계 점수를 혼합하는 7가지 다른 방법을 시도했습니다. 하지만 표 청크(table chunks)에 대한 CE의 점수가 너무 낮아 모든 혼합 결과를 지배했기 때문에 어떤 방법도 작동하지 않았습니다. 해결책은 수학적인 것이 아니라 구조적인 것이었습니다. 즉, 이미 잘 작동하고 있는 것을 보호하고, CE는 여백(margins)을 개선하도록 하는 것이었습니다.
-
여전히 리트리버(retriever)가 가장 중요합니다. v1 → v2 (BM25 추가) 단계가 정확도 면에서 가장 큰 도약이었습니다. v2 → v3 (리랭커 추가) 단계는 정밀도(precision)를 미세 조정하는 과정이었으며, 거의 성능 퇴보를 일으킬 뻔했습니다. 리랭커를 찾기 전에 1단계 검색(first-stage retrieval)에 먼저 투자하세요.
다음 단계
- 스트리밍 응답 (Streaming responses)
- 대화 메모리 (Conversation memory)
- Streamlit UI 도입 가능성
직접 시도해 보세요
- v3 (reranker): github.com/santanu2908/chat-with-pdf-rag
- v2 (hybrid retrieval): github.com/santanu2908/chat-with-pdf-rag
- v1 (pure FAISS): github.com/santanu2908/chat-with-pdf-rag
uv sync
cp .env.example .env # set your API key
uv run uvicorn app.main:app --reload
http://localhost:8000/docs에서 열고, 샘플 PDF를 업로드한 다음,
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기