☰
RAG进阶实践:路由、子索引与多文档Agent的检索优化
2026/10/4 15:51:10 网站建设 项目流程

前几篇我们把最基础的 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_exceptions=True) return merge_results(results, decision)

注意set(decision.target_indices)这一步,我用它去重并固定并行调用的子索引,避免同一个库被重复检索。merge_results负责把各子索引返回的结果做融合,融合策略我在下一小节里讲。整个调度器不到 100 行代码,调用关系清晰,出了问题也好排查,不需要引入额外的基础设施。

3.3 结果融合与去重

多路检索最常见的坑,是不同子索引返回了大量重复或互相矛盾的 chunk,直接全部拼接给大模型,输出质量会非常不稳定。我试过两种有效的融合方式。

第一种是 RRF(Reciprocal 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_k=top_k, filter=must_filter, # 强制注入,调用方不能绕过 )

这避免了误删过滤条件的低级事故。每种子索引的切分参数也做了差异化:FAQ 和工单用较短的 chunk(256 token),技术手册用稍长的 chunk(512 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 这一层做扎实,用户体验的提升会比单纯换一个大模型版本明显得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询