ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从RAG到LoopAgent:用循环检索解决复杂文档问答难题

从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_rounds=6): 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, temperature=0.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, k=3) 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的解析工具和多轮追问能力,让它在文档问答这条路上走得更实一些。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进