Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
An incident classifier chooses one action for one alert. A search ranker makes a different prediction: among thousands of documents, which few should appear first for a query or developer?
This is a ranking problem. Retrieval finds plausible candidates quickly; a ranking model orders them using query, document, freshness, and source-trust features. A recommendation feed uses the same shape without a typed query: developer and session context replace query features. In both cases, the displayed order becomes part of the future training data.

5 restores all three relevant documents, reranking reaches NDCG@5 of 1.000, and propensity correction shows why raw click-through rate isn't a relevance label.Separate eligibility, retrieval, and ranking
Suppose a developer searches for retry idempotency key. The corpus contains 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 absent from 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": "stale-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', 'stale-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. A real system might combine lexical retrieval, embeddings, popularity, and personalized retrieval.
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', 'stale-changelog'] recall=0.333
2k=5 ids=['api-guide', 'blog-post', 'stale-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 stale-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', 'stale-changelog', 'retry-runbook', 'sdk-example']
2ranked order: ['api-guide', 'retry-runbook', 'sdk-example', 'blog-post', 'stale-changelog']
3rank scores: [('api-guide', 2.488), ('retry-runbook', 2.428), ('sdk-example', 2.296), ('blog-post', 1.282), ('stale-changelog', 0.894)]A production scorer learns weights or nonlinear interactions from labeled examples. The request path remains the same: retrieve cheaply, score the bounded candidate set, then apply policy.
Two-tower architectures and the precomputation constraint
Candidate retrieval often uses a two-tower architecture. 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. This pattern dates back at least to deep semantic search models that projected queries and documents into one shared space.[1]
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.
1import numpy as np
2
3rng = np.random.default_rng(42)
4n_items = 1_000
5dim = 16
6candidate_budget = 5
7
8# Item tower output is computed before any live query arrives.
9doc_embeddings = rng.normal(size=(n_items, dim))
10query_embedding = rng.normal(size=dim)
11
12# Exact search stands in for an ANN lookup in this small lab.
13retrieval_scores = doc_embeddings @ query_embedding
14candidate_ids = np.argsort(retrieval_scores)[-candidate_budget:]
15
16# A pairwise reranker now computes cross-features for five rows, not 1,000.
17candidate_quality = rng.normal(size=candidate_budget)
18cross_features = np.column_stack(
19 [retrieval_scores[candidate_ids], candidate_quality]
20)
21rerank_scores = cross_features @ np.array([0.8, 0.2])
22
23print("offline item vectors:", doc_embeddings.shape[0])
24print("live query vectors:", 1)
25print("pairwise rows before retrieval:", n_items)
26print("pairwise rows after retrieval:", rerank_scores.shape[0])
27
28assert doc_embeddings.shape == (n_items, dim)
29assert len(set(candidate_ids)) == candidate_budget
30assert rerank_scores.shape == (candidate_budget,)1offline 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.
A metric that values the top slots
In a ranked list, placing the best result first matters more than moving 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 . Position 1 has denominator , so it keeps full weight. Lower positions receive progressively smaller weight. Normalized DCG (NDCG) divides by the score of the ideal ordering, producing a value between zero and one for a query.[2]
Calculate NDCG@5 for both orders. Build the ideal top five by sorting 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:", round(reranker_ndcg(candidates, 5), 3))
17print("ranked-order reranker ndcg@5:", round(reranker_ndcg(ranked, 5), 3))1retrieval-order reranker ndcg@5: 0.91
2ranked-order reranker ndcg@5: 1.0The 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:", round(reranker_ndcg(tight_ranked, 3), 3))
19print("k=3 system ndcg:", round(system_ndcg(tight_ranked, eligible, 3), 3))1k=3 survivors: ['api-guide', 'blog-post', 'stale-changelog']
2k=3 grades: [3, 0, 0]
3k=3 reranker ndcg: 1.0
4k=3 system ndcg: 0.674At k=3, the perfect reranker order among survivors still yields reranker NDCG 1.0, because the ideal list is built only from those three documents. System NDCG falls to about 0.673 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.
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.
Turn judgments into pair preferences
Pairwise learning-to-rank methods such as RankNet train from preferences so a relevant item scores above a less relevant one.[3] For this one query, create the preference pairs from offline judgments.
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', 'stale-changelog'), ('retry-runbook', 'blog-post')]The pair ("api-guide", "blog-post") says the API guide should score higher for this query. 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. Joachims, Swaminathan, and Schnabel describe propensity-weighted learning-to-rank as a counterfactual correction for biased implicit feedback.
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. Add constraints for stale documents, private drafts, source policy, and overconcentration. 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.[4] 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 |