1. 纯向量检索到底哪里不够用1.1 从一个真实翻车案例说起去年帮一个团队调优他们的知识库问答系统场景很典型内部技术文档检索大概三万多个文档块用的是一个主流向量数据库嵌入模型选的也是榜上有名的开源方案。离线评测的时候召回率看着还行Top-5 能到 78% 左右团队觉得可以上线了。结果上线第一周就炸了。用户搜XX 接口的超时时间怎么配置返回的全是讲XX 接口的鉴权流程的段落——语义确实相关都是同一个接口的文档但用户要的那个具体参数值藏在另一个段落里向量检索死活排不到前面。更离谱的是搜错误码0x8003返回的居然是0x8001和0x8005的说明因为这几个错误码的文本描述高度相似嵌入向量几乎挤在一起。这就是纯向量检索最要命的地方它擅长的是意思差不多不擅长精确匹配。而真实业务里用户大量的查询恰恰是精确的——错误码、函数名、配置项、版本号、订单号、产品型号。这些东西在向量空间里没有区分度因为嵌入模型压根不是为区分0x8003和0x8005训练的。1.2 向量检索的三个结构性缺陷我把纯向量检索的问题归纳成三个层面理解了这三个层面你就明白为什么大厂的 RAG 系统从来不只用向量。第一语义漂移。嵌入模型把文本压缩成一个固定维度的向量这个过程必然有信息损失。两个意思完全不同的句子如果共享大量上下文词汇向量相似度可能很高。比如如何关闭自动续费和如何开启自动续费在多数嵌入模型里余弦相似度能到 0.9 以上但用户要的是完全相反的操作。第二精确匹配无能。向量检索本质上是近似最近邻搜索它返回的是距离最近的结果不是包含关键词的结果。你搜一个具体的错误码、一个特定的 API 名称、一个精确的数字向量检索没有任何机制保证这些东西出现在结果里。第三无法表达结构化约束。真实查询经常带条件最近三个月内的、属于财务部门的、状态为已完成的。纯向量检索对这些元数据条件无能为力你只能靠后置过滤但后置过滤会导致召回数量不可控——过滤完可能一条都不剩。这里有个常见误区很多人觉得换个更强的嵌入模型就能解决。实测下来嵌入模型从小的换到大的精确匹配能力提升非常有限因为这是向量检索的范式问题不是模型能力问题。1.3 大厂的真实做法混合检索大厂 RAG 系统普遍采用的是混合检索Hybrid Retrieval核心思路是把向量检索和关键词检索的结果融合再经过重排序。关键词检索负责精确命中向量检索负责语义泛化两者互补。具体来说关键词检索这一路通常用 BM25 或其变体。BM25 是经典的信息检索算法核心思想是一个词在文档中出现频率越高、在整个语料库中出现频率越低这个词的区分度就越高。它天然擅长精确匹配你搜0x8003它就能精确找到包含这个字符串的文档。向量检索这一路负责处理用户说的和文档写的不一样的情况。比如用户问怎么让程序跑得更快文档里写的是性能优化指南纯关键词检索可能匹配不上但向量检索能通过语义相似度找到。两路结果各取所长融合后送入重排序模型精排最终返回给大模型。这套架构在多个公开评测集上比纯向量检索的召回率能提升 15 到 30 个百分点具体取决于数据特点和查询分布。2. 混合检索的核心组件拆解2.1 BM25 为什么至今仍是关键词检索的首选BM25 诞生几十年了深度学习时代它依然没有被淘汰原因很简单它在精确匹配场景下的表现至今没有哪个神经检索模型能稳定超越而且它不需要训练、不需要 GPU、延迟极低。BM25 的评分公式看起来复杂但核心逻辑就三条词频饱和一个词在文档里出现 10 次和出现 100 次相关性差距不是线性的出现到一定次数后收益递减。逆文档频率越罕见的词权重越高。的这种词到处都是权重接近零0x8003这种词很罕见权重很高。文档长度归一化长文档天然更容易包含查询词需要做惩罚否则长文档永远排前面。实际工程中BM25 有两个参数需要调k1 控制词频饱和速度b 控制文档长度归一化强度。多数场景下 k1 取 1.2 到 2.0b 取 0.75 是安全的默认值。但如果你的文档长度差异极大比如有的文档块 50 字有的 2000 字b 可以适当调低到 0.5 左右减少长度归一化的激进程度。中文场景下 BM25 有个坑需要先做分词。分词的粒度直接影响检索效果。我的经验是技术文档场景用细粒度分词比如把向量数据库切成向量、数据库配合 BM25 效果更好因为用户查询可能只包含其中一部分。但如果是法律、医疗这类术语密集的领域粗粒度分词保留完整术语反而更准。2.2 向量检索这一路怎么选型和调优向量检索这一路选型主要看三个维度嵌入模型、索引类型、距离度量。嵌入模型的选择不要只看排行榜。排行榜上的模型多是在通用语义相似度任务上评测的你的业务场景可能完全不同。我的做法是拿 200 条真实用户查询人工标注正确答案然后测几个候选模型的召回率。实测下来一个在排行榜上排 20 名开外的模型在特定垂直领域可能吊打排名前 3 的通用模型。索引类型方面主流向量数据库都支持多种索引。HNSW 是图索引查询快、召回高但内存占用大IVF 是倒排索引内存友好但需要训练且召回略低。数据量在百万级以下HNSW 基本是首选因为省心。千万级以上就要考虑 IVF 加量化了用精度换内存和速度。距离度量要和嵌入模型的训练目标匹配。多数现代嵌入模型用余弦相似度训练那就用余弦有些模型用点积训练那就用内积。用错了度量方式效果会明显下降。这个细节很多人忽略但实测影响不小。2.3 融合策略RRF 为什么比加权求和更稳两路检索各自返回一个有序列表怎么融合成一个列表常见做法有两种加权求和和 RRFReciprocal Rank Fusion。加权求和是给两路分数各乘一个权重再加起来。问题是BM25 的分数和向量相似度的分数不在一个量纲上BM25 分数可能是 0 到 30余弦相似度是 0 到 1直接加权需要先归一化而归一化方式的选择又很讲究搞不好就引入偏差。RRF 的思路完全不同它不看分数只看排名。公式很简单每个文档的最终得分是sum(1 / (k rank))k 通常取 60。也就是说一个文档在 BM25 里排第 1、在向量检索里排第 5它的 RRF 得分就是1/(601) 1/(605)。RRF 的好处是不需要归一化不需要调权重对两路检索的分数分布不敏感。实测下来RRF 在多数场景下比精心调参的加权求和还要稳而且几乎不需要调参。这也是为什么大厂系统里 RRF 出现频率极高。实操心得RRF 的 k 值取 60 是论文里的默认值但我试过在文档块数量较少几千条的场景下调到 20 到 30效果更好因为排名差异被放大了。数据量大就保持 60。3. 从零搭建混合检索的完整实操3.1 环境准备与依赖安装先明确技术栈。向量检索我用一个轻量级的本地向量库关键词检索用 BM25 的实现重排序用一个小的交叉编码器模型。整套东西可以在一台普通开发机上跑起来不需要 GPU 集群。pip install rank_bm25 pip install sentence-transformers pip install numpy向量库这里我用一个内存版的方案演示生产环境换成任意主流向量数据库即可接口逻辑是一样的。重排序模型用交叉编码器它比双编码器慢但精度高适合对少量候选做精排。from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer, CrossEncoder import numpy as np3.2 文档预处理与分块策略分块是 RAG 系统里最容易被低估的环节。块切得太大检索精度下降切得太小上下文丢失。我的经验是技术文档按语义段落切每块 200 到 500 字块之间保留 50 字左右的重叠。重叠的作用是防止关键信息正好被切在边界上。比如一个配置说明跨了两个段落没有重叠的话两个块各拿一半检索时可能都匹配不完整。def chunk_document(text, chunk_size400, overlap50): chunks [] start 0 while start len(text): end start chunk_size chunks.append(text[start:end]) start end - overlap return chunks中文场景下按字符数切块比按词数更直观因为中文字符的信息密度比较均匀。但要注意切块时尽量在句号、换行这些自然边界处断开不要硬切在句子中间。预处理阶段还要做一件事给每个块附加元数据。至少要有文档 ID、块序号、来源标题。这些元数据在后续过滤和结果展示时都会用到。3.3 BM25 索引构建与查询BM25 这一路的构建很直接。先把所有文档块分词然后建索引。tokenized_corpus [list(chunk) for chunk in all_chunks] bm25 BM25Okapi(tokenized_corpus) def bm25_search(query, top_k20): tokenized_query list(query) scores bm25.get_scores(tokenized_query) top_indices np.argsort(scores)[::-1][:top_k] return [(idx, scores[idx]) for idx in top_indices]这里有个细节中文按字符分词是最简单的做法但效果一般。更好的做法是用分词工具切成词。不过对于技术文档按字符分词的鲁棒性反而不错因为技术术语里经常混着英文、数字、符号分词工具容易切错按字符切虽然粒度细但不会漏。BM25 返回的 top_k 建议设大一点比如 20 到 50。因为后面还要融合和重排序这一路多召回一些给后续环节更多选择空间。3.4 向量索引构建与查询向量这一路先用嵌入模型把所有块编码成向量。model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) embeddings model.encode(all_chunks, normalize_embeddingsTrue) def vector_search(query, top_k20): query_vec model.encode([query], normalize_embeddingsTrue)[0] scores np.dot(embeddings, query_vec) top_indices np.argsort(scores)[::-1][:top_k] return [(idx, scores[idx]) for idx in top_indices]normalize_embeddingsTrue这个参数很关键它把向量归一化到单位长度这样点积就等于余弦相似度。省去了手动算余弦的步骤也避免了数值问题。嵌入模型的选择上多语言模型适合中英混合的技术文档。如果你的文档全是中文可以选专门的中文嵌入模型效果通常更好。但要注意换模型意味着所有向量都要重新编码所以选型阶段要多花点时间。3.5 RRF 融合与重排序两路检索各自返回结果后用 RRF 融合。def rrf_fusion(bm25_results, vector_results, k60): scores {} for rank, (idx, _) in enumerate(bm25_results): scores[idx] scores.get(idx, 0) 1 / (k rank 1) for rank, (idx, _) in enumerate(vector_results): scores[idx] scores.get(idx, 0) 1 / (k rank 1) sorted_items sorted(scores.items(), keylambda x: x[1], reverseTrue) return sorted_items融合后取前 20 个候选送入交叉编码器重排序。交叉编码器把查询和文档拼在一起输入模型直接输出相关性分数精度比双编码器高很多但计算量大所以只对少量候选做。cross_encoder CrossEncoder(cross-encoder/ms-marco-MiniLM-L-6-v2) def rerank(query, candidates, top_k5): pairs [(query, all_chunks[idx]) for idx, _ in candidates] scores cross_encoder.predict(pairs) ranked sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) return ranked[:top_k]重排序这一步是提升精度的关键。实测下来经过重排序后Top-5 的准确率能比只做融合提升 10 到 20 个百分点。代价是延迟增加交叉编码器对 20 个候选打分大概需要 100 到 300 毫秒取决于模型大小和硬件。3.6 完整流程串联把上面的环节串起来一个完整的混合检索流程是这样的def hybrid_search(query, top_k5): bm25_results bm25_search(query, top_k30) vector_results vector_search(query, top_k30) fused rrf_fusion(bm25_results, vector_results) reranked rerank(query, fused[:20], top_ktop_k) return reranked整个流程的延迟在普通开发机上大概是BM25 检索 5 到 20 毫秒向量检索 10 到 50 毫秒取决于数据量和索引类型RRF 融合 1 毫秒以内重排序 100 到 300 毫秒。总计 150 到 400 毫秒对于大多数问答场景是可以接受的。如果要进一步降延迟可以把重排序模型换成更小的或者只对 Top-10 做重排序。但我不建议完全去掉重排序因为它是精度提升最明显的一环。4. 实际调优中踩过的坑与排查技巧4.1 召回率上不去先查这三个地方混合检索搭起来之后如果召回率不达预期按这个顺序排查。第一检查分块。十次召回问题里有六次是分块不合理。典型症状是正确答案明明在文档里但检索不到。这时候把分块结果打印出来看看很可能答案被切碎了或者关键信息正好落在重叠区之外。我的做法是拿几个失败案例手动定位答案在原文的位置然后看它被分到了哪个块块的内容是否完整。第二检查分词。BM25 这一路对分词敏感。如果查询里的关键词被分词工具切错了BM25 就匹配不上。比如向量数据库被切成向量、数据、库用户搜向量库就匹配不上。解决办法是加自定义词典把领域术语加进去。第三检查嵌入模型。如果 BM25 能召回但向量这一路召回不了说明嵌入模型对你的领域不敏感。这时候要么换模型要么在领域数据上做微调。微调嵌入模型的门槛比想象中低几百条标注数据就能有明显提升。4.2 融合后排序混乱的排查思路RRF 融合后有时候会出现明显不相关的结果排在前面。这种情况通常是两路检索里有一路返回了噪声。排查方法是把两路各自的 Top-10 打印出来对比。如果 BM25 的 Top-10 里有一半是无关的说明查询里的词太常见BM25 的 IDF 权重没起作用。这时候可以加停用词表把常见但无意义的词过滤掉。如果向量检索的 Top-10 里有一半无关说明嵌入模型把语义空间搞混了。常见于查询很短的情况比如只有两三个字。短查询的向量表示本身就不稳定解决办法是查询扩展——用大模型把短查询改写成更完整的句子再去做向量检索。实操心得查询扩展这招在短查询场景下效果拔群。我试过一个场景用户查询平均长度只有 4 个字加了查询扩展后向量检索的召回率从 52% 提升到 71%。代价是多了一次大模型调用延迟增加 200 到 500 毫秒。4.3 重排序模型选型与延迟平衡重排序模型不是越大越好。我对比过几个不同大小的交叉编码器结论是在候选数量 20 以内的情况下小模型和大模型的精度差距很小通常 2 到 3 个百分点但延迟差距是 3 到 5 倍。所以我的建议是先用小模型如果精度不够再换大的。而且重排序的候选数量不要设太大20 到 30 足够了。候选太多一是延迟高二是后面的候选本来相关性就低重排序也救不回来。还有一个细节重排序模型的训练数据和你的业务场景是否匹配。多数公开的重排序模型是在通用问答数据上训练的对技术文档、法律文书这类专业文本效果会打折扣。如果有条件用业务数据微调一下重排序模型提升非常明显。4.4 常见问题速查表问题现象可能原因排查方法解决方案精确匹配搜不到BM25 分词错误打印分词结果加自定义词典语义相关但排序靠后重排序模型不匹配对比重排序前后微调重排序模型短查询效果差向量表示不稳定测试不同长度查询查询扩展长文档总是排前面BM25 长度归一化不足检查 b 参数调低 b 值融合后噪声多某一路召回质量差分别看两路 Top-10过滤停用词或换模型延迟过高重排序候选太多统计各环节耗时减少候选数量或换小模型4.5 一个容易被忽略的细节查询预处理用户输入的查询往往很脏有多余空格、有特殊符号、有错别字。这些都会影响检索效果。上线前一定要做查询预处理。最基本的去首尾空格、统一全角半角、去掉连续重复的标点。进阶一点做拼写纠错但纠错要谨慎因为技术术语里的错误可能是故意的比如变量名里的下划线。我的做法是维护一个查询改写规则表把常见的口语化表达映射到标准术语。比如用户搜咋配置映射成如何配置搜报错了映射成错误处理。这个规则表可以人工维护也可以用大模型自动生成候选再人工审核。5. 混合检索的进阶优化方向5.1 元数据过滤与结构化检索前面提到纯向量检索无法表达结构化约束混合检索虽然好一些但元数据过滤还是需要单独处理。主流向量数据库都支持带过滤条件的向量检索但过滤和检索的执行顺序会影响性能。如果过滤条件能筛掉大部分数据应该先过滤再检索这样检索的数据量小速度快。如果过滤条件很宽松筛不掉多少数据那就先检索再过滤避免过滤操作本身的开销。实际工程中我通常把元数据分成两类高选择性元数据比如文档类型、部门和低选择性元数据比如时间范围。高选择性的用于预过滤低选择性的用于后过滤。5.2 多路召回的扩展思路混合检索是两路但实际系统里可以扩展到多路。比如再加一路基于大模型的查询改写检索。用大模型把用户查询改写成多个不同角度的子查询每个子查询各自检索最后融合。这招在处理复杂查询时特别有效。比如用户问对比 A 方案和 B 方案的性能差异单路检索可能只召回 A 或只召回 B多路改写后一路专门检索 A一路专门检索 B融合后两个方案的信息都能拿到。代价是延迟和成本增加。每多一路就多一次检索如果每路还带大模型调用延迟会明显上升。所以多路召回适合对精度要求高、对延迟容忍度也高的场景比如离线分析、报告生成。5.3 缓存与增量更新生产环境的 RAG 系统缓存是必须的。用户查询有大量重复相同查询直接返回缓存结果能省掉整个检索流程。缓存策略上精确匹配缓存最简单但命中率有限。语义缓存更激进把查询编码成向量如果和缓存里的查询向量相似度超过阈值就返回缓存结果。语义缓存命中率高但有误判风险——两个查询语义相似但意图不同返回错误结果。我的经验是阈值设高一点0.95 以上宁可少命中也不要误判。增量更新是另一个工程问题。文档会更新索引也要跟着更新。BM25 索引更新相对简单重建或增量添加都行。向量索引更新麻烦一些HNSW 支持增量插入但删除需要标记删除再定期重建。如果文档更新频繁要考虑索引重建的频率和成本。5.4 评测体系的搭建没有评测调优就是盲人摸象。混合检索的评测至少要覆盖三个指标召回率、精确率、MRR平均倒数排名。召回率衡量该找的有没有找到精确率衡量找到的有没有用MRR 衡量有用的排得够不够前。三个指标要一起看只看一个容易调偏。评测集的构建我的做法是从真实用户查询里采样 200 到 500 条人工标注每条查询对应的正确文档块。标注工作量大但一次投入长期受益。后续每次调优跑一遍评测集就知道有没有提升。注意评测集要定期更新因为用户查询分布会变化。我见过一个系统评测集是半年前建的在上面调优到 90% 召回上线后实际效果只有 60%因为用户查询模式已经变了。6. 一些个人体会混合检索这套东西搭起来不难难的是调优。我踩过的最大坑是过早优化——一开始就上复杂的多路召回、大模型重排序结果延迟高得没法用精度也没比简单方案好多少。后来退回来先用 BM25 加向量两路RRF 融合小模型重排序把基础打牢再逐步加东西反而效果更好。另一个体会是不要迷信排行榜和论文。论文里的方法在论文的数据集上有效你的数据可能完全不同。每次改动都要在自己的评测集上验证数据说话。最后RAG 系统里检索只是其中一环检索之后的生成环节同样重要。检索返回的上下文怎么组织、怎么塞进提示词、怎么让大模型引用来源这些都会影响最终效果。检索做得再好生成环节拉胯用户体验照样差。这块内容展开又是另一个话题了后面有机会再聊。