Field Log · Entry

Cross-Encoder Reranker: Joint Attention부터 실전 추론까지 (3/14)

Query와 document token을 한 Transformer에 넣어 모든 layer에서 joint attention한 뒤 relevance score로 재정렬하는 Cross-Encoder 구조

이번 글의 결론

  • Cross-Encoder는 query와 document를 하나의 sequence로 넣어 모든 Transformer layer에서 token-level joint attention을 계산합니다.
  • Query와 무관한 document vector를 만들 수 없으므로 corpus 전체 검색보다 작은 candidate reranking에 적합합니다.
  • Output은 model마다 logit·regression 값·class probability가 다릅니다. score=0.8의 의미를 model card와 activation에서 확인해야 합니다.
  • Candidate 수뿐 아니라 (query + document) token 길이가 비용을 결정합니다. 조용한 truncation은 품질 실험을 무효로 만들 수 있습니다.
  • 첫 baseline은 frozen candidate list, stable tie-break, token·latency trace를 포함해야 재현 가능합니다.

앞 글에서 ranking loss의 공통 언어를 만들었습니다. 이제 가장 널리 쓰이는 neural reranker 구조인 Cross-Encoder를 해부합니다.

Bi-Encoder
  q → encoder → one vector ─┐
                            ├─ dot product
  d → encoder → one vector ─┘

Cross-Encoder
  [query tokens ; document tokens]

     one Transformer stack

       relevance score

1. 입력은 Pair 하나의 Sequence다

BERT-style Cross-Encoder의 전형적인 입력은 다음과 같습니다.

[CLS] query tokens [SEP] document tokens [SEP]

RoBERTa, XLM-R, ModernBERT, decoder-only model은 special token 형식이 다를 수 있습니다. 핵심은 query와 document가 같은 forward pass에 들어간다는 점입니다.

예를 들어:

Query:
Orion S2 승인된 emergency shutdown 온도

Document:
Safety Manual v4 · Approved
The S2 controller initiates emergency shutdown at 88°C.

Model은 다음 관계를 layer마다 만들 수 있습니다.

"S2"        ↔ "S2 controller"
"승인된"    ↔ "Approved"
"emergency" ↔ "emergency shutdown"
"온도"      ↔ "88°C"

Bi-Encoder는 각 text를 query와 독립적인 single vector로 압축한 뒤 마지막에 dot product합니다. Cross-Encoder는 그 압축 전에 상대 문맥을 봅니다.

2. Joint Attention이 주는 표현력

Self-attention layer에서 query token i는 document token j를 직접 참고할 수 있습니다.

Attention(Q, K, V) = softmax(QKᵀ / √d_k) V

한 sequence 안에 있으므로 attention matrix에는 다음 block이 모두 있습니다.

┌─────────────────────────────┐
│ query → query │ query → doc │
│───────────────┼─────────────│
│ doc → query   │ doc → doc   │
└─────────────────────────────┘

이 구조는 특히 다음 mismatch를 구분하는 데 유리합니다.

  • exact identifier: E104 vs E140
  • number·unit: 88°C vs 88°F
  • negation: “지원한다” vs “지원하지 않는다”
  • entity relation: 누가 누구를 인수했는가
  • temporal status: current vs archived
  • field alignment: shutdown temperature vs shutdown pressure

하지만 “joint attention을 썼으니 항상 정확하다”는 결론은 아닙니다. Training label이 해당 distinction을 가르치지 않았다면 architecture의 capacity가 있어도 사용하지 못합니다.

3. 왜 Document Embedding을 미리 만들 수 없는가

Cross-Encoder의 document representation은 query에 따라 달라집니다.

h_d(q₁, d) ≠ h_d(q₂, d)

같은 문서라도 query가 온도를 물으면 temperature token에, 승인 상태를 물으면 metadata token에 interaction이 집중될 수 있습니다.

따라서 다음 cache는 만들 수 없습니다.

document_id → reusable one-vector-for-all-queries

대신 exact pair score는 제한적으로 cache할 수 있습니다.

cache key = hash(
  normalized_query,
  document_content_version,
  reranker_version,
  input_template_version,
)

Query 표현, document 내용, model, template 중 하나가 바뀌면 invalidation해야 합니다. Long-tail query에서는 hit rate가 낮을 수 있습니다.

4. 계산 비용은 Candidate 수와 길이에서 온다

Vanilla self-attention의 sequence length 비용은 대략 quadratic입니다.

L = tokens(query) + tokens(document) + special tokens
attention work per pair ≈ O(L²)
total pair work ≈ Σ_i O(L_i²)

실제 latency에는 embedding, feed-forward layer, kernel, padding, batch, device가 함께 작용합니다. 그래도 다음 두 knob가 핵심입니다.

  1. 몇 개 후보를 rerank하는가?
  2. 각 pair를 몇 token까지 읽는가?
top-20 × 256 tokens
top-100 × 512 tokens

둘은 단순히 5배 차이가 아닐 수 있습니다. 긴 sequence의 attention 비용과 padding waste가 함께 늘기 때문입니다.

5. Score Head의 형태

Encoder의 대표 representation에 projection head를 붙이는 전형적인 형태입니다.

h = Transformer([q; d])
s = wᵀ h_[CLS] + b

Model에 따라 다음 output이 가능합니다.

outputranking에 쓰는 값
Single logit[-2.1, 4.7, ...]raw logit 또는 monotonic transform
Binary logits[logit_not, logit_rel]relevant class logit·probability
Regression[-1, 1], 임의 범위scalar score
Multiclass gradeP(0), P(1), P(2), P(3)expected grade 등

Sigmoid는 순서를 바꾸지 않는다

Single logit s에 sigmoid를 적용해도 monotonic이므로 같은 query의 ranking은 같습니다.

s_i > s_j
⇔ sigmoid(s_i) > sigmoid(s_j)

따라서 순위만 필요하면 raw logit으로 충분할 수 있습니다. 하지만 threshold, 사용자 표시, 여러 query 간 비교에는 calibration이 별도 문제입니다.

서로 다른 Model Score를 바로 더하지 않는다

0.9 from model A
3.2 from model B

Scale과 activation이 다릅니다. Retriever score와 reranker logit도 마찬가지입니다. Weighted fusion 전에 normalization·calibration을 validation set에서 학습해야 합니다.

6. Sentence Transformers로 가장 작은 Baseline 만들기

다음 코드는 query와 후보를 pair로 만들어 rerank합니다. 예시는 multilingual Cross-Encoder model ID를 사용하지만, 실제 선택은 10편의 in-domain bake-off를 거쳐야 합니다.

from dataclasses import dataclass

import numpy as np
from sentence_transformers import CrossEncoder


@dataclass(frozen=True)
class Candidate:
    document_id: str
    text: str
    retrieval_rank: int
    retrieval_score: float | None = None


@dataclass(frozen=True)
class RerankedCandidate:
    candidate: Candidate
    rerank_score: float
    rerank_rank: int


MODEL_ID = "BAAI/bge-reranker-v2-m3"
model = CrossEncoder(MODEL_ID, max_length=512)


def rerank(
    query: str,
    candidates: list[Candidate],
    *,
    top_k: int,
    batch_size: int = 32,
) -> list[RerankedCandidate]:
    if not candidates:
        return []
    if not 1 <= top_k <= len(candidates):
        raise ValueError("top_k must be within candidate count")

    pairs = [(query, candidate.text) for candidate in candidates]
    raw = model.predict(
        pairs,
        batch_size=batch_size,
        show_progress_bar=False,
    )
    scores = np.asarray(raw, dtype=np.float32).reshape(-1)

    # score가 같으면 원 retrieval rank, document_id 순으로 고정한다.
    order = sorted(
        range(len(candidates)),
        key=lambda index: (
            -float(scores[index]),
            candidates[index].retrieval_rank,
            candidates[index].document_id,
        ),
    )[:top_k]

    return [
        RerankedCandidate(
            candidate=candidates[index],
            rerank_score=float(scores[index]),
            rerank_rank=rank,
        )
        for rank, index in enumerate(order, start=1)
    ]

왜 Tie-break를 명시하는가

Quantization, rounded API score, low-confidence candidate에서 동점이 생길 수 있습니다. 실행마다 순서가 바뀌면 diff와 cache가 불안정해집니다.

sort key = (-rerank_score, retrieval_rank, document_id)

같은 score에서는 기존 검색기의 근거를 보존하는 conservative policy입니다.

7. Input Template도 Model의 일부다

Model card가 instruction이나 template을 요구하면 정확히 지켜야 합니다.

query_document_pair
instruction + query + document
Query: {q} Document: {d}
<special tokens defined by tokenizer>

다음은 서로 다른 model input입니다.

"환불 기간" + document

"사용자 질문과 현재 승인된 정책 문서의 직접적인 답변 관련성을 평가하라"
+ "환불 기간"
+ title/status/date/body

Template이 바뀌면 score distribution과 ranking이 바뀔 수 있습니다. reranker_version에 prompt·serialization version을 포함합니다.

8. Structured Document를 Text로 직렬화한다

Metadata를 버리고 body만 넣으면 reranker가 version·authority를 판단할 수 없습니다.

def serialize_document(document: dict[str, str]) -> str:
    fields = [
        ("title", document.get("title", "")),
        ("status", document.get("status", "")),
        ("effective_date", document.get("effective_date", "")),
        ("source", document.get("source", "")),
        ("body", document.get("body", "")),
    ]
    return "\n".join(
        f"{name}: {value.strip()}"
        for name, value in fields
        if value and value.strip()
    )

Field 순서와 이름도 실험 변수입니다. JSON·YAML·plain delimiter 중 무엇이 좋다는 보편 법칙은 없습니다. Model의 training 형식과 token overhead를 확인합니다.

Untrusted document에는 instruction처럼 보이는 text가 들어갈 수 있습니다. 일반 Cross-Encoder classification model은 instruction LLM보다 공격면이 작을 수 있지만, keyword stuffing과 adversarial text에는 여전히 취약할 수 있습니다. Decoder-only instruction reranker의 prompt injection은 12편에서 다룹니다.

9. Truncation은 조용히 일어나는 Data Loss다

Max length가 512이고 query·special token이 40개라면 document에 남는 공간은 472개보다 작거나 같습니다.

document body: 2,400 tokens
model input:   first 470 tokens only
answer span:   token 1,820

Score가 낮은 이유가 relevance 부족이 아니라 answer span 삭제일 수 있습니다.

먼저 측정한다

def pair_token_length(query: str, document: str) -> int:
    encoded = model.tokenizer(
        query,
        document,
        add_special_tokens=True,
        truncation=False,
    )
    return len(encoded["input_ids"])


lengths = [pair_token_length(query, c.text) for c in candidates]
truncation_rate = sum(length > model.max_length for length in lengths) / len(lengths)

Tokenizer마다 pair encoding 형식이 다르므로 whitespace word count 대신 실제 tokenizer를 씁니다.

Head-only Truncation

문서 앞부분에 title·summary가 잘 배치돼 있으면 강한 baseline입니다. 답이 뒤에 자주 있으면 실패합니다.

Head + Tail

정책 결론이 끝에 있는 문서에 유리할 수 있지만 중간을 잃습니다.

Query-aware Windowing

Long document를 overlapping window로 나누고 각 window를 score합니다.

d → w1, w2, w3, ...
s(d) = max_j f(q, w_j)

max는 하나의 answer span만 있으면 강하지만 우연히 keyword-heavy window가 과대평가될 수 있습니다. top-m mean, logsumexp, learned aggregator도 비교할 수 있습니다.

def aggregate_window_scores(scores: list[float], top_m: int = 2) -> float:
    if not scores:
        return float("-inf")
    selected = sorted(scores, reverse=True)[:top_m]
    return sum(selected) / len(selected)

Document rank와 함께 best window·offset을 저장해야 generator에 같은 evidence를 전달할 수 있습니다.

10. Batch Size보다 Token Batch를 본다

다음 batch는 pair 수가 같아도 실제 work가 다릅니다.

Batch A: 32 pairs × ~90 tokens
Batch B: 32 pairs × ~500 tokens

Padding을 longest sequence에 맞추면 Batch B뿐 아니라 길이가 섞인 batch도 낭비가 큽니다.

short pairs ─┐
medium pairs ├─ sort/bucket by token length
long pairs  ─┘

Production dynamic batcher는 다음을 동시에 제한할 수 있습니다.

  • max pairs per batch
  • max total tokens per batch
  • max wait time
  • max sequence length
  • tenant·priority isolation

Batching은 throughput을 높일 수 있지만 queue delay 때문에 p95가 나빠질 수 있습니다. Offline pairs/s만 보고 설정하지 않습니다.

11. Candidate Depth Sweep

고정 evaluation queries에서 다음 grid를 재생합니다.

N ∈ {10, 20, 50, 100, 200}
max_length ∈ {256, 384, 512}
batch_tokens ∈ {4k, 8k, 16k}

기록할 값:

영역metric
CandidateRecall@N, evidence coverage@N
RankingMRR@10, nDCG@5/10, Precision@5
Inputtoken p50/p95, truncation rate
Servingp50/p95, pairs/s, queue time, GPU memory
RAGanswer correctness, support, abstention

Candidate depth를 늘리면 gold recall은 오를 수 있지만 hard distractor도 많아집니다. Reranker가 큰 candidate distribution에서 학습되지 않았다면 순위가 오히려 흔들릴 수 있습니다.

12. 설명 가능성을 과장하지 않는다

Attention heatmap이나 token attribution은 debugging signal이 될 수 있지만 “model이 이 근거로 결정했다”는 완전한 인과 설명은 아닙니다.

더 실용적인 error analysis는 counterfactual입니다.

원본: Approved · S2 · 88°C
변형 1: Draft    · S2 · 88°C
변형 2: Approved · S1 · 88°C
변형 3: Approved · S2 · 88°F

각 필드를 바꿨을 때 score와 rank가 기대 방향으로 움직이는지 봅니다. 이 방식은 domain requirement와 직접 연결됩니다.

13. Cross-Encoder Baseline 검증표

  • Model card의 language, license, max length, input template을 확인했다.
  • Output이 logit·probability·regression 중 무엇인지 기록했다.
  • Frozen candidate list로 비교한다.
  • Stable tie-break가 있다.
  • 실제 tokenizer 기준 token length와 truncation rate를 기록한다.
  • Long document window와 document aggregation을 분리한다.
  • Candidate depth와 max length를 함께 sweep한다.
  • Batch의 pair 수뿐 아니라 total token을 제한한다.
  • Retrieval rank와 rerank rank의 이동을 trace한다.
  • Threshold를 쓸 경우 별도 calibration을 수행한다.

스스로 확인하기

  1. Cross-Encoder가 document embedding을 corpus indexing 때 미리 만들 수 없는 이유는 무엇인가?
  2. Single logit에 sigmoid를 적용해도 같은 query의 순위가 바뀌지 않는 이유는 무엇인가?
  3. Max length를 늘리지 않고 긴 문서의 뒤쪽 answer span을 살리는 방법은 무엇인가?
  4. Batch size 32라는 정보만으로 GPU work를 비교할 수 없는 이유는 무엇인가?
  5. Attention visualization보다 field counterfactual이 production debugging에 유용한 경우는 언제인가?

다음 글에서는 relevance를 true/false token으로 표현한 monoT5, pairwise duoT5, ranking loss를 직접 쓰는 RankT5, 여러 후보를 함께 읽는 ListT5를 구분합니다.

참고자료