BM25 (Best Matching 25) is the scoring function that decides how relevant a document is to a query in almost every keyword search system running in production today: Elasticsearch, OpenSearch, Lucene-based search, Solr, and the lexical leg of nearly every hybrid search pipeline all default to it. It belongs to the same family as classical TF-IDF, ranking documents by how often query terms appear, how rare those terms are across the whole corpus, and how long the document is, but it fixes several of TF-IDF’s practical failure modes with two tunable constants. Despite dating back to the 1990s, BM25 remains the standard zero-shot baseline that every new retrieval method, including transformer-based dense retrievers, gets measured against, and it frequently still wins outright on exact-match, keyword-heavy queries.
From TF-IDF to Okapi BM25
BM25 grew out of the Probabilistic Relevance Framework, a body of information-retrieval theory developed by Stephen Robertson, Karen Sparck Jones, and collaborators starting in the 1970s and 1980s. It was first implemented in the Okapi search system at City University London and tuned through a series of TREC (Text REtrieval Conference) evaluations in the early-to-mid 1990s, where researchers tried roughly two dozen variants of the scoring formula before settling on the version that stuck, hence “25.” Robertson and Hugo Zaragoza later formalized the full framework, including BM25 and its field-weighted successor BM25F, in a 2009 survey that remains the canonical reference. Unlike neural retrieval methods, BM25 requires no training data and no model to host: it is a closed-form statistical formula computed directly from term counts, which is a large part of why it has stayed the default for three decades.
The Formula, Term by Term
score(D, Q) = Σ IDF(qᵢ) · (f(qᵢ, D) · (k₁ + 1)) / (f(qᵢ, D) + k₁ · (1 − b + b · |D| / avgdl))
Each query term qᵢ contributes independently to the document’s score, and the three pieces of that contribution are worth pulling apart individually.
Term frequency, with saturation (k₁)
f(qᵢ, D) is how many times the term appears in the document. Raw TF-IDF would let this grow the score linearly forever, so stuffing a document with a keyword a hundred times would make it look a hundred times more relevant. BM25 caps this with the k₁ constant: as f(qᵢ, D) grows, the fraction f · (k₁+1) / (f + k₁) approaches a ceiling of k₁ + 1 and flattens out, so the fifth occurrence of a term adds far less to the score than the first. Elasticsearch and Lucene default k₁ to 1.2; values in the 1.2-2.0 range are typical, with lower values saturating faster (each extra occurrence matters less) and higher values behaving closer to unsaturated raw counts.
# Scenario: does repeating a SKU in a product listing meaningfully help it rank?
from rank_bm25 import BM25Okapi
listing_once = ["Wireless mouse, model WM-4471, ergonomic grip."]
listing_stuffed = ["Wireless mouse WM-4471 WM-4471 WM-4471 WM-4471, ergonomic grip."]
bm25 = BM25Okapi([d.lower().split() for d in listing_once + listing_stuffed])
print(bm25.get_scores("wm-4471".split()))
# The stuffed listing scores higher, but nowhere close to 4x higher:
# k1's saturation means the 2nd, 3rd, and 4th mentions add rapidly less.
Length normalization (b)
|D| / avgdl compares this document’s length to the average document length in the corpus. The constant b (default 0.75) controls how strongly that ratio penalizes long documents: at b = 1, length normalization is fully applied, so a document twice the average length needs proportionally more term matches to score the same as a short one; at b = 0, length normalization is switched off entirely, and documents compete purely on raw term counts regardless of size. Without this term, a document that is simply longer, and therefore statistically more likely to contain any given word somewhere, would rank higher for reasons that have nothing to do with relevance.
Inverse document frequency, smoothed
The IDF(qᵢ) term down-weights common words and up-weights rare ones:
IDF(qᵢ) = ln(1 + (N − n(qᵢ) + 0.5) / (n(qᵢ) + 0.5))
where N is the total number of documents and n(qᵢ) is how many of them contain the term. This is a smoothed variant of the original Robertson-Sparck Jones IDF: the classic formula, log((N − n + 0.5) / (n + 0.5)), can go negative when a term appears in more than half the corpus (a common stopword, say), which would make matching that term actively hurt a document’s score. Adding the + 1 inside the logarithm, the form Lucene and Elasticsearch actually ship, guarantees the IDF term stays non-negative for every term in the vocabulary.
The widget below isolates the term-frequency component of the formula above (holding IDF fixed at 1.0) so k₁ and b’s individual effects are easy to see directly, rather than taken on faith:
Why BM25 Beats Naive TF-IDF
| Raw TF-IDF | BM25 | |
|---|---|---|
| Term frequency | Unbounded, linear | Saturates toward k₁ + 1 |
| Document length | Not accounted for | Explicitly normalized via b |
| Common-term IDF | Can go negative | Smoothed to stay non-negative |
| Tuning | Effectively none | Two interpretable constants (k₁, b) |
| Behavior on keyword stuffing | Rewards it | Diminishing returns after a few occurrences |
The practical effect is that BM25 behaves closer to human judgments of relevance: a document that mentions a term three times isn’t three times as relevant as one that mentions it once, and a document isn’t more relevant just because it’s longer.
Architecture: Where BM25 Runs
graph TB
classDef default fill:#ffffff,stroke:#4338CA,stroke-width:2px,color:#0F172A,rx:8px,ry:8px;
classDef data fill:#EEF0F7,stroke:#0D9488,stroke-width:2px,color:#0F172A,rx:8px,ry:8px;
classDef process fill:#F7F8FC,stroke:#6366F1,stroke-width:2px,color:#0F172A,rx:8px,ry:8px;
classDef output fill:#4338CA,stroke:#4338CA,stroke-width:2px,color:#ffffff,rx:8px,ry:8px;
subgraph OFF["Offline: Indexing"]
D1([Documents]):::data --> TOK[Tokenize]:::process --> STAT[Compute per-term stats:<br/>doc freq, avgdl, doc length]:::process --> INV[(Inverted Index +<br/>corpus statistics)]:::data
end
subgraph ON["Online: Query"]
Q1([Query]):::data --> QTOK[Tokenize query terms]:::process --> LOOK[Look up postings<br/>for each term]:::process
LOOK --> INV
INV --> SCORE[Compute BM25 score<br/>per candidate doc]:::process --> RANK[Ranked Results]:::output
end
Both stages are cheap: indexing happens once per document, and query-time scoring only has to touch the postings lists for the query’s own terms, which is why BM25 scales to billions of documents on commodity hardware without a GPU in sight.
BM25 Variants
| Variant | What it changes | Typical use |
|---|---|---|
| BM25F | Scores multiple weighted fields (title, body, tags) as one document | Structured documents where a title match should count more than a body match |
| BM25+ | Adds a small lower-bound constant so very long documents aren’t over-penalized to near-zero | Corpora with highly variable document lengths |
| BM25L | Adjusts the length-normalization term to reduce bias against long documents | Similar motivation to BM25+, different correction |
| BM25-Adpt / BM25T | Learns or adapts the term-frequency saturation behavior per term instead of a single global k₁ | Research and specialized IR systems, less common in off-the-shelf search engines |
# Scenario: a title match should count more than a body match (BM25F's core idea)
def weighted_term_frequency(query_terms, fields, field_weights):
# combine per-field term counts into one weighted count before
# that combined count reaches the usual saturation and IDF terms
combined = {}
for field_name, field_text in fields.items():
weight = field_weights[field_name]
tokens = field_text.lower().split()
for term in query_terms:
combined[term] = combined.get(term, 0) + weight * tokens.count(term)
return combined
fields = {
"title": "Wireless mouse WM-4471",
"body": "Ergonomic grip, 2.4GHz receiver, compatible with most laptops.",
}
print(weighted_term_frequency(["wm-4471"], fields, field_weights={"title": 3.0, "body": 1.0}))
# {'wm-4471': 3.0}: the title mention counts 3x a body mention would,
# before that combined count even reaches BM25's saturation curve.
BM25 vs. Dense Embeddings
BM25 only sees vocabulary: it cannot match “myocardial infarction” to a query about “heart attack,” because the two share no terms. That’s exactly the gap dense, embedding-based retrieval closes, at the cost of losing BM25’s precision on exact identifiers, SKUs, and rare tokens. See Dense Retrieval vs. Sparse Retrieval for the full comparison; the short version is that production systems increasingly run both and fuse the results (see Hybrid Search) rather than picking one.
BM25 in Production Search Stacks
- Elasticsearch, OpenSearch, and Solr (all Lucene-based) use BM25 as the out-of-the-box default similarity, requiring zero configuration to get a reasonable ranking.
- Vector databases with sparse fields (Qdrant, Weaviate, Pinecone) run BM25 or a BM25-like scorer as the sparse leg of a hybrid query alongside dense vector search.
- Postgres full-text search (
tsvector/ts_rank) is a looser cousin: it uses its own weighting scheme, not the BM25 formula, though teams sometimes layer a real BM25 implementation on top for closer parity. - Vespa implements its own BM25-family rank profile as one of several first-stage retrieval options in its broader multi-phase ranking framework.
// Scenario: a catalog with very short titles and very long descriptions,
// where the default k1/b combination under-rewards short, precise matches
PUT /products/_settings
{
"index": {
"similarity": {
"custom_bm25": { "type": "BM25", "k1": 1.5, "b": 0.3 }
}
}
}
// Lowering b weakens the length penalty, so a short title match isn't
// discounted just for being shorter than the average indexed document.
What’s New (2025-2026)
- BM25 remains a stubbornly strong zero-shot baseline. BEIR and similar heterogeneous IR benchmarks continue to show plain BM25 beating or matching many dense retrievers out-of-domain, which is why almost no serious retrieval evaluation in 2025-2026 skips it as a baseline.
- Learned sparse retrieval is starting to sit next to, not replace, BM25. Models like SPLADE keep BM25’s inverted-index infrastructure and interpretability while learning term weights and expansions with a transformer, and some production stacks now run both, or swap in learned sparse scores where BM25’s raw term matching is too literal.
- BM25 as the default “free” leg of hybrid search. Every major vector database’s native hybrid query API (Qdrant’s Query API, Weaviate’s hybrid mode, Elasticsearch’s RRF retriever, Pinecone’s sparse-dense index) ships BM25 as the zero-cost, zero-training sparse component, which is a large part of why hybrid search has become the out-of-the-box recommendation rather than a manual pipeline teams assemble themselves.
When to Reach for BM25
| Scenario | Recommendation |
|---|---|
| Exact identifiers: product SKUs, error codes, legal citations, names | BM25 alone or as the dominant signal in a hybrid query |
| No training data or embedding model available | BM25; works immediately with no model to host |
| Conversational, paraphrased natural-language queries | Pair with dense retrieval; BM25 alone will miss synonyms |
| High query volume, tight latency budget | BM25; inverted-index lookups are inherently cheap |
| General-purpose enterprise search or RAG | Hybrid: BM25 plus dense embeddings, fused with RRF or a native hybrid API |
BM25 is not a legacy technique being phased out by neural search. It is the still-competitive, zero-training baseline that most 2025-2026 retrieval architectures build alongside, not instead of, dense embeddings.
How to Use: Scoring documents with BM25 and tuning k1/b
from rank_bm25 import BM25Okapi
corpus = [
"BM25 ranks documents by term frequency, inverse document frequency, and length.",
"Dense retrieval uses learned embeddings to match meaning instead of exact words.",
"The k1 parameter controls how quickly repeated terms stop adding to the score.",
"Restarting the Kubernetes pod cleared the OOMKilled error on node-4.",
]
tokenized_corpus = [doc.lower().split() for doc in corpus]
# Default tuning: k1=1.2 (term-frequency saturation), b=0.75 (length normalization)
bm25_default = BM25Okapi(tokenized_corpus, k1=1.2, b=0.75)
query = "How does BM25 score term frequency?".lower().split()
scores_default = bm25_default.get_scores(query)
# b=0 disables length normalization entirely; long and short docs compete on raw term counts
bm25_no_length_norm = BM25Okapi(tokenized_corpus, k1=1.2, b=0.0)
scores_no_norm = bm25_no_length_norm.get_scores(query)
for i, doc in enumerate(corpus):
print(f"{scores_default[i]:.3f} (b=0.75) {scores_no_norm[i]:.3f} (b=0) {doc}")
Ready to build?
Leverage AI technologies to build your product stack
Superteams can help you build, deploy and launch AI application stacks using open source technologies — from architecture through to production.
Talk to Superteams