ARTICLE DETAIL

资讯详情

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

RAG进阶:Router、Sub-index与多文档Agent的工程实践

RAG进阶:Router、Sub-index与多文档Agent的工程实践 做RAG做到第7篇我越来越确定一件事单靠“把所有文档扔进向量库然后top-k检索、拼Prompt、让模型生成”这套朴素方案根本扛不住真实业务。RAG系列走到7.4这篇聊的是进阶链路里最容易被忽视的三件套Router、Sub-index和多文档Agent。它们不是锦上添花的优化而是当你的知识库从几篇测试文档膨胀到几千份跨部门真实资料之后能不能继续用的分水岭。如果你正在做知识库落地要面对多个业务域的文档同时存在或者已经踩到“检索结果老是答非所问”的坑这篇的每一条经验都是我们项目组实打实趟出来的。有人可能会说“这不就是把索引拆细一点、加个路由嘛”实际做起来你会发现拆多细、路由怎么判、Agent怎么调度、冲突怎么处理每个决定背后都有代价和取舍。这篇就按我自己的踩坑顺序来写先从单索引为什么失控说起。1. 单一索引在多文档场景下是怎么一步步失控的1.1 一个向量库装下整个公司的幻觉最典型翻车现场长这样知识库刚起步时只有几十篇测试文档不管三七二十一全部塞进同一个向量库跑起来也没觉得哪里不对。等文档涨到几千份事情开始变得诡异。你问一个“退款多久到账”系统回答引用的内容可能来自产品手册的“退款流程说明”也可能来自客服工单里的“退款投诉处理案例”甚至来自财务制度里的“退款入账规则”。这三种来源描述的是完全不同语境下的“退款”。产品手册讲的是用户在App里的操作路径客服工单讲的是客诉怎么安抚、钱多久到账的现状财务制度讲的是内部分录怎么记、什么时候允许冲销。单索引的问题在于它把这三类chunk的embedding全部挤在同一个向量空间里而embedding模型是按语义相似度聚类的不是按文档来源聚类的。你用query向量去捞top-k捞到的总是离query向量最近的那个片区而那片区域往往被数量最多、用词最“通用”的chunk霸占真正跟提问语境匹配的那份文档反而被淹没。这和几百人共用一个聊天群是一个道理产品问题、报销问题、技术问题全在一个群里刷全体成员问一句回复自然夹杂大量无关消息甚至还有人抢答错误答案。RAG的向量库如果只有一个collection就是这个“全体成员群”。1.2 失控后的四个具体征兆我自己总结单索引失控通常先出现这四个征兆。它们不是某个模型或者某个embedding的问题是结构性问题。召回结果乱序相关文档明明是A主题top-k却几乎全被B主题的相似chunk覆盖。比如查“服务器宕机处理”返回的却是产品团队写的“宕机监控功能”说明文档因为两边用词相近在语义空间里挨得近。权限和责任隔离做不到一个collection里的所有chunk天然共享同一份访问边界。要做部门级或密级隔离只能自己在metadata里加filter而filter在语义检索里经常不生效向量检索结果一旦不够稳定过滤条件就会被各种无关文档绕过。增删改成本失控更新一份旧文档意味着要么全库重建要么写很复杂的增量更新。听着忍一忍也能接受但文档进入“每天都在改”的状态后全库重建耗时会让你开始拖延更新最终知识库内容越来越旧用户问到的永远是上个月的答案。冷门知识被“挤”出检索范围几千份文档里只有三五份讲老版本系统的迁移记录这种长尾chunk会被主题热门的内容淹没。单索引场景下它们几乎永远不会出现在top-20里你自己知道知识库里有这份资料但用户怎么问都问不出来。1.3 为什么加大top-k和加重排也救不回来很多人到这里第一反应是加大top-k或者加一个reranker。我也试过效果只能说治标不治本。top-k加大到20甚至30正确chunk确实可能进来但噪声也会成倍增加。后接的reranker负担变大而且reranker天然擅长的是“同一主题下哪个chunk更相关”它对“这两个是完全不同主题、你应该选哪个主题”这种事并不擅长。换句话说reranker做的是排序不是分流。你要它跨语义区域纠正方向它没有这个能力。所以正确的方向不是“在捞回来的内容里做文章”而是“在捞之前就把query送进正确的门”。这就是Router和Sub-index存在的意义。2. Router决定这个查询该进哪扇门2.1 Router到底管什么Router在RAG体系里是一个前置路由决策模块。输入是用户query输出是“这次查询应该走哪条链路”。链路可以是一个FAQ向量库、一个财务制度子索引、一个工单数据库的SQL查询、一个企业Wiki的图谱查询甚至可以是“直接让LLM根据外部知识作答完全不触发检索”。类比一下电商客服。用户问“物流到哪了”客服机器人不会去翻退款政策文档它知道该调物流API。但这并不是凭空知道的是系统在背后先做了一个意图分流。Router在RAG里干的就是这件事让query先被归类再去所属的子空间里精检索而不是每次都去全量空间里碰运气。没有Router的系统所有query共享同一个检索入口相当于所有客服请求都堆给同一个值班员不管你是问物流、问退款还是问发票他都得从自己的脑子里翻一遍结果自然顾此失彼。2.2 三种主流实现方式怎么选Router不是只有一个实现我见过的方案大体分三种各有各的适用场景。实现方式判断依据优点缺点典型延迟适合场景规则路由关键词、正则、意图词典快、便宜、完全可控泛化差规则维护成本随词表膨胀亚毫秒级入口明确、意图固定的封闭场景Embedding语义路由query和索引描述的向量相似度简单、中等泛化、零额外LLM成本对复杂意图理解有限需要写索引描述毫秒级中大型多索引RAG的首选方案LLM函数调用路由让大模型从工具列表里选一个泛化最强能处理绕口令式query慢、贵可能出现幻觉式误选500ms到2s额外token开销复杂意图、多Agent调度场景规则路由最便宜但只适合入口很少且意图固定的场景。比如公司内部只支持查制度、查产品、查工单三类可以用关键词词典把“报销”“发票”直接映射到finance索引。问题是一旦词表维护跟不上漏一个词就误判一次词越多越容易互相覆盖比如“发票”和“开票”可能被分到不同规则组。Embedding语义路由是性价比之王。先给每个子索引写一段描述把描述向量化query进来也向量化算相似度挑最像的那个索引。整个过程一次embedding加一次向量检索毫秒级完成不需要额外调用大模型也不会产生额外token成本。我大多数项目直接用它兜底。LLM函数调用路由是泛化最强的把每个子索引定义成工具函数让模型用function calling选一个。适合query特别绕、需要理解上下文才能分流的场景但每次路由都是一次额外的大模型调用延迟和成本都是实打实的开销。高频场景不建议每条query都走它后面我会单独讲怎么组合。2.3 能直接跑的路由模块先看Embedding语义路由的实现。这里有一个关键点路由的效果很大程度取决于每个子索引的description写得怎么样算法本身反而很简单。from sentence_transformers import SentenceTransformer import numpy as np class EmbeddingRouter: def __init__(self, index_metas): # index_metas 结构示例 # [ # {name: finance_docs, # desc: 财务制度、费用报销流程、发票与开票规则、差旅费用标准, # threshold: 0.45}, # {name: product_docs, # desc: 产品功能说明、使用手册、退款流程、版本特性, # threshold: 0.45}, # ] self.model SentenceTransformer(BAAI/bge-m3) self.index_metas index_metas self.desc_embs np.stack([self.model.encode(m[desc]) for m in index_metas]) def route(self, query): if not query or not query.strip(): return None q_emb self.model.encode(query) scores q_emb self.desc_embs.T best int(np.argmax(scores)) meta self.index_metas[best] if float(scores[best]) meta.get(threshold, 0.4): return None # 低于阈值进入fallback return meta[name]当你想用LLM路由时代码长这样。本质上是把每个子索引抽象成一个带说明的工具让模型自己选from langchain_core.tools import tool tool def query_finance_index(question: str) - str: 当问题涉及财务制度、费用报销、发票开票规则、差旅标准时 用这个索引检索。 return finance_retriever.invoke(question) tool def query_product_index(question: str) - str: 当问题涉及产品功能、使用手册、退款流程、版本特性时 用这个索引检索。 return product_retriever.invoke(question) tools [query_finance_index, query_product_index]注意LLM路由里工具函数的docstring就是“索引描述”模型判断时要读的就是这段文字。很多人写“finance相关文档”这种一句话模型面对“报销流程”这种具体词时很容易猜错。把你能想到的所有同义词和子主题都塞进去路由准确率会明显上升。2.4 路由兜底宁愿返回浅答案也不要硬进门路由必须设计fallback路径这是我最想强调的一点。实测里无论哪种路由都可能出现置信度不足的情况Embedding路由的相似度低于阈值或者LLM路由输出一个不存在的工具名。这个时候如果你硬把query塞进一个错误的子索引检索出来的内容大概率完全不相关且用户很难察觉系统已经走偏了。我的做法是Embedding路由低于阈值时返回一个“不确定属于哪个域”的信号让上层Agent决定是让用户换种问法还是显式提示“该问题可能涉及多个主题已使用通用索引结果可能不完整”。LLM路由如果模型没有选中任何工具就走默认索引同时把“当前结果可能较浅”作为提示同级返回。另外还有一个好用的技巧把对话上下文里上一轮的用户意图也拼进路由输入。比如用户先问“报销流程”下一句问“那发票呢”如果只看当前这句“那发票呢”路由八成会跑偏拼上上文之后才能判断这还是在问财务域。3. Sub-index把一张大水表拆成多个独立小库3.1 按什么维度拆拆到多细才算合理Sub-index的核心思路是让每个小库内部的语义足够内聚。拆分的维度我见过很多实际用得上的大概四类按业务域拆分财务、产品、运维、客服这是最常见的做法适合部门边界清晰的企业知识库。按文档类型拆分手册、制度、工单、日志。适合文档来源杂乱、格式差异大的场景。按权限级别拆分公开、内部、机密。方便后续做访问控制密级文档根本不会出现在公开索引里。按时间粒度拆分近30天记录和历史归档。特别适合日志类、公告类内容热数据和冷数据天然分离。拆分的核心原则是“同库内embedding空间尽量内聚跨库之间语义距离尽量拉开”。但别拆到“每篇文档一个索引”那么细那样Router要维护的索引描述会爆炸而且一个需要跨两三个子索引拼答案的问题会被硬拆成只选一个索引的单选题。我实际项目里一般是6到8个业务域子索引。如果团队有建模能力想更严谨地表达“这个文档属于哪个域”也可以引入本体Ontology来显式建模关系但对多数项目来说metadata里的一个归属字段就够用了没必要上纲上线。3.2 子索引的元数据和“索引描述”写法每个子索引不只是“一个向量集合”它应该自带一套元数据资产。我在每个子索引维护这些字段index name索引唯一标识比如finance_docs。description给Router用的索引描述一句话说清楚这个库里装了什么。metadata schema每个chunk带哪些字段比如department、doc_type、updated_at、permission_level。permission tags哪些角色可以访问这个索引。updated_at索引最新重建时间方便Agent判断数据新旧。description的写法有讲究。模板我推荐这样“这部分内容包括A、B、C主题主要回答X、Y、Z相关问题如果用户提到M、N优先选择这里。”比如“财务制度、费用报销流程、发票与开票规则、差旅费用标准中国大陆”就比“财务相关文档”好用得多。原因是Router不管走Embedding还是LLM判断依据都是这段描述与query的语义重合度你把“报销”“开票”这种高频词写进描述命中概率自然就上去了。我们曾经把一个索引描述从“财务相关文档”改成上边那样之后路由误判率几乎降了一半。3.3 构建三个子索引的完整过程实际构建子索引的代码并不复杂关键是每个子索引单独建库、单独持久化并在写入时带上完整的metadata。下面这段是我项目里简化过的结构API名称以你用的框架版本为准from langchain_community.vectorstores import Chroma from langchain_openai import OpenAIEmbeddings from langchain.text_splitter import RecursiveCharacterTextSplitter embeddings OpenAIEmbeddings(modeltext-embedding-3-small) def build_sub_index(docs, index_name, persist_dir, chunk_size600): splitter RecursiveCharacterTextSplitter( chunk_sizechunk_size, chunk_overlap100, separators[\n\n, \n, 。, , ], ) chunks splitter.split_documents(docs) store Chroma.from_documents( documentschunks, embeddingembeddings, collection_nameindex_name, persist_directorypersist_dir, ) return store # 假设 load_docs 是你自建的文档读取函数支持 PDF/Word/Markdown/HTML finance_store build_sub_index(load_docs(./data/finance), finance_docs, ./stores/finance) product_store build_sub_index(load_docs(./data/product), product_docs, ./stores/product) ops_store build_sub_index(load_docs(./data/ops), ops_docs, ./stores/ops)这里必须多说一句子索引质量的隐藏天花板是切分不是向量库。固定字数切分在PDF、HTML、Markdown上会切断标题、表格和代码块导致每个chunk都“缺头少尾”。实操中我优先用语义切分工具按Markdown标题、PDF章节、代码块边界去做结构化切分本地没有合适工具的话至少按段落切分加标题识别比无脑固定300字强很多。设备、型号、表格类文档尤其明显字符切分出来的chunk检索时总是缺上下文而结构化切分基本不会犯这个错。3.4 更新、版本与权限治理Sub-index带来的最大工程红利是更新隔离。之前改一份文档要全库重建现在只需要重建受影响的子索引。每个子索引带updated_at时间戳Agent检索时看到finance_docs的版本是今天ops_docs的版本是三个月前就能在回答里合理标注“运维手册可能已过时建议人工复核”。权限控制也顺带变简单了。按子索引的permission tags做白名单财务角色只能查finance_docs客服角色只能查product和service相关索引。这比在单一大索引里做metadata过滤可靠得多因为库级隔离是物理边界不会出现filter被向量检索噪声绕过的隐患。同一个企业知识库不同角色查不同子索引基本就是这么落地的。4. 多文档 Agent从被动检索到主动调度4.1 Agent在RAG里的职责边界普通RAG是“一次检索、拼Prompt、一次生成”Agent化之后检索变成循环动作。核心区别用一句话概括普通RAG是接单员Agent是项目经理。Agent看到query之后先规划要取哪些证据查完第一个子索引发现证据不足再决定是否补查第二个最后把多个来源的结果汇总成答案。为什么需要这一步因为真实业务问题经常是“跨域”的。用户问“财务报销流程是什么顺便看看有没有相关的客诉案例”这明显需要同时问finance和service两个子索引。没有Agent你只能做一个多索引检索然后硬拼结果。有Agent系统会自己判断“我需要先查财务制度拿到报销流程再查工单看客诉案例最后综合起来回答”。这就是从被动检索到主动调度的变化。4.2 一个可运行的多文档Agent骨架下面这段是LangChain生态的写法核心不在框架而在你把每个子索引封装成一个tool之后Agent就有了“选择、调用、观察、再选择”的能力from langchain.agents import create_openai_tools_agent, AgentExecutor from langchain.tools import Tool from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate tools [ Tool(namequery_finance, funcfinance_store.as_retriever().invoke, description财务制度、费用报销流程、发票开票规则、差旅费用标准。问题涉及账务和费用时使用。), Tool(namequery_product, funcproduct_store.as_retriever().invoke, description产品功能、使用手册、退款流程、版本功能说明。问题涉及产品操作和功能时使用。), Tool(namequery_ops, funcops_store.as_retriever().invoke, description故障工单、生产事故记录、运维操作手册。问题涉及线上故障和运维操作时使用。), ] prompt ChatPromptTemplate.from_messages([ (system, 你是企业知识库助手。根据用户问题选择合适的工具检索并回答。), (human, {input}), (placeholder, {agent_scratchpad}), ]) llm ChatOpenAI(modelgpt-4o-mini, temperature0) agent create_openai_tools_agent(llm, tools, prompt) executor AgentExecutor(agentagent, toolstools, max_iterations5, verboseTrue) result executor.invoke({input: 财务报销流程是什么顺便看看有没有相关的客诉案例})两个参数必须提。max_iterations5是防死循环的保险丝不加它会看到某些query让Agent反复查同一个索引七八次token烧了一半结果都一样纯属浪费。temperature0让Agent的动作选择稳定这类调度任务不需要创造性一定要确定性优先。4.3 顺序补充式 vs 并行聚合式多文档Agent实际跑起来有两种编排模式我建议按查询类型分别用。顺序补充式是先查最相关的子索引Agent看结果不够再决定补查哪一个。这种模式适合“追问式”“诊断式”场景比如“系统最近为什么老报警”Agent先查ops_docs发现缺少配置信息再补查product_docs。这种模式省token但延迟和问题复杂度成正比链条长了会慢。并行聚合式是把一个query拆成多个子问题同时发给多个子索引最后汇总。适合有明显并列结构的查询比如“对比A方案和B方案的优缺点”“汇总上季度所有系统问题”。实现的时候需要额外加一步“子query生成”这个分解质量很关键我一般会放几个few-shot示例让模型学会按业务域拆解问题。成本上并行聚合式单次生成时间更短但总token消耗更高需要按场景取舍。4.4 多文档结果合并与冲突处理多个子索引同时返回结果后问题就来了证据之间可能互相矛盾。比如产品手册写“退款1到3个工作日到账”客诉工单里却有大量“退款超过5天未到账”的案例。如果Agent把两边的信息硬合成一个答案要么掩盖了真实问题要么凭空造出一个不存在的“标准事实”。我的处理方式很直白把两个来源分别作为引用块放回Prompt显式指示模型“如果来源之间有冲突请列出所有版本并在回答中标注来源”。最终答案里允许出现“根据产品手册退款流程是……但根据客诉工单部分案例显示……”这种带分歧的表述。一套简单的冲突检测逻辑可以这样做对比两段检索摘要的实体重叠度重叠度高但结论词不匹配就标记为可疑冲突。宁可让答案看起来“啰嗦”也不能让它用一条不存在的规则误导用户。5. 实测后的调参与避坑心得5.1 同一批数据下的前后对比下面是我们内部一组模拟测试的结果数据是5000份混合文档产品手册、财务制度、运维工单、客服案例重点看趋势不同数据集的具体数值会差不少。指标单一大索引Router Sub-indextop-5相关召回率61%82%冷门主题召回率top-20内18%67%更新一个子集所需耗时全库重建约40分钟单子索引重建约5分钟平均检索延迟不含生成210ms260ms含路由最亮眼的是冷门主题召回率。原因不复杂Router把长尾问题导入了正确的窄空间而不是在全库碰运气。窄空间里的候选集本来就少噪声少命中率自然高。延迟增加50毫秒左右则来自路由的embedding计算和额外向量查询对最终体验影响很小换来的是精度的大幅提升。5.2 我踩过的五个实打实的坑第一索引描述写得太虚。最典型的就是写“财务相关文档”这种词。实际改成“财务制度、报销流程、发票与开票规则、差旅费用标准”之后路由误判率明显下降。描述里多写同义词和子主题词成本为零收益立竿见影。第二子索引拆过头。我们一开始图权限隔离方便拆了三十多个索引结果Router这边先撑不住了LLM路由面对三十多个工具会犹豫Embedding路由的向量之间互相干扰。后来合并到8个核心业务域索引准确性反而上去了。记住拆分是为了检索和内聚不是为了权限的极端细化。第三Agent循环打转。有一个query让Agent连续查了六次同一个子索引每次结果差不多回答还越扯越远。加上max_iterations限制和“重复检索结果直接终止”的判断之后这类问题基本消失。第四切分器一刀切。财务表格、运维代码块、产品手册的Markdown结构用同一个字符切分器就是灾难。我现在给每个子索引配不同的切分策略表格类文档按行和表头结构切代码类文档按代码块边界切效果是质的提升。第五路由只看当前query。用户说“那退款呢”单独看这句根本不知道该去哪。把上一轮的意图拼进路由输入之后准确率立马上一个台阶。这也印证了开头那句Router的判断依据决定了RAG系统的分流边界。5.3 成本与延迟的现实账LLM路由一次额外调用可能带来几百毫秒到两秒的延迟几百token的成本。如果用户量小、日活几十完全无所谓但如果你在做对话密集型的面向客户的系统每秒几十次并发纯LLM路由就是烧钱。我实测下来性价比最高的方案是两级路由先用Embedding语义路由粗筛出top2候选索引再用LLM对这top2做精排选一个。粗筛开销极小精排只处理两个候选既控制了延迟和成本又能借大模型的理解能力处理复杂query。这套组合我们跑稳定之后路由准确率稳定维持在90%以上。6. 这套进阶方案什么时候不该用边界6.1 先泼三盆冷水不该用的场景虽然这套组合很香但必须承认它有适用范围。知识库小于300个chunk或者根本就是单一主题这时候Sub-index和Router都是纯负担单索引加一个reranker完全够用还省掉维护索引描述和更新策略的功夫。查询模式极其固定如果整个系统就是“查FAQ、查制度条款”这种封闭场景规则路由加单一索引就够了上Agent纯属浪费token。没有权限隔离和数据来源隔离的硬需求拆分的主要收益之一就是隔离你不需要隔离拆分意义就少了一大半。团队没有运维精力三十个子索引要维护描述、更新策略、监控报警比单索引累得多。宁可3个索引配一把描述也不要去建30个没人管的僵尸索引。6.2 我给自己画的分界线到底该不该上这套架构我给自己定了一个打分表四个问题回答“是”得一分文档种类是否不少于三类每类文档是否独立更新是否需要按角色控制访问范围是否常有跨域组合查询两分及以上我才会考虑Router加Sub-index如果需要跨域组合查询且更新隔离有硬要求再多叠加Agent调度。四项全否的老老实实单索引加重排那才是那个场景下最稳最快的方案。我把这四个问题写在了项目wiki的第一页每次新项目启动先打分再动手。RAG架构不是越复杂越好而是先想清楚你的知识库是不是真的乱到需要分流。如果你的答案是“还没到那份上”那就别拆如果答案是“已经到了”Router、Sub-index和多文档Agent这套组合大概率能帮你把检索精度和工程可维护性同时提上一个台阶。
返回列表