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
이 글은 production reranking의 설계 가이드입니다. Python과 Sentence Transformers 학습·배포 코드는 부분 예시이며, 데이터 수집부터 모델 학습·실제 부하 시험까지 한 명령으로 완료하는 프로젝트는 아닙니다. 바로 실행할 수 있도록 제공하는 것은 다음의 작은 frozen-candidate metric 검산입니다.
먼저 실행할 수 있는 후보·Metric 검산
검산 안내와 retrieval-toy.mjs, fixture.json, expected.json을 같은 폴더에 저장합니다. Node.js 20 이상에서 외부 dependency·모델 다운로드·API key 없이 실행합니다.
node retrieval-toy.mjs
문서 4개와 query 3개의 vector·qrel·reranker score를 사람이 정한 toy입니다. Query별 exact cosine 후보 2개를 고정하고 순서만 바꿉니다. 기대 출력은 candidateRecall=0.25, baselineNdcg=0.289382, challengerNdcg=0.45866이며, positive qrel 없는 1개 query는 평균에서 제외됐다고 표시합니다. 이 개선은 수동 score로 구성한 예시의 계산 결과이며 모델 성능이 아닙니다.
특히 정답이 후보에 없는 query는 reranking 후에도 0점이고, 일부 gold가 후보 밖에 있으면 oracle nDCG도 1보다 작다는 점을 검산합니다. Embedding pipeline 편과 평가 코드를 공유하되, 이 글에서는 candidate set을 바꾸지 않은 비교와 production gate에 집중합니다. 실제 학습 결과에는 따로 dependency lock, model·data revision, 실행 명령과 로그를 붙여야 합니다.
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
Reader questions
읽으며 남긴 질문
글을 읽으며 붙잡아 둔 질문입니다. 다시 살펴보거나, 보내지 않은 질문을 한 번에 블로그 주인에게 전달할 수 있습니다.