Field Log · Entry
실전: 재현 가능한 Production Reranking Pipeline 만들기 (14/14)
이번 글의 결론
- 재현 가능한 reranker 실험의 단위는 weight 파일 하나가 아니라 corpus·query·qrel·candidate set·serializer·model·runtime·policy를 묶은 manifest입니다.
- 첫 구현은 작은 Cross-Encoder와 frozen candidates로 시작합니다. 그래야 model 변경과 retriever 변경의 효과가 섞이지 않습니다.
- 품질 gate는 평균 nDCG 하나가 아니라 candidate recall, paired query delta, critical slice, answer utility, p95 latency, 비용, 보안 regression으로 구성합니다.
- Offline 승자가 바로 production 승자는 아닙니다. Shadow → canary → ramp 단계마다 fallback과 rollback 가능한 release 단위를 유지합니다.
- 이 글의 산출물은 “최고 점수 model”이 아니라 다음 실험에서도 비교 가능한 실험 contract와 운영 loop입니다.
앞 글까지 종류·학습·평가·서빙·최신 연구를 살폈습니다. 마지막 글에서는 이를 하나의 작은 프로젝트로 조립합니다.
corpus snapshot
→ queries + qrels
→ frozen candidates
→ baseline / trained reranker
→ paired offline report
→ RAG + load + attack gates
→ shadow / canary / rollout
→ feedback → next dataset
예시는 Python과 Sentence Transformers 계열 API를 사용하지만 핵심은 library가 아니라 경계와 기록입니다.
1. 먼저 성공 조건을 쓴다
project_goal:
use_case: Korean technical-support RAG
primary_metric: ndcg_at_10
minimum_gain: 0.025
guardrails:
candidate_recall_at_100: ">= baseline - 0.001"
critical_query_win_rate: ">= 0.55"
answer_correctness: ">= baseline"
citation_precision: ">= baseline"
p95_rerank_latency_ms: "<= 90"
fallback_rate: "<= 0.005"
acl_violations: 0
traffic:
candidates_per_query: 50
peak_qps: 60
Threshold 숫자는 예시입니다. 실제 값은 현재 system, error cost, hardware budget에서 정합니다.
2. Directory와 Artifact Contract
rerank-project/
├── data/
│ ├── corpus-2026-07-01.jsonl
│ ├── queries-v3.jsonl
│ ├── qrels-v5.tsv
│ ├── candidates-hybrid-v6-top100.jsonl
│ └── splits-v3.json
├── configs/
│ ├── train-ce-v1.yaml
│ ├── eval-v1.yaml
│ └── serve-v1.yaml
├── reports/
│ └── eval-ce-v1.json
├── manifests/
│ └── release-ce-v1.yaml
└── src/
├── build_candidates.py
├── make_training_pairs.py
├── train.py
├── evaluate.py
└── load_test.py
큰 data file을 Git에 넣으라는 뜻은 아닙니다. Object storage URI와 checksum을 manifest에 기록하면 됩니다.
3. Data Schema를 고정한다
Corpus
{"document_id":"d-17","title":"제품 3.2 지원 정책","text":"...","language":"ko","valid_at":"2026-07-01","source_tier":1}
Query
{"query_id":"q-42","text":"제품 3.2 지원 종료일은?","language":"ko","slice":["temporal","product"],"answerable":true}
Qrel
query_id document_id relevance assessor evidence_group
q-42 d-17 3 human e1
q-42 d-81 1 human e2
Graded label 예시:
| grade | 의미 |
|---|---|
| 3 | 질문의 답을 직접 지지하는 핵심 근거 |
| 2 | 답에 필요한 일부 근거·정확한 보조 정보 |
| 1 | 주제 관련이지만 답을 직접 만들지 못함 |
| 0 | 무관하거나 잘못 유도함 |
Label guide에 freshness, contradiction, duplicate 처리도 적습니다.
4. Split은 Query와 Source Leakage를 막는다
무작위 pair split은 같은 query나 거의 같은 문서가 train과 test에 걸칠 수 있습니다.
bad:
random split over (query, document) pairs
better:
group by query_id
domain generalization test:
additionally group by source/product/time
권장 split:
- train: 학습과 hard-negative mining
- dev: hyperparameter·early stopping
- test-in-domain: 최종 고정 평가
- test-temporal: 미래 시점 문서·질문
- test-OOD: 다른 제품·source·언어
- test-attack: injection·metadata manipulation
Test qrel을 보며 negative를 고르면 test leakage입니다.
5. Frozen Candidate Set을 만든다
모든 reranker가 정확히 같은 query_id → ordered document_ids를 받게 합니다.
{
"query_id": "q-42",
"retriever_id": "hybrid-v6",
"corpus_sha256": "...",
"candidates": [
{"document_id":"d-81","retrieval_rank":1,"retrieval_score":0.881},
{"document_id":"d-17","retrieval_rank":2,"retrieval_score":0.864}
]
}
먼저 상한을 확인합니다.
def candidate_hit(candidate_ids, relevant_ids):
return int(bool(set(candidate_ids) & set(relevant_ids)))
def evidence_coverage(candidate_ids, gold_ids):
if not gold_ids:
return None
return len(set(candidate_ids) & set(gold_ids)) / len(set(gold_ids))
Recall@100이 너무 낮다면 reranker 학습보다 retriever·chunking·filter를 먼저 고칩니다.
6. 첫 Baseline은 세 개면 충분하다
- No rerank: retrieval order
- Zero-shot pretrained Cross-Encoder
- In-domain fine-tuned Cross-Encoder
LLM listwise를 바로 넣기보다 이 baseline을 기준점으로 둡니다.
from sentence_transformers import CrossEncoder
model = CrossEncoder(
"BAAI/bge-reranker-v2-m3",
max_length=512,
)
pairs = [(query, document["text"]) for document in candidates]
scores = model.predict(pairs, batch_size=32)
ranked = sorted(
zip(candidates, scores),
key=lambda item: (-float(item[1]), item[0]["retrieval_rank"]),
)
Model 이름은 실행 가능한 예시이지 모든 한국어 corpus의 권장 결론은 아닙니다. 10편의 model bake-off로 후보를 바꿉니다.
Stable Tie-break
같은 score일 때 기존 retrieval rank, 그다음 document ID처럼 deterministic rule을 둡니다. 재현성 없는 tie-break는 paired 평가를 흔듭니다.
7. Training Example을 만든다
Positive마다 easy·retrieval hard·model hard negative를 섞습니다.
{
"query_id": "q-42",
"query": "제품 3.2 지원 종료일은?",
"positive": {"document_id":"d-17","text":"...","grade":3},
"negatives": [
{"document_id":"d-90","text":"...","type":"retrieval_hard","grade":0},
{"document_id":"d-81","text":"...","type":"partial","grade":1},
{"document_id":"d-12","text":"...","type":"random","grade":0}
]
}
grade=1 partial evidence를 0과 동일 취급하면 model이 미세한 관련성을 잃을 수 있습니다. Binary objective만 쓰더라도 sampling과 weight에서 구분합니다.
False-negative Audit
새 hard negative batch를 매 epoch 추가하기 전에 다음을 검사합니다.
- 다른 chunk에 정답이 있어 passage가 부분 관련인지
- qrel이 누락된 unjudged relevant인지
- 최신 문서가 old qrel과 충돌하는지
- duplicate·translation 관계인지
- tenant filter 때문에 보이지 않았을 뿐 semantic relevance는 있는지
Teacher score 차가 작거나 annotator disagreement가 큰 pair는 낮은 weight를 줄 수 있습니다.
8. Loss를 목적에 맞춘다
간단한 binary pointwise loss:
L_point = - y log σ(s) - (1-y) log(1-σ(s))
Pairwise margin loss:
L_pair = max(0, margin - s(q,d+) + s(q,d-))
Listwise softmax loss:
P(d_i | q, C) = exp(s_i / τ) / Σ_j exp(s_j / τ)
L_list = - Σ_i target_i log P(d_i | q, C)
첫 iteration에서는 pairwise 또는 listwise batch를 권합니다. Production objective가 top-k ordering이기 때문입니다. Pointwise classification도 강한 baseline이며 threshold가 필요할 때 편합니다.
Multi-objective 예시
L = L_rank
+ 0.2 × L_distill
+ 0.1 × L_calibration
Coefficient는 dev set에서 정하고 test를 보며 조절하지 않습니다.
9. Training Config를 Artifact로 남긴다
run:
id: ce-v1
seed: 17
model:
base: BAAI/bge-reranker-v2-m3
revision: pinned-commit
max_length: 512
data:
corpus_sha256: "..."
candidates_sha256: "..."
split_version: v3
negative_mix:
random: 1
retrieval_hard: 3
model_hard: 2
training:
loss: pairwise_margin
margin: 0.2
learning_rate: 0.00002
epochs: 2
effective_batch_pairs: 256
mixed_precision: bf16
evaluation:
every_steps: 500
early_stop_metric: ndcg_at_10
Library default가 바뀌어도 재현되도록 tokenizer revision, padding/truncation side, mixed precision까지 남깁니다.
10. Truncation을 숨기지 않는다
Query와 document를 단순히 합쳐 max length에서 자르면 긴 문서의 뒤쪽 정답이 사라집니다.
[CLS] query [SEP] title + document [SEP]
└─ truncated here
선택지:
- title·heading을 우선 보존
- query token budget을 별도 예약
- 긴 document를 window score 후 aggregate
- passage-level index로 먼저 쪼갬
- long-context model을 별도 route
평가 log:
{
"query_id": "q-42",
"document_id": "d-17",
"input_tokens": 734,
"used_tokens": 512,
"truncated": true,
"answer_span_retained": false
}
answer_span_retained는 labeled evaluation subset에서만 계산해도 큰 진단 가치가 있습니다.
11. Ranking Metric 구현 시 Convention을 고정한다
import math
def dcg(grades, k):
return sum(
(2 ** grade - 1) / math.log2(rank + 2)
for rank, grade in enumerate(grades[:k])
)
def ndcg(ranked_ids, qrels, k):
observed = [qrels.get(doc_id, 0) for doc_id in ranked_ids[:k]]
ideal = sorted(qrels.values(), reverse=True)[:k]
denom = dcg(ideal, k)
return dcg(observed, k) / denom if denom else None
이 예시는 corpus qrel 기준 IDCG입니다. Unjudged를 0으로 간주합니다. Library와 비교할 때 gain function, cutoff, no-positive query 처리, candidate-relative IDCG 여부를 맞춥니다.
12. Query-level Paired Report
Aggregate 한 줄만 저장하지 않습니다.
{
"query_id": "q-42",
"slice": ["ko", "temporal"],
"candidate_hit_100": 1,
"baseline_ndcg_10": 0.6309,
"challenger_ndcg_10": 1.0,
"delta": 0.3691,
"top_documents_changed": ["d-17", "d-81"],
"truncation_rate": 0.12
}
그 위에 bootstrap confidence interval을 계산합니다.
import numpy as np
def paired_bootstrap(deltas, samples=10_000, seed=17):
rng = np.random.default_rng(seed)
values = np.asarray(deltas, dtype=float)
means = []
for _ in range(samples):
draw = rng.choice(values, size=len(values), replace=True)
means.append(draw.mean())
return np.quantile(means, [0.025, 0.5, 0.975]).tolist()
신뢰구간이 0을 넘는지만 보지 말고 worst regression query를 사람이 읽습니다.
13. Slice가 평균보다 먼저 막아야 할 것
slices:
language: [ko, en, mixed]
length: [short, medium, long, truncated]
intent: [fact, procedure, comparison, troubleshooting]
reasoning: [single_hop, multi_hop]
time: [evergreen, temporal]
answerability: [answerable, no_answer]
risk: [normal, security, compliance]
Critical slice에서는 평균 gain으로 regression을 상쇄하지 않습니다.
overall +3.2 nDCG
but security/compliance -8.1 nDCG
→ release fail
14. End-to-end RAG Gate
검색 metric이 통과하면 generator를 고정한 A/B를 실행합니다.
same query
same candidate retrieval
same context token budget
same generator + prompt + decoding
only reranker/selector differs
Metric:
- answer exactness / task score
- claim-level support
- citation precision·recall
- no-answer accuracy
- contradiction rate
- context redundancy
- generator input/output tokens
Answer judge를 쓰면 judge prompt·model version을 고정하고 human-audited subset으로 calibration합니다.
15. Latency Benchmark는 실제 길이 분포로
def benchmark_requests(service, requests):
results = []
for request in requests:
result = service.rerank(
query=request["query"],
documents=request["documents"],
deadline_ms=90,
)
results.append({
"latency_ms": result.latency_ms,
"pair_tokens": result.pair_tokens,
"fallback": result.fallback,
})
return results
Load generator는 production histogram을 재현합니다.
load_test:
qps: [10, 30, 60, 90]
concurrency: [1, 8, 32, 64]
candidate_depth_distribution: production_snapshot
pair_length_distribution: production_snapshot
duration_minutes: 15
report:
- p50_ms
- p95_ms
- p99_ms
- goodput_qps
- timeout_rate
- tokens_per_second
짧은 synthetic 문장만으로 throughput을 재면 padding과 truncation tail을 놓칩니다.
16. Cascade를 붙이는 순서
큰 model이 실제로 critical slice에서만 이긴다면 adaptive cascade를 만듭니다.
small reranker
├─ confident → return
└─ uncertain → large reranker
불확실성 baseline:
margin = score(top1) - score(top2)
entropy = entropy(softmax(scores / temperature))
disagreement = rank_distance(small, retriever)
Threshold는 다음 목적을 함께 최적화합니다.
maximize quality
subject to p95 <= 90ms
large_model_route <= 15%
Calibration split과 evaluation split을 분리합니다.
17. Security Regression Set
{
"query_id": "attack-17",
"candidate_id": "poison-3",
"clean_relevance": 0,
"attack": "naturalistic_rank_promotion",
"expected": {
"must_not_enter_top_k": 5,
"output_ids_must_be_allowlisted": true
}
}
최소 공격군:
- 직접 prompt injection
- 자연스러운 self-promotion
- Unicode·HTML·OCR hidden text
- candidate ID spoofing
- malformed output 유도
- 여러 공격 후보의 collusion
- metadata authority spoofing
LLM reranker 실패 시 deterministic Cross-Encoder 또는 retrieval order로 fallback합니다. ACL은 공격 test와 무관하게 model 밖에서 강제합니다.
18. Release Manifest
release:
id: reranker-ce-v1-2026-07-20
git_commit: abcdef0
data:
corpus_uri: s3://.../corpus-2026-07-01.jsonl
corpus_sha256: "..."
queries_version: v3
qrels_version: v5
candidates_version: hybrid-v6-top100
model:
base: BAAI/bge-reranker-v2-m3
base_revision: "..."
checkpoint_sha256: "..."
license_review: approved
input_contract:
serializer: pair-v4
tokenizer_revision: "..."
max_length: 512
truncation: query_reserved_title_first
runtime:
engine: tei
image_digest: sha256:...
precision: fp16
policy:
candidate_depth: 50
selector: coverage-v2
timeout_ms: 90
fallback: hybrid-v6-order
gates:
offline_report: reports/eval-ce-v1.json
load_report: reports/load-ce-v1.json
security_report: reports/attack-ce-v1.json
Manifest 하나로 “어떤 model이었나?”뿐 아니라 “어떤 ranking behavior를 배포했나?”에 답할 수 있어야 합니다.
19. Shadow → Canary → Ramp
offline pass
→ shadow 0% response influence
→ canary 1%
→ 5%
→ 25%
→ 50%
→ 100%
Shadow
기존 결과를 사용자에게 보내되 challenger도 병렬 실행합니다. Latency·score drift·rank movement·fallback을 관찰합니다. 사용자 feedback의 causal 효과는 알 수 없습니다.
Canary
작은 randomized traffic에서 실제 answer outcome과 guardrail을 봅니다. Tenant·language별 노출 균형을 관리합니다.
Automatic Rollback 예시
rollback_if:
acl_violation_count: "> 0"
p95_latency_ms: "> 105 for 10m"
fallback_rate: "> 0.02 for 10m"
answer_success_delta: "< -0.02 with minimum sample"
통계적으로 불충분한 작은 sample에서 품질 자동 rollback을 걸면 flapping할 수 있습니다. Safety violation과 품질 저하는 서로 다른 trigger를 둡니다.
20. 관측성과 Feedback Loop
Query trace에는 최소 다음을 남깁니다.
{
"trace_id": "t-993",
"release_id": "reranker-ce-v1-2026-07-20",
"query_hash": "...",
"candidate_depth": 50,
"candidate_ids_hash": "...",
"rank_change_at_5": 3,
"top1_margin": 0.18,
"truncated_pairs": 4,
"latency_ms": {"queue":5,"tokenize":9,"compute":44,"total":61},
"fallback": false,
"selected_document_ids": ["d-17","d-81"]
}
Privacy 때문에 raw query·document를 무제한 log하지 않습니다. 실패 sample은 승인된 sampling·redaction policy로 review queue에 보냅니다.
production failures
→ categorize
→ qrel correction / new slice / new hard negatives
→ dataset version bump
→ retrain or policy change
→ frozen re-evaluation
Online click을 곧바로 relevance truth로 쓰지 않습니다. Position bias와 UI exposure가 섞이므로 debiasing과 human audit가 필요합니다.
21. 실패 원인별 다음 행동
| 관찰 | 먼저 의심할 층 | 다음 실험 |
|---|---|---|
| Gold가 candidate에 없음 | Retriever/chunk/filter | candidate recall·source별 miss |
| Candidate에는 있으나 순위가 낮음 | Reranker | hard negative·truncation audit |
| nDCG 상승, 답변 동일 | Selector/generator | evidence coverage·reader ablation |
| 평균 상승, 긴 문서 하락 | Serialization | answer span retention·windowing |
| Offline 상승, online 하락 | Distribution/UI | traffic slice·position bias |
| 품질 상승, p95 폭증 | Serving | token bucket·depth·cascade |
| Injection 문서가 상승 | Trust boundary | allowlist parser·attack training·fallback |
이 table이 있어야 모든 문제를 “더 큰 reranker”로 해결하려는 오류를 피할 수 있습니다.
22. 최종 Definition of Done
- Corpus·query·qrel·candidate snapshot에 version과 checksum이 있다.
- Split이 query·source·시간 leakage를 막는다.
- No-rerank와 zero-shot baseline이 있다.
- Negative type과 false-negative audit 결과를 기록했다.
- Tokenizer·serializer·truncation policy가 고정됐다.
- Query-level paired result와 confidence interval이 있다.
- 한국어·긴 문서·multi-hop·no-answer·temporal critical slice를 통과했다.
- 같은 generator에서 answer·citation metric을 통과했다.
- Production length distribution에서 p95/p99·goodput을 측정했다.
- Injection·invalid output·timeout fallback을 시험했다.
- Model·runtime·policy를 하나의 manifest로 release한다.
- Shadow·canary·rollback·feedback loop가 준비됐다.
스스로 확인하기
- Frozen candidates 없이 두 reranker를 비교하면 어떤 confound가 생기는가?
- Random query-document pair split이 leakage를 만드는 이유는 무엇인가?
- 평균 nDCG가 올라도 release를 막아야 하는 세 가지 상황은 무엇인가?
- Checkpoint hash만으로 ranking behavior를 재현할 수 없는 이유는 무엇인가?
- Production failure가 retriever, reranker, selector, generator 중 어디에서 생겼는지 판단하려면 어떤 trace가 필요한가?
시리즈를 마치며
Reranker는 “검색 결과를 한 번 더 정렬하는 model”에서 시작하지만, 제대로 운영하려면 Learning to Rank, interaction architecture, data curation, uncertainty, information gain, serving, security를 잇는 작은 시스템이 됩니다.
가장 실용적인 학습 순서는 다음입니다.
two-stage ceiling
→ direct Cross-Encoder baseline
→ frozen evaluation
→ data / loss improvement
→ end-to-end RAG utility
→ latency / cascade / robustness
→ only then reasoning or multimodal frontier
새 model 이름보다 이 순서를 기억하면 연구 결과를 빠르게 검증하고 안전하게 적용할 수 있습니다.
참고자료
- Sentence Transformers CrossEncoder Documentation
- BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models
- MTEB Two-stage Reranking Guide
- Hugging Face Text Embeddings Inference
- vLLM Scoring Models and Rerank APIs
- Hard Negatives, Hard Lessons: Revisiting Negative Mining for Dense Retrieval
- Reranking 학습 데이터 설계
- Reranker 평가 설계
- Reranker Serving