Reranker Fine-Tuning on Click Data: When Off-the-Shelf Stops Winning
요약
본 기사는 RAG(Retrieval-Augmented Generation) 파이프라인에서 검색 성능 향상을 위해 '오프더쉘프' 재랭커가 한계에 부딪히는 상황을 지적하며, 기업 내부의 전문 용어와 복잡한 사용자 행동 패턴을 다룰 때 발생하는 문제를 다룹니다. 해결책으로 가장 가치 있는 데이터 소스인 사용자의 '클릭 데이터(Click Data)'를 활용하여 Reranker 모델을 파인튜닝하는 방법을 제시합니다. 이 과정에서 클릭 데이터를 정제하고, (긍정-부정) 쌍별 선호도 목록을 생성한 후, Pairwise Loss와 같은 적절한 손실 함수를 사용하여 모델을 훈련시키는 구체적인 방법론을 설명합니다.
핵심 포인트
- 오프더쉘프 재랭커는 MS MARCO 등 공개 데이터셋으로 훈련되어 내부의 전문 용어(예: 청구 프로세스, 부품 번호)에 취약하며, 실제 사용자 행동 패턴을 반영하지 못한다.
- 가장 저렴하고 가치 있는 파인튜닝 신호는 검색 로그에서 얻은 '클릭 데이터'이다. 모든 클릭은 긍정 레이블, 스킵(Skip)은 부정 레이블로 간주할 수 있다.
- 단순히 Recall@K 같은 지표만 볼 것이 아니라, Faithfulness와 Coverage 등 RAG 평가의 네 가지 차원을 종합적으로 고려하여 병목 구간을 진단해야 한다.
- 클릭 데이터를 활용하기 위해서는 세션 제거, 봇 트래픽 필터링, 과적합 방지 등을 포함하는 엄격한 데이터 정제 과정이 필수적이다.
- 최종 목표는 (긍정-부정) 쌍별 선호도 목록(Pairwise Preference List)을 생성하고, MarginRankingLoss와 같은 Pairwise Loss 함수를 사용하여 재랭커 모델을 훈련시키는 것이다.
책: RAG Pocket Guide: Retrieval, Chunking, and Reranking Patterns for Production 또한 저의 책: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구를 사용하는 개발자를 위한 IDE 저: xgabriel.com | GitHub
RAG 파이프라인을 연결하고, Hugging Face 에서 bge-reranker-v2-m3 를 선택하여 bi-encoder 뒤에 플러그인했다가 hit rate 가 8 포인트 점프하는 것을 보았다. 그 후 더 이상 손을 댔다. 2 개 분기 뒤에는 도메인이 변했다. 코퍼러스는 두 배로 커졌고, 쿼리의 절반은 reranker 가 본 적이 없는 전문 용어였으며, 지원 팀은 모델이 승리를 기록하는 것보다 더 빠르게 티켓을 제출했다. Recall@5 는 여전히 괜찮아 보인다. 사용자는 그렇지 않다. 이것이 오프더 Shelf rerankers 가 무료 승리가 되지 않는 순간이다. 그들은 MS MARCO 와 몇 개의 공개 IR 데이터셋에서 훈련되었다. 당신의 쿼리는 청구 프로세스, 부품 번호, 내부 약어에 대해 있다. 크로스-엔코더는 두 가지 거의 동일한 제품 사양 중 엔지니어가 실제로 열었던 것을 알 수 없다. 당신의 사용자는 알 수 있다. 그들은 클릭한다. 클릭 데이터는 당신이 소유할 가장 저렴한 fine-tuning 신호이다. 모든 클릭은 레이블링된 긍정: 이 쿼리, 이 문서, 사용자가 선택했다. 모든 클릭 위의 스킵은 레이블링된 부정: 이 쿼리, 이 문서, 사용자가 보았다가 넘어갔다. 당신은 이미 검색 로그에 있다. 질문은 무엇을 해야 하는지이다.
"reranker 가 멈췄다"가 어떻게 보이는지
fine-tuning任何东西 전에 reranker 가 병목인지 증명하라. 일반적으로 trace 에서 나타나는 패턴:
bi-encoder 는 50 명을 검색한다. 올바른 것은 94% 의 경우에 있다. cross-encoder 는 재랭킹한다. 올바른 것은 상위 5 에 약 78% 의 경우이다. 한 년 전 그 숫자는 88% 였다. 누구도 그것을 드립트하지 않았기 때문에 누구도注意到了因为它没有人把它放在仪表板上。94 → 78 간격은 reranker 의 슬라이스이다. 당신의 retriever 는 문서를 완전히 누락했다면, fine-tuning 이 얼마나 되든 그걸 수정할 수 없다.
RAG Evaluation Beyond Recall@K 에서의 네 가지 지표 해프니스를 실행하라. faithfulness 와 coverage 가 괜찮아 보이고, recall@50 이 높고, rerank-top-5 가 슬라이딩한다면 reranker 를 fine-tune 한다. 다른 모양은 upstream stage 를 먼저 수정한다.
데이터 모양: 훈련 전에 클릭 데이터가 어떻게 보이는지
무언가를 잊어라. 최소 유용한 행은 네 열이다:
click_events.jsonl — 쿼리 임프레션당 한 행
{ " query " : " refund window for digital downloads " , " shown_doc_ids " : [ " d_8821 " , " d_3340 " , " d_2207 "
이전 번역본의 연속입니다.
"d_91", "d_44"], "clicked_doc_ids": ["d_3340"], "session_id": "s_5b21...", "ts": 1736201044} 트레너가 만나는 전에는 이를 정화하는 5 가지 규칙:
- 클릭이 없는 세션은 제거합니다. 신호 없이 잡음만 남습니다.
- 봇 트래픽을 제거합니다. 사용자 에이전트 허용 목록과 "60 초에 50 회 이상의 쿼리" 규칙으로 80%를 제거할 수 있습니다.
- 같은 doc-id 에서 클릭 전환율 (CTR) 이 0.95 이상인 쿼리는 제거합니다. 이는 북마크이거나 검색창을 스크립팅하는 사람이거나 할 것입니다.
- 빈도수에 따라 상위 1% 의 쿼리를 제거합니다. 이들은 훈련 데이터셋을 지배하여 홈페이지에 과적합 (overfit) 을 일으킬 것입니다.
- (query, clicked_doc_id) 로 중복을 제거합니다. 사용자가 동일한 결과를 10 회 클릭하는 것은 하나의 긍정입니다, 10 개의 것이 아닙니다.
정화 후 나오는 것은 쌍별 선호도 목록입니다: # pairs.jsonl — (긍정, 부정) 쌍마다 한 줄
{ "query": "디지털 다운로드 환불 기간", "pos_doc_id": "d_3340", "neg_doc_id": "d_8821" }
{ "query": "디지털 다운로드 환불 기간", "pos_doc_id": "d_3340", "neg_doc_id": "d_2207" }
각 임프레션 (impression) 에 대해, 클릭 위치보다 위에 표시되었으나 클릭되지 않은 문서가 하드 부정입니다. 클릭 위치 아래에 표시된 문서는 모호합니다 — 사용자는 스크롤을 멈췄을 수 있으므로 제거합니다. 5 개의 표시 중 하나를 클릭하면 임프레션당 최대 4 개의 부정이 나옵니다. 월간 100 만 회 임프레션은 몇 백만 쌍을 얻습니다. 그것이 충분합니다.
쌍별 손실 (Pairwise loss): 이 데이터에 맞는 유일한 목표함수입니다.
쌍이 있습니다. 쌍을 취하는 손실이 맞습니다. MarginRankingLoss 는 리커스 시스템 문헌의 BPR 과 개념적으로 유사하며, 모델이 긍정을 부정보다 마진만큼 높게 점수를 내도록 요구합니다. 형태:
loss = max(0, margin - (score_pos - score_neg))
부정이 너무 가까울 때 그래디언트가 흐릅니다. 이미 긍정을 부정보다 멀리 랭킹할 때 손실은 0 이고 예시는 기여하지 않습니다. 마진은 크로스 인코더가 [-10, +10] 범위의 logits 을 방출하는 경우 보통 1.0 입니다.
문장 트랜스포머 (sentence-transformers) 훈련 루프는 40 줄입니다:
from sentence_transformers import CrossEncoder, InputExample, losses
from torch.utils.data import DataLoader
model = CrossEncoder("BAAI/bge-reranker-v2-m3", num_labels=1)
train_examples = []
for row in load_pairs("pairs.jsonl"):
q = row["query"]
pos_text = doc_text(row["pos_doc_id"])
neg_text = doc_text(row["neg_doc_id"])
# 삼중 입력: (안커, 긍정, 부정)
train_examples.append(InputExample(texts=[q,
pos_text , neg_text ]) ) loader = DataLoader ( train_examples , shuffle = True , batch_size = 16 ) loss_fn = losses . MultipleNegativesRankingLoss ( model = model ) model . fit ( train_dataloader = loader , epochs = 1 , warmup_steps = 500 , output_path = " rerank-finetuned-v1 " , ) One epoch over a few hundred thousand pairs on a single A10 takes about an hour. Two epochs is the limit. Past that, train loss keeps falling while eval loss climbs. Classic overfit. Distillation: when click data is too sparse If you have ten thousand pairs instead of a million, do not train from scratch. Distill. The shape: take a strong teacher reranker (Cohere rerank-3 , bge-reranker-v2-gemma , or a closed-source one you can hit via API), score every (query, candidate) pair the teacher sees, and have your smaller student learn the teacher's continuous score, not the binary click label. Why this works: the teacher's score for a doc is closer to the truth than a single click event. The teacher knows that doc A scores 0.91 and doc B scores 0.88; both are relevant, A is slightly more so. A click only tells you "the user picked A" — it has no view on how much more relevant A is. The distillation loss is regression on the teacher's score: loss = mse ( student_score ( q , d ), teacher_score ( q , d )) MarginMSELoss in sentence-transformers does exactly this with a triplet (query, pos, neg) : the student learns the difference between teacher's score for the positive and the negative. It stabilizes the loss when teachers are calibrated within a query but drift across queries. Teacher reranks via a hosted API run on the order of $1 per 1000 calls at typical pricing (check current rates on the vendor's pricing page). A million (query, candidate) pairs is roughly four-figure spend on teacher labels, paid once per major distillation refresh. Cache the labels in S3 and never re-pay for them. When click data is the bottleneck, distillation widens the dataset 50x. A student trained on clicks ∪ teacher-labels typically beats either source alone by a few NDCG points on a held-out eval. The training budget question: one GPU-hour for 100k pairs Numbers people ask for, with caveats. On a single A10G at roughly $1/hour on a typical cloud GPU provider, with bge-reranker-base (~280M params): Pairs Epochs Approx wall time Approx cost at ≈$1/hr 50k 1 30 min $0.55 100k 1 60 min $1.10 100k 2 2h $2.20 500k 1 5h $5.50 500k 2 10h $11 These are rough. Your batch size, max-seq-len, and whether you're using L
oRA 모든 스윙이 숫자를 뒤집는다. 지출은 잡음에 있다. Fine-tuned 모델의 평가 비용은 훈련 비용보다 높으며, 이는 평가가 홀드아웃된 한 달의 쿼리에 대한 상위 50 개 재랭킹 점수를 필요로 하기 때문이다. 대부분의 시간이 여기에 소비된다. 여러 테너트용 재랭커를 배포하고 싶다면 LoRA 를 사용하라. LoRA 어댑터는 몇 MB 로, 베이스는 공유되며 추론 시 라우팅이 가능하다. LoRA 가 없으면 각 테너트 모델은 전체 1.1 GB 체크포인트이며, 추가 테너트는 열 모델의 푸트프린트에 1.1 GB 를 더한다. 8~10 개 이상의 테너트를 넘어선 경우 사용 hardly GPU 메모리를 지불하고 있다. 평가: 중요한 숫자는 오프더 Shelf Train/test 규율을 따르는 lift 만이다. 마지막 N 일의 클릭 로그를 홀드아웃하라. 절대 그 위에서 훈련하지 마라. Fine-tuned 모델은 정확히 동일한 임프레션에서 오프더 Shelf 재랭커를 이겨야 하며, 이를 오프라인으로 점수화한다. # eval_lift.py — 전체 평가는 이것이다 def ndcg_at_5 ( scored , clicked ): ranked = sorted ( scored , key = lambda x : - x [ 1 ])[: 5 ] dcg = sum ( ( 1 if d in clicked else 0 ) / math . log2 ( i + 2 ) for i , ( d , _ ) in enumerate ( ranked ) ) idcg = sum ( 1 / math . log2 ( i + 2 ) for i in range ( min ( len ( clicked ), 5 ))) return dcg / idcg if idcg else 0 baseline_ndcg = mean ( ndcg_at_5 ( score ( base , q , docs ), clicks ) for q , docs , clicks in held_out ) finetuned_ndcg = mean ( ndcg_at_5 ( score ( ft , q , docs ), clicks ) for q , docs , clicks in held_out ) lift = ( finetuned_ndcg - baseline_ndcg ) / baseline_ndcg print ( f
대신입니다. 상위 쿼리는 트래픽의 50% 를 차지합니다. 파인튜닝은 이를 기억하고 긴 꼬리 (long tail) 에서 성능이 저하됩니다. 먼저 인기 편향 (popularity skew) 을 수정해야 합니다 — 쿼리 리파이팅, 인텐트 분류 — 그런 다음 다시 검토하세요. 바이-엔코더가 병목입니다. 리콜@50 이 90% 미만이면 문서가 랭커에 도달하지 못합니다. 랭커 파인튜닝은 이를 해결하지 않습니다. 매월 새로운 제품 표면 (product surface) 을 출시합니다. 모델이 배포되기 전에 낡아집니다..teacher 랭커를 API 를 통해 사용하고, 표면이 안정화될 때까지 훈련 단계를 완전히 건너뛰세요. 홀드아웃 테스트 세트 규율 (discipline) 이 없습니다. 리프트 숫자는 환상입니다. 이를 없으면 파인튜닝이 도움이 되었는지 해쳤을지 알 수 없습니다. 평가 먼저 구축하세요; 훈련 두 번째로 하세요. 클릭 로그가 공개 데이터셋에서 놓친 것을 발견했을 때만 파인튜닝을 하세요, 그리고 NDCG@5 를 깨끗한 홀드아웃 세트 (clean held-out set) 로 증명할 수 있어야 합니다. 그 증거 없이는 검색 바에 과적합되는 모델을 훈련하고 있습니다. 이번 주에 홀드아웃 평가를 구축하세요, 오프더샤프 랭커에 대해 실행하세요, 그리고 파인튜닝이 GPU 시간 대비 가치 있는지 결정하세요. 이것이 유용하다면 이 포스트는 더 큰 문제의 한 조각입니다: 검색, 랭킹, 청크링이 프로덕션 RAG 에서 어떻게 상호작용하는지.RAG Pocket Guide 는 전체 파이프라인을 안내합니다 — 리트리버 선택, 청킹 전략, 랭커 패턴, 쿼리 리파이팅, 그리고 모든 것을 Ship-ready 로 만드는 평가 규율. 만약 업무에서 검색이나 RAG 를 구축하고 있다면, 그것이 책입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기