AI Engineering Academy · レッスン

BM25キーワード検索を実装する

Pythonでrank_bm25を使ってBM25を設定し、ドキュメントコーパスをインデックス化して、完全一致する語、専門用語、製品名を確実に扱えるキーワード検索を実行します。

レッスン 2/413 ステップ

「BM25キーワード検索を実装する」はCoddyKit上の無料AI Engineering Academyレッスンです。 これはレッスン2/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはAI Engineering Academy学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 AI Engineering Academyコースには全4レッスンが含まれています。

rank_bm25 のインストール

rank_bm25 は、BM25 アルゴリズムの BM25Okapi、BM25L、BM25Plus の各バリアントを提供する軽量な Python ライブラリです。外部サービスは必要なく、すべてメモリ上で動作し、一般的なハードウェア上で数千件のドキュメントを数秒でインデックス化できます。pip install rank-bm25 でインストールすれば、インフラストラクチャを構築せずにキーワード検索を作成できます。

# 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')

トークン化:最初に行う重要なステップ

BM25 が扱うのは生の文字列ではなく、トークンのリストです。トークン化の品質は、検索品質に直接影響します。単純に空白で分割するだけでは、句読点の除去、ステミング、ストップワードの除去に対応できません。本番システムでは、テキストを小文字化し、句読点とストップワードを除去し、必要に応じてステミングを適用して、'run'、'runs'、'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']

BM25 インデックスの構築

BM25 インデックスの作成は、一度だけ実行するオフライン処理です。トークン化したコーパスを BM25Okapi に渡すと、すべての用語について逆文書頻度を計算し、正規化に使用するドキュメント長を保存します。インデックスは軽量で、数万件のドキュメントであっても数メガバイト程度です。コーパスに新しいドキュメントを追加したときは、インデックスを再構築してください。

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')

BM25 検索の実行

検索するときは、インデックスの作成時と同じトークナイザーを使ってクエリをトークン化してください。トークン化の不一致は、検索品質が低下する一般的な原因です。get_scores を呼び出すと全ドキュメントの関連度スコアを取得でき、get_top_n を使うと上位 N 件の結果を直接取得できます。インデックス作成とクエリ処理では、必ず同じ前処理パイプラインを使用してください。

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]}")

BM25 ハイパーパラメーターの調整

BM25Okapi は 2 つのハイパーパラメーターを受け取ります。k1 は語の出現頻度の飽和を制御し、値を大きくすると出現頻度の高い語に高いスコアを付けられます。b はドキュメント長の正規化を制御します(1.0 = 完全な正規化、0.0 = 正規化なし)。文章には k1=1.5, b=0.75 のデフォルト値が適しています。短いチャンク(100 語未満)では、b を 0.3 のように小さくして、長さによる偏りを抑えてみてください。

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

技術用語とコードトークンの扱い

コードベースや技術文書では、トークナイザーで積極的にステミングするのではなく、技術トークンを保持する必要があります。BM25Okapi、pgvector、LLM のような用語は、そのまま維持してください。大文字の頭字語、CamelCase、snake_case の識別子のようなパターンに一致するトークンについてはステミングを省略するハイブリッドトークナイザーを使うと、開発者向け検索の結果が向上します。

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']

BM25 インデックスの永続化

アプリケーションの再起動時に再インデックス化のコストが発生しないよう、BM25 インデックスはディスクに永続化してください。rank_bm25 のオブジェクトは通常の Python オブジェクトなので、pickle でシリアライズできます。大規模なコーパスでは、スコアリング後にテキストを取得できるよう、インデックスと元のドキュメントリストの両方を保存してください。pickle ファイルは信頼できない入力に対して安全ではないため、機密データを保存しないでください。

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')

インデックスの増分更新

BM25 は増分更新をサポートしていません。新しいドキュメントが到着したら、インデックス全体を再構築する必要があります。頻繁に変化するコーパスでは、バッチ更新が現実的な解決策です。一定期間、新しいドキュメントを収集してから、クリティカルパス外でインデックスを再構築します。一方のインデックスでライブトラフィックを処理している間に、もう一方を再構築し、完了後にアトミックに切り替えるダブルバッファリングパターンを使用してください。

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)

BM25 と LangChain の統合

LangChain には、BM25 検索を標準のリトリーバーインターフェースに統合する BM25Retriever ラッパーが用意されています。これにより、LCEL チェーン内で BM25 をそのまま使えるコンポーネントとして利用したり、EnsembleRetriever を使ってベクトルリトリーバーと組み合わせたりできます。weights パラメーターは、最終的な順位において BM25 と密検索リトリーバーのどちらをどの程度重視するかを制御します。

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])

BM25 の品質評価

BM25 の検索品質を測定するには、クエリと既知の関連ドキュメントを対応付けたゴールドデータセットを作成します。K 件時点のヒット率(関連ドキュメントが上位 K 件に含まれるかどうか)と、MRR(平均逆順位)を計算します。同じテストセットで密検索の数値と比較し、ハイブリッドシステムにおける最適な重み付けを決定してください。

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%}')

大規模環境での本番 BM25

数百万件のドキュメントを含むコーパスでは、純粋な Python 実装の rank_bm25 は遅すぎます。本番規模の BM25 は、Elasticsearch と OpenSearch(どちらも BM25 をデフォルトのスコアリング関数として使用)、Typesense、および Qdrant の疎ベクトルモードで利用できます。これらのシステムはディスク上に転置インデックスを保持し、部分更新をサポートし、インデックス全体を再構築することなく同時実行されるクエリを処理できます。

クイックチェック

このレッスンで学んだ、BM25 キーワード検索の実装についての理解度を確認しましょう。

レッスンのまとめ

このレッスンでは、次のことを学びました。rank_bm25 は、トークン化された入力を必要とするメモリ上の BM25 インデックスを提供します。インデックス作成時とクエリ処理時の一貫したトークン化は、正確なスコアリングに不可欠です。また、ハイパーパラメーター k1 と b は、ドキュメント長の分布に合わせて調整できます。大規模な本番環境では、メモリ上の BM25 ではなく Elasticsearch または OpenSearch を使用してください。次は、BM25 と密検索の結果を統合する逆順位融合を実装します。

無料で開始

AI チューターと学ぶ Python — 無料

ブラウザでリアルコードを書いて実行し、24/7 の AI チューターから瞬時にサポートを受け、ウェブまたはアプリで続きから学習できます。

コース
30
レッスン
120

よくある質問

「BM25キーワード検索を実装する」レッスンは無料ですか?

はい。「BM25キーワード検索を実装する」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、AI Engineering Academyコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 AI Engineering Academyコースには全4レッスンが含まれています。

「BM25キーワード検索を実装する」で何を学びますか?

Pythonでrank_bm25を使ってBM25を設定し、ドキュメントコーパスをインデックス化して、完全一致する語、専門用語、製品名を確実に扱えるキーワード検索を実行します。 ブラウザで直接実行するハンズオンコードでAI Engineering Academyを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

AI Engineering Academyを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのAI Engineering Academyは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン2/4です。

「BM25キーワード検索を実装する」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このAI Engineering Academyレッスンでコードを書いて実行できますか?

はい。すべてのAI Engineering Academyレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. 密検索と疎検索の比較:トレードオフ
  2. BM25キーワード検索を実装する
  3. スコア統合のための逆順位融合
  4. Pineconeとpgvectorでハイブリッド検索
← AI Engineering Academyに戻る