尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

RAG进阶实践:路由、子索引与多文档Agent的检索优化

RAG进阶实践:路由、子索引与多文档Agent的检索优化 前几篇我们把最基础的 RAG 链路跑通了文档载入、切分、向量化丢进同一个向量库用户问一句我们做一次相似度检索把 topk 个 chunk 拼给大模型。这套东西在几十份文档、单一主题的场景下完全够用但一旦文档量上去、主题变杂问题就来了。我做过一个挺典型的项目几百份文档涵盖了产品手册、销售 FAQ、售后工单和内部技术规范用户问“你们 API 的限流规则是什么”系统返回的却是技术规范里讲限流算法实现的片段销售同事想要的明明是面向客户的简版说明。产品经理拿这个 case 问我怎么办我知道这时候靠调 chunk size、换 embedding 模型已经治标不治本了真正要动的是检索架构本身。所以这篇 7.4 进阶我想把 RAG 里最容易踩坑也最影响效果的三件事一次性讲清楚Router路由、Sub-index子索引、多文档 Agent。本文不绕理论重点是我在项目里验证过的拆法、配置和坑适合已经跑通基础 RAG、正在做生产级优化的同学直接对照着改。1. Router从“一个库搜全部”到“先分流再检索”1.1 统一索引的两个头号问题先说我为什么抛弃“单向量库 单次相似度检索”的架构。表面上看所有文档切完 chunk 后塞进同一个向量库查询时统一做 ANN逻辑最简单。但有两个问题在文档量上来之后会迅速放大。第一个问题是主题稀释。不同来源的文档往往在语义上互相重叠比如“限流”这个词在技术规范里指服务端的令牌桶算法在售后工单里指用户被限流后的报错 rate limit exceeded在销售 FAQ 里指“免费版每分钟最多请求 60 次”。三批 chunk 的向量都落在相似区域用户问一句含糊的“被限流了怎么办”向量检索会把三种内容都捞上来。到底取哪一段喂给大模型模型没有依据只能靠猜答案就变得不稳定。第二个问题是检索公平性。一个 400 页的 SDK 参考手册切成 2000 个 chunk一个 12 页的《新员工权限说明》只能切出 60 个 chunk。用户问“普通员工能看哪些报表”前者里有大量“报表”“权限”相关但语境完全无关的 chunk相似度排序稳稳占住前 5 位后者压根进不了 top10。这不是模型不行是相似度检索天然偏向文档量大的主题。回想一下搜索引擎的思路网页权重、站点质量、垂直领域优先本质上都是在对抗这种“大块头淹没小内容”的问题。所以我们要在检索之前加一道工序先判断用户意图属于哪个方向再决定去哪个子文档集里找。这就是 Router 存在的意义。它不一定非要用大模型先看我做的三类实现方案再决定你在什么阶段用哪一种。1.2 Router 的三种实现方式与选型第一种是关键词规则路由。维护一个“意图 - 关键词/正则”的映射表比如“价格、套餐、怎么收费”转到 sales_faq 子索引“报错、失败、无法”转到 support_tickets 子索引。查询时先做大小写归一化再逐个匹配关键词命中哪个就算哪个。实现成本最低几十行代码搞定延迟 10ms 以内也不消耗大模型调用。但它的缺点也很明显词表需要持续维护遇到没见过的同义表达就漏。实测下来纯关键词路由在小规模内部工具里够用一旦面向真实用户开放漏检率会快速上升。我见过一个团队往里堆了上千条关键词最后还是挡不住口语化提问。第二种是 Embedding 路由。给每个子索引写一段“摘要描述”比如“销售 FAQ面向客户的报价、套餐、合同、常见业务问题”然后把用户的 query 向量化与每个子索引摘要做余弦相似度取最高且超过阈值我一般先定 0.75再根据测试集校准的子索引。相比关键词它对同义表达更鲁棒延迟在几十毫秒量级同样不依赖 LLM 调用。第三种是 LLM 路由。把子索引的清单和各自的职责描述写进 prompt让大模型判断当前问题应该交给哪个模块甚至可以一次返回多个候选。这种方式的语义理解能力最强能处理“我调接口超时是不是我配的有问题还是你们服务端挂了”这种融合多个意图的复杂 query。但代价是每次路由都有 1 秒以上的延迟和 token 成本而且大模型偶尔会“发挥过度”给一个很离谱的答案。所以我不建议全部 query 都用 LLM 做路由。三种方案的对比我用一张表说清楚实现方式响应延迟LLM 调用维护成本适用场景关键词规则路由10ms 内不需要低但要持续维护词表文档类别有明显标志词Embedding 路由30-80ms不需要中阈值要定期校准子索引主题边界相对清晰LLM 路由1s 以上每次决策都调用低改 prompt 即可类别模糊、多意图、开放提问1.3 工程上最省心的组合方案我最终采用的是“三级组合”先用关键词规则做快筛命中就直接落地关键词没命中走 Embedding 路由拿 query 和子索引摘要做相似度如果最高分低于阈值再交给 LLM 做最终判断LLM 也给不出结果时落到一个专门建的“兜底总索引”。这套设计的核心逻辑是能用便宜手段解决的绝不轻易上贵手段包括最贵的 LLM 路由只处理长尾情况。实际上线后大约 60% 的请求在关键词层就结束了30% 走 Embedding 层真正需要 LLM 路由的不到 10%延迟表现和准确率都更可控。2. Sub-index把“一个库”拆成“多个小库”2.1 三种拆分维度别拍脑袋选Router 解决了“去哪查”的问题Sub-index 解决的是“库怎么组织”。拆法不一样后续的检索效果和运维成本差别非常大我建议先按下面三个维度想清楚。第一是按文档类型拆。这是我用得最多的方式最适合有明显边界的文档集。比如产品手册、销售 FAQ、操作指南、历史工单每个类型一个子索引。它们的语言风格不同FAQ 是短问答工单是口语化的故障描述技术手册是长段落加代码。混在两个库里切分参数、检索策略都很难同时适配。第二是按主题聚簇拆。如果文档类型杂、不好分边界可以考虑按业务主题拆。比如一个大健康产品拆成“诊断”“用药”“保险”“运营”四个主题。这种拆法对 Embedding 模型的要求较高最好先拿一批已标注的测试集做聚类实验再用聚类中心定义子索引。第三是按时间/版本分区拆。适用于强时效性的知识库比如政策条文、产品版本变更记录。拆出来之后检索时可以在路由层加上时间过滤避免旧版本信息污染新答案。时间分区通常和文档类型、主题聚簇叠加使用不单独构成主拆分维度。这里要重点说一个容易踩的坑拆得太细。我见过有人给每个文档单独建一个子索引结果几十个索引路由表复杂到没法维护而且一个跨文档问题要在几十个库里来回试探漏检率反而上升。我的经验是子索引数量控制在 5-10 个以内宁可粗一点、边界清晰一点也不要追求极致的粒度。因为 Router 本身也会犯错索引越细误路由的代价越大。2.2 子索引的两种存储形态子索引在物理实现上有两条路线。第一种是多 Collection/多库模式每个子索引是独立的向量表或数据库查询时只搜目标索引。它的优势是隔离性最好检索天然不串库缺点是跨库查询要手动做多路并行再对结果做融合代码复杂度高一些。第二种是单 Collection 加 Metadata Filter 模式。所有文档仍存在一个向量库里但在每一条 chunk 的 metadata 里打上子索引标签查询时先按 metadata 过滤再在过滤后的集合里做向量检索。比如使用 Qdrant 的 payload 过滤、Milvus 的 expr 过滤或 Elasticsearch 的 post_filter。这种方式维护最简单一个库搞定所有数据但是在数据量很大时过滤条件下的索引过滤性能会下降而且我踩到过一个问题代码里某一路查询忘写了 filter 条件结果用户问题跑到了完全无关的文档集里。所以走这条路线我一定会在公共查询函数里强制注入 filter而不是让调用方自己传。两种方式没有绝对优劣。如果团队刚上手、文档量在百万级以下、子索引数量明确我建议先用单 Collection Metadata Filter快速迭代如果子索引之间的数据敏感性差异很大或者子索引数量稳定且各自在持续增长多库隔离更省心。2.3 别忘了给 Router 准备一份“索引摘要”拆完子索引建完向量库第 3.1 步我差点漏掉的东西每个子索引要有一份“摘要文档”。这份摘要不是把所有 chunk 压缩成一段话而是写清楚这个子索引管什么、包含哪些来源、适合回答什么问题、不适合回答什么问题。比如技术规范子索引负责内部技术方案、接口设计、算法原理相关文档包含 API 参考与后端设计稿。适合回答“接口字段是什么、限流算法怎么实现”等技术细节问题不适合回答面向客户的简版说明。这份摘要的作用是给 Embedding 路由做比对的基准。用户 query 向量化之后先和所有摘要做相似度对比决定该进哪个子索引。没有这一步Router 就退化成纯关键词匹配了。每次新增子索引也要同步更新摘要并重新向量化这和新增文档本身是同等重要的一次发布操作。关于摘要的质量我给一个判断标准给一个完全不了解文档集的人读摘要他能快速说出“哪个问题应该去哪里查”就是合格的。写摘要时尽量用业务语言别写一堆技术黑话。之前看到一个摘要写“本索引覆盖 ETL Pipeline 的分层映射规则”实际上业务方根本不知道 ETL 是啥路由效果自然打折扣。3. 多文档 Agent让检索链路自己会决策3.1 为什么这里一定要 Agent而不是写死逻辑Router 加 Sub-index已经能解决大部分单轮检索问题。但要支撑复杂的真实业务还需要一个多文档 Agent 层。原因很简单真实用户的问题不会按你预设的边界提问。举一个我遇到的 query“客户说他的账号登录不上我们能不能查到是权限问题还是并发超限顺便帮他确认下现在的套餐是不是还能扩容”。这句话里至少涉及三个方向工单排查、权限规范、套餐规则。写死任何一条路由链路都只能回答其中一部分。多文档 Agent 的价值是维护一个决策与调度的上下文它拆解任务、并行调多个子索引、聚拢证据、再决定要不要追问或补充。有人可能会说这不就是把多个检索结果拼一起吗区别在于 Agent 会基于中间结果调整策略。比如技术子索引没搜到有效的排障步骤它会去工单子索引里找相似案例两个子索引证据冲突时它能标记冲突并让用户在后续对话中做选择。这些行为如果用硬编码实现每个分支都是一次需求量级不小的开发而用 Agent 编排可以更自然地扩展。3.2 轻量 Agent 框架怎么搭我建议先别急着上重型 Agent 框架手写一个轻量调度器就够了。我的思路是一个两层结构第一层是调度 Agent负责理解当前任务、决定要不要并行查多个子索引第二层是文档 Agent每个子索引对应一个负责在指定索引内做检索并返回结果。调度 Agent 拿到所有文档 Agent 的输出后再做汇总。下面这段是我项目里用的轻量调度逻辑核心是把“决策”和“执行”分开import asyncio from dataclasses import dataclass dataclass class RouterDecision: target_indices: list[str] confidence: float reason: str async def dispatch(question: str, router, doc_agents: dict): decision router.route(question) if decision.confidence 0.7: decision.target_indices.append(fallback_index) tasks [doc_agents[idx].asearch(question) for idx in set(decision.target_indices)] results await asyncio.gather(*tasks, return_exceptionsTrue) return merge_results(results, decision)注意set(decision.target_indices)这一步我用它去重并固定并行调用的子索引避免同一个库被重复检索。merge_results负责把各子索引返回的结果做融合融合策略我在下一小节里讲。整个调度器不到 100 行代码调用关系清晰出了问题也好排查不需要引入额外的基础设施。3.3 结果融合与去重多路检索最常见的坑是不同子索引返回了大量重复或互相矛盾的 chunk直接全部拼接给大模型输出质量会非常不稳定。我试过两种有效的融合方式。第一种是 RRFReciprocal Rank Fusion。对每个子索引返回的 topk 结果按位次打分分数 1 / (60 rank)然后把所有子索引的分数加起来排序。这个公式对排序位次敏感但对具体相似度分数的尺度不敏感适合合并不同模型的检索结果。实测下来RRF 比单纯按相似度分数直接 merge 要稳很多。第二种是“证据分组投票法”更适合答案合成阶段。把召回结果按“文档来源”分组如果多个子索引都命中了同一篇文档的不同小节说明这篇文档的证据强度更高在最终拼接时权重上调反之只有单个子索引命中了单条片段置信度就调低。我在汇总 prompt 里会明确告诉大模型多来源一致的证据优先采信单来源孤证要有取舍意识。两个方法可以叠加RRF 用在做检索排序证据分组投票用在最终答案合成。这套组合下来我遇到过的“各个子库证据打架”问题基本能被压住模型不会再输出前后矛盾的话。4. 一个可直接复现的完整实操案例4.1 场景与文档组织为了让你能直接套用我用一个“中型 SaaS 产品知识库”作为完整例子走一遍。假设我们的文档目录长这样docs/ product_manual/ # 产品手册功能说明、参数、接口文档 sales_faq/ # 销售 FAQ报价、套餐、合同、开票 operation_guide/ # 操作指南后台配置、账号权限、部署 support_tickets/ # 脱敏历史工单故障现象、排查过程、结论每个子目录对应一个子索引。当初定 4 个子索引而不是按文档细分成 20 个就是因为上面说的“控制在 5-10 个以内”的经验。文档总大小在 2000 页左右单 Collection metadata filter 方案完全能扛住。4.2 路由表和子索引配置路由表我按关键词规则 Embedding 路由两层来配。先给一份关键词规则示例查询意图样例目标子索引命中关键词示例API 限流每分钟几次、价格怎么算sales_faq价格、套餐、合同、发票、报价、限流次数后台怎么配置回调地址、如何创建子账号operation_guide配置、后台、创建、部署、账号、权限点击导出没反应、接口报错 500support_tickets报错、失败、超时、无反应、工单、bug底层架构、字段含义、算法原理product_manual架构、字段、算法、协议、接口定义建子索引的时候我给每个 chunk 的 metadata 都打上了sub_index标签并在公共查询函数里强制带过滤条件def search(query_embedding: list[float], sub_index: str, top_k: int 5): must_filter {sub_index: sub_index} return collection.query( query_embedding, top_ktop_k, filtermust_filter, # 强制注入调用方不能绕过 )这避免了误删过滤条件的低级事故。每种子索引的切分参数也做了差异化FAQ 和工单用较短的 chunk256 token技术手册用稍长的 chunk512 token因为长文档的技术段落上下文依赖更强。4.3 跑一次完整查询看链路怎么走用一句我实际遇到的用户提问做验证“为什么我调用 api 提示 rate limit exceeded是按账号还是按 ip 限制的”流程是调度 Agent 先把 query 交给路由层。关键词规则层命中了“api”和“rate limit”但这条命中对应两个子索引sales_faq 里有面向客户的限流次数说明product_manual 里有限流算法和限制对象的接口定义。于是路由层没有立刻拍板而是同时把两个子索引都列为候选交给调度 Agent 并行检索。两个文档 Agent 并行执行后product_manual 返回了“限流按账号维度计数的接口说明”sales_faq 返回了“免费版每分钟最多 60 次”的面向客户描述。汇总 Agent 在合成时识别到这是两个不同维度的答案最终输出先用销售 FAQ 的版本给出用户可感知的简答再附上产品手册中的技术细节并标注“如果你需要和开发对接可以参考接口文档中的具体字段”。这个回答质量是单库检索时代完全达不到的因为它天然区分了“写给客户看”和“写给技术看”两种答案形态。5. 踩坑实录六类高频问题与排查方向5.1 问题速查表我把项目里踩过的高频坑整理成一张速查表方便你直接定位现象根因排查方向Router 把什么都路由到技术文档路由 prompt 里没有给“拒绝”选项在 LLM 路由 prompt 中增加“无法确定时返回 unknown”分支小文档完全被大文档淹没子索引内没有做分数归一化改成并行查各子索引后做 RRF 融合多个子索引回答互相矛盾汇总时没有证据分组按文档来源分组投票统一交叉验证后再合成切了太多子索引后漏检率上升路由表太碎决策易错合并为 5-10 个粗粒度子索引留一个兜底总索引多路并行时链路超时上游服务和下游检索共用同一个超时时间区分上游超时短和下游检索超时长增加熔断同一套 embedding 在不同环境结果不一致模型版本或预处理逻辑不一致统一模型版本号并校验向量维度5.2 三个值得投入时间的高性价比优化点第一是路由日志回流。每次路由决策都落一条日志包含 query、命中关键词、路由到的子索引、最终返回了哪些 chunk、用户有没有继续追问。每周花半小时看一遍误判样本把常见误判词补进关键词表或修正索引摘要。这个操作成本极低但对路由准确率的提升非常明显。我出现过最典型的误判是把“怎么申请优惠”路由到了产品手册日志出来后才看到原来是“优惠”这个词在销售 FAQ 里没维护补上之后命中率立刻上去了。第二是统一 embedding 模型版本并且把它固定成发布配置。很多人忽略这点觉得模型只是个函数换版本无所谓。实际上模型一换向量空间就变了子索引摘要的向量、chunk 的向量全部要重新算。否则可能出现“query 向量和子索引摘要在同一个索引里却比不出相似度”的诡异问题。我踩过版本不一致的坑之后把模型名称和向量维度写进了一个公共配置任何环境不一致都会直接报错而不是静默跑出错误结果。第三是给 chunk 保留“文档血缘”信息。每个 chunk 除了 sub_index 和原文文本还存了来源文档标题、章节号、原始路径。这个血缘信息在最后一步合成答案时非常有用它让大模型能写出可追溯的引用来源也让你可以回溯排查是哪一篇文档导致的错误答案。很多人只存了个“文档名”但没存章节号真出问题时连定位原文都费劲。最后再分享一个我个人的操作习惯每次调整路由规则或索引摘要之后我都会用 20-30 条真实历史 query 做一遍回归测试看误路由率有没有变化。RAG 到了进阶阶段最贵的不再是模型成本而是维护检索链路的工程成本。把路由、子索引、Agent 这一层做扎实用户体验的提升会比单纯换一个大模型版本明显得多。
返回列表