Rank documents for a developer using candidate retrieval, relevance metrics, and feedback-loop safeguards.
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.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 intentionally 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']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.
Pause and predict: If the ranker becomes perfect while candidate budget stays at
3, can the runbook appear? No. It never reaches the ranker.
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.
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.[1]
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.
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.
Pairwise learning-to-rank methods such as RankNet train from preferences so a relevant item scores above a less relevant one.[2] 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.
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 |
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 the probability that its position was examined. The propensities below are deliberately fixed for the miniature example: slot one is examined with probability 1.0, slot two with probability 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.
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.
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:
5 must be 1.00.9520 milliseconds1latencies_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.[3] 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(dumps(receipt, indent=2, sort_keys=True))
33print("receipt sha256:", sha256(payload.encode()).hexdigest()[:12])1{
2 "artifact": "docs-ranker-v7",
3 "candidate_set": "retrieval-v3",
4 "eligibility_policy": "docs-access-policy-v4",
5 "experiment": {
6 "assignment_unit": "developer_id",
7 "control": "docs-ranker-v6",
8 "guardrails": [
9 "bad_result_reports_per_session",
10 "p95_latency_ms",
11 "blocked_document_impressions"
12 ],
13 "minimum_duration_days": 14,
14 "minimum_requests_per_arm": 5000,
15 "primary_metric": "successful_resolutions_per_session",
16 "safety_stops": {
17 "blocked_document_impressions": 0,
18 "p95_latency_ms": 20
19 },
20 "treatment": "docs-ranker-v7"
21 },
22 "offline": {
23 "candidate_recall_at_5": 1.0,
24 "p95_latency_ms": 18,
25 "reranker_ndcg_at_5": 1.0
26 },
27 "release_checks": {
28 "blocked_items_absent": true,
29 "candidate_recall_at_5": true,
30 "impression_schema": true,
31 "outcome_schema": true,
32 "outcomes_join_impressions": true,
33 "p95_latency_ms": true,
34 "reranker_ndcg_at_5": true
35 },
36 "required_checks_pass": true,
37 "status": "candidate_for_ab_test"
38}
39receipt 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.
5 to 4. Predict which relevant document disappears, then rerun candidate recall and reranker NDCG. Explain why both metrics matter.blog-post relevance from 0 to 1. Recompute reranker NDCG and explain how graded judgments change evaluation.save outcome event. Decide whether saves should replace resolved sessions as the primary online metric or remain a diagnostic signal.0.5 to 0.25. Recompute corrected signal and explain why an incorrect propensity model creates a different bias.source_id, then reproduce one failure.| Evidence | What a strong answer shows |
|---|---|
| candidate contract | separates eligibility and retrieval from learned reranking |
| offline evaluation | measures candidate recall, graded reranker NDCG, slices, and scorer latency |
| feedback safety | persists immutable impressions before joining later outcome events |
| debiasing judgment | treats toy propensity weighting as a concept demonstration, not an automatic production fix |
| experiment plan | pins versions, assignment unit, primary metric, minimum exposure, guardrails, and safety stops |
| Symptom | Cause | Fix |
|---|---|---|
| NDCG looks strong but desired document never appears | candidates lost recall | measure retrieval recall separately |
| 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 |
Answer every question, then check your score. Score above 75% to mark this lesson complete.
10 questions remaining.
Introduction to Information Retrieval.
Manning, C. D., Raghavan, P., Schutze, H. · 2008 · Cambridge University Press
Learning to Rank using Gradient Descent.
Burges, C. J. C., et al. · 2005 · ICML 2005
Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing.
Kohavi, R., Tang, D., Xu, Y. · 2020
Questions and insights from fellow learners.