0Pricing
AI Engineering Academy · Leçon

Implémenter une recherche par mots-clés avec BM25

Configurez BM25 avec rank_bm25 en Python, indexez votre corpus de documents et exécutez des recherches par mots-clés qui traitent de manière fiable les termes exacts, le jargon technique et les noms de produits.

Implémenter une recherche par mots-clés avec BM25 est une leçon AI Engineering Academy gratuite sur CoddyKit. Ceci est la leçon 2 sur 4. Tu peux lire la leçon complète ci-dessous gratuitement — puis la pratiquer en direct dans le navigateur avec un éditeur de code intégré et un tuteur IA 24/7. Elle fait partie du parcours d'apprentissage AI Engineering Academy, et ta progression se synchronise sur le web et l'application CoddyKit. Le cours AI Engineering Academy comprend 4 leçons au total.

Certaines parties de cette leçon n'ont pas encore été traduites et s'affichent en anglais.

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 production

Handling 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.

Questions Fréquemment Posées

La leçon « Implémenter une recherche par mots-clés avec BM25 » est-elle gratuite ?

Oui — le texte complet de « Implémenter une recherche par mots-clés avec BM25 » est gratuit à lire ici sur le web. Pour la pratiquer de manière interactive (un éditeur de code intégré et un tuteur IA 24/7) et déverrouiller le reste du cours AI Engineering Academy, passe à CoddyKit PRO. Le cours AI Engineering Academy comprend 4 leçons au total.

Qu'est-ce que j'apprendrai dans « Implémenter une recherche par mots-clés avec BM25 » ?

Configurez BM25 avec rank_bm25 en Python, indexez votre corpus de documents et exécutez des recherches par mots-clés qui traitent de manière fiable les termes exacts, le jargon technique et les noms… Tu pratiques AI Engineering Academy avec du code pratique que tu exécutes directement dans le navigateur, et un tuteur IA 24/7 répond à tes questions au fur et à mesure que tu avances dans la leçon.

Dois-je avoir de l'expérience pour commencer AI Engineering Academy ?

Aucune expérience préalable n'est requise. AI Engineering Academy sur CoddyKit est structuré pour les débutants jusqu'aux apprenants avancés, donc tu peux commencer ici ou depuis le début et avancer à ton rythme. Ceci est la leçon 2 sur 4.

Combien de temps prend la leçon « Implémenter une recherche par mots-clés avec BM25 » ?

La plupart des leçons CoddyKit prennent environ 5–10 minutes. Chacune est courte et interactive, tu progresses régulièrement et tu repiques exactement où tu t'es arrêté sur le web et l'app.

Peux-tu écrire et exécuter du code dans cette leçon AI Engineering Academy ?

Oui. Chaque leçon AI Engineering Academy inclut un éditeur de code intégré, tu écris et exécutes du vrai code directement dans ton navigateur et tu reçois des retours IA instantanés — aucune configuration locale requise.

Toutes les leçons de ce cours

  1. Recherche dense ou creuse : compromis
  2. Implémenter une recherche par mots-clés avec BM25
  3. Fusion réciproque des rangs pour combiner les scores
  4. Recherche hybride avec Pinecone et pgvector
← Retour à AI Engineering Academy