LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 177 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/8
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/11
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageClustering and PCACore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/24
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernanceData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/16
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/19
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning & Test-Time Compute
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnProduction ML SystemsRanking and Recommendation Systems
📊MediumEvaluation & Benchmarks

Ranking and Recommendation Systems

Rank documents for a developer using candidate retrieval, relevance metrics, and feedback-loop safeguards.

15 min read
Learning path
Step 45 of 177 in the full curriculum
Gradient Boosted Trees in ProductionForecasting and Anomaly Detection

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.

Three aligned ranking contracts: eligibility leaves five of seven documents, reranking changes relevance grades from 3-0-0-2-2 to 3-2-2-0-0, and inverse-propensity correction makes two raw click rates equal at 0.40.
Eligibility removes two blocked documents, a candidate budget of 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.

StageJobExample guard
eligibility filterremove forbidden or stale documentscurrent, permitted source
candidate generationrecover perhaps 200 plausible documentstext or embedding retrieval
rankingorder candidates preciselyrelevance, freshness, source trust
business policyenforce final constraintssponsored 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.

build-doc-search-fixture.py
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)
Output
1corpus rows: 7 2query: retry idempotency key

The corpus includes a private draft and a stale guide. Remove both before retrieval. A learned score can't override access policy.

filter-eligible-listings.py
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)
Output
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.

measure-candidate-recall.py
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 )
Output
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.000

At 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.

score-and-rerank.py
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])
Output
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 u(q)∈Rdu(q) \in \mathbb{R}^du(q)∈Rd. A separate encoder maps each document or item to a vector v(i)∈Rdv(i) \in \mathbb{R}^dv(i)∈Rd. 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]Reference 1Learning Deep Structured Semantic Models for Web Search Using Clickthrough Data.https://www.microsoft.com/en-us/research/publication/learning-deep-structured-semantic-models-for-web-search-using-clickthrough-data/

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 O(log⁡N)O(\log N)O(logN) 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.

two-tower-vs-cross-feature.py
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,)
Output
1offline item vectors: 1000 2live query vectors: 1 3pairwise rows before retrieval: 1000 4pairwise rows after retrieval: 5

Precomputation 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:

DCG@k=∑i=1k2reli−1log⁡2(i+1)\text{DCG@k} = \sum_{i=1}^{k} \frac{2^{rel_i} - 1}{\log_2(i+1)}DCG@k=i=1∑k​log2​(i+1)2reli​−1​

Here relirel_ireli​ is the judged relevance at position iii. Position 1 has denominator log⁡2(2)=1\log_2(2)=1log2​(2)=1, 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]Reference 2Introduction to Information Retrieval.https://nlp.stanford.edu/IR-book/

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.

compare-ndcg.py
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))
Output
1retrieval-order reranker ndcg@5: 0.91 2ranked-order reranker ndcg@5: 1.0

The 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:

compare-system-and-reranker-ndcg.py
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))
Output
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.674

At 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]Reference 3Learning to Rank using Gradient Descent.https://www.microsoft.com/en-us/research/publication/learning-to-rank-using-gradient-descent/ For this one query, create the preference pairs from offline judgments.

build-pair-preferences.py
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])
Output
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.

Diagram showing Eligible corpus, Candidates, Ranker, and Top results.
Eligible corpus, Candidates, Ranker, and Top results.

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.

write-impression-log.py
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])
Output
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 fieldReason
request and query IDgroup displayed slate and join later outcomes
eligibility-policy versionknow which documents were allowed
candidate set versionknow what could have ranked
final positionsmeasure exposure
ranker versionattribute outcomes
timestamped click, save, resolved, bad-result eventsdistinguish curiosity from value
freshness and permission snapshotreproduce 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.

reproduce-position-bias.py
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 )
Output
1same-quality-a: position=1 raw_ctr=0.40 2same-quality-b: position=2 raw_ctr=0.20

Naive 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.

correct-toy-position-bias.py
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}")
Output
1same-quality-a: corrected_signal=0.40 2same-quality-b: corrected_signal=0.40

Both 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.

reproduce-policy-bypass.py
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"])
Output
1unsafe first result: private-draft 2permitted: False

The 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 5 must be 1.0
  • reranker NDCG@5 must be at least 0.95
  • p95 scorer latency must be at most 20 milliseconds
  • blocked documents must stay absent
  • impression rows must include fields needed for replay
  • outcome events must join a logged impression
check-offline-ranking-gates.py
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()))
Output
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: True

Passing 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]Reference 4Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing.https://experimentguide.com/ 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.

publish-ranking-candidate-receipt.py
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])
Output
1status: candidate_for_ab_test 2offline: {'candidate_recall_at_5': 1.0, 'reranker_ndcg_at_5': 1.0, 'p95_latency_ms': 18} 3receipt sha256: d0fe7347e924

The 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

SymptomCauseFix
NDCG looks strong but desired document never appearscandidates lost recallmeasure retrieval recall and system NDCG separately from reranker NDCG
Prohibited document appears firstpolicy filter ran after scoring or not at allfilter eligibility before retrieval and gate blocked impressions
Popular documents dominate foreverclick-position looplog exposure and use controlled evaluation
Corrected click score still looks suspiciouspropensity estimate is wrongvalidate click model or collect randomized intervention data
Corrected click score becomes unstabletiny propensities create large inverse weightsadd variance controls and evaluate estimator behavior
Relevance improves while bad-result reports risewrong online objectivepair engagement with success and bad-result guardrails
Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A candidate generator for the query returns, in order, ['api-guide', 'blog-post', 'stale-changelog']. The judged relevant eligible documents are {'api-guide', 'retry-runbook', 'sdk-example'}. What does this imply before any reranker runs?

Correct answer: Candidate recall is 1/3; retry-runbook and sdk-example can't appear because they never reach the reranker.

Candidate recall counts judged relevant eligible documents that survive candidate generation. Only api-guide is returned, so recall is 1 out of 3. A reranker can reorder only its input set, so missing relevant documents must be exposed by recall rather than hidden inside a ranking metric on survivors.

2.An engineer proposes using each document's offline human relevance grade as an online ranker feature. What is the correct design?

Correct answer: Reserve relevance for labels and evaluation; score online with available signals such as query fit and reliability.

The relevance grade is an offline judgment for a frozen query. Treating it as a serving feature leaks the target and assumes a judgment exists for future requests. The ranker should instead use available query, document, freshness, and source-trust signals, while relevance supplies training labels and evaluation grades.

3.For a fixed candidate set, the retrieval order has relevance grades [3, 0, 0, 2, 2], while the reranked order has [3, 2, 2, 0, 0]. Why is the reranked slate's NDCG@5 equal to 1.0?

Correct answer: It places the top five candidates in the ideal order obtained from all candidate relevances, so its DCG equals the candidate set's ideal DCG@5.

NDCG@5 divides the slate's DCG@5 by the DCG of the best five grades selected from all candidates and sorted by relevance. Here the candidate set has exactly five items, and [3, 2, 2, 0, 0] is already ideal, so the normalized value is 1.0. This is a reranker metric over surviving candidates, not proof of whole-corpus recall.

4.A developer accidentally sorts the raw corpus with rank_score before eligibility filtering. The top result is 'private-draft' with permitted=False because it has strong query_fit and reliability. What change fixes the required policy invariant?

Correct answer: Filter freshness and access-policy eligibility before retrieval and keep a release check that blocked documents are absent.

Policy eligibility is a hard constraint, not a preference for the ranker to learn. If forbidden or stale documents enter retrieval or scoring, a strong relevance score can put them at the top. Filter them before candidate generation and verify that blocked documents remain absent in release checks.

5.A ranked top-three slate contains three permitted, current documents from one source. A hard rule allows at most two documents from one source in the top three. What should the system do?

Correct answer: Apply the diversity constraint after scoring, adjust the slate to at most two, and gate releases on that invariant.

The documents are eligible, so the issue isn't access permission or freshness filtering. It's a final-slate constraint. A hard diversity rule should deterministically adjust the ranked slate and be covered by release checks rather than left for a learned score to trade away.

6.Which logging design preserves the evidence needed to replay a slate, join outcomes, and analyze position bias?

Correct answer: Write an immutable row for every exposed document before outcomes, including request, position, snapshots, and component versions.

Clicks alone omit the documents that were shown but ignored, and aggregate logs lose request-level exposure. An immutable impression row written before outcomes records the actual slate, position, serving context, and deployed versions. Later events can then join by request and document.

7.A team adds query-document cross-attention inside the item encoder used for first-stage retrieval. What serving property does this break?

Correct answer: Item vectors can no longer be precomputed independently because each representation now depends on the live query.

Two-tower retrieval scales by encoding items independently of the live query. Query-document cross-attention couples both sides, so the item representation changes per request. Keep that pairwise work behind candidate retrieval, where it runs over a bounded set.

8.Document A at slot 1 has 40 clicks from 100 impressions with propensity 1.0. Document B at slot 2 has 20 clicks from 100 impressions with assumed propensity 0.25. Using clicks / propensity / impressions, what follows?

Correct answer: A=0.40 and B=0.80, but the gap depends on the propensity assumption and doesn't prove B is twice as appealing.

A's corrected signal is 40 / 1.0 / 100 = 0.40. B's is 20 / 0.25 / 100 = 0.80. Changing the assumed slot-two propensity changes the conclusion even though the clicks didn't change. Propensities therefore require careful estimation, and small values create large, noisy inverse weights.

9.A reranker achieves NDCG@5 of 1.0 on one frozen query. What evidence is still needed before treating its offline ranking quality as established?

Correct answer: Average NDCG over a frozen query set, inspect failed queries and slices, and report candidate recall separately.

A perfect score on one query establishes only that one judged slate. Ranking quality must be aggregated over a frozen query set and examined on slices such as languages and new documents. Candidate recall remains separate because reranker NDCG over survivors can hide retrieval losses.

10.A candidate passes these offline gates: candidate_recall_at_5=1.0, reranker_ndcg_at_5=1.0, nearest-rank p95 latency=18 ms with a 20 ms limit, blocked documents absent, valid impression and outcome schemas, and outcomes join impressions. What status is justified?

Correct answer: It's a candidate for a controlled A/B test with pinned versions, predeclared metrics, minimum exposure, guardrails, and safety stops.

Passing offline checks is necessary but not enough for a global rollout. Live engagement can change because of latency, layout, freshness, source trust, and ranking behavior. Randomized assignment, pinned versions, minimum exposure, guardrails, and safety stops reduce the chance of accepting a noisy or unsafe change.

10 questions remaining.

Next Step
Continue to Forecasting and Anomaly Detection

Ranking orders choices at one moment. Next you'll predict values over future time periods and distinguish recurring demand patterns from operational surprises.

PreviousGradient Boosted Trees in Production
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Learning Deep Structured Semantic Models for Web Search Using Clickthrough Data.

Huang, P.-S., He, X., Gao, J., Deng, L., Acero, A., & Heck, L. · 2013 · CIKM

https://www.microsoft.com/en-us/research/publication/learning-deep-structured-semantic-models-for-web-search-using-clickthrough-data/

Introduction to Information Retrieval.

Manning, C. D., Raghavan, P., Schutze, H. · 2008 · Cambridge University Press

https://nlp.stanford.edu/IR-book/

Learning to Rank using Gradient Descent.

Burges, C. J. C., et al. · 2005 · ICML 2005

https://www.microsoft.com/en-us/research/publication/learning-to-rank-using-gradient-descent/

Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing.

Kohavi, R., Tang, D., Xu, Y. · 2020

https://experimentguide.com/

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.