☰
用轻量LoopAgent替代传统RAG:内部文档问答准确率提升实践
2026/10/17 11:56:43 网站建设 项目流程

前段时间接了个内部文档问答的活,资料库不大,大概三百多份设备手册和修订记录,可检索需求却很实际:运维人员要能直接问“E031报警怎么解除”“某型号模块支持的最大电流是多少”。我一开始也是老老实实按教科书方案搭RAG:文档解析、按章节切块、向量化、存向量库、检索、重排、交给大模型生成答案。结果一上线测试就不太对味,精确型号和代码术语经常召回不到,好不容易召回的片段又因为切块太碎,没法拼出完整答案。后来我把思路整个翻了过来,用一套极轻量的LoopAgent替代传统RAG链路,反而把准确率提了上去。这篇文章就完整记录一下这个过程,包括设计取舍、核心实现、参数调整和踩过的坑,适合做内部知识库、设备手册、制度文档这类中小规模检索问答场景的朋友参考。

1. 为什么我会想起用 LoopAgent 替代 RAG

1.1 传统RAG的三座大山

先说说RAG这个链路本身的问题。标准做法是:把文档切成小块,用embedding模型把每一块变成向量存进向量库,用户提问时同样做向量化,然后在库里做相似度检索,把最相关的top-k块拼给大模型生成答案。这个流程乍一看没什么毛病,但实际跑起来会遇到三道坎。

第一道坎是切块。文档切分永远没有一个完美的答案。按固定字符切,经常把一句话、一个步骤、一张表格切成两半;按语义切,需要额外维护一个模型,而且切出来的块大小极不稳定。为了让召回不漏,常规做法是切得碎一点,再用重叠窗口去补。可切碎之后,答案经常被拆到好几个块里,重排器只能挑一两个块送进上下文,模型看到的就是残缺信息。

第二道坎是向量召回对精确Token不敏感。运维人员问“E031报警怎么解除”,这句话里有明显题眼:E031、报警、解除。但embedding模型的工作方式是语义相似,它会把“E031”当作普通token泛化到向量空间里,最后检索回来的可能是“设备故障概述”“错误代码管理”这类宽泛内容。你要的是具体故障码对应的处理步骤,向量检索给的却是一堆“相关但不命中”的东西。

第三道坎是基础设施维护成本。embedding模型要加载、向量库要部署、分块策略要反复调,还有增量更新时的重嵌入。对一个几百份文档的内部知识库来说,这套东西的重心明显放错了地方。

1.2 文档检索的本质是“定位”,不是“联想”

后来我反复想一个问题:这类文档问答场景里,用户的真实需求到底是什么?把问题抽象一下,很多都是“待填空”类型:E031的处理步骤是____,某型号模块的最大电流是____,某流程的审批时限是____。你要做的事情,本质上是准确无误地从几百份文档里找到填那个空格的那几行内容,而不是让系统发挥联想,给用户一篇“意思相近”的资料。

向量检索擅长的是模糊联想:用户说“我想找关于散热设计的资料”,系统可以凭语义召回一堆相关文档。可企业知识库里大量问题都有强关键词,比如型号、编号、参数名、日期、操作步骤里的动词短语。这些信息的检索,靠精确Token命中远比靠向量余弦相似度可靠。

想明白这一点之后,我的思路就变了:定位的事情,应该交给一个能反复“查目录—翻页—看上下文”的机制,由大模型自己判断找到的信息是否足够。这就是LoopAgent的雏形。

1.3 从一次失败实验到LoopAgent

我先在测试集上做了基线,结果很不理想:50道题只有30道答对。翻看错误案例,发现一个共性——向量检索召回的片段跟答案经常是“邻近但不对”,差一个图、差一个前置条件、差表格里的某一列说明。模型拿到一块残缺文本,为了生成答案只能去“编”,于是出现看似合理但实际错误的结果。

就在那个节点,我决定做一次试验:去掉向量召回,改用“关键词全文检索 + 多轮循环阅读”。具体思路是:让模型第一轮用关键词迅速锁定候选块;第二轮把候选块上下文展开通读;如果信息不够,第三轮根据当前判断发起新的搜索。整个过程就是“搜索—阅读—再搜索—再阅读”,直到模型有把握回答为止。这就是我后面要展开的轻量LoopAgent方案。

2. 轻量 LoopAgent 的整体设计与链路拆解

2.1 架构拆解:工具层、决策循环层、输出层

所谓轻量,指的是不引入LangGraph、AutoGen这类重型Agent框架,纯粹手写一个循环体加几个工具函数。整个架构只有三层。

工具层跑在检索端,维护一个文档块存储,提供三个能力:关键词搜索、按块ID读取正文、按块ID展开相邻上下文。决策循环层是核心,一个带系统提示词的大模型,每轮根据当前状态决定下一步动作。输出层在循环收敛后把答案和证据出处一起封装。

可能有人要问:市面上Agent框架那么多,为什么自己写?我的判断是,就两个工具函数加一个搜索接口,引入一个分布式Agent框架完全是杀鸡用牛刀。循环体代码本身二十几行就能写清楚,真正要花心思的是提示词和状态管理。自己写还能少一层依赖,后续出现诡异问题排查起来也容易。

2.2 工具选型:关键词全文检索为什么够用

我这里没有用向量库,文档块索引直接建在SQLite FTS5上,用BM25排序。有些做RAG的朋友一听“关键词检索”就觉得原始,但在这个场景里,关键词命中有两个向量检索很难替代的好处。

第一个好处是可解释性强。系统说“搜索E031,命中了三个块”,这个结果是确定的,不是“意思相近”凑出来的。用户和调试者都能理解,也能验证。第二个好处是细节保真。向量模型为了泛化,通常会损失精确,比如把“E031”泛化成“错误代码”,把“支持最大电流”泛化成“额定电流”。垂直领域的型号和参数恰恰是这种泛化的牺牲品。

我用一个生活类比来理解这件事:向量检索像“凭印象找一个人”,你说那人脸熟,它带你到一片区域自己认;关键词检索像“照着门牌号找”,你说要找某号楼某单元,它直接带你到门口。文档库里的参数和型号,就是那个门牌号。

2.3 决策循环协议:让模型自己决定下一步

设计这套方案最关键的一点,是定义一个足够小但足够完整的“行动空间”,让大模型在每一步都只做四选一:

  • search(query):根据当前线索或问题,提出检索词,从索引中获取候选块列表。
  • read(block_id):读取某个候选块的完整正文。
  • expand(block_id):读取某块相邻上下文,用于补全语境。
  • answer(answer, evidence_ids):输出最终答案,并附上支撑证据的块ID列表。

这个协议看起来简单,实际效果却出人意料地好。举一个实际流程:模型看到问题里有“E031”,第一轮行动是search E031,工具返回5个候选块;模型先read了故障码列表那个块,发现只有代码说明没有操作步骤,于是调expand读取相邻章节,找到“E031报警解除步骤”这一节;然后判断足够,输出answer并引用两个块ID。整个交互三轮,比RAG多了一两轮延迟,但每轮都是在“逼近真实答案”,准确率自然不一样。

3. 核心实现与实操要点

3.1 BlockStore:三个关键函数怎么实现

我存文档块用的是SQLite FTS5,结构很简单,blocks表里每行是一个块,字段包括block_id、doc_title、section_path、snippet和content。建索引时把标题、正文、章节路径都拼进FTS索引里,这样搜索“E031”或“最大电流”时都能命中。

核心类大概是这样一个骨架:

import sqlite3 class BlockStore: def __init__(self, db_path: str): self.conn = sqlite3.connect(db_path) self.conn.row_factory = sqlite3.Row def search(self, query: str, top_k: int = 5) -> list[dict]: rows = self.conn.execute( "SELECT block_id, doc_title, section_path, snippet " "FROM blocks WHERE blocks MATCH ? " "ORDER BY rank LIMIT ?", (query, top_k), ).fetchall() return [dict(r) for r in rows] def read_block(self, block_id: str) -> str: row = self.conn.execute( "SELECT content FROM blocks WHERE block_id = ?", (block_id,) ).fetchone() return row["content"] if row else "" def expand_context(self, block_id: str, before: int = 0, after: int = 1) -> str: # 实际实现里,按同一文档的顺序字段找相邻块 ...

search返回的每个候选块都会带文档标题和章节路径。这个细节非常重要,模型读到块时可以感知“这个块来自哪个文档、属于哪个章节”,判断时更有据可依。read_block返回全文,expand_context则用于解决跨块上下文丢失。

我在实现时没有做向量化,所以索引更新也很轻松。新文档进来,解析成块后直接insert进SQLite,不需要重算任何向量。

3.2 循环体:状态、收敛条件、防呆机制

循环体是整个方案的灵魂。每轮调用大模型,传入用户问题、系统提示词、已经执行过的行动和观察结果,模型输出一条JSON格式指令,解析后执行工具,把结果追加进状态,再进入下一轮。

一个浓缩的核心逻辑长这样:

def run_loop(question: str, store: BlockStore, max_rounds: int = 6): state = { "question": question, "read_blocks": set(), "searched_keywords": [], "candidate_ids": [], "history": [], } for _round in range(max_rounds): instruction = call_llm(SYSTEM_PROMPT, state) action = instruction["action"] if action == "answer": # 校验证据是否真正读过 if all(bid in state["read_blocks"] for bid in instruction["evidence_ids"]): return { "answer": instruction["answer"], "evidence_ids": instruction["evidence_ids"], "rounds": _round, } else: state["history"].append( "错误:你引用了未读过的块,请重新决策。" ) continue if action == "search": query = instruction["query"] if query in state["searched_keywords"]: state["history"].append("错误:检索词重复,请换一个角度。") continue hits = store.search(query, top_k=5) state["searched_keywords"].append(query) state["candidate_ids"].extend(h["block_id"] for h in hits) state["history"].append( f"搜索「{query}」获得{len(hits)}个候选块," f"标题与章节:{...}" ) elif action == "read": bid = instruction["block_id"] if bid in state["read_blocks"]: state["history"].append("错误:该块已读过。") else: content = store.read_block(bid) state["read_blocks"].add(bid) state["history"].append( f"读取块{bid}:{content[:800]}" ) elif action == "expand": # 返回相邻块内容并标记已读 ... return best_effort_answer(state)

这里面有两个防呆机制是反复踩坑后才加上的。一是禁止检索词重复。缺少这个限制,模型在信息不足时容易“复读”上一次的检索词,白白消耗轮次。二是answer环节校验证据是否真实读过。如果模型引用了未读块,解析器会拒绝该结果并注入一条错误反馈,逼它重新决策。加了这两条之后,循环空转率明显下降。

3.3 提示词设计:让模型学会“证明自己找到了答案”

提示词对效果的影响,比循环体代码更大。我最初版本写得太宽松,模型经常读了一个块就急着回答,或者答非所问。后来改成了一套相对严格的结构化提示词。

你是一个文档检索助手,只能依据文档库中实际读到的内容回答用户问题。 可用工具: - search(query):关键词搜索,返回候选块列表。 - read(block_id):读取某个块的完整正文。 - expand(block_id):读取某个块的相邻上下文。 你必须按以下JSON格式输出: {"action": "search", "query": "..."} {"action": "read", "block_id": "..."} {"action": "expand", "block_id": "..."} {"action": "answer", "answer": "...", "evidence_ids": [...]} 规则: 1. 检索时优先使用问题中的精确关键词,包括型号、编号、参数名、步骤词。 2. read后要判断这个块是否直接回答了问题;如果只回答了部分,优先expand补全。 3. 禁止编造。读完后仍无法回答,就继续检索。 4. 只有当你能依据已读块给出明确答案时,才输出answer。 5. answer必须引用已读的块ID。 当前状态: 已读块:{已读块列表} 已检索关键词:{搜索历史} 发现线索:{模型自己的简短分析} 请继续。

特意加了一条“发现线索”,让模型每轮都写一句自己的分析。这个字段能极大减少模型瞎转。模型一旦写出“E031的故障码定义在第3块,但处理步骤可能在第4块或后续章节”,它的下一步行动就会非常明确。

3.4 实现中的几个小坑

工具返回结果必须截断。一次read可能返回一千多字,全塞进上下文既浪费token又干扰判断。我一般截到600到800个字符,如果模型需要更多信息,它自然会expand。

温度务必设成0。答案类任务不需要创造性。我因为忘记改温度,同样的题跑十次有两次答案措辞不一样,引用块也会漂移,排查起来非常痛苦。

搜索要带上限。top_k我固定为5,太少会漏,太多会把噪声带进上下文。候选块只要在列表里出现,模型可以自己选读哪些,不一定要全部读。

4. 评测与参数调整记录

4.1 我搭的简易评测集

为了验证这个方案值不值得继续做,我搭了一个内部小评测集,大概30份文档、60道问题。问题分成四类:精确数值查询(某型号最大电流)、故障代码处理(E031报警解除步骤)、流程类查询(审批时限需要几步)、跨章节整合(需要把两张表的信息拼起来)。评分标准是:最终答案内容正确,且引用的块ID能支撑答案。

这个评测集不追求全面,只求覆盖日常运维里最高频的几种提问模式。对于做垂直知识库的人,我建议也这样搞,别一上来就追求大规模benchmark,先把典型场景测准。

4.2 RAG基线与LoopAgent的对比

两类方案在同一个评测集上的结果如下:

方案答案命中率引用正确率平均轮次平均耗时
传统RAG基线71.7%65.0%1次检索2.1s
LoopAgent(无expand)83.3%80.0%3.6轮4.5s
LoopAgent(带expand)91.7%90.0%3.2轮4.8s

RAG基线的失败案例里,有接近一半是召回环节就没找到关键段落,还有一部分是找到了片段但上下文太碎,模型理解偏了。LoopAgent没有库外召回的问题,因为关键词检索把包含题眼的块都列了出来,剩下的交给循环阅读去判断。带expand之后,跨块答案的得分上涨最明显。

4.3 几组关键参数的调参记录

第一是分块大小。我最初按256个字符切,问题很多;改成按段落切、单块最大800字之后,稳定了不少。块太小,模型读完一个块还要猜上下文;块太大,单次read的信息密度低且容易混入噪声。

第二是top_k。3太少,经常漏掉藏在另一章节的关键块;5是甜点值;再往上收益不大,还增加噪声。

第三是max_rounds。我把上限从4调到6,准确率有明显上涨,调到8之后基本持平,token成本却涨了三分之一。最后取6轮。补充一句,平均实际轮次是3.2轮,上限设6更多是为了给复杂问题留余地。

第四是expand的默认参数。无expand时跨块回答的命中率很低,设置after=1之后显著改善,复杂文档里改到after=2效果更好,但成本也随之上升。建议先用默认值跑评测,按失败案例调。

4.4 Token成本到底涨了多少

很多人一听“多轮循环”就觉得成本会爆炸。实测下来,单题平均3.2轮,每轮输入输出大概1500到2000 token,单题总token大约是传统RAG的2到2.5倍。但传统RAG方案里也有embedding服务的调用成本、向量库的部署成本,综合算下来整体成本并没有显著上升。

如果对成本敏感,可以做一个降级策略:循环决策用一个小模型,最后生成答案时再把所有证据拼接起来喂给一个大模型。我试过用市面上常见的轻量模型做决策,判断能力稍微弱一点但基本可用,成本能再压一半。

5. 常见问题与排查方法

5.1 模型在循环里反复搜索同一个关键词

症状非常典型:连续两轮或三轮的action都是search,而且query一字不差。原因通常是模型觉得上一轮信息不够,又想不到新的检索角度,于是复读。

对策有两条。一是在提示词里强制要求“不得重复任何已检索的关键词”;二是在解析器层面做拦截,遇到重复搜索时直接注入一条错误反馈,比如“该关键词已检索过,当前候选块有X个,建议先读取未读块或换检索词”。实测第二条更有效,因为强制错误反馈会打断模型的“省事倾向”。

5.2 模型回答时引用未读过的块

这是很多Agent类项目都会遇到问题,模型为了凑证据会编造块ID。我的做法在前面已经提到:answer解析器会校验evidence_ids,如果某个ID不在read_blocks里,就拒绝该条answer并反馈错误。这个操作让引用正确率从80%提到了90%以上。

更深一层的校验是内容一致性,也就是检查证据块内容是否真的包含答案关键点。我后来在解析器里加了一个简单匹配:如果答案里的数字、型号字符串没有出现在证据块中,自动视为可疑。这类结果一般不会直接返回给用户,而是触发一次补充阅读或直接判为未命中。

5.3 文档跨块跨度太大,expand不够用

有些PDF转文本后,一个完整操作步骤被分到好几个块,甚至相关参数表在另一章节。单纯expand可能管不了那么远。这种情况我会做两个增强。

第一个是在分块阶段保留章节路径,让块和块之间有层级关系,expand时优先展开同章节相邻块,而不是机械地按物理顺序扩。第二个是允许模型在发现“可能某个表格存放参数”时,主动search该章节的标题关键词,比如“XX型号 参数表”。这类检索词虽然没有答案里的关键值,但能准确锁定承载答案的那一章。

5.4 如何控制“伪答案”

在运维和文档问答场景里,一个错误答案比没答案更危险。我特别强调一个观点:允许LoopAgent输出“未找到明确答案”。技术上很容易实现,只要循环耗尽后模型仍然无法给出带证据的回答,就返回未命中。

我统计过,未命中率大概在6%左右。传统RAG几乎不会说“不知道”,因为它总要拼一个答案出来,这就导致幻觉成本很高。两相对比下来,LoopAgent的“未命中”反而是质量标记。对严肃文档场景,这6%的未命中完全可以接受,总比用户照着错误操作步骤去修设备强。

5.5 后续可以做的增强

如果文档量和复杂度继续上涨,可以在循环体前增加一个文档路由模块,先判断问题属于哪个子系统,再在子库内检索;也可以在answer前增加一个独立的校验模型,对答案和证据做一次交叉验证。但既然是轻量方案,我建议先跑通主流程再按需增加。每多一个模块,就多一个故障点。

我在实际使用中最大的体会是:很多人一提到文档问答就默认必须上RAG,但RAG只是实现“检索增强生成”的一种手段,不是目的。用LoopAgent做文档检索,本质上是把“一次召回定胜负”换成了“多次求证后作答”,把复杂问题分解成可控的小步骤。对几百份到几千份文档的垂直知识库场景,这套方案在准确率、可解释性和维护成本上都有明显优势。它也有自己的天花板,文档到百万级、问题描述非常模糊时,关键词检索的短板就藏不住了,那时候再考虑引入向量召回也不迟。但至少现在,我手上的这类需求,已经不怎么想回头用传统RAG了。

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

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

立即咨询