开篇:你有没有被搜索气到过?
凌晨两点,生产环境炸了。
你手指颤抖地在公司知识库搜索框里敲下"线上OOM怎么排查",回车。出来的结果是:第1条《什么是OOM?新手入门指南》,第2条《Java内存模型详解(上)》,第3条《每周技术分享第47期:JVM那些事》。你翻到第17页,终于在某个不起眼的角落找到了那篇《2023年双十一订单系统OOM排查复盘》,里面写着你要的答案。
但这个时候,告警已经响了四十分钟。
你可能没想过,这个"搜不到东西"的问题,背后是一整个学科在较劲。信息检索这个领域发展了几十年,到今天大致分成了两个门派。一派叫"稀疏检索",代表选手是BM25,思路简单粗暴,你说什么词我就找什么词,一个字都不差。另一派叫"稠密检索",代表选手是各种Embedding模型,思路高级一些,我不管你说的是哪几个字,我懂你的意思。
在RAG,也就是检索增强生成这个火得一塌糊涂的技术里,第一步永远是"找到相关文档"。找不到,后面大模型再厉害也是巧妇难为无米之炊。这篇文章就带你把这两套系统从里到外翻个遍,看看同样一段文字,在两种系统里分别是怎么跑起来的。
为了方便讲清楚,我们拿三段文档当例子贯穿全文,分别贴上标签:
- doc_0 [AI/ML]:Machine learning is a subset of artificial intelligence that enables computers to learn from data without being explicitly programmed. It uses algorithms to identify patterns and make decisions.
- doc_2 [AI/NLP]:Natural language processing (NLP) is a field of AI that focuses on the interaction between computers and human language. It involves tasks like text classification, sentiment analysis, and machine translation.
- doc_9 [AI/DL]:Deep learning is a subset of machine learning that uses neural networks with multiple layers. It has achieved breakthrough results in computer vision, speech recognition, and natural language processing.
这三段话都跟AI有关,但侧重不同,正好用来观察两种检索方式的差异。
上半场:BM25,那个你天天用但叫不出名字的算法
从"查字典"到"倒着查字典"
先说个最朴素的想法。如果我有100篇文档,要找包含"learning"这个词的,怎么办?
最笨的办法,一篇一篇读,读到了就记下来。100篇还好,100万篇呢?每次搜索都从头到尾扫一遍,用户早睡着了。
所以聪明人选了另一条路,不按文档找词,反过来,按词找文档。就像书后面的索引,你想找"机器学习",翻到索引页,它直接告诉你在第37页、第89页、第156页。这就叫倒排索引。
这个想法一点都不新,图书馆里用了上百年。但把它做到极致,就是BM25这一套东西的基石。
先别急着讲算法,我们来想一个问题:当你把一段话扔进搜索引擎的时候,搜索引擎看到的是什么?
它看到的不是一段话,它看到的是一堆词。或者说得更专业一点,一堆token。
分词这件小事,没你想的那么简单
分词,就是把一句话拆成一个个最小单位。你可能觉得,英文不就是按空格切吗?有什么难的?
那你试试处理这个句子:
“用 Python3 写的 API_KEY_123 在 C++ 环境下跑 BERT-base 模型 v2.0.1 时遇到了 bug,联系 admin@example.com”
按空格切肯定不行。API_KEY_123 虽然有下划线,但它是一个东西,不能拆。C++ 后面两个加号,你按空格切没问题,但按"非字母字符"切就切成C了。版本号 v2.0.1 是一个整体,邮箱地址也是一个整体。还有 JavaScript 这种驼峰词,BERT 这种全大写缩写,你要不要转小写?转了之后 Apple 和 apple 就分不出来了,一个是公司一个是水果。
所以真正能用的分词器,都是一串正则表达式按优先级排队,从最具体的模式往最通用的模式挨个匹配。大概长这样:
patterns=[r'...@...',# 电子邮件,最具体,优先匹配r'[A-Z0-9]+[_-][A-Z0-9]+',# 代号加连接符,API_KEY_123, XK9-2B4-7Q1r'[A-Z]\+\+',# 技术术语,C++, C#r'\d+(?:\.\d+)+',# 版本号,3.14, 2.0.1r'[A-Z]{2,}',# 大写缩写,API, NASA, HTTP,保留大写r'[A-Z][a-z]+[A-Z]',# 驼峰词,JavaScript, PyTorch,保留大小写r'[A-Za-z]+\d+',# 字母数字混合,Python3, ES6, HTML5r'\d+(?:\.\d+)?',# 数字r"[a-zA-Z]+(?:'[a-z]+)?"# 普通单词,最后捕获,转小写]优先级很重要。你得先匹配邮箱,再匹配普通单词,不然邮箱地址里的字母会被当成普通单词拆得七零八落。
拿我们的 doc_0 来跑一遍分词:
原文是 “Machine learning is a subset of artificial intelligence that enables computers to learn from data without being explicitly programmed. It uses algorithms to identify patterns and make decisions.”
经过正则链逐层匹配,再去掉停用词,最后得到17个有效token:
['Machine', 'learning', 'subset', 'artificial', 'intelligence', 'enables', 'computers', 'learn', 'data', 'without', 'explicitly', 'programmed', 'uses', 'algorithms', 'identify', 'patterns', 'decisions']这里有个细节值得说一下。停用词,也就是 is, a, of, that 这种没什么信息量的功能词,不是所有词都过滤。只过滤纯小写的词。
为什么?因为 IT 这个词,小写 it 是停用词,但大写 IT 可能是信息技术。AI 也是一样,小写 ai 可能在某些停用词表里,但大写 AI 肯定是人工智能。如果不分青红皂白全过滤了,就会把这些缩写误删掉。
这个小细节,做得不好的分词器根本不会注意到,但在技术文档场景里,差别挺大的。
三张表撑起一个搜索引擎
分完词之后,就要建索引了。每加入一篇文档,背后有三张核心表在同步更新。
第一张表,倒排索引,给定一个词,告诉你它出现在哪些文档里。比如 “machine” 出现在第0篇和第9篇。
第二张表,词频表,给定一篇文档和一个词,告诉你这个词在这篇文档里出现了几次。比如 doc_0 里 “machine” 出现1次。
第三张表,文档长度表,给定一篇文档,告诉你它总共有多少个token。比如 doc_0 去停用词后有17个token。
classInvertedIndex:index:Dict[str,Set[int]]={}# 倒排索引,"machine" → {0, 9}term_frequency:Dict[int,Counter]={}# 词频,doc_0 → {"machine": 1, ...}doc_lengths:Dict[int,int]={}# 文档长度,doc_0 → 17我们把三篇文档挨个加进去,感受一下这个过程。
加 doc_0 的时候,17个词第一次进来,每个词的倒排表里都只有一个元素,就是0。词频表里每个词都是1。文档长度是17。
加 doc_2 的时候,有意思了。比如 “language” 这个词,doc_0 里也有,所以它的倒排表就从 {0} 变成了 {0, 2}。而 “nlp” 这个词第一次出现,倒排表就是 {2}。
加 doc_9 的时候,“learning” 这个词在 doc_0 和 doc_9 里都有,倒排表变成 {0, 9}。“deep” 是新词,倒排表是 {9}。
最后三张表的状态,大概是这个样子:
| 词 | 出现在哪(倒排) | doc_0 词频 | doc_2 词频 | doc_9 词频 |
|---|---|---|---|---|
| machine | {0, 9} | 1 | 0 | 1 |
| learning | {0, 9} | 1 | 0 | 1 |
| intelligence | {0} | 1 | 0 | 0 |
| language | {0, 2} | 1 | 1 | 0 |
| deep | {9} | 0 | 0 | 1 |
| neural | {9} | 0 | 0 | 1 |
| nlp | {2} | 0 | 1 | 0 |
| processing | {2, 9} | 0 | 1 | 1 |
三张表建好了,搜索就快了。用户搜什么词,直接查倒排表,立刻知道哪些文档至少包含一个词,候选集就出来了。
打分的艺术,BM25到底在算什么
候选集有了,但哪个排第一哪个排第二?这就需要打分。
BM25的核心直觉就一句话,不是每个词都同等重要。一个词对文档得分的贡献,由三个因素共同决定:
第一个因素,词频,也就是TF。这个词在文档里出现得多不多?出现得多说明相关度高,但也不是越多越好,有个饱和上限,出现10次和出现100次差别不大。
第二个因素,稀有度,也就是IDF。这个词在整个文档库里有多罕见?越罕见的词,区分力越强。比如"的"这个字到处都是,告诉你一篇文章里有"的"字,等于什么都没说。但"贝叶斯优化"这个词只在少数文档里有,一搜一个准。
第三个因素,文档长度。这篇文档比平均长度长还是短?长文档天然词多,随便一个词都可能出现,所以要惩罚一下。同样出现1次,在一篇500字的短文里比在一篇5000字的长文里更有说服力。
这三个因素揉在一起,就是BM25的公式。
我们拿实际的例子算一遍,就什么都懂了。假设查询是 “deep learning algorithms”。
第一步,把查询也分词,得到三个词:deep, learning, algorithms。
第二步,查倒排索引找候选文档。deep 在 doc_9,learning 在 doc_0 和 doc_9,algorithms 在 doc_0。所以候选文档是 doc_0 和 doc_9,doc_2 一个词都不沾边,直接排除。
第三步,给每个候选文档算分。
先算每个词的IDF,这是全局统计量,跟具体文档没关系。总共有3篇文档,平均长度是(17+19+20)/3≈18.67。
IDF的公式长这样:
idf = ln((N - df + 0.5) / (df + 0.5) + 1)df是文档频率,也就是有多少篇文档包含这个词。
算一下:
- idf(‘deep’) = ln((3-1+0.5)/(1+0.5)+1) ≈ 0.981
- idf(‘learning’) = ln((3-2+0.5)/(2+0.5)+1) ≈ 0.470
- idf(‘algorithms’) = ln((3-1+0.5)/(1+0.5)+1) ≈ 0.981
看到了吗,deep只在1篇里出现,很稀有,IDF接近1。learning在2篇里都有,比较常见,IDF只有0.47。deep的区分力是learning的两倍多,这就是IDF的作用,它帮你找到那些真正能把文档区分开的词。
然后算每个词对具体文档的贡献。BM25里有两个常用参数,k1=1.5控制TF饱和的速度,b=0.75控制长度惩罚的强度。
对doc_0来说,它不包含deep,所以deep贡献0分。只算learning和algorithms。
对于’learning’,doc_0里tf=1,文档长度dl=17,平均长度avgdl≈18.67:
分子 = tf * (k1 + 1) = 1 * 2.5 = 2.5 分母 = 1 + k1 * (1 - b + b * (dl / avgdl)) = 1 + 1.5 * (0.25 + 0.75 * 17/18.67) = 1 + 1.5 * (0.25 + 0.683) = 1 + 1.5 * 0.933 = 2.400 score_learning = idf * (分子 / 分母) = 0.470 * (2.5 / 2.400) ≈ 0.490algorithms的tf也是1,所以算出来是 0.981 * 1.042 ≈ 1.022。
doc_0总分就是 0 + 0.490 + 1.022 = 1.512。
再算doc_9。doc_9包含deep和learning,不包含algorithms。
对于’deep’,doc_9里tf=1,dl=20:
分母 = 1 + 1.5 * (0.25 + 0.75 * 20/18.67) = 1 + 1.5 * (0.25 + 0.804) = 1 + 1.5 * 1.054 = 2.581 score_deep = 0.981 * (2.5 / 2.581) ≈ 0.950learning在doc_9里也是tf=1,所以 score_learning = 0.470 * 0.969 ≈ 0.455。
doc_9总分就是 0.950 + 0.455 + 0 = 1.405。
最终排序出来了,doc_0排第一,1.512分,doc_9排第二,1.405分。
哎,等等,你可能会觉得不对。查询是"deep learning algorithms",doc_9讲的是深度学习,应该更相关才对,怎么排第二了?
这就是BM25的特点,也可以说是它的局限。它只看词,不看意思。doc_0里有个"algorithms",这个词很稀有,IDF很高,一下把分数拉上去了。而doc_9虽然有"deep"和"learning"两个词,但learning比较常见,IDF低,再加上文档更长被惩罚了一点,总分就稍微低了一点点。
这个例子很小,只有三篇文档,所以差距不明显。但你能从中看到BM25的性格,它认死理,有就是有,没有就是没有,每个词多少分算得明明白白。
BM25的能力边界
讲完怎么工作的,我们来聊聊BM25能做什么不能做什么。
它能干的事情挺多的。精确匹配,比如你搜"Python 3.12",它不会给你扯"Python 3.11"的事,版本号差一个数字就是不一样的词。完全可解释,每个结果你都能回溯,为什么这篇排第一,因为匹配了哪几个词,每个词贡献了多少分,清清楚楚。零成本启动,纯Python算法就能写,不需要GPU,不需要下载几个G的模型,pip install一下就完事了。
但它干不了的事情也挺要命的。
你搜"汽车",它搜不出"轿车",因为这俩字不一样。你搜"deep learning",它搜不出一篇满是"neural networks"但就是没出现过"deep learning"这两个词的文档。你搜"电池寿命",它不知道你其实想搜"续航能力"。跨语言就更别想了,中文搜不出英文的。
说穿了,BM25懂拼写,但不懂意思。
这就是为什么现在生产级的RAG系统,通常把BM25当作第一道粗筛,它召回率高,不怕漏,只要有关键词就一定能捞出来。然后再接上稠密模型做精排,让模型去理解语义,处理同义词和跨语言的情况。
好,上半场就到这里。下半场我们来看看稠密检索这一派是怎么玩的。
中场休息:同一个问题,两种答案
在进下半场之前,我们先做个小实验。同样一个查询,“deep learning breakthroughs”,我们先记一下BM25会返回什么。
BM25的结果是这样的:第一名doc_9,因为有deep和learning两个词。第二名doc_0,因为有learning一个词。doc_2直接排除,因为一个词都没有。
等会儿我们看完稠密检索,再回来对比,你会发现很有意思的差异。
下半场:稠密检索,让机器真正读懂文本
把文字塞进1024维空间
稠密检索的思路跟BM25完全不一样。它不关心你用了哪些词,它关心的是,你这段话到底在说什么意思。
怎么表示"意思"呢?用向量。
具体来说,就是用一个神经网络模型,把任意长度的文本,映射成一个固定维度的浮点数向量。比如1024维。每一维单独看没什么明确含义,但整个向量在高维空间里的位置,就代表了这段文本的语义。
语义相近的文本,向量在空间里就靠得近。语义差得远的,向量就离得远。
这个把文本变成向量的过程,就叫编码,或者叫生成embedding。
现在业界用得比较多的一个模型叫BGE-M3,是北京智源研究院2024年出的旗舰模型。这个模型挺有意思的地方在于,它一个模型能同时产出三种东西:
embeddings=model.encode([text],return_dense=True,# 稠密向量,1024维,算语义相似度return_sparse=True,# 稀疏权重,类似BM25的词级权重return_colbert_vecs=True# 每个token一个向量,做细粒度匹配)也就是说,你可以用同一个模型,同时做BM25式的关键词匹配和语义相似度搜索,不用跑两套系统。当然,大多数场景下大家默认只用稠密向量。
我们把doc_0扔进模型里,出来的就是一个1024维的numpy数组:
service=EmbeddingService(model_name="BAAI/bge-m3",use_fp16=True)result=service.encode_text(doc_0_text)# result['dense'] → numpy array, shape=(1024,)# result['dimension'] → 10241024个浮点数,就代表了这段话的语义。
那怎么判断两个文本语义像不像呢?算向量之间的相似度。最常用的是余弦相似度,就是看两个向量的夹角,夹角越小越相似,取值范围是-1到1。
# 余弦相似度similarity=np.dot(vec1,vec2)/(norm1*norm2)除了余弦相似度,还有欧氏距离和点积,但默认用余弦的最多。
这个思路说起来简单,但效果是真的好。你说"汽车",它的向量和"轿车"的向量离得很近,因为意思差不多。你说"deep learning",它和"neural networks"的向量也离得近,因为讲的是一回事。你用中文搜,英文文档也能搜出来,因为模型是多语言的,不同语言表达同一个意思,向量在同一个空间里相邻。
但问题也随之而来。
高维空间里怎么快速找人
BM25快,是因为有倒排索引,一个词对应哪些文档,查表就行。
那稠密检索呢?如果我有10万篇文档,每篇一个1024维的向量。用户给了一个查询向量,我要找最相似的top10,怎么办?
最笨的办法,跟每个向量都算一遍相似度,然后排序。10万篇的话,每次搜索要做10万次1024维的向量运算。还行,能接受。但如果是1000万篇呢?1亿篇呢?
每次搜索扫一遍全库,肯定不现实。
所以就有了ANN,近似最近邻索引。它的核心思路是,用一些巧妙的数据结构,把高维空间提前组织好,搜索的时候不用全扫,只搜一小部分区域就行。代价是可能漏掉几个最相似的,也就是"近似"的含义,但通常精度损失很小,速度能提升好几个数量级。
ANN有好几种实现方式,我们重点说两个,ANNOY和HNSW。这也是业界最常用的两种。
两棵树的故事,ANNOY的随机森林
ANNOY是Spotify开源的一个库,全称是Approximate Nearest Neighbors Oh Yeah,名字挺中二的。它的数据结构是随机划分森林。
什么叫随机划分森林呢?想象一下,你有一堆点散落在二维平面上。你随机选两个点,画一条垂直平分线,把平面切成左右两半。然后每一半再随机选两个点,再切一刀。一直切下去,直到每个小区域里只有少数几个点。这就是一棵树。
但一棵树可能切得不太均匀,某些区域搜不准。怎么办?建很多棵树,每棵树切的方式不一样。搜索的时候,每棵树都搜一遍,把结果合并起来。树越多,越准,但构建越慢,内存也越贵。
□──────────────────────□ │ 第1刀 │ │ │ │ │ │ 第2刀 │ │ │ │ │ □─────────┴─────┴──────□放到1024维空间也是一个道理,只不过切的不是直线,是超平面。
ANNOY的使用方式大概是这样的:
index=AnnoyIndex(dimension=1024,n_trees=50,metric="angular")# 添加文档index.add_item("doc_0",vector_0)index.add_item("doc_9",vector_9)# 构建索引,一次性建50棵树index.rebuild_index()# 搜索doc_ids,distances=index.search(query_vector,top_k=3)这里有几个特点。第一,添加文档的时候不立即建树,只是存起来,标记一下"还没建"。等你调用build的时候才一次性把所有树建好。所以如果你频繁增删文档,ANNOY就不太方便,因为每次都要重建。
第二,ANNOY不支持原生删除。要删一篇文档怎么办?只能把剩下的文档全部拿出来,重新建一遍索引,跳过被删掉的那篇。挺笨的,但没办法,数据结构决定了。
所以ANNOY适合什么场景?静态数据,读多写少。比如你把知识库文档全部导进去,然后一天重建一次索引,这种场景用ANNOY就挺好,内存占用低,实现简单。
一张图的江湖,HNSW的分层小世界
HNSW是另一种更流行的ANN实现,全称是Hierarchical Navigable Small World,分层可导航小世界。名字听着吓人,其实原理挺直观的。
你把它想象成一个城市的路网。最顶层是高速公路,路口很少,但一跳就能跨半个城市。中间层是城市主干道,路口多一些。最底层是社区街道,密密麻麻,每一步只走一小段路。
搜索的时候,你从高速公路的入口上去,快速接近目标区域,然后往下一层一层降,降到街道层的时候再精细搜索。这样一来,大部分路程都在高速上跑,很快,最后一小段在街道上慢慢找,很准。
顶层(稀疏,跳得远) ○ ────────────────── ○ │ │ │ 中层(中等) │ │ ○ ─── ○ ─── ○ │ │ │ │ │ │ │ 底层(稠密,搜得细) │ │ ○─○─○─○─○─○─○ │ └──────────────────────┘每一层都是一张图,图里的每个节点连到附近的几个邻居。顶层节点少,连接得远。底层节点多,连接得近。层数是多少呢?每个新节点加入的时候,随机决定它最高能到第几层,指数衰减,大部分节点都在底层,只有少数能到高层。
HNSW的搜索过程就是从顶层的某个入口点开始,在当前层找到离目标最近的点,然后往下走一层,以这个点为起点继续找,一直走到底层。
HNSW有几个关键参数。M是每个节点在图里连多少条边,M越大越准但内存越高,一般设16。ef_construction是建索引的时候搜索多宽,越大越准但建得越慢,一般设200。ef_search是搜索的时候搜索多宽,越大越准但越慢,一般设50。
index=HNSWIndex(dimension=1024,ef_construction=200,M=16,ef_search=50,space="cosine")# 添加文档,即时更新,不需要rebuildindex.add_item("doc_0",vector_0)# 删除,软删除,标记一下就行index.delete_item("doc_0")# 搜索labels,distances=index.knn_query(query_vector,k=top_k)HNSW比ANNOY好的地方在于,增删都是即时的,不需要重建整个索引。删除是软删除,就是标记一下这个节点被删了,搜索的时候跳过它,结构还在。所以频繁增删的动态场景,HNSW更合适。
代价是内存占用高一些,因为要存图的边。但对大多数场景来说,这点内存成本完全值得。
两种索引怎么选?
简单总结一下:
- 数据基本不变,读多写少,追求低内存,选ANNOY。
- 数据经常变,频繁增删,追求搜索精度,选HNSW。
大多数生产环境现在默认用HNSW,因为灵活度高,性能也好。FAISS、Milvus、Qdrant这些向量数据库,默认都是HNSW。
从头到尾走一遍搜索流程
讲完了向量和索引,我们把整个搜索流程串起来看一遍。
用户提交查询 “deep learning breakthroughs”,整个流程分四步:
第一步,把查询文本编码成向量。
query_embedding=embedding_service.encode_text("deep learning breakthroughs")['dense']# 一个1024维的numpy数组第二步,用ANN索引搜索最相似的top_k个向量。以HNSW为例:
doc_ids,distances=vector_index.search(query_embedding,top_k=3)假设返回的结果是 [‘doc_9’, ‘doc_0’, ‘doc_2’],对应的距离是 [0.231, 0.589, 0.942]。距离越小越相似。
第三步,从文档存储里把这些文档的原文和元数据取出来。向量索引里只存了向量和ID,具体的文档内容存在另一个地方,叫DocumentStore。
documents=document_store.get_documents_by_ids(doc_ids)第四步,把距离转换成相似度分数,让用户看起来更直观。距离0的时候相似度是1,距离越大相似度越低:
score=1.0/(1.0+distance)算出来就是,doc_9 得分0.812,doc_0 得分0.629,doc_2 得分0.515。
到这里,搜索就完成了。
对比一下,有意思的地方来了
还记得中场休息的时候我们留的那个问题吗?同样的查询 “deep learning breakthroughs”,BM25返回的是什么?
BM25返回doc_9第一,doc_0第二,doc_2直接排除,因为一个查询词都没有。
稠密检索呢?doc_9第一,doc_0第二,doc_2第三。
哎,doc_2居然也搜出来了。为什么?因为doc_2讲的是NLP,NLP属于AI领域,和deep learning虽然不是一回事,但在语义空间里比不相关的文档还是近很多。BGE-M3知道它们都是AI下面的子领域,所以给了0.515的相似度,虽然不高,但至少没漏掉。
这就是稠密检索最厉害的地方,它懂意思,不会因为你换了个说法就认不出来了。
但反过来想,如果用户搜的是一个非常具体的东西,比如某个错误码"E_CONN_REFUSED_10061",BM25一搜一个准,精确匹配。稠密检索呢?如果模型没怎么见过这个错误码,可能就搜不准,因为它对这种专有名词的理解可能不如精确匹配来得可靠。
所以你看,两种方式各有所长,不是谁替代谁的关系。
系统是怎么组织的
聊到这里,我们可以画一下整个稠密检索系统的架构了。大概是这么几层:
┌─────────────────────────────────────────────────────┐ │ API 层(FastAPI) │ │ 搜索、添加、删除文档接口 │ └──────────────────────┬──────────────────────────────┘ │ ┌──────────────────┴──────────────────┐ │ │ ┌───▼───────────┐ ┌────────▼─────────┐ │ VectorIndex │ │ DocumentStore │ │ (向量索引) │ │ (文档存储) │ │ ANNOY / HNSW │ │ 内存 / 数据库 │ └───────────────┘ └──────────────────┘ │ ┌───▼───────────────┐ │ EmbeddingService │ │ (编码服务) │ │ BGE-M3 模型 │ └───────────────────┘最上层是API,用户通过HTTP接口加文档、搜文档。中间有两个组件,向量索引负责存向量和做相似度搜索,文档存储负责存原文和元数据。最底层是编码服务,负责把文本变成向量。
向量索引有两个实现,ANNOY和HNSW,遵循同一个接口,所以切换的时候上层代码一行都不用改。这就是面向接口编程的好处。
删除文档的时候要注意,得同时删向量索引和文档存储,两边都要删,不然就不同步了。
还有一个东西值得提一下,就是日志。稠密检索加ANN这一套,本质上是个黑盒。你搜出来的结果为什么是这几个?向量离得近。为什么离得近?不知道,模型说的。
所以系统里一定要有完善的日志。索引的时候打日志,记录文档ID、长度、文本预览。生成embedding的时候打日志,记录维度、耗时。搜索的时候打日志,记录查询原文、每条结果的距离、总耗时。如果debug模式开着,甚至可以把向量的前10维数值打印出来,看看min、max、mean正不正常。
没有日志的话,出了问题根本无从下手。
尾声:不是二选一,是缺一不可
讲到这里,两种检索方式都讲完了。我们来对个账:
| 维度 | BM25(稀疏) | BGE-M3 + ANN(稠密) |
|---|---|---|
| 原理 | TF-IDF统计词频加倒排索引 | 神经网络编码加ANN近似搜索 |
| 理解什么 | 关键词是否出现 | 文本说了什么意思 |
| 维度 | 词汇量大小,动态,几千到几万 | 固定1024 |
| 同义词 | 不行,“轿车"不等于"汽车” | 可以,语义空间相邻 |
| 跨语言 | 不行 | 可以,100多种语言 |
| 可解释 | 可以,每个词贡献可回溯 | 不行,黑盒 |
| 可追责 | 可以,精确回答为什么排第一 | 不行,只能说向量离得近 |
| 模型依赖 | 无,纯算法 | BGE-M3约2.3GB,推荐GPU |
| 启动成本 | pip install就行 | 下载2.3GB模型加首次推理 |
看完这张表你就明白了,这俩根本不是竞争关系,是互补关系。
BM25的强项是精确和可解释,你搜一个具体的错误码、一个函数名、一个版本号,它绝不会给你乱扯。它的弱点是不懂语义,同义词、跨语言、改写表达都不行。
稠密检索的强项是语义理解,你换个说法、换种语言、说个同义词,它都能懂。它的弱点是对精确匹配的场景可能不够准,而且是黑盒,出了问题不知道为什么。
所以现在业界的主流做法是,两个都用。BM25负责粗筛,先把可能相关的文档都捞出来,保证不遗漏。稠密模型负责精排,按语义相关度重新排序,把最相关的顶到前面。两路结果融合之后再送给大模型生成答案。
有意思的是,BGE-M3这个模型本身就同时输出稀疏权重和稠密向量,这其实已经暗示了方向,两条路最终会走向融合。同一个模型,既能做关键词匹配,又能做语义理解,甚至还能做细粒度的token级匹配,三套输出各有用处,组合起来用效果最好。
回到最开头那个场景。凌晨两点,线上OOM,你搜"线上OOM怎么排查"。如果只有BM25,那篇写着"内存溢出故障处理手册"的文档可能永远不会被搜到,因为标题里没有OOM这三个字母。如果只有稠密检索,那篇包含完整错误堆栈的文档可能排名不高,因为模型对专有名词的理解不如精确匹配。
但如果两套系统一起上呢?BM25把所有带OOM的文档都捞出来,稠密模型把语义相关的"内存溢出"、“堆溢出”、"GC频繁"的文档也加进来,然后一起排序。你要的那篇复盘,大概率就在第一页了。
检索这个事,从来都不是一条路走到黑。稀疏有稀疏的踏实,稠密有稠密的聪明。真正厉害的系统,是把两者的长处都用上,让用户不用关心背后的技术细节,只需要知道,我搜了,想要的就在第一页。
这大概就是RAG系统里"找到相关文档"这第一步的全部秘密了。看起来简单,不就是搜个东西吗?真钻进去,里面的门道深着呢。
附:动手跑一跑
如果你想亲手试试这两套系统,可以从这两个入口开始:
BM25的核心实现在bm25_engine.py里,主要看三个类:
TextProcessor负责分词,第37到112行InvertedIndex负责构建倒排索引,第145到179行BM25负责打分排序,score_document方法在第291到304行
稠密检索的部分,重点看这几个文件:
embedding_service.py里的EmbeddingService.encode_text()方法,第68到124行,看文本怎么变成向量indexing.py里的AnnoyIndex和HNSWIndex,两个ANN索引的实现main.py里的search_documents端点,第236到301行,完整的搜索流程
不用全看,挑感兴趣的地方跑一跑,改一改参数,看看结果有什么变化。比读十篇文章都管用。
技术这东西,说到底还是得亲手摸一摸,才知道它到底是怎么回事。