Field Log · Entry

실전: 재현 가능한 Production Reranking Pipeline 만들기 (14/14)

고정 후보 데이터셋에서 baseline 학습과 paired 평가, 부하 시험, canary, release manifest, rollback으로 이어지는 production reranking 실습

이번 글의 결론

  • 재현 가능한 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은 세 개면 충분하다

  1. No rerank: retrieval order
  2. Zero-shot pretrained Cross-Encoder
  3. 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/filtercandidate recall·source별 miss
Candidate에는 있으나 순위가 낮음Rerankerhard negative·truncation audit
nDCG 상승, 답변 동일Selector/generatorevidence coverage·reader ablation
평균 상승, 긴 문서 하락Serializationanswer span retention·windowing
Offline 상승, online 하락Distribution/UItraffic slice·position bias
품질 상승, p95 폭증Servingtoken bucket·depth·cascade
Injection 문서가 상승Trust boundaryallowlist 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가 준비됐다.

스스로 확인하기

  1. Frozen candidates 없이 두 reranker를 비교하면 어떤 confound가 생기는가?
  2. Random query-document pair split이 leakage를 만드는 이유는 무엇인가?
  3. 평균 nDCG가 올라도 release를 막아야 하는 세 가지 상황은 무엇인가?
  4. Checkpoint hash만으로 ranking behavior를 재현할 수 없는 이유는 무엇인가?
  5. 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 이름보다 이 순서를 기억하면 연구 결과를 빠르게 검증하고 안전하게 적용할 수 있습니다.

참고자료