rapidbm25 について
rapidbm25は、ローカルのテキストやコード・ドキュメント検索を構築する開発者向けのRustベースBM25検索ライブラリです。rank_bm25互換のスコア計算に加え、増分インデックス、バッチ検索、MCPサーバーを備えています。
主な機能
- 01BM25Okapi、BM25L、BM25Plusのスコア計算
- 02インデックスの増分更新
- 03バッチクエリとtop-n検索
- 04任意のトークナイザーとハイブリッド融合
- 05インデックスの保存と読み込み
活用シーン
- 01ローカルのコードやドキュメントフォルダーの検索
- 02文書の変更に合わせたテキストインデックス更新
- 03BM25とベクトル検索の順位統合
- 04MCPワークフローでのBM25検索
MCP 対応
すぐに実行できるMCPサーバーを含み、MCPクライアントにプロジェクトの検索機能を公開します。
設定
ローカルサーバーの設定はどのクライアントでも共通です。コピーしてクライアントの MCP 設定ファイルに貼り付けてください。
ツール
ローカルサーバーのツールはマシン上で起動して初めて取得できます。メンテナーはまだツール一覧を提供していません。
詳細
rapidbm25
rank_bm25 の BM25Okapi、BM25L、BM25Plus とそのまま置き換えられる、Rust ネイティブの実装です。
同じコンストラクター、同じ属性、ビット単位で完全に一致するスコアを備え、get_scores は約 1,470 倍高速です。
さらに、高速な top_n、保存/読み込み、インクリメンタルインデックス、バッチ検索、
オプションのトークナイザー、ハイブリッドフュージョン、すぐに実行できる MCP サーバー、エージェントスキルも提供します。
インストール
pip install rapidbm25
ビルド済みホイール:Linux(manylinux_2_28 および musllinux_1_2、x86_64 および aarch64)の CPython 3.9~3.15、 macOS(x86_64 10.13 以降、arm64 11.0 以降)、Windows(x86_64)に対応しています。その他のプラットフォームでは sdist からビルドされ、 Rust ツールチェーン(1.85 以降)が必要です。
使い方
# from rank_bm25 import BM25Okapi
from rapidbm25 import BM25Okapi
docs = ["Hello there good man!", "It is quite windy in London", "How is the weather today?"]
bm25 = BM25Okapi([d.split(" ") for d in docs]) # also: tokenizer=..., k1, b, epsilon
query = "windy London".split(" ")
scores = bm25.get_scores(query) # numpy float64, identical to rank_bm25
top = bm25.get_top_n(query, docs, n=1) # ['It is quite windy in London']
ids, best = bm25.top_n(query, n=2) # new: Rust partial select, no full sort
どの rank_bm25?
pip install rank_bm25 でインストールされるのは 0.2.2 であり、その BM25L と BM25Plus はプロジェクトの
後の git main(47aa3dd)とは異なります。0.2.2 の BM25L は項頻度を乗算するため(その語を含まない文書のスコアは 0)、
BM25Plus の idf は ln(N+1)/df ではなく ln(N+1) - ln(df) です。BM25Okapi は同じです。
rapidbm25 はデフォルトで 0.2.2 に一致します。git-main の動作にするには、BM25L/BM25Plus に compat="47aa3dd" を渡してください。
どちらもビット単位でテストされています。
from rapidbm25 import BM25L
BM25L([d.split(" ") for d in docs], compat="47aa3dd")
rank_bm25 と同じ点
- クラス
BM25Okapi(corpus, tokenizer=None, k1=1.5, b=0.75, epsilon=0.25)、BM25L(..., delta=0.5)、BM25Plus(..., delta=1)。 - 属性
corpus_size、avgdl、doc_len、doc_freqs、idf、average_idf(Okapi)、k1、b、epsilon/delta。 - 同じ引数とアサーションを持つ
get_scores、get_batch_scores、get_top_n。 - スコアは、クエリ語の繰り返し、未知語、空の文書、Okapi の負の idf の下限、NaN になるケース(
k1=0、空の文書がある場合のb=1)を含め、ビット単位で一致します。
異なる点
| rank_bm25 | rapidbm25 | |
|---|---|---|
get_top_n の同点時の順序 | numpy の不安定な argsort の逆順(numpy のビルドによって異なります) | スコアの降順、次に doc id の昇順(決定論的) |
| トークン | ハッシュ可能な任意の値 | str(str 以外のコーパストークンでは TypeError が発生します) |
ビルド後に .idf/.doc_freqs を編集 | 後続のスコアが変わります | 影響しません(スコアはネイティブインデックスから取得します) |
tokenizer= | マルチプロセスプールで実行 | プロセス内で実行 |
BM25L/BM25Plus の compat= キーワード | 該当なし | "0.2.2"(デフォルト)または "47aa3dd" |
ピクル化/save/load | pickle のみ | pickle とバージョン付きファイル(読み込み時にコピー。mmap=True はオプトイン) |
fixes= | 該当なし | compat クラスのデフォルトは False(ビット単位で完全一致)。LiveIndex のデフォルトは True |
保存、読み込み、pickle
okapi_ix = BM25Okapi([d.split(" ") for d in docs])
okapi_ix.save("/tmp/rapidbm25-okapi.rbm25")
okapi_loaded = BM25Okapi.load("/tmp/rapidbm25-okapi.rbm25") # copies into memory
save はフォーマット 1 で書き込みます。内容はマジック値 RBM25、JSON ヘッダー、続いてリトルエンディアンの数値列です。
浮動小数点数は IEEE-754 のビット列そのままで保存されるため、load 後の get_scores は保存されたインデックスとビット単位で一致します。
デフォルトの load は列をメモリにコピーするため、後からファイルを切り詰めたりインプレースで編集したりしても、スコアが変わったりプロセスが停止したりすることはありません。
load(mmap=True) は呼び出し元のファイルをマップするため、そのファイルが変更された場合は安全ではありません。
切り詰めると SIGBUS が発生することがあり、インプレースで書き込むとスコアが変わる可能性があります。pickle は pickle 化可能な tokenizer を保持しますが、ファイル形式に保存されるのはインデックス内のトークンであり、任意の呼び出し可能オブジェクトではありません。
ライブインデックス
LiveIndex は新しいクラスです(互換クラスは不変で、ビット単位で完全に一致します)。安定した文字列 ID を割り当て、add、delete、update のたびに N、文書頻度、avgdl、idf を更新し、再構築する代わりに転置リストを更新します。スコアベクトルの順序は挿入順です。update では文書の位置が維持され、delete では空いた位置が詰められます。get_scores は、同じ k1、b、epsilon または delta、compat、fixes を指定して live.corpus() から構築した BM25Okapi/BM25L/BM25Plus と一致します。空のライブインデックスも有効です。LiveIndex では fixes のデフォルトは True、互換クラスでは False です。rank_bm25 の数式だけでなく、バグもそのまま再現するには fixes=False を指定してください。bm25_index_path が使用するフォルダーインデックスは LiveIndex であるため、こちらのデフォルトが適用されます。
from rapidbm25 import LiveIndex, Tokenizer, fuse
words = Tokenizer(lowercase=True, stopwords="english", stemmer="english")
live_ix = LiveIndex(tokenizer=words)
live_ix.add("dogs", "The running dogs chased the ball")
live_ix.add("cats", "A sleeping cat", metadata={"kind": "pet"})
live_ix.add("weather", "It is quite windy in London")
pet_hits = live_ix.search("sleeping cats", k=1, where={"kind": "pet"})
live_ix.save("/tmp/rapidbm25-live.rbm25")
reloaded = LiveIndex.load("/tmp/rapidbm25-live.rbm25")
batch_scores = reloaded.get_scores_many(["dogs", "london wind"])
fused = fuse([["dogs", "cats"], [("cats", 0.9), ("weather", 0.2)]], method="rrf")
where は dict($eq、$ne、$in、$nin、$gt、$gte、$lt、$lte、$exists)または callable です。$in と $nin には文字列ではなくリストを指定します。ブール値は 0 や 1 と一致しません。callable はインデックスのロックの外で実行されます。add/delete/update はロックを取得するため、複数のスレッドで 1 つの LiveIndex を共有できます。
variant は "okapi"(デフォルト)、"l"、または "plus" です。compat は静的クラスと同じく "0.2.2" または "47aa3dd" です。delta のデフォルトは、L では 0.5、Plus では 1 です。
トークナイザー
Tokenizer はオプトイン方式です。Unicode の単語(UAX #29)、任意の小文字化、英語のストップワードリスト(または独自のリスト)、Snowball ステマー(stemmer="english" は Porter2 です。他の Snowball 言語も使用できます)、および Han、ひらがな、カタカナ、ハングルに対する重複する文字バイグラムの生成を行う cjk=True に対応しています。互換クラスでトークナイズを行う唯一の方法は tokenizer= を渡すことです。省略した場合、それらのクラスの動作は変わりません。
バッチ検索
get_scores_many(queries) は (n_queries, corpus_size) の float64 配列を返します。
top_n_many(queries, n) は、クエリごとに (ids, scores) のペアを 1 つ返します。どちらも GIL を解放し、スレッドプールでクエリをスコアリングします。行 i は、単一クエリの呼び出しとビット単位で完全に一致します。LiveIndex にも同じ 2 つのメソッドがあります。
fixes=True
BM25Okapi、BM25L、BM25Plus では、fixes=False がデフォルトで、バグも含めて rank_bm25 と一致します。LiveIndex では fixes=True がデフォルトです。Okapi の IDF の式はどちらの場合も ln(N - df + 0.5) - ln(df + 0.5) のままです。平滑化した式への置き換えはありません。
| issue | fixes=False | fixes=True |
|---|---|---|
| #19 クエリ内のトークンの重複 | 重複するたびにその用語が再度加算されます | その用語は最初に現れた順に 1 回だけ数えられます |
| #36 空のコーパス | 互換クラスで ZeroDivisionError が発生します | 空の静的インデックスとなり、get_scores は形状 (0,) を返します |
| #39 idf がちょうど 0 | 用語がちょうど半数の文書に含まれる場合、Okapi の idf は 0 です | 下限値が正の場合、その idf には既存の epsilon * average_idf の下限値が使われます |
#39 は他の idf を変更しません。また、epsilon * average_idf が 0 以下の場合は適用されません。これは一般的な 2 文書の場合です。各用語が 1 つの文書に出現すると、コーパスの半数に含まれることになり、平均 idf は正の値ではないため、その用語のスコアは引き続き 0 です。BM25L と BM25Plus にはこのゼロはありません。この修正は Okapi のみに適用されます。L と Plus の idf が実際の文書頻度でちょうど 0 になることはありません。
ハイブリッド融合
fuse(rankings, method="rrf", k=60) は、逆順位融合です。計算式は weight / (k + rank) で、順位は 1 から始まります。k=60 がデフォルトです(一般的な Cormack 定数)。入力スコアは無視されるため、BM25 のリストとベクトルストアのリストで共通の尺度を使う必要はありません。method="weighted" は各リストを [0, 1] に最小最大正規化し、重み付き合計を返します。同じ正のスコアが並ぶリストはその重み分を加算し、ゼロのリストは何も加算しません。
インテグレーション
オプションの追加機能です。どちらも LiveIndex を使用する薄いリトリーバーで、from_texts、k、save/load に対応しています。依存関係がインストールされていない場合、テストはスキップされます。
pip install "rapidbm25[langchain]" # langchain-core
pip install "rapidbm25[llama-index]" # llama-index-core
from rapidbm25.integrations.langchain import BM25Retriever
retriever = BM25Retriever.from_texts(["sunny Paris", "windy London"], k=1)
assert retriever.invoke("windy")[0].page_content == "windy London"
BM25Retriever.from_documents は LangChain の Document オブジェクトを受け付けます。
rapidbm25.integrations.llama_index.BM25Retriever は _retrieve を実装し、NodeWithScore ノードを返します。
ベンチマーク
ワークロード:Python 標準ライブラリのソースとドキュメントからなる 16,000 個のチャンク(平均 193 トークン、語彙数 58,073)、保留クエリ 200 件(平均 8.7 トークン)、BM25Okapi。マシン:Intel Xeon(Sapphire Rapids)の KVM ゲスト、8 vCPU、Linux x86_64、CPython 3.13.5、numpy 2.5.4。共有 VM のため、小さな差はノイズとして扱ってください。
5 ラウンド実施し、各ラウンドではすべての実装を新しいプロセスで 1 回ずつ、順番を入れ替えて実行します。中央値です。rapidbm25 の行は、1 プロセスあたり 20 バッチを計測しています。
| 実装 | インデックス構築 | get_scores × 200 | エンドツーエンド × 200(トークン化 + スコア計算 + top-10) |
|---|---|---|---|
| rank_bm25 0.2.2 | 0.433 s | 4.225 s | 4.322 s |
rapidbm25、np.lexsort による top-10 | 0.119 s(3.6×) | 2.87 ms(1,470×) | 53.6 ms(81×) |
rapidbm25、top_n(q, 10) | 0.124 s | 2.90 ms | 16.4 ms(263×) |
np.lexsort を使ったエンドツーエンド処理は、クエリごとに 16,000 件のスコアをソートする処理がボトルネックです。top_n は Rust で部分選択を行うため、3.3× 高速です。
0.2 の保存、増分更新、バッチ検索
python bench/bench_v02.py を 1 プロセスで実行し、ウォームアップ後の 5 回の中央値を計測しました。マシン:Intel Xeon、4 vCPU、Linux x86_64、CPython 3.12.3、リリースビルド。特に記載がない限り、合成コーパスを使用しています。8,000 件のドキュメント、各 48 トークン、語彙数 4,000。保存ファイルのサイズは 7,924,566 バイトです。
| 操作 | n | 中央値 |
|---|---|---|
静的な BM25Okapi の構築 | 8,000 | 10.7 ms |
save | 8,000 | 15.9 ms |
load(mmap=True) + クエリ 1 件 | 8,000 | 2.01 ms |
load(mmap=False) + クエリ 1 件 | 8,000 | 2.81 ms |
すべてのドキュメントを LiveIndex.add | 8,000 | 85.3 ms |
LiveIndex.delete | 800 | 6.82 ms |
| 残ったドキュメントの静的な再構築 | 7,200 | 8.53 ms |
構築済みのインデックスに LiveIndex.add でさらに 200 件を追加 | 200 | 1.57 ms |
| その全セットの静的な再構築 | 8,200 | 9.58 ms |
BM25Okapi(...) の 1 回の呼び出しで 8,000 件のドキュメントを構築するほうが、個別に 8,000 回 add するより高速です。ライブインデックスが作成された後なら、200 件の追加は 1.57 ms で済みます。8,200 件すべてを再構築する場合は 9.58 ms です。また、800 件の削除は 6.82 ms で、残りを再構築する場合の 8.53 ms より短時間で済みます。
バッチ検索の比較も、エンドツーエンド(ループ内の get_scores と get_scores_many)で、5 回の中央値です。
| ワークロード | 順次実行 | get_scores_many | 高速化 |
|---|---|---|---|
| Okapi、8,000×48、語彙数 4,000、クエリ 64 件(短い転置リスト) | 0.331 ms | 1.021 ms | 0.32× |
| Okapi、30,000×40、語彙数 200、クエリ 64 件(長い転置リスト) | 4.94 ms | 3.64 ms | 1.36× |
| BM25Plus、30,000×32、語彙数 4,000、クエリ 48 件(すべてのドキュメントをスコア計算) | 7.59 ms | 3.66 ms | 2.08× |
この 4 vCPU のゲストでは、クエリごとに短い転置リストしか走査しない場合、疎な Okapi カーネルの処理コストが十分に低いため、スレッドプールを使うとかえって遅くなります。クエリが長い転置リストを走査する場合は高速化し、各語の寄与をすべてのドキュメントに加算する BM25Plus では約 2× 高速になります。どちらの方法でも、各行のスコアは単一クエリのスコアとビット単位で一致します。
0.1 の比較を再現するには、次のコマンドを実行してください。
pip install rapidbm25 rank-bm25==0.2.2
python bench/gen_workload.py --out /tmp/bm25-wl # chunks your Python stdlib (numbers will differ by machine and corpus)
python bench/bench_interleaved.py --workloads /tmp/bm25-wl --out /tmp/bench.json
正確性の検証
- コントラクトの再生:
contract/bm25.contract.jsonには、PyPI から入手した rank_bm25 0.2.2 を実行して得られた入出力ベクトル 407 件が含まれています。contract/bm25-47aa3dd.contract.jsonには、git 47aa3dd から得られた 407 件が含まれています。tests/replay_contract.pyは、完全一致(浮動小数点数は 16 進数で比較)でこれらを再生します。それぞれ 407/407 件です。スコアを 1 ULP ずらす対照ビルド(tests/control_ulp_off.py)では 263 件が失敗するため、この再生は盲目的な検証ではありません。 - ファジング(
tests/fuzz_equivalence.py):3 種類すべてのバリエーションとエッジケースのパラメーターについて、各参照実装に対して 1,000 件のランダムケースを比較しました。スコア、バッチスコア、idf、doc_freqs、avgdl、average_idf、top-n をビット単位で比較し、失敗は 0 件でした。 - CI のすべての wheel は、それぞれのプラットフォームにインストールされ、テストスイート、rank_bm25 0.2.2 との 300 ケースのファジング、両方のコントラクト再生を実行します。
- wheel は
cargo-auditableでビルドされます(依存関係の一覧は.dep-v0セクションに埋め込まれます)。
MCP サーバー
rapidbm25-mcp は、ホイールに同梱されている stdio MCP サーバーです(MCP SDK への依存はありません)。
bm25_index_texts、bm25_index_path、bm25_search、bm25_list_indexes、bm25_drop_index を利用できます。
テキストのインデックスはメモリ上に保存されます。bm25_index_path は、フォルダーのインデックスを
RAPIDBM25_CACHE(デフォルト:~/.cache/rapidbm25、ディレクトリのモードは 0700、ファイルのモードは 0600)にキャッシュします。
キャッシュされたファイルは、サイズ、mtime、およびインデックス作成時のバイト列の SHA-256 が
すべて一致する場合にのみ再利用されます。読み取り後、パスの情報を再度取得します。シンボリックリンクは
たどりません。別のハードリンクがあるファイル(st_nlink > 1)はスキップされます。glob のファイル名が . で始まる場合を除き、ドットファイルは
スキップされます。読み取り対象は RAPIDBM25_ROOTS(デフォルト:作業ディレクトリ)の範囲内に限られます。
データがマシンの外に送信されることはありません。
{ "mcpServers": { "rapidbm25": { "command": "uvx", "args": ["--from", "rapidbm25", "rapidbm25-mcp"],
"env": { "RAPIDBM25_ROOTS": "/path/to/repo" } } } }
詳細:mcp/README.md。レジストリのメタデータ:server.json。
エージェントスキル
シェルコマンドを実行するエージェント向けの skill/rapidbm25-search/(SKILL.md + scripts/bm25_search.py)です。
python skill/rapidbm25-search/scripts/bm25_search.py --path ./src --query "parse_header timeout" -k 10
ソースからビルド
pip install maturin && maturin build --release # or scripts/build_auditable.sh
ライセンス
Apache-2.0。LICENSE と NOTICE を参照してください(rank_bm25 との API 互換性については、そちらに記載されています)。
コメント
AI アプリケーションが外部のツールやデータソースに統一的な方法で接続するためのオープンなプロトコルです。クライアントはモデルとの対話を担い、MCP サーバーはツール・リソース・プロンプトテンプレートを提供します。標準メッセージでやり取りするため、1 つのサーバーを複数のクライアントで再利用できます。