BM25 › Implementing BM25 from Scratch
Language AI HandbookAll chapters BM25 › Implementing BM25 from ScratchOne section · Sections in this chapter6 / 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
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:
# 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:
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:
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:
# 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] = resultsQuery: "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.