BM25 From Scratch, and Why It Still Beats Embeddings Sometimes
BM25 is a ranking function from the 1990s that still runs behind Lucene, Elasticsearch and OpenSearch, and it is still the baseline that new retrieval models are measured against. It scores documents by the query words they contain, weighted by how rare each word is, with two corrections: repeated words help less and less, and long documents need more matches to score the same.
That simplicity is the reason it still matters in retrieval-augmented generation. An embedding model compares meaning, which handles paraphrases well but can blur near-identical identifiers. BM25 compares exact tokens, which fails on paraphrases but is precise for error codes, product IDs and rare names. This guide builds BM25 in 31 lines of Python and explains each part with computed numbers. It then tests it against a real embedding model (BAAI/bge-small-en-v1.5) on a ten-document corpus and combines the two with reciprocal rank fusion.
The formula and parameter guidance follow Robertson and Zaragoza's survey The Probabilistic Relevance Framework: BM25 and Beyond (2009). The benchmark evidence comes from BEIR (Thakur et al., 2021), and the fusion method from Cormack, Clarke and Büttcher (2009). For where retrieval fits in a RAG system, see Hybrid Search: Vector and Keyword.
What BM25 computes
BM25 treats a query and a document as bags of words: order is ignored, only which terms occur and how often. A document's score is a sum over the query's terms. Each term found in the document contributes three things multiplied together:
- IDF: how rare the term is across the whole collection. A term in every document tells you nothing; a term in one document tells you a lot.
- Term frequency, saturated: how often the term appears in this document, with diminishing returns controlled by
k1. - Length normalization: how long this document is compared with the average, controlled by
b.
Written out, with f the term's count in the document, dl the document length and avgdl the average length:
score = Σ IDF(term) × f × (k1 + 1) / (f + k1 × (1 − b + b × dl / avgdl))
A query term that does not occur in the document contributes zero, so a document with none of the query's terms scores zero and is never returned. That is BM25's precision and its main weakness at once.
Quick reference
- Robertson and Zaragoza derive the term-frequency part from an 'eliteness' model of how authors use words; the formula is an approximation of that model, not an arbitrary heuristic.
- Published BM25 variants differ in small ways, such as a separate saturation for repeated query terms. Robertson and Zaragoza report that experiments found these extras unimportant.
- BM25 needs collection statistics (document frequencies and average length), so adding documents changes every score slightly.
Remember this
BM25 sums, over matching query terms, rarity times a saturating, length-normalized count.
BM25 in 31 lines
This is the implementation used for every number in this guide:
1import math, re2from collections import Counter3 4def tokenize(text):5 return re.findall(r"\w+", text.lower())6 7class BM25:8 def __init__(self, docs, k1=1.2, b=0.75):9 self.k1, self.b = k1, b10 self.docs = [Counter(tokenize(d)) for d in docs]11 self.lengths = [sum(tf.values()) for tf in self.docs]12 self.avgdl = sum(self.lengths) / len(docs)13 self.df = Counter(term for tf in self.docs for term in tf)14 self.n = len(docs)15 16 def idf(self, term):17 df = self.df.get(term, 0)18 return math.log(1 + (self.n - df + 0.5) / (df + 0.5))19 20 def score(self, query, i):21 tf, dl, s = self.docs[i], self.lengths[i], 0.022 for term in tokenize(query):23 f = tf.get(term, 0)24 if f:25 norm = self.k1 * (1 - self.b + self.b * dl / self.avgdl)26 s += self.idf(term) * f * (self.k1 + 1) / (f + norm)27 return s28 29 def search(self, query, k=3):30 scores = [(self.score(query, i), i) for i in range(self.n)]31 return [i for s, i in sorted(scores, reverse=True)[:k] if s > 0]__init__ computes everything that depends on the collection: each document's term counts and length, the average length, and each term's document frequency (how many documents contain it). score applies the formula for one document, and search scores all of them and returns the top k with a positive score.
Use it like this:
1docs = ["Error E1042: the payment gateway timed out ...", "Order SKU-88231 ships in a red box ...", ...]2bm = BM25(docs)3print(bm.search("SKU-88231")) # indexes of the best documentsThe tokenizer is deliberately minimal: lowercase, then split on anything that is not a letter, digit or underscore. SKU-88231 becomes sku and 88231. Real search engines use analyzers that also stem words (refunds to refund), remove stop words and handle accents, and those choices often matter more than the scoring function.
Quick reference
- This version scores every document for every query, which is fine for thousands of documents. Search engines use an inverted index (term to list of documents) so a query only touches documents that contain its terms.
Countermakes term frequency and document frequency one line each;dfcounts each term once per document because it iterates over each document's distinct terms.- The code uses Lucene's IDF variant, explained in the next section.
Remember this
BM25 is collection statistics computed once plus a short scoring loop per query.
IDF: rare terms carry the signal
Robertson and Zaragoza derive BM25's IDF from the Robertson/Spärck Jones relevance weight with no relevance information, which gives:
IDF = log((N − n + 0.5) / (n + 0.5))
where N is the number of documents and n is the number containing the term. In the ten-document test corpus used later, e1042 appears in one document and the appears in seven:
e1042: log(9.5 / 1.5) = 1.846the: log(3.5 / 7.5) = −0.762
The second value is negative. Any term in more than half the documents gets a negative weight, so matching it lowers a document's score. That is defensible in theory, but surprising in practice. Lucene adds 1 inside the logarithm, log(1 + (N − n + 0.5) / (n + 0.5)), which keeps every weight positive. With that version, used in the code above, e1042 scores 1.992 and the scores 0.383. The ratio is what matters: the error code carries about five times the weight of the.
This is why BM25 is good at identifiers. A product code, an error number or a person's surname usually appears in very few documents, so it gets one of the highest IDF values in the collection, and a document containing it rises to the top.
Quick reference
- IDF is computed per collection. The same word can be rare in one index and common in another, so scores are not comparable across indexes.
- With very few documents, IDF is noisy. A term in 2 of 10 documents and a term in 1 of 10 get quite different weights for little reason.
- Stop-word removal matters less with BM25 than with raw term counts, because IDF already gives common words small weights, but it still removes noise and saves space.
Remember this
IDF makes rare terms dominate the score, and Lucene's +1 variant keeps common terms from going negative.
k1: term-frequency saturation
A document that mentions "refund" ten times is probably more about refunds than one that mentions it once, but not ten times more. BM25's term-frequency factor f × (k1 + 1) / (f + k1) (at average length) rises quickly and then levels off towards a ceiling of k1 + 1. With k1 = 1.2:
- 1 occurrence: 1.000
- 2 occurrences: 1.375
- 3 occurrences: 1.571
- 5 occurrences: 1.774
- 10 occurrences: 1.964, against a ceiling of 2.2
A smaller k1 saturates sooner. At k1 = 0.5, ten occurrences score only 1.429, so presence matters and repetition hardly does. A larger k1 keeps rewarding repetition: at k1 = 2.0, ten occurrences score 2.5.
Saturation is the main difference from classic TF-IDF, where the term-frequency part grows without limit (as f or 1 + log f). It also limits keyword stuffing: past a few occurrences, repeating a word barely moves the score. The (k1 + 1) in the numerator does not change the ranking, since it multiplies every term equally. Robertson and Zaragoza note it is there so that a single occurrence gets the same weight as in the plain relevance-weight formula, which is why one occurrence scores exactly 1.0 above.
Quick reference
- Setting
k1 = 0removes term frequency entirely: the score becomes the sum of the IDFs of the matching terms. - Lucene's
BM25Similaritydefaults tok1 = 1.2; Robertson and Zaragoza report that values between 1.2 and 2 work reasonably well in many experiments. - Short fields such as titles rarely repeat terms, so
k1matters most for long body text.
Remember this
k1 sets how quickly repeated occurrences stop adding score; around 1.2 to 2 is the usual range.
b: document length normalization
Long documents contain more words, so they match more query terms by chance. Robertson and Zaragoza describe two reasons a document might be long. Verbosity: the author uses more words for the same content, which suggests dividing counts by length. Scope: the document covers more topics, which suggests not penalizing length at all. Real collections mix both, so BM25 applies a partial normalization controlled by b between 0 and 1.
The factor 1 − b + b × dl / avgdl scales k1. For two occurrences of a term (k1 = 1.2):
- Document twice the average length, b = 0: no length penalty, factor 1.375.
- Same document, b = 0.75: factor 1.073.
- Same document, b = 1: full normalization, factor 1.000.
- Document half the average length, b = 0.75: factor 1.600.
So with the usual default, two hits in a short document are worth about 50% more than two hits in a long one. Robertson and Zaragoza report that values of b between 0.5 and 0.8 work reasonably well in many collections, and Lucene defaults to 0.75. They also note that the best values depend on the type of documents and queries, so the defaults are a starting point, not a result.
Quick reference
- If your documents are chunks of roughly equal size, as in most RAG pipelines, length normalization has little to do;
bmatters more for collections of very uneven length. - Tuning
k1andbneeds a set of queries with known relevant documents; without one, keep the defaults. - Robertson and Zaragoza note that how you measure length (words, characters, unique terms) makes little difference, because only the ratio to the average is used.
Remember this
b sets how much long documents are penalized; 0.75 is the common default and only a starting point.
BM25 against embeddings on a small corpus
To see the difference concretely, the code above and an embedding model were run on ten short support articles. The articles include three error codes that differ by one digit (E1042, E1024, E1402) and two product codes that differ by two swapped digits (SKU-88213, SKU-88231). Five queries were run against them; the results are in the table.
The product-code query shows the failure mode. The embedding model ranked the wrong SKU first, with cosine similarities of 0.700 and 0.695: the two strings are almost identical as text, so their vectors are almost identical too. BM25 sees 88231 as a separate token that occurs in exactly one document, gives it a high IDF, and ranks the correct document first by a factor of more than two. The error-code query went the right way for the embeddings, but by a margin small enough that a slightly different corpus could flip it.
The paraphrase query shows the reverse. "How long until I get my money back" shares no words with "Refunds are sent to the original payment method within five business days", so BM25 returns nothing. The embedding model matches the meaning and ranks the refunds article first. The sign-in query shows a smaller BM25 weakness: the second result, "Invoices can be downloaded...", matched only on the word can.
This is a demonstration of the mechanism, not a benchmark. Ten documents and five queries say nothing about average accuracy, and a larger or newer embedding model may separate the SKUs. The point is which kind of query each method is structurally good or bad at.
| Query | BM25 | Dense | RRF hybrid |
|---|---|---|---|
E1042 | Correct (only match) | Correct, but E1024 close behind (0.645 vs 0.599) | Correct |
SKU-88231 | Correct (3.61 vs 1.49) | Wrong: SKU-88213 first (0.700 vs 0.695) | Correct, but only on a tie-break |
| payment timed out | Correct | Correct | Correct |
| I can't sign in to my account | Correct; 2nd is an unrelated invoices doc | Correct; 2nd is the password reset doc | Correct; password reset 2nd |
| how long until I get my money back | Nothing returned (no shared words) | Correct: the refunds doc | Correct |
Quick reference
- Reproduce it with
pip install fastembed numpy; the model downloads on first use. - The embedding model sees
SKU-88231as a sequence of subword tokens, and changing two digits changes very little of that sequence. - Where IDs matter, many systems also add an exact-match filter or a separate lookup before ranking, so the right record cannot be outranked.
Remember this
BM25 wins on rare exact tokens such as IDs; embeddings win on paraphrase; each fails where the other is strong.
What the benchmarks show
The BEIR benchmark (Thakur et al., 2021) tested ten retrieval systems zero-shot on 18 datasets, from biomedical search to argument retrieval to fact checking. Its main finding was that BM25 held up across domains better than most of the neural systems tested. In nDCG@10 averaged against BM25:
- The dense retrievers did worse on average: TAS-B −2.8%, ANCE −7.4%, DPR −47.7%.
- docT5query, which expands documents with generated queries and then uses BM25, did +1.6%.
- BM25 followed by a cross-encoder reranker did best, at +11%, beating BM25 on 16 of 18 datasets.
The mechanism the authors point to is domain shift. Dense models trained on MS MARCO beat BM25 by 7 to 18 points on MS MARCO itself, then fell behind on datasets far from it, such as BioASQ and Touché-2020. BM25 has no training data to drift away from. It applies the same statistics to any text, so it degrades less when the domain changes.
The boundary is time. BEIR's dense models are from 2020 and 2021, and embedding models trained on far larger and more varied data have since raised scores on BEIR's retrieval tasks, so do not read these figures as a current ranking. What has not changed is the structure of the result: a lexical method has no domain to drift from, and a strong reranker on top of a cheap first stage remains a reliable design. For measuring retrieval on your own data, see RAG Retrieval Metrics Explained.
Quick reference
- nDCG@10 rewards putting relevant documents near the top of the first ten results.
- BEIR also reports that the best systems were the most expensive: rerankers and late-interaction models cost far more compute per query than BM25.
- Test your own embedding model against BM25 on your own queries before assuming either wins.
Remember this
On BEIR, BM25 generalized across domains better than the dense models of its time; a reranker on top did best.
Hybrid retrieval with reciprocal rank fusion
Since BM25 and embeddings fail on different queries, the common fix is to run both and merge the results. Their scores cannot be added directly: BM25 scores are unbounded and depend on the collection, while cosine similarities sit in a narrow band. Reciprocal rank fusion (RRF) avoids the problem by using only ranks. Each document gets 1 / (k + rank) from each list it appears in, and the sums are sorted:
1def rrf(rankings, k=60, top=3):2 s = {}3 for r in rankings:4 for rank, d in enumerate(r, start=1):5 s[d] = s.get(d, 0) + 1 / (k + rank)6 return sorted(s, key=s.get, reverse=True)[:top]Cormack, Clarke and Büttcher fixed k = 60 in a pilot study and found it near-optimal; Elasticsearch's RRF uses 60 as its default rank_constant. In the test above, RRF put the right document first for all five queries, but look at how. The paraphrase query worked because BM25 returned nothing, so the dense list stood alone. For SKU-88231 the two SKUs tied exactly: each was ranked first by one method and second by the other, scoring 1/61 + 1/62. The correct one came first only because the BM25 list was passed in first.
That tie shows RRF's limit. It ignores how confident each retriever was, so BM25's clear 3.61-to-1.49 margin counts the same as dense's 0.700-to-0.695 near-tie. When one signal must win, such as an exact ID match, add a rule or a weight rather than relying on fusion, and put a reranker after fusion for the final order. For the reranking step, see Rerankers vs Embeddings.
Quick reference
- A larger
kflattens the differences between ranks, so lower-ranked documents from each list matter more. - Retrieve more candidates from each method than you need (for example 50 each) before fusing, so a document ranked low by one method can still be found.
- Weighted fusion (multiplying each list's contribution) is an option when you know one retriever is more reliable for your queries.
Remember this
RRF merges BM25 and vector results by rank, fixing each method's blind spots, but it ignores confidence margins.
Using BM25 in production
Do not deploy the 31-line class. Use an engine that keeps an inverted index, updates statistics incrementally and has proper analyzers:
- Lucene-based engines (Elasticsearch, OpenSearch, Solr) use BM25 as their default similarity, with Lucene's
k1 = 1.2andb = 0.75. - Python libraries that implement BM25 work for prototypes and small collections held in memory.
- PostgreSQL's built-in full-text search is not BM25. Its documentation states that
ts_rankandts_rank_cd"do not use any global information", so there is no IDF. Rare and common terms are weighted by frequency within the document only.
Whichever engine you choose, most of the quality comes from the analyzer: tokenization, lowercasing, stemming, synonyms and how identifiers are split. An ID like SKU-88231 should stay searchable both whole and in parts, and an analyzer that strips digits or splits on hyphens differently from your users' typing will lose the main advantage BM25 has.
For a RAG system, a reasonable default is hybrid retrieval (BM25 plus embeddings, fused with RRF) followed by a reranker. Evaluate it against BM25 alone and embeddings alone on real queries from your logs.
Quick reference
- Keep the BM25 index and the vector index fed from the same document IDs so fusion can match results.
- Field-level scoring (a title match counting more than a body match) is handled by BM25F, a multi-field extension that Robertson and Zaragoza describe.
- Re-check analyzer settings when you add a language; stemming rules are language-specific.
Remember this
Use an engine with an inverted index and good analyzers; Postgres's ts_rank is not BM25.
Practice: reproduce and break it
Work through these with the code in this guide:
1. Index the ten-document corpus (three error codes, two SKUs, password, lockout, refunds, dark mode and invoices articles) and run the five queries from the table.
2. Print idf("e1042") and idf("the"), then change the IDF to the classic form without 1 + and find which terms go negative.
3. Plot the term-frequency factor for k1 = 0.5, 1.2 and 2.0 for counts 1 to 10 and check it against the saturation diagram.
4. Add a document that repeats "payment" twenty times. With b = 0.75 and then b = 0, does it outrank the E1042 article for "payment timed out"?
5. Embed the corpus with any embedding model and rerun the queries. Note where it ranks a near-identical ID first.
6. Fuse both rankings with the rrf function, print the fused scores for the SKU query, and add a rule that boosts documents containing the exact query string.
Pass criterion: your BM25 scores match the guide to two decimal places, you can explain the step 4 result from the formula, and you can explain why the two SKUs tie under RRF and how to break the tie in favour of the exact match.
Quick reference
- Keep the corpus small enough to check by hand; the goal is to understand the scores.
- For step 5, any model works; record which one you used.
- Write down one query from your own product where each method would fail.
Remember this
Reproducing the scores by hand shows which part of the formula decides each ranking.
Key takeaway
BM25 scores a document by summing, over the query terms it contains, the term's IDF times a term-frequency factor that saturates (k1) and is normalized for length (b). Rare terms dominate, repetition helps less and less, and long documents need more matches. With Lucene's defaults of k1 = 1.2 and b = 0.75, it is a strong ranking function with no training data.
Its strengths and weaknesses are exactly opposite to embeddings'. It is precise on identifiers, codes and rare names, where embeddings can blur near-identical strings, and blind to paraphrase, where embeddings shine. On BEIR it generalized across domains better than the dense retrievers of its time. That is why the standard design is hybrid: BM25 and vector search fused with reciprocal rank fusion, then a reranker. For the wider design, see Hybrid Search: Vector and Keyword; for the final ordering step, see Rerankers vs Embeddings.
Practice (45 min): reproduce the scores and run the six steps above.
References
- Robertson and Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond, Foundations and Trends in Information Retrieval 3(4), 2009 — checked Oct 22, 2026
- Thakur et al., BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models, arXiv:2104.08663 — checked Oct 22, 2026
- Cormack, Clarke and Büttcher, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods, SIGIR 2009 — checked Oct 22, 2026
- Apache Lucene: BM25Similarity source — checked Oct 22, 2026
- Elasticsearch docs: Reciprocal rank fusion — checked Oct 22, 2026
- PostgreSQL docs: Controlling Text Search (ranking) — checked Oct 22, 2026
Shoaib Hossain
Lead Author & Systems ArchitectSoftware engineer and distributed systems architect specializing in backend scalability, cloud-native infrastructure, databases, and AI engineering workflows. Author and maintainer of Core Concept Learning.
Related Articles
Explore this topic