1. 纯向量检索到底哪里不够用
1.1 从一个真实翻车案例说起
去年我帮一个团队调他们的知识库问答系统,场景很典型:内部技术文档大概八千多篇,用了一套开箱即用的向量检索方案,embedding 模型选的某主流中文模型,向量库用的 FAISS,整套链路跑起来 demo 效果惊艳,问什么都能答个八九不离十。结果上线第二周,客服部门反馈了一个问题:有人问“报销流程里住宿费的标准是多少”,系统给出的答案是“差旅住宿标准为每晚 500 元”,但实际文档里写的是“一线城市 600 元,二线城市 500 元,其他城市 400 元”。答案没错,但不完整,而且丢掉了最关键的城市分级条件。
这个问题不是 embedding 模型不行,而是纯向量检索的召回机制本身有结构性缺陷。向量检索的本质是把 query 和文档都映射到同一个高维空间,然后算余弦相似度或者内积,取 Top-K。它擅长的是“语义相近”,不擅长“条件精确匹配”。上面那个例子里,“住宿费标准”和“差旅住宿标准”语义高度相似,向量空间里距离很近,但“一线城市”“二线城市”这些限定词在向量化之后被平均掉了,权重被稀释,导致召回时只捞到了最泛化的那段文本。
我后来做了个统计,在他们那个场景下,纯向量检索的召回率大概在 72% 左右,也就是说有将近三成的 query 没能把真正相关的文档片段捞回来。这个数字在 demo 阶段看不出来,因为 demo 的 query 都是精心设计的,但真实用户的提问方式千奇百怪,纯向量的短板就暴露了。
1.2 向量检索的三个硬伤
我把纯向量检索的问题归纳成三个层面,这三个层面也是大厂 RAG 系统一定要做混合检索的根本原因。
第一个硬伤是关键词失焦。向量模型在训练时会把语义相近的词映射到相近的位置,这是它的优势,但在某些场景下反而成了劣势。比如用户搜“错误码 E5021”,这是一个精确的标识符,向量模型可能把它和“错误码 E5022”“错误码 E5031”映射得很近,因为它们在语义上都是“错误码”加一串数字。但用户要的就是 E5021,差一个数字都不行。纯向量检索没法保证这种精确匹配,而 BM25 这类稀疏检索可以。
第二个硬伤是长尾 query 召回差。向量模型的训练数据决定了它的语义空间覆盖范围。如果用户的提问方式比较小众,或者用了领域内特有的缩写、黑话,向量模型可能没见过,映射出来的向量就不准。我见过一个案例,用户问“P0 故障的响应 SLA 是多久”,这里的“P0”在向量空间里可能和“优先级”“紧急”这些词混在一起,但实际文档里写的是“一级故障(P0)”。纯向量检索能不能召回,全看模型训练时有没有见过类似的表达。
第三个硬伤是召回结果不可解释。向量检索给出的是一个相似度分数,你很难跟用户解释“为什么这篇文档被排在第一”。在需要审计或者调试的场景下,这个黑盒特性很要命。而 BM25 的分数是可以拆解的,你能看到是哪个词贡献了多少分,出了问题好排查。
注意:我不是说向量检索没用,恰恰相反,它在语义泛化、同义表达、跨语言检索这些场景下是不可替代的。问题在于,单靠它不够。
1.3 大厂为什么集体转向混合检索
我接触过几个大厂出来的朋友,聊下来发现他们的 RAG 系统几乎没有纯向量检索的,清一色是混合检索加重排序的架构。所谓混合检索,就是同时跑一路稀疏检索(通常是 BM25 或者它的变体)和一路稠密检索(向量检索),然后把两路结果融合。重排序则是用一个交叉编码器(cross-encoder)或者专门的 rerank 模型对融合后的候选集做精排。
这个架构不是拍脑袋定的,而是被业务场景逼出来的。大厂的 RAG 系统通常要服务多个业务线,query 分布极其分散,有精确的订单号查询,有模糊的自然语言提问,有带条件的筛选式查询,还有多轮对话里的指代消解。单一检索方式根本覆盖不了这么宽的需求谱系。
从工程角度看,混合检索的代价主要是多了一路检索的延迟和融合逻辑的复杂度,但换来的是召回率的显著提升。我实测过,在同一个数据集上,纯向量检索的 Recall@10 大概 0.72,加上 BM25 做混合之后能到 0.89,再叠一层 rerank 能到 0.94。这个提升幅度在真实业务里就是能不能用的区别。
2. 混合检索的核心原理拆解
2.1 BM25 为什么在 RAG 里还活着
BM25 是个老古董了,上世纪九十年代的东西,但它在 RAG 系统里活得很好。原因很简单:它解决的是向量检索解决不了的问题。
BM25 的核心思想是词频和逆文档频率的加权。一个词在文档里出现得越多,说明文档越相关;但这个词如果在所有文档里都出现,那它的区分度就低,权重就要降。公式本身不复杂,但它的几个特性在 RAG 场景下特别有用。
首先是精确匹配能力。BM25 是基于词的,query 里的词和文档里的词必须对上才能得分。这意味着“E5021”只会匹配“E5021”,不会匹配“E5022”。这个特性在查错误码、订单号、产品型号、人名、地名这些场景下是刚需。
其次是可解释性。BM25 的分数可以拆到每个词上,你能清楚地看到是哪个词把这篇文档推上来的。调试的时候,如果发现某篇不相关的文档排得很高,一看分数构成就知道是哪个词出了问题,可能是分词不对,可能是停用词没处理好。
第三是对长尾词的鲁棒性。BM25 不依赖训练数据,只要词在文档里出现过,它就能算分。向量模型没见过的词,BM25 照样能处理。这在领域知识库场景下特别重要,因为领域内的术语、缩写、代号,通用 embedding 模型很可能没见过。
当然 BM25 也有它的短板。它不理解同义词,“电脑”和“计算机”在它眼里是两个完全不同的词。它也不理解语义,“苹果手机”和“iPhone”它匹配不上。所以它不能单独用,必须和向量检索配合。
2.2 向量检索的不可替代性
说完 BM25,再说向量检索为什么不能被丢掉。向量检索的核心价值是语义泛化。
用户问“怎么修改登录密码”,文档里写的是“如何重置账户口令”,这两个表达在字面上几乎没有重叠,BM25 匹配不上,但向量检索可以,因为它们在语义空间里距离很近。这种同义表达、换一种说法的能力,是向量检索的看家本领。
另外,向量检索在跨语言场景下也有优势。如果 embedding 模型是多语言的,用户用中文问,文档是英文的,向量检索也能召回。BM25 做不到这一点,除非你做查询翻译或者用多语言分词。
还有模糊匹配。用户问“那个关于数据备份的方案”,这里的“那个”和“方案”都很模糊,BM25 可能只能匹配到“方案”这个词,但向量检索能结合上下文理解用户大概想要什么。
所以混合检索的逻辑不是谁替代谁,而是互补。BM25 负责精确匹配和长尾词,向量检索负责语义泛化和模糊匹配,两路结果融合之后,覆盖面比任何单路都宽。
2.3 融合策略:RRF 为什么成为默认选择
两路检索跑完之后,怎么把结果合并?常见的有几种做法:加权求和、倒数排名融合(RRF)、以及学习一个融合模型。
加权求和的问题是,BM25 的分数和向量相似度的分数不在一个量纲上。BM25 的分数可能是 0 到 20 之间的任意值,向量相似度通常是 0 到 1 之间。直接加权求和,权重的调节非常敏感,换个数据集就得重新调。
RRF 的思路不一样,它不看分数,只看排名。公式是score = sum(1 / (k + rank)),其中 rank 是文档在每一路结果里的排名,k 是一个平滑常数,通常取 60。这个做法的好处是对分数分布不敏感,不管两路检索的分数范围差多少,RRF 都能稳定融合。
我实测下来,RRF 在大多数场景下都比加权求和稳,尤其是当两路检索的分数分布差异很大的时候。而且 RRF 几乎没有超参数要调,k 取 60 是论文里的默认值,实践中也很少需要改。
不过 RRF 也有缺点,它丢掉了分数信息,只保留排名。如果某一路检索的分数特别有区分度,RRF 用不上这个信息。所以有些团队会用加权 RRF,给不同路的结果加一个权重,或者用学习式融合,训练一个小的模型来预测最终相关性。但后者需要标注数据,成本高,不是所有团队都玩得起。
3. 从零搭一套混合检索 RAG 的实操路径
3.1 整体架构与数据流
我拿一个模拟项目来演示,假设我们要给一个技术文档库搭 RAG 系统,文档大概一万篇,中文为主,包含 API 文档、故障排查手册、配置指南这几类。
整体数据流是这样的:离线阶段,文档先做分块,然后分别走两条路——一路做分词建 BM25 索引,一路做 embedding 建向量索引。在线阶段,用户 query 进来,同时查两路索引,各自取 Top-50,然后用 RRF 融合成 Top-20,再送进 rerank 模型精排出 Top-5,最后拼进 prompt 给大模型生成答案。
这个架构里,分块策略、分词质量、embedding 模型选择、rerank 模型选择,每一个环节都会影响最终效果。我下面逐个拆。
3.2 文档分块:别小看这一步
分块是 RAG 的地基,地基没打好,后面怎么调都是白搭。我见过太多团队在分块上偷懒,直接按固定长度切,结果切出来的块要么语义不完整,要么把关键信息切散了。
我的做法是按语义结构分块。技术文档通常有标题层级,一级标题、二级标题、三级标题,每个标题下面的内容是一个语义单元。我优先按这个结构切,如果某个段落太长,再按句子边界切。块的大小控制在 300 到 500 个 token 之间,太小了信息不够,太大了噪声多。
这里有个细节:块要带上下文。比如一个三级标题下的段落,单独看可能不知道它在说什么,但如果把二级标题和一级标题拼在前面,语义就完整了。我通常会在块的开头加上标题路径,比如“配置指南 > 数据库配置 > 连接池设置”,这样检索的时候更容易命中。
实操心得:分块的时候保留 10% 到 20% 的重叠,避免关键信息刚好落在切分边界上被切掉。重叠部分不用太多,否则索引会膨胀。
3.3 BM25 索引构建:分词是命门
BM25 的效果,七成看分词。中文分词比英文麻烦,英文按空格切就行,中文得靠分词器。我试过几种方案,jieba 是最常用的,但它的默认词典对技术文档不够友好,很多术语会被切错。
我的做法是自定义词典加领域词表。把文档里出现的高频术语、产品名、缩写、错误码格式都加到自定义词典里,确保它们不被切散。比如“连接池”不能被切成“连接”和“池”,“E5021”不能被切成“E”“5021”。
另外,停用词表也要定制。通用停用词表里可能有“的”“了”“是”这些,但技术文档里“的”有时候很重要,比如“数据的备份”和“数据备份”是两个不同的概念。我通常会保留“的”,但去掉“了”“呢”“吧”这些语气词。
还有一个技巧是同义词扩展。BM25 不理解同义词,但我可以在索引阶段做扩展。比如“报错”和“错误”是同义词,我在建索引的时候把文档里的“报错”同时索引成“错误”,这样用户搜“错误”也能命中。这个扩展表需要人工维护,但维护成本不高,效果很明显。
3.4 向量索引构建:模型选择和参数调优
向量检索这块,embedding 模型的选择是核心。我试过几种主流的中文模型,各有优劣。选模型的时候不能只看 MTEB 榜单,要看你的场景。技术文档场景下,模型对术语的理解能力比通用语义能力更重要。
我一般会拿一批真实 query 做小规模评测,看不同模型的 Recall@10 和 MRR。评测集不用太大,一两百条就够,但要是真实分布,不能是精心构造的。
向量库的选择上,FAISS 适合单机小规模,Milvus 和 Qdrant 适合分布式。如果数据量在百万级以下,FAISS 加 HNSW 索引就够了。HNSW 的参数 M 和 efConstruction 需要调,M 控制图的连接度,efConstruction 控制建索引时的搜索深度。我通常 M 取 16 到 32,efConstruction 取 100 到 200,再高收益递减。
注意:向量索引建好之后,一定要用真实 query 测召回率。我见过团队建完索引直接上线,结果发现 embedding 模型和向量库的版本不匹配,向量维度对不上,检索结果全是乱的。
3.5 RRF 融合与 Rerank 精排
两路检索各取 Top-50,用 RRF 融合。RRF 的 k 值我一般取 60,这是论文默认值,实践中也很少需要改。融合之后取 Top-20 送进 rerank。
Rerank 模型我用过两种:一种是基于 cross-encoder 的,把 query 和文档拼在一起送进模型打分;另一种是基于 LLM 的,让大模型判断相关性。前者快,后者准但贵。我通常用 cross-encoder 做第一层精排,如果对精度要求极高,再叠一层 LLM 判断。
Rerank 的输入长度要注意,cross-encoder 通常有最大长度限制,比如 512 个 token。如果文档块太长,会被截断,影响打分。所以分块的时候控制长度,不光是为了检索,也是为了 rerank。
3.6 完整代码示例
下面是一个简化版的混合检索实现,用 Python 写的,依赖 rank_bm25、sentence-transformers 和 faiss。
import jieba import numpy as np from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer import faiss # 模拟文档集 docs = [ "一线城市差旅住宿标准为每晚600元,二线城市500元,其他城市400元。", "报销流程需在费用发生后30天内提交,逾期不予受理。", "错误码E5021表示数据库连接超时,请检查网络配置和连接池设置。", "系统支持通过配置文件修改日志级别,默认级别为INFO。", "P0故障需在15分钟内响应,1小时内给出初步解决方案。", ] # 自定义词典 for term in ["差旅住宿", "报销流程", "错误码", "连接池", "日志级别", "P0故障"]: jieba.add_word(term) # BM25 索引 tokenized_docs = [list(jieba.cut(doc)) for doc in docs] bm25 = BM25Okapi(tokenized_docs) # 向量索引 model = SentenceTransformer("shibing624/text2vec-base-chinese") doc_embeddings = model.encode(docs, normalize_embeddings=True) dimension = doc_embeddings.shape[1] index = faiss.IndexFlatIP(dimension) index.add(doc_embeddings.astype(np.float32)) def hybrid_search(query, top_k=3, rrf_k=60): # BM25 检索 tokenized_query = list(jieba.cut(query)) bm25_scores = bm25.get_scores(tokenized_query) bm25_ranks = np.argsort(bm25_scores)[::-1][:top_k * 2] # 向量检索 query_embedding = model.encode([query], normalize_embeddings=True) _, vector_ranks = index.search(query_embedding.astype(np.float32), top_k * 2) vector_ranks = vector_ranks[0] # RRF 融合 rrf_scores = {} for rank, doc_id in enumerate(bm25_ranks): rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (rrf_k + rank + 1) for rank, doc_id in enumerate(vector_ranks): rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (rrf_k + rank + 1) sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True) return [(doc_id, docs[doc_id], score) for doc_id, score in sorted_docs[:top_k]] # 测试 query = "E5021错误码是什么意思" results = hybrid_search(query) for doc_id, doc, score in results: print(f"文档ID: {doc_id}, RRF分数: {score:.4f}") print(f"内容: {doc}") print("---")这段代码跑下来,query“E5021错误码是什么意思”会优先召回第三条文档,因为 BM25 精确匹配到了“E5021”和“错误码”,向量检索也贡献了一部分分数。如果只用向量检索,“E5021”可能被稀释,召回结果不一定排第一。
4. 踩坑记录与排查手册
4.1 分词把关键信息切碎了
这是我踩过最多的坑。有一次做产品型号检索,用户搜“XPS-15-9520”,分词器把它切成了“XPS”“-”“15”“-”“9520”,BM25 匹配的时候按词匹配,结果召回了一堆不相关的文档。后来我把产品型号的格式加到自定义词典里,用正则先识别出型号模式,再整体加入词典,问题才解决。
排查思路:如果发现 BM25 召回的结果里,精确匹配的文档没排上来,先看分词结果。用jieba.cut打印一下 query 和文档的分词,看看关键术语有没有被切散。
4.2 向量模型和业务术语不匹配
通用 embedding 模型在领域术语上的表现往往不如预期。我做过一个测试,用通用模型检索“连接池耗尽”相关的文档,结果召回了一堆“内存耗尽”“线程耗尽”的文档,因为模型把“耗尽”这个语义学得很好,但“连接池”这个领域概念没区分开。
解决办法有两个:一是换领域微调过的模型,二是用混合检索兜底。BM25 能精确匹配“连接池”,把向量检索漏掉的补回来。如果领域术语特别多,可以考虑用领域数据微调 embedding 模型,但成本较高,一般先用混合检索顶着。
4.3 RRF 融合后排序反而变差
RRF 不是万能的。我遇到过一种情况:BM25 的 Top-1 是正确答案,向量检索的 Top-1 是错误答案,但向量检索的 Top-2 到 Top-10 里混进了很多噪声。RRF 融合之后,噪声文档因为同时出现在两路结果里,RRF 分数被推高了,反而把正确答案挤下去了。
这个问题的根源是两路检索的质量不均衡。如果一路检索明显比另一路准,RRF 的等权融合就会拖后腿。解决办法是给两路加权重,或者先对每路结果做截断,只取 Top-10 再融合,减少噪声的干扰。
4.4 Rerank 模型拖慢响应
Rerank 是精度利器,但也是延迟大户。cross-encoder 要对每个 query-doc 对做一次前向计算,Top-20 就是 20 次计算,如果模型大一点,延迟直接上秒级。
我的优化做法是:第一层用轻量级 rerank 模型快速筛一遍,把 Top-20 压到 Top-8;第二层如果需要,再用大模型精排。另外,rerank 可以异步做,先返回混合检索的结果,rerank 结果出来之后再更新。用户体验上,先看到结果比等两秒看到更好的结果更重要。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 精确匹配的文档没召回 | 分词切碎了关键术语 | 打印分词结果 | 加自定义词典 |
| 同义表达召回差 | 向量模型语义能力不足 | 换模型对比测试 | 混合检索兜底或微调模型 |
| 融合后排序变差 | 两路检索质量不均衡 | 分别看两路 Top-10 | 加权 RRF 或截断融合 |
| 响应延迟高 | rerank 计算量大 | 打点看各阶段耗时 | 轻量模型加异步更新 |
| 召回结果重复 | 分块重叠过多 | 检查分块参数 | 降低重叠比例 |
| 长尾 query 效果差 | 训练数据覆盖不足 | 分析 bad case | 同义词扩展加混合检索 |
5. 几个容易被忽略的优化细节
5.1 Query 改写对混合检索的增益
用户 query 往往很短,信息量不足。我通常会在检索前做一步 query 改写,把口语化的提问转成更适合检索的形式。比如“那个报销的事情”改写成“报销流程 报销标准 报销时限”,这样 BM25 和向量检索都能更好地命中。
Query 改写可以用规则,也可以用大模型。规则适合固定场景,比如把“怎么”“如何”统一成“方法”,把“是什么”统一成“定义”。大模型改写更灵活,但要注意控制延迟和成本。我一般用一个小模型做改写,或者用大模型的流式输出,边改写边检索。
5.2 元数据过滤的妙用
技术文档通常有元数据,比如文档类型、更新时间、所属模块。这些元数据可以在检索前做过滤,也可以在检索后做加权。比如用户问“最新的配置指南”,我可以在检索前过滤掉超过一年的文档,或者在 RRF 融合时给新文档加一个时间衰减权重。
元数据过滤的另一个用途是权限控制。不同角色的用户能看到的文档不一样,检索的时候要带上权限过滤条件,避免越权召回。这个在混合检索里要注意,BM25 和向量检索都要加过滤,不能只加一路。
5.3 缓存策略降低重复计算
RAG 系统里,query 的重复率往往比想象中高。我统计过一个内部系统,Top 100 的高频 query 占了总请求量的 40% 左右。这些 query 的检索结果完全可以缓存,不用每次都跑两路检索加 rerank。
缓存的粒度可以细一点,不只缓存最终答案,也缓存检索结果。因为同一个 query 在不同上下文里,生成的答案可能不同,但检索结果是一样的。缓存检索结果能省掉最耗时的部分,同时保留生成的灵活性。
实操心得:缓存要设过期时间,文档更新之后缓存要失效。我一般用文档版本号做缓存 key 的一部分,文档更新时版本号变了,缓存自然失效。
5.4 评测集建设:没有评测就是盲调
混合检索的参数很多,RRF 的 k 值、两路的 Top-K、rerank 的阈值,每一个都会影响效果。没有评测集,调参就是盲人摸象。
我的做法是从真实日志里采样构建评测集。从线上 query 里随机抽几百条,人工标注每条 query 对应的正确文档。标注成本不低,但一次投入长期受益。评测集建好之后,每次调参都跑一遍,看 Recall@10、MRR、NDCG 这些指标的变化。
评测集要定期更新,因为用户的提问方式会变,文档也会变。我一般每季度更新一次,把新的 bad case 加进去,把过时的 case 删掉。
6. 不同规模团队的落地建议
6.1 小团队:先用现成方案跑通
如果团队只有一两个人,我建议不要一上来就自己搭混合检索。先用现成的 RAG 框架,比如 LangChain 或者 LlamaIndex,它们都内置了混合检索的支持。BM25 可以用 rank_bm25 这个库,向量检索可以用 Chroma 或者 Qdrant 的本地模式,rerank 可以用开源的 cross-encoder 模型。
先把链路跑通,用真实数据测一测,看看纯向量检索到底差多少。如果差距不大,可能你的场景对精确匹配要求不高,纯向量也能用。如果差距明显,再逐步加 BM25 和 rerank。
6.2 中等团队:自建检索服务
团队规模到了五到十人,有专门的工程资源,可以考虑自建检索服务。BM25 用 Elasticsearch 或者 OpenSearch,它们内置了 BM25 和向量检索,可以在一个服务里同时跑两路。Elasticsearch 的 RRF 支持从 8.8 版本开始有了,用起来很方便。
这个阶段要注意的是索引更新和版本管理。文档更新之后,BM25 索引和向量索引都要更新,而且要保证两路索引的版本一致。我见过团队只更新了向量索引,忘了更新 BM25 索引,结果检索结果对不上。
6.3 大团队:平台化与自动化
大团队的 RAG 系统通常要服务多个业务线,这时候要考虑平台化。检索服务做成 API,业务线通过配置接入,不用关心底层实现。评测和调优也要自动化,每次模型更新或者参数调整,自动跑评测集,指标下降就报警。
这个阶段还要考虑多租户隔离和资源配额。不同业务线的数据要隔离,检索资源要分配,避免一个业务线的流量把整个系统打挂。这些工程问题在小规模时不用考虑,但规模上来之后就是必须解决的。
7. 我个人的一些经验体会
混合检索这个事,我最大的体会是不要迷信单一方案。向量检索火起来之后,很多人觉得 BM25 过时了,应该被淘汰。但实际用下来,BM25 在精确匹配和长尾词上的优势,向量检索替代不了。反过来,向量检索的语义泛化能力,BM25 也做不到。两者是互补关系,不是替代关系。
另一个体会是评测比调参重要。我见过太多团队花大量时间调 RRF 的 k 值、调 rerank 的阈值,但连一个像样的评测集都没有。调出来的参数在测试集上好看,上线就翻车。先把评测集建好,再调参,效率高得多。
最后说一个细节:混合检索的收益不是线性的。从纯向量到混合检索,召回率提升很明显;但从混合检索到混合检索加 rerank,提升幅度就小一些;再往上叠更复杂的策略,收益递减。所以不要一味追求架构复杂,够用就行。先把混合检索加 rerank 这套基础架构跑稳,再根据实际 bad case 决定要不要加更多层。