리랭커(reranker)는 1차 검색이 뽑아온 후보 문서들을 질문과의 실제 관련도로 다시 정렬해 상위 몇 개만 남기는 2단계 컴포넌트다. 벡터 검색이 "대충 비슷한 것 100개"를 빠르게 건져오면, 리랭커가 "정말 답이 될 만한 것 5개"를 정밀하게 골라낸다. RAG 품질이 애매할 때 손대야 할 곳은 보통 여기다.
나는 처음 RAG를 붙일 때 임베딩 모델만 좋은 걸로 바꾸면 검색이 좋아질 줄 알았다. 그런데 top-k를 20으로 늘려도 정작 정답 문단이 8~15위쯤에 파묻혀 있는 경우가 많았다. 모델에 20개를 다 넣자니 컨텍스트가 지저분해지고 비용도 오른다. 리랭커를 끼운 뒤에야 이 문제가 풀렸다.
바이인코더 vs 크로스인코더
왜 벡터 검색만으로는 부족할까. 일반적인 임베딩 검색은 바이인코더(bi-encoder) 구조다. 질문과 문서를 따로따로 벡터로 변환해두고 코사인 유사도만 계산한다. 문서 벡터는 질문이 뭔지 모른 채 미리 만들어져 있으니, 미묘한 어긋남(예: "환불 정책"과 "결제 취소 절차")을 잡아내는 데 한계가 있다. 대신 엄청나게 빠르다 — 벡터 DB에 미리 넣어두면 수백만 건도 수십 ms에 훑는다.
크로스인코더(cross-encoder)는 다르다. 질문과 문서를 한 쌍으로 묶어 모델에 함께 통과시켜 0~1 사이 관련도 점수를 뱉는다. 둘을 같이 보니 정확도가 훨씬 높다. 문제는 느리다는 것. 후보가 100개면 100번 추론해야 한다. 그래서 이 둘을 역할 분담시킨다.
- 바이인코더(벡터 검색): 수백만 → 후보 50~100개, 아주 빠름
- 크로스인코더(리랭커): 후보 50~100개 → 최종 5~10개, 느리지만 정밀
실제로 어떻게 끼우나
파이프라인은 단순하다. 벡터 검색으로 넉넉히 뽑고, 리랭커로 점수 매겨 자르면 된다.
def retrieve(query, k_recall=60, k_final=6):
# 1차: 벡터 검색으로 후보를 넉넉히
candidates = vector_store.search(query, top_k=k_recall)
# 2차: 크로스인코더로 (query, doc) 쌍 점수화
pairs = [(query, c.text) for c in candidates]
scores = reranker.predict(pairs) # 예: bge-reranker, Cohere Rerank 등
ranked = sorted(zip(candidates, scores),
key=lambda x: x[1], reverse=True)
# 점수 컷오프도 같이 걸면 쓰레기 문서를 버릴 수 있다
return [c for c, s in ranked[:k_final] if s > 0.3]
여기서 k_recall과 k_final의 간극이 리랭커가 일할 공간이다. 1차에서 60개를 건지고 최종 6개를 쓰면, 리랭커가 54개를 걸러낼 여지가 생긴다. 만약 1차를 top-6으로 좁게 뽑으면 리랭커가 아무리 좋아도 이미 놓친 정답을 되살릴 수 없다. 리랭커의 상한은 1차 검색의 recall이 결정한다. 이걸 몇 번 데인 뒤에야 체감했다.
지연시간(latency)이라는 대가
리랭커는 공짜가 아니다. 크로스인코더 추론이 후보 수에 비례해 늘어난다. 후보 100개를 로컬 GPU 리랭커로 돌리면 수십~수백 ms가 추가되고, API형 리랭커(예: Cohere, Voyage의 rerank 엔드포인트)를 쓰면 네트워크 왕복까지 더해진다. 대략적인 트레이드오프를 표로 정리하면 이렇다.
| 후보 수(k_recall) | recall 경향 | 리랭킹 지연 |
|---|---|---|
| 20 | 정답 놓칠 위험 | 가장 낮음 |
| 50~60 | 대체로 균형 | 중간 |
| 200+ | recall 상승폭 둔화 | 눈에 띄게 느림 |
개인적으로는 50~60에서 시작해 도메인 평가셋으로 조정하는 걸 권한다. 후보를 무작정 늘려도 recall은 어느 지점부터 거의 평평해지는데 지연만 계속 커진다. 그리고 리랭킹은 병렬화가 잘 되니, API형이라면 배치 호출로 왕복 횟수를 줄이는 게 체감상 큰 차이를 만든다.
흔한 함정
리랭커 점수는 모델마다 스케일이 다르다. 어떤 모델은 0~1 시그모이드, 어떤 모델은 제한 없는 로짓을 뱉는다. 다른 파이프라인에서 쓰던 컷오프(0.3 같은)를 그대로 옮기면 전부 걸러지거나 전부 통과한다.
또 하나. 리랭커를 넣었다고 벡터 검색을 대충 해도 되는 건 아니다. 1차가 후진 후보만 물어오면 리랭커는 "덜 나쁜 쓰레기"를 고를 뿐이다. 그리고 청크가 너무 길면 크로스인코더 입력 길이 제한에 걸려 뒷부분이 잘린다 — 리랭킹 대상 청크는 임베딩용보다 오히려 짧게 유지하는 게 안전하다.
자주 묻는 질문
리랭커는 항상 넣어야 하나요?
아니다. 문서가 수천 건 이하이고 질문이 키워드 위주라면 벡터 검색만으로 충분한 경우가 많다. 리랭커는 후보가 애매하게 비슷비슷할 때, 즉 top-k 안에 정답이 있는데 순위가 낮을 때 가장 효과가 크다. 먼저 평가셋으로 "정답이 top-20 안에는 들어오는가"를 확인하고, 들어오는데 순위만 낮다면 그때 리랭커가 답이다.
임베딩 모델을 더 좋은 걸로 바꾸는 것과 무엇이 다른가요?
임베딩 교체는 1차 후보의 recall을 끌어올리고, 리랭커는 그 후보 안에서의 정밀도(순위)를 끌어올린다. 둘은 대체재가 아니라 보완재다. recall이 낮으면 임베딩·청킹을, recall은 괜찮은데 순위가 엉망이면 리랭커를 손봐야 한다.
로컬 리랭커와 API 리랭커 중 뭘 쓰나요?
트래픽이 적고 지연에 민감하지 않으면 오픈소스 크로스인코더를 로컬에서 돌리는 게 비용상 유리하다. 반대로 운영 부담을 줄이고 다국어 품질이 중요하면 상용 rerank API가 편하다. 다만 API는 요청당 과금과 네트워크 지연이 붙으니 후보 수를 무작정 키우면 비용이 빠르게 늘어난다.
리랭킹 후 몇 개를 모델에 넣는 게 좋나요?
정해진 답은 없지만, 나는 보통 4~8개에서 시작한다. 너무 많이 넣으면 관련 없는 문단이 답을 흐리고(컨텍스트 오염), 뒤쪽 문서는 모델이 덜 참고하는 경향도 있다. 리랭커의 점수 컷오프를 함께 걸어 "충분히 관련 있는 것만" 통과시키는 방식이 개수 고정보다 안정적이었다.

댓글 0