0Pricing
AI Engineering Academy · บทเรียน

การสร้างการค้นหาด้วยคำสำคัญ BM25

ตั้งค่า BM25 ด้วย rank_bm25 ใน Python สร้างดัชนีคลังเอกสาร และเรียกใช้การค้นหาด้วยคำสำคัญที่รองรับคำตรงตัว ศัพท์เทคนิค และชื่อผลิตภัณฑ์ได้อย่างน่าเชื่อถือ

การสร้างการค้นหาด้วยคำสำคัญ BM25 เป็นบทเรียน AI Engineering Academy ฟรีบน CoddyKit นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 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” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส AI Engineering Academy ให้อัปเกรดเป็น CoddyKit PRO คอร์ส AI Engineering Academy มีบทเรียนทั้งหมด 4 บทเรียน

คุณจะเรียนรู้อะไรในบทเรียน “การสร้างการค้นหาด้วยคำสำคัญ BM25”

ตั้งค่า BM25 ด้วย rank_bm25 ใน Python สร้างดัชนีคลังเอกสาร และเรียกใช้การค้นหาด้วยคำสำคัญที่รองรับคำตรงตัว ศัพท์เทคนิค และชื่อผลิตภัณฑ์ได้อย่างน่าเชื่อถือ คุณปฏิบัติ AI Engineering Academy ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน

คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน AI Engineering Academy หรือไม่

ไม่จำเป็นต้องมีประสบการณ์มาก่อน AI Engineering Academy บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน

บทเรียน “การสร้างการค้นหาด้วยคำสำคัญ BM25” ใช้เวลานานแค่ไหน

บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย

ฉันเขียนและรันโค้ดในบทเรียน AI Engineering Academy นี้ได้ไหม

ได้ บทเรียน AI Engineering Academy ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ

บทเรียนทั้งหมดในหลักสูตรนี้

  1. การดึงข้อมูลแบบหนาแน่นเทียบกับแบบเบาบาง: จุดแลกเปลี่ยน
  2. การสร้างการค้นหาด้วยคำสำคัญ BM25
  3. การรวมอันดับแบบผกผันเพื่อผสานคะแนน
  4. การค้นหาแบบผสมใน Pinecone และ pgvector
← กลับไปที่ AI Engineering Academy