从RAG到LoopAgent:用循环检索解决复杂文档问答难题
最近在维护某跨平台系统的文档检索模块时我临时接手了一个问题频出的老功能知识库里的资料不少但问答准确率就是上不去。一开始我按照惯性想继续优化RAG管线换更好的embedding模型、扩召回数量、调prompt结果折腾两天收益很有限。后来我把整个思路倒过来用一套轻量的LoopAgent替代原来的RAG检索链路反而在长尾问法、跨文档比对和缺证据识别这几个老大难问题上有明显好转。这里把这个过程整理出来聊聊我为什么弃用固定检索管线、LoopAgent循环里到底发生了什么、实现上踩过哪些坑以及什么场景下它依然比不上RAG。这套方案的核心动作很简单把“召回一次、拼接、生成答案”改成“让模型反复使用检索工具直到证据够用再回答”。如果你的知识库场景里存在信息分散、说法不一致、问法刁钻的情况这文章对你应该有参考价值。1. 项目再出发从“查一次”到“查明白”1.1 传统RAG文档检索的卡点在哪里我之前做的RAG链路非常标准文档切块向量化把用户问题embedding后做TopK召回最后拼接上下文交给大模型生成。这套模式在单点知识问答、操作手册查询这类场景里表现不错因为答案通常集中在某一小段文本里召回对了就基本能答对。但放到真实的跨平台系统资料库中问题就复杂了。用户的问题经常不是“某个参数是多少”而是“A模块和B模块对同一个字段的处理规则有什么差异”或者“请假审批到底需要哪几层确认”。这种问题要命的地方在于答案分散在多个文档、多个章节里甚至不同文档对同一件事的用词都不一样。RAG的固定流程在这里暴露了三个硬伤。第一它只做一轮检索召回结果的好坏直接决定最终答案的上限一旦向量检索没把关键文档捞进来后面再怎么生成都是错的。第二embedding模型对“同义改写”的容忍度有限文档里通篇写的是“缺勤申报”用户问的是“请假流程”向量相似度往往不够把相关片段顶到前排。第三文档切块会把跨章节的上下文关系切断即使多个块都召回了拼接顺序也是固定的模型很难主动发现“这里还缺一份资料”并回头去补。这些都是RAG管线的结构性限制不是靠堆参数就能解决的。也就是说在进LoopAgent之前我得承认一个事实固定管线只适合“答案正好落在某个块里”的理想情况。1.2 LoopAgent的解题思路把检索变成有反馈的循环LoopAgent的思路和RAG有本质区别。RAG把检索看成一个一次性的取数动作LoopAgent则把检索看成一个连续决策过程。系统不再是在用户提问后立刻找几个块然后生成答案而是先看有哪些文档、再搜索关键词、读取片段、判断信息是否足够不够就换关键词再搜直到能凑齐回答所需的证据。这个改变带来的最直接好处是模型拥有了“纠错”的机会。第一次搜索用的是用户原话里的词可能没命中但模型读到部分结果后会意识到“这些片段在讲别的事”于是重新组织关键词再搜一次。这种反馈循环非常接近人工查资料的路径先翻目录再到疑似章节里读几页发现不对就换章节而不是打开书就断定答案在某一页。用生活里的例子类比RAG就像自助图书馆里给你一张索书号你按号去架子上拿书拿到哪本读哪本LoopAgent则像个有经验的图书管理员他先扫一眼书架陈列抽出一本该目录翻两页觉得不对换一个关键词查索引最后锁定了正确的位置再告诉你答案。显然面对复杂问题时后者的成功率更高。不过这里要注意LoopAgent不是完全替代RAG里的向量能力而是把“检索流程”的控制权从管道设计者手里移交给了模型循环。系统本身仍然需要文本检索工具只是不再依赖一次性的TopK召回决定生死。1.3 项目定位与适用范围我在设计这套“轻量LoopAgent”的时候给自己定了三条约束。第一不引入重型框架核心逻辑能在一两百行代码内表达清楚第二不依赖向量数据库因为当时的部署环境里没有现成向量库我也不想为这个模块单独搭一套计算资源第三保留传统关键字检索作为工具代理循环可以主动选择用哪些工具、怎么组合。边界也明确LoopAgent的目标不是优化高频低延迟问答而是做深度、多跳、复杂文档检索。如果用户问的是“XX服务的默认端口是多少”RAG一轮召回就够LoopAgent反而浪费时间和计算。这个判断很重要它决定了我后续的方案选型和架构取舍避免陷入“凡是检索都上Agent”的误区。2. 核心机制拆解循环里每一轮到底在做什么2.1 三个核心组件会话状态、临时记忆、动作工具LoopAgent能稳定工作的前提是三个组件配合到位会话状态、临时记忆、动作工具。会话状态是整个循环的“档案袋”里面记录着当前用户问题、已经执行了多少轮、已经找到了哪些证据片段、搜索过哪些关键词。这个状态每轮都在更新模型每次做决策前都要读取它而不是只凭最新一次结果就回答。我初始定义的状态结构大致包含以下几个字段原问题、候选关键词集合、已检索文档ID列表、已读片段列表、本轮生命周期内的进度摘要。这些字段的存在让代理不会在一开始因为找到一段相关信息就直接作答而是检查“是否已经覆盖了问题的全部侧面”。临时记忆其实是会话状态中专门存放“上一轮动作结果”的部分。它把最近一次搜索返回的标题、摘要、命中片段压缩成一个简短的观察记录供模型在下一步决策时参考。它的主要作用是避免模型“失忆”因为对话模型本身没有跨请求的持久记忆如果不把前一轮结果塞回提示词模型每轮都像是从零开始。动作工具集是我给代理预置的几个可调用能力包括列出全部文档、在指定范围内搜索关键词、按文档号读取指定片段、宣告结束并给出答案。每个工具的描述里明确写了输入输出格式模型只能在这个集合里选动作。整个循环就是“读状态—选动作—执行工具—更新状态”的不断重复直到动作变成“宣告结束”。2.2 停止条件和确认机制如何防止“跑飞”循环代理最让人担心的问题就是死循环或者跑飞。我见过不少同行调试类似系统时代理在一两个相关片段之间反复横跳每轮都做相同的搜索既不回答问题也不停止。所以停止条件必须从一开始就设计好不能指望模型自觉。我在实现里用了三重停止防线。第一是硬性最大轮数这个值我在实测里常用5到8轮再高基本就是无效空转第二是内容重复检测如果模型本轮选择的搜索词与上一轮完全相同且返回的结果没有任何新内容系统会标记为“已收敛”强制让代理跳到作答或者坦白说查不到第三是证据充足性判断代理在决策环节里需要评估“当前证据是否足以回答用户问题的所有子要点”如果它对所有子要点都能给出支撑就停止检索进入回答阶段。这三重防线本质上是把“人工查阅资料的停止直觉”编码成规则。人读书查到几条相互印证的出处后就不会继续翻了代理也需要同样的判断。我特别建议不要把“证据充足”完全交给模型凭感觉判断至少要用程序校验一下它即将引用的事实是否真的存在于已读片段列表中否则模型很容易面不改色地编造出处。2.3 输出结果的结构设计与可信度表达LoopAgent的输出也不该只是一段文本。我最终做成结构化返回体包含答案正文、证据列表、缺失信息列表、执行步数、以及一个模型自我评估的可信度分数。证据列表里每项都标注了文档ID、所在章节和原始片段摘要这样使用方可以追溯到源头排查真正的问题。可信度分数我没有做得很玄学就是让模型在最后一步综合判断“已读证据是否直接支撑答案、有没有互相矛盾的信息、有没有关键子问题未覆盖”给出一个区间0.0到1.0的数值。这个分数虽然主观但在实际项目里很有用下游系统遇到低分结果可以自动转入人工复核流程而不是直接把可能错误的答案返回给用户。设计这个结构化返回的初衷很简单把代理中间过程的可见性带回来。传统RAG是一个黑盒用户看到答案但不知道证据是什么LoopAgent把每一轮检索、每一次命中都记录下来。这既方便我做调试也方便用户判断答案是否可信等于给系统加了一层透明护栏。3. 轻量实现过程我如何从零搭出这套检索循环3.1 文档接入层先做好检索工具集开始写代理逻辑之前我先实现了文档接入层也就是代理要调用的那套工具集。为了保持轻量我没有用向量数据库而是直接用文本文件加一个简单的倒排索引。文档统一切成段落级分块每段保留标题信息然后把分词后的词语映射到段落ID列表。文档接入层对外暴露三个方法列出所有文档的标题与ID、给定关键词返回最相关的若干段落、给定文档ID和段落号返回该段完整文本。这三个方法对应之前说的工具集实现起来非常简单。比如关键词检索我直接用词频加权和命中密度来计算相关度不追求复杂算法够用就好。切块参数我控制在每段300到800字太短会丢失上下文太长则不利于精准定位。这一步有个很重要的设计细节搜索返回的结果不是直接给模型看的全文而是经过裁剪的摘要包括文档标题、段落序号和前两句话。这样每轮提示词不会太长模型也能快速判断这段文本值不值得深入阅读。只有当模型决定读取某个文档某个段落时系统才返回完整内容。这种两级读取模式很关键它既控制了token消耗又给了代理“先粗看再细看”的能力。3.2 循环代理主体感知、行动、复盘三步法循环代理的主体逻辑我是按“感知、行动、复盘”三步来组织的。感知阶段把当前session状态里的问题、上轮观察、已收集证据摘要、可调用工具描述以及历史搜索关键词全部拼成提示词发给对话模型要求模型返回一个结构化的动作指令。行动阶段根据返回的动作指令调用文档接入层的方法把执行结果做清洗后追加到会话状态里。复盘阶段则是对新增证据做摘要压缩同时更新“已试过且无效的关键词”列表避免模型下轮继续使用。核心代码骨架大致长这样我把关键部分简化写出示意。import json class LoopAgent: def __init__(self, doc_pool, llm_ask, max_rounds6): self.doc_pool doc_pool self.llm_ask llm_ask self.max_rounds max_rounds def run(self, question): state { question: question, round: 0, evidence: [], searched_words: [], read_docs: [], done: False } while state[round] self.max_rounds and not state[done]: state[round] 1 action self._decide_next_action(state) self._execute_action(action, state) return self._build_result(state) def _decide_next_action(self, state): prompt self._build_prompt(state) raw self.llm_ask(prompt, temperature0.2) try: return json.loads(raw) except json.JSONDecodeError: # 解析失败默认做一次宽松搜索避免直接报错 return {action: search, query: state[question]} def _execute_action(self, action, state): act action[action] if act search: query action.get(query, state[question]) state[searched_words].append(query) hits self.doc_pool.search(query, k3) state[evidence].extend(hits) state[last_observation] hits elif act read: doc_id action[doc_id] seg action.get(segment) content self.doc_pool.read(doc_id, seg) state[evidence].append(content) state[read_docs].append(doc_id) elif act answer: state[final_answer] action.get(answer, ) state[done] True这个骨架并不长但已经能满足一个最小可用的LoopAgent。实际部署时我还会在_execute_action里加上“本轮搜索结果与上轮完全相同时强制结束检索”的检查逻辑防止模型兜圈子。3.3 接入外部对话模型请求与结构化返回处理代理的大脑是一个外部对话模型我通过通用HTTP接口调用没有绑定特定厂商SDK。请求体里把提示词传过去温度设低一些返回解析成JSON作为动作指令。为了让模型稳定输出结构化动作我在提示词里用了非常明确的格式约束并提供两个成功示例。提示词里我最看重的部分是“动作约束清单”可选动作只有search、read、answer三种search必须提供query字段且query不能和历史searched_words里完全重复answer必须附带evidence_id列表且这些ID必须在当前已读证据里存在如果证据不足即使已经达到第6轮answer里也要显式声明missing字段。这些约束写起来不复杂但对防止幻觉引用起了决定性作用。解析返回结果时我用了一层宽容策略如果模型返回的不是合法JSON系统不会直接爆错而是退回成一次基于原问题关键词的搜索并把异常记录到日志里。这个兜底动作保证了即使模型偶尔抽风代理也只是多跑一轮不会中断。我在实测中发现只要提示词约束写得够死模型返回不合法的概率通常不到2%但兜底逻辑仍然不可省。4. 真实踩坑记录与调优速查4.1 五个高频问题症状、原因、对策LoopAgent看起来简单实际调起来问题不少。我整理了一份高频问题排查表全是自己踩过的真实场景。症状可能原因对策代理反复搜索相同关键词不推进提示词里没有禁止重复搜索词模型认为搜索结果可直接复用在提示词里写死“禁止与历史searched_words完全重复”并加程序侧重复检测文档里有明确答案却查不到初始搜索词照抄用户原话未做同义扩展第一轮后强制要求模型根据已读片段提取2到3个新关键词再搜一轮回答引用了不存在的内容模型生成答案时没有严格受证据约束answer动作里强制带上evidence_id字段程序校验ID是否真实存在于证据列表响应速度太慢每轮都要好几秒每轮上下文太长、每次搜索返回太多结果检索返回结果裁剪到两条以内read时只读前两段减少提示词膨胀模型输出格式频繁错误提示词示范样本太少动作schema表述不清在提示词中补充两个完整示例把可选动作和字段用列表逐项列出其中第一个问题是最容易被忽视的。很多初版实现只管往提示词里堆证据结果模型每一轮都认为自己还可以再搜一次一样的关键词因为上一次的搜索“可能有漏网之鱼”。但实际结果是无限空转。后来我在提示词里加入了一句“如果本轮的新增证据没有带来任何未出现过的关键词请直接选择answer”空转率立刻大降。第二个问题则比较隐蔽。文档里的用词和用户问题经常对不上号代理第一轮搜索可能找到了一堆无关内容但模型在复盘时可以从无关内容里学到新词。比如用户问“审批多久能通过”文档里写的是“办结时限”模型读到片段后意识到“时限”可能和“多久”相关于是第二轮搜“办结时限”就能命中。要实现这个效果模型必须被明确要求“从当前片段中提取与问题相关的同义表述”而不是简单总结片段意思。4.2 提示词与控制参数的调优心得提示词的写法对LoopAgent的影响远超预期。我调试中总结出一个相对稳定的模板开头说明代理身份与任务接下来给当前会话状态、上一轮搜索结果摘要、可调用工具及参数格式然后是动作约束清单最后是要求模型仅输出JSON的指令与两个示例。这里有几个参数细节值得分享。模型温度设置在0.1到0.2之间过低会反复给出相同动作过高会引入随机搜索词0.15附近实测最稳。搜索返回条数k控制在3以内因为返回太多会让提示词很快变长而且模型容易被无关信息带偏。最大轮数建议从4起步如果发现某个问题经常在5轮之后才能找到答案再把max_rounds调到6但不要无脑拉高轮数越多延迟和token成本涨得越厉害。另一个让我印象深刻的细节是在提示词中给每个动作字段加“类型说明”非常重要。比如query字段要写“请给出2到4个关键词不要写完整句子”read动作的doc_id必须来自已有的文档列表结果。模型在字段类型不清晰时很容易把query写成一整句话反而降低检索命中率。4.3 性能和使用体验的几个优化动作轻量方案的优势之一是便于性能调优我对症做了几个优化动作。第一是给文档目录和段落摘要做常驻内存缓存避免每轮都重复扫描文件列表第二是read动作支持并发读取多个段落在文档轮次较多的场景下能省掉不少IO时间第三是把每次检索的命中结果做缓存如果代理再次搜到相同关键词直接用上一轮的缓存结果不再重新查索引。此外我做了一个很接地气的优化把上一轮搜索结果里高频出现的新词追加到候选关键词集里。这样做的好处是当模型下一轮决策拿不准时系统可以从候选集里自动挑词补一次搜索不需要模型额外生成。本来这一步也可靠模型完成但实测下来自动追加的命中率更稳定因为候选词是从文本里真实提取的没有模型“自造词”的风险。经过这些优化单次检索的完成时间从我最初版本的近十秒降到了五秒左右在内部演示场景里已经可以接受。当然它依然比RAG的一轮查询要慢这是循环机制本身的代价使用场景需要心里有数。5. 实测验证什么场景下它比RAG更好用5.1 对比实验的设计与结果为了确认LoopAgent的价值我用某跨平台系统的内部资料库做了一组对比实验选了大约一百个真实历史问答记录同时跑旧版RAG管线和新版LoopAgent。这批问题的特点是大约四成是单点事实查询比如“某服务的默认超时时间是多少”三成是跨文档对比剩下三成是描述性问法提问用词和文档原文差别较大还有一部分本来就缺乏足够资料。结果很符合预期。单点事实查询上RAG准确率略高LoopAgent偶尔在简单问题上绕远了表现略有波动。跨文档对比和异词问法这两类问题上LoopAgent胜出明显RAG经常只找到一个角度的资料就给出片面答案而LoopAgent会主动搜集第二个文档的相关章节再交叉印证。整体算下来LoopAgent的综合正确率大约高出十几个百分点但平均响应时间长了三倍。这组结果坚定了我一个判断LoopAgent和RAG不是继承关系而是不同场景各有所长。高频简单问答继续用RAG复杂长尾问题才启用LoopAgent。板块负责人看到数据后也接受了这个方案把两套逻辑做了路由切分而不是一刀切替换。5.2 不适合的场景与混合方案建议LoopAgent不适合的场景同样清晰。在每天几千次访问的轻量检索接口里如果每次请求都跑五六轮模型调用成本和延迟都压不住。这类入口应该保留RAG用一轮召回拿个八九不离十的答案。另外如果用户提问非常标准化、文档内容高度结构化LoopAgent的优势根本体现不出来反而增加无谓开销。我最后采用的是混合方案前置一个快速路由先用传统检索判断问题是否有高置信度的直接命中命中就直接走RAG生成答案只有低置信度或用户明确追问“对比”“差异”时才转发给LoopAgent做深度循环检索。这样既保住了高频路径的体验又让复杂问题有了兜底手段。混合方案的链路我在实现里保持得非常简单就是一个if-else路由加一个共享的文档池。LoopAgent里用的检索工具和RAG管线的切块逻辑复用同一份代码避免了维护两套文档处理流程的麻烦。这个小架构在稳定性上表现也不错至少我跑了半个月没出过状态性问题。6. 一点个人经验总结如果让我重新做一次这个模块我不会再去死磕RAG的召回率。文档检索的核心矛盾不是“召回不够”而是“系统不会自我纠偏”。LoopAgent的价值恰恰体现在这里它把检索重试、同义词扩展、证据交叉验证这些原本要写在管道里的逻辑转化为模型的逐轮决策降低了规则代码的复杂度却提升了复杂问题的回答质量。我个人在实际使用中的一点体会是不要让代理承担超出它能力的判断比如“你现在掌握的已经足够回答用户了”这种抽象判断最好用程序规则来把关比如检查已读文档数、检查新增关键词数、检查重复轮次。模型可以决定搜什么、读什么但“是否该停止”必须由规则托底。另外调这种循环代理一定要留痕每轮动作、搜索词、命中结果全部记下来没有trace就没有排查依据。这套方案后期扩展余地也很大我正准备给它加上对图片型PDF的解析工具和多轮追问能力让它在文档问答这条路上走得更实一些。