0Pricing
AI Engineering Academy · درس

تنفيذ البحث بالكلمات المفتاحية باستخدام BM25

أعدّوا BM25 باستخدام rank_bm25 في Python، وفهرسوا مجموعة مستنداتكم، ونفذوا عمليات بحث بالكلمات المفتاحية تتعامل بثبات مع المصطلحات الدقيقة والمصطلحات التقنية وأسماء المنتجات.

تنفيذ البحث بالكلمات المفتاحية باستخدام BM25 درس مجاني في AI Engineering Academy على CoddyKit. هذا هو الدرس 2 من أصل 4. يمكنك قراءة الدرس كاملاً أدناه مجاناً — ثم تمرن عليه مباشرة في المتصفح باستخدام محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7. هذا الدرس جزء من مسار التعلم في AI Engineering Academy، وتقدمك يتزامن عبر الويب وتطبيق CoddyKit. تتضمن دورة AI Engineering Academy 4 دروس في المجموع.

بعض أجزاء هذا الدرس لم تُترجم بعد وتظهر باللغة الإنجليزية.

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.

الأسئلة الشائعة

هل درس «تنفيذ البحث بالكلمات المفتاحية باستخدام BM25» مجاني؟

نعم — نص درس «تنفيذ البحث بالكلمات المفتاحية باستخدام BM25» كامل متاح مجاناً هنا على الويب. لتمرينه بشكل تفاعلي (محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7) وفتح باقي دورة AI Engineering Academy، انتقل إلى CoddyKit PRO. تتضمن دورة AI Engineering Academy 4 دروس في المجموع.

ماذا ستتعلم في «تنفيذ البحث بالكلمات المفتاحية باستخدام BM25»؟

أعدّوا BM25 باستخدام rank_bm25 في Python، وفهرسوا مجموعة مستنداتكم، ونفذوا عمليات بحث بالكلمات المفتاحية تتعامل بثبات مع المصطلحات الدقيقة والمصطلحات التقنية وأسماء المنتجات. تتمرن على AI Engineering Academy مع أكواد عملية تشغلها مباشرة في المتصفح، ومدرس ذكاء اصطناعي متاح 24/7 يجيب على أسئلتك أثناء عملك.

هل أحتاج إلى خبرة سابقة لأبدأ AI Engineering Academy؟

لا تُشترط خبرة سابقة. AI Engineering Academy على CoddyKit منظم للمبتدئين حتى المتقدمين، لذا يمكنك البدء من هنا أو من البداية والتقدم بسرعتك الخاصة. هذا هو الدرس 2 من أصل 4.

كم من الوقت يستغرق درس «تنفيذ البحث بالكلمات المفتاحية باستخدام BM25»؟

معظم دروس CoddyKit تستغرق حوالي 5–10 دقائق. كل منها موجز وتفاعلي، لذا تحرز تقدماً مستمراً وتستأنف من حيث توقفت عبر الويب والتطبيق.

هل يمكنني كتابة وتشغيل أكواد في درس AI Engineering Academy هذا؟

نعم. كل درس في AI Engineering Academy يتضمن محرر أكواد مدمج، لذا تكتب وتشغل أكواداً حقيقية مباشرة في متصفحك وتحصل على تعليقات فورية من الذكاء الاصطناعي — بدون إعداد محلي.

جميع الدروس في هذه الدورة

  1. الاسترجاع الكثيف مقابل المتناثر: المفاضلات
  2. تنفيذ البحث بالكلمات المفتاحية باستخدام BM25
  3. دمج الدرجات باستخدام دمج الرتب التبادلي
  4. البحث الهجين في Pinecone وpgvector
← العودة إلى AI Engineering Academy