Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Hybrid retrieval can find the necessary evidence inside a retrieval-augmented generation (RAG) system and still leave it outside the generator's context. Consider this fictional enterprise policy lab:
Today is day 10 after deprecation. Can a service account use the legacy token endpoint with audit logging enabled?
The retriever fires across the policy catalog. BM25 catches exact keyword overlap on an operational note (api-token-troubleshooting-v1) that mentions legacy token errors but explicitly warns that it doesn't authorize access. Dense vector search matches semantic proximity on a password-reset document (api-password-reset-v1). The actual governing rule, api-token-legacy-v2-rule (Rule AUTH-14), allows service accounts to use the endpoint within 14 days of deprecation if audit logging is active. That rule is retrieved at rank 3.
The generator prompt budget accepts only two passages. The troubleshooting note and the password-reset policy occupy the prompt while Rule AUTH-14 stays outside it. An assistant might consequently produce an unsupported refusal: "No, legacy token access is strictly forbidden." We haven't run a generator here; that response illustrates the downstream risk.
For this question's one labeled relevant chunk, candidate recall is 1: the rule is in the candidate set. The four candidates have trusted, current permission decisions in the fixture. This demonstrates an ordering failure at cutoff two, not corpus-wide retrieval quality or a production privacy guarantee.
This continues the hybrid-search pipeline: policy-answerer-v2 filters for current, permitted chunks, then fuses sparse and dense ranks. policy-answerer-v3 adds a reranker to score each query-chunk pair before context selection. The executable labs use a hand-written scorer to test ordering and trace contracts. They don't run a learned model or establish its accuracy.

The boundary: retrieve candidates, then improve their order
A two-stage retriever separates two problems. Retrieval decides whether evidence gets a chance to compete; reranking decides which admitted evidence reaches the top. Generation should support its claims with selected evidence. Merely restricting the prompt doesn't prevent a model from inventing claims or using its parametric memory.
| Stage | Question | Optimized signal | Must never change |
|---|---|---|---|
| Hybrid retrieval | Is useful evidence in the candidate set? | Recall over current permitted chunks | Authorization and freshness boundary |
| Reranking | Which retrieved chunks best answer this query? | Precision near the top | Candidate membership |
| Generation | What answer can be supported? | Grounded response with citations | Claims supported by selected evidence |
The target is in the candidate set but below a two-chunk cutoff. Reranking may change its rank and selected context, not its source identity or access permissions.
The reranker can't restore a missing document. It must stay inside the same permission boundary: never search a restricted or superseded document as a shortcut. Permitted hybrid candidates enter scoring; admin-only and superseded records never do. That skip is the boundary, not a later filter.
This pipeline accepts permitted candidates, scores their query-chunk pairs, selects context, and records the decision. A freshness or permission revocation may remove a candidate between stages; it must never add a forbidden one. The fixed-membership examples assume an unchanged authorized snapshot.
Why does the reranker receive the permitted hybrid candidate list instead of every policy chunk?
Answer
Reranking is an ordering stage, not an authorization stage. Scoring restricted or stale chunks can leak their content through scores, logs, caches, or final context even if they're filtered later.
Start with the first-stage receipt, before any model score. The small fixture shows api-token-legacy-v2-rule at rank 3. Its presence confirms recall; its position predicts a miss at a two-chunk cutoff. Predict the next output: the answer stays out until some stage changes the order.
1from __future__ import annotations
2
3from dataclasses import dataclass
4from math import isfinite, log2
5
6def finite_number(value: object) -> bool:
7 if type(value) not in (int, float):
8 return False
9 try:
10 return isfinite(value)
11 except OverflowError:
12 return False
13
14def nonnegative_integer(value: object, name: str) -> None:
15 if type(value) is not int or value < 0:
16 raise ValueError(f"{name} must be a nonnegative integer")
17
18@dataclass(frozen=True)
19class Candidate:
20 chunk_id: str
21 document_id: str
22 parent_id: str
23 version: str
24 permitted: bool
25 current: bool
26 first_stage_rank: int
27 text: str
28
29QUERY = (
30 "legacy token endpoint for service account on day 10 after deprecation "
31 "with audit logging enabled"
32)
33TARGET_ID = "api-token-legacy-v2-rule"
34CANDIDATES = [
35 Candidate(
36 "api-token-troubleshooting-v1",
37 "api-token-troubleshooting",
38 "api-token-troubleshooting-v1",
39 "api-token-troubleshooting/2026-04-20",
40 True,
41 True,
42 1,
43 (
44 "Legacy token endpoint errors can be inspected during migration. "
45 "This note does not authorize temporary access."
46 ),
47 ),
48 Candidate(
49 "api-password-reset-v1",
50 "api-password-reset",
51 "api-password-reset-v1",
52 "api-password-reset/2026-01-03",
53 True,
54 True,
55 2,
56 "Password reset tokens expire after 30 minutes.",
57 ),
58 Candidate(
59 "api-token-legacy-v2-rule",
60 "api-auth",
61 "api-auth-v2",
62 "api-auth/2026-04-01",
63 True,
64 True,
65 3,
66 (
67 "Rule AUTH-14. Service accounts may use the legacy token endpoint "
68 "within 14 days of deprecation when audit logging is enabled."
69 ),
70 ),
71 Candidate(
72 "api-audit-export-v1",
73 "api-audit",
74 "api-audit-v1",
75 "api-audit/2026-03-12",
76 True,
77 True,
78 4,
79 "Audit logs can be exported within 14 days.",
80 ),
81]
82
83first_stage = sorted(CANDIDATES, key=lambda candidate: candidate.first_stage_rank)
84first_stage_ids = [candidate.chunk_id for candidate in first_stage]
85print("Hybrid order:", first_stage_ids)
86assert TARGET_ID in first_stage_ids
87assert first_stage_ids.index(TARGET_ID) + 1 == 31Hybrid order: ['api-token-troubleshooting-v1', 'api-password-reset-v1', 'api-token-legacy-v2-rule', 'api-audit-export-v1']The candidate record carries document_id and parent_id from the earlier RAG pipeline. Reranking may move evidence, but citation packing still points to the same source identity.
Keep the evidence boundary executable
Test the first-stage allowlist against a source store containing an admin-only rule and an expired revision. Neither should enter reranking. Here, permitted and current are trusted fixture decisions, not fields supplied by the question or source text.
1SOURCE_STORE = CANDIDATES + [
2 Candidate(
3 "admin-token-legacy",
4 "admin-token-terms",
5 "admin-token-terms",
6 "admin-token/2026-05-01",
7 False,
8 True,
9 0,
10 "Admin service accounts receive immediate legacy token access.",
11 ),
12 Candidate(
13 "api-token-legacy-v1-rule",
14 "api-auth",
15 "api-auth-v1",
16 "api-auth/2025-02-01",
17 True,
18 False,
19 0,
20 "Service accounts may use the legacy token endpoint within 30 days.",
21 ),
22]
23FIRST_STAGE_RECORDS = {
24 (candidate.chunk_id, candidate.version): candidate
25 for candidate in first_stage
26}
27
28def in_snapshot(candidate: Candidate) -> bool:
29 return (
30 isinstance(candidate, Candidate)
31 and candidate.permitted is True
32 and candidate.current is True
33 and FIRST_STAGE_RECORDS.get((candidate.chunk_id, candidate.version)) == candidate
34 )
35
36def rerankable_candidates(store: list[Candidate]) -> list[Candidate]:
37 return [
38 candidate
39 for candidate in store
40 if in_snapshot(candidate)
41 ]
42
43rerankable = rerankable_candidates(SOURCE_STORE)
44blocked_ids = sorted(
45 candidate.chunk_id
46 for candidate in SOURCE_STORE
47 if candidate not in rerankable
48)
49print("Rerankable:", [candidate.chunk_id for candidate in rerankable])
50print("Blocked:", blocked_ids)
51assert rerankable == CANDIDATES
52assert blocked_ids == ["admin-token-legacy", "api-token-legacy-v1-rule"]1Rerankable: ['api-token-troubleshooting-v1', 'api-password-reset-v1', 'api-token-legacy-v2-rule', 'api-audit-export-v1']
2Blocked: ['admin-token-legacy', 'api-token-legacy-v1-rule']The snapshot compares complete records, including text and parent identity. Matching an ID and version alone would admit changed content under an old receipt. In a service, fetch the recorded revisions and recheck the caller's current permissions before remote scoring and context packing. A changed record needs a fresh retrieval decision. Sending text to a hosted reranker is itself a disclosure: user access doesn't automatically authorize an external processor. Scope caches, logs, and provider use to the same data-handling policy.
Now replay context packing without reranking. The generator receives a troubleshooting note that grants no temporary access and a password-reset policy. The legacy-token rule is retrieved but absent from context.
1CONTEXT_BUDGET = 2
2
3def pack_context(candidates: list[Candidate], budget: int) -> list[str]:
4 nonnegative_integer(budget, "context budget")
5 if len({candidate.chunk_id for candidate in candidates}) != len(candidates):
6 raise ValueError("duplicate context candidate IDs")
7 if any(not in_snapshot(candidate) for candidate in candidates):
8 raise ValueError("context candidate outside authorized snapshot")
9 return [candidate.chunk_id for candidate in candidates[:budget]]
10
11context_before = pack_context(first_stage, CONTEXT_BUDGET)
12print("Context before rerank:", context_before)
13print("Target reaches generation:", TARGET_ID in context_before)
14assert TARGET_ID not in context_before1Context before rerank: ['api-token-troubleshooting-v1', 'api-password-reset-v1']
2Target reaches generation: FalseThe current hybrid search ordering leaves out the right chunk. Reranking is one possible repair; improving first-stage ranking or context selection is another. What additional signal comes from reading the query and chunk together?
Read the scoring contract, not just the model's family name
Which tensor becomes the score? Does it depend on one passage or the whole list? Those questions explain both serving behavior and cache validity. A decoder backbone doesn't imply that a reranker generates a ranked list or even uses next-token probabilities.
BERT-style encoder classification heads
BERT-style cross-encoders concatenate the query and candidate chunk: [CLS] Query [SEP] Chunk [SEP]. Bidirectional self-attention lets the two sides interact before pooling.[1][2] This is one architecture, not a description of every checkpoint sold under the BGE or Jina name.
Let be the final hidden state of the [CLS] token. A linear projection head maps this pooled vector to a scalar logit:
Applying a sigmoid function bounds the logit into . During pointwise training, each query-passage example carries a binary relevance label and contributes binary cross-entropy:
At inference time, there are no gold labels or gradient updates. The model computes (or ) for each candidate and sorts descending. Mathematically, sigmoid is strictly increasing, so it preserves order. Finite-precision saturation can introduce ties near 0 or 1. A sigmoid score isn't automatically a calibrated relevance probability. Sentence Transformers documents checkpoints that return raw logits and an optional sigmoid transformation.[3]
Pointwise scoring evaluates each pair in isolation, but training may use a pairwise preference loss. Given a relevant passage with logit and an irrelevant passage with logit , the pairwise logistic preference loss penalizes inverted score differences:[4]
A model trained with pairwise preference losses still scores individual query-passage pairs independently at serving time. It doesn't need to evaluate all passage pairs during inference.
Token-based scoring and decoder classification heads
monoT5 is an encoder-decoder model trained to predict true or false; its published scoring method renormalizes probabilities over those two target tokens.[5] Qwen3-Reranker uses a causal decoder and lowercase yes/no logits at the final prompt position.[6] An illustrative prompt might be:
1Instruction: Decide whether the document satisfies the query conditions.
2Query: legacy token endpoint for service account on day 10 with audit logging
3Document: Rule AUTH-14. Service accounts may use the legacy token endpoint...
4Relevant (Yes/No):Use the checkpoint's actual template and tokenizer, not this sketch. For two single-token labels at the same prediction position, log-odds equal the difference between their logits:
Renormalizing over just these two labels gives:
This is not the full-vocabulary probability : other possible tokens were removed from the denominator. Binary-label methods need the relevant forward computation, not a long generated explanation. An encoder-decoder also runs its encoder; don't equate its execution path with a causal decoder.
Other decoder-based scorers have different contracts. BGE-reranker-v2-gemma's example takes the single Yes logit, without subtracting No. RankLLaMA's published model example loads a sequence-classification model with one scalar output.[7][8] Neither should be described as the binary formula above.
| Scoring contract | Example | Input dependence | Cost drivers to measure |
|---|---|---|---|
| Pooled-state classification head | BERT-style cross-encoder | One query-passage pair | Checkpoint size, pair lengths, batch shapes, precision |
| Target-token binary score | monoT5; Qwen3-Reranker | One templated query-passage pair | Encoder/decoder architecture, template tokens, pair lengths |
| Decoder classification head | RankLLaMA | One query-passage pair | Backbone, adapters, lengths, serving implementation |
| Whole-list interaction | Jina Reranker v3.5 | Query and the complete candidate list | Total list length, attention pattern, ordering and batching |
As of September 21, 2026, Qwen3-Reranker provides 0.6B, 4B, and 8B checkpoints under Apache 2.0. Its card documents 32K context, while the example wrapper chooses max_length=8192; a wrapper can impose a smaller effective limit.[6] Jina Reranker v3.5 is a 0.6B listwise model with causal attention and cosine-based scoring, not the BERT-style head above. Its published weights use CC BY-NC 4.0; check deployment licensing before commercial use.[9]
Hosted Cohere Rerank accepts a query and candidate list and returns ranked items with relevance scores.[10] API shape alone doesn't establish that each score is independent of the other candidates. Benchmark the actual checkpoint or service on your evidence and workload; family names don't supply universal latency, VRAM, or instruction-following guarantees.
Here are the losses for invented logits and . This computes the objective, not a trained model's performance.
1from math import exp, log1p
2
3def softplus(x: float) -> float:
4 return max(x, 0.0) + log1p(exp(-abs(x)))
5
6positive, negative = 1.2, 0.2
7pointwise_mean = (softplus(-positive) + softplus(negative)) / 2
8pairwise_loss = softplus(-(positive - negative))
9print(f"Pointwise mean loss: {pointwise_mean:.3f}")
10print(f"Pairwise preference loss: {pairwise_loss:.3f}")
11print(f"Preference loss after reversing scores: {softplus(positive - negative):.3f}")1Pointwise mean loss: 0.531
2Pairwise preference loss: 0.313
3Preference loss after reversing scores: 1.313Training data must represent the ranking task. Hard negatives from the same first-stage retriever expose plausible near matches; random unrelated negatives can make the task too easy. Keep query and document leakage out of held-out evaluation, and check negation, dates, and no-answer questions separately from an overall average.
Preserve the text that was actually scored
Joint interaction also introduces a pair-length ceiling. Original BERT uses a maximum sequence length of 512 tokens, including special tokens.[2] Other checkpoints expose different limits, so read the tokenizer and model configuration instead of assuming 512.
If a wrapper silently truncates an overlong pair, the model may score only a prefix while the generator later receives the full chunk. An omitted clause could determine relevance or qualify a rule. This unrecorded mismatch hides the scope of the score; it doesn't prove that the generated answer is wrong. Log tokenized pair length, define a truncation policy, and reject unexpected truncation under that policy.
| Policy | When to use | What to log |
|---|---|---|
| Preserve full query; truncate document tail | Only when a prefix is an acceptable scoring unit | tokenizer, max_length, truncated, kept_span |
| Sliding windows over the chunk | Authorizing clause may sit past the first window | Window offsets, per-window scores, aggregation rule |
| Refuse overlong pairs | Safety-critical policies where a partial score is unacceptable | Pair token count, refuse reason, fallback (human / no-score) |
| Score only the packed context text | Generator context is already budgeted | Checksum of scored text vs packed text |
Truncation isn't only a local-model concern. Check a local wrapper's tokenizer configuration and truncation strategy. Cohere's Rerank API truncates documents to max_tokens_per_doc, default 4096.[10] Either way, ranked text can disagree with packed text. A sliding-window strategy can retain later clauses, but max-over-window aggregation can favor long documents and omit qualifications from other windows. Record the aggregation policy and keep necessary surrounding context.
The next lab uses whitespace tokens only to expose the accounting. It reserves three illustrative BERT special tokens. A real implementation must use the checkpoint's tokenizer and exact special-token count.
1from hashlib import sha256
2import json
3
4@dataclass(frozen=True)
5class PairBudget:
6 max_pair_tokens: int
7 query_tokens: list[str]
8 chunk_tokens: list[str]
9 special_tokens: int = 3
10
11def tokenize(text: str) -> list[str]:
12 return text.lower().split()
13
14def truncate_document_tail(budget: PairBudget) -> tuple[list[str], bool]:
15 """Keep the full query, then fill the remaining budget from the chunk head."""
16 query = budget.query_tokens
17 nonnegative_integer(budget.max_pair_tokens, "max pair tokens")
18 nonnegative_integer(budget.special_tokens, "special tokens")
19 if budget.max_pair_tokens == 0:
20 raise ValueError("max pair tokens must be positive")
21 remaining = budget.max_pair_tokens - len(query) - budget.special_tokens
22 if remaining <= 0:
23 raise ValueError("no document room after query and special tokens")
24 kept_chunk = budget.chunk_tokens[:remaining]
25 truncated = len(kept_chunk) < len(budget.chunk_tokens)
26 return query + kept_chunk, truncated
27
28def token_checksum(tokens: list[str]) -> str:
29 serialized = json.dumps(tokens, ensure_ascii=False, separators=(",", ":"))
30 return sha256(serialized.encode("utf-8")).hexdigest()
31
32query = "May service accounts use the legacy token endpoint on day 10?"
33# The authorizing clause is at the end; retaining the head drops it.
34chunk = (
35 "Background: migration tooling and temporary endpoints are under review. "
36 "Operational notes discuss log shipping and dashboard ownership. "
37 "Rule AUTH-14. Service accounts may use the legacy token endpoint "
38 "within 14 days of deprecation when audit logging is enabled."
39)
40budget = PairBudget(
41 max_pair_tokens=24,
42 query_tokens=tokenize(query),
43 chunk_tokens=tokenize(chunk),
44)
45scored_tokens, was_truncated = truncate_document_tail(budget)
46packed_tokens = budget.query_tokens + budget.chunk_tokens # generator sees full text
47scored_checksum = token_checksum(scored_tokens)
48packed_checksum = token_checksum(packed_tokens)
49authorizing_clause = "within 14 days of deprecation when audit logging is enabled"
50scored_text = " ".join(scored_tokens)
51print("Truncated:", was_truncated)
52print("Scored pair tokens including specials:", len(scored_tokens) + budget.special_tokens, "/", budget.max_pair_tokens)
53print("Authorizing clause in scored text:", authorizing_clause in scored_text)
54print("Checksum match (scored == packed):", scored_checksum == packed_checksum)
55assert was_truncated
56assert authorizing_clause not in scored_text
57assert scored_checksum != packed_checksum
58# This fixture's policy refuses any scored/packed mismatch.
59release_ok = scored_checksum == packed_checksum
60assert release_ok is False
61print("Release gate (scored/packed mismatch):", release_ok)1Truncated: True
2Scored pair tokens including specials: 24 / 24
3Authorizing clause in scored text: False
4Checksum match (scored == packed): False
5Release gate (scored/packed mismatch): FalseThat failure is intentional: this lab refuses any scored/packed token mismatch. The checksum compares normalized toy token lists, not exact source bytes; lowercasing and whitespace splitting erase some differences. Real rerankers and generators can use different tokenizers, so record the scored source span and exact text separately from each model's token accounting. A parent expansion or explicit window score can be valid if its scope and aggregation are recorded.
With the text boundary explicit, test ranking independently of a model endpoint. The next fixture uses transparent phrase rules, not a trained transformer. Its integer scores exercise ordering and packing code; they aren't semantic entailment or authorization decisions.
1@dataclass(frozen=True)
2class Request:
3 endpoint: str
4 remedy: str
5 days_since_deprecation: int
6 audit_enabled: bool
7
8def validate_request(request: Request) -> None:
9 if any(
10 not isinstance(value, str) or not value.strip()
11 for value in (request.endpoint, request.remedy)
12 ):
13 raise ValueError("endpoint and remedy must be nonempty strings")
14 nonnegative_integer(request.days_since_deprecation, "days since deprecation")
15 if type(request.audit_enabled) is not bool:
16 raise ValueError("audit enabled must be a Boolean")
17
18REQUEST = Request(
19 endpoint="legacy token endpoint",
20 remedy="temporary access",
21 days_since_deprecation=10,
22 audit_enabled=True,
23)
24validate_request(REQUEST)
25requirements = [
26 REQUEST.endpoint,
27 REQUEST.remedy,
28 "day 10 is within the 14-day window",
29 "audit logging is enabled",
30]
31print("Query conditions:", requirements)
32assert 0 <= REQUEST.days_since_deprecation <= 14
33assert REQUEST.audit_enabled1Query conditions: ['legacy token endpoint', 'temporary access', 'day 10 is within the 14-day window', 'audit logging is enabled']The rule should rank first for this question. The scorer awards phrase-match points and subtracts six for the troubleshooting note's disclaimer. This deliberately narrow heuristic can't distinguish arbitrary paraphrases or resolve conflicting policies. The audit-export chunk gets timing points even though its 14-day window applies to a different action, a useful reminder of the heuristic's limits.
1@dataclass(frozen=True)
2class PairScore:
3 candidate: Candidate
4 score: float
5 reasons: tuple[str, ...]
6
7class ConstraintAwarePairScorer:
8 def score(self, request: Request, candidate: Candidate) -> PairScore:
9 text = candidate.text.lower()
10 score = 0
11 reasons: list[str] = []
12
13 if request.endpoint.lower() in text:
14 score += 2
15 reasons.append("endpoint")
16 if request.remedy.lower() in text:
17 score += 3
18 reasons.append("remedy")
19 if "service accounts" in text:
20 score += 1
21 reasons.append("principal")
22 if "within 14 days" in text and 0 <= request.days_since_deprecation <= 14:
23 score += 2
24 reasons.append("migration-window")
25 if "audit logging is enabled" in text and request.audit_enabled:
26 score += 2
27 reasons.append("audit-condition")
28 if "does not authorize temporary access" in text:
29 score -= 6
30 reasons.append("non-authorizing-note")
31 return PairScore(candidate, score, tuple(reasons))
32
33def rerank(
34 request: Request,
35 candidates: list[Candidate],
36 scorer: ConstraintAwarePairScorer,
37) -> list[PairScore]:
38 validate_request(request)
39 if len({candidate.chunk_id for candidate in candidates}) != len(candidates):
40 raise ValueError("duplicate candidate IDs")
41 if any(not in_snapshot(candidate) for candidate in candidates):
42 raise ValueError("candidate outside authorized snapshot")
43 scored: list[PairScore] = []
44 for candidate in candidates:
45 result = scorer.score(request, candidate)
46 if (
47 not isinstance(result, PairScore)
48 or result.candidate != candidate
49 or not in_snapshot(result.candidate)
50 or not finite_number(result.score)
51 ):
52 raise ValueError("invalid score or changed candidate in scorer response")
53 scored.append(result)
54 return sorted(
55 scored,
56 key=lambda result: (-result.score, result.candidate.first_stage_rank, result.candidate.chunk_id),
57 )
58
59reranked = rerank(REQUEST, rerankable, ConstraintAwarePairScorer())
60reranked_ids = [result.candidate.chunk_id for result in reranked]
61for result in reranked:
62 print(result.candidate.chunk_id, result.score, result.reasons)
63assert reranked_ids[0] == TARGET_ID
64assert rerank(REQUEST, [], ConstraintAwarePairScorer()) == []1api-token-legacy-v2-rule 7 ('endpoint', 'principal', 'migration-window', 'audit-condition')
2api-audit-export-v1 2 ('migration-window',)
3api-password-reset-v1 0 ()
4api-token-troubleshooting-v1 -1 ('endpoint', 'remedy', 'non-authorizing-note')The target policy is retrieved at rank 3 but the prompt accepts two chunks. Is this a recall failure or an ordering failure?
Answer
It's an ordering failure. Recall succeeded because the target is in the candidate set. Reranking must move it into the context budget.
Context selection now changes for the right reason. The candidate set stays fixed while the supported rule moves to rank 1. A context budget is a maximum, not a quota, so low-scoring near matches are not admitted merely to fill space.
Two chunks is only this lab's simplified budget. Production packing counts generator tokens after reserving instructions and output space, limits redundant passages from one parent document, and preserves clauses needed to interpret a rule. If packing expands a scored passage into a larger parent section, record that expansion explicitly rather than claiming every added sentence received the passage's score.
1MIN_CONTEXT_SCORE = 5
2selected_after = [
3 result.candidate
4 for result in reranked
5 if result.score >= MIN_CONTEXT_SCORE
6][:CONTEXT_BUDGET]
7context_after = pack_context(selected_after, CONTEXT_BUDGET)
8print("Context before:", context_before)
9print("Context after:", context_after)
10assert TARGET_ID in context_after
11assert "api-token-troubleshooting-v1" not in context_after
12assert set(reranked_ids) == set(first_stage_ids)1Context before: ['api-token-troubleshooting-v1', 'api-password-reset-v1']
2Context after: ['api-token-legacy-v2-rule']The fixture's threshold of 5 separates this rule from these three distractors. It isn't a permission rule: with audit disabled, the rule still scores 5 and remains relevant because it explains why access isn't allowed. A generator must apply the actual policy conditions, not interpret a high relevance score as an automatic affirmative. Scores that rank a denial highly can be completely correct.
Score calibration and safe admission thresholds
Learned scores aren't automatically calibrated probabilities. A sigmoid score of 0.85 doesn't establish an 85% chance that the document answers the question, and 0.80 doesn't establish twice the relevance of 0.40. A calibration procedure must define and test the event its probability represents.
Scores can vary with query type, candidate distribution, and checkpoint. Imagine a hypothetical no-answer query whose best distractor scores 0.90 and an answerable query whose best evidence scores 0.60. The shared threshold alone can't separate them. These numbers illustrate the problem; they aren't measured score ranges for broad or technical queries.
An unvalidated static threshold (such as score >= 0.70) can produce two failure modes:
- Evidence starvation: Useful evidence below the threshold never reaches generation.
- Noise admission: Irrelevant candidates above the threshold consume context.
Compare these admission strategies on labeled development cases, including no-answer cases:
- Rank-first with context budget: Take the highest-ranked items that fit. Strictly increasing score transformations preserve mathematical order. This avoids a fixed score scale but still selects distractors when every candidate is irrelevant; it isn't an abstention rule.
- Score margin falloff gating: Rather than filtering on an absolute score, filter candidates whose score falls significantly below the top candidate's score: A common additive shift leaves this rule unchanged; changing score scale generally doesn't. Nearby scores don't prove that sources agree or that any is relevant. Tune for the checkpoint and inspect admitted evidence.
- Calibrated admission or abstention: Platt scaling fits ; isotonic regression fits a monotone mapping.[11] Define carefully: passage relevance isn't the same event as having enough evidence for a complete answer. Fit calibration and choose thresholds on development data independent of scorer training, then evaluate reliability and abstention on an untouched test set. Revalidate when the checkpoint, tokenizer, scoring policy, or workload changes.
Measure candidate recall and top-of-list relevance
With pair scoring in place, ask how to prove it improves the stage that owns the failure. Keep evaluation narrow, and let each metric answer a different question:
- Recall@candidate_k checks whether first-stage retrieval gave the reranker a chance.
- Mean reciprocal rank (MRR) averages how early the first relevant chunk appears across a release suite.
- Normalized discounted cumulative gain (NDCG) at the context cutoff checks whether relevant chunks fit near the top of the context budget.[12]
For this one request, reciprocal rank (RR) is easy to read: rank 3 gives ; rank 1 gives . MRR is the mean of those per-request values. NDCG supports graded relevance.
This lab uses binary relevance, where a relevant chunk has and an irrelevant chunk has . With binary labels, linear-gain and exponential-gain DCG formulas agree, so the numbers below don't depend on which variant you pick:
Predict the fixture result before running it: moving the only relevant chunk from rank 3 to rank 1 should change RR from to , and put the chunk inside NDCG@2.
1RELEVANT_IDS = {TARGET_ID}
2
3def reciprocal_rank(ids: list[str], relevant_ids: set[str]) -> float:
4 if len(ids) != len(set(ids)):
5 raise ValueError("duplicate ranked IDs")
6 for rank, chunk_id in enumerate(ids, start=1):
7 if chunk_id in relevant_ids:
8 return 1.0 / rank
9 return 0.0
10
11def ndcg_at_k(ids: list[str], relevant_ids: set[str], k: int) -> float:
12 nonnegative_integer(k, "NDCG cutoff")
13 if len(ids) != len(set(ids)):
14 raise ValueError("unique ranked IDs required")
15 dcg = sum(
16 (2 ** int(chunk_id in relevant_ids) - 1) / log2(rank + 1)
17 for rank, chunk_id in enumerate(ids[:k], start=1)
18 )
19 ideal_hits = min(len(relevant_ids), k)
20 idcg = sum((2**1 - 1) / log2(rank + 1) for rank in range(1, ideal_hits + 1))
21 return dcg / idcg if idcg else 0.0
22
23before_rr = reciprocal_rank(first_stage_ids, RELEVANT_IDS)
24after_rr = reciprocal_rank(reranked_ids, RELEVANT_IDS)
25before_ndcg = ndcg_at_k(first_stage_ids, RELEVANT_IDS, CONTEXT_BUDGET)
26after_ndcg = ndcg_at_k(reranked_ids, RELEVANT_IDS, CONTEXT_BUDGET)
27print(f"RR: {before_rr:.2f} -> {after_rr:.2f}")
28print(f"NDCG@{CONTEXT_BUDGET}: {before_ndcg:.2f} -> {after_ndcg:.2f}")
29assert after_rr > before_rr
30assert before_ndcg == 0.0 and after_ndcg == 1.01RR: 0.33 -> 1.00
2NDCG@2: 0.00 -> 1.00These numbers check this hand-labeled fixture, not model quality or generated-answer correctness. Deduplicate IDs before scoring: repeating a relevant chunk mustn't earn gain twice. This implementation defines NDCG as zero when there are no relevant labels; report that convention and evaluate no-answer behavior separately. Treat unjudged documents carefully rather than assuming every missing label means irrelevance.
For a held-out query suite, compare per-query scores on identical authorized candidates, then aggregate MRR/NDCG with uncertainty and query-type breakdowns. Also score the final packed context: filtering, deduplication, and token budgets can remove a highly ranked passage after the reranker.
Candidate count sets the ceiling and the bill
An ordering metric can't rescue an item that never reaches the reranker. If the reranker receives only the first two candidates, api-token-legacy-v2-rule is cut before pair scoring. Use a candidate-recall gate before comparing reranker models.
For relevant-set and candidate-set , candidate recall is . Any context selected solely from has recall no higher than that value. With two required passages but only one retrieved, even an oracle reranker can't exceed 50% passage recall. For multi-hop questions, measure whether the whole required evidence set is present, not just whether one relevant chunk appears.
Predict the budget tradeoff: top 2 is too small to score the target; top 3 gives it a chance. The lab keeps every other variable fixed so that ceiling is visible.
1def target_present(candidates: list[Candidate]) -> bool:
2 return TARGET_ID in [candidate.chunk_id for candidate in candidates]
3
4def recall_at_k(ids: list[str], relevant_ids: set[str], k: int) -> float:
5 nonnegative_integer(k, "candidate cutoff")
6 if len(ids) != len(set(ids)):
7 raise ValueError("unique candidate IDs required")
8 return len(set(ids[:k]) & relevant_ids) / len(relevant_ids) if relevant_ids else 0.0
9
10RERANK_CANDIDATE_BUDGET = 3
11too_small = first_stage[:2]
12release_input = first_stage[:RERANK_CANDIDATE_BUDGET]
13limited_ids = [result.candidate.chunk_id for result in rerank(
14 REQUEST, too_small, ConstraintAwarePairScorer()
15)]
16release_reranked = rerank(REQUEST, release_input, ConstraintAwarePairScorer())
17release_reranked_ids = [result.candidate.chunk_id for result in release_reranked]
18print("top-2 includes target:", target_present(too_small), limited_ids)
19print("top-3 includes target:", target_present(release_input), release_reranked_ids)
20print(f"Candidate recall@2: {recall_at_k(first_stage_ids, RELEVANT_IDS, 2):.1f}")
21print(f"Candidate recall@3: {recall_at_k(first_stage_ids, RELEVANT_IDS, 3):.1f}")
22assert not target_present(too_small)
23assert release_reranked_ids[0] == TARGET_ID1top-2 includes target: False ['api-password-reset-v1', 'api-token-troubleshooting-v1']
2top-3 includes target: True ['api-token-legacy-v2-rule', 'api-password-reset-v1', 'api-token-troubleshooting-v1']
3Candidate recall@2: 0.0
4Candidate recall@3: 1.0Compute and latency budgeting in the two-stage funnel
A pairwise cross-encoder scores each query-candidate pair online. Increasing candidate depth increases total work, but wall-clock latency also depends on batch scheduling and hardware. For dense attention with fixed layer count and hidden width, the attention component scales as for pair length . Doubling multiplies that component by roughly four, not necessarily the complete request latency.
Choose a retrieval shortlist by measuring the recall/latency frontier. There is no universal GPU millisecond range, CPU depth limit, or safe candidate count. Include actual query and passage lengths, queueing, concurrency, precision, model loading, and endpoint overhead in the measurement.
Batching also needs a concrete contract:
- Padding a batch to its longest sequence can waste work. Length bucketing reduces that waste, while waiting to form batches can add latency.
- Triton's default dynamic batching requires compatible input shapes. Ragged batching is optional configuration; the backend must handle concatenated inputs and their boundaries.[13] Selecting a runtime doesn't automatically remove padding.
- FlashAttention improves attention memory access; it doesn't remove dense attention's quadratic arithmetic. Whether a particular reranker supports variable-length execution must be checked in its implementation.
- Compare smaller checkpoints, quantization, and different batching strategies on both relevance and serving measurements. Faster execution alone doesn't establish preserved ranking quality.
This fixture uses a sequential cost model to make the candidate-depth trade-off visible. It isn't a p95 benchmark: production serving batches pairs, and percentiles must be measured end to end. Cohere recommends against sending more than 1,000 documents in one Rerank request; that's a hosted-API cousin of the same knob.[10]
1SEQUENTIAL_PAIR_COST_MS = 7.5
2REQUEST_OVERHEAD_MS = 4.0
3FIXTURE_LATENCY_BUDGET_MS = 30.0
4
5def estimated_fixture_latency_ms(candidate_count: int) -> float:
6 nonnegative_integer(candidate_count, "candidate count")
7 if not finite_number(candidate_count):
8 raise ValueError("candidate count is too large for this float cost model")
9 latency = REQUEST_OVERHEAD_MS + SEQUENTIAL_PAIR_COST_MS * candidate_count
10 if not finite_number(latency):
11 raise ValueError("fixture latency overflow")
12 return latency
13
14release_fixture_latency = estimated_fixture_latency_ms(len(release_input))
15too_wide_fixture_latency = estimated_fixture_latency_ms(len(first_stage))
16print(
17 f"top-3 fixture latency: {release_fixture_latency:.1f} ms, "
18 f"pass={release_fixture_latency <= FIXTURE_LATENCY_BUDGET_MS}"
19)
20print(
21 f"top-4 fixture latency: {too_wide_fixture_latency:.1f} ms, "
22 f"pass={too_wide_fixture_latency <= FIXTURE_LATENCY_BUDGET_MS}"
23)
24assert release_fixture_latency <= FIXTURE_LATENCY_BUDGET_MS
25assert too_wide_fixture_latency > FIXTURE_LATENCY_BUDGET_MS1top-3 fixture latency: 26.5 ms, pass=True
2top-4 fixture latency: 34.0 ms, pass=FalseFor this fixture, three candidates recover the rule and fit the synthetic budget. The context cap is two, but only one passage clears the fixture threshold. A real service chooses these limits using held-out relevance labels and measured end-to-end p95 latency by candidate count, input length, concurrency, and batch strategy. With dense attention, a pair's attention work grows roughly quadratically in its token length; equal candidate counts can therefore have very different costs. Measure queueing, network, tokenization, model execution, and packing together.
Define failure behavior before serving: on a timeout, either fall back to the original authorized order and mark the trace as degraded, or abstain if policy requires reranking. Never fill the gap with unscored restricted evidence. On equal scores, this lab preserves first-stage rank and then uses chunk ID as a deterministic tie-breaker.
Can this sequential fixture prove deployed p95 latency for a batched cross-encoder endpoint?
Answer
No. It makes the candidate-count tradeoff visible. Measure end-to-end p95 latency with the real model, input lengths, batching strategy, and hardware before setting a production budget.
Choose an interaction design deliberately
Once candidate depth is chosen, choose when query and chunk are allowed to interact. Predict the storage tradeoff first: anything computed without the query can be indexed; a query-specific score must wait for request time.
Three ranking designs make different choices across the interaction spectrum:
| Design | Stored before request | Request-time work | Architectural mechanism |
|---|---|---|---|
| Bi-encoder retrieval | One dense vector per chunk | Inner product against query vector | Single vector bottleneck; zero token-level cross interaction |
| BERT-style cross-encoder | No query-specific pair score | Bidirectional attention over | Query and document tokens interact before pooling |
| ColBERT late interaction | Token vectors per chunk | MaxSim across token matrices | Retains token granularity with pre-indexable document vectors |
Bi-encoders compress a passage into one vector before comparing it with the query.[14] Encoding every condition into that representation can be challenging, but this doesn't mean a bi-encoder necessarily loses negation or entity roles. Those behaviors need evaluation on the actual checkpoint.
BERT-style cross-encoders let query and document tokens interact before the pooled score is computed. That creates an opportunity to model fine distinctions, not a guarantee that the trained model resolves them correctly. Its output is still a compressed scalar.
ColBERT bridges the gap through late interaction.[15] It keeps document representations indexable offline as individual contextual token vectors, while preserving token-level comparison at query time. For each query token vector, the MaxSim operator finds its maximum dot product across all document token vectors, then sums those per-token maxima:
Original ColBERT L2-normalizes token embeddings, making token dot products equal cosine similarities.[15] Token dimension is a configuration choice, not always 128. PLAID is a search engine for ColBERTv2 representations that uses centroid interaction and pruning; its reported speedups depend on the configuration and workload.[16] Don't infer a universal sub-10-ms result.
![Three scoring designs for the same query-chunk pair: a bi-encoder compresses each side to one vector then takes a dot product; a cross-encoder concatenates [CLS] query [SEP] chunk [SEP] and reads a score from the [CLS] state after joint attention; ColBERT keeps indexed chunk-token vectors and sums MaxSim, 0.9 plus 0.8 equals 1.7.](/cdn/content-image/retrieval/reranking-cross-encoders-rag/illustrations/_generated/reranker_interaction_spectrum_dark.png?v=73b372bb230e)
Two query vectors find their best matches independently: matches at , matches at , and the score is their sum. These invented 2-D vectors all have unit length; is chosen so . The lab illustrates MaxSim, not embeddings from a trained ColBERT checkpoint.
1from math import hypot, sqrt
2
3query_vecs = [(1.0, 0.0), (0.0, 1.0)]
4doc_vecs = [(0.9, sqrt(0.19)), (0.6, 0.8), (-0.8, -0.6)]
5
6def dot(left: tuple[float, float], right: tuple[float, float]) -> float:
7 return left[0] * right[0] + left[1] * right[1]
8
9def maxsim(
10 query: list[tuple[float, float]],
11 document: list[tuple[float, float]],
12) -> float:
13 if not query or not document:
14 raise ValueError("query and document need token vectors")
15 for vector in query + document:
16 if len(vector) != 2 or not all(finite_number(value) for value in vector):
17 raise ValueError("this lab requires finite 2-D token vectors")
18 if abs(hypot(*vector) - 1.0) > 1e-9:
19 raise ValueError("this lab requires unit-length token vectors")
20 return sum(max(dot(query_vec, doc_vec) for doc_vec in document) for query_vec in query)
21
22score = maxsim(query_vecs, doc_vecs)
23assert all(abs(dot(v, v) - 1.0) < 1e-9 for v in query_vecs + doc_vecs)
24print(f"MaxSim: {score:.1f}")
25assert abs(score - 1.7) < 1e-91MaxSim: 1.7Why can a document embedding be indexed before a request, while a cross-encoder relevance score can't?
Answer
A document embedding is computed without knowing the query. A cross-encoder score depends on a specific query and a specific chunk processed together, so it exists only at request time.
Cache invalidation and multi-tenant security
Cross-encoder scores are computationally expensive, which makes response caching attractive. Caching scores simply by chunk ID or (query, chunk_id) is a severe correctness and security vulnerability.
In dynamic enterprise environments, three events corrupt simplistic caches:
- Document updates: If a policy document changes but keeps its chunk ID, an un-invalidated cache entry continues serving scores calculated against the obsolete version.
- Model upgrades: A checkpoint or scoring-method change can shift score scales and ordering. Mixing incompatible cached scores can corrupt ranking.
- Cross-tenant data leakage: In multi-tenant environments, Tenant A and Tenant B might issue identical queries. If Tenant A has access to a confidential contract and caches its high relevance score, Tenant B's retrieval pipeline must not see that score, receive that document ID, or learn of its existence through side-channel timing probes.
Fingerprint the exact scoring input, immutable model and tokenizer revisions, scoring settings, and trusted tenant/access namespace. Include instructions, templates, truncation or windows, aggregation, adapters, and relevant precision settings. A mutable model name isn't an immutable revision. Avoid normalizing away input distinctions unless the scorer deliberately uses that same normalization.
For an independently scored pair, the input contains one query and passage. For a listwise model such as Jina Reranker v3.5, it contains the complete ordered candidate list: changing a neighbor or its position can change the score.[9] Pairwise cache entries aren't interchangeable with whole-list results.
Use structured serialization rather than ambiguous string concatenation. This small fixture hashes already-rendered model input and explicit configuration. The revision strings below are invented teaching values, not real checkpoint pins.
1def score_cache_key(
2 model_input: str,
3 *,
4 model_revision: str,
5 tokenizer_revision: str,
6 scoring_settings: dict[str, object],
7 tenant_id: str,
8 security_scope: tuple[str, ...],
9 grant_revision: str,
10) -> str:
11 strings = (model_input, model_revision, tokenizer_revision, tenant_id, grant_revision)
12 if any(not isinstance(value, str) or not value for value in strings):
13 raise ValueError("cache input and revisions must be nonempty strings")
14 if (
15 not isinstance(scoring_settings, dict)
16 or not all(isinstance(key, str) for key in scoring_settings)
17 or not isinstance(security_scope, tuple)
18 or not all(isinstance(grant, str) and grant for grant in security_scope)
19 ):
20 raise ValueError("invalid scoring settings or security scope")
21 payload = {
22 "schema": 1,
23 "input_sha256": sha256(model_input.encode("utf-8")).hexdigest(),
24 "model_revision": model_revision,
25 "tokenizer_revision": tokenizer_revision,
26 "scoring_settings": scoring_settings,
27 "tenant_id": tenant_id,
28 "security_scope": sorted(set(security_scope)),
29 "grant_revision": grant_revision,
30 }
31 try:
32 serialized = json.dumps(
33 payload, sort_keys=True, ensure_ascii=False,
34 separators=(",", ":"), allow_nan=False,
35 )
36 except (TypeError, ValueError, OverflowError) as error:
37 raise ValueError("cache configuration must be finite JSON data") from error
38 return sha256(serialized.encode("utf-8")).hexdigest()
39
40cache_config = {
41 "model_revision": "fixture-model-revision-1",
42 "tokenizer_revision": "fixture-tokenizer-revision-1",
43 "scoring_settings": {"template": "fixture-v1", "truncation": "refuse"},
44 "tenant_id": "policy-lab",
45 "security_scope": ("read:api-auth-v2",),
46 "grant_revision": "fixture-grants-1",
47}
48pair_input = json.dumps([QUERY, CANDIDATES[2].text])
49pair_key = score_cache_key(pair_input, **cache_config)
50changed_text_key = score_cache_key(pair_input + " revised", **cache_config)
51other_tenant_key = score_cache_key(
52 pair_input, **{**cache_config, "tenant_id": "other-fixture-tenant"}
53)
54list_input = json.dumps([QUERY, [candidate.text for candidate in CANDIDATES]])
55reversed_list_input = json.dumps([QUERY, [candidate.text for candidate in reversed(CANDIDATES)]])
56print("Changed text changes key:", pair_key != changed_text_key)
57print("Changed tenant changes key:", pair_key != other_tenant_key)
58print("Changed list order changes key:", score_cache_key(list_input, **cache_config)
59 != score_cache_key(reversed_list_input, **cache_config))
60assert pair_key != changed_text_key and pair_key != other_tenant_key
61assert score_cache_key(list_input, **cache_config) != score_cache_key(reversed_list_input, **cache_config)1Changed text changes key: True
2Changed tenant changes key: True
3Changed list order changes key: TrueThe key identifies inputs; it doesn't authenticate a caller, encrypt sensitive material, or prove that text is correct. Derive access fields from trusted authorization, and recheck current access before exposing cached IDs, scores, or text, not only before generation. Cache isolation alone doesn't establish protection against timing side channels. Keep source revisions in cached result records for traceability as well.
Emit the trace that evaluation needs
Once the ranking looks right, make the decision reproducible. Without evidence-level traces, a bad answer could come from retrieval order, pair scoring, context packing, or source freshness.
Record first-stage IDs, actual reranker input, candidate source identity, policy versions, pair scores, model versions, selected context IDs, and release gates. A document version is part of correctness: a well-ranked stale rule is still unsafe context.
1SCORER_VERSION = "phrase-rule-fixture-v1"
2selected_context = [
3 result.candidate
4 for result in release_reranked
5 if result.score >= MIN_CONTEXT_SCORE
6][:CONTEXT_BUDGET]
7release_trace = {
8 "query_id": "api-token-legacy-access-001",
9 "fixture_authority": {
10 "tenant": "policy-lab",
11 "caller": "service-account-reader",
12 "grant_revision": "fixture-grants-1",
13 "replay_date": "2026-05-27",
14 },
15 "versions": {
16 "retriever": "policy-retriever-v2",
17 "index": "policy-index/2026-05-27",
18 "sparse": "bm25-tokenizer-v1",
19 "dense": "fixture-embeddings-v1",
20 "fusion": "rrf-k60",
21 "reranker": SCORER_VERSION,
22 },
23 "first_stage_ids": first_stage_ids,
24 "rerank_input_ids": [candidate.chunk_id for candidate in release_input],
25 "reranked_ids": release_reranked_ids,
26 "candidate_records": [
27 {
28 "chunk_id": result.candidate.chunk_id,
29 "document_id": result.candidate.document_id,
30 "parent_id": result.candidate.parent_id,
31 "version": result.candidate.version,
32 "first_stage_rank": result.candidate.first_stage_rank,
33 "rerank_score": result.score,
34 "reasons": result.reasons,
35 }
36 for result in release_reranked
37 ],
38 "selected_context_ids": [candidate.chunk_id for candidate in selected_context],
39 "selected_versions": [candidate.version for candidate in selected_context],
40 "gates": {
41 "permitted_and_current": all(
42 in_snapshot(candidate) for candidate in release_input
43 ),
44 "target_in_context": TARGET_ID in [
45 candidate.chunk_id for candidate in selected_context
46 ],
47 "ordering_lift": ndcg_at_k(release_reranked_ids, RELEVANT_IDS, CONTEXT_BUDGET)
48 > ndcg_at_k([candidate.chunk_id for candidate in release_input], RELEVANT_IDS, CONTEXT_BUDGET),
49 "latency_budget": release_fixture_latency <= FIXTURE_LATENCY_BUDGET_MS,
50 },
51}
52stores_raw_policy_text = any(
53 candidate.text in str(release_trace)
54 for candidate in SOURCE_STORE
55)
56print("Versions:", release_trace["versions"])
57print("Rerank input:", release_trace["rerank_input_ids"])
58print("Selected context:", release_trace["selected_context_ids"])
59print("Trace stores raw policy text:", stores_raw_policy_text)
60print("Gates:", release_trace["gates"])
61assert set(release_trace["reranked_ids"]) == set(release_trace["rerank_input_ids"])
62assert not stores_raw_policy_text
63assert all(release_trace["gates"].values())1Versions: {'retriever': 'policy-retriever-v2', 'index': 'policy-index/2026-05-27', 'sparse': 'bm25-tokenizer-v1', 'dense': 'fixture-embeddings-v1', 'fusion': 'rrf-k60', 'reranker': 'phrase-rule-fixture-v1'}
2Rerank input: ['api-token-troubleshooting-v1', 'api-password-reset-v1', 'api-token-legacy-v2-rule']
3Selected context: ['api-token-legacy-v2-rule']
4Trace stores raw policy text: False
5Gates: {'permitted_and_current': True, 'target_in_context': True, 'ordering_lift': True, 'latency_budget': True}The trace records the actual three-candidate input and compares ordering on that subset. Its authority labels describe a trusted teaching snapshot, not implemented authentication. Gold-target and ordering gates belong to offline evaluation, where labels exist. Live traces record IDs, versions, latency, and selection, but normally don't know the correct answer. The substring check only detects complete fixture text; it misses fragments or encoded content and isn't a privacy audit. IDs, scores, and traces still require access controls.
Try to break the receipt
What if a scorer returns a different candidate with a very high score? Or someone edits text while preserving the ID and version? These tests exercise the actual boundary, not just the expected order. The final case also shows why a relevant rule can support a denial.
1from dataclasses import replace
2
3def rejected(action) -> bool:
4 try:
5 action()
6 except ValueError:
7 return True
8 return False
9
10class RebindingScorer(ConstraintAwarePairScorer):
11 def score(self, request: Request, candidate: Candidate) -> PairScore:
12 return PairScore(SOURCE_STORE[-2], 999.0, ("wrong-candidate",))
13
14class BooleanScoreScorer(ConstraintAwarePairScorer):
15 def score(self, request: Request, candidate: Candidate) -> PairScore:
16 return PairScore(candidate, True, ())
17
18changed_record = replace(CANDIDATES[2], text="Changed rule under unchanged ID/version")
19assert rejected(lambda: rerank(REQUEST, [changed_record], ConstraintAwarePairScorer()))
20assert rejected(lambda: rerank(REQUEST, CANDIDATES, RebindingScorer()))
21assert rejected(lambda: rerank(REQUEST, CANDIDATES, BooleanScoreScorer()))
22assert rejected(lambda: pack_context(CANDIDATES, True))
23print("Changed record, rebound result, Boolean score/budget: rejected")
24
25for days, audit in [(0, True), (14, True), (15, True), (10, False)]:
26 request = replace(REQUEST, days_since_deprecation=days, audit_enabled=audit)
27 target_score = next(
28 result.score for result in rerank(request, CANDIDATES, ConstraintAwarePairScorer())
29 if result.candidate.chunk_id == TARGET_ID
30 )
31 permitted_by_rule = 0 <= days <= 14 and audit
32 print(f"day={days}, audit={audit}: score={target_score}, policy_allows={permitted_by_rule}")1Changed record, rebound result, Boolean score/budget: rejected
2day=0, audit=True: score=7, policy_allows=True
3day=14, audit=True: score=7, policy_allows=True
4day=15, audit=True: score=5, policy_allows=False
5day=10, audit=False: score=5, policy_allows=FalseThe fictional rule treats days 0 through 14 as inclusive and assumes the caller is a service account. These are this lab's interpretation rules, not general API policy. At day 15 or with auditing disabled, the chunk remains relevant and clears the +5 admission gate, while access is denied. The score selects evidence; interpreting that evidence is a separate operation.
Release checks for policy-answerer-v3
Use the gates as a diagnosis path. A missing target is a recall failure; a target below the context cutoff is an ordering failure; a score-text mismatch is a truncation failure; correct context with an unsupported answer belongs to the next generation and evaluation stage.
Before this reranker serves another legacy-token question:
| Gate | Evidence to log | Failure response |
|---|---|---|
| Candidate recall | Gold chunk ID in first-stage top k | Fix retrieval or raise measured candidate budget |
| Authorization and freshness | ACL decision, chunk version, effective date | Reject request context; never score blocked evidence |
| Truncation-aware scoring | Pair token length, truncated flag, scored vs packed checksum or kept span | Refuse pair, re-chunk, or record explicit window; never rank a silent prefix |
| Ordering lift | Before/after MRR or NDCG on held-out traces | Retrain, replace, or remove reranker |
| Serving budget | Candidate count, token length, model version, p95 latency | Batch, cap input, or choose a tested alternative |
| Downstream grounding | Selected chunk IDs and generated citations | Evaluate in the next pipeline stage |
Caching only by chunk ID is unsafe. Include the exact scoring input's checksum, immutable model/tokenizer revisions, scoring settings, and trusted access scope. For listwise scoring, input identity covers the full ordered list. Recheck access before disclosing a cache hit. Replay labeled cases after source changes; a high score for obsolete policy is still obsolete evidence.