Appearance
第12章 稀疏检索:BM25 为什么仍然重要
"BM25 has been the SOTA for text retrieval for 30 years. Embeddings didn't kill it — they joined it." — 一位匿名 IR 研究员
本章要点
- BM25 是 1994 年提出的经典 IR 算法——2026 年仍然是 RAG 生产系统的主力召回源之一
- 稠密(Embedding)和稀疏(BM25)各有结构性优势——不是替代关系、是互补关系
- BM25 的三件套:词项频率 TF、逆文档频率 IDF、长度归一化——每一项都有对应的工程意义
- 2020 年后的新路线:SPLADE 等 "稀疏神经检索" 把 embedding 的学习能力注入倒排索引
- 生产实操的关键是 tokenizer 对齐 + hybrid search 融合——这两步错一步、BM25 的收益就被抵消
12.1 为什么不只用 Embedding
第 9-11 章讲完了 dense retrieval 的整条链路。一个朴素的想法:既然 embedding 能捕捉语义、直接全用它不就好了?答案是 不够用。四类场景 BM25 明显优于 embedding:
- 精确术语:查询包含唯一标识(产品代号、订单号、法条编号、API 名),字面匹配才精准——"查订单 OD-2026042401"如果 embedding 把 OD-2026042401 语义化成"某产品订单"就完了
- 长尾查询:embedding 对训练语料里罕见的词表达能力弱。罕见专有名词、新出现的行业术语、用户特定黑话——BM25 纯字面匹配反而稳
- 高精度场景:法律条文、医疗诊断代码、金融合规术语——用户问"第 35 条"就要命中"第 35 条"、不能召回"第 37 条类似"
- 多语言混文:中英混杂的企业文档里,英文单词的字面形态是最强信号——embedding 模型在 domain 外的混语表现不稳定
第 3 章讨论过"找不到"家族的子模式 1"Embedding 语义漂移"——BM25 正是修这个子模式的。
Hybrid search(第 13 章)把两条召回融合——取各自的优势、补各自的盲区。这是 2024 年后 SOTA RAG 的标配。
12.2 BM25 的直觉和公式
BM25(Best Matching 25)是 Robertson 和 Sparck Jones 在 1994 年提出的 IR 排名函数。公式看起来吓人、拆解开来都是直觉:
text
BM25(query, doc) = Σ_{term ∈ query} IDF(term) × (TF(term, doc) × (k1+1)) / (TF(term, doc) + k1 × (1 - b + b × |doc|/avgdl))三个组件:
TF:词项频率
一个词在文档里出现次数越多、相关性越高。但不是线性:一个词出现 10 次不比出现 3 次相关 3 倍——边际递减。BM25 用 TF × (k1+1) / (TF + k1) 做饱和曲线,k1 通常 1.2-2.0。
text
TF=1 时饱和值约 0.55
TF=5 时饱和值约 0.83
TF=20 时饱和值约 0.95TF 饱和的意义:防止"关键词堆砌"——一篇 SEO 垃圾文把某词重复 100 次不该比一篇正常文章更相关。
IDF:逆文档频率
一个词出现在越多文档里、区分度越低。"的"出现在几乎所有文档、不说明什么;"PagedAttention"只出现在某几篇技术文档里、极有区分度。
text
IDF(term) = log((N - df + 0.5) / (df + 0.5) + 1)N 是总文档数,df 是含该词的文档数。高频词 IDF 低、低频词 IDF 高。
长度归一化
长文档天然出现更多词——不应该因此获得相关性加分。BM25 用 1 - b + b × |doc|/avgdl 惩罚长文档,b 通常 0.75。b=1 完全归一化、b=0 不归一化。
总的感觉
BM25 = 命中了多少稀有词 × 命中次数多不多,再按文档长度归一化。三个因素平衡得好就能捕捉到大多数"字面相关性"。
12.3 倒排索引:BM25 的工程载体
BM25 怎么跑得快?靠倒排索引。
数据结构
每个词对应一个 posting list:
text
"SSO" -> [(doc_1, tf=3), (doc_5, tf=1), (doc_12, tf=2), ...]
"企业版" -> [(doc_1, tf=5), (doc_3, tf=2), (doc_12, tf=1), ...]
"定价" -> [(doc_3, tf=4), (doc_12, tf=3), ...]查询 "SSO 企业版 定价" 时:
- 读三个词各自的 posting list
- 对出现在多个 list 里的 doc 算 BM25 分数(交集)
- top-k 返回
典型实现用 pfor / varint 压缩 posting list、跳表(skip list)加速交集求值。Lucene / Elasticsearch / Tantivy 都是这套数据结构。
复杂度
每个 query term 的 posting list 遍历 O(df)。多个 term 的交集用跳表 O(n log n / step)。实测:百万文档的倒排索引、典型 3-5 词查询、P99 延迟 2-10ms——和 HNSW 同量级。
存储占用
倒排索引远比 embedding 省存储:
- 100 万文档、平均 500 词 = 5 亿 posting entries
- 压缩后约 200 MB
- 相比 HNSW + 1024 dim embedding 的 5-6 GB,占用 1/30
这让 BM25 在大规模场景有天然优势——同样内存能装 30 倍文档量。
12.4 tokenizer:BM25 效果的生死线
BM25 的效果高度依赖 如何把文本切成词项。切得好就准、切得差就废。
英文:相对简单
英文基本按空格切、加上 stemming(词根化):
text
"running runs ran" → tokens ["run", "run", "run"]
"technologies" → ["technolog"]Porter Stemmer / Lancaster Stemmer 是主流。简单粗暴但有效。
中文:必须分词
中文没有空格、必须用分词工具。主流选择:
- jieba:历史最久、速度快、规则 + HMM
- LAC(百度):深度学习分词、精度高、速度中等
- pkuseg:北大、多领域模型(新闻、医学、旅游)
- ICTCLAS:学术界经典
- bge-tokenizer(配合 bge-m3):和 embedding 共享 tokenizer
分词错误对 BM25 是致命的——"深度学习"被切成"深度 / 学习",查询"深度学习"可能匹配到只讨论"学习深度"的文档。
混文场景
中英混合最坑:
- "SSO 单点登录" 需要 "SSO"、"单点"、"登录" 同时索引
- 代码片段 "User.login()" 要保留完整 token、不能按下划线切
实操建议:用 embedding 模型相同的 tokenizer(bge-m3 tokenizer、XLM-R tokenizer)做 BM25 分词。好处是检索阶段两个召回走相同的词单位、融合时语义对齐。
同义词扩展
生产 BM25 常加同义词表:
text
单点登录 -> SSO
SAML 2.0 -> SAML2, SAML
企业版 -> Enterprise, enterprise edition查询时自动扩展("企业版 SSO" → "企业版 | Enterprise | Enterprise edition" AND "SSO | 单点登录 | SAML")。Elasticsearch 的 synonym analyzer、Tantivy 都支持。
同义词表的维护是持续工作——业务里新冒出来的术语要及时入表。生产做法:从用户查询日志挖掘高频 miss 词、配对到现有 chunk 里的近义词、人工审核。
12.5 BM25 的三大参数调优
k1:TF 饱和速度
默认 1.2-2.0。饱和快(k1 小)适合短文档、饱和慢(k1 大)适合长文档。中文技术文档常用 k1=1.5。
b:长度归一化强度
默认 0.75。b 大(接近 1)对长文档严厉惩罚、适合 FAQ 库(所有文档应该差不多长);b 小(接近 0)对长度不敏感、适合长度差异大的语料(书籍 + 博客 + FAQ 混库)。
调优方法
和第 10 章 ANN 调优一样——用 gold set 跑参数扫描:
text
k1 \ b 0.0 0.5 0.75 1.0
1.0 0.72 0.78 0.80 0.76
1.5 0.74 0.81 0.83 0.78
2.0 0.73 0.80 0.82 0.77取 recall@10 最高的组合。典型结果集中在 k1=1.2-1.8、b=0.5-0.85。
12.6 SPLADE:神经稀疏检索
2020 年后 IR 领域出现"稀疏神经检索"——把 Embedding 的学习能力带进倒排索引。代表是 SPLADE(Formal et al., arXiv:2107.05720)。
核心思路
传统 BM25 用精确词项作倒排 key。SPLADE 用 BERT 输出一个稀疏向量——向量每个维度对应 vocabulary 里一个词、值是"这个词对当前文档的重要度(学出来的权重)"。多数维度为 0(因此是稀疏的)、少数非零值充当"扩展词 + 权重"。
例子:"新版企业版 SSO" 的文档可能产生稀疏向量 {"企业版": 0.8, "SSO": 0.9, "Enterprise": 0.3, "SAML": 0.2, "单点登录": 0.4, ...}——SPLADE 学会了自动加同义词扩展。
优点
- 保留 BM25 的可解释性(每个分数来自哪个词项可追溯)
- 具备 embedding 的同义词捕捉能力
- 倒排索引原生支持、生产部署不用改检索引擎
缺点
- 推理有 BERT 开销——比 BM25 慢 10-50 倍
- 生态仍在发展——比成熟 embedding 模型少
- 主要效果在英文——中文 SPLADE 模型少
是否值得用
决策:
- 基础线够用(BM25 + Hybrid search)→ 不用 SPLADE
- 追求顶级检索质量且能承担推理成本 → 值得上
- 多数国内 RAG 项目 2026 年仍停留在 BM25 + Hybrid——SPLADE 是"下一代"而非"现行标配"
12.7 BM25 的工程实现选择
Elasticsearch / OpenSearch
最主流的企业搜索引擎。优点:成熟、运维工具全、支持各种 analyzer。缺点:资源占用高、JVM 系、和 RAG 栈(Python / Rust)需要 REST API 连接。
Tantivy(Rust)
Quickwit 开源的 Rust 倒排索引库。优点:速度快、内存省、嵌入式可用。缺点:生态小、无分布式(要自己组合 Quickwit 做分布式)。
BM25s(Python)
轻量纯 Python 实现、github.com/xhluca/bm25s。优点:几 MB 代码、10 行起手、精度达标。缺点:单机、不适合亿级。适合 MVP 阶段。
pgvector + tsvector
Postgres 内置的全文搜索。pgvector 做 embedding、tsvector 做 BM25——一个数据库搞定 hybrid。优点:不引入新组件。缺点:tsvector 的 BM25 实现比专用引擎简陋、中文支持一般(需要 pg_jieba 扩展)。
向量库自带 BM25
Qdrant 2024 年加了 BM25 支持、Weaviate 一直有 BM25 + vector 一体化。这种"一站式"方案省运维——适合中小规模。
选型建议
- MVP / 小规模:BM25s 或向量库自带
- 已有 Elasticsearch 栈:复用 ES
- Rust 生态 / 极致性能:Tantivy
- pgvector 栈:pg_jieba + tsvector
- 超大规模 + 商业支持:Elasticsearch / OpenSearch
12.8 BM25 的真实坑点
坑 1:停用词列表过激进
"the、a、of" 在英文是正常停用词。但 "of" 在法律文档里是关键词("Section 5 of the Act")。停用词表不能一刀切。
坑 2:大小写不一致
"SSO" 和 "sso" 应该是同一个 token——入库时统一小写化。但 "iPhone" 小写成 "iphone" 后含义不变吗?需要按 domain 决定。
坑 3:数字和字符编码
"20000" 和 "2 万" 同义但字面不同——BM25 召回不了"2 万"的文档当查询 "20000"。需要 query expansion 或 normalization 规则。
坑 4:多词短语忽略顺序
"机器学习"三个字切成 ["机器", "学习"]——BM25 匹配"学习机器"也会命中。要保留短语匹配的话用 n-gram 或 phrase query。
坑 5:负面查询支持差
"不是 XXX"、"except XXX" 这类语义 BM25 处理不好。需要 query rewrite 转成过滤条件。
坑 6:极长 query 分母变大
查询 "详细说明新版企业版套餐里私有化部署的 SSO 配置方式 包含 SAML 2.0 OIDC LDAP 是否都支持"——BM25 给每个词打分加总、不重要词的噪声压倒关键词。生产要做 query 缩减(去停用词、关键词提取)。
12.9 BM25 的可观测性
和向量检索一样,BM25 有独立的监控指标:
bm25_empty_rate:返回 0 结果的比例(分词失败或查询全是停用词)bm25_long_query_rate:> N 词的查询比例(需要 rewrite)bm25_recall_on_gold:gold set 上的 recall@20index_doc_count和index_size_mb:索引规模
和 embedding 召回在同一块看板上对比——能看出两者强弱。某类 query 在 BM25 高但 embedding 低——说明是字面匹配敏感型;反之则是语义匹配敏感型。这些分析指导融合策略调整。
12.10 BM25 vs Dense 的互补性实证
BEIR benchmark(Thakur et al., arXiv:2104.08663)在 18 个数据集上系统对比 BM25 和多种 dense retrieval 模型。几个反直觉的发现:
| 数据集 | BM25 | Dense | 赢家 |
|---|---|---|---|
| MS MARCO(通用搜索) | 0.228 | 0.329 | Dense |
| TREC-COVID(医疗) | 0.656 | 0.594 | BM25 |
| BioASQ(生物医学) | 0.465 | 0.305 | BM25 |
| NQ(自然问答) | 0.305 | 0.398 | Dense |
| FiQA(金融 QA) | 0.236 | 0.274 | Dense |
| SciFact(科学事实核查) | 0.665 | 0.502 | BM25 |
观察:
- 通用 QA(MS MARCO、NQ):Dense 略优
- 领域专门(医疗、金融、科学):BM25 往往赢,尤其领域外训练的 dense 模型
- 术语密集型(SciFact、TREC-COVID):BM25 的字面优势放大
结论:永远 Hybrid 不亏。单独 dense 在自己的地盘强、单独 BM25 在术语地盘强、融合则两端都不塌。
12.11 BM25 在 RAG 里的三种用法
生产 RAG 里 BM25 有三种典型用法:
用法 1:召回源之一(主流)
和 dense 并行召回、用 RRF 或其他融合(第 13 章)。每路 top-50、融合取 top-20。99% 的 Hybrid RAG 用这个方案。
用法 2:dense 不可用时的 fallback
向量库宕机或 embedding 服务超时时、降级到纯 BM25 检索。虽然精度下降、但不至于无法服务。第 4 章讨论的在线可用性设计体现这个。
用法 3:结构化字段的精确搜索
问题里包含明确字段(订单号、产品代号、法条编号)时直接走 BM25 / 精确匹配跳过 embedding。用 query classifier 识别此类问题、路由到不同召回路径。
12.12 BM25 在代码搜索场景的特殊性
代码搜索是 BM25 最被低估的场景。理由:
- 代码符号(函数名、类名、API 名)是精确匹配敏感的——搜
UserService.login就要命中UserService.login,不能召回user_controller.authenticate - 编程语言本身是结构化的——变量名、函数签名、import 语句都是可 tokenize 的稀有词
- 代码里的 stemming 不适用——
getUser/getUsers/get_user都不该被归一成get user
代码 tokenizer 的特殊要求
不要用自然语言 tokenizer 切代码——会把 User.login() 切错。需要代码感知 tokenizer:
- 保留
.和::等符号分隔 - CamelCase 可选切分(
UserLogin→['UserLogin', 'User', 'Login']两级索引) - 排除 string literal 和 comment(检索代码结构而非注释文本)
Sourcegraph 的 zoekt、GitHub 的 Searchable Index 都用专门的代码 tokenizer。RAG 栈里 tree-sitter(第 5 章讨论过)可辅助——先 AST 切 symbol 再索引。
代码搜索里 BM25 的地位
Cursor、Copilot Chat 等主流代码助手都用 BM25 + 向量混合——BM25 对精确符号匹配是无可替代的。纯向量检索连"找 login 函数"这种基础需求都做不稳定。
代码 rerank 的差异
Cross-encoder rerank 对代码不一定适用——预训练语料偏自然语言。专用选择:
- 代码专用 embedding 反向当 rerank 用(voyage-code-2)
- 训练小的 code-reranker(某些团队在 CodeSearchNet 数据集上 fine-tune 过)
多数代码 RAG 项目 BM25 + 代码 embedding + 精确符号 filter 三路融合就够——不一定需要独立 rerank 阶段。
12.13 BM25 的进阶:字段加权、短语匹配、相关反馈
基本 BM25 把文档看成一袋词、每个词独立打分、不区分来源字段、不考虑顺序。这三个假设在生产里经常不够用——本节讲三个经典进阶,每一个在业务里都有具体场景。
BM25F:字段加权
企业知识库的 chunk 通常有结构——title / body / code / tags。默认 BM25 把这些字段拼成一段平铺文本、每个字段权重相同。但直觉上 title 命中比 body 命中更可信——产品代号在 title 里出现一次远比 body 出现五次更说明相关。
BM25F(Robertson & Zaragoza 2009)让每个字段带权重:
text
BM25F(query, doc) = Σ_term IDF(term) × saturate( Σ_field weight_f × TF_f(term) / length_norm_f )典型配置(企业文档):
- title: weight=3.0, b=0.5(title 短、对长度不敏感)
- body: weight=1.0, b=0.75
- tags: weight=2.0, b=0.3(tags 极短、几乎不归一)
- code: weight=1.5, b=0.5
Elasticsearch 的 multi_match 查询、Tantivy 的 field boost 都是 BM25F 的直接实现。标定字段权重和 k1/b 一样——在 gold set 上扫参数。
短语与邻近匹配
标准 BM25 丢失词序——查询 "机器学习" 会命中含 "学习机器" 的文档。多数场景无所谓、但精确短语和邻近性偶尔很关键:
- 书名 / 产品名:《The Art of War》不是 "art、war、the"
- 代码短语:
user.login不是 "user、login" - 法律条款:第 35 条第 2 款 不是 "35、条、2、款"
两个机制解决:
- Phrase query:倒排索引存每个词的位置(positional index)、query 时验证词按给定顺序相邻出现。Elasticsearch / Tantivy 都支持
"..."短语语法 - 邻近评分:不强制相邻、但词距越近分数越高——用
score × e^(-α × avg_distance)衰减远距离。Lucene 的SpanNearQuery、OpenSearch 的match_phraseslop 参数都是这个
生产 RAG 里短语匹配常作加权叠加:BM25 标准召回 + 精确短语匹配 bonus 分,而不是硬过滤——避免"没有精确短语就找不到"的极端。
伪相关反馈(PRF / RM3)
经典 IR 里的 pseudo-relevance feedback:假设 top-k 召回里的前几条真相关、从这几条里抽高 IDF 词补进原 query、再召回一轮。RM3(Lavrenko 2001)是最著名的实现。
工作流:
- 用原 query 跑一次 BM25、拿 top-10
- 从 top-10 里抽 20-50 个 IDF 高的关键词
- 把这些词按权重加到原 query(原词权重高、扩展词权重低)
- 用扩展后的 query 再跑一次 BM25、取最终 top-k
收益:在 TREC 等经典 benchmark 上提升 2-5 点 recall。代价:两次检索、延迟翻倍。
RM3 在 LLM 时代有点被冷落——大家觉得 "LLM 直接 rewrite 更好"(第 15 章的 HyDE 等)。但有两个场景 RM3 仍有价值:
- 离线索引无 LLM 预算:RM3 不需要 LLM、纯基于统计、成本零
- 可解释性要求:扩展词可追溯到 top-10 文档、比 LLM 黑盒改写透明
学习排序(LTR):BM25 作特征
大型搜索引擎(Google、Bing、电商站内搜索)的排序从不是单一 BM25——是多特征学习排序:
- BM25 score(作为一个特征)
- BM25F per-field scores
- PageRank / 文档质量分
- 点击率历史
- recency
- personalization signals
特征喂给 gradient-boosted ranker(LightGBM、XGBoost)或更复杂的 neural ranker。BM25 是基础特征之一、不是唯一信号。
对一般 RAG 项目这套 LTR 栈偏重——但在中大规模 RAG 里它仍然是终局形态。初期 BM25 + embedding hybrid、规模起来后引入 click feedback 和 LTR。
进阶技术的选择顺序
不是所有项目都要上这些。典型引入节奏:
| 项目阶段 | 建议 |
|---|---|
| MVP | 标准 BM25 + embedding hybrid |
| 有结构化文档 | 加 BM25F 字段加权 |
| 有短语类 query badcase | 加短语 / 邻近匹配 |
| LLM 预算紧 / 要可解释性 | 加 RM3 |
| 规模大 + 有点击反馈 | 引入 LTR |
每一步都要有 badcase 驱动——不是越复杂越好。
12.14 中文稀疏检索的深层工程问题
§12.4 把"中文分词"作为 BM25 的生死线提过一次。但中文 RAG 的稀疏检索有一系列英文场景不存在的深层工程问题——任何一个没处理、BM25 的 recall 就会比预期低 10-20 个点。这些问题在英文 IR 教材里几乎不出现、要中文项目实战里一个一个踩坑才能总结。
字粒度、词粒度、n-gram 的权衡
中文切词的粒度选择直接决定召回特性:
- 字粒度(一字一 token):不依赖分词正确性、任何词都能命中。但精度差——"机器学习"切成"机/器/学/习"后,和"学习机器"共享 4 个字、BM25 分数接近
- 词粒度(jieba / LAC 切):精度高。代价是分词错误直接丢召回——"新冠肺炎"若被切成"新/冠肺/炎",查询"新冠"就找不到
- 2-gram(双字 token):稳健折中。"机器学习"切"机器/器学/学习",保留"机器"和"学习"的邻接信息、避开分词错误
生产常见混合:字粒度 + 词粒度双路索引、查询时两路 BM25 → RRF 融合。字粒度兜底长尾、词粒度保精度。索引存储翻倍、但对中文 RAG 是值得的。
分词错误的连锁影响
中文分词器对生词(业务专有名词、新产品、小众术语)经常切错。一次错分的连锁影响:
- 索引阶段:chunk 里的 "某某云服务" 被切成 "某某 / 云 / 服务"——索引里没 "某某云服务" 这个 token
- 查询阶段:用户搜 "某某云服务",tokenizer 切成同样的 3 个 token、部分命中、但 recall 低
- 更坏情形:查询和索引分词用不同版本——"某某云服务" 分别被切成 "某某云 / 服务" 和 "某某 / 云 / 服务",完全不对齐
防线:
- 统一 tokenizer 版本 + vocab:索引和查询必须用完全相同的分词配置、包括用户词典
- 业务词典维护:产品名、功能名、专业术语写进 jieba 的 user dict、强制不切分
- 字粒度兜底:词粒度失效时字粒度 BM25 仍能命中、作为 safety net
中文规范化:繁简、全半角、异体字
中文有多种"形近但字面不同"的变体:
- 繁简:"計算"vs"计算"——简繁转换后字面完全不同
- 全半角:"123"vs"123"、":"vs":"——用户输入习惯导致字面不匹配
- 异体字:"峰"vs"峯"、"裡"vs"里"——同音同义但码点不同
- 旧字形:Unicode 标准化形式不同
入库前和查询前都要规范化:
python
import unicodedata
from opencc import OpenCC
cc = OpenCC('t2s') # 繁转简
def normalize_cn(text):
# Unicode 规范化(NFKC 合并兼容字符)
text = unicodedata.normalize('NFKC', text)
# 繁转简
text = cc.convert(text)
# 全半角统一
text = text.translate(全半角映射表)
return text.lower()两端都做同一套规范化——否则一端"計算"、另一端"计算"——索引和查询的 token 对不上。
未登录词(OOV)的处理
新词(新产品、新事件、热词)是分词器的永恒难题。应对:
- 定期更新词典:每月从业务数据挖掘高频未登录词、人工审核后加进 user dict
- 开放词典来源:开源新词库(如清华 THUOCL、百度百科词条)定期同步
- 字粒度救火:任何 OOV 在字粒度索引里都能命中、只是精度降低
OOV 是最常见的"为什么 BM25 突然查不到这条"的元凶——新产品上线后的第一周尤其需要盯。
中英混文的 token 统一
企业中文文档里夹着英文技术术语是常态:"SSO 认证"、"API 接口"、"Kubernetes 部署"。混文的 tokenizer 策略:
- 字符类型切分:中文按字 / 词切、英文按空格切、数字和符号独立
- 大小写统一:英文部分小写化、
SSO和sso统一到sso - 数字规范化:"20000" vs "2 万" vs "两万"——按业务决定统一成哪种
生产做法:用 embedding 模型自带的 tokenizer(bge-m3、xlm-roberta)——它们天然处理中英混文、和向量检索共用同一 vocab、融合时对齐最好。
中文停用词的陷阱
英文停用词表成熟(the / a / of / is)。中文停用词要谨慎:
- "的 / 了 / 着 / 是"——通常停词
- "在 / 从 / 对"——上下文依赖,法律文档里可能是关键词
- "能 / 可 / 不"——否定和情态,问"能不能 SSO"时"能"和"不"都有意义
不要直接用开源停用词表——会把"不 / 能"这类重要词去掉。先做 BM25 无停用词版本测业务 recall、逐词加停用词看是否提升、不提升的不加。保守优于激进。
中文场景的 BM25 监控
中文 BM25 的特有监控:
oov_rate:查询里命中 user dict 外的词比例——高于 10% 该更新词典zero_result_rate:返回 0 结果的 query 比例——分词或规范化问题的信号tokenizer_version_drift:索引和查询的 tokenizer 版本是否一致——每日自动校验
中文 BM25 的"能用 → 好用"差距主要在这些细节上——别小看规范化和词典维护,是中文 RAG 长期质量的根基。
12.15 倒排索引的性能优化:压缩、BlockMax-WAND 与 skip list
§12.3 给了倒排索引的基本结构——但现代生产 BM25(Lucene、Tantivy、Quickwit)能做到亿级文档毫秒级响应、不是靠朴素遍历。背后是三十年累积的三类核心优化:posting list 压缩、skip list 加速交集、BlockMax-WAND 动态剪枝。这些技术决定了 BM25 能不能撑住生产规模——选型不考虑引擎的这层实现、遇到规模瓶颈才知道被坑。
为什么 posting list 压缩必需
一个 posting entry 原始数据 (doc_id=2^32, tf=1)——8 bytes。百万文档 × 平均 500 个词 = 5 亿 entries = 4 GB 原始。全部 load RAM 成本难接受。
压缩后 posting list 典型只占原始 20-30%——1 GB 就能装、冷热分层后内存需求更低。这是大规模 BM25 能跑起来的前提。
VarByte 和 PFor-delta
Delta encoding:posting list 按 doc_id 升序、存相邻 doc_id 的差值。差值通常小(连续文档)、用变长编码省空间。
VarByte:每 byte 用 7 位存数据、1 位标志还有没有后续字节。小数字 1 byte、大数字 4-5 bytes、平均 1.5-2 bytes per entry。
PFor-delta(Zukowski 2006):128 entry 一块、用固定 bit 数编码大部分 entry、异常值单独存。压缩率更好、解码靠 SIMD 几乎免费。Lucene 用 PFor 变种(PForDelta + 128-block)。
生产工程选择:Lucene / Tantivy / Quickwit 都用类似 PFor 的块编码——解码快、压缩率好。自建通常不值得——用库。
Skip list:加速多词交集
多词 query(如 "SSO AND 企业版 AND 定价")在倒排索引上是多 posting list 求交集。朴素扫描两条 list O(N+M)、如果两条长度差大(SSO 出现在 100 万文档、"企业版"只在 1 万)浪费严重。
Skip list 在 posting list 里加跳表——每 K 个 entry 加一个 skip pointer 指向后 K 位。求交集时短 list 的每个 entry 在长 list 里用 skip 跳转、O(M log N) 代替 O(N+M)。
Lucene 的 skip list 每 128 entry 一个跳跃点。亿级 posting list 的求交从秒级降到毫秒级。
BlockMax-WAND:动态剪枝
BM25 检索是找 top-k 相关文档、不是"所有匹配文档"。BlockMax-WAND(Ding & Suel 2011)利用这点做动态剪枝:
核心思路:
- 每个 posting list 块(128 entry)记录 "本块 BM25 最大可能分数"
- 查询时维护当前 top-k 的最低分数 threshold
- 某块的最大分数 < threshold → 整块跳过、不扫描里面的 entry
- 随 threshold 上升、越来越多块被跳过
实际效果:k=10 时、BlockMax-WAND 能跳过 90-99% 的 posting entries——查询延迟和 k 几乎无关、而朴素算法延迟随文档数线性增长。
这个算法是 Lucene / Elasticsearch / Tantivy 的主查询引擎。配置的 top_k 越小、查询越快——因为跳过的块越多。
真实性能数字
亿级文档 × 平均 500 词的倒排索引、单机 Tantivy / Lucene:
| 操作 | 延迟 | 吞吐 |
|---|---|---|
| 单词查询 | 2-5ms | 5000+ QPS |
| 3 词 AND 查询 | 5-15ms | 2000-3000 QPS |
| Phrase 查询 | 10-30ms | 1000 QPS |
| 带 filter 的查询 | +2-10ms | 依 filter 基数 |
这些数字是三十年算法优化 + 现代硬件的综合结果。朴素实现的 BM25 要达到同样性能、需要 10-100 倍硬件。
压缩 vs 解压 CPU 的权衡
压缩省内存、但解压占 CPU。权衡:
- 压缩率高 (PFor-delta, Roaring):内存小但解压慢
- 压缩率低 (naïve varint):内存大但解压快
- 不压缩:最快但内存爆炸
现代 BM25 引擎都选平衡点:压缩率高但解压用 SIMD——1 GHz CPU 能解每秒 10 亿 entry。内存和 CPU 都不是瓶颈。
Posting list 的段 (segment) 管理
实际 Lucene / Tantivy 不是一个大 posting list、是 多个 segment:
- 新增文档先进"inmemory segment"、定期 flush 到磁盘 segment
- 磁盘 segment 不可变、用 append-only
- 定期 merge 小 segment 为大 segment(background job)
- 删除不直接改 segment、标记 deleted bit、merge 时才真删
这种 LSM-tree 风格设计让增量更新高效、但查询要同时扫多个 segment、结果合并。segment 数量控制是运维关键——太多 segment 查询慢、太少 merge 成本高。
查询优化器的角色
现代 BM25 引擎有查询优化器——自动决定:
- 多词 query 按什么顺序 AND(选择率低的先扫)
- 是否用 BlockMax-WAND(term 少时不划算)
- Filter 是 pre-filter 还是 post-filter(参考 ch10 §10.11)
- 需不需要采样估算(百亿级时可能)
Elasticsearch 的 query planner 相当成熟、自建少见。使用时要了解优化器的选择——有时它选错(数据分布偏斜、估算错)、需要手动 hint 修正。
对 RAG 工程的启示
不是要自己实现这些——是要选引擎时看清它的实现:
- Elasticsearch / Tantivy:上面所有优化都有、生产级
- 自建 Python BM25(如 rank_bm25):教学级、生产上不行
- pgvector + tsvector:Postgres 的全文搜索做了基本优化、但不如专用引擎
- 向量库自带 BM25(Qdrant 1.10+、Weaviate):实现质量参差、大规模要测
规模 > 1000 万文档时、BM25 引擎的实现质量开始决定系统能否撑住。小规模时差不多、大规模差 10-100 倍。
什么时候需要关心这些
- MVP / 小规模 → 不关心、用默认引擎
- 百万到千万 → 了解引擎选的是什么实现、不要踩坑
- 千万到亿级 → 深度理解、可能需要调 segment 参数、query planner hint
- 亿级以上 → 专人做 BM25 性能工程、或上商业方案(Elastic Enterprise / Vespa)
这是 BM25 在大规模场景下的"第二层知识"——多数文章不讲、但规模起来后决定成败。
12.16 稀疏检索的 SOTA 演化:从 BM25 到 learned sparse
§12.2 讲的 BM25 来自 1994 年——三十年前的算法、到 2026 年仍然是生产 RAG 的主力之一。但不是没进化——稀疏检索这三十年一直在发展、2020 年后进入快速迭代期。了解这条演化线能帮判断"该不该追新"、以及稀疏检索在 AI 时代反而更重要的原因。
稀疏检索的三十年演化
五个关键阶段:
- BM25(1994):基于统计、纯字面匹配
- LambdaMART(2009):学习排序、BM25 作特征之一
- DeepImpact / unicoil(2020):第一批 learned sparse、用 BERT 学 term 权重
- SPLADE(2021):query 和 doc 都映射到稀疏向量空间
- SPLADE++(2023):改进损失函数 + 蒸馏、精度追平 dense
- Unified learned sparse(2024+):统一模型、同时学稀疏 + 稠密、未来方向
Learned sparse 的核心创新
传统 BM25 的 term 权重是统计的(TF × IDF)——不管词本身的语义重要性。learned sparse 把这一步学出来:
text
传统 BM25 对 "Rust Fiber" 的权重:
"Rust" 权重 = IDF("Rust") = 5.2
"Fiber" 权重 = IDF("Fiber") = 4.8
Learned sparse 的 BERT 学出来:
"Rust" 权重 = 8.5 (更重要、因为领域术语)
"Fiber" 权重 = 3.2 (低、因为 fiber 多义、此处不那么重要)
额外扩展:
"async" 权重 = 2.1 (语义相关、原 query 没有)
"coroutine" 权重 = 1.8Learned sparse 的向量同时具备:
- BM25 的稀疏性和可解释性(只有几十个非零维度)
- Dense 的语义能力(能扩展同义词 / 相关概念)
SPLADE++ 的 SOTA 性能
SPLADE++ 在公开 benchmark 上的成绩:
- BEIR 平均分:略高于 BM25 + dense + RRF 的组合
- MS MARCO:NDCG@10 超过 ColBERT v2
- 推理延迟:比 dense retrieval 慢 2-3×、但比 ColBERT 快 5-10×
换句话说、SPLADE++ 是"一个模型抵 hybrid"——不需要 late fusion。
为什么稀疏在 AI 时代反而重要
有人以为 "dense embedding 是未来、BM25 会被淘汰"——事实相反:
- 精确匹配需求永远在:代号、订单号、法条编号——稠密永远处理不好
- 长尾查询的 robustness:稀疏对训练数据少的专业领域更稳
- 可解释性:稀疏向量的非零维度直接是词项、能告诉用户"为什么这条匹配"
- 增量更新友好:倒排索引天然增量、dense 需要重建
- 低成本:BM25 推理几乎免费、self-host learned sparse 比 dense 便宜
AI 时代的主流不是"用 dense 替代 sparse"、而是"让稀疏更聪明"——learned sparse 就是这个方向。
SPLADE 在生产的实际应用
2024-2026 年采用 SPLADE 的团队越来越多:
- Qdrant / Elasticsearch / Weaviate 都支持 SPLADE 原生集成
- Cohere、Jina、Voyage 的托管 API 提供 SPLADE embedding
- 开源:naver/splade、xperl/splade 等实现
生产部署流程:
- 用 SPLADE 模型把 chunk 转成稀疏向量(~100-300 非零维度)
- 存倒排索引(每个非零维度 = 一个 "虚拟词项")
- 查询时 query 也过 SPLADE 产出稀疏向量、走标准倒排查询
- 可选:和 dense 做 late fusion、进一步提精度
Learned sparse 的代价
不是免费——比 BM25 多了:
- 模型推理:每个 chunk 和每个 query 都要过 BERT 级模型
- 存储:虽然稀疏、但每个 chunk 的非零维度 100-300 个、比 BM25 的 50-100 多
- 延迟:query 时加 30-50ms 的模型推理
比 dense 便宜、比 BM25 贵——处在中间档。是否值得看业务。
什么时候上 learned sparse
判断依据:
- BM25 + dense + RRF 已是 SOTA 瓶颈:融合精度到顶、要再提必须换思路 → 考虑 SPLADE
- 愿意维护额外的模型:SPLADE 模型推理服务、监控、更新
- 基础设施支持:向量库 / 搜索引擎需要支持 learned sparse
- 团队有 ML 能力:调优 SPLADE 不是傻瓜式
多数 RAG 项目BM25 + dense + RRF 已经够用——不用追 learned sparse。等真正瓶颈明确 + 团队能力就位再上。
Unified learned sparse:未来方向
2024-2026 年学术前沿是 unified model——同一个 BERT 级 encoder 同时输出:
- Dense vector(语义)
- Sparse vector(term 权重)
- 可选:bm25-style stat features
检索时两路并行、最后合并——但两路共享模型、训练数据一起用、精度互相促进。
这是 §13.18 "early fusion" 的具体实现——模型内部做融合、外部不需要多路。
Hugging Face / Cohere 的 embed-v4 开始有类似能力——预计 2027-2028 年成为主流。跟进这个方向、等 6-12 个月社区验证后再采用。
历史视角:长寿算法的共同特征
BM25 能活三十年、有几个共同特征:
- 数学简洁:一行公式说得清
- 参数少:只有 k1、b 两个超参
- 稳健:对数据质量容忍度高
- 增量:加数据不用重建
- 可解释:每个分数都能追溯到原词
Learned sparse(SPLADE 家族)也有这些特征——所以有潜力成为下一个三十年的主力。相比之下、纯 dense embedding 的可解释性和增量性都差——更像"强但短命"的技术路线。
对工程师的实用启示
- BM25 别因为"过时"就放弃——它仍在 hybrid 里是主力
- Learned sparse 是 BM25 的自然进化、不是替代——关注但不追新
- 长寿技术的特征(简洁 / 少参数 / 可解释 / 增量友好)值得作选型 heuristic
- IR 领域的创新是渐进的、不是颠覆性的——每年的 SOTA 比上一年进步 1-2%、三十年累积才是大进步
对 RAG 的工程师:理解稀疏检索的演化、比追最新 dense embedding 模型更有长期价值——这是三十年检验的基础功。
12.17 BM25 的 debug:为什么这条召回不到或召回错
前面讲了 BM25 的原理和进阶——但实际项目里 "这条 query 为什么没召回期望的文档" 是最常见的排查需求。和 embedding debug(§9.14)、chunk debug(§6.14)并列、BM25 也需要专门的调试方法。这节给出 BM25 debug 的实用工具箱。
常见 BM25 问题症状
每种症状对应不同根因——分类查、不要一概而论。
Query-level debug 流程
用户抱怨 "查 X 找不到期望结果"、调试步骤:
python
def debug_bm25_query(query, expected_doc_id):
# 1. 分词查看
tokens = tokenizer.tokenize(query)
print(f"Query tokens: {tokens}")
# 2. 每个 token 的 IDF 和 posting list 大小
for token in tokens:
df = inverted_index.get_df(token) # 含此 token 的文档数
idf = math.log((N - df + 0.5) / (df + 0.5) + 1)
print(f" {token}: df={df}, idf={idf:.2f}")
# 3. 期望文档是否在倒排里
expected_doc = docs[expected_doc_id]
expected_tokens = tokenizer.tokenize(expected_doc.text)
for token in tokens:
if token in expected_tokens:
tf = expected_tokens.count(token)
print(f" Expected doc 含 {token}: tf={tf}")
else:
print(f" Expected doc NOT 含 {token}") # 命中失败原因
# 4. 期望文档的 BM25 score
expected_score = bm25.score(query, expected_doc)
print(f"Expected doc score: {expected_score:.2f}")
# 5. Top-10 实际召回
actual = bm25.search(query, top_k=10)
for i, doc in enumerate(actual):
print(f" Rank {i}: doc_id={doc.id}, score={doc.score:.2f}")
# 6. Expected 在 top-10 里吗
ranks = [d.id for d in actual]
if expected_doc_id in ranks:
print(f" Expected at rank {ranks.index(expected_doc_id) + 1}")
else:
print(f" Expected NOT in top 10")这个脚本 2-3 分钟跑完、能定位 80% 的 BM25 问题。
分词问题的诊断
BM25 召回失败最常见原因是分词错:
- 期望 doc 含 "企业版"、query 含 "企业版"——但分词器把 "企业版" 切成了 ["企业", "版"]
- 索引里的 posting list 是以 ["企业", "版"] 为 key
- 查询如果按 "企业版" 单独查(某些库支持原词)——空
- 按 "企业 OR 版"——召回一堆含 "企业"(如 "创业企业")但无关的
验证:
python
# 查特定 token 的 posting list 大小
for token in ["企业版", "企业", "版"]:
df = inverted_index.get_df(token)
print(f"{token}: {df} docs")结果能看出实际入库的 token 是什么——和期望不一致就是分词问题。
同义词 miss 的分析
Query 用 "SSO"、文档用 "单点登录"——BM25 字面不匹配、不会召回。诊断:
python
def find_synonym_miss(query, gold_docs):
query_tokens = set(tokenize(query))
for doc in gold_docs:
doc_tokens = set(tokenize(doc.text))
overlap = query_tokens & doc_tokens
if not overlap:
print(f"Doc {doc.id}: zero token overlap with query")
print(f" Query: {query_tokens}")
print(f" Doc sample: {doc_tokens[:10]}")出现 zero overlap——必须加同义词扩展才能召回。同义词表是 BM25 的必备补丁。
精确 term 丢失的原因
Query 含产品代号 "OD-12345"、期望精确匹配——但没召回。可能原因:
- Tokenizer 把 "OD-12345" 切了:变成 "OD" + "12345"、分开查
- 大小写:Tokenizer 没小写化、但索引小写了——不匹配
- 停用词:某些 tokenizer 把短数字当停用词
- 特殊字符:
-被当分隔符去掉
诊断:看 "OD-12345" 分词后是什么、索引里有没有对应 token。
长 query 的反常
极长 query 的 BM25 分数反常低——因为:
- query 里多了不重要的词(stop word 级)
- 这些词的 IDF 低、但出现多次、拉低整体匹配精度
- 真关键词被稀释
诊断:
python
def short_vs_long_query(long_query, expected_id):
keywords = extract_keywords(long_query) # 用 TF-IDF 或规则抽
short = " ".join(keywords)
print(f"Long query: {long_query}")
print(f"Long query score: {bm25.score(long_query, docs[expected_id]):.2f}")
print(f"Short query: {short}")
print(f"Short query score: {bm25.score(short, docs[expected_id]):.2f}")如果 short 版明显高——说明长 query 里的非关键词拖累了——上 query rewrite 做关键词抽取。
Posting list 的异常值
查看倒排索引的"异常大 posting list"——这些 token 的 IDF 极低、几乎每个文档都含:
python
huge_postings = [(t, df) for t, df in inverted_index.items() if df > N * 0.5]
for t, df in sorted(huge_postings, key=lambda x: -x[1]):
print(f"{t}: {df} docs ({df/N*100:.1f}%)")含有这类词、query 里用了——会召回海量不相关结果。加入停用词表或在 query 端过滤。
Debug UI 和自助工具
把上面的 debug 脚本做成 UI 让业务自助:
- 输入 query
- 输入 expected doc_id
- 立即看分词 / IDF / 分数 / rank
业务方能自己查、工程不被每次问题打断——生产力提升明显。
BM25 调试的 gold-set
积累一套"BM25 已知 tricky cases":
yaml
cases:
- name: "产品代号必精确匹配"
query: "订单 OD-12345"
expected_top1: "order-12345-record"
assertion: "rank <= 1"
- name: "同义词扩展必工作"
query: "SSO 怎么配"
expected_top_k: ["doc-sso-config", "doc-saml-guide"]
assertion: "all in top 5"每次改 BM25 参数 / 分词 / 同义词表——跑这套 case、防 regression。
和 embedding 互补性的 debug
有时问题不是 BM25 的——是该用 dense:
python
def compare_bm25_dense(query):
bm25_results = bm25.search(query, top_k=10)
dense_results = dense.search(query, top_k=10)
bm25_only = set(bm25_results) - set(dense_results)
dense_only = set(dense_results) - set(bm25_results)
overlap = set(bm25_results) & set(dense_results)
print(f"BM25 only: {len(bm25_only)}, Dense only: {len(dense_only)}, Overlap: {len(overlap)}")BM25 only 和 Dense only 都大——说明两者看不同方向、值得 hybrid。如果 overlap 90%+——说明 hybrid 意义小、一条就够。
监控 BM25 健康度
生产监控增加:
empty_result_rate:query 返回 0 条的比例(> 5% 异常)top_score_distribution:top-1 的 BM25 分数分布(骤降说明 query 质量变差)huge_posting_hits:超大 posting list 被命中的频率zero_overlap_rate:query 和 top-doc 零字面重叠(表示同义词 miss 多)
常见反模式
- 不 log 分词结果:事后查分词不对劲无从下手
- debug 只看分数:不看 posting list、IDF、token 分布
- 改 tokenizer 不重建索引:分词逻辑变但老 posting list 还用老词——错位
- 同义词表不维护:新术语、新产品出来后、BM25 recall 持续降
- 不做 regression 测试:每次调 BM25 都手工测几个 case、不系统
Debug 工具的 ROI
建 BM25 debug 工具链的投入:
- 初版脚本:1 人周
- UI:1-2 人周
- Regression 测试:持续维护
收益:
- 每个 BM25 问题定位时间从几小时到 15 分钟
- 业务自助减少工程打扰
- 同义词 / 分词问题能持续发现和修复
BM25 看起来古老、但 debug 能力差异决定它能否在生产发挥价值。有工具 vs 没工具、生产质量差 20-30%。
12.18 BM25 的可解释性:稀疏检索的隐藏优势
Dense retrieval 是黑盒——"这个 chunk 得分 0.87" 没人能说清为什么。BM25 不一样——每个分数都可拆解到具体词项的贡献。这个可解释性不只是学术好看——在生产里有实用价值:debug、用户信任、合规审计。这节讲 BM25 可解释性的工程价值——稀疏检索被低估的优势之一。
什么是"可解释"
BM25 的分数能拆解到每个词的贡献——Dense 的 0.87 是黑盒。
BM25 的打分分解
具体实现:
python
def explain_bm25_score(query, chunk, bm25):
breakdown = []
for term in tokenize(query):
idf = bm25.idf(term)
tf = count_occurrences(term, chunk)
contribution = bm25.formula(idf, tf, len(chunk))
breakdown.append({
"term": term,
"idf": idf,
"tf": tf,
"contribution": contribution,
})
total = sum(b["contribution"] for b in breakdown)
return {"total": total, "breakdown": breakdown}输出例:
text
Query: 企业版 SSO 价格
Total score: 12.5
Breakdown:
"企业版": IDF=5.2, TF=2, contribution=4.8
"SSO": IDF=6.1, TF=1, contribution=5.2
"价格": IDF=4.8, TF=3, contribution=2.5用户 / 开发者一眼看出哪个词贡献了最多分——完全透明。
可解释性的生产用途
用途 1:Debug 召回问题
用户问 "企业版 SSO 价格"、召回一条奇怪的 chunk。用 explain:
text
Expected chunk: score=8.2
"企业版" contribution 3.0
"SSO" contribution 5.2
"价格" not in chunk
→ 缺 "价格" term、分数低
→ 需要 query expansion 或 synonym
Actual top-1: score=9.1
"企业版" contribution 3.0
"SSO" contribution 0 (not in chunk!) ← 奇怪
"价格" contribution 6.1立即知道:top-1 召回的 chunk 根本没 "SSO"、只因为 "价格" 权重高——BM25 的权衡偏了。
用途 2:用户信任
给专业用户看"为什么这条被返回":
text
📄 这份文档匹配你的查询、基于:
- "企业版" 出现 2 次 (权重高)
- "SSO" 出现 1 次 (关键词、权重最高)
- "价格" 出现 3 次 (权重中等)这种解释让用户相信系统——不是盲信黑盒。
用途 3:合规审计
"为什么这份文档被返回给用户"——合规审计时能回答:
- Dense:难以精确解释
- BM25:明确的 per-term 贡献
在法律 / 医疗 / 金融场景——这是合规的硬需求。
Explain UI 的设计
给用户的 UI:
text
╔════════════════════════════════╗
║ 答案: 企业版价格 20000... ║
║ ║
║ 📊 匹配原因: ║
║ ✓ 精确包含 "企业版" ║
║ ✓ 精确包含 "SSO" ║
║ ○ "价格" 出现 3 次 ║
║ ║
║ [展开详细评分] ║
╚════════════════════════════════╝简洁版给普通用户、点"详细"给专业用户——分层展示。
Dense 为什么可解释性差
Dense 的黑盒性:
- 1024 维向量、每维的含义不明
- Cosine 是整体度量、不能按词拆
- BERT encoder 的 attention 层可以看、但难直观解释
有工具(attention visualization、SHAP)能部分解释 dense——但不如 BM25 直观。
混合时的可解释性
Hybrid RAG(BM25 + dense)下、可解释性部分保留:
text
Final score = 0.5 × BM25_score + 0.5 × Dense_score
BM25 contribution:
企业版: 4.8
SSO: 5.2
价格: 2.5
subtotal: 12.5
Dense contribution: 0.87 (黑盒、但整体)
Combined: ...用户看 BM25 部分的 breakdown、至少部分可解释——比纯 dense 好。
合规场景的特殊需求
金融 / 医疗 / 法律:
- 监管:为什么这份文档被推给用户?
- 诉讼:能证明"当时系统基于这些理由返回"
- 审计:每次检索可追溯
这些场景BM25 几乎必须——纯 dense 系统合规审计很难过。
可解释性的监控
生产可以给可解释性加监控:
term_contribution_distribution:各词的平均贡献、检查是否合理zero_overlap_rate:query 和 chunk 零词重叠但被召回的比例(异常)dominant_term_rate:单个词贡献 > 80% 的 query 比例(过于依赖一个词)
这些指标让"可解释性"可量化——不只是"有 explain 功能"。
可解释性的长期价值
不是一次性的技术——累积价值:
- 团队 debug 效率高
- 用户长期信任
- 合规审计顺畅
- 新人理解系统快
Dense 黑盒的成本慢慢累积——可解释性是 BM25 的复利。
提供 API level 的 explain
除 UI、API 也应该能返回 explain:
json
{
"answer": "...",
"sources": [
{
"chunk_id": "...",
"score": 12.5,
"explain": {
"method": "bm25",
"terms": [
{"term": "企业版", "tf": 2, "idf": 5.2, "contribution": 4.8},
...
]
}
}
]
}下游消费者(审计工具 / 分析平台)能用这些数据——生态价值。
Explainability 的研究前沿
Dense retrieval 也在追求可解释性:
- Attention visualization:看 encoder 关注什么 token
- SHAP for embeddings:attribute 分数到输入
- Neuron-level interpretation:哪些神经元激活
但这些都比 BM25 的内生解释复杂——BM25 的简洁可解释性仍是优势。
对选型的启示
"选 dense 还是 BM25 还是 hybrid"——可解释性也是考虑维度:
- 纯 dense:黑盒、适合简单场景
- 纯 BM25:透明、适合合规场景
- Hybrid:平衡、推荐大多数
合规硬要求时、保留 BM25 路径(即使 dense 更准)——可解释性是价值。
可解释性的"教学"价值
好解释让团队理解 IR 基础:
- 新人看 BM25 explain 学到 TF-IDF
- 业务看 explain 理解"什么样的 query 对"
- 产品看 explain 设计更好的产品体验
技术的可解释性促进团队能力——不只是工具。
BM25 的 "lived transparency"
很多系统宣称"可解释 AI"——多是事后附加的。BM25 的可解释性是天生的——不是"额外加的 feature"、是算法本质。
这种"lived transparency"的价值在 AI 时代反而凸显——用户和监管对 AI 系统的透明度要求越来越高。老算法的新价值——BM25 不会退场的原因之一。
结语:不要低估稀疏
每次有人说"BM25 过时了、dense 才是未来"——可以拿出可解释性反驳。好算法不会过时、找到自己的场景。
BM25 1994 年发明、30 年后仍是生产 RAG 主力——部分因为精度、更因为可解释、可控、可审计。这些属性在 AI 时代比精度更稀缺。
12.19 跨书关联:BM25 的算法哲学
BM25 诞生 30 年仍不过时——因为它抓住了信息检索的基本物理:稀有词贡献更多信息、但贡献非线性饱和、长度差异要补偿。这三条原则和 TCP 的拥塞控制、页面置换的 LRU、机器学习的正则化共享同一种美学:简单假设 + 数学友好 + 工程实测不错。
《Hyper 与 Tower:工业级 HTTP 栈》讨论过 TCP 的 congestion window——同样是"基于经验公式 + 参数可调 + 鲁棒"。好算法在生产系统里的生命周期往往 10-30 年——2026 年你用 BM25 不代表你落后,你用的是经过时间验证的工程 SOTA。
12.20 本章小结
- BM25 不是 embedding 的替代品——是互补。精确术语、长尾词、高精度、多语言混文场景 BM25 仍是王者
- 三件套:TF 饱和 + IDF 稀有度 + 长度归一化——每一项都有工程直觉
- 倒排索引支撑 BM25 的毫秒级检索、存储比 embedding 省 30 倍
- tokenizer 是 BM25 的生死线——中文尤其关键、同义词表配合
- SPLADE 是稀疏神经检索的新路线——保留可解释性 + 学习能力
- 生产实现多种选择:Elasticsearch / Tantivy / BM25s / pgvector / 向量库自带
- BM25 的六类坑(停用词、大小写、数字、短语、否定、长 query)要各自处理
下一章讲 Hybrid Search——把 embedding 召回和 BM25 召回融合成一个 unified top-k。