search_ads_recsys / 44 · learning to rank search · 5 / 10

Learning to rank for search

Retrieval (lessons 42/43) hands you a few hundred candidates. Learning to rank (LTR) scores them with rich query–document features and produces the relevance ordering the user actually sees. The art is the feature vector and the choice of objective.

The plan

Where this sits in the cascade (lesson 01): retrieval answers "which ~500 documents could plausibly match?" cheaply, over the whole corpus. Ranking — this lesson — answers "what is the exact order of those 500?" using features too expensive to compute corpus-wide. The next lesson (45) adds a small, even-more-expensive neural rerank stage on the top ~50. Each stage spends more compute on fewer documents. LTR is the middle stage, and historically the one that moved the metric most per dollar.

The feature vector — the heart of classical LTR

A classical LTR model is a function s = f(x) where x is a feature vector for a single (query, document) pair, and s is a scalar score. You compute x for every retrieved candidate, score each one, and sort descending. Everything interesting lives in x. A production web-search ranker has hundreds to low-thousands of features; they fall into four families.

FamilyDepends onConcrete examples
Query features query only length in tokens; detected intent (navigational / informational / transactional, from lesson 41); query frequency (head vs tail); has-entity; is-question; language.
Document features document only quality / spam score; freshness (age in days); PageRank / authority; document length; click-through rate aggregated over all queries (popularity prior); domain trust.
Query–document match both — the load-bearing family BM25 score itself (callback to 42); dense cosine similarity (43); exact-match term count; term proximity / min-window span; field matches (does the query hit the title vs only the body?); per-query historical CTR for this doc.
User / context query + user + session location, device, time-of-day; personalization signals — prior clicks on this domain, session intent. Mostly deferred to lesson 47; a non-personalized ranker drops this family.
The one idea to internalize: retrieval scores are ranking features, not answers
BM25 (42) and dense cosine (43) each produce a score that orders the candidate set. The naive move is to sort by one of them and ship it. LTR's move is to feed both scores, plus a few hundred others, into a learned model and let the model decide how much each matters — for this query type, this intent, this field. BM25 is great for tail queries with rare matching terms; cosine is great when wording differs from intent; freshness matters for news and not for reference. A single retrieval score can't make those trade-offs. The ranker learns them from data. This is exactly why a hybrid system (43) often does its final fusion inside the ranker rather than with a fixed RRF constant.

The features must be cheap enough to compute for a few hundred candidates within the ranking latency budget, but they can be far richer than retrieval's — retrieval features must be precomputable into an index over a billion documents; ranking features are computed on-the-fly for ~500. That asymmetry is the whole reason for a separate ranking stage. Term-proximity over the matched spans, field-level BM25, and a cross-feature like "query-length × doc-length" are all fair game in the ranker and impossible in the inverted index.

Labels — what is "correct"?

To learn f you need a target relevance for each (query, document). Two sources, with opposite trade-offs.

Graded human judgments (qrels)

Human raters assign a graded relevance label on an ordinal scale, classically 0–4: 0 = irrelevant, 1 = related, 2 = relevant, 3 = highly relevant, 4 = perfect/navigational. The set of judged (query, doc, grade) triples is called qrels (query relevance judgments — the term comes from the TREC evaluation tradition). Grades are expensive (rater time, guidelines, double-judging for agreement) but unbiased by presentation: a rater judges the document on its merits, not on where it happened to appear.

Pooling. You cannot judge every document for every query — that is billions of pairs. Instead you pool: run several ranking systems (or several configs), take the union of each one's top-k (say top-20), and send only that pool to raters. Anything no system surfaced is assumed irrelevant. Pooling makes judgment tractable but introduces a bias of its own — a future system that surfaces a genuinely great document no pooled system found gets no credit for it. You re-pool periodically to patch this.

Implicit clicks

Click logs are nearly free and arrive in the billions: every search session is a labeled-ish example. But clicks are biased labels, and conflating "clicked" with "relevant" is the classic junior mistake. The dominant distortion is position bias: a document at rank 1 gets clicked far more than the same document at rank 5, simply because users look at the top first. So a click confounds "this document is relevant" with "this document was shown high." There is also presentation bias (rich snippets, thumbnails draw the eye) and trust bias (users trust top results). All of this is lesson 07; the consequence for LTR is that you cannot feed raw clicks to a ranker without correction, or you train it to reproduce the previous ranker's ordering — a feedback loop, not learning.

Human judgments (qrels)Implicit clicks
CostHigh (rater hours)~Free (logged)
VolumeThousands of queriesBillions of sessions
BiasLow (guideline drift only)High — position / presentation / trust
FreshnessStale between judgment roundsReal-time
Tail coveragePoor (only judged queries)Good (whatever users issued)
UseEval gold + head-query trainingDebiased training at scale

The mature answer uses both: human judgments as the unbiased evaluation gold and for high-value head queries, and position-debiased clicks (IPS weighting, below) for the volume and tail coverage that judgments can never reach.

Three objectives, made concrete for search

Lesson 05 established the pointwise / pairwise / listwise families and their calibration properties. Here we make each concrete for ranking and work the numbers — because the difference between them is exactly the difference between "predict a number per document" and "get the order right where it matters."

Pointwise — regress or classify each document independently

Treat each (query, doc) as an independent supervised example: regress the grade (f(x) ≈ grade) with squared error, or classify relevant-vs-not with BCE. Train, then sort by predicted score. Simple, reuses all your standard regression/classification machinery, and the output can even be calibrated (05).

The structural flaw: ranking is relative, and pointwise loss is absolute. A pointwise model is penalized for predicting 2.7 when the grade is 3, even if every other document scored lower and the order is already perfect. It spends capacity matching absolute grade values that the user never sees — the user sees only the sort. Worse, it weights a query with 1000 candidates 1000× more than a query with one candidate, and it has no notion that getting rank-1 right matters more than rank-50. Pointwise is the right baseline and often a fine production choice when you also need a calibrated probability (ads — 05), but on a pure ranking metric it leaves points on the table.

Pairwise — RankNet

Stop predicting grades; predict preferences. For a pair of documents i, j under the same query where i is more relevant than j (graded higher), RankNet (Burges et al., 2005) models the probability that i should rank above j as a sigmoid of the score difference:

P(i ≻ j) = σ(si − sj) = 1 / (1 + e−(si − sj))

The loss is binary cross-entropy against the known label (the pair is correctly ordered, so the target is 1):

Lij = −log σ(si − sj)

This is the same loss as BPR from 05 — pairwise preference, depends only on the difference si − sj, so it is shift-invariant and produces a ranking score, not a probability. The point of RankNet is that it optimizes the order directly: it is only ever asked "is i above j?", never "what grade is i?".

Worked numbers — RankNet on a correct and a wrong pair

Correctly-ordered pair. Document i is more relevant and the model agrees: si = 2.0, sj = 1.0. Then

σ(si − sj) = σ(2.0 − 1.0) = σ(1.0) = 1 / (1 + e−1) = 1 / (1 + 0.3679) = 0.731
Lij = −log(0.731) = 0.313 nats

The model assigns a 73% probability to the correct ordering and pays a small loss — it is mostly right but not certain. To drive this down it must widen the gap si − sj.

Mis-ordered pair. Suppose the model got it backwards — it scored the more-relevant document lower: si = 1.0, sj = 2.0, so si − sj = −1.0. Now

σ(−1.0) = 1 / (1 + e1) = 1 / (1 + 2.718) = 0.269
Lij = −log(0.269) = 1.314 nats

The loss is 4.2× larger (1.314 vs 0.313) — the model is penalized hard for the inversion, and the gradient pushes si up and sj down. Note the asymmetry: a confident-correct pair (si − sj = +3, loss ≈ 0.049) costs almost nothing, while a confident-wrong pair (−3, loss ≈ 3.05) costs a lot. That is exactly the shape you want for a ranking loss.

RankNet's blind spot: every mis-ordered pair contributes the same amount of loss regardless of where in the list the inversion happens. Swapping the documents at ranks 1 and 2 (which a user definitely notices) costs the same as swapping ranks 99 and 100 (which nobody sees). The metric we actually care about — NDCG (08, 48) — is dominated by the top. RankNet doesn't know that. Listwise fixes it.

Listwise — LambdaRank / LambdaMART and the λ trick

The breakthrough (Burges, 2006/2010) is almost embarrassingly direct. RankNet's gradient on the pair (i, j) has a magnitude; call it λij. LambdaRank scales that gradient by the change in the ranking metric that would result from swapping i and j in the current ranking:

λij = −σ(sj − si) · |ΔNDCGij|

The first factor is the RankNet gradient (large when the model is wrong about the pair). The second factor, |ΔNDCGij|, is the absolute change in NDCG if you swapped those two documents' positions, holding everything else fixed. The remarkable thing: nobody ever differentiates NDCG (it is flat-then-step, non-differentiable). LambdaRank skips the loss entirely and defines the gradients directly — the λ's — which is legal because gradient descent only ever needs gradients. The effect: the model pours its learning into the pairs whose correct ordering moves the metric most, which are overwhelmingly the pairs near the top.

LambdaMART is LambdaRank's gradient applied to gradient-boosted regression trees (GBDT / MART = Multiple Additive Regression Trees) instead of a neural net. Each boosting round fits a tree to the current λ's. This combination dominated production web-search LTR for roughly a decade, and we'll see why after the worked example.

First, NDCG itself, since the λ's are built on it. DCG (Discounted Cumulative Gain) at position p uses gain 2grade − 1 discounted by log2(rank + 1); NDCG normalizes by the ideal (best-possible) DCG so it lands in [0, 1].

DCG = Σr (2grader − 1) / log2(r + 1) · NDCG = DCG / IDCG
Worked numbers — NDCG and why the λ concentrates at the top

A 4-document candidate list with true grades. The model's current ranking happens to be perfect, so we will measure how much two different swaps hurt — which is exactly what |ΔNDCG| measures.

rank rgradegain 2g−1discount log₂(r+1)DCG term
137log₂2 = 1.0007 / 1.000 = 7.000
223log₂3 = 1.5853 / 1.585 = 1.893
311log₂4 = 2.0001 / 2.000 = 0.500
400log₂5 = 2.3220 / 2.322 = 0.000

This ordering is already ideal (grades descending), so DCG = IDCG = 7.000 + 1.893 + 0.500 + 0 = 9.393 and NDCG = 1.000.

Swap A — top-1 with rank-3 (grade-3 doc drops to rank 3, grade-1 doc rises to rank 1):

DCG' = 1.000 + 1.893 + 3.500 + 0 = 6.393 → NDCG' = 6.393 / 9.393 = 0.681
|ΔNDCG|A = 1.000 − 0.681 = 0.319

Swap B — rank-3 with rank-4 (grade-1 doc and grade-0 doc trade places, at the bottom):

DCG'' = 7.000 + 1.893 + 0 + 0.431 = 9.324 → NDCG'' = 9.324 / 9.393 = 0.993
|ΔNDCG|B = 1.000 − 0.993 = 0.007

The punchline. The top swap costs |ΔNDCG| = 0.319; the bottom swap costs 0.007 — a ~45× larger signal at the top. Since λ ∝ |ΔNDCG|, LambdaRank's gradient for the top pair is ~45× larger than for the bottom pair, even though RankNet alone would treat them identically. The model is steered to fix top-of-list inversions first. That is the entire reason listwise beats pairwise on a top-weighted metric.

ListNet is the other listwise style: instead of swap-deltas, it defines a probability distribution over the list via softmax of scores (the top-one probability P(i) = esi / Σj esj) and minimizes cross-entropy against the distribution implied by the true grades. Clean and fully differentiable, but it does not explicitly weight by the eval metric, and in practice LambdaMART's metric-aware gradient won the production bake-offs.

Why LambdaMART dominated — and where neural rankers fit now

For roughly 2010–2020, the default production web-search ranker was LambdaMART (GBDT trained with LambdaRank gradients — LightGBM's lambdarank objective is exactly this). Four reasons, each a real engineering property:

Where neural / transformer rankers fit now. They win precisely where GBDTs are weak: when the signal is in raw text rather than hand-built features. A cross-encoder that reads the query and document text jointly (BERT-style, lesson 45) captures semantic match that no BM25 / proximity feature encodes — but it costs orders of magnitude more per document. So the modern stack is usually both: a fast LambdaMART (or a small neural ranker) over feature vectors ranks the few hundred candidates, and an expensive cross-encoder reranks only the top ~50. The GBDT didn't get replaced; it got a neural stage stacked on top of it (next lesson). The trees also frequently consume the neural model's score as one more feature — closing the loop on "retrieval/model scores are features."

The ranking pipeline

retrieval candidates (~500 docs from lessons 42 + 43) │ each candidate → build a feature vector x ▼ ┌─────────────────────────────────────────────────────────┐ │ FEATURE VECTOR x (per query-document pair) │ │ │ │ query feats : len, intent, freq ┐ │ │ doc feats : quality, freshness, PageRank │ │ │ ★ match feats : BM25 ◄── retrieval score (42)│ ~hundreds│ │ cosine◄── retrieval score (43)│ of dims │ │ proximity, field-match, CTR │ │ │ user/ctx : geo, device, session (L47) ┘ │ └─────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ LambdaMART : GBDT ensemble (Multiple Additive Reg.Trees) │ │ │ │ tree₁ tree₂ tree₃ ... tree_T │ │ ▱ ▱ ▱ ▱ │ │ / \ / \ / \ / \ │ │ split on split on split on split on │ │ BM25>12? fresh<7d? cosine>.4? intent=nav? │ │ │ │ score(x) = Σ_t leaf_t(x) ← gradients = λ's, │ │ scaled by |ΔNDCG| │ └─────────────────────────────────────────────────────────┘ │ score per document ▼ sort candidates by score → ranked list (top to bottom) │ ▼ top ~50 → neural rerank stage (lesson 45)

Read it as: retrieval scores enter on the left as two features among hundreds; the ensemble learns query-conditional thresholds on them (note the splits — "use BM25 heavily, but on a fresh-doc / navigational query weight freshness and exact title-match more"); the summed leaf values are the score; the sort is the output ranking; and the top slice flows to lesson 45's reranker.

Practical concerns

Training data from logs, and feature logging

The data engineering is where LTR projects actually fail. You need, for each impression: the feature vector exactly as the model saw it at serving time, the label (click/grade), and the position shown. The single most important rule: log the features at serving time, do not recompute them offline. If you recompute BM25 / freshness / CTR features from a later snapshot of the index, they will not match what the model scored — freshness drifts, CTR aggregates grow, the index changes. That train/serve skew silently degrades the model and is nearly invisible (the loss looks fine offline; production NDCG quietly sags). The fix is a feature-logging system that snapshots the served feature vector into the training log. This is the search analog of the calibration train/serve trap in 05 and the geometry trap in 03 — same lesson, different stage.

Position-debiased training with IPS

Recall clicks are position-biased (07). If a document at rank 1 gets clicked, part of that click is "it was relevant" and part is "it was at rank 1." Training on raw clicks teaches the ranker to reproduce the old ranker. The standard correction is Inverse Propensity Scoring (IPS): estimate the examination propensity pr = P(user examined position r), then weight each clicked example by 1 / pr. A click at a rarely-examined low position is strong evidence of relevance (the user had to work to see it), so it gets up-weighted; a click at rank 1 is weak evidence, down-weighted.

Worked numbers — IPS reweighting

Suppose the examination propensities are p1 = 1.0 (rank 1 always examined) and p5 = 0.2 (rank 5 examined a fifth of the time). A click at rank 1 contributes weight 1 / 1.0 = 1. A click at rank 5 contributes weight 1 / 0.2 = 5. So one rank-5 click counts as much as five rank-1 clicks toward "this is relevant." Without IPS, the model would learn that rank-1 documents are 5× more relevant purely because they were examined 5× more — pure position bias baked into the model. IPS removes the part of the click that the position explains, leaving the part that relevance explains. The propensities themselves come from a position-bias model or from randomization experiments (result-randomization / RandPair), per lesson 07.

IPS is unbiased but high-variance (those 1/p weights blow up for tiny propensities); production systems clip the weights, smooth the propensity estimates, and lean on human judgments for the head where clicks are densest and propensity estimates least reliable.

Interactive · LTR objective explorer

A toy 5-document candidate list, each with a fixed true grade (0–4) and a current model score. Toggle the objective and watch its loss; nudge any one document's score with the slider and watch the loss, the pairwise/λ gradient on that document, and the NDCG of the resulting order all respond live. toy model

Move a score, watch the objective and NDCG respond
5 docs, true grades fixed. Scores start mis-ordered so there is something to fix. Pointwise = squared error to grade (absolute). Pairwise = RankNet BCE summed over all mis-orderable pairs. Listwise = same pairs but each weighted by |ΔNDCG| of swapping them (LambdaRank). The "λ on selected" KPI is the listwise gradient magnitude on the doc you're moving — note how it spikes when the doc is near the top.
OBJECTIVE
doctrue grademodel scorecurrent rank
objective loss
NDCG of order
λ on selected (listwise grad)
order optimal?
Reading

Interview prompts you should be ready for

  1. "You already have BM25 and a dense cosine score from retrieval. Why train a ranker at all — why not just sort by one of them?" (The senior answer: those are two features. A single retrieval score can't make query-conditional trade-offs — BM25 for rare-term tail queries, cosine for vocabulary-mismatch, freshness for news. The ranker learns, from data, how to combine hundreds of features including both retrieval scores. Sorting by one score is leaving the entire ranking stage on the floor.)
  2. "Walk me through why LambdaRank beats RankNet, with a concrete example." (RankNet treats every inversion equally; LambdaRank scales each pair's gradient by |ΔNDCG| of swapping it. A top swap moves NDCG far more than a bottom swap — in the worked example, 0.319 vs 0.007, ~45× — so the gradient concentrates where the user looks. Bonus signal: nobody differentiates NDCG; the λ's are the gradients, defined directly.)
  3. "Your clicks say rank-1 results are great. How do you keep your ranker from just learning to reproduce the old ranker?" (Position bias — clicks confound relevance with exposure. IPS: weight each click by 1/propensity-of-examination so low-position clicks count more. Propensities from a position-bias model or randomization. Clip the weights for variance. Link to lesson 07.)
  4. "Why did LambdaMART (GBDT) stay the production default for a decade, and what changed?" (Trees are scale-invariant — they handle heterogeneous BM25/cosine/PageRank/boolean features with no normalization, missing values, few-hundred-feature tabular regimes where neural nets underfit, and they're debuggable. What changed: cross-encoders read raw query+doc text and capture semantic match no hand-built feature encodes — but they're expensive, so they rerank only the top ~50 on top of the GBDT, not replace it.)
  5. "What's the most likely silent bug in an LTR training pipeline?" (Train/serve feature skew — recomputing features offline from a later index snapshot instead of logging the served feature vector. Freshness/CTR/BM25 drift, the offline loss looks fine, production NDCG sags invisibly. Fix: snapshot the exact served features into the training log. Same family as the calibration and geometry train/serve traps.)
  6. "When would you use graded human judgments vs clicks for training?" (Judgments: unbiased, expensive, low-volume — eval gold + high-value head queries; built via pooling, which has its own coverage bias. Clicks: free, biased, huge volume — debiased with IPS for the tail. Mature systems use both: judgments as the unbiased metric, debiased clicks for scale. A junior says "just use clicks" and inherits the feedback loop.)
Takeaway
Retrieval produces a candidate set; LTR produces the order. The candidate set's own retrieval scores — BM25, dense cosine — re-enter the ranker as features, alongside query, document, match, and context features the index can't hold. The objective decides what "good order" means: pointwise predicts absolute grades and ignores that ranking is relative; pairwise (RankNet) optimizes order pair-by-pair but weights all inversions equally; listwise (LambdaRank/LambdaMART) scales each pair's gradient by |ΔNDCG| so the model fixes the top first — the worked example showed a top swap mattering ~45× more than a bottom one. LambdaMART (scale-invariant, robust, debuggable GBDT) owned production LTR for a decade and now sits under a neural reranker rather than being replaced by it (lesson 45). The two failure modes a senior names without prompting: training on raw position-biased clicks (fix with IPS, lesson 07) and train/serve feature skew (fix by logging served features).