Implementing BM25 Keyword Search
Set up BM25 using rank_bm25 in Python, index your document corpus, and run keyword searches that handle exact terms, technical jargon, and product names reliably.
Implementing BM25 Keyword Search is a free AI Engineering Academy lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the AI Engineering Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Installing rank_bm25
rank_bm25 is a lightweight Python library that provides BM25Okapi, BM25L, and BM25Plus variants of the BM25 algorithm. It requires no external services, runs entirely in memory, and can index thousands of documents in seconds on commodity hardware. Install it with pip install rank-bm25 and you are ready to build keyword search without any infrastructure setup.
# Install: pip install rank-bm25
from rank_bm25 import BM25Okapi
# BM25Okapi is the most common variant
# BM25L and BM25Plus handle very short documents better
# For most RAG use cases BM25Okapi is the right choice
corpus = [
'Python decorator pattern explained with examples',
'How to use context managers in Python',
'JavaScript async await tutorial',
]
tokenized = [doc.lower().split() for doc in corpus]
bm25 = BM25Okapi(tokenized)
print('Index built with', len(corpus), 'documents')Tokenization: The Critical First Step
BM25 operates on token lists, not raw strings. The quality of your tokenization directly impacts retrieval quality. Simple whitespace splitting misses punctuation stripping, stemming, and stop word removal. For production systems, use a proper tokenizer that lowercases text, removes punctuation, strips stop words, and optionally applies stemming to match morphological variants like 'run', 'runs', and 'running'.
import re
from nltk.corpus import stopwords
from nltk.stem import PorterStemmer
STOP_WORDS = set(stopwords.words('english'))
stemmer = PorterStemmer()
def tokenize(text: str) -> list[str]:
text = text.lower()
text = re.sub(r'[^a-z0-9\s]', ' ', text)
tokens = text.split()
tokens = [t for t in tokens if t not in STOP_WORDS and len(t) > 1]
tokens = [stemmer.stem(t) for t in tokens]
return tokens
print(tokenize('Running Python decorators efficiently in production!'))
# ['run', 'python', 'decor', 'effici', 'product']Building the BM25 Index
Creating a BM25 index is a one-time offline operation. You pass the tokenized corpus to BM25Okapi and it computes inverse document frequencies for all terms and stores document lengths for normalization. The index is lightweight — a few megabytes even for tens of thousands of documents. You should rebuild it whenever new documents are added to your corpus.
from rank_bm25 import BM25Okapi
def build_bm25_index(documents: list[str]):
tokenized = [tokenize(doc) for doc in documents]
bm25 = BM25Okapi(tokenized)
return bm25, tokenized
# Example with a small corpus
docs = [
'Vector databases store dense embeddings for similarity search',
'BM25 is a sparse keyword retrieval algorithm used in search engines',
'Hybrid search combines dense and sparse retrieval for better recall',
'PostgreSQL supports vector search via the pgvector extension',
]
bm25, tokenized = build_bm25_index(docs)
print(f'Index contains {bm25.corpus_size} documents')Performing a BM25 Search
To search, tokenize the query using the same tokenizer as the index — inconsistent tokenization is a common source of poor retrieval. Call get_scores to get relevance scores for all documents, or get_top_n to retrieve the top N results directly. Always use the same preprocessing pipeline for both indexing and querying.
def bm25_search(bm25, documents: list[str], query: str, top_k: int = 3):
query_tokens = tokenize(query)
scores = bm25.get_scores(query_tokens)
# Get indices sorted by score descending
ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)
results = []
for idx, score in ranked[:top_k]:
results.append({
'document': documents[idx],
'score': round(score, 4),
'rank': len(results) + 1,
})
return results
results = bm25_search(bm25, docs, 'sparse keyword search engine')
for r in results:
print(f"Rank {r['rank']} (score {r['score']}): {r['document'][:60]}")Tuning BM25 Hyperparameters
BM25Okapi accepts two hyperparameters: k1 controls term frequency saturation (higher values let high-frequency terms score higher) and b controls document length normalization (1.0 = full normalization, 0.0 = no normalization). Defaults of k1=1.5, b=0.75 work well for prose. For short chunks (under 100 words), try lower b values like 0.3 to reduce length bias.
from rank_bm25 import BM25Okapi
# Default hyperparameters — good starting point
bm25_default = BM25Okapi(tokenized, k1=1.5, b=0.75)
# Tuned for short document chunks
bm25_short = BM25Okapi(tokenized, k1=1.2, b=0.3)
# Tuned for long documents
bm25_long = BM25Okapi(tokenized, k1=2.0, b=0.9)
# Always benchmark hyperparameters against a golden eval set
# before deploying to productionHandling Technical Jargon and Code Tokens
For codebases and technical documentation, your tokenizer should preserve technical tokens rather than aggressively stemming them. Terms like BM25Okapi, pgvector, and LLM should remain intact. A hybrid tokenizer that skips stemming for tokens matching patterns like uppercase acronyms, CamelCase, or snake_case identifiers will produce better results for developer-facing search.
import re
def technical_tokenize(text: str) -> list[str]:
text = text.lower()
# preserve underscores in snake_case and dots in version numbers
text = re.sub(r'[^a-z0-9_.\s]', ' ', text)
tokens = text.split()
# keep tokens that look like identifiers (contain _ or .)
tokens = [
t for t in tokens
if len(t) > 1 and t not in STOP_WORDS
]
return tokens
print(technical_tokenize('Install pgvector 0.5.1 extension in PostgreSQL 16'))
# ['pgvector', '0.5.1', 'extension', 'postgresql', '16']Persisting the BM25 Index
BM25 indices should be persisted to disk between application restarts to avoid re-indexing costs. Since rank_bm25 objects are plain Python, you can serialize them with pickle. For larger corpora, save both the index and the original document list so you can retrieve the text after scoring. Never store sensitive data in pickle files as they are not secure against untrusted input.
import pickle
def save_bm25_index(bm25, documents: list[str], path: str):
with open(path, 'wb') as f:
pickle.dump({'bm25': bm25, 'documents': documents}, f)
print(f'Index saved to {path}')
def load_bm25_index(path: str):
with open(path, 'rb') as f:
data = pickle.load(f)
return data['bm25'], data['documents']
save_bm25_index(bm25, docs, '/tmp/bm25_index.pkl')
bm25_loaded, docs_loaded = load_bm25_index('/tmp/bm25_index.pkl')Incremental Index Updates
BM25 does not support incremental updates — you must rebuild the entire index when new documents arrive. For corpora that change frequently, batch updates are the practical solution: collect new documents over a time window, then rebuild the index off the critical path. Use a double-buffering pattern where one index serves live traffic while the other is being rebuilt, then swap them atomically.
import threading
class SwappableBM25Index:
def __init__(self):
self._index = None
self._docs = []
self._lock = threading.RLock()
def rebuild(self, new_docs: list[str]):
tokenized = [tokenize(d) for d in new_docs]
new_index = BM25Okapi(tokenized)
with self._lock:
self._index = new_index
self._docs = new_docs
print(f'Index rebuilt with {len(new_docs)} documents')
def search(self, query: str, top_k: int = 5):
with self._lock:
return bm25_search(self._index, self._docs, query, top_k)Integrating BM25 with LangChain
LangChain provides a BM25Retriever wrapper that integrates BM25 search into a standard retriever interface. This lets you use BM25 as a drop-in component within LCEL chains and combine it with vector retrievers using EnsembleRetriever. The weights parameter controls how much influence BM25 versus the dense retriever has on the final ranking.
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
from langchain_core.documents import Document
langchain_docs = [Document(page_content=d) for d in docs]
bm25_retriever = BM25Retriever.from_documents(langchain_docs)
bm25_retriever.k = 5
# Combine with a vector retriever (assuming vector_retriever is already defined)
# ensemble = EnsembleRetriever(
# retrievers=[bm25_retriever, vector_retriever],
# weights=[0.4, 0.6], # 40% BM25, 60% dense
# )
results = bm25_retriever.invoke('sparse keyword search')
for doc in results:
print(doc.page_content[:80])Evaluating BM25 Quality
To measure BM25 retrieval quality, create a golden dataset of queries paired with their known relevant documents. Compute hit rate at K (whether the relevant document appears in the top K results) and MRR (mean reciprocal rank). Compare these numbers against dense retrieval on the same test set to decide the optimal weighting in your hybrid system.
def hit_rate_at_k(bm25, documents, queries, relevant_docs, k=5):
hits = 0
for query, relevant in zip(queries, relevant_docs):
results = bm25_search(bm25, documents, query, top_k=k)
retrieved = [r['document'] for r in results]
if relevant in retrieved:
hits += 1
return hits / len(queries)
# Example evaluation
test_queries = ['BM25 algorithm', 'hybrid search systems']
test_relevant = [
'BM25 is a sparse keyword retrieval algorithm used in search engines',
'Hybrid search combines dense and sparse retrieval for better recall',
]
hit_rate = hit_rate_at_k(bm25, docs, test_queries, test_relevant, k=3)
print(f'Hit rate @3: {hit_rate:.2%}')Production BM25 at Scale
For corpora with millions of documents, rank_bm25 in pure Python will be too slow. Production-scale BM25 is available in Elasticsearch and OpenSearch (both use BM25 as their default scoring function), Typesense, and Qdrant's sparse vector mode. These systems maintain inverted indexes on disk, support partial updates, and handle concurrent queries without rebuilding the entire index.
Quick Check
Test your understanding of BM25 keyword search implementation from this lesson.
Lesson Recap
In this lesson you learned: rank_bm25 provides an in-memory BM25 index that requires tokenized input, consistent tokenization between indexing and querying is essential for accurate scoring, and hyperparameters k1 and b can be tuned for your specific document length distribution. For production at scale, use Elasticsearch or OpenSearch rather than in-memory BM25. Next up we implement reciprocal rank fusion to merge BM25 and dense retrieval results.
Frequently asked questions
Is the “Implementing BM25 Keyword Search” lesson free?
Yes — the full text of “Implementing BM25 Keyword Search” is free to read here on the web, and the AI Engineering Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the AI Engineering Academy course, upgrade to CoddyKit PRO.
What will I learn in “Implementing BM25 Keyword Search”?
Set up BM25 using rank_bm25 in Python, index your document corpus, and run keyword searches that handle exact terms, technical jargon, and product names reliably. You practise AI Engineering Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start AI Engineering Academy?
No prior experience is required. AI Engineering Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Implementing BM25 Keyword Search” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this AI Engineering Academy lesson?
Yes. Every AI Engineering Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Dense vs Sparse Retrieval: Trade-offs
- Implementing BM25 Keyword Search
- Reciprocal Rank Fusion for Score Merging
- Hybrid Search in Pinecone and pgvector