ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

混合检索RAG实战:查询增强+双路召回+重排,命中率从61%到89%

混合检索RAG实战:查询增强+双路召回+重排,命中率从61%到89% 1. 混合检索 RAG 到底在解决什么问题1.1 从一次翻车现场说起去年下半年我接手了一个企业知识库问答项目文档量大概四万份覆盖产品手册、售后工单、内部流程规范三大类。一开始走的是最朴素的路线LangChain 默认的 RecursiveCharacterTextSplitter 切块配 OpenAI 的 embedding塞进 Chroma检索就靠向量相似度 top-k。Demo 阶段效果惊艳老板看了直点头。上线第三天客服部门反馈问“XX型号设备报错 E-204 怎么处理”系统答的是另一款型号的通用维护流程完全驴唇不对马嘴。我拉出日志一看向量检索返回的 top-5 里有三条是语义相近但型号不同的文档。问题出在哪纯向量检索擅长捕捉“意思相近”但对“精确匹配”这件事天生不敏感。E-204 这个错误码在 embedding 空间里被稀释了模型觉得“报错处理”这个语义更重要于是把别的型号的报错文档也捞了上来。这就是 RAG 最典型的瓶颈之一召回阶段就已经错了后面生成再强也救不回来。后来我把这套链路彻底重构了一遍核心思路就是标题里说的查询增强、双路召回、重排。改完之后同一批测试集的命中率从 61% 提到了 89%客服那边的投诉直接归零。这篇文章就把这套全链路的做法完整拆开讲包括每一步为什么这么做、参数怎么定、踩过哪些坑。适合已经跑通过基础 RAG、但被召回质量卡住的同学也适合刚开始搭知识库、想一步到位把架构设计对的人。1.2 纯向量检索的三个死穴在展开方案之前先把问题定义清楚。纯向量检索在实际项目里翻车基本逃不出这三种情况。第一专有名词和编号被语义淹没。产品型号、错误码、订单号、人名这类 token在 embedding 模型眼里和普通词没区别都会被压缩进一个稠密向量。当文档里出现大量语义结构相似的段落时这些关键标识符的区分度就没了。我实测过一个案例查询“合同编号 HT-2023-0871 的付款条款”纯向量 top-10 里能命中正确文档的概率不到 40%。第二短查询语义稀疏。用户输入往往就几个词比如“报销流程”“年假规定”。这种查询本身信息量就少embedding 出来的向量指向性很弱容易召回一大片泛泛相关的内容。而 BM25 这类基于词频的算法反而对短查询的关键词匹配更稳。第三多跳问题和上下文依赖。有些问题需要先定位到某个实体再顺着实体找关联信息。纯向量检索是“一次性”的没有这种逐步收敛的能力。这时候就需要查询增强来把用户的模糊问题改写成更适合检索的形式。理解了这三个死穴后面的方案设计就顺理成章了用查询增强解决“问得不好”用双路召回解决“单路有盲区”用重排解决“粗排不够精”。2. 查询增强让用户的烂问题变成好查询2.1 查询改写、扩展与分解的分工查询增强不是一个单一技术而是一组手段的统称。我在项目里主要用三种各有各的适用场景不能混着乱用。查询改写Rewrite解决的是口语化、指代不清的问题。用户问“那个报销的咋弄”改写后变成“员工费用报销的流程和所需材料”。这一步用 LLM 做prompt 里明确要求保留原始意图、补全指代、去掉口水词。查询扩展Expansion解决的是同义词和术语差异。用户说“年假”文档里写的是“带薪年休假”用户说“电脑坏了”文档里是“终端设备故障”。扩展的做法是让 LLM 生成 2-3 个语义等价但用词不同的查询变体然后并行检索结果合并。这一步对提升召回率效果非常明显我实测能带来 8-12 个百分点的提升。查询分解Decomposition解决的是复合问题。用户问“新员工入职第一周需要完成哪些培训以及这些培训的考核标准是什么”这其实是两个问题。分解成“新员工入职第一周培训清单”和“新员工培训考核标准”分别检索再合并上下文比直接拿长查询去检索准得多。注意查询增强不是越多越好。每多一次 LLM 调用就多一份延迟和成本。我的经验是改写必做扩展看场景术语差异大的领域必做分解只在检测到复合问句时才触发。2.2 用 LLM 做查询改写的实操 prompt直接上我在用的 prompt 模板这个是迭代了七八版之后比较稳的REWRITE_PROMPT 你是一个查询优化助手。请将用户的原始问题改写为更适合知识库检索的形式。 要求 1. 保留原始意图不要添加用户没有问的信息 2. 补全指代词如它、那个为具体对象 3. 去掉口语化表达和语气词 4. 如果问题包含多个子问题用分号分隔 5. 只输出改写后的查询不要任何解释 原始问题{query} 改写后这里有个细节很关键要求“只输出改写后的查询”。早期我没加这句LLM 总爱在前面加一句“好的改写后的查询是”导致检索时把这句话也当成查询内容反而引入噪声。加上之后干净多了。另一个坑是改写过度。有一次用户问“怎么请病假”LLM 改写成了“员工因病需要请假时的申请流程、所需证明材料、审批权限及薪资计算方式”信息是丰富了但把用户没问的“薪资计算”也塞进去了导致召回了一堆不相关的文档。后来我在 prompt 里加了“不要添加用户没有问的信息”这个问题才解决。2.3 查询扩展的并行检索与结果融合扩展出来的多个查询变体检索完怎么合并最简单的是把所有结果去重后按分数排序但不同查询的分数尺度可能不一样直接比不公平。我用的是RRFReciprocal Rank Fusion倒数排名融合公式很简单RRF_score(d) Σ 1 / (k rank_i(d))其中 k 通常取 60rank_i(d) 是文档 d 在第 i 个查询结果里的排名。这个方法的妙处在于只看排名不看分数天然规避了不同检索器分数不可比的问题。我实测下来RRF 融合比简单加权平均稳定得多尤其是在向量检索和 BM25 混合的场景下。具体实现上扩展查询和原始查询一起并行跑每个查询各召回 top-20然后用 RRF 合并成一个候选集取 top-30 进入下一阶段。这个数字不是拍脑袋定的后面重排章节会讲怎么调。3. 双路召回向量库和搜索引擎各干各的活3.1 为什么单路召回一定有盲区这是整个架构里我最想强调的一点。向量检索和关键词检索的能力是互补的不是替代关系。向量检索强在语义泛化用户问“怎么退钱”能召回“退款流程”问“设备发烫”能召回“散热异常处理”。但它弱在精确匹配型号、编号、专有名词容易被稀释。BM25 强在精确匹配和词频统计查询里的关键词只要在文档里出现就能拿到高分型号 E-204 就是 E-204不会被语义模糊掉。但它弱在语义泛化用户说“退钱”文档写“退款”BM25 可能就匹配不上。我做过一组对比实验在同一批 500 条测试查询上检索方式Recall10精确匹配类查询命中率语义类查询命中率纯向量72%48%81%纯 BM2565%79%52%双路 RRF88%84%86%数据很直白单路各有各的瘸腿双路融合才能把两边的长板都吃上。精确匹配类查询靠 BM25 兜底语义类查询靠向量兜底融合之后整体召回率上了一个台阶。3.2 向量库选型Chroma、Milvus 还是 Qdrant向量库这块我前后用过三个说说各自的适用场景。Chroma适合原型和小规模。装起来就一行pip install chromadb本地持久化也简单。但文档量上到十万级之后检索延迟明显上升而且它的索引选项比较有限。我早期项目用它五万份文档以内没问题再往上就吃力了。Milvus是工业级的选择分布式、支持多种索引类型IVF、HNSW、DiskANN百万级甚至亿级都能扛。但部署复杂度高单机跑个 standalone 也要 Docker Compose 一堆配置。适合已经有运维能力、文档量确实大的团队。Qdrant是我现在的主力选择介于两者之间。Rust 写的性能好单机部署简单一个 Docker 容器搞定支持 HNSW 索引和 payload 过滤过滤和向量检索能一起做这点对 RAG 特别有用——比如只检索某个部门或某个时间段的文档。API 也清爽Python 客户端用起来很顺手。选型建议五万份文档以内 Chroma 够用五万到百万级 Qdrant 性价比最高百万级以上再考虑 Milvus。别一上来就上分布式运维成本会吃掉你所有精力。3.3 搜索引擎选型Elasticsearch 的 BM25 配置要点关键词那一路我用的是 Elasticsearch 的 BM25。这里有几个配置细节直接影响召回质量。分词器选择。中文场景必须用 IK 分词器标准分词器会把中文按单字切BM25 效果惨不忍睹。IK 有两种模式ik_smart粗粒度、ik_max_word细粒度。索引时用ik_max_word切得细召回全查询时用ik_smart切得粗精度高这是官方推荐的做法。BM25 参数调优。BM25 有两个核心参数k1 控制词频饱和度b 控制文档长度归一化。默认 k11.2、b0.75大多数场景够用。但如果你的文档长度差异很大比如有的几百字有的几万字可以适当调低 b 到 0.5-0.6减弱长度归一化的影响。字段权重。如果文档有标题、正文、标签等字段标题的权重应该更高。用 multi_match 查询给标题字段加 boost{ query: { multi_match: { query: E-204 报错, fields: [title^3, content^1, tags^2], type: best_fields } } }标题权重给 3 倍标签给 2 倍正文 1 倍。这个比例是我调了几轮之后比较满意的型号类查询的命中率提升明显。3.4 双路结果融合RRF 参数怎么定前面提过 RRF这里说参数。公式里的 k 值论文里推荐 60我实测在 30-80 之间效果差异不大最终定在 60。真正影响大的是每路召回的候选数量。我的配置是向量路 top-30BM25 路 top-30RRF 融合后取 top-40 进入重排。为什么是 30 而不是 10因为重排模型需要一定的候选池才能发挥作用候选太少重排没得选候选太多重排延迟上去了。3030 融合出 40 左右是个平衡点。这里有个容易忽略的点两路的候选数量不必相等。如果你的场景精确匹配需求更强BM25 那路可以多召回一些比如向量 top-20、BM25 top-40。反过来语义需求强就调过来。这个要根据你的实际查询分布来定没有标准答案。4. 重排把粗排的噪声压下去4.1 重排模型为什么比向量相似度准双路召回 RRF 融合出来的 top-40质量已经不错了但还不够。原因是召回阶段用的都是“双塔”结构——查询和文档分别编码最后算相似度。这种结构的致命伤是查询和文档在编码时互相看不到对方无法做细粒度的交互。重排模型Cross-Encoder不一样它把查询和文档拼在一起送进模型做全交叉的注意力计算。查询里的每个词都能和文档里的每个词交互判断相关性时信息量大得多。代价是慢——双塔可以预计算文档向量重排必须实时算所以只能用在候选集上不能用来做全量召回。我用的是 BGE-reranker 系列中文场景效果很稳。base 版够用large 版更准但慢一倍。实测在 top-40 候选上重排能把正确文档从第 15 位提到第 2 位的情况很常见。4.2 重排的部署与延迟控制重排模型部署有两种方式本地跑和调 API。本地跑用 sentence-transformers 或 FlagEmbedding一张消费级显卡比如 3060 12G跑 base 版batch size 32 的情况下40 个候选的重排延迟大概 80-150ms。API 方式省事但有网络延迟和成本。延迟控制的关键是控制候选数量和批处理。40 个候选是甜点区再往上延迟增长快但收益递减。批处理就是把 40 个 (query, doc) 对一次性送进模型而不是循环单个算GPU 利用率能高好几倍。实操心得如果你的场景对延迟极其敏感比如要求端到端 500ms 以内可以把重排候选降到 20或者用更小的重排模型。但别为了延迟直接砍掉重排我试过命中率会掉 10 个点以上得不偿失。4.3 重排之后的截断策略重排完取 top-k 送进 LLM 生成。k 取多少这个直接影响生成质量和 token 成本。我的经验是k5 到 8。取太少可能漏掉关键信息取太多噪声增加而且 LLM 的上下文里塞太多不相关内容反而会干扰生成。我一般取 top-6然后根据文档长度动态调整——如果单篇文档很长就取少一点如果都是短文档可以取到 8。还有一个技巧设置分数阈值。重排模型输出的相关性分数是有绝对意义的低于某个阈值的直接丢掉哪怕还没到 k 个。比如 BGE-reranker 的分数低于 0.3 基本就是不相关了硬塞给 LLM 只会添乱。这个阈值要在你的测试集上校准不同模型尺度不一样。5. 全链路串起来从查询到答案的完整流程5.1 完整链路的代码骨架把前面几块拼起来整个流程是这样的def hybrid_rag_pipeline(user_query): # 1. 查询增强 rewritten llm_rewrite(user_query) expanded llm_expand(rewritten) # 返回 [rewritten, variant1, variant2] # 2. 双路召回 all_candidates [] for q in expanded: vec_results vector_search(q, top_k30) bm25_results bm25_search(q, top_k30) all_candidates.append((vec_results, bm25_results)) # 3. RRF 融合 fused rrf_fusion(all_candidates, k60, top_n40) # 4. 重排 reranked rerank(rewritten, fused, top_k6, threshold0.3) # 5. 生成 context build_context(reranked) answer llm_generate(rewritten, context) return answer这个骨架看着简单但每一步的参数和细节都是前面几节讨论过的。别小看这些参数它们决定了系统是能用还是好用。5.2 各阶段耗时拆解与优化端到端延迟是 RAG 系统能不能上生产的关键指标。我把各阶段耗时拆开给你看基于 4 万文档、单卡 3060 的实测阶段耗时占比优化手段查询改写300-500ms25%用小模型或缓存常见查询查询扩展400-600ms30%和改写合并成一次 LLM 调用向量检索30-50ms3%HNSW 索引调 ef_searchBM25 检索20-40ms2%ES 分片优化RRF 融合10ms1%纯计算可忽略重排80-150ms8%批处理控制候选数LLM 生成500-1500ms32%流式输出减少用户感知延迟看出来了吧LLM 调用改写扩展生成占了总延迟的 85% 以上。所以优化延迟的重点不在检索而在 LLM。我的做法是改写和扩展合并成一次调用让 LLM 一次输出改写结果和扩展变体生成用流式输出让用户先看到字。这样用户感知的首字延迟能压到 1 秒以内。5.3 效果评估怎么知道改进了没有评估就没有优化。我建了一套评估流程核心是构造带标注的测试集。具体做法从真实用户查询里采样 200-500 条人工标注每条查询对应的正确文档可能是一篇也可能是多篇。然后跑全链路看正确文档有没有出现在最终的 top-k 里。指标用 Recallk 和 MRR平均倒数排名。这套评估集建起来费劲但一劳永逸。每次改参数、换模型跑一遍就知道是变好还是变坏。我见过太多团队凭感觉调参改了半天其实是在原地打转。有评估集和没评估集调优效率差十倍。6. 踩过的坑与排查实录6.1 召回率上不去的排查顺序召回率不达标按这个顺序查能覆盖 90% 的情况先看分块。分块太大一个 chunk 里混了多个主题向量被平均掉了分块太小上下文不完整。我一般用 512 token 左右重叠 50-100 token。如果文档有天然结构标题、章节按结构切比按长度切好得多。再看 embedding 模型。中文场景别用英文模型BGE、M3E、GTE 这些中文模型效果好很多。模型选错后面怎么调都白搭。然后看双路权重。如果精确匹配类查询命中率低说明 BM25 那路召回太少或权重不够反之亦然。最后看重排。如果正确文档在召回候选里但没进最终 top-k那是重排的问题如果压根没在候选里那是召回的问题。先定位问题出在哪一环再动手调。6.2 常见问题速查表现象可能原因排查方法解决精确匹配查询答错BM25 未启用或权重低看候选里有没有正确文档提高 BM25 召回数调字段 boost语义查询答错向量模型不适配单独测向量路召回换中文 embedding 模型正确文档在候选但没进 top-k重排模型问题看重排前后排名变化换重排模型或调阈值回答包含无关信息重排阈值太低看送进 LLM 的上下文提高阈值减少 k端到端太慢LLM 调用太多打点看各阶段耗时合并 LLM 调用流式输出同义查询召回不到查询扩展未启用测扩展前后召回率启用扩展用 RRF 融合6.3 几个反直觉的经验第一重排不是万能的。如果召回候选里压根没有正确文档重排再强也变不出来。所以召回是基础重排是锦上添花。别指望重排能救召回。第二查询扩展不是越多越好。我试过扩展到 5 个变体结果召回了一堆语义漂移的内容反而拉低了精度。2-3 个变体是甜点区。第三RRF 的 k 值没那么敏感。网上很多文章把 k 值说得神乎其神我实测 30 到 80 之间差异在 1-2 个百分点以内。别在这上面浪费时间把精力放在分块和模型选型上。第四评估集比调参重要。我见过太多人凭感觉调参改了一个参数觉得“好像好点了”其实可能是噪声。有评估集改完跑一遍好就是好坏就是坏清清楚楚。这套混合检索 RAG 全链路我从最初的纯向量一路踩坑改过来最大的体会是RAG 的效果瓶颈几乎永远在召回而不是生成。把查询增强、双路召回、重排这三块做扎实命中率能从及格线拉到优秀线。至于生成那一步现在的大模型只要上下文给对了基本不会掉链子。最后分享一个小技巧如果你的文档里有大量结构化信息表格、参数、编号在分块时把这些信息单独抽出来存成 metadata检索时用 payload 过滤先缩小范围再做向量和 BM25 检索。这一步能让精确匹配类查询的命中率再上一个台阶我在设备手册场景里实测有效。
返回列表