Learning to rank: features and a worked feature set

What it is

Training a model to order results, rather than hand-tuning a scoring formula. Three formulations, distinguished by what the loss function sees:

POINTWISE     Predict a relevance score per (query, document).
              Loss: regression or classification, per item.
              Simple, and it optimises the wrong thing: getting
              every absolute score right is neither necessary
              nor sufficient for a good ORDERING.

PAIRWISE      Predict which of two documents is better.
              Loss: over pairs within a query.
              Directly optimises ordering. RankNet, LambdaRank,
              LambdaMART.

LISTWISE      Optimise a list-level metric directly.
              Loss: over the whole result list. ListNet,
              LambdaMART with NDCG-weighted gradients,
              SoftRank.

Commonly confused: LambdaMART is usually called listwise and is mechanically pairwise, with each pair's gradient scaled by the NDCG change that swapping that pair would produce. That scaling is what makes it optimise a list metric using pairwise machinery, and it is the reason it remains the strongest practical method on tabular features.

Also commonly confused with "use a neural ranker". Gradient-boosted trees still win on heterogeneous tabular features, which is what ranking features are; neural rankers win when the features are raw text and the model can learn representations.

The problem it solves

A hand-tuned formula stops scaling at about five signals.

score = 2.0*bm25_title + 1.0*bm25_body + 0.5*log(popularity)
      + 0.3*freshness - 0.8*log(price)

Problems:
  - The weights were chosen by someone's intuition and tested
    on a handful of queries.
  - Interactions are invisible: freshness matters enormously
    for news queries and not at all for reference queries, and
    a linear form cannot express that.
  - Every new signal requires re-tuning every weight.
  - Nobody can say whether a change helped, because there is
    no metric being optimised.

Learning to rank replaces the weights with a fit and the intuition with a metric. The model learns the interactions, and the question "did this help" becomes measurable.

What it does not solve, and the boundary to state: it reorders what retrieval returned. If the answer-bearing document was never a candidate, no ranker recovers it, which is why retrieval recall is measured separately and is the ceiling.

Mechanics

The worked feature set

This is the part that decides whether an LTR system works, and it is where most of the engineering time goes. Features in five families:

QUERY-ONLY (no document involved; the model uses these to
modulate everything else)
  query_length_tokens
  query_length_chars
  is_navigational          classifier: does it name one thing?
  has_exact_identifier     regex: SKU, model number, ISBN
  detected_language
  query_frequency_bucket   head / torso / tail
  intent_class             transactional / informational / nav
DOCUMENT-ONLY (precomputable, no query involved)
  quality_score            editorial or learned
  popularity_30d           log-scaled interactions
  freshness_days           age in days, and log(age)
  content_length
  has_image, has_price, in_stock
  seller_rating
  spam_score
QUERY-DOCUMENT (the ones that matter most, and the only ones
that must be computed at query time)
  bm25_title, bm25_body, bm25_tags     per-field lexical
  exact_phrase_match_title             boolean
  cosine_similarity_dense              vector score
  term_coverage                        fraction of query terms present
  proximity_min_window                 tightest span containing all terms
  field_with_best_match                categorical
  edit_distance_to_title               for near-miss typos
USER-DOCUMENT (personalisation; optional and expensive)
  user_category_affinity
  has_viewed_before
  days_since_last_interaction
  price_vs_user_typical_band
CONTEXT
  device_type, hour_of_day, day_of_week
  session_position         first query or a refinement?
  previous_query_in_session

Three design rules for this set, and each has a failure behind it:

1. Log-scale anything with a heavy tail. Popularity, view counts and prices span orders of magnitude, and a raw count means the model spends its capacity on the top 0.1 percent of items. log(1 + x) is almost always right.

2. Never feed a raw identifier as a numeric feature. seller_id = 88412 implies an ordering that does not exist. Trees will happily split on it and learn nothing generalisable. Use aggregate statistics of the entity instead: seller_rating, seller_return_rate.

3. Query-document features carry most of the signal, and they are the only ones that cannot be precomputed, so they set the query-time cost. Everything else comes from the feature store.

Why gradient-boosted trees, still

Ranking features are HETEROGENEOUS: counts, ratios, booleans,
categoricals, log-scaled scores, all on different scales with
different distributions.

TREES                              NEURAL NETS
+ Scale-invariant: no need to      - Need normalisation, and the
  normalise anything                 right normalisation per feature
+ Handle missing values natively   - Need imputation, which is a
                                     modelling decision
+ Capture interactions without     - Capture them, given enough data
  feature crosses
+ Fast inference: a few hundred    - Slower, and often needs a GPU
  tree traversals
+ Interpretable: feature           - Harder to attribute
  importance, SHAP
- Cannot learn representations     + Learn from raw text directly
  from raw text

The rule: trees for tabular features, neural for raw text. Which is why production systems usually run both, with a cross-encoder doing the text understanding on a small candidate set and a GBDT doing the feature-based ranking on a larger one. See the multi-stage funnel.

LambdaMART, and the idea worth knowing

The problem with a pairwise loss: swapping the documents at
positions 1 and 2 changes NDCG far more than swapping the
documents at positions 49 and 50, and a plain pairwise loss
treats those swaps identically.

LambdaRank's insight: don't define a loss function, define the
GRADIENT directly, scaled by the metric change.

  lambda_ij = |ΔNDCG_ij| * sigmoid_derivative(s_i - s_j)

  where ΔNDCG_ij is the NDCG change from swapping i and j.

Pairs whose swap barely moves NDCG get small gradients. Pairs
at the top of the list get large ones. So the model spends its
capacity where the metric is sensitive.

That is the whole idea and it is elegant: you cannot differentiate NDCG, so you weight the pairwise gradients by it instead. LambdaMART is LambdaRank's gradients inside gradient boosting, and it won the Yahoo Learning to Rank Challenge and remains the strong baseline.

import lightgbm as lgb

# The essentials for a ranking objective, with the parts that
# people get wrong called out.
ranker = lgb.LGBMRanker(
    objective="lambdarank",
    metric="ndcg",
    ndcg_eval_at=[5, 10],
    # Position discount truncation: pairs below this position
    # contribute nothing. Matching it to the page size focuses
    # the model where users look.
    lambdarank_truncation_level=20,
    n_estimators=500,
    learning_rate=0.05,
    num_leaves=63,
)

ranker.fit(
    X_train, y_train,
    # CRITICAL: group tells the ranker which rows belong to the
    # same query. Without it, it compares documents ACROSS
    # queries, which is meaningless and silently produces a
    # much worse model.
    group=group_sizes_train,
    eval_set=[(X_valid, y_valid)],
    eval_group=[group_sizes_valid],
)

The group parameter is the single most common implementation error. Ranking loss is defined within a query, and omitting the grouping makes the model learn to compare a document for one query against a document for another, which is meaningless. It trains without error and produces a much worse model.

Training data: the hard part

The model is the easy part. Labels are where LTR projects fail.

HUMAN JUDGEMENTS
  Raters score (query, document) pairs on a graded scale.
  + High quality, graded, no position bias.
  - $0.50 to $3 per judgement. 1,000 queries x 20 documents
    is 20,000 judgements.
  - Inter-rater agreement is typically 0.6-0.8, which is the
    noise floor of everything downstream and must be measured.
  - Raters judge TOPICAL relevance; users want usefulness.

CLICK LOGS
  + Free, abundant, at scale, reflects real intent.
  - POSITION BIAS dominates: position 1 gets several times the
    clicks of position 5 regardless of relevance.
  - PRESENTATION BIAS: the click reflects the snippet.
  - SELECTION BIAS: you only observe what you showed, so the
    log is a record of your current ranker's decisions.

DERIVED SIGNALS (better than raw clicks)
  click-through with a dwell threshold ("long click")
  add-to-cart, purchase, save          strong intent
  query reformulation after a click    a NEGATIVE signal: the
                                       result did not satisfy
  last click in a session              often the satisfying one

Deriving labels from clicks properly requires debiasing:

# Inverse propensity weighting. A click at a low-propensity
# position is much stronger evidence than one at position 1.
def label_from_clicks(impressions, propensity, clip=0.05):
    rel = defaultdict(float)
    for imp in impressions:
        p = max(propensity[imp.position], clip)   # clip bounds variance
        if imp.clicked and imp.dwell_ms > 30_000:
            rel[imp.doc_id] += 1.0 / p
        elif not imp.clicked:
            rel[imp.doc_id] -= 0.2 / p            # weak negative
    return rel

The clip matters and is not a detail: very low propensities produce enormous weights and the variance of the estimator explodes, so a handful of rare observations dominate training. Clipping trades a little bias for a large variance reduction.

And the practical recommendation: use both sources. Human judgements for a smaller, high-quality, position-unbiased evaluation set; debiased click data for the much larger training set. Judgements alone are too expensive to cover the tail; clicks alone bake in the current ranker's biases.

A worked example: from formula to model

STARTING POINT
  A hand-tuned formula, 6 weights, NDCG@10 = 0.62 on a
  1,000-query judged set.

STEP 1: instrument
  Log every (query, document, features, position, click, dwell)
  tuple at serving time. Without this there is no training
  data, and it takes a week of traffic to accumulate enough.

STEP 2: build the label set
  5,000 queries sampled stratified by frequency bucket, so the
  tail is represented rather than swamped by head queries.
  Human judgements on 1,000 of them (20 documents each,
  20,000 judgements) for evaluation.
  Debiased click labels on the remaining 4,000 for training.

STEP 3: features
  Start with the 12 query-document features, because they carry
  most of the signal and are the ones the current formula
  already approximates. Add document-only and query-only next.
  Personalisation last, because it is the most expensive and
  the least certain.

STEP 4: train
  LightGBM LambdaRank, group by query id, truncation at 20.
  Split by QUERY, never by row: rows from the same query in
  both train and test leaks badly and inflates the metric.

STEP 5: measure
  NDCG@10 = 0.71 offline, +14.5% over the formula.

STEP 6: DO NOT SHIP ON THAT
  Interleave against the formula. Offline gains routinely fail
  to replicate, and the most common cause is evaluation-set
  bias: the judged pool came from the OLD ranker's results, so
  the new ranker's genuinely new documents are unjudged and
  scored as irrelevant.
  Pool judgements over BOTH rankers before comparing.

STEP 7: feature importance, as a sanity check
  bm25_title              0.19
  cosine_dense            0.14
  popularity_30d_log      0.11
  exact_phrase_title      0.09
  freshness_log           0.08
  ...
  seller_id_numeric       0.06   <-- WRONG. A raw identifier
                                      should not be predictive;
                                      this is the model
                                      memorising sellers.

Step 7's finding is the kind of thing feature importance is for, and removing that feature and replacing it with seller_rating and seller_return_rate both generalises better and removes a fairness problem nobody had noticed.

Production evidence

Burges, "From RankNet to LambdaRank to LambdaMART: An Overview" (Microsoft Research, 2010) is the definitive account of the progression, including the insight that you define the gradient rather than the loss.

Chapelle and Chang, "Yahoo! Learning to Rank Challenge Overview" (2011) documents that gradient-boosted decision trees dominated the competition, and the datasets released are still standard benchmarks.

LightGBM's lambdarank objective and XGBoost's rank:ndcg are the production implementations, and LightGBM's documentation of lambdarank_truncation_level reflects that position truncation is a real tuning parameter.

Joachims et al., "Accurately Interpreting Clickthrough Data as Implicit Feedback" (SIGIR 2005) established position bias with eye-tracking, and Joachims, Swaminathan and Schnabel's "Unbiased Learning-to-Rank with Biased Feedback" (WSDM 2017) is the propensity-weighting reference.

Elasticsearch's Learning to Rank plugin and OpenSearch's equivalent implement the standard shape: features defined as queries, logged at serving time, and a model applied as a rescorer over the top N, which is exactly the funnel structure.

Airbnb's published search-ranking work documents the progression from GBDT to neural ranking and is unusually honest about the neural model initially performing worse, which is useful evidence against assuming neural is an upgrade.

The debate

The case for GBDT (LambdaMART): it wins on tabular features, needs no normalisation, handles missing values natively, trains in minutes on commodity hardware, is interpretable through feature importance and SHAP, and infers fast. Fifteen years after the Yahoo challenge it remains the strong baseline.

The case for neural ranking: it learns from raw text without hand-engineered lexical features, captures semantic matching that no BM25 feature expresses, and improves with data where trees plateau. A cross-encoder over the top 50 genuinely outperforms any feature-based model.

The case for keeping the hand-tuned formula: it is debuggable, it needs no training data pipeline, no labels, no retraining cadence, and for a small corpus with clear signals it can be close enough. The LTR machinery is a real ongoing cost.

My position: LambdaMART over a good feature set for the light ranker, a cross-encoder for the heavy ranker, and neither until there is a labelling pipeline.

The sequencing matters more than the model choice. A team that trains a ranker before it can measure whether the ranker helped has built something it cannot improve. So step one is serving-time feature logging plus a judged evaluation set, and step two is the model. Teams consistently do those in the opposite order and then cannot tell whether their model is good.

On the model, GBDT for feature-based ranking because ranking features are exactly the heterogeneous tabular data trees are best at, and because it trains in minutes, which means you can iterate. Neural rankers earn their place where the input is raw text, which is the cross-encoder stage over a small candidate set, not the feature-based stage over hundreds.

Two implementation details I would treat as non-negotiable. Group by query, because omitting it makes the model compare documents across queries, which is meaningless, trains without error and silently produces a much worse model. And split by query, never by row, because rows from the same query appearing in both train and test leaks badly and inflates the offline metric, which then fails to replicate online.

And the discipline that separates a working LTR system from a demo: pool judgements across both rankers before comparing offline. If the judged set came from the old ranker's results, the new ranker's genuinely new documents are unjudged and scored as irrelevant, so it measures worse precisely because it found something new. That is the most common cause of an offline gain failing to replicate, it is silent, and TREC solved it thirty years ago.

Where I would push back: feature engineering beats model choice at this stage. Going from six hand-tuned signals to a well-designed forty-feature set with proper log-scaling and no raw identifiers is worth far more than swapping LambdaMART for a neural ranker, and it is where I would spend the first month.

Follow-up Q&A

"Pointwise, pairwise or listwise?" Pairwise in practice, via LambdaMART, and it is worth knowing why. Pointwise predicts an absolute score per document, which optimises the wrong thing: getting every score right is neither necessary nor sufficient for a good ordering. Pairwise optimises which of two documents is better, which is what ranking is. LambdaMART is usually called listwise and is mechanically pairwise, with each pair's gradient scaled by the NDCG change that swapping it would cause, so it optimises a list metric using pairwise machinery.

"What is LambdaRank's actual insight?" That you cannot differentiate NDCG, so instead of defining a loss and deriving a gradient, you define the gradient directly and scale it by the metric change. Swapping positions 1 and 2 changes NDCG far more than swapping 49 and 50, and a plain pairwise loss treats those identically. Weighting each pair's gradient by its delta-NDCG makes the model spend capacity where the metric is sensitive.

"Why gradient-boosted trees rather than a neural network?" Because ranking features are heterogeneous tabular data: counts, ratios, booleans, categoricals and log-scaled scores on completely different scales. Trees are scale-invariant, handle missing values natively, capture interactions without explicit crosses, train in minutes, and are interpretable through feature importance. Neural nets need normalisation decisions per feature and imputation, and their advantage is learning representations from raw text, which is the cross-encoder stage over a small candidate set rather than the feature stage over hundreds.

"Walk me through your feature set." Five families. Query-only, like length, whether it is navigational, and whether it contains an exact identifier, which the model uses to modulate everything else. Document-only, precomputable: quality, log popularity, freshness, stock status. Query-document, which carry most of the signal and are the only ones computed at query time: per-field BM25, exact phrase match, dense cosine, term coverage and proximity. User-document for personalisation, which is expensive and optional. And context, like device and session position.

"What are the rules for building features?" Log-scale anything with a heavy tail, so popularity and price do not make the model spend its capacity on the top 0.1 percent of items. Never feed a raw identifier as a numeric feature, because seller_id = 88412 implies an ordering that does not exist and the model will memorise sellers rather than learn something generalisable; use aggregate statistics of that entity instead. And remember that query-document features are the only ones that cannot be precomputed, so they set the query-time cost.

"What is the most common implementation error?" Omitting the group parameter, which tells the ranker which rows belong to the same query. Ranking loss is defined within a query, so without grouping the model compares a document for one query against a document for another, which is meaningless. It trains without any error and produces a much worse model, which makes it hard to find. Close second: splitting train and test by row rather than by query, which leaks rows from the same query into both and inflates the offline metric.

"Where do the labels come from?" Both sources, deliberately. Human judgements for a smaller high-quality evaluation set, because they are graded and position-unbiased and they cost fifty cents to three dollars each, so twenty thousand judgements is a real budget. And debiased click data for the much larger training set, using inverse propensity weighting so a click at position five counts for more than one at position one, with clipping because low propensities otherwise produce enormous weights and the variance explodes. Judgements alone cannot cover the tail; clicks alone bake in the current ranker's biases.

"Your offline NDCG improved 14 percent. Do you ship?" No, interleave first. The most common cause of an offline gain failing online is evaluation-set bias: if the judged pool came from the old ranker's results, the new ranker's genuinely new documents are unjudged and default to irrelevant, so it measures worse precisely because it surfaced something new. Pooling judgements over both rankers before comparing is the TREC fix and it is thirty years old. Then interleaving decides, because it needs far less traffic than an A/B test and controls for position bias by construction.

"What would feature importance tell you?" Mostly whether the model is learning what you think. The finding worth looking for is a feature that should not be predictive being predictive: a raw seller identifier at six percent importance means the model is memorising sellers rather than learning generalisable signal, which both generalises worse and is a fairness problem. Replacing it with seller rating and return rate is strictly better. Feature importance is a debugging tool rather than an explanation.

Common misconceptions

"LambdaMART is listwise." It is mechanically pairwise with NDCG-weighted gradients, which is how it optimises a list metric with pairwise machinery.

"Neural rankers are an upgrade over GBDT." On tabular features they usually are not. Airbnb's published experience of an initial neural regression is a useful counter-example. They win on raw text.

"A better model fixes bad results." It reorders what retrieval returned. Recall lost at retrieval is unrecoverable.

"Clicks are relevance labels." Clicks are relevance times attractiveness times position bias, and the position term dominates. They need debiasing before they are labels.

"Feature importance explains the model." It is a debugging tool. Its most useful output is finding features that should not be predictive and are.

Interview delivery note

Give the three formulations and land on one with a reason: "Pointwise predicts an absolute score, which optimises the wrong thing, because getting every score right is neither necessary nor sufficient for a good ordering. Pairwise optimises which of two documents is better, which is what ranking actually is. In practice LambdaMART, which people call listwise and is mechanically pairwise with each pair's gradient scaled by the NDCG change from swapping it."

Explain LambdaRank's idea, because it is genuinely elegant and few candidates can: "The insight is that you can't differentiate NDCG, so instead of defining a loss and deriving a gradient you define the gradient directly and weight it by the metric change. Swapping positions one and two moves NDCG far more than swapping forty-nine and fifty, and a plain pairwise loss treats them the same."

Spend most of the answer on features, because that is where the work is: "The model is the easy part. Five feature families, and the query-document ones carry most of the signal and are the only ones you can't precompute. Two rules I'd hold: log-scale anything heavy-tailed, or the model spends its capacity on the top tenth of a percent of items; and never feed a raw identifier as a numeric feature, because it implies an ordering that doesn't exist and the model just memorises sellers."

Name the implementation trap: "The most common error is omitting the group parameter, which tells the ranker which rows are the same query. Without it the model compares documents across queries, which is meaningless, and it trains without any error and silently produces a much worse model."

Close on the sequencing, because it is the judgement: "and I wouldn't train anything until there's serving-time feature logging and a judged evaluation set. A team that trains a ranker before it can measure whether the ranker helped has built something it can't improve, and teams reliably do those in the opposite order."

Further reading

  • Burges, "From RankNet to LambdaRank to LambdaMART: An Overview" (2010).
  • Chapelle and Chang, "Yahoo! Learning to Rank Challenge Overview" (2011).
  • Joachims, Swaminathan and Schnabel, "Unbiased Learning-to-Rank with Biased Feedback" (WSDM 2017).
  • The LightGBM lambdarank documentation, particularly lambdarank_truncation_level and the group parameter.
  • Airbnb's "Applying Deep Learning to Airbnb Search" (KDD 2018), for an honest account of a GBDT-to-neural transition.