news 2026/10/20 6:18:28

稀疏检索全解:从BM25到SPLADE的工程实践与对比分析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
稀疏检索全解:从BM25到SPLADE的工程实践与对比分析

1. 先搞清楚“稀疏”到底在说什么:一个高维空间的直觉

今天是我这个数据科学学习日志的第37天,检索这个话题我拖了很久才写。不是因为难,而是因为站在2025年回头看,“稀疏检索”这四个字总给人一种过时技术的感觉——毕竟现在到处都在讲语义嵌入、向量数据库、大模型RAG,谁还提词频和倒排索引呢?

但等我真正把稀疏检索从头捋了一遍才发现,它不仅没有过时,反而在很多关键环节依然无法被替代。这篇日志我就想把稀疏检索这件事讲透:它为什么叫“稀疏”,算法核心是什么,和当前主流的稠密检索相比到底输在哪、赢在哪,以及我花了两个晚上在一个模拟检索项目上把整个管线跑通的全部细节。

先从“稀疏”这个词说起。

在信息检索里,文本通常被表示成词袋模型,也就是把一段文本映射为一个高维向量,向量的每一维对应词表中的一个词。举个最朴素的例子:假设有一句话“猫坐在垫子上”,我们统计它包含哪些词,得到一个只在这几个词对应维度上非零、其余维度全为0的向量。词表如果是20万个词,这句话的向量就是20万维,但其中只有十几个维度有值。

这就是“稀疏”的全部含义:向量维度极高,但非零元素极少。

我刚学到这里时有个困惑:高维空间不是有“维度灾难”吗?为什么检索偏偏喜欢这种极端高维的表示?后来才琢磨明白,维度灾难的前提是你必须完整计算所有维度的坐标。但稀疏向量非零元素太少,你完全可以只枚举非零分量,把存储和计算都集中在有信息的少数维度上。换句话说,稀疏向量的“高维”只是名义上的,真正参与计算的东西非常少。

如果用生活类比来理解,稀疏向量就像一本只有每页几十个词被画了重点的书:全书有几百页,但你要看的其实就那几十个标注。检索的时候,你只需要把画了重点的地方翻出来,不会去逐页扫描全文。

这个直觉是理解一整套稀疏检索技术的钥匙。不管是经典的TF-IDF、BM25,还是后来的学习型稀疏表示SPLADE,做的一切都是在回答同一个问题:高维空间里,哪些维度真正值得给出权重?怎么给?

我后来在做项目的时候,经常见到有人一提“向量检索”就默认要上稠密嵌入,结果数据集里的文本是产品型号、故障代码这种高度精确的词项,语义向量反而召回一堆不相关的模糊结果。这种场景下,传统的稀疏匹配几乎是最优解。这也是我决定把稀疏检索作为今天主题的直接原因——它跟实践贴得太近了。

2. 倒排索引:稀疏检索的“地基”与加速引擎

理解了稀疏向量的概念,下一个绕不开的部件就是倒排索引。没有倒排索引,稀疏检索的“高效”就是一句空话。我第一次尝试用暴力方式做向量内积,遍历全部文档计算相似度,数据量只有几千条时还能忍受,一旦上了一万条,响应延迟立刻从几十毫秒涨到几百毫秒,而这还只是演示环境。

倒排索引的直觉非常简单:正向索引是“文档→词”,倒排索引则是“词→文档列表”。它把索引的构建方向反转过来,为每一个词项记录“哪些文档包含这个词”,这个词对应的文档编号列表通常叫倒排列表(posting list)。

检索一个查询词时,算法步骤是:

  1. 查询语句分词,得到一组查询词项。
  2. 对每个词项,在倒排索引的词典中查找它的倒排列表。
  3. 合并这些倒排列表,统计每篇文档命中了哪些查询词项。
  4. 用打分公式(TF-IDF、BM25等)计算文档和查询的相关性分数。
  5. 按分数排序,返回Top-K结果。

整个过程的计算密集点只在倒排列表的合并与打分上。由于查询词项本身数量很少(通常就是个位数到十几个),参与计算的倒排列表也少,所以检索比遍历全库快好几个数量级。我模拟项目里两万多条文档,单次查询的平均响应时间可以压到十几毫秒,这在稠密向量需要依赖ANN索引才能达到的延迟水平下,稀疏检索用最朴素的结构就做到了。

倒排索引里还有一些老练的优化手段,比如跳表合并。当两条倒排列表都比较长时,用跳表或分块索引快速跳过不可能相交的区间,避免逐条比较。还有个常见做法是文档编号按某种顺序编码,用位图或精心设计的压缩算法存储倒排列表,既能省内存又能利用SIMD指令加速合并。这些细节我在项目里只摸了一部分,但能明显感受到:稀疏检索之所以快,不是某个单点很惊艳,而是从数据结构到打分公式整套配合得严密。

还有一个很容易忽略的点:倒排索引天然支持布尔组合。你可以非常自然地处理“必须包含A但不包含B”这类约束,比如在学术检索里要求某篇论文摘要同时出现“图神经网络”和“推荐系统”,这种精度是纯模糊语义向量很难表达的。可以说,只要是涉及精确词项、指定字段、布尔逻辑的搜索,稀疏表示和倒排索引始终是基础设施级别的最优选择。

我自己在构建索引时有一条血的教训:分词结果直接决定索引质量。英文倒还好,词项基本按空格分;中文如果不做合理的分词,一条短语会被切得支离破碎,比如“稀疏检索”被切成“稀”“疏”“检”“索”四个单字,查询时完全匹配不上。后来我在预处理阶段正确配置中文分词器,把“稀疏检索”作为整体索引,效果立刻不一样。这里先埋个伏笔,后面写实践部分我再展开。

3. TF-IDF与BM25:经典公式为什么至今还在牌桌上

索引提供了候选集,打分公式则决定排序。稀疏检索最核心的两个打分公式是TF-IDF和BM25。我第一次看到它们时觉得公式简单得像玩具,完全无法理解为什么一堆从事信息检索几十年的研究者至今还在研究如何改进BM25。直到自己跑过排序结果、调过参数,才发现“简单”和“有效”之间隔着大量的经验细节。

3.1 TF-IDF的三个直觉

TF-IDF由两部分组成:词频和逆文档频率。词频衡量一个词在文档中出现了多少次,直觉是出现越多的词越重要;逆文档频率衡量一个词在整个文集中的稀缺程度,直觉是越罕见的词携带的信息量越大。

具体公式通常是:

  • TF = 词在文档中的出现次数
  • IDF = ln((N + 1) / (df + 1)) + 1

其中N是文档总数,df是包含该词的文档数。+1和+1是平滑项,防止分母为0,也让新出现的词不会得到负的IDF。最终,TF与IDF相乘,就是这个词在当前文档中的权重。

但有一个关键问题:单纯的词频会把长文档带偏。一篇5000字的说明文档里,“系统”可能出现50次,而一篇500字的短文档里“系统”出现10次。按绝对词频,长文档的这个词权重会高出很多,但这并不能代表长文档与查询更相关。TF-IDF里的词频没有做很好的长度归一化,这是它的一个天然短板。

3.2 BM25把词频打上了“饱和”和“归一化”两个补丁

BM25是BM25系列中最常用的BM25函数,公式看着复杂,拆开就是两个改进:

  • 词频饱和:词频对打分的影响不是线性的,而是随着次数增加逐渐饱和。
  • 文档长度归一化:综合文档长度与整个文集平均长度的相对关系来调整词频贡献。

BM25打分公式长这样:

score(D, Q) = Σ_{q_i in Q} IDF(q_i) × (tf(q_i, D) × (k1 + 1)) / (tf(q_i, D) + k1 × (1 - b + b × |D| / avgdl))

其中有两个超参数:k1控制词频饱和的强度,b控制文档长度归一化的强度。k1默认值一般在1.2到2.0之间,b默认0.75。k1越小,词频达到一定次数后,再增加次数对分数的拉动越弱;b越大,文档长度对分数的惩罚越明显。b设为0时文档长度完全不参与归一化,设为1时完全归一化。

我实际体验下来的感受是:k1和b对排序结果的影响是实打实的。我拿模拟项目里的一组技术FAQ数据做了个对比实验,用同一个查询词,固定b=0.75不变,把k1从1.0调到2.0,Top-10结果里有两条排序位置发生了明显对调。原因是短文档里如果某个词出现多次,k1较小时词频被迅速压饱和,词频高的优势反而不容易发挥出来;k1较大时,同样的词频能拉开更大分差,长文档里多次命中的优势又被放大了一些。所以不存在一个“绝对最优”参数,完全看你的文档长度分布和查询习惯。

3.3 表格对比:TF-IDF与BM25

对比维度TF-IDFBM25
词频处理线性累加,词频高就一直加分引入饱和函数,多次出现的边际收益递减
文档长度归一化基本没有b参数显式控制,结合平均文档长度
参数数量无(或只有平滑项)k1、b两个超参数需要调节
调参成本很低中等,但调好后效果普遍更好
排序稳定性长文档容易虚高对文档长度不敏感,排序更稳

我个人的结论很直接:只要不是临时做原型、需要最小可用版本,直接优先选BM25。它比起TF-IDF多花的调参时间,通常一两天就能在效果上赚回来。而且现在几乎所有检索框架都自带BM25实现,没有太多额外成本。

4. 稀疏检索与稠密检索的正面Battle:不是旧王与新人,是各司其职

到了这一步,就必须正视那个所有检索相关文章都会遇到的问题:稀疏检索已经这么成熟了,为什么这几年密集检索、向量数据库、语义搜索会大火?它们到底比稀疏强在哪?

稠密检索的思路和稀疏完全相反。它用一个深度编码模型(比如基于Transformer的编码器)把文本整体映射成一个固定维度的稠密向量,比如768维或1024维,向量里每一个维度都有非零的实数值,语义相近的文本在向量空间里距离也近。

这种做法有一个关键优势:语义匹配。你搜索“怎么修复电脑黑屏”,传统稀疏检索可能因为查询词和文档没有重叠词项而召回不到结果,但稠密向量编码器能理解“黑屏”和“显示器不亮”在语义上是相关的,从而把这两者关联起来。

我在给模拟项目加语义搜索能力时专门对比过这两类的召回差异。某个查询意图非常口语化,比如“笔记本开机没反应”,文档里写的是“按下电源键无任何指示灯亮起”,两个句子一个共同词都没有。BM25对这种查询几乎无能为力,稠密检索却能在Top-3里命中相关文档。这个体验非常震撼,也是许多人转向语义检索的直接原因。

但稠密检索并不是没有代价的。它的第一代价是资源:固定维度向量需要GPU推理才能得到,对海量文档做离线编码要花不少算力,在线查询也得经过一次模型推理才能拿到查询向量。第二代价是近邻搜索:向量库在高维度做近似近邻搜索,要么建图(如HNSW)、要么乘积量化,索引构建复杂度和存储成本都比倒排索引高一个量级。第三代价是可解释性:一个三维度漂浮的实数向量完全没法向用户解释“为什么返回这篇文档”,而TF-IDF可以直接展示命中哪些词、每个词贡献多少分。

所以在真实的检索系统里,我的经验是不要做二选一,而是做混合检索。一条查询同时丢给BM25倒排索引和稠密向量库,各自返回候选结果,再用一个融合策略把两边的排名合并。最常见的融合方案有这么几种:

  • 加权分数相加:两边分数分别做归一化或对数变换后加权求和。
  • 倒数排序融合:不依赖分数绝对值,只看排名位置。
  • 级联重排:先用稀疏检索快速召回几百条,再用稠密或更重的模型做精排。

我在模拟项目里试过加权融合,发现一个麻烦:BM25分数和余弦相似度的数值尺度差异很大,加权系数非常难调。后来改用倒数排序融合,突然就变得很稳定。思路是这样:把每条文档在BM25结果里的名次记为rank_sparse,在稠密结果里的名次记为rank_dense,融合分等于两个名次的倒数相加(通常再加一个常数k防止分母为零)。这种做法的好处是它不关心各检索器自己的分数分布,只看排名,鲁棒性一下子提升了。

现实中我见过不少团队在混合检索的融合策略上栽跟头,就是因为太执着于把两边分数合成一个符合直觉的“总分”。但分数尺度和分布完全不同,直接相加很容易让某一方主导。从排名角度去融合,虽然看似丢失了分数信息,实际效果反而稳定得多。

5. 学习型稀疏检索:SPLADE为什么能让稀疏“翻红”

说到这,很多人可能会默认稀疏检索就是完全规则化的、没有语义能力的“老古董”。但近几年的研究早就打破了这个印象,以SPLADE为代表的一类方法,让“稀疏”和“语义”在同一套向量表示里共存,我做项目调研时看到这种设计的思路,确实有眼前一亮的感觉。

SPLADE的思路可以简单拆解成三步:

第一,文本输入给一个预训练语言编码器,得到每个位置的语境化表示。这一步和第二、三步一样都基于Transformer结构的语言模型,本质上用了和稠密检索相同的语义理解能力。

第二,通过一个输出层,把每个位置的表示映射到词表空间,预测当前位置与每个词表词项的相关性分数。

第三,把所有位置的分数聚合起来,再经过激活函数剪枝,保留下每个词项的权重,形成一个稀疏向量。

这个输出结果和TF-IDF/Bm25式的稀疏向量长得一样:维度是词表大小,只有少数词项非零,但权重来自于语义上下文,而不是朴素的统计。比如查询“笔记本开不了机”,SPLADE在输出里可能不仅保留“笔记本”“开机”这两个原词的口感,还会给“无法启动”“电源”“显示器”等语义相关的词一些权重,让后续的倒排索引匹配不再依赖字面完全相同。

这种设计带来的好处非常实际。部署阶段它依然可以用标准倒排索引,不需要建ANN索引;存储在词项级稀疏向量,内存远小于稠密向量;检索时还能利用BM25的成熟工程实现。换句话说,SPLADE在工程层面延续了传统稀疏检索的一切优点,算法层面又补上了语义泛化能力,它是“在传统公开排名上做文章而非另起炉灶”的结果。

我对SPLADE有个诚实的评价:它的语义召回能力确实达不到最顶尖稠密模型的程度,在大量需要比喻、转述、跨语言这种极端语义场景下还是逊色一些。但如果你的目标是“用比稠密向量低得多的维护成本,把召回效果从纯BM25提升一截”,这个性价比非常高。我甚至觉得很多中小团队根本不需要第一时间上千万级稠密向量库,先用SPLADE这样带语义能力的稀疏检索顶住,等到数据量和复杂查询场景真正增长到非稠密向量不可了,再去建混合检索也不迟。

另外提一句,工程上落地SPLADE向量要注意的点:它产出的向量本来就是词项级权重,转换成分数为BM25式打分前,通常要把负数权重截断成零。这样一个高维稀疏向量才能真正复用到标准倒排索引的检索流程里。如果不做这一步,稀疏性会被破坏,得到的向量反而比稠密向量还难处理,有点“画虎不成反类犬”的感觉。

6. 从零搭一套最小可用的稀疏检索管线:我的实践笔记

前面的原理和对比讲了这么多,最后必须落到实操。我在一个模拟项目X上完整跑通了一遍稀疏检索管线,项目内容是2万多条技术类和产品类混合文档,语言以中文为主。这里把我的操作过程整理成笔记,包括参考代码、评估结果和踩坑记录,供想动手的人直接参考。

6.1 数据预处理与索引构建

预处理这步决定索引质量。我的处理流程是这样的:

  1. 统一文本格式,去掉HTML标签和多余空白。
  2. 中文文本做分词,这里直接用了通用的中文分词组件,分词器需要引入尽可能大的领域词表,能识别“稀疏检索”“语义匹配”这类整体词。
  3. 去掉自定义的领域停用词列表,注意不是照搬教科书停用词表,必须结合文档领域调整。比如在技术文档里,“技术”“问题”这种高频泛词在检索里几乎没有区分度,我就把它们放进停用词,但保留“故障”“版本号”这类信息量高的词。
  4. 对每篇文档统计词项频率,并计算文档长度,供BM25打分使用。

构建索引时,我用了一个常见的检索开源库来建倒排索引,它也自带BM25实现,省去了自己写倒排表的麻烦。我的模拟项目X文档量不大,索引构建只需要几十秒。如果数据量更大,分布式索引那些话题就另说了,这里不展开。

6.2 核心代码片段

下面给出两条最核心的代码片段。第一段是构建索引并打分的入口,第二段是BM25的实现思路(我为了理解原理,自己照着公式重写了一个简化版):

import jieba from collections import Counter STOP_WORDS = set(["的", "了", "和", "是", "技术", "问题"]) def tokenize(text): words = jieba.lcut(text.lower()) return [w for w in words if w.strip() and w not in STOP_WORDS] def evaluate(query, index, documents): """query: 查询字符串, index: 倒排索引对象, documents: 文档列表""" tokens = tokenize(query) scores = index.search(tokens, k=10) for rank, (doc_id, score) in enumerate(scores): print(f"Top-{rank+1}: doc {doc_id}, score = {score:.4f}")

BM25简化实现中关键的打分公式部分:

def bm25_score(tf, df, doc_len, avg_dl, n_docs, k1=1.5, b=0.75): idf = math.log((n_docs - df + 0.5) / (df + 0.5) + 1.0) tf_factor = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc_len / avg_dl)) return idf * tf_factor

这个简化版没有包含词项在查询语句里的权重,但已经足够跑通一个小项目。真正用开箱即用的方案,直接调检索库自带的BM25评分器即可。

6.3 评估:指标的功利与真实反馈

我用一批人工标注的查询-相关文档对做了离线评测,测试集有60条查询,每查询平均标注了5篇相关文档,评价指标用了MRR、Recall@10和P@10。

跑完的结果让我再次确认:BM25默认参数虽然不算最优,但在多数查询上已经表现出稳定可用性。查询词项和文档有较充分重叠时Top-1命中率很高;而口语化改写严重的查询,还真的会掉链子。具体数据如下:

方法MRRRecall@10P@10
TF-IDF基线0.410.480.32
BM25(k1=1.5, b=0.75)0.550.620.41
BM25(k1=1.2, b=0.5)0.520.600.39
BM25调参后(k1=0.9, b=0.4)0.580.660.44

做一个诚实的说明:这个数据集是我自己构造的,查询之间差异很大,这些数字不具备普遍性,只能说明不同参数确实会造成明显差异。我在调整参数时观察到,当文档长度非常不均衡时,把b调低一些会更稳,因为过度做文档长度归一化会压制长文档里原本有意义的词频信号。

6.4 踩坑记录:四个真问题

第一个坑是中文分词粒度。最初把“数据科学”当成一个词还是拆成“数据”“科学”,会直接影响是否命中。我的建议是分词工具的词典要和领域定制词表配合,越贴近真实语言习惯越好。

第二个坑是停用词过滤过度。有些词在通用语料里是停用词,但在特定领域是关键词,比如“数据”在通用文本里极高频,但在数据科学文档里恰恰是核心概念,删了反而召回率骤降。我后来把停用词表设计成了可按领域切换的配置项,而不是写死一套。

第三个坑是BM25的k1和b不分场景乱调。最开始的实验跟着默认值走,效果中规中矩,后来专门针对短查询多、文档长短差异大的场景把k1调低到0.9附近,明显减少了短文档被词频饱和压分的问题。调参前一定要想明白你到底希望词频和文档长度各自发挥多大作用。

第四个坑是只测离线指标不看线上感受。MRR高不代表用户满意,因为很多查询的“正确答案”不止一种表达方式。我把20条典型查询的实际Top-10打印出来人工看了一遍,发现好几条虽然命中了标注文档,但排序里也混进了完全无意义的公版文档,这又逼着我去调了停用词和分数阈值。

7. 我最后想保留的一点私人体会

如果把这几天的学习体会浓缩成一句话,那就是:稀疏检索不是陈旧技术的代名词,它更像一套“以少量关键词为锚点、以精确词项匹配为核心”的检索世界观。它有明确的边界能力,也有可靠的工程表现。在和稠密检索、混合检索做过一轮又一轮对比后,我现在更倾向把“稀疏”和“稠密”放在同一条技术光谱上来理解,而不是把它们对立起来。

有一件事是我在做模拟项目时印象最深的:当我把BM25参数从默认调成更适合自己数据分布的组合后,同样的六条查询里有三条从Top-5之外直接进了Top-3。那一刻我突然意识到,所谓“传统算法”,往往不是因为做到头了才变得传统,而是因为它太稳定,以至于很多人都懒得再去研究它内部还能怎么优化。

如果未来还有机会再做检索方向的项目,我会优先考虑这样的扩展路径:先上BM25,再叠加SPLADE式稀疏向量,最后按需引入稠密向量做混合检索和重排。每一步都是在前一步基础上增加能力,而不是推翻重来。我也真心建议还在纠结“要不要上语义搜索”的团队,先把手头的稀疏检索效果调到位——很多时候,真正的检索瓶颈并不在模型不够强,而在于基础匹配器还远没有被用透。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 6:18:05

Python人脸识别门禁系统实战:RetinaFace+ArcFace从零搭建

1. 从零搭建人脸识别门禁系统:为什么我选Python而不是C很多人一提到人脸识别门禁,第一反应就是"这玩意儿得上C吧,Python跑得动吗?"我当初也这么想过。三年前接了一个园区门禁改造的活儿,需求很明确&#xff…

作者头像 李华
网站建设 2026/10/20 6:17:03

把上班当交易:用交易心态破解职场内耗与职业焦虑

1. 把上班当成交易,心态立刻就稳了这几年听得最多的一句话就是“上班如上坟”,好像工作是什么苦大仇深的负担。但我在职场上摸爬滚打这么多年,越来越觉得,上班本质上就是一场冷静的交易:你付出时间和专业技能&#xff…

作者头像 李华
网站建设 2026/10/20 6:15:55

RabbitMQ消息可靠发送实战:从连接管理到确认机制与Outbox模式

1. 为什么"发送到RabbitMQ"会成为一个需要认真对待的问题刚接触消息队列的同学,往往觉得发送消息就是"连上服务端,调个basic_publish,完事"。真实的生产环境根本没这么温柔。我见过不少项目,开发阶段一切正常…

作者头像 李华
网站建设 2026/10/20 6:15:48

artcraft 工艺单元与配置驱动:构建可复现的创意生成流水线

1. 从“artcraft”这个名字说起:它到底想解决什么问题第一次看到“artcraft”这个项目标题,我的直觉是:这大概率是一个把“艺术创作”和“工程实现”缝合在一起的东西。art 是艺术,craft 是手艺、工艺,合在一起就是“艺…

作者头像 李华
网站建设 2026/10/20 6:13:18

C#算法实战:从LINQ到并发场景的性能优化与踩坑总结

不是想聊LINQ吗?那我们先从一个场景说起。某个月底,团队接到一个数据清洗需求:从一份百万级订单表里,按客户聚合金额、过滤掉异常记录、再按时间窗口做统计。临时表也能干,但改业务逻辑要重新部署存储过程,…

作者头像 李华