Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A developer searches for retry idempotency key. A search ranker doesn't emit one yes-or-no action. Given that query, which documents should appear first, and in what order?
Gradient boosted trees scored one job at a time: intervene or wait. Ranked order also changes the training data. Classifier labels are usually collected independently of the last prediction. Yesterday's ranking becomes today's clicks. A recommendation feed uses the same funnel without a typed query: developer and session context replace query features.
Separate eligibility, retrieval, and ranking
The corpus for that retry idempotency key query has one million documents. A feature-heavy model shouldn't score every document on every request.
| Stage | Job | Example guard |
|---|---|---|
| eligibility filter | remove forbidden or stale documents | current, permitted source |
| candidate generation | recover perhaps 200 plausible documents | text or embedding retrieval |
| ranking | order candidates precisely | relevance, freshness, source trust |
| business policy | enforce final constraints | sponsored labels, diversity, safety |
A document that never enters the candidate set can't be rescued by a perfect ranker. A forbidden document shouldn't enter the candidate set at all. Candidate recall, policy filtering, and ranking quality are separate obligations.
Build a seven-document fixture. The relevance value is an offline human judgment for one frozen query. It belongs in evaluation data, not in online model features.
1from hashlib import sha256
2from json import dumps
3from math import log2
4
5query = "retry idempotency key"
6corpus = [
7 {"id": "api-guide", "title": "retry idempotency API guide", "current": True, "permitted": True, "retrieval": 0.96, "query_fit": 0.98, "reliability": 0.88, "relevance": 3},
8 {"id": "blog-post", "title": "general release blog", "current": True, "permitted": True, "retrieval": 0.91, "query_fit": 0.35, "reliability": 0.97, "relevance": 0},
9 {"id": "legacy-changelog", "title": "legacy changelog", "current": True, "permitted": True, "retrieval": 0.90, "query_fit": 0.15, "reliability": 0.99, "relevance": 0},
10 {"id": "retry-runbook", "title": "retry policy runbook", "current": True, "permitted": True, "retrieval": 0.84, "query_fit": 0.92, "reliability": 0.98, "relevance": 2},
11 {"id": "sdk-example", "title": "idempotency SDK example", "current": True, "permitted": True, "retrieval": 0.80, "query_fit": 0.86, "reliability": 0.96, "relevance": 2},
12 {"id": "private-draft", "title": "private retry incident draft", "current": True, "permitted": False, "retrieval": 0.99, "query_fit": 0.99, "reliability": 0.99, "relevance": 3},
13 {"id": "stale-guide", "title": "stale retry migration guide", "current": False, "permitted": True, "retrieval": 0.95, "query_fit": 0.94, "reliability": 0.70, "relevance": 2},
14]
15
16print("corpus rows:", len(corpus))
17print("query:", query)1corpus rows: 7
2query: retry idempotency keyThe corpus includes a private draft and a stale guide. Remove both before retrieval. A learned score can't override access policy.
1eligible = [
2 item
3 for item in corpus
4 if item["current"] and item["permitted"]
5]
6blocked = sorted(item["id"] for item in corpus if item not in eligible)
7
8print("eligible:", [item["id"] for item in eligible])
9print("blocked:", blocked)1eligible: ['api-guide', 'blog-post', 'legacy-changelog', 'retry-runbook', 'sdk-example']
2blocked: ['private-draft', 'stale-guide']Measure candidate recall before ordering quality
Candidate generation runs a cheap retrieval stage. This lab uses a precomputed retrieval score so you can focus on the contract between stages. In Core Retrieval Algorithms that cheap stage was BM25, dense similarity, or a fused shortlist. Here the same contract shows up in a production ranker: recover the right documents first, then spend compute ordering them.
The judged relevant eligible documents are api-guide, retry-runbook, and sdk-example. Measure how many survive candidate generation.
1relevant_ids = {
2 item["id"]
3 for item in eligible
4 if item["relevance"] > 0
5}
6
7def candidates_at(limit):
8 return sorted(
9 eligible,
10 key=lambda item: (-item["retrieval"], item["id"]),
11 )[:limit]
12
13def candidate_recall(items):
14 returned_ids = {item["id"] for item in items}
15 return len(returned_ids & relevant_ids) / len(relevant_ids)
16
17for limit in (3, 5):
18 items = candidates_at(limit)
19 print(
20 f"k={limit} ids={[item['id'] for item in items]} "
21 f"recall={candidate_recall(items):.3f}"
22 )1k=3 ids=['api-guide', 'blog-post', 'legacy-changelog'] recall=0.333
2k=5 ids=['api-guide', 'blog-post', 'legacy-changelog', 'retry-runbook', 'sdk-example'] recall=1.000At k=3, retrieval keeps only one of three relevant eligible documents. No downstream ranker can recover the missing runbook or SDK example. Raising this tiny lab's candidate budget to 5 restores candidate recall before the expensive scorer runs.
The relevant item never enters the top-100 candidate set. Can a more accurate reranker place it first?
Answer
No. Ranking can reorder only retrieved candidates. Fix candidate recall before interpreting downstream ordering metrics.
Rerank the surviving candidates
Keep five candidates. The first-stage retrieval order is fast but imprecise: blog-post and legacy-changelog sit near the top on retrieval alone, yet their query_fit stays low for the idempotency intent.
The scorer below is hand-written so each feature remains visible. query_fit represents a richer query-document match feature. reliability represents source trust. Neither uses the offline relevance label.
1candidates = candidates_at(5)
2
3def rank_score(item):
4 return 2.0 * item["query_fit"] + 0.6 * item["reliability"]
5
6ranked = sorted(
7 candidates,
8 key=lambda item: (-rank_score(item), item["id"]),
9)
10
11print("retrieval order:", [item["id"] for item in candidates])
12print("ranked order:", [item["id"] for item in ranked])
13print("rank scores:", [(item["id"], round(rank_score(item), 3)) for item in ranked])1retrieval order: ['api-guide', 'blog-post', 'legacy-changelog', 'retry-runbook', 'sdk-example']
2ranked order: ['api-guide', 'retry-runbook', 'sdk-example', 'blog-post', 'legacy-changelog']
3rank scores: [('api-guide', 2.488), ('retry-runbook', 2.428), ('sdk-example', 2.296), ('blog-post', 1.282), ('legacy-changelog', 0.894)]A production scorer learns weights or nonlinear interactions from labeled examples. The request path stays the same: retrieve cheaply, score the bounded candidate set, then apply policy. Those two orders of the same five documents are ready to compare. Which one is actually better?
A metric that values the top slots
A classifier's accuracy doesn't care which mistakes you made first. A ranked list does. Putting the best document first is a bigger win than sliding it from position 40 to position 39. Discounted Cumulative Gain (DCG) gives graded relevance near the top more weight:
Here is the judged relevance at position , counting from 1. Position 1 has denominator , so it keeps full weight. Lower positions receive a logarithmic discount. This is the exponential-gain form Manning uses for NDCG.[1] Common library helpers often use linear gain instead. The two agree on binary labels and disagree on grades such as 0, 2, 3, so pin the gain function in the evaluation suite.
Normalized DCG (NDCG) divides by the score of the ideal ordering, producing a value between zero and one for a query.
Work the retrieval order [3, 0, 0, 2, 2] by hand before looking at code. Gain is , so a grade-3 document contributes 7 and a grade-2 document contributes 3:
The ideal order of these five grades is [3, 2, 2, 0, 0]:
So retrieval-order NDCG@5 is . Build that ideal list from all candidate relevances before truncating. Otherwise a relevant candidate below the evaluated slate could disappear from the denominator. The guard for best == 0 handles a query with no judged relevant documents instead of dividing by zero.
This lab computes reranker NDCG over documents that survived retrieval. That boundary is deliberate: candidate recall measures misses before ranking, while reranker NDCG measures ordering quality inside the bounded candidate set. A whole-system evaluation can also compute NDCG against the eligible judged corpus so retrieval misses reduce the final score.
1def dcg(relevances):
2 return sum(
3 (2**relevance - 1) / log2(rank + 2)
4 for rank, relevance in enumerate(relevances)
5 )
6
7def reranker_ndcg(items, limit):
8 relevances = [item["relevance"] for item in items[:limit]]
9 ideal = sorted(
10 (item["relevance"] for item in items),
11 reverse=True,
12 )[:limit]
13 best = dcg(ideal)
14 return dcg(relevances) / best if best else 0.0
15
16print("retrieval-order reranker ndcg@5:", f"{reranker_ndcg(candidates, 5):.3f}")
17print("ranked-order reranker ndcg@5:", f"{reranker_ndcg(ranked, 5):.3f}")1retrieval-order reranker ndcg@5: 0.910
2ranked-order reranker ndcg@5: 1.000The scorer lifts reranker NDCG@5 from 0.910 to 1.000. That doesn't prove the model will improve a live docs portal. It only proves this frozen judged query improved after reranking.
Reranker NDCG can still look perfect when retrieval drops relevant documents. Compare it with whole-system NDCG, which builds the ideal list from the eligible judged corpus so missing relevants lower the score:
1def system_ndcg(items, eligible_items, limit):
2 relevances = [item["relevance"] for item in items[:limit]]
3 ideal = sorted(
4 (item["relevance"] for item in eligible_items),
5 reverse=True,
6 )[:limit]
7 best = dcg(ideal)
8 return dcg(relevances) / best if best else 0.0
9
10tight = candidates_at(3)
11tight_ranked = sorted(
12 tight,
13 key=lambda item: (-rank_score(item), item["id"]),
14)
15
16print("k=3 survivors:", [item["id"] for item in tight_ranked])
17print("k=3 grades:", [item["relevance"] for item in tight_ranked])
18print("k=3 reranker ndcg:", f"{reranker_ndcg(tight_ranked, 3):.3f}")
19print("k=3 system ndcg:", f"{system_ndcg(tight_ranked, eligible, 3):.3f}")1k=3 survivors: ['api-guide', 'blog-post', 'legacy-changelog']
2k=3 grades: [3, 0, 0]
3k=3 reranker ndcg: 1.000
4k=3 system ndcg: 0.674At k=3, the perfect reranker order among survivors still yields reranker NDCG 1.000, because the ideal list is built only from those three documents. System NDCG falls to 0.674 because the ideal top three from the eligible corpus is [3, 2, 2] and two relevant documents never entered the slate. Report both when you care about end-to-end quality; keep candidate recall as the explicit retrieval diagnostic.

k=3 window leaves retry-runbook and sdk-example outside ranking. Reranker NDCG on the survivors is still 1.000, while system NDCG falls to 0.674 because the missing grade-2 documents never compete.One query isn't an evaluation set. Average reranker NDCG over a frozen query set, report slices such as new documents and languages, and inspect failed queries. Keep candidate recall separate so a strong reranker score can't hide retrieval loss.
Why does normalized discounted cumulative gain (NDCG) penalize moving a relevant item from rank 1 to rank 10?
Answer
Discounted cumulative gain gives earlier positions more weight through a logarithmic discount. NDCG then divides by the ideal ordering, making the score comparable across queries with different relevance sets.
Two-tower retrieval and the precomputation constraint
The lab just ranked five rows. A live corpus has closer to a million. Candidate retrieval often uses a two-tower architecture so that million-row scan never happens on the request path. One encoder maps the live query or session context to a vector . A separate encoder maps each document or item to a vector . The search system compares those vectors with a dot product or cosine similarity. Huang et al. trained deep structured semantic models that map queries and documents into one shared space and score them with cosine similarity.[2]
The separation changes serving work. Because the item tower doesn't depend on the current query, item vectors can be computed offline and loaded into an approximate nearest neighbor (ANN) index. A request runs the query tower once, retrieves a small candidate set, and reserves expensive pairwise scoring for those candidates. Exact complexity and latency depend on the index, recall target, hardware, and corpus, so ANN search shouldn't be described with one universal bound.
The cross-feature bottleneck
An early cross-feature mixes query and item information before each side has produced its independent vector. Query-document cross-attention is one example. The resulting item representation changes with every query, so it can't be precomputed.
Using such a model as first-stage retrieval requires pairwise model work across the corpus. Using it after retrieval requires pairwise work only across the bounded candidate set. That makes cross-features useful for reranking even when they're too expensive for candidate generation.
The miniature example uses exact dot products so every step stays visible. A production ANN index changes how candidates are found, but it preserves the same separation between precomputed item vectors and live query work.
1from random import Random
2
3rng = Random(42)
4n_items = 1_000
5dim = 16
6candidate_budget = 5
7
8def random_vector():
9 return [rng.gauss(0.0, 1.0) for _ in range(dim)]
10
11def dot(left, right):
12 return sum(a * b for a, b in zip(left, right))
13
14# Item tower output is computed before any live query arrives.
15doc_embeddings = [random_vector() for _ in range(n_items)]
16query_embedding = random_vector()
17
18# Exact search stands in for an ANN lookup in this small lab.
19retrieval_scores = [dot(doc, query_embedding) for doc in doc_embeddings]
20candidate_ids = sorted(
21 range(n_items),
22 key=lambda item_id: retrieval_scores[item_id],
23)[-candidate_budget:]
24
25# A pairwise reranker now computes cross-features for five rows, not 1,000.
26candidate_quality = [rng.gauss(0.0, 1.0) for _ in range(candidate_budget)]
27rerank_scores = [
28 0.8 * retrieval_scores[item_id] + 0.2 * quality
29 for item_id, quality in zip(candidate_ids, candidate_quality)
30]
31
32print("offline item vectors:", len(doc_embeddings))
33print("live query vectors:", 1)
34print("pairwise rows before retrieval:", n_items)
35print("pairwise rows after retrieval:", len(rerank_scores))
36
37assert len(doc_embeddings) == n_items
38assert len(doc_embeddings[0]) == dim
39assert len(set(candidate_ids)) == candidate_budget
40assert len(rerank_scores) == candidate_budget1offline item vectors: 1000
2live query vectors: 1
3pairwise rows before retrieval: 1000
4pairwise rows after retrieval: 5Precomputation removes item-encoder work from the request path. Candidate retrieval narrows the pairwise work by a factor of 200 in this fixture, from 1,000 query-item rows to 5. Measure ANN recall and latency on your own data because the index adds an approximation that this exact-search lab doesn't model.
Turn judgments into pair preferences
The hand-written scorer is pointwise: each document gets an independent score. Pairwise learning-to-rank methods such as RankNet train from preferences so a relevant item scores above a less relevant one.[3] Listwise methods try to optimize a list metric directly. This lab stays with a transparent pointwise score and pairwise labels built from the same frozen query.
| Family | What it learns | This lab |
|---|---|---|
| pointwise | a score or probability per document | rank_score |
| pairwise | which of two documents should rank higher | preference pairs below |
| listwise | the whole ordered list, often toward NDCG | not implemented here |
1pairs = [
2 (preferred["id"], other["id"])
3 for preferred in ranked
4 for other in ranked
5 if preferred["relevance"] > other["relevance"]
6]
7
8print("pair count:", len(pairs))
9print("first pairs:", pairs[:5])1pair count: 8
2first pairs: [('api-guide', 'retry-runbook'), ('api-guide', 'sdk-example'), ('api-guide', 'blog-post'), ('api-guide', 'legacy-changelog'), ('retry-runbook', 'blog-post')]The pair ("api-guide", "blog-post") says the API guide should score higher for this query. Equal grades don't form a pair, so retry-runbook vs sdk-example is absent. A learned pairwise model uses features to reduce preference mistakes across many queries. The hand-written scorer stays useful as a transparent baseline.
Log exposure before reading clicks
The ranked slate is now ready to display. Persist one immutable impression row per exposed document before reading clicks. Later click and resolved events join back through request and document IDs. Without the slate, position, eligibility-policy version, candidate-set version, and ranker version, you can't reproduce what the developer saw.
1slate = ranked[:3]
2impressions = [
3 {
4 "request_id": "req-1042",
5 "query": query,
6 "eligibility_policy": "docs-access-policy-v4",
7 "candidate_set": "retrieval-v3",
8 "ranker": "docs-ranker-v7",
9 "document_id": item["id"],
10 "position": position,
11 }
12 for position, item in enumerate(slate, start=1)
13]
14outcomes = [
15 {
16 "event_id": "evt-click-1042",
17 "occurred_at": "2026-05-01T12:00:03Z",
18 "request_id": "req-1042",
19 "document_id": "api-guide",
20 "event": "click",
21 },
22 {
23 "event_id": "evt-resolved-1042",
24 "occurred_at": "2026-05-01T12:04:18Z",
25 "request_id": "req-1042",
26 "document_id": "api-guide",
27 "event": "resolved",
28 },
29]
30
31print("slate:", [item["id"] for item in slate])
32print("logged positions:", [
33 (row["document_id"], row["position"])
34 for row in impressions
35])
36print("later outcomes:", [
37 (row["document_id"], row["event"])
38 for row in outcomes
39])1slate: ['api-guide', 'retry-runbook', 'sdk-example']
2logged positions: [('api-guide', 1), ('retry-runbook', 2), ('sdk-example', 3)]
3later outcomes: [('api-guide', 'click'), ('api-guide', 'resolved')]The first row doesn't mean api-guide is universally best. It means this request exposed it at position 1, and one developer clicked it.
Log enough context to interpret later outcomes:
| Logged field | Reason |
|---|---|
| request and query ID | group displayed slate and join later outcomes |
| eligibility-policy version | know which documents were allowed |
| candidate set version | know what could have ranked |
| final positions | measure exposure |
| ranker version | attribute outcomes |
| timestamped click, save, resolved, bad-result events | distinguish curiosity from value |
| freshness and permission snapshot | reproduce context |
Clicks are biased labels
When a model places a document first, it receives more attention and therefore more clicks. Training directly on clicks rewards past ranking position as if it were document relevance. This is a feedback loop.

Take two documents with equal underlying appeal. Historical ranker always places same-quality-a first and same-quality-b second. Slot one is examined more often.
1historical = [
2 {"document": "same-quality-a", "position": 1, "impressions": 100, "clicks": 40},
3 {"document": "same-quality-b", "position": 2, "impressions": 100, "clicks": 20},
4]
5
6for row in historical:
7 raw_ctr = row["clicks"] / row["impressions"]
8 print(
9 f"{row['document']}: position={row['position']} "
10 f"raw_ctr={raw_ctr:.2f}"
11 )1same-quality-a: position=1 raw_ctr=0.40
2same-quality-b: position=2 raw_ctr=0.20Naive training labels say document a is twice as attractive. The log doesn't justify that conclusion because document and position never vary independently.
A simple counterfactual correction weights each click by the inverse of its position's examination probability. This example fixes slot-one propensity at 1.0 and slot-two propensity at 0.5.
1examination_propensity = {1: 1.0, 2: 0.5}
2
3for row in historical:
4 corrected_signal = (
5 row["clicks"]
6 / examination_propensity[row["position"]]
7 / row["impressions"]
8 )
9 print(f"{row['document']}: corrected_signal={corrected_signal:.2f}")1same-quality-a: corrected_signal=0.40
2same-quality-b: corrected_signal=0.40Both corrected signals become 0.40. The arithmetic exposes the idea, not a production estimator. Real propensities need careful estimation, often through randomized interventions or a validated click model. Very small propensities also create large, noisy weights, so a production estimator needs variance controls and evaluation. Inverse-propensity learning-to-rank treats examination as the missing piece: you can only supervise a click on documents the user actually saw.[4]
Why can't raw click rate serve as an unbiased relevance label?
Answer
Users can click only items they were shown, and higher-ranked items receive more attention. Log exposure and position, then use controlled exploration or bias-aware estimation before treating clicks as preference evidence.
Protect users and the dataset
Ranking can narrow what developers see. Eligibility constraints for stale documents, private drafts, and source policy run before retrieval because those items must never compete. Diversity among otherwise eligible items belongs after scoring: if product policy permits at most two results from one source in the top three, adjust the final slate deterministically and gate that invariant in release checks. Monitor slices such as new documents, small doc sets, languages, and stale categories rather than accepting one portal-wide NDCG.
Policy must stay outside the learned score. Reproduce a bad integration that ranks the raw corpus without applying eligibility first.
1unsafe_order = sorted(
2 corpus,
3 key=lambda item: (-rank_score(item), item["id"]),
4)
5
6print("unsafe first result:", unsafe_order[0]["id"])
7print("permitted:", unsafe_order[0]["permitted"])1unsafe first result: private-draft
2permitted: FalseThe private draft ranks first because its query match is strong. Better model training won't fix this bug. Eligibility filtering must run before retrieval and remain covered by release checks.
Gate an offline candidate
A useful release packet includes offline query judgments, candidate recall, reranker NDCG, p95 scoring latency, diversity checks, impression and outcome schemas, and an A/B stopping rule. A list that looks good offline isn't allowed to silently write its own future training labels.
Use concrete gates for this candidate:
- candidate recall at
5must be1.0 - reranker NDCG@5 must be at least
0.95 - p95 scorer latency must be at most
20milliseconds - blocked documents must stay absent
- impression rows must include fields needed for replay
- outcome events must join a logged impression
1latencies_ms = [11, 13, 12, 15, 14, 16, 13, 12, 17, 18]
2
3def percentile_nearest_rank(values, percentile):
4 rank = max(1, int(len(values) * percentile + 0.999999))
5 return sorted(values)[rank - 1]
6
7required_impression_fields = {
8 "request_id",
9 "query",
10 "eligibility_policy",
11 "candidate_set",
12 "ranker",
13 "document_id",
14 "position",
15}
16required_outcome_fields = {
17 "event_id",
18 "occurred_at",
19 "request_id",
20 "document_id",
21 "event",
22}
23exposed_keys = {
24 (row["request_id"], row["document_id"])
25 for row in impressions
26}
27release_checks = {
28 "candidate_recall_at_5": candidate_recall(candidates) >= 1.0,
29 "reranker_ndcg_at_5": reranker_ndcg(ranked, 5) >= 0.95,
30 "p95_latency_ms": percentile_nearest_rank(latencies_ms, 0.95) <= 20,
31 "blocked_items_absent": not (
32 {"private-draft", "stale-guide"}
33 & {item["id"] for item in ranked}
34 ),
35 "impression_schema": all(
36 required_impression_fields <= row.keys()
37 for row in impressions
38 ),
39 "outcome_schema": all(
40 required_outcome_fields <= row.keys()
41 for row in outcomes
42 ),
43 "outcomes_join_impressions": all(
44 (row["request_id"], row["document_id"]) in exposed_keys
45 for row in outcomes
46 ),
47}
48
49for name, passed in release_checks.items():
50 print(f"{name}: {passed}")
51print("release gate:", all(release_checks.values()))1candidate_recall_at_5: True
2reranker_ndcg_at_5: True
3p95_latency_ms: True
4blocked_items_absent: True
5impression_schema: True
6outcome_schema: True
7outcomes_join_impressions: True
8release gate: TruePassing offline checks earns a controlled online experiment, not an immediate global rollout. Engagement changes can reflect latency, layout, freshness, source trust, and ranking behavior. Randomized A/B assignment isolates the candidate change more reliably than comparing two time windows, which is the same discipline as Experiment Design and A/B Testing.[5] Predeclare minimum sample size, minimum duration, and safety stops before exposure starts so the team doesn't stop when a noisy result happens to look favorable. The miniature receipt below uses illustrative commitments; real values come from traffic, power analysis, and risk policy.
Publish a receipt before exposure begins.
1receipt = {
2 "artifact": "docs-ranker-v7",
3 "eligibility_policy": "docs-access-policy-v4",
4 "candidate_set": "retrieval-v3",
5 "offline": {
6 "candidate_recall_at_5": round(candidate_recall(candidates), 3),
7 "reranker_ndcg_at_5": round(reranker_ndcg(ranked, 5), 3),
8 "p95_latency_ms": percentile_nearest_rank(latencies_ms, 0.95),
9 },
10 "release_checks": release_checks,
11 "required_checks_pass": all(release_checks.values()),
12 "experiment": {
13 "assignment_unit": "developer_id",
14 "control": "docs-ranker-v6",
15 "treatment": "docs-ranker-v7",
16 "primary_metric": "successful_resolutions_per_session",
17 "minimum_duration_days": 14,
18 "minimum_requests_per_arm": 5000,
19 "guardrails": [
20 "bad_result_reports_per_session",
21 "p95_latency_ms",
22 "blocked_document_impressions",
23 ],
24 "safety_stops": {
25 "blocked_document_impressions": 0,
26 "p95_latency_ms": 20,
27 },
28 },
29 "status": "candidate_for_ab_test" if all(release_checks.values()) else "blocked",
30}
31payload = dumps(receipt, sort_keys=True)
32print("status:", receipt["status"])
33print("offline:", receipt["offline"])
34print("receipt sha256:", sha256(payload.encode()).hexdigest()[:12])1status: candidate_for_ab_test
2offline: {'candidate_recall_at_5': 1.0, 'reranker_ndcg_at_5': 1.0, 'p95_latency_ms': 18}
3receipt sha256: d0fe7347e924The receipt binds eligibility policy, candidate generator, ranker, offline evidence, release checks, assignment unit, primary metric, minimum exposure, and guardrails. Your later analysis must keep these versions pinned.
When ranking breaks
| Symptom | Cause | Fix |
|---|---|---|
| NDCG looks strong but desired document never appears | candidates lost recall | measure retrieval recall and system NDCG separately from reranker NDCG |
| Prohibited document appears first | policy filter ran after scoring or not at all | filter eligibility before retrieval and gate blocked impressions |
| Popular documents dominate forever | click-position loop | log exposure and use controlled evaluation |
| Corrected click score still looks suspicious | propensity estimate is wrong | validate click model or collect randomized intervention data |
| Corrected click score becomes unstable | tiny propensities create large inverse weights | add variance controls and evaluate estimator behavior |
| Relevance improves while bad-result reports rise | wrong online objective | pair engagement with success and bad-result guardrails |