BM25: the Search Algorithm Behind Elasticsearch
Covers BM25, the probabilistic ranking algorithm powering modern search engines. Topics include IDF derivation, term saturation, length normalization, BM25L.
Language AI HandbookAll chapters BM25Full chapter · Sections in this chapter1 / 14 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
BM25
BM25 (Best Matching 25) is the algorithm that powers Elasticsearch and Solr, both of which use Lucene. It's been the standard in information retrieval since the 1990s and remains widely used today, even as neural approaches have emerged. Understanding BM25 gives you insight into what makes search work, and why simple term counting falls apart at scale.
Before neural language models rewrote the playbook, BM25 was the gold standard for text retrieval. Ask a search expert from 2010 what algorithm they trusted, and BM25 would come up within seconds. The remarkable thing is that this hasn't changed much even now. Production search systems at companies including Google and Microsoft still use BM25; Amazon does too. Sometimes it is the primary ranker, but more often it is the first stage in a hybrid pipeline that combines lexical matching with dense semantic retrieval. Understanding BM25 is not optional knowledge for anyone serious about search.
In this chapter, you'll learn where BM25 comes from (hint: probability theory), why it consistently outperforms TF-IDF, and how to implement it from scratch. We'll also explore its variants and see empirically how it stacks up against simpler approaches. Along the way, we'll cover the intuition behind each design decision so that the formula doesn't feel like a magic black box.
The Problem with Counting Words
Before diving into BM25, it helps to understand what problem it's solving. The obvious approach to search is to count how many times a query term appears in each document and rank by that count. This is called term frequency (TF) ranking, and it fails in predictable ways.
Consider a collection of news articles and the query "president." A full-length 10,000-word biography of a president will mention the word hundreds of times. A tight 200-word breaking news story might mention it twice. Raw term frequency ranking would always favor the longer document, even when the short article is more relevant to your query. Length bias is one of the most fundamental failure modes of naive retrieval.
The second failure mode is treating all terms equally. If your query is "the president of the United States," the word "the" appears in essentially every document. Counting "the" provides no discriminative signal. You want to reward documents for containing informative, specific terms, not common filler words.
The third failure mode is not recognizing diminishing returns. Once a document has established that it's about machine learning by mentioning the term ten times, the eleventh mention adds very little new information. Treating each occurrence as equally valuable leads to systems that can be gamed by keyword repetition.
BM25 solves all three of these problems with a single, elegant formula. It normalizes for document length, weights terms by how informative they are, and models diminishing returns from repeated occurrences. Let's see how.
The Probabilistic Foundation
BM25 didn't emerge from nowhere. It's the result of decades of research in probabilistic information retrieval, a field that asks: given a query, what's the probability that a document is relevant?
The Binary Independence Model
The story begins with the Binary Independence Model (BIM), developed in the 1970s. BIM makes two key assumptions:
- Binary: Each term either appears in a document or it doesn't (we ignore term frequency)
- Independence: Terms occur independently of each other given the relevance of a document
A probabilistic retrieval model that estimates document relevance by assuming terms occur independently. Documents are ranked by the odds ratio of relevance given the observed query terms.
Under BIM, we want to rank documents by , the probability that document is relevant given query . Using Bayes' theorem and the independence assumption, this leads to ranking by:
where:
- : a term in both the query and document
- : the set of terms appearing in both query and document
- : probability of term appearing in a relevant document
- : probability of term appearing in a non-relevant document
- : the event that a document is relevant
- : the event that a document is not relevant
The expression inside the logarithm is the odds ratio: it measures how much more likely a term is to appear in relevant documents compared to non-relevant ones. A term that's common in relevant documents but rare in non-relevant ones will have a high odds ratio, contributing positively to the score. A term equally common in both will have an odds ratio near 1, contributing little (since ). We sum these contributions over all terms that appear in both the query and the document.
The difficulty is that we rarely have relevance judgments to estimate these probabilities. Robertson and Spärck Jones showed that with some simplifying assumptions, this reduces to something familiar: inverse document frequency.
The key simplifying assumption is to treat as constant across terms (say 0.5, meaning any relevant document is equally likely to contain any given term), and to estimate from the empirical document frequency in the collection. With these substitutions and a little algebra, the log-odds expression becomes proportional to the familiar IDF formula. The probabilistic model gives us a principled justification for something that might otherwise seem like a heuristic.
From BIM to BM25
The Binary Independence Model ignores term frequency entirely, which is clearly a limitation. If a document mentions "machine learning" ten times, that should matter more than mentioning it once.
Robertson and Walker addressed this in the 1990s through a series of papers that culminated in BM25. The key insight was to incorporate term frequency in a principled way while maintaining the probabilistic foundation.
The derivation involves the 2-Poisson model, which assumes that term frequencies in relevant and non-relevant documents follow different Poisson distributions. Relevant documents are assumed to have a mixture of two Poisson processes: one for "elite" documents (those about the topic) and one for non-elite documents that happen to contain the term. Working through the math, this mixture model leads to a term frequency weighting that saturates. Additional occurrences provide diminishing returns, which is exactly the behavior we want.
The name "BM25" refers to the 25th model in a series of experiments conducted at the Robertson/Sparck Jones research group. BM11 and BM15 were earlier attempts; BM25 proved to be the most effective formulation. The number has no other special significance.
The BM25 Formula
The full BM25 formula looks intimidating at first glance, but every piece exists for a reason. Rather than presenting it all at once, let's build it up from the three core problems any good retrieval function must solve:
- Which terms matter? Not all words are equally informative. "The" appears everywhere; "quantum" is rare and specific.
- How much does repetition help? If a document mentions your query term once, that's good. But is mentioning it 100 times really 100 times better?
- How do we handle document length? A 10,000-word document naturally contains more vocabulary than a 100-word document. Should we penalize it for that?
BM25 addresses each of these problems with a specific component. Let's examine them one by one, then see how they combine into the complete formula.
Problem 1: Which Terms Matter? The IDF Component
Consider searching for "the cat sat on the mat." The word "the" appears in virtually every English document. Finding it tells you almost nothing. But "mat"? That's much more specific.
The Inverse Document Frequency (IDF) quantifies this intuition. A term that appears in many documents gets a low IDF; a rare term gets a high IDF. BM25 uses a specific IDF formulation derived from its probabilistic foundations:
where:
- : total number of documents in the collection
- : number of documents containing term
- The terms provide smoothing to avoid division by zero and extreme values
This formulation differs from the standard IDF you might have seen in TF-IDF. The in the numerator comes directly from the probabilistic derivation, representing the number of documents that don't contain the term. Intuitively, the formula computes a log-odds ratio: the numerator approximates the probability that a relevant document doesn't contain term , while the denominator approximates the probability that a non-relevant document does contain it.
For very common terms appearing in more than half the documents, this IDF becomes negative. The formula effectively penalizes ubiquitous terms rather than just ignoring them. If a term appears in 80% of documents, matching on it might hurt your score slightly, pushing you to focus on more discriminative terms. Standard TF-IDF simply assigns a low positive weight to common terms; BM25's probabilistic derivation says such terms should actively count against a match.
Let's visualize how IDF varies with document frequency:
PythonIn [3]Expand
import numpy as np
# Simulate a collection of 10,000 documents
N = 10000
# Document frequencies ranging from 1 to N
doc_freqs = np.arange(1, N + 1)
# Calculate BM25 IDF
bm25_idf = np.log((N - doc_freqs + 0.5) / (doc_freqs + 0.5))
# Standard IDF for comparison
standard_idf = np.log(N / doc_freqs)VisualizationOut [4]Expand

The plot reveals an important difference: standard IDF is always positive, merely approaching zero for common terms. BM25's IDF crosses zero at exactly 50% document frequency and becomes increasingly negative beyond that. This means stopwords like "the" or "a" that appear in nearly every document will have strongly negative IDF, effectively penalizing documents that match only on these terms. In practice, this is why BM25 implementations often include a floor at zero or simply filter out negative-IDF terms before scoring.
Problem 2: How Much Does Repetition Help? The Saturation Effect
If a document mentions "machine learning" once, that's evidence of relevance. What about ten times? A hundred times?
Raw term frequency treats each occurrence equally: ten mentions = ten times the score. But this creates problems. A document that repeats a term obsessively (keyword stuffing) would dominate results. More fundamentally, there's a diminishing returns effect: the first mention tells you the document is about the topic; additional mentions provide less and less new information. The gap in relevance between a document that mentions a term once and a document that mentions it twice is much larger than the gap between 20 and 21 mentions.
BM25 captures this with a saturation function:
where:
- : frequency of term in document
- : saturation parameter controlling how quickly the function approaches its asymptote
The full formula includes length normalization in the denominator, which we'll add shortly.
The principle that additional occurrences of a query term in a document provide diminishing returns for relevance. The first few occurrences matter a lot; the hundredth occurrence adds almost nothing.
To understand why this formula creates saturation, consider the mathematical behavior. When (term doesn't appear), the formula yields 0. When (term appears once), we get . As grows very large, both the numerator and denominator grow at the same rate, so the ratio approaches , an asymptote that cannot be exceeded no matter how many times the term appears.
You can verify this limit directly: as ,
So the maximum possible contribution of any single query term, no matter how often it appears, is bounded by times the IDF weight. This cap makes BM25 resistant to keyword stuffing in a way that TF-IDF is not.
The intuition is this: the first occurrence of a term is strong evidence of relevance. Each additional occurrence provides weaker and weaker evidence, until eventually more occurrences barely matter at all.
The parameter controls how quickly you approach this ceiling:
- : Instant saturation. Any non-zero term frequency gives the same score (like the Binary Independence Model)
- : No saturation. Score is linear in term frequency (like raw TF-IDF)
- to : The empirically validated sweet spot for most collections
Let's visualize this saturation effect:
PythonIn [5]Expand
import numpy as np
# Term frequencies to plot
tf_values = np.arange(0, 21)
# Different k1 values
k1_values = [0.5, 1.2, 2.0, 5.0]
# Calculate saturation curves (without length normalization for clarity)
saturation_curves = {}
for k1 in k1_values:
scores = (tf_values * (k1 + 1)) / (tf_values + k1)
saturation_curves[k1] = scoresVisualizationOut [6]Expand

The visualization reveals the saturation behavior clearly. With , the curve flattens quickly: by term frequency 5, you've captured most of the possible score. With , the curve is nearly linear over this range, meaning repetition continues to help. The default value of provides a principled middle ground: the first few occurrences matter a lot, but the score doesn't keep climbing indefinitely.
Problem 3: How Do We Handle Document Length?
Here's a subtle but important issue. Imagine two documents about machine learning:
- Document A: A focused 200-word abstract that mentions "neural network" twice
- Document B: An extensive 10,000-word survey that mentions "neural network" twenty times
Which is more relevant to a query about neural networks? Raw term frequency would strongly favor Document B. But that's not necessarily right. Document B mentions the term more often simply because it's longer. The density of the term, its frequency relative to document length, might be a better signal.
But even this isn't the full story. Sometimes a long document has more relevant content: a detailed guide to neural networks should rank above a tweet that mentions the phrase once. The right approach is somewhere between ignoring length entirely and fully normalizing for it. BM25 lets you tune this tradeoff explicitly.
BM25 addresses this through length normalization, which adjusts term frequency based on how a document's length compares to the collection average:
where:
- : length normalization parameter (typically between 0 and 1)
- : length of document (number of terms)
- : average document length across the collection
This expression appears in the denominator of the BM25 formula, multiplied by . Let's trace through what happens for different document lengths:
- Average-length document (): The ratio , so the expression becomes . No adjustment.
- Longer document (): The ratio exceeds 1, so the expression is greater than 1. This increases the denominator, reducing the score.
- Shorter document (): The ratio is less than 1, so the expression is less than 1. This decreases the denominator, boosting the score.
A technique to prevent longer documents from having an unfair advantage in retrieval. BM25 normalizes term frequency by comparing document length to the collection average, with the strength of normalization controlled by the parameter .
The parameter controls how aggressively we normalize:
- : No length normalization. The expression always equals 1, and document length is ignored entirely.
- : Full length normalization. A document twice the average length has its term frequencies effectively halved.
- : The typical default. This provides moderate normalization that accounts for length without removing its effect.
The intuition behind is that document length is partially informative. Some of the extra length in a long document reflects relevant content; some of it is just verbosity. The 0.75 setting is calibrated to discount verbosity while retaining some of the signal from long, content-rich documents.
Let's see how length normalization affects scoring:
PythonIn [7]Expand
# Fixed term frequency
tf = 5
k1 = 1.2
# Document lengths as multiples of average
length_ratios = np.linspace(0.2, 3.0, 50)
# Different b values
b_values = [0.0, 0.25, 0.5, 0.75, 1.0]
# Calculate scores for each b
length_curves = {}
for b in b_values:
normalization = 1 - b + b * length_ratios
scores = (tf * (k1 + 1)) / (tf + k1 * normalization)
length_curves[b] = scoresVisualizationOut [8]Expand

The visualization confirms our intuition. When , document length doesn't matter at all: all curves are flat. With , a document that's three times average length gets significantly penalized. The default provides a reasonable middle ground, acknowledging that longer documents might naturally contain more occurrences without completely ignoring the length difference.
Putting It All Together: The Complete Formula
Now we can assemble the complete BM25 formula. For each query term, we multiply three components:
- IDF: How informative is this term?
- Saturated TF: How often does it appear (with diminishing returns)?
- Length adjustment: Normalized by document length
The final formula sums these contributions across all query terms:
where:
- : the document being scored
- : the query (set of query terms)
- : a term in the query
- : inverse document frequency of term
- : frequency of term in document
- : saturation parameter (typically 1.2 to 2.0)
- : length normalization parameter (typically 0.75)
- : length of document (number of terms)
- : average document length in the collection
Each component addresses one of our original problems. The IDF weights rare terms more heavily. The saturation function ensures diminishing returns from repetition. The length normalization factor in the denominator adjusts for document size. Together, they create a scoring function that has worked well across decades of information retrieval research.
This formula does not include query term frequency. The original BM25 paper includes a query-side term frequency component as well (sometimes called BM25 with query term weighting), but in practice most implementations drop it. For typical short web queries, query terms appear at most once, making the query TF component degenerate to a constant. For longer queries, you could re-add it, but the gain is usually marginal.
Parameter Interaction: A Heatmap View
The parameters and interact in interesting ways. Let's visualize how different combinations affect the score for a document with a fixed term frequency:
PythonIn [9]Expand
# Create parameter grids
k1_range = np.linspace(0.1, 3.0, 50)
b_range = np.linspace(0.0, 1.0, 50)
K1, B = np.meshgrid(k1_range, b_range)
# Fixed parameters for visualization
tf = 3 # Term frequency
doc_len = 150 # Document length
avgdl = 100 # Average document length
idf = 2.5 # IDF of a moderately rare term
# Calculate BM25 scores for each (k1, b) combination
scores = idf * (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * (doc_len / avgdl)))VisualizationOut [10]Expand

The heatmap reveals how the parameters affect the score. Moving right (higher ) increases scores because term frequency contributes more. Moving up (higher ) decreases scores because our document is longer than average and gets penalized more heavily. The default parameters sit in a region that balances these effects, but the optimal choice depends on your specific collection and retrieval goals.
A Worked Example
With the formula understood, let's trace through a concrete example to see how all the pieces fit together. We'll score three short documents against a simple query, computing each component step by step.
Our corpus consists of several documents:
- D1: "the cat sat on the mat" (6 words)
- D2: "the cat sat on the cat mat" (7 words)
- D3: "the dog ran in the park" (6 words)
And our query is: "cat mat"
Notice that D1 and D2 both contain our query terms, but D2 mentions "cat" twice. D3 contains neither term. Let's see how BM25 handles these differences.
Step 1: Collection Statistics
Before scoring any document, we need collection-level statistics. These establish the baseline against which individual documents are compared:
PythonIn [11]Expand
documents = [
"the cat sat on the mat",
"the cat sat on the cat mat",
"the dog ran in the park",
"the bird flew over the house",
"the fish swam in the pond",
"the rabbit hopped through the field",
"the horse trotted down the road",
]
query = "cat mat"
# Tokenize
doc_tokens = [doc.lower().split() for doc in documents]
query_tokens = query.lower().split()
# Collection statistics
N = len(documents) # Total documents
doc_lengths = [len(tokens) for tokens in doc_tokens]
avgdl = sum(doc_lengths) / N
# Document frequency for each term
from collections import Counter
doc_freq = Counter()
for tokens in doc_tokens:
for term in set(tokens):
doc_freq[term] += 1ConsoleOut [12]Expand
Number of documents (N): 7 Document lengths: [6, 7, 6, 6, 6, 6, 6] Average document length (avgdl): 6.14 Document frequencies for query terms: 'cat': appears in 2 document(s) 'mat': appears in 2 document(s)
We have 7 documents with an average length of about 6 words. "Cat" appears in 2 of the 7 documents, and "mat" appears in 2 as well. These statistics feed into our IDF and length normalization calculations.
Step 2: Computing IDF
Now let's compute the IDF for each query term. Remember, IDF measures how discriminative a term is. Terms appearing in fewer documents get higher IDF:
PythonIn [13]Expand
import math
def compute_idf(term, N, doc_freq):
n = doc_freq.get(term, 0)
return math.log((N - n + 0.5) / (n + 0.5))
idf_scores = {term: compute_idf(term, N, doc_freq) for term in query_tokens}ConsoleOut [14]Expand
IDF scores: 'cat': log((7 - 2 + 0.5) / (2 + 0.5)) = 0.7885 'mat': log((7 - 2 + 0.5) / (2 + 0.5)) = 0.7885
Both terms appear in 2 out of 7 documents. Their IDF values reflect how specific they are relative to this collection. With a larger corpus, terms appearing in only 2 out of thousands of documents would receive much higher IDF values. This small corpus example illustrates the mechanics, but the dynamics play out more clearly at scale.
Step 3: Scoring Each Document
Now we combine everything: IDF, saturated term frequency, and length normalization. For each query term in each document, we compute the contribution and sum them up:
PythonIn [15]Expand
def bm25_score(doc_tokens, query_tokens, doc_freq, N, avgdl, k1=1.2, b=0.75):
doc_len = len(doc_tokens)
term_freq = Counter(doc_tokens)
score = 0
score_breakdown = []
for term in query_tokens:
if term not in term_freq:
continue
tf = term_freq[term]
idf = compute_idf(term, N, doc_freq)
# BM25 term score
numerator = tf * (k1 + 1)
denominator = tf + k1 * (1 - b + b * (doc_len / avgdl))
term_score = idf * (numerator / denominator)
score += term_score
score_breakdown.append(
{"term": term, "tf": tf, "idf": idf, "term_score": term_score}
)
return score, score_breakdown
# Score each document
results = []
for i, tokens in enumerate(doc_tokens):
score, breakdown = bm25_score(tokens, query_tokens, doc_freq, N, avgdl)
results.append(
{
"doc_id": i + 1,
"score": score,
"breakdown": breakdown,
"length": len(tokens),
}
)ConsoleOut [16]Expand
BM25 Scores (k1=1.2, b=0.75):
Document 2: "the cat sat on the cat mat"
Length: 7 (avgdl = 6.14)
Total Score: 1.7891
'cat': TF=2, IDF=0.7885, contribution=1.0432
'mat': TF=1, IDF=0.7885, contribution=0.7459
Document 1: "the cat sat on the mat"
Length: 6 (avgdl = 6.14)
Total Score: 1.5921
'cat': TF=1, IDF=0.7885, contribution=0.7960
'mat': TF=1, IDF=0.7885, contribution=0.7960
Document 3: "the dog ran in the park"
Length: 6 (avgdl = 6.14)
Total Score: 0.0000
Document 4: "the bird flew over the house"
Length: 6 (avgdl = 6.14)
Total Score: 0.0000
Document 5: "the fish swam in the pond"
Length: 6 (avgdl = 6.14)
Total Score: 0.0000
Document 6: "the rabbit hopped through the field"
Length: 6 (avgdl = 6.14)
Total Score: 0.0000
Document 7: "the horse trotted down the road"
Length: 6 (avgdl = 6.14)
Total Score: 0.0000Let's visualize the score breakdown for each document to see how each query term contributes:
VisualizationOut [17]Expand

The visualization makes the scoring dynamics clear:
- D2 wins despite being the longest document. Why? It contains "cat" twice, and the saturation effect hasn't fully kicked in at TF=2. The extra occurrence still provides meaningful additional score.
- D1 comes second with both query terms appearing once each. It's slightly shorter than average, which gives it a small boost.
- D3 scores zero because it contains neither query term. No amount of length normalization or parameter tuning can help a document that lacks the query terms entirely.
The negative scores might seem strange, but they're a consequence of our small corpus where both query terms are common relative to the collection size. In practice, with larger corpora and more discriminative query terms, you'd see positive scores for matching documents. What matters is the relative ranking, and BM25 correctly identifies D2 as most relevant.
Implementing BM25 from Scratch
Understanding the formula is one thing; implementing it efficiently is another. Let's build a complete BM25 retrieval system step by step, seeing how the mathematical concepts translate into code.
We'll use a larger corpus to make the results more interesting and realistic:
PythonIn [18]Expand
# Sample corpus about programming languages
corpus = [
"Python is a high-level programming language known for its simple syntax and readability",
"Java is a popular object-oriented programming language used for enterprise applications",
"JavaScript is the programming language of the web enabling interactive web pages",
"Python and JavaScript are both interpreted languages with dynamic typing",
"Java requires compilation to bytecode which runs on the Java Virtual Machine",
"The Python programming language supports multiple programming paradigms",
"Web developers often use JavaScript frameworks like React and Vue",
"Python is widely used for machine learning and data science applications",
"Java enterprise applications often use Spring framework for dependency injection",
"JavaScript can run on servers using Node.js runtime environment",
]Building the Index
A BM25 index needs to store several pieces of information efficiently:
- Document frequencies: For each term, how many documents contain it? (Needed for IDF)
- Document lengths: How long is each document? (Needed for length normalization)
- Average document length: The collection-wide baseline for length comparison
- Inverted index: For each term, which documents contain it and with what frequency? (Needed for efficient retrieval)
The inverted index is the key data structure that makes search practical at scale. Without it, you'd need to scan every document for every query, which becomes prohibitive once you have millions of documents. With an inverted index, you can immediately look up which documents contain each query term and score only those candidates. This reduces the scoring workload from O(N) per query to O(postings), where postings is the number of documents containing any query term.
Let's create a class that builds and stores this index:
PythonIn [19]Expand
import re
from collections import defaultdict
class BM25Index:
def __init__(self, k1=1.2, b=0.75):
self.k1 = k1
self.b = b
self.documents = []
self.doc_lengths = []
self.avgdl = 0
self.doc_freq = defaultdict(int)
self.inverted_index = defaultdict(list) # term -> [(doc_id, tf), ...]
def tokenize(self, text):
"""Simple tokenization: lowercase and split on non-alphanumeric."""
return re.findall(r"\b[a-z]+\b", text.lower())
def index(self, documents):
"""Build the BM25 index from a list of documents."""
self.documents = documents
for doc_id, doc in enumerate(documents):
tokens = self.tokenize(doc)
self.doc_lengths.append(len(tokens))
# Count term frequencies in this document
term_freq = Counter(tokens)
# Update document frequencies and inverted index
for term, tf in term_freq.items():
if tf > 0:
self.doc_freq[term] += 1
self.inverted_index[term].append((doc_id, tf))
self.avgdl = sum(self.doc_lengths) / len(self.doc_lengths)
self.N = len(documents)The indexing process makes a single pass through the corpus, computing all the statistics we need. One subtle point: we store each term's (doc_id, tf) pairs in the inverted index during indexing. This means we don't need to re-tokenize documents at query time.
Implementing the Scoring Function
Now we add methods to compute BM25 scores. The scoring function directly implements our formula, computing IDF, saturated term frequency, and length normalization for each query term:
PythonIn [20]Expand
def compute_idf(self, term):
"""Compute IDF for a term."""
n = self.doc_freq.get(term, 0)
return math.log((self.N - n + 0.5) / (n + 0.5))
def score_document(self, doc_id, query_terms):
"""Compute BM25 score for a single document."""
doc_len = self.doc_lengths[doc_id]
tokens = self.tokenize(self.documents[doc_id])
term_freq = Counter(tokens)
score = 0
for term in query_terms:
if term not in term_freq:
continue
tf = term_freq[term]
idf = self.compute_idf(term)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (
1 - self.b + self.b * (doc_len / self.avgdl)
)
score += idf * (numerator / denominator)
return score
def search(self, query, top_k=5):
"""Search the index and return top-k results."""
query_terms = self.tokenize(query)
# Find candidate documents (those containing at least one query term)
candidates = set()
for term in query_terms:
for doc_id, _ in self.inverted_index.get(term, []):
candidates.add(doc_id)
# Score candidates
results = []
for doc_id in candidates:
score = self.score_document(doc_id, query_terms)
if score > 0:
results.append((doc_id, score))
# Sort by score descending
results.sort(key=lambda x: x[1], reverse=True)
return results[:top_k]
# Add methods to the class
BM25Index.compute_idf = compute_idf
BM25Index.score_document = score_document
BM25Index.search = searchNotice how the search method uses the inverted index to find candidate documents efficiently. Instead of scoring all documents, we only consider those containing at least one query term. This optimization matters for large corpora where most documents won't match the query at all. A query for "quantum entanglement" against a collection of 10 million news articles would match perhaps a few hundred documents; there's no need to score the other 9,999,700.
Testing the Implementation
Let's test our BM25 implementation with several queries to see how it ranks documents:
PythonIn [21]Expand
# Build the index
index = BM25Index(k1=1.2, b=0.75)
index.index(corpus)
# Search for different queries
queries = [
"python programming",
"web javascript",
"machine learning",
"java enterprise",
]
search_results = {}
for query in queries:
results = index.search(query, top_k=3)
search_results[query] = resultsConsoleOut [22]Expand
Query: "python programming" -------------------------------------------------- 1. [Score: 0.9592] The Python programming language supports multiple programmin... 2. [Score: 0.6588] Python is a high-level programming language known for its si... 3. [Score: 0.3806] Python and JavaScript are both interpreted languages with dy... Query: "web javascript" -------------------------------------------------- 1. [Score: 1.9894] JavaScript is the programming language of the web enabling i... 2. [Score: 1.6471] Web developers often use JavaScript frameworks like React an... 3. [Score: 0.3806] JavaScript can run on servers using Node.js runtime environm... Query: "machine learning" -------------------------------------------------- 1. [Score: 3.0581] Python is widely used for machine learning and data science ... 2. [Score: 1.1753] Java requires compilation to bytecode which runs on the Java... Query: "java enterprise" -------------------------------------------------- 1. [Score: 2.0553] Java enterprise applications often use Spring framework for ... 2. [Score: 1.9072] Java is a popular object-oriented programming language used ... 3. [Score: 1.0190] Java requires compilation to bytecode which runs on the Java...
Our BM25 implementation successfully retrieves relevant documents. Several patterns emerge from these results:
- Specific queries work well: "machine learning" finds the one document explicitly about ML and data science.
- Multiple matches boost scores: Documents matching both query terms (like "Python programming") score higher than those matching just one.
- IDF matters: Common terms like "programming" contribute less than rarer terms like "machine" or "enterprise."
The implementation is functionally complete, but production systems like Elasticsearch add many optimizations: compressed inverted indexes, skip lists for faster intersection, block-max optimizations that skip posting lists when their maximum score can't enter the top-k, and caching of IDF values across queries.
BM25 Variants
The original BM25 formula has some known issues that researchers have addressed with variants. Understanding these variants helps you make better choices when the default BM25 underperforms in specific situations.
The Problem with Short Documents
One issue with BM25 is that very short documents can be over-penalized. If a document is much shorter than average, the length normalization term becomes less than 1, which boosts the term frequency component. But this boost might not be enough to compensate for the document simply having fewer terms.
Consider a collection of academic abstracts with an average length of 200 words, but you've added some very short 20-word author bio entries. Those bio entries might contain a query term once, but because they're so short relative to the average, the length normalization boost might make them score oddly high. More seriously, the normalization approaches break down mathematically when documents are extremely short.
More fundamentally, there's a theoretical concern: Lv and Zhai (2011) showed that the original BM25 violates a property called the "term frequency normalization lower bound." This property says that a document containing a query term should always score higher than the same document without the query term. At extreme length differences, standard BM25 can violate this. The variants below fix this.
BM25L: Lower Bound on TF
BM25L (BM25 with Lower bound) addresses this by modifying how term frequency is normalized. First, it computes a length-normalized term frequency:
where:
- : the length-normalized term frequency
- : raw frequency of term in document
- : length normalization parameter (typically 0.75)
- : length of document (number of terms)
- : average document length across the collection
This formula essentially "inflates" the term frequency for short documents and "deflates" it for long documents. A document half the average length would have its term frequency doubled; a document twice the average length would have it halved.
Then BM25L adds a lower bound constant to this normalized value:
where:
- : the document being scored
- : the query (set of query terms)
- : a term in the query
- : inverse document frequency of term
- : length-normalized term frequency (defined above)
- : lower bound constant (typically 0.5)
- : saturation parameter (same as in standard BM25)
The addition of to the normalized term frequency ensures that even a single occurrence of a term makes a nonzero contribution to the score, regardless of document length. This acts as a guarantee: the term frequency component can never fall below , which is strictly positive. This restores the lower bound property that standard BM25 can violate.
A variant of BM25 that adds a small constant to the length-normalized term frequency, preventing the term frequency normalization lower bound from being violated and ensuring consistent behavior across documents of very different lengths.
BM25+: Additive IDF
BM25+ takes a different approach by adding a constant to the entire term weight:
where:
- : the document being scored
- : the query (set of query terms)
- : a term in the query
- : inverse document frequency of term
- : frequency of term in document
- : saturation parameter (typically 1.2 to 2.0)
- : length normalization parameter (typically 0.75)
- : length of document (number of terms)
- : average document length in the collection
- : additive constant (typically 1.0)
The key insight is that is added inside the IDF multiplication. This guarantees that a document containing a query term always scores higher than one that doesn't, regardless of length normalization effects. Even if the term frequency component becomes very small due to aggressive length normalization, the ensures a minimum positive contribution. The IDF scaling means that this floor is higher for rare terms (where it matters more) than for common terms.
Let's compare these variants:
PythonIn [23]Expand
def bm25_standard(tf, idf, doc_len, avgdl, k1=1.2, b=0.75):
"""Standard BM25 scoring."""
numerator = tf * (k1 + 1)
denominator = tf + k1 * (1 - b + b * (doc_len / avgdl))
return idf * (numerator / denominator)
def bm25l(tf, idf, doc_len, avgdl, k1=1.2, b=0.75, delta=0.5):
"""BM25L with lower bound on normalized TF."""
# First normalize TF by document length
ctd = tf / (1 - b + b * (doc_len / avgdl))
# Then add delta to the normalized value
ctd_adjusted = ctd + delta
numerator = (k1 + 1) * ctd_adjusted
denominator = k1 + ctd_adjusted
return idf * (numerator / denominator)
def bm25_plus(tf, idf, doc_len, avgdl, k1=1.2, b=0.75, delta=1.0):
"""BM25+ with additive component."""
numerator = tf * (k1 + 1)
denominator = tf + k1 * (1 - b + b * (doc_len / avgdl))
return idf * ((numerator / denominator) + delta)
# Compare for different document lengths
avgdl = 100
tf = 2
idf = 2.0 # Moderately rare term
doc_lengths = [20, 50, 100, 200, 500]
comparison = {"BM25": [], "BM25L": [], "BM25+": []}
for doc_len in doc_lengths:
comparison["BM25"].append(bm25_standard(tf, idf, doc_len, avgdl))
comparison["BM25L"].append(bm25l(tf, idf, doc_len, avgdl))
comparison["BM25+"].append(bm25_plus(tf, idf, doc_len, avgdl))ConsoleOut [24]Expand
| Doc Length | BM25 | BM25L | BM25+ |
|---:|---:|---:|---:|
| 20 | 3.55 | 3.61 | 5.55 |
| 50 | 3.20 | 3.32 | 5.20 |
| 100 | 2.75 | 2.97 | 4.75 |
| 200 | 2.15 | 2.54 | 4.15 |
| 500 | 1.29 | 2.00 | 3.29 |
: BM25 variant scores across document lengths (TF=2, avgdl=100, IDF=2.0). BM25+ maintains higher scores due to its additive delta term. {#tbl-bm25-variants}BM25+ consistently gives higher scores, especially for shorter documents where the additive term has more relative impact. BM25L provides a middle ground with its adjusted term frequency calculation. For most practical applications, the differences are small, and the defaults work well. The variants matter primarily when your collection has a mix of very short and very long documents, such as a mix of social media posts and full articles.
Field-Weighted BM25
Real documents often have structure: titles, abstracts, body text, metadata. A match in the title should probably count more than a match buried in the body. Field-weighted BM25 (sometimes called BM25F) addresses this.
An extension of BM25 that handles structured documents by computing a weighted combination of term frequencies across different fields, with each field having its own boost weight and length normalization. This allows title matches to be weighted more heavily than body text matches.
The key idea is to compute a "virtual" term frequency that combines contributions from all fields:
where:
- : virtual (combined) term frequency for term in document
- : a field in the document (e.g., title, body, abstract)
- : boost weight for field (higher values give more importance to matches in that field)
- : frequency of term in field of document
- : length normalization parameter specific to field
- : length of field in document
- : average length of field across all documents
This virtual term frequency then plugs into a simplified BM25 formula (note that length normalization is already applied per-field in ):
where:
- : the document being scored
- : the query (set of query terms)
- : a term in the query
- : inverse document frequency of term
- : virtual term frequency (computed from the formula above)
- : saturation parameter (typically 1.2 to 2.0)
Notice that the length normalization term is absent from the denominator here because it's already incorporated into the virtual term frequency on a per-field basis. Each field has its own average length, so comparisons are made within fields rather than across the whole document.
An important subtlety: because is a linear combination of per-field term frequencies, all field lengths are compared against their own field averages. A title match against the average title length, and a body match against the average body length. This is much more principled than simply concatenating fields and doing standard BM25 on the concatenated text.
Let's implement a simple version:
PythonIn [25]Expand
class BM25FIndex:
def __init__(self, field_weights, k1=1.2, b=0.75):
self.field_weights = field_weights # {'title': 3.0, 'body': 1.0}
self.k1 = k1
self.b = b
self.documents = []
self.field_lengths = defaultdict(list) # field -> [lengths]
self.avgdl = {} # field -> average length
self.doc_freq = defaultdict(int)
self.N = 0
def tokenize(self, text):
return re.findall(r"\b[a-z]+\b", text.lower())
def index(self, documents):
"""Index documents with multiple fields.
Each document should be a dict: {'title': '...', 'body': '...'}
"""
self.documents = documents
self.N = len(documents)
# Compute document frequencies and field lengths
all_terms = set()
for doc in documents:
doc_terms = set()
for field in self.field_weights:
tokens = self.tokenize(doc.get(field, ""))
self.field_lengths[field].append(len(tokens))
doc_terms.update(tokens)
for term in doc_terms:
self.doc_freq[term] += 1
# Compute average lengths per field
for field, lengths in self.field_lengths.items():
self.avgdl[field] = sum(lengths) / len(lengths) if lengths else 1
def compute_virtual_tf(self, doc_id, term):
"""Compute weighted, normalized term frequency across fields."""
doc = self.documents[doc_id]
virtual_tf = 0
for field, weight in self.field_weights.items():
tokens = self.tokenize(doc.get(field, ""))
tf = tokens.count(term)
if tf > 0:
field_len = len(tokens)
avgdl_field = self.avgdl[field]
# Normalize by field length, then weight
normalized_tf = tf / (
1 - self.b + self.b * (field_len / avgdl_field)
)
virtual_tf += weight * normalized_tf
return virtual_tf
def search(self, query, top_k=5):
query_terms = self.tokenize(query)
results = []
for doc_id in range(self.N):
score = 0
for term in query_terms:
if self.doc_freq.get(term, 0) == 0:
continue
vtf = self.compute_virtual_tf(doc_id, term)
if vtf > 0:
idf = math.log(
(self.N - self.doc_freq[term] + 0.5)
/ (self.doc_freq[term] + 0.5)
)
score += idf * (vtf * (self.k1 + 1)) / (vtf + self.k1)
if score > 0:
results.append((doc_id, score))
results.sort(key=lambda x: x[1], reverse=True)
return results[:top_k]Let's test with structured documents:
PythonIn [26]Expand
# Structured documents with title and body
structured_docs = [
{
"title": "Introduction to Python Programming",
"body": "This guide covers the basics of writing code in a popular scripting language.",
},
{
"title": "Advanced Java Development",
"body": "Learn enterprise patterns and best practices for building robust Python applications.",
},
{
"title": "Web Development Fundamentals",
"body": "Build modern websites using Python frameworks like Django and Flask.",
},
{
"title": "Python for Data Science",
"body": "Use Python libraries like pandas and numpy for data analysis and machine learning.",
},
]
# Index with title weighted 3x more than body
bm25f_index = BM25FIndex(field_weights={"title": 3.0, "body": 1.0})
bm25f_index.index(structured_docs)
# Search
bm25f_results = bm25f_index.search("python programming", top_k=4)ConsoleOut [27]Expand
Query: "python programming" Field weights: title=3.0, body=1.0 ------------------------------------------------------------
The results demonstrate how field weights influence ranking. Document 1 ranks highest because both "Python" and "Programming" appear in its title, which carries 3x the weight of the body field. Document 2 mentions "Python" only in the body text despite having "Development" in the title, so it ranks lower. This shows how BM25F can prioritize matches in more important fields, which is exactly the behavior you want in a product search or document retrieval system where title relevance means more than body relevance.
BM25 vs TF-IDF: An Empirical Comparison
How much does BM25's sophistication matter? Let's compare it directly against TF-IDF on a retrieval task.
PythonIn [28]Expand
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
# Larger test corpus
test_corpus = [
"Machine learning algorithms learn patterns from data",
"Deep learning uses neural networks with many layers",
"Natural language processing enables computers to understand text",
"Computer vision allows machines to interpret images",
"Reinforcement learning trains agents through rewards and penalties",
"Supervised learning requires labeled training examples",
"Unsupervised learning finds patterns without labels",
"Transfer learning reuses knowledge from one task to another",
"Neural networks are inspired by biological neurons",
"Gradient descent optimizes model parameters iteratively",
"Backpropagation computes gradients for neural network training",
"Convolutional networks excel at image recognition tasks",
"Recurrent networks process sequential data like text",
"Attention mechanisms help models focus on relevant inputs",
"Transformers revolutionized natural language processing",
"BERT provides contextual word embeddings for NLP tasks",
"GPT generates coherent text using autoregressive modeling",
"Word embeddings represent words as dense vectors",
"Sentiment analysis determines the emotional tone of text",
"Named entity recognition identifies people places and organizations",
]
# Build BM25 index
bm25_index = BM25Index(k1=1.2, b=0.75)
bm25_index.index(test_corpus)
# Build TF-IDF index
tfidf_vectorizer = TfidfVectorizer()
tfidf_matrix = tfidf_vectorizer.fit_transform(test_corpus)
def tfidf_search(query, top_k=5):
"""Search using TF-IDF cosine similarity."""
query_vec = tfidf_vectorizer.transform([query])
similarities = cosine_similarity(query_vec, tfidf_matrix)[0]
top_indices = similarities.argsort()[::-1][:top_k]
return [
(idx, similarities[idx]) for idx in top_indices if similarities[idx] > 0
]Now let's compare rankings for several queries:
PythonIn [29]Expand
comparison_queries = [
"neural network training",
"text processing language",
"learning from examples",
"image recognition deep",
]
comparison_results = []
for query in comparison_queries:
bm25_results = bm25_index.search(query, top_k=5)
tfidf_results = tfidf_search(query, top_k=5)
comparison_results.append(
{"query": query, "bm25": bm25_results, "tfidf": tfidf_results}
)ConsoleOut [30]Expand
Query: "neural network training" ====================================================================== BM25 Results: 1. [6.2469] Backpropagation computes gradients for neural network t... 2. [2.1479] Supervised learning requires labeled training examples... 3. [1.6279] Neural networks are inspired by biological neurons... TF-IDF Results: 1. [0.6237] Backpropagation computes gradients for neural network t... 2. [0.2194] Supervised learning requires labeled training examples... 3. [0.1636] Neural networks are inspired by biological neurons... Query: "text processing language" ====================================================================== BM25 Results: 1. [5.0717] Natural language processing enables computers to unders... 2. [4.5748] Transformers revolutionized natural language processing... 3. [1.3142] Recurrent networks process sequential data like text... TF-IDF Results: 1. [0.5659] Natural language processing enables computers to unders... 2. [0.5165] Transformers revolutionized natural language processing... 3. [0.1519] Recurrent networks process sequential data like text... Query: "learning from examples" ====================================================================== BM25 Results: 1. [3.6137] Supervised learning requires labeled training examples... 2. [2.8361] Machine learning algorithms learn patterns from data... 3. [2.5437] Transfer learning reuses knowledge from one task to ano... TF-IDF Results: 1. [0.4163] Supervised learning requires labeled training examples... 2. [0.3313] Machine learning algorithms learn patterns from data... 3. [0.2836] Transfer learning reuses knowledge from one task to ano... Query: "image recognition deep" ====================================================================== BM25 Results: 1. [4.6189] Convolutional networks excel at image recognition tasks... 2. [2.4534] Deep learning uses neural networks with many layers... 3. [1.9145] Named entity recognition identifies people places and o... TF-IDF Results: 1. [0.4320] Convolutional networks excel at image recognition tasks... 2. [0.2347] Deep learning uses neural networks with many layers... 3. [0.1689] Named entity recognition identifies people places and o...
The rankings often agree on the top result, but there are subtle differences in how the methods order subsequent results. BM25's saturation effect means that a document with many occurrences of a query term doesn't dominate as much as it would with raw TF-IDF. The length normalization also helps ensure shorter, focused documents aren't overwhelmed by longer ones. Notice how the score scales differ significantly: BM25 scores can exceed 1.0, while TF-IDF cosine similarities are bounded between 0 and 1.
Let's visualize the score distributions:
PythonIn [31]Expand
# Get all scores for a specific query
query = "neural network learning"
bm25_all_scores = []
tfidf_all_scores = []
query_terms = bm25_index.tokenize(query)
query_vec = tfidf_vectorizer.transform([query])
tfidf_sims = cosine_similarity(query_vec, tfidf_matrix)[0]
for doc_id in range(len(test_corpus)):
bm25_score_val = bm25_index.score_document(doc_id, query_terms)
bm25_all_scores.append(bm25_score_val)
tfidf_all_scores.append(tfidf_sims[doc_id])VisualizationsOut [32]Expand


BM25 scores tend to have a wider dynamic range with clearer separation between relevant and non-relevant documents. TF-IDF cosine similarities are bounded between 0 and 1, which can make it harder to distinguish between moderately relevant documents. This separation is important in practice: when you're taking the top-10 results out of millions, you want the scoring function to clearly distinguish the winner from the also-rans.
Using BM25 in Practice
While understanding BM25 internals is valuable, in practice you'll often use existing implementations. The rank_bm25 library provides a simple, efficient implementation:
PythonIn [33]Expand
# Install if needed: uv pip install rank-bm25
from rank_bm25 import BM25Okapi
# Tokenize corpus
tokenized_corpus = [doc.lower().split() for doc in test_corpus]
# Create BM25 index
bm25 = BM25Okapi(tokenized_corpus)
# Search
query = "neural network training optimization"
tokenized_query = query.lower().split()
scores = bm25.get_scores(tokenized_query)ConsoleOut [34]Expand
Query: "neural network training optimization" Top 5 results using rank_bm25: ------------------------------------------------------------ 1. [Score: 6.2540] Backpropagation computes gradients for neural network training 2. [Score: 2.1638] Supervised learning requires labeled training examples 3. [Score: 1.6298] Neural networks are inspired by biological neurons 4. [Score: 1.5328] Deep learning uses neural networks with many layers 5. [Score: 0.0000] Named entity recognition identifies people places and organizations
The library returns results consistent with our from-scratch implementation, correctly identifying documents about neural networks and training. The rank_bm25 library handles tokenization and indexing, then performs scoring internally, making it convenient for rapid prototyping.
For production search systems, Elasticsearch and OpenSearch use BM25 as their default scoring algorithm. They also support BM25F for multi-field documents out of the box. Elasticsearch exposes the k1 and b parameters directly in the index mapping settings, letting you tune them without any code changes.
BM25 in Elasticsearch
In Elasticsearch, BM25 is configured per-field in the index mapping. Here's how you'd set custom parameters:
PythonIn [55]Expand
# Elasticsearch index mapping with custom BM25 parameters
mapping = {
"settings": {
"index": {
"similarity": {"custom_bm25": {"type": "BM25", "k1": 1.5, "b": 0.6}}
}
},
"mappings": {
"properties": {
"title": {"type": "text", "similarity": "custom_bm25"},
"body": {
"type": "text",
"similarity": "BM25", # Default parameters
},
}
},
}The typical workflow for tuning BM25 in Elasticsearch starts with the defaults, then adjusts based on the characteristics of your collection. If your documents are very uniform in length (like product descriptions), you might lower toward 0.5. If your collection has a lot of spam or keyword stuffing, you might lower toward 1.0 to apply more aggressive saturation.
Limitations and Modern Context
BM25 works well in practice, but it has fundamental limitations that become apparent as soon as you push beyond exact keyword matching.
The most significant limitation is vocabulary mismatch. BM25 only matches exact terms. If your query says "car" but the document says "automobile," BM25 won't find the connection. This is not a minor edge case: it's a fundamental restriction of lexical matching that affects every retrieval task. Users phrase queries in their own words, which may not match the words authors chose when writing documents. Synonyms and paraphrases create vocabulary gaps, as do abbreviations and spelling variants that BM25 cannot bridge.
Closely related is the lack of semantic understanding. "Bank of the river" and "bank account" would be treated identically by BM25: it sees the tokens, not the meaning. A query about financial institutions would retrieve documents about riverbanks if they happen to use the word "bank" heavily. This kind of lexical ambiguity is pervasive in natural language, and BM25 has no mechanism to resolve it.
The bag-of-words assumption also discards word order entirely. "Dog bites man" scores the same as "Man bites dog" for a query about dogs and men, despite having very different meanings. Phrase queries are a partial workaround (Elasticsearch supports them with match_phrase), but they require exact contiguous matches and don't generalize to paraphrases.
Finally, BM25 does not capture relationships between query terms. A query for "machine learning" is treated as two independent queries for "machine" and "learning." A document about industrial machinery that also happens to discuss learning processes might score well, even though it's irrelevant to the query's intent.
Despite these limitations, BM25 remains widely used because of several practical advantages:
- Speed: BM25 requires only sparse vector operations over the inverted index, making it extremely fast even on billion-document collections.
- Interpretability: You can explain exactly why a document ranked where it did. The score is a sum of per-term contributions, each traceable back to the formula.
- No training required: BM25 works out of the box on any text collection without labeled examples or GPU compute.
- Handling rare terms: Neural retrievers struggle with rare or unseen terms (out-of-vocabulary words). BM25 handles them naturally since it only requires the term to appear in the index.
Modern systems often combine BM25 with neural retrievers through hybrid search, getting the best of both worlds.
A retrieval strategy that combines lexical matching (like BM25) with semantic matching (like dense embeddings). BM25 handles exact matches and rare terms well, while embeddings capture semantic similarity. The two scores are combined with a weighted sum or a re-ranking step.
The typical hybrid pipeline works as follows: BM25 retrieves a first-stage set of candidates (say, the top 100 or 1000 documents), then a neural re-ranker like a cross-encoder re-scores those candidates and selects the final top-10. This is much cheaper than running a neural model over the entire collection, while still allowing semantic understanding in the final ranking stage. Elasticsearch and Vespa have native support for hybrid retrieval with BM25 plus dense vectors; Weaviate does as well.
Another emerging pattern is to use BM25 alongside dense retrievers in parallel, then fuse their rankings with a technique called Reciprocal Rank Fusion (RRF). Each system contributes its rankings independently, and the fusion step combines them without needing to calibrate the score scales. This sidesteps the difficult problem of making BM25 scores and cosine similarities comparable.
Summary
BM25 represents the high point of classical information retrieval. Its design embodies three carefully motivated ideas: terms should be weighted by how informative they are (IDF), additional occurrences provide diminishing returns (saturation), and longer documents deserve proportional scrutiny (length normalization). Each parameter in the formula was validated empirically across many test collections, which is why the defaults work well in practice.
The key takeaways from this chapter:
- Probabilistic foundation: BM25 derives from the Binary Independence Model and the 2-Poisson model, giving it a principled basis that TF-IDF lacks.
- Term frequency saturation: The parameter ensures diminishing returns from repeated terms, with typical values between 1.2 and 2.0.
- Length normalization: The parameter prevents long documents from unfairly dominating, with 0.75 as the empirically validated default.
- IDF weighting: Rare terms receive higher weight, and terms appearing in more than half the collection receive negative IDF in the BM25 formulation.
- Variants exist: BM25L and BM25+ fix edge cases with short documents, while BM25F handles structured documents with multiple fields.
The standard parameters (, ) work well across most collections, though tuning can help for specific domains. Decreasing suppresses keyword stuffing; decreasing helps when document length variation is meaningful. BM25+ or BM25L becomes worth considering when your collection has extreme length variation.
BM25's lasting influence is evident in its continued use in Elasticsearch and Solr, among other search engines. Even as neural retrievers have emerged, BM25 often is the first-stage retriever or as part of hybrid systems where exact lexical matching complements semantic understanding. Understanding BM25 gives you insight into what makes search work at a fundamental level, and why modern approaches needed to go beyond lexical matching to achieve true semantic understanding of language.
Key Parameters
Understanding BM25's parameters is essential for tuning retrieval performance:
| Parameter | Typical Value | Range | Effect |
|---|---|---|---|
k1 | 1.2 | 0.0 - 3.0 | Controls term frequency saturation. Lower values cause faster saturation. Higher values make scoring more linear with term frequency. |
b | 0.75 | 0.0 - 1.0 | Controls length normalization strength. At 0, document length is ignored. At 1, term frequencies are fully normalized by document length. |
delta (BM25L/BM25+) | 0.5 - 1.0 | 0.0 - 2.0 | Additive constant ensuring minimum contribution from term matches. Higher values boost scores for documents with query terms. |
Tuning guidance:
- Start with defaults (, ): These work well for most text collections and are the defaults in Elasticsearch and Lucene.
- Increase (toward 2.0) when term frequency is highly informative, such as in longer documents where repeated mentions indicate relevance.
- Decrease (toward 0.5) for collections where a single mention is as informative as multiple mentions, such as short documents or when keyword stuffing is a concern.
- Decrease (toward 0.25) when document length variation is meaningful, for example, when longer documents contain more relevant information rather than just more filler.
- Increase (toward 1.0) when longer documents tend to match queries spuriously due to containing more vocabulary.
- Use BM25+ or BM25L when your collection has highly variable document lengths and you observe short, relevant documents being ranked too low.
BM25 Knowledge Check
Join the discussionAsk a question or share what you learned.
Keep exploring
Reference
Citation details
Cite or share this article.


Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!