Home › Sorting, searching & vector search › Vector search
algorithm medium

Cross-encoder reranking

Problem A search or question-answering system has a cheap candidate list that is roughly right but poorly ordered. It needs a more precise ordering of the best few dozen results, within a latency budget, before showing them or feeding a model.

Re-score the top-k candidates of a cheap first stage with a model that reads query and candidate together; more accurate than separate embeddings, but one model call per pair.

O(k) model calls per query, each O(L^2) in the joint length L for a transformer; first stage unchanged
rerankingretrievalcross-encodercascadelate-interaction
Overview

A first stage (keywords, vectors, or both fused) compares a query with documents encoded once, offline, so it cannot see how this query's words relate to that document's words. A cross-encoder reads query and document through one model together, every query token attending to every document token, and outputs one relevance score. Nothing can be precomputed for a pair, so it only re-orders the top k of the cheap stage.

Key facts

Use when

  • Web and enterprise search, RAG context selection for assistants, e-commerce product search, legal and patent prior-art search, support-ticket deduplication, code search, biomedical literature search: recall-oriented first stage, then precision on the top.

Avoid when

  • The first stage misses relevant items; reranking cannot recover them.
  • The latency budget allows only a handful of model calls: use a fused first stage (hybrid search) or a distilled bi-encoder.

Deep dive

How it works

Parts:

  • first stage — cheap retriever over the corpus (BM25, ANN such as HNSW, or their fusion); returns k candidate ids
  • pair builder — makes (query, passage) inputs; splits long documents into overlapping passages (chunking)
  • cross scorer — model taking both texts jointly (BERT-style encoder, T5, or LLM), one score per pair
  • aggregator — document score = max (or top-2 mean) over its passages
  • sorter and cache — keeps the best n (top-k); cache keyed on (query, doc id, model version)
  1. Run the first stage for the top k.
  2. Split each candidate into windows that fit the model's maximum length.
  3. Batch the (query, window) pairs, sorted by length to limit padding; score them.
  4. Take each document's max over its windows; sort; return the top n.
  5. If another component needs a probability, calibrate on judged data; raw scores rank only within one query.
 corpus (N docs, encoded once)
   |  BM25 / ANN / fusion              ~ms, indexed
   v
 top-k ids --text--> [query ; passage] --> cross scorer --> score
   (top k)              one call per pair                     |
                                                              v
                                           max over passages, sort -> top-n

Why joint wins: a bi-encoder squeezes each text into one vector before seeing the other, so word order, negation and "from X to Y" versus "from Y to X" are largely lost. Why it cannot be indexed: the score depends on the pair, so there is nothing to precompute per document.

Model families: an encoder classifier (BERT reranking); a sequence-to-sequence model whose "true/false" token logits are the score (monoT5); an instructed LLM scoring one passage (pointwise) or ordering a window of passages (listwise, RankGPT). Distillation: train a bi-encoder (two-tower) on a cross-encoder's scores (knowledge distillation); RankGPT distilled an LLM's rankings into a small model that, per its abstract, beat a larger supervised one on BEIR. Late interaction (ColBERT) encodes separately but compares token vectors, so documents can be precomputed.

Code

The scorer is a toy standing in for a neural cross-encoder: bigram and proximity features exist only when query and text are read together, which a bag-of-words first stage cannot see. Cascade, windows, max-aggregation and metrics are real.

use std::time::Instant;

fn toks(s: &str) -> Vec<String> {
    s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(|w| w.to_lowercase()).collect()
}

/// Stage 1: BM25-lite on bags of words (k1 = 1.2, b = 0.75); a real system uses an inverted index.
fn bm25(q: &[String], docs: &[Vec<String>], i: usize) -> f64 {
    let n = docs.len() as f64;
    let avg = docs.iter().map(|d| d.len()).sum::<usize>() as f64 / n;
    q.iter().map(|t| {
        let tf = docs[i].iter().filter(|w| *w == t).count() as f64;
        let df = docs.iter().filter(|d| d.contains(t)).count() as f64;
        let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
        idf * tf * 2.2 / (tf + 1.2 * (0.25 + 0.75 * docs[i].len() as f64 / avg))
    }).sum()
}

/// Toy "cross" scorer: sees query AND passage together, so it can use word order and distance.
/// Stand-in for a neural forward pass; same interface: (query, passage) -> score.
fn cross(q: &[String], p: &[String]) -> f64 {
    let bigrams = q.windows(2).filter(|b| p.windows(2).any(|c| c == *b)).count() as f64;
    let pos: Vec<usize> = q.iter().filter_map(|t| p.iter().position(|w| w == t)).collect();
    let span = if pos.len() > 1 { (pos.iter().max().unwrap() - pos.iter().min().unwrap() + 1) as f64 } else { 1.0 };
    2.0 * bigrams / (q.len().max(2) - 1) as f64 + pos.len() as f64 / q.len() as f64 / span.sqrt()
}

/// Windows of `w` tokens (stride w/2), one score per pair; document score = max over windows.
fn rerank(q: &[String], docs: &[Vec<String>], cand: &[usize], w: usize, pairs: &mut usize) -> Vec<usize> {
    let mut s: Vec<(usize, f64)> = cand.iter().map(|&i| {
        let d = &docs[i];
        let starts = (0..d.len().max(1)).step_by(w / 2).take_while(|&a| a == 0 || a + w / 2 < d.len());
        let best = starts.map(|a| { *pairs += 1; cross(q, &d[a..(a + w).min(d.len())]) }).fold(f64::MIN, f64::max);
        (i, best)
    }).collect();
    s.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
    s.into_iter().map(|x| x.0).collect()
}

/// nDCG@k with gain 2^g - 1; `rel[i]` is the grade of doc i.
fn ndcg(rank: &[usize], rel: &[u32], k: usize) -> f64 {
    let dcg = |g: Vec<u32>| g.iter().take(k).enumerate().map(|(i, &g)| (2f64.powi(g as i32) - 1.0) / (i as f64 + 2.0).log2()).sum::<f64>();
    let mut ideal = rel.to_vec();
    ideal.sort_by(|a, b| b.cmp(a));
    dcg(rank.iter().map(|&i| rel[i]).collect()) / dcg(ideal)
}

fn main() {
    let corpus = [
        "flights from paris to boston: fares and schedules",           // 0 wrong direction
        "cheap flights from boston to paris, daily nonstop service",   // 1 relevant
        "boston paris flights",                                        // 2 keyword soup
        "paris travel guide with museum tickets and flights to rome",  // 3
        "apple stock price today and analyst targets",                 // 4 relevant
        "apple pie: the price of stock for broth, and pie crust tips", // 5
        "stock market news: apple, price moves on the nasdaq",         // 6 partial
        "how to prune an apple tree; fruit stock price at the market", // 7
    ];
    // (query, graded relevance per doc)
    let qrels = [("flights from boston to paris", [0, 3, 1, 0, 0, 0, 0, 0]), ("apple stock price", [0, 0, 0, 0, 3, 0, 1, 0])];
    let docs: Vec<Vec<String>> = corpus.iter().map(|d| toks(d)).collect();
    let k = 4;
    let (mut n, mut m, mut pairs) = ([0.0; 2], [0.0; 2], 0);
    for (q, rel) in &qrels {
        let qt = toks(q);
        let mut first: Vec<usize> = (0..docs.len()).collect();
        first.sort_by(|&a, &b| bm25(&qt, &docs, b).total_cmp(&bm25(&qt, &docs, a)).then(a.cmp(&b)));
        let mut second = rerank(&qt, &docs, &first[..k], 8, &mut pairs); // only the top k are re-scored
        second.extend_from_slice(&first[k..]);
        for (j, r) in [&first, &second].iter().enumerate() {
            n[j] += ndcg(r, rel, k);
            m[j] += 1.0 / (1 + r.iter().position(|&i| rel[i] > 0).unwrap()) as f64;
        }
        println!("{q:30} bm25 {:?} rerank {:?}", &first[..k], &second[..k]);
        if rel[1] == 3 { assert!(first[0] == 0 && second[0] == 1); } // known case: reversed route fixed
    }
    let nq = qrels.len() as f64;
    println!("nDCG@{k} {:.3} -> {:.3}   MRR {:.3} -> {:.3}", n[0] / nq, n[1] / nq, m[0] / nq, m[1] / nq);
    assert!(n[1] > n[0] && m[1] > m[0]);

    let qt = toks("flights from boston to paris");
    let (mut p, t) = (0usize, Instant::now());
    for _ in 0..200_000 { std::hint::black_box(rerank(&qt, &docs, &[0, 1, 2, 3], 8, &mut p)); }
    println!("toy cross scorer: {:.2e} pairs/s", p as f64 / t.elapsed().as_secs_f64());
}

Compiled and run (release): BM25 ranks the reversed route "paris to boston" first (same words, shorter text); the reranker puts the right document first. Over the two toy queries nDCG@4 went 0.822 → 0.983, MRR 0.750 → 1.000; on query 2 it still lifts an irrelevant document over a partial match (toy features).

Complexity and constants

  • Per query: first stage plus k model calls; each is O(L^2) attention in the joint length L (query + passage) plus O(L) feed-forward.
  • Measured here (toy scorer, one core of an Apple M4 Pro, includes allocation and sorting): 7.6-11.5 x 10^6 pairs/s over three runs, order of 100 ns per pair. A neural model is orders of magnitude slower (order of ms per pair on CPU); never extrapolate this figure.

Efficiency

  • Cost: k forward passes over L joint tokens per query. Small encoder: order of ms per pair on CPU, order of 0.1-1 ms batched on GPU (not measured here). Bound: compute for long inputs, memory bandwidth for small batches. Toy scorer: about 0.1 µs per pair (measured).
  • Levers:
    • smaller k — latency falls linearly in k (cost model) — when judged recall@k is flat beyond it
    • shorter inputs — attention work falls roughly with L^2 — truncate, or score titles and best passages
    • length-sorted batches — fewer padded tokens, better GPU use (practice) — always when k pairs are scored together
    • caching — skips repeated pairs — repeated queries and stable documents, key includes model version
    • distillation or late interaction — moves cost to an index lookup (ColBERT precomputes document representations) — when k calls do not fit the budget
  • Naive → standard → state of the art:
    • cross-encoder over the whole corpus · O(N) model calls, infeasible
    • BM25 or ANN top-k, cross-encoder on k · O(k) calls
    • fused first stage, small k, timeout fallback, distilled or late-interaction first cut · fewest calls at equal quality

Implementation notes

  • Choosing k: on 50+ judged queries plot first-stage recall@k and post-rerank nDCG@10; take the knee. Vespa bounds its second ranking phase by a rerank count (phased ranking).
  • Truncation: query and passage share the model's maximum length (512 tokens for BERT-style encoders); reserve room for the query, cut at sentence boundaries, or window and take the max.
  • Calibration: logits shift with query length and model; for "show nothing if low" fit Platt or isotonic scaling per model on judged pairs.
  • Latency budget: first stage + k · t_pair / parallelism; one batch for all pairs; on timeout fall back to first-stage order.
  • Format: match training exactly (separators, prompt template); a wrong template silently loses quality.

Pitfalls

  • Reranking a list that lacks the answer: nDCG cannot exceed first-stage recall@k. Measure it first.
  • Comparing scores across queries or models, then thresholding.
  • Dropping the first-stage signal on exact identifiers; fuse by rank (fusion) or learn weights (learning to rank).
  • Domain shift: BEIR found BM25 a robust baseline, re-rankers best on average but costly (BEIR); evaluate on your own queries (metrics).
  • Listwise LLM rerankers: order sensitivity, missing or invented ids in the output, cost growing with window size.
  • Near-duplicates all score high; diversify afterwards (MMR).

Variants and alternatives

Bi-encoder onlywhen no per-pair model fits the latency or the candidate set is the whole corpus.
Late interaction (ColBERT)larger index, near cross-encoder quality, documents precomputed.
Pointwise / pairwise / listwise LLMstrong zero-shot, no labels, highest cost; listwise sees all candidates.
Feature-based learning to rankmicrosecond scoring from click or feature data; a cross score can be one feature.
LLM as judge (judge)offline labels for tuning a reranker.

In production

  • Search engines run phased ranking (Vespa); RAG pipelines rerank retrieved chunks before filling the prompt.
  • Open models: MS MARCO-trained cross-encoders (sentence-transformers) and BGE rerankers; MS MARCO and BEIR are the usual benchmarks.
  • Same cascade in e-commerce, patent and legal prior-art, code and biomedical search, and ticket deduplication.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
sentence-transformersPythonUKPLab/sentence-transformerspip install sentence-transformers————CrossEncoder class, pretrained MS MARCO rerankers, retrieve-and-rerank example
FlagEmbeddingPythonFlagOpen/FlagEmbeddingpip install FlagEmbedding————BGE reranker models, inference and fine-tuning
rerankersPythonAnswerDotAI/rerankerspip install rerankers————One API over cross-encoders, T5, ColBERT, RankGPT, API rerankers
rank_llmPythoncastorini/rank_llmpip install rank-llm————Pointwise, pairwise, listwise LLM rerankers; BM25 plus RankZephyr demo
fastembed-rsRustAnush008/fastembed-rscargo add fastembed————Local embeddings and reranking (BGE, Jina rerank models)
VespaJava/C++vespa-engine/vespadocker pull vespaengine/vespa————Search engine with first, second and global ranking phases

Sources

  • Nogueira, Cho, Passage Re-ranking with BERT, 2019. paper
  • Khattab, Zaharia, ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT, 2020. paper · code
  • Nogueira, Jiang, Lin, Document Ranking with a Pretrained Sequence-to-Sequence Model, 2020. paper
  • Sun et al., Is ChatGPT Good at Search? Investigating Large Language Models as Re-Ranking Agents, 2023. paper · code
  • Bajaj et al., MS MARCO: A Human Generated MAchine Reading COmprehension Dataset, 2016. paper
  • Thakur et al., BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models, 2021. paper · code

Builds on

Used by

Systems (8):

Alternatives and related

Hybrid search and rank fusionA search system that runs both keyword and meaning-based (vector) retrieval gets two result lists with incomparable scores. This decides how to merge them into one ranking, e.g. as the first stage of RAG.BM25 rankingA search box over documents must order results by relevance to the typed keywords, favouring rare terms without letting repeated words or long documents dominate. Also the first-stage retriever for RAG.HNSWApplications that must find the vectors most similar to a query (embeddings search, RAG, recommendations) among millions of in-memory vectors, while still accepting new inserts, need fast approximate nearest-neighbour lookup.Two-tower retrievalA service with millions of items (products, videos, documents, ads) must pick a few hundred plausible ones for each user or query within milliseconds, so a costlier model can rank them.Learning to rankSearch, feeds, ads and product listings show a short ordered list per request, chosen from hundreds of candidates. The best items must sit at the top, but the only labels are graded judgements or position-biased clicks.
maximal marginal relevance · plannedMMR and other diversification re-ranking of result lists
Text chunkingSystems that search or answer questions over long documents (contracts, manuals, support tickets, source repositories) can only index or read short passages, but a bad cut separates a fact from the context that gives it meaning.LLM-as-judgeTeams shipping generated answers, summaries, code reviews, support replies or agent runs have no exact reference to score them against, and humans are too slow and costly to grade every change before release.Evaluation metricsAfter training a model, how do you measure whether it is actually good, and whether one model is really better than another? This helps pick and compute metrics that don't mislead on imbalanced data or rankings.Knowledge distillationA product needs a model that is cheap to run (phone, low-latency service, huge traffic), but the accurate model is too big or slow. The small model must inherit what the big one knows.EmbeddingsA neural network has to take categorical inputs, such as words, users, items or board squares, and represent each as a useful numeric vector, without paying for a huge one-hot input.