搜不到想要的东西?聊聊RAG背后那两套检索系统的爱恨情仇

发布时间:2026/8/1 19:20:51
搜不到想要的东西?聊聊RAG背后那两套检索系统的爱恨情仇 开篇你有没有被搜索气到过凌晨两点生产环境炸了。你手指颤抖地在公司知识库搜索框里敲下线上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联系 adminexample.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}101learning{0, 9}101intelligence{0}100language{0, 2}110deep{9}001neural{9}001nlp{2}010processing{2, 9}011三张表建好了搜索就快了。用户搜什么词直接查倒排表立刻知道哪些文档至少包含一个词候选集就出来了。打分的艺术BM25到底在算什么候选集有了但哪个排第一哪个排第二这就需要打分。BM25的核心直觉就一句话不是每个词都同等重要。一个词对文档得分的贡献由三个因素共同决定第一个因素词频也就是TF。这个词在文档里出现得多不多出现得多说明相关度高但也不是越多越好有个饱和上限出现10次和出现100次差别不大。第二个因素稀有度也就是IDF。这个词在整个文档库里有多罕见越罕见的词区分力越强。比如的这个字到处都是告诉你一篇文章里有的字等于什么都没说。但贝叶斯优化这个词只在少数文档里有一搜一个准。第三个因素文档长度。这篇文档比平均长度长还是短长文档天然词多随便一个词都可能出现所以要惩罚一下。同样出现1次在一篇500字的短文里比在一篇5000字的长文里更有说服力。这三个因素揉在一起就是BM25的公式。我们拿实际的例子算一遍就什么都懂了。假设查询是 “deep learning algorithms”。第一步把查询也分词得到三个词deep, learning, algorithms。第二步查倒排索引找候选文档。deep 在 doc_9learning 在 doc_0 和 doc_9algorithms 在 doc_0。所以候选文档是 doc_0 和 doc_9doc_2 一个词都不沾边直接排除。第三步给每个候选文档算分。先算每个词的IDF这是全局统计量跟具体文档没关系。总共有3篇文档平均长度是(171920)/3≈18.67。IDF的公式长这样idf ln((N - df 0.5) / (df 0.5) 1)df是文档频率也就是有多少篇文档包含这个词。算一下idf(‘deep’) ln((3-10.5)/(10.5)1) ≈ 0.981idf(‘learning’) ln((3-20.5)/(20.5)1) ≈ 0.470idf(‘algorithms’) ln((3-10.5)/(10.5)1) ≈ 0.981看到了吗deep只在1篇里出现很稀有IDF接近1。learning在2篇里都有比较常见IDF只有0.47。deep的区分力是learning的两倍多这就是IDF的作用它帮你找到那些真正能把文档区分开的词。然后算每个词对具体文档的贡献。BM25里有两个常用参数k11.5控制TF饱和的速度b0.75控制长度惩罚的强度。对doc_0来说它不包含deep所以deep贡献0分。只算learning和algorithms。对于’learning’doc_0里tf1文档长度dl17平均长度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里tf1dl20分母 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里也是tf1所以 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 algorithmsdoc_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年出的旗舰模型。这个模型挺有意思的地方在于它一个模型能同时产出三种东西embeddingsmodel.encode([text],return_denseTrue,# 稠密向量1024维算语义相似度return_sparseTrue,# 稀疏权重类似BM25的词级权重return_colbert_vecsTrue# 每个token一个向量做细粒度匹配)也就是说你可以用同一个模型同时做BM25式的关键词匹配和语义相似度搜索不用跑两套系统。当然大多数场景下大家默认只用稠密向量。我们把doc_0扔进模型里出来的就是一个1024维的numpy数组serviceEmbeddingService(model_nameBAAI/bge-m3,use_fp16True)resultservice.encode_text(doc_0_text)# result[dense] → numpy array, shape(1024,)# result[dimension] → 10241024个浮点数就代表了这段话的语义。那怎么判断两个文本语义像不像呢算向量之间的相似度。最常用的是余弦相似度就是看两个向量的夹角夹角越小越相似取值范围是-1到1。# 余弦相似度similaritynp.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的使用方式大概是这样的indexAnnoyIndex(dimension1024,n_trees50,metricangular)# 添加文档index.add_item(doc_0,vector_0)index.add_item(doc_9,vector_9)# 构建索引一次性建50棵树index.rebuild_index()# 搜索doc_ids,distancesindex.search(query_vector,top_k3)这里有几个特点。第一添加文档的时候不立即建树只是存起来标记一下还没建。等你调用build的时候才一次性把所有树建好。所以如果你频繁增删文档ANNOY就不太方便因为每次都要重建。第二ANNOY不支持原生删除。要删一篇文档怎么办只能把剩下的文档全部拿出来重新建一遍索引跳过被删掉的那篇。挺笨的但没办法数据结构决定了。所以ANNOY适合什么场景静态数据读多写少。比如你把知识库文档全部导进去然后一天重建一次索引这种场景用ANNOY就挺好内存占用低实现简单。一张图的江湖HNSW的分层小世界HNSW是另一种更流行的ANN实现全称是Hierarchical Navigable Small World分层可导航小世界。名字听着吓人其实原理挺直观的。你把它想象成一个城市的路网。最顶层是高速公路路口很少但一跳就能跨半个城市。中间层是城市主干道路口多一些。最底层是社区街道密密麻麻每一步只走一小段路。搜索的时候你从高速公路的入口上去快速接近目标区域然后往下一层一层降降到街道层的时候再精细搜索。这样一来大部分路程都在高速上跑很快最后一小段在街道上慢慢找很准。顶层稀疏跳得远 ○ ────────────────── ○ │ │ │ 中层中等 │ │ ○ ─── ○ ─── ○ │ │ │ │ │ │ │ 底层稠密搜得细 │ │ ○─○─○─○─○─○─○ │ └──────────────────────┘每一层都是一张图图里的每个节点连到附近的几个邻居。顶层节点少连接得远。底层节点多连接得近。层数是多少呢每个新节点加入的时候随机决定它最高能到第几层指数衰减大部分节点都在底层只有少数能到高层。HNSW的搜索过程就是从顶层的某个入口点开始在当前层找到离目标最近的点然后往下走一层以这个点为起点继续找一直走到底层。HNSW有几个关键参数。M是每个节点在图里连多少条边M越大越准但内存越高一般设16。ef_construction是建索引的时候搜索多宽越大越准但建得越慢一般设200。ef_search是搜索的时候搜索多宽越大越准但越慢一般设50。indexHNSWIndex(dimension1024,ef_construction200,M16,ef_search50,spacecosine)# 添加文档即时更新不需要rebuildindex.add_item(doc_0,vector_0)# 删除软删除标记一下就行index.delete_item(doc_0)# 搜索labels,distancesindex.knn_query(query_vector,ktop_k)HNSW比ANNOY好的地方在于增删都是即时的不需要重建整个索引。删除是软删除就是标记一下这个节点被删了搜索的时候跳过它结构还在。所以频繁增删的动态场景HNSW更合适。代价是内存占用高一些因为要存图的边。但对大多数场景来说这点内存成本完全值得。两种索引怎么选简单总结一下数据基本不变读多写少追求低内存选ANNOY。数据经常变频繁增删追求搜索精度选HNSW。大多数生产环境现在默认用HNSW因为灵活度高性能也好。FAISS、Milvus、Qdrant这些向量数据库默认都是HNSW。从头到尾走一遍搜索流程讲完了向量和索引我们把整个搜索流程串起来看一遍。用户提交查询 “deep learning breakthroughs”整个流程分四步第一步把查询文本编码成向量。query_embeddingembedding_service.encode_text(deep learning breakthroughs)[dense]# 一个1024维的numpy数组第二步用ANN索引搜索最相似的top_k个向量。以HNSW为例doc_ids,distancesvector_index.search(query_embedding,top_k3)假设返回的结果是 [‘doc_9’, ‘doc_0’, ‘doc_2’]对应的距离是 [0.231, 0.589, 0.942]。距离越小越相似。第三步从文档存储里把这些文档的原文和元数据取出来。向量索引里只存了向量和ID具体的文档内容存在另一个地方叫DocumentStore。documentsdocument_store.get_documents_by_ids(doc_ids)第四步把距离转换成相似度分数让用户看起来更直观。距离0的时候相似度是1距离越大相似度越低score1.0/(1.0distance)算出来就是doc_9 得分0.812doc_0 得分0.629doc_2 得分0.515。到这里搜索就完成了。对比一下有意思的地方来了还记得中场休息的时候我们留的那个问题吗同样的查询 “deep learning breakthroughs”BM25返回的是什么BM25返回doc_9第一doc_0第二doc_2直接排除因为一个查询词都没有。稠密检索呢doc_9第一doc_0第二doc_2第三。哎doc_2居然也搜出来了。为什么因为doc_2讲的是NLPNLP属于AI领域和deep learning虽然不是一回事但在语义空间里比不相关的文档还是近很多。BGE-M3知道它们都是AI下面的子领域所以给了0.515的相似度虽然不高但至少没漏掉。这就是稠密检索最厉害的地方它懂意思不会因为你换了个说法就认不出来了。但反过来想如果用户搜的是一个非常具体的东西比如某个错误码E_CONN_REFUSED_10061BM25一搜一个准精确匹配。稠密检索呢如果模型没怎么见过这个错误码可能就搜不准因为它对这种专有名词的理解可能不如精确匹配来得可靠。所以你看两种方式各有所长不是谁替代谁的关系。系统是怎么组织的聊到这里我们可以画一下整个稠密检索系统的架构了。大概是这么几层┌─────────────────────────────────────────────────────┐ │ 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行完整的搜索流程不用全看挑感兴趣的地方跑一跑改一改参数看看结果有什么变化。比读十篇文章都管用。技术这东西说到底还是得亲手摸一摸才知道它到底是怎么回事。