Lexical search, hybrid search, and reranking
A support engineer types "E1043 X200 paper jam" into the company's new semantic search. The top results are thoughtful articles about printer jams in general. The one page that mentions error E1043 on the X200, the page that actually fixes the problem, is ranked fifteenth. An embedding model has squeezed "E1043" into the same neighborhood as every other error code, because for most of its training data, one error code is much like another.
That's the gap this section fills. Exact word matching, an idea from the 1970s refined into BM25 by the 1990s, is still one of the strongest retrieval methods there is. On BEIR's zero-shot benchmark, BM25 beat many neural retrievers of its day outside their training domain (Thakur et al., 2021). The best systems don't choose. They combine lexical and dense retrieval, then rerank the survivors with a model that reads the query and each candidate together. And to know whether any of it works, you need metrics you can compute correctly in your sleep. We'll build all of it.
The inverted index
Lexical search represents a document as a sparse vector over the vocabulary , with one coordinate per word that appears in it. Scoring a query is a dot product between two sparse vectors, which only involves the words they share. So instead of storing documents, a search engine stores, for each term , a posting list of the documents containing it and how often. That's the inverted index, and it's why Google could search the web in 1998. A query touches only the posting lists of its own terms, so its cost is the total length of those lists, the number of documents that contain each query term, summed over the terms. For the rare, specific terms that carry most queries, that is a tiny fraction of the collection, and engines skip most of even those lists with dynamic pruning algorithms such as WAND, which stop scoring documents that can no longer reach the top .
Throughout this section, is a document, is a query (a set of terms), is the number of documents in the collection, is how many times term occurs in , is the number of documents containing , is the length of in tokens, and is the average length over the collection.
TF-IDF, and what it gets wrong
You met TF-IDF in §1.4 as a weighting for co-occurrence matrices. As a ranking function it scores
In plain words, a document scores well when it uses the query's words often, and rare words count for more than common ones. The idea that rarity signals importance is Karen Spärck Jones's (1972). A query word that appears in every document can't help you choose among them.
Two flaws show up the moment you try it.
- Term frequency grows without bound. A page that says "jam" 40 times gets 40 times the credit of a page that says it once. But the second mention of a word is strong evidence of aboutness, and the fortieth adds almost nothing. We want a score that saturates.
- Long documents win for being long. A 5,000-word manual mentions everything more often than a 50-word answer. We need to normalize by length, but not completely, because some long documents really do cover more.
BM25 fixes both, and the way it fixes them is worth understanding, because interviewers ask you to derive it.
BM25 derived from the probabilistic relevance framework
BM25 comes from Stephen Robertson and colleagues' probabilistic model of retrieval, summarized in Robertson and Zaragoza (2009). It's built in three steps.
Step 1, rank by probability of relevance. The probability ranking principle (Robertson, 1977) says that if you rank documents by , you get the best expected precision at every cutoff. Ranking by that probability is the same as ranking by its log odds.
Step 2, the binary independence model gives IDF. Pretend a document is just the set of terms it contains, and that terms occur independently given relevance. Let be the probability that term appears in a relevant document and the probability it appears in a non-relevant one. Then the log odds of relevance, up to a constant that doesn't depend on , is a sum over the query terms present in of the Robertson and Spärck Jones (RSJ) weight
∂ From log odds to the RSJ weight
Let indicate whether term is in . By Bayes' rule, the odds of relevance are
The prior ratio is the same for every document, so drop it. With independent terms, , and similarly for with . Taking logs,
The last sum doesn't depend on . Assume terms not in the query are equally likely in relevant and non-relevant documents (), so their weight is zero. What remains is .
Without any relevance judgments we have to guess and . Almost every document is non-relevant, so . For , with no information, take . Then and
where the 0.5s are the smoothing from the RSJ formula with zero relevance information. In plain words, IDF falls out of a probabilistic model as the log odds that a term marks a document as unusual. One practical wrinkle is that this IDF goes negative for terms in more than half the documents, so Lucene and Elasticsearch use , which is always positive. We'll use that form.
Step 3, term frequency with saturation and length normalization. The binary model ignores how often a term appears. Robertson's group modeled term counts with a mixture of two Poisson distributions, one for documents that are really about the term ("elite" documents) and one for documents that mention it in passing. The exact formula is unwieldy, but its shape is simple. It's zero at , increases, and levels off. BM25 replaces it with the simplest function of that shape,
where sets how quickly it saturates. For length, the "verbosity" view says that a document twice as long says the same things with twice as many words, so its counts should be divided by its relative length. The "scope" view says it covers more topics and its counts are honest. BM25 interpolates between the two with , dividing the count by
Substituting into the saturation function and multiplying by , which makes a single occurrence in an average-length document worth exactly 1, gives the full formula,
In plain words, each shared term contributes its rarity times a saturating count, where the count is discounted for documents longer than average.
The two knobs have clean extremes. With the count term is 1 whenever the term is present, so BM25 becomes a binary model that ignores repetition. As it approaches raw tf with length normalization. With there's no length normalization, and with there's full normalization. The model itself says nothing about how to set them. Robertson and Zaragoza (2009) report that many retrieval experiments found values around and to work reasonably well, while noting that the best values depend on the documents and queries. Lucene and Elasticsearch default to and . Treat these as starting points and retune them on your data.
Let's score a tiny collection by hand. Four documents, the query "cat mat", , .
Check the first document. "cat" appears in 3 of the 4 documents, so . The document has 6 tokens against an average of 5.75, so , and the count part is . That's for "cat", and "mat" adds the same way, for a total of . Now look at the fourth document. It says "cat" four times and is short, yet it scores only 1.8 times what a single mention earns. That's saturation doing its job, and the document that matches both terms wins.
BM25 from scratch
Implement bm25(query, docs, k1=1.2, b=0.75), where query is a list of tokens and docs is a list of token lists. Return a NumPy array with one score per document, using the boxed formula with the always-positive IDF, . Sum over distinct query terms. Terms that appear in no document contribute nothing. Build document frequencies once (an inverted index of counts), not once per query term per document.
SPLADE, sparse vectors learned by a Transformer
BM25's weakness is the flip side of its strength. It matches only the words you typed. A query for "car" misses a document that says "automobile", the vocabulary mismatch problem. Could a model learn sparse vectors, with the efficiency and exact-match behavior of an inverted index, but with term weights and expansions that understand language?
SPLADE (Formal et al., 2021) does exactly that with BERT's MLM head from §7.1. Run the text through BERT, and at every position the MLM head produces logits over the whole vocabulary, scores for "which word could be here". SPLADE turns them into one non-negative weight per vocabulary word,
using max pooling over positions as in SPLADE v2 (the first version summed). In plain words, each word in the vocabulary gets the strongest evidence any position in the text provides that it belongs, and the log damps large values the way BM25's saturation does.
The score of a query and a document is the dot product of their vectors, , which an inverted index computes efficiently as long as the vectors are sparse. A document about cars now has weight on "vehicle" and "automobile" even if it never uses those words. That's document expansion, learned end to end with the InfoNCE loss of §7.2.
What keeps the vectors sparse? A regularizer. The obvious choice, the norm of each vector, makes every vector sparse but ignores which terms are active. An inverted index is slow when a few terms are active in a huge share of documents, because their posting lists are long. The cost of a sparse dot product is proportional to , so SPLADE uses the FLOPS regularizer of Paria et al. (2020), a smooth version of that cost estimated on a batch of documents,
In plain words, square each term's average weight over the batch, so a term active everywhere is punished much more than one active in a single document. SPLADE v2 reported more than 9 percent gains in nDCG@10 on TREC DL 2019 over the first version and state-of-the-art BEIR results at the time (Formal et al., 2021).
SPLADE pooling and the FLOPS regularizer
Implement splade_vectors(logits, mask), where logits is (B, T, V) MLM logits and mask is (B, T) with 1 for real tokens. Return (B, V) vectors over real positions only (padding must not contribute). Then implement flops_reg(S) for a (B, V) batch of vectors, returning .
| method | representation | handles synonyms | exact identifiers | index | needs training |
|---|---|---|---|---|---|
| BM25 | sparse, term counts | no | yes | inverted index | no |
| SPLADE | sparse, learned weights with expansion | partly | mostly | inverted index | yes |
| dense bi-encoder | dense vector | yes | weak | ANN index (§7.4) | yes |
| ColBERT | dense vector per token | yes | better than single-vector | multi-vector index | yes |
Hybrid search with reciprocal rank fusion
Lexical and dense retrievers fail on different queries. BM25 misses paraphrases, dense models miss rare identifiers and exact phrases. So run both and combine their rankings. The question is how.
Adding raw scores doesn't work. BM25 scores are unbounded and depend on query length and the collection, while cosines live in and cluster in a narrow band. You can normalize each list (for example min-max to ) and take a weighted sum, but then the weight needs tuning per collection, and normalization is sensitive to outliers at the top of a list.
Reciprocal rank fusion (RRF, Cormack, Clarke & Büttcher, 2009) ignores scores and uses only ranks. For a set of ranked lists to fuse, where is the rank of document in list (starting at 1),
and a document missing from a list contributes nothing from it. In plain words, each list votes for each document with a weight that falls off with rank, and the constant keeps the top few positions from dominating. With , rank 1 is worth and rank 10 is worth , so a document that's fifth in both lists () beats one that's first in one list and absent from the other (). That's the behavior you want from a fusion method, rewarding agreement. The constant 60 came from the original paper's experiments, where RRF beat the individual systems it combined and the standard fusion methods of the time. It's robust enough that many search engines and vector databases ship it as their standard way to fuse hybrid results.
Reciprocal rank fusion
Implement rrf(rankings, k=60, top=None). rankings is a list of ranked lists of document ids (best first, ranks start at 1). Return a list of (doc_id, score) pairs sorted by decreasing score, breaking ties by the smaller doc id. If top is given, return only the first top pairs. A document absent from a list gets nothing from that list.
Reranking with cross-encoders
A bi-encoder, dense or sparse, never lets the query and the document look at each other. A cross-encoder does. Feed [CLS] query [SEP] document [SEP] into an encoder, exactly the pair format that outlived NSP in §7.1, and put a linear layer on the [CLS] state to output a relevance score. Every query token can attend to every document token in every layer, so the model can check negation, word order, and which entity does what.
monoBERT (Nogueira & Cho, 2019) did this with BERT on MS MARCO and beat the previous state of the art by 27 percent relative in MRR@10. Cross-encoders are trained either pointwise, with binary cross-entropy on relevant and non-relevant pairs, or listwise, with a softmax over one positive and several hard negatives per query, which is InfoNCE again with a cross-encoder as the critic. Modern rerankers are often distilled from larger ones or from LLM judgments, and instruction-tuned LLMs (the kind Chapter 14 builds) can rerank listwise, given a numbered list of passages and asked to output an ordering (RankGPT, Sun et al., 2023).
The catch is cost. A cross-encoder needs one forward pass per query and document pair, so it can never score a whole collection. It reranks a shortlist.
Reranking 100 candidates costs about 11 TFLOP, tens of milliseconds on a modern GPU, which is affordable. Scoring the whole collection is not. That gives the standard retrieval stack, often called retrieve and rerank.
- Candidate generation. BM25 and a dense retriever each fetch a few hundred candidates, cheaply, with high recall.
- Fusion. RRF merges the lists.
- Reranking. A cross-encoder scores the top 50 to 200 fused candidates and the top 5 to 20 go to the user, or to an LLM in §7.6.
The reranker's knowledge can also flow backward, into the cheap first stage. Score each query's candidates with the cross-encoder, turn the scores into a teacher distribution over the candidates, and train the bi-encoder to match it, minimizing instead of, or alongside, InfoNCE's one-hot target. The teacher's soft labels say how relevant each hard negative is, which a binary label cannot, and they quietly fix false negatives, since an unlabeled relevant passage gets a high teacher score. RocketQA, E5's fine-tuning stage, and ColBERTv2 all distill this way, and Margin-MSE (Hofstätter et al., 2020) is a popular variant that matches the teacher's score gap between a positive and a negative.
Each stage is tuned for what it's good at. First stages for recall at large , the reranker for precision at small . That means you must measure them with different metrics, which is the last topic of this section.
Retrieval metrics, done right
Here's a question that sounds easy. Two retrievers answer the same 200 queries. System A puts a relevant document first more often. System B finds more of the relevant documents somewhere in its top 20. Which is better? It depends on who consumes the ranking. A person who reads the first result, a reranker that rescores the top 100, and a language model that reads the top 5 want different things, and each metric below encodes one of those wants. Getting them right, and computing them correctly, is a standard interview topic and a common source of silent bugs.
Fix one query with a set of relevant documents (possibly with graded relevance labels), and a ranked list of retrieved documents . Precision and recall for a classifier, with their trade-off and their confidence intervals, are the subject of §0.11. Ranking adds one ingredient, the cutoff , since the system returns a list and the reader stops somewhere.
Precision@k and recall@k. Precision@k is the fraction of the top that are relevant, . Recall@k is the fraction of the relevant documents that made the top , . Recall answers "did we find them?", which is what a first stage feeding a reranker should maximize, at a large such as 100 or 1,000. Precision answers "how much of what we show is useful?", which matters when every slot costs something, such as prompt tokens. Don't confuse this recall with the recall of §7.4, which compares an approximate index with exact search and needs no relevance labels at all. A perfect index can have poor retrieval recall, because the embedding model ranked the wrong documents first. RAG pipelines often report a third variant, hit rate@k (also called success@k), which is 1 if any relevant document is in the top and 0 otherwise.
Reciprocal rank is (0 if none is retrieved). Averaged over queries, it's the mean reciprocal rank (MRR). It fits tasks with one right answer, such as known-item search or question answering, and it is harsh, since the first result is worth 1 and the second only 0.5. MS MARCO reports MRR@10, which counts only the top 10.
Average precision rewards every relevant document, not just the first. Take the precision at each rank where a relevant document appears, add them up, and divide by the number of relevant documents,
so relevant documents that were never retrieved contribute zero. In plain words, AP is the average, over the relevant documents, of how clean the list was down to each of them. Averaged over queries it is the mean average precision (MAP), the metric MTEB uses for reranking (§7.3). For a ranking with relevant documents at ranks 1 and 3 out of two relevant in total, AP is . If a third relevant document existed and was never retrieved, AP would drop to . That denominator is the most common bug, dividing by the number of relevant documents retrieved instead of all of them, which rewards a system for retrieving less.
nDCG@k handles graded relevance (say 0 to 3) and rewards putting the best documents first. The discounted cumulative gain adds each result's gain, discounted by the log of its position,
where is the relevance label of the document at rank , and the ideal DCG, , is the DCG of the best possible ranking, all judged documents sorted by label (Järvelin & Kekäläinen, 2002). In plain words, nDCG asks how much of the best achievable gain your top captured, with each position counting a bit less than the one above it. The position-1 document gets full weight, position 3 half (), and position 7 a third ().
Two conventions for the gain are in use. TREC's tools and BEIR (and so MTEB) use the label itself, as above. Learning-to-rank papers often use (Burges et al., 2005), which rewards highly relevant documents more strongly. Results computed with different conventions aren't comparable, so say which one you use.
Let's compute all of them for one ranking by hand. The query has four judged relevant documents, with labels 3, 2, 1, and 1, and the system returns five documents with labels . The fourth relevant document wasn't retrieved.
By hand, . The ideal ranking is , so and . Recall@5 is 3 of 4, and the reciprocal rank is 1.
Recall@k, MRR, and nDCG@k
Implement retrieval_metrics(ranked, qrels, k). ranked maps each query id to its ranked list of doc ids (best first). qrels maps each query id to a dict {doc_id: relevance}, where relevance is a positive integer for judged relevant documents (unlisted documents have relevance 0). Return a dict with keys "recall", "mrr", and "ndcg", each averaged over the queries in qrels that have at least one relevant document. Use only the top k of each ranking for all three (so MRR is MRR@k). Use linear gains and the discount, with the ideal DCG computed from all judged relevant documents. A query missing from ranked scores 0 on everything.
Average precision and MAP
Implement average_precision(ranked, relevant) for one query, where ranked is a list of doc ids (best first) and relevant is a set of relevant ids, using the formula above with no cutoff. Return 0.0 when relevant is empty. Then implement mean_ap(ranked_by_q, qrels), the mean of AP over the queries in qrels that have at least one relevant document, where a query missing from ranked_by_q scores 0.
Where the labels come from
Every metric above needs relevance labels, and labels are the expensive part. Nobody judges every document in a collection of millions for every query. TREC-style evaluations use pooling. Each participating system submits its top results, the union of everyone's top 100 or so is judged by assessors, and every unjudged document is treated as non-relevant (Manning, Raghavan & Schütze, 2008, chapter 8). That works well for the systems in the pool. A new system that finds relevant documents nobody else found is penalized for them, because they were never judged. So when you evaluate a genuinely new retriever on an old pooled benchmark, report how many of its top 10 were judged at all. A low number means the score is a lower bound.
The two big passage benchmarks show why the metric follows the labels. MS MARCO has about one judged relevant passage per query, labeled sparsely from clicks and annotators, so recall is noisy and MRR@10 is the natural measure. The TREC Deep Learning tracks judged a few dozen queries deeply, with graded labels from 0 to 3, which is what nDCG@10 needs. BEIR mixes both kinds and reports nDCG@10 everywhere (§7.3).
Is the difference real?
Suppose the new model gains two points of nDCG@10. Retrieval metrics are averages over queries, and query difficulty varies far more than systems do. Some queries are easy for everything, some are hopeless for everything. That's why you compare systems per query, with a paired test on the differences, the paired bootstrap or randomization test of §0.11. Here are two simulated systems where B is truly 2 points better, compared on query sets of three sizes.
With 50 queries the true gain is invisible. The measured difference even has the wrong sign, and the interval covers zero comfortably. With 200 the paired test finds it, while an unpaired interval, which ignores that both systems answered the same queries, still straddles zero. The pairing removes the variation in query difficulty, which is most of the variance. So report per-query paired comparisons, and be wary of leaderboard gaps of a point or two on small test sets.
In PyTorch
A cross-encoder is an encoder with a scalar head, trained listwise here with one positive and several negatives per query. With random weights the numbers are meaningless, but the shapes and the loss are exactly what you'd use.
A real pipeline, with BM25 from rank_bm25, a dense retriever, RRF, and a pretrained reranker from sentence-transformers, looks like this. It needs downloads, so it's shown and not run.
Read more
- Robertson & Zaragoza (2009). The Probabilistic Relevance Framework: BM25 and Beyond. The derivation of BM25 from the people who invented it.
- Manning, Raghavan & Schütze (2008). Introduction to Information Retrieval, chapters 6, 8, and 11. Inverted indexes, TF-IDF, evaluation, and the probabilistic model, free online.
- Formal et al. (2021). SPLADE v2: Sparse Lexical and Expansion Model for Information Retrieval. Max pooling, distillation, and the FLOPS regularizer in practice.
- Cormack, Clarke & Büttcher (2009). Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. Two pages, one formula, still the default.
- Nogueira & Cho (2019). Passage Re-ranking with BERT. The cross-encoder reranker that changed MS MARCO.
- Järvelin & Kekäläinen (2002). Cumulated Gain-based Evaluation of IR Techniques. Where DCG and nDCG come from.
- Thakur et al. (2021). BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models. Zero-shot retrieval across 18 datasets, and the case for BM25 as a baseline.
- Sun et al. (2023). Is ChatGPT Good at Search? Investigating Large Language Models as Re-Ranking Agents (RankGPT). Listwise reranking with LLMs.