关于 rapidbm25
rapidbm25 是基于 Rust 的 BM25 搜索库,面向构建本地文本或代码与文档搜索的开发者。它提供兼容 rank_bm25 的评分,并支持增量索引、批量搜索和 MCP 服务。
主要功能
- 01BM25Okapi、BM25L 和 BM25Plus 评分
- 02增量更新索引
- 03批量查询与 top-n 搜索
- 04可选分词器与混合融合
- 05保存和加载索引
适用场景
- 01搜索本地代码或文档目录
- 02随文档变化更新文本索引
- 03融合 BM25 与向量搜索排名
- 04在 MCP 工作流中使用 BM25 检索
MCP 相关
包含可直接运行的 MCP 服务,向 MCP 客户端提供该项目的搜索能力。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
rapidbm25
可直接替代 rank_bm25 的 BM25Okapi、BM25L 和 BM25Plus 的 Rust 原生实现。
构造函数和属性相同,得分逐位完全一致,get_scores 快约 1,470 倍,
还提供快速的 top_n、保存/加载、增量索引、批量搜索、
可选分词器、混合融合、可直接运行的 MCP 服务和 Agent 技能。
安装
pip install rapidbm25
预构建 wheel:适用于 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;对 BM25L / BM25Plus 传入 compat="47aa3dd",即可使用 git-main 的行为。两种行为均经过逐位精确测试。
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 构建版本而异) | 得分降序,然后按较小的文档 id 排序(确定性) |
| 词元 | 任意可哈希对象 | str(语料中的非 str 词元会引发 TypeError) |
构建后编辑 .idf / .doc_freqs | 会影响后续得分 | 无影响(得分来自原生索引) |
tokenizer= | 在多进程池中运行 | 在进程内运行 |
BM25L / BM25Plus 的 compat= 关键字 | 不适用 | "0.2.2"(默认值)或 "47aa3dd" |
Pickle / save / load | 仅支持 pickle | 支持 pickle,另有带版本的文件(load 会复制;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 会填补空位。使用相同的 k1、b、epsilon 或 delta、compat 和 fixes 时,get_scores 的结果与基于 live.corpus() 构建的 BM25Okapi / BM25L / BM25Plus 一致。空的实时索引也是有效的。
LiveIndex 的 fixes 默认为 True,兼容类则默认为 False。传入 fixes=False 可同时保留 rank_bm25 的公式和原有 bug。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 是一个字典($eq、$ne、$in、$nin、$gt、$gte、$lt、$lte、$exists)或可调用对象。$in 和 $nin 接受列表,而不是字符串。布尔值不会匹配 0 或 1。可调用对象在索引锁之外运行。add / delete / update 会获取锁,因此多个线程可以共享一个 LiveIndex。
variant 为 "okapi"(默认值)、"l" 或 "plus"。compat 为 "0.2.2" 或 "47aa3dd",与静态类相同。L 的 delta 默认为 0.5,Plus 的 delta 默认为 1。
分词器
Tokenizer 是可选功能。支持 Unicode 词语(UAX #29)、可选的小写转换、原始英语停用词列表(或自定义列表)、Snowball 词干提取器(stemmer="english" 使用 Porter2;也接受其他 Snowball 语言),以及对汉字、平假名、片假名和韩文字母使用重叠字符二元组的 cjk=True。向兼容类传入 tokenizer= 是启用分词的唯一方式;省略它则不会改变这些类的行为。
批量搜索
get_scores_many(queries) 返回形状为 (n_queries, corpus_size) 的 float64 数组。
top_n_many(queries, n) 为每个查询返回一个 (ids, scores) 对。两者都会释放 GIL,并在线程池上为查询评分。第 i 行与单查询调用的结果逐位相同。
LiveIndex 也有这两个方法。
fixes=True
对于 BM25Okapi、BM25L 和 BM25Plus,fixes=False 是默认值,结果与 rank_bm25 一致,包括其 bug。LiveIndex 默认使用 fixes=True。无论哪种设置,Okapi IDF 公式都保持为 ln(N - df + 0.5) - ln(df + 0.5)。不会用平滑后的公式替代。
| issue | fixes=False | fixes=True |
|---|---|---|
| #19 重复的查询词 | 每次重复都会再次添加该词 | 每个词只计一次,按首次出现顺序 |
| #36 空语料库 | 兼容类会引发 ZeroDivisionError | 空静态索引,get_scores 返回形状为 (0,) 的结果 |
| #39 IDF 恰好为 0 | 当某个词出现在恰好一半的文档中时,Okapi IDF 为 0 | 当下限为正时,该 IDF 使用现有的 epsilon * average_idf 下限 |
#39 不会改变任何其他 IDF。当 epsilon * average_idf 为零或负数时,此修复不会生效。常见的 2 文档情况就是如此:每个仅出现在一篇文档中的词都出现在一半语料库中,平均 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 节点。
基准测试
工作负载:16,000 个 Python 标准库源码和文档分块(平均 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。共享虚拟机,因此细微差距可视为噪声。
共 5 轮;每轮中,每种实现都在全新进程中运行一次,并按轮换顺序执行。 以下为中位数;rapidbm25 各行的耗时是在每个进程中运行 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,单进程,预热后取 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) + 一次查询 | 8,000 | 2.01 ms |
load(mmap=False) + 一次查询 | 8,000 | 2.81 ms |
对每个文档执行 LiveIndex.add | 8,000 | 85.3 ms |
LiveIndex.delete | 800 | 6.82 ms |
| 静态重建剩余文档 | 7,200 | 8.53 ms |
对已构建的索引再添加 200 个文档,使用 LiveIndex.add | 200 | 1.57 ms |
| 静态重建整个文档集 | 8,200 | 9.58 ms |
一次调用 BM25Okapi(...) 构建 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会逐项精确比较(浮点数以十六进制表示):两组数据均为 407/407。将分数偏移 1 ulp 的对照构建(tests/control_ulp_off.py)有 263 项未通过,因此回放测试并非形同虚设。 - 模糊测试(
tests/fuzz_equivalence.py):针对每个参考实现运行 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 是随 wheel 一同提供的 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)。
只有当文件大小、修改时间以及已编入索引的字节的 SHA-256 均未变化时,才会复用缓存文件;读取后会再次对路径执行 stat。不会跟随符号链接。具有其他硬链接(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。
Agent 技能
skill/rapidbm25-search/(SKILL.md + scripts/bm25_search.py),供运行 shell 命令的 Agent 使用:
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 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。