Skip to content

第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.95

TF 饱和的意义:防止"关键词堆砌"——一篇 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 企业版 定价" 时:

  1. 读三个词各自的 posting list
  2. 对出现在多个 list 里的 doc 算 BM25 分数(交集)
  3. 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@20
  • index_doc_countindex_size_mb:索引规模

和 embedding 召回在同一块看板上对比——能看出两者强弱。某类 query 在 BM25 高但 embedding 低——说明是字面匹配敏感型;反之则是语义匹配敏感型。这些分析指导融合策略调整。

12.10 BM25 vs Dense 的互补性实证

BEIR benchmark(Thakur et al., arXiv:2104.08663)在 18 个数据集上系统对比 BM25 和多种 dense retrieval 模型。几个反直觉的发现:

数据集BM25Dense赢家
MS MARCO(通用搜索)0.2280.329Dense
TREC-COVID(医疗)0.6560.594BM25
BioASQ(生物医学)0.4650.305BM25
NQ(自然问答)0.3050.398Dense
FiQA(金融 QA)0.2360.274Dense
SciFact(科学事实核查)0.6650.502BM25

观察:

  • 通用 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_phrase slop 参数都是这个

生产 RAG 里短语匹配常作加权叠加:BM25 标准召回 + 精确短语匹配 bonus 分,而不是硬过滤——避免"没有精确短语就找不到"的极端。

伪相关反馈(PRF / RM3)

经典 IR 里的 pseudo-relevance feedback:假设 top-k 召回里的前几条真相关、从这几条里抽高 IDF 词补进原 query、再召回一轮。RM3(Lavrenko 2001)是最著名的实现。

工作流:

  1. 用原 query 跑一次 BM25、拿 top-10
  2. 从 top-10 里抽 20-50 个 IDF 高的关键词
  3. 把这些词按权重加到原 query(原词权重高、扩展词权重低)
  4. 用扩展后的 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 策略:

  • 字符类型切分:中文按字 / 词切、英文按空格切、数字和符号独立
  • 大小写统一:英文部分小写化、SSOsso 统一到 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-5ms5000+ QPS
3 词 AND 查询5-15ms2000-3000 QPS
Phrase 查询10-30ms1000 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.8

Learned 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 等实现

生产部署流程:

  1. 用 SPLADE 模型把 chunk 转成稀疏向量(~100-300 非零维度)
  2. 存倒排索引(每个非零维度 = 一个 "虚拟词项")
  3. 查询时 query 也过 SPLADE 产出稀疏向量、走标准倒排查询
  4. 可选:和 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 本章小结

  1. BM25 不是 embedding 的替代品——是互补。精确术语、长尾词、高精度、多语言混文场景 BM25 仍是王者
  2. 三件套:TF 饱和 + IDF 稀有度 + 长度归一化——每一项都有工程直觉
  3. 倒排索引支撑 BM25 的毫秒级检索、存储比 embedding 省 30 倍
  4. tokenizer 是 BM25 的生死线——中文尤其关键、同义词表配合
  5. SPLADE 是稀疏神经检索的新路线——保留可解释性 + 学习能力
  6. 生产实现多种选择:Elasticsearch / Tantivy / BM25s / pgvector / 向量库自带
  7. BM25 的六类坑(停用词、大小写、数字、短语、否定、长 query)要各自处理

下一章讲 Hybrid Search——把 embedding 召回和 BM25 召回融合成一个 unified top-k。

基于 VitePress 构建