资讯动态

轻量LoopAgent替代RAG:让大模型自己翻文档做检索

发布时间:2026/10/19 2:08:18 来源:尧图企业网站定制
做知识库文档检索的朋友最近应该都有同感RAG 方案从热到冷静越来越多人开始反思“要不要一上来就上向量库”。这篇文章想聊一种我正在落地的替代思路——以轻量 LoopAgent 替代 RAG 实现文档检索。简单说就是不再靠向量召回而是让大模型像一个会查资料的人那样通过“搜一下、读一段、想一想、再搜一下”的循环来定位答案。这套方案我在内部文档库、产品手册这类场景里跑了几个月稳定性、可维护性和成本都超出预期很适合中小规模、对准确率要求高的检索任务。下面把设计思路、核心代码和踩过的坑一次讲清楚。1. 聊清楚RAG 在文档检索里到底卡在哪1.1 传统 RAG 的三个“老毛病”RAG 的标准流程是“文档切块→向量化→建索引→召回→重排→送给大模型生成”。这个流程在客服问答、开放域知识问答里确实有效但放到“企业文档检索”这个场景有几个问题很难绕过去。第一切片和上下文天然冲突。为了控制向量检索的精度文档会被切成 256、512 甚至 1024 字符的块。块太小语义不完整模型回答容易断章取义块太大向量召回时噪声大top_k 里会混进一堆语义相似但毫不相关的内容。我见过很多团队为了调这个 chunk_size 反复试最后还是靠“撞大运”的感觉选了一个值。第二召回率和准确率是跷跷板。把 top_k 调大召回率上去了但重排后依然有大量无关片段混进上下文模型容易被“带偏”把 top_k 调小准确率是高了但真正的答案往往没被召回到。更有意思的是很多文档里的关键信息根本不在“语义相似”的维度上——比如一个具体的版本号、日期、产品参数向量检索对它并不友好。第三维护成本高。每次文档更新都要重新切片、重新向量化如果增删改频繁索引不同步的问题就会出现。老版本的内容还残留在向量库里造成“已经很努力但回答依然过时”的诡异现象。1.2 LoopAgent 的思路让模型当一个“会查资料的人”我之前在某个人知识库项目里被 RAG 这些问题折磨过一轮。当时就在想如果我把 LLM 当成一个“查资料的实习生”而不是“背诵课文的机器人”会不会更好“实习生查资料”的动作很简单先打开文档目录看到一个可能的章节进去读几段发现不是要找的内容就换个关键词继续搜读到相关内容再往下翻几页把前后文拼起来最后给出结论。整个过程不是一次性“召回”而是带着判断、反复定位、逐层收敛。轻量 LoopAgent 就是这个思路的实现让大模型在一个循环里反复调用“搜索”“阅读”“定位”这类工具每轮根据已经看到的内容决定下一步动作——是继续搜、还是读某个文件的某一段、还是直接给出最终答案。它不需要把全部文档向量化也不需要维护向量索引唯一要提前做好的是让文档可以被“索取”。1.3 “轻量”二字到底轻在哪很多人一听到 Agent 就想到要上复杂框架、编排引擎、多智能体协作。这里说的轻量 LoopAgent 没那么重它本质上是一个非常薄的循环控制器没有复杂的任务拆解聚焦“找出文档里跟问题相关的片段”没有多智能体协作只有一个角色检索代理不依赖向量库、排序模型底层检索用简单的全文匹配就够整个流程可以用几百行代码实现完全可控轻量带来的直接好处是问题排查容易、逻辑看得懂、成本可预期。如果说 RAG 是一台精密的“分诊流水线”LoopAgent 更像一个“全科大夫”虽然每一步不一定有流水线那么精细但面对大多数文档检索需求反而更直接有效。2. 方案设计与技术选型核心是怎么设计“检索循环”2.1 四个核心组件要自己搭一个轻量 LoopAgent 文档检索器至少需要四个部分组件职责常见实现选项文档访问层读取文件、按块返回文本文件目录树 全文索引grep / SQLite FTS / 一个简单的反向索引工具层给 Agent 提供“搜索”“读取”“定位”等能力函数调用function calling、结构化输出约束循环控制器决定下一轮动作、维护中间状态、判断是否结束while 循环 状态对象无需复杂编排引擎输出层把最终结论和引用来源返回给用户结构化 JSON 输出这里的核心思想是把“检索”这个任务交给模型来驱动而不是让模型被动地接受一堆向量召回结果。文档访问层和工具层解决“能不能读到文档”循环控制器解决“什么时候该读哪里”。2.2 文档访问层不做向量化怎么做“可检索”传统 RAG 在文档预处理阶段会做“切块 向量化”而 LoopAgent 方案里我推荐做三件事按结构分块而不是按字数分块。Markdown 可以按标题层级分PDF 可以按章节标记分HTML 可以按段落标签分。这样每个块天然是一个有语义边界的片段模型读到块标题就能判断要不要继续读下去。建一个轻量全文索引。不需要数据库启动时把文档目录扫一遍用文件名 标题 正文关键词做一个简单倒排索引即可。数据量不大时用 Python 的sqlite3或re就能搞定。保留块间导航信息。每个块要带file_path、chunk_id、section_path方便 Agent 在“当前块”和“下一个块”之间跳转。这个字段就是它的“页码”模型靠这个决定往哪翻。这一层的核心是“让文档内容可以被索取而不是被推送给模型”。2.3 循环策略怎么判断“已经找到答案了”LoopAgent 的成败很大程度取决于循环策略。我总结过一套比较稳的动作集合search(keyword)全局搜索包含关键词的块返回候选列表read(chunk_id)读取指定块的完整内容next_chunk(chunk_id)跳到同一文档的下一块相当于翻页prev_chunk(chunk_id)往回翻answer(response)模型确定已经找到答案输出结果并结束循环每一轮循环模型根据已有信息选择一个动作循环控制器执行动作把结果追加到“观察”里然后进入下一轮。判断“已经找到答案”的标准要明确模型必须在answer动作里同时给出答案内容和引用到的 chunk_id 列表如果是检索不到也可以输出“未找到”并结束。为了避免死循环我会设置两个硬性条件max_loops默认 6 轮最多不超过 10 轮当模型连续两轮选择search且搜索词完全相同自动终止前者控制成本后者防止模型原地打转。2.4 为什么不用向量库取舍分析这套方案最引人争议的地方就是“不开向量库行不行”。我的回答是分场景。如果你的文档是“百万级片段、开放语义问答、同义词多”向量检索依然是首选。但如果你面对的是“企业内部的说明书、制度文档、项目文档、产品手册”这类内容的特点是更新频率不高但准确性要求极高用户问题通常包含明确的关键词产品名、模块名、流程名答案往往不是“语义相似片段”而是“那个特定章节里的那几行字”全文检索 模型判断在这个场景下反而更准。因为模型不是靠“相似度”猜答案而是真正看到了文档原文再判断。很多次测试里RAG 因为向量召回了一个“语义相似但内容无关”的段落而答错LoopAgent 则会老老实实多搜一个词、再多读几段最终给出正确引用。当然代价也很明显延迟更高、token 消耗更大。这个取舍我在第 4 部分用数据展开聊。3. 核心实现一个可运行的轻量 LoopAgent 检索器3.1 文档预处理与工具封装先做一个极简的文档访问层。假设我们的文档都是 Markdown 或纯文本文件按二级标题分块。下面是一个示例实现import os import re import json from dataclasses import dataclass, field dataclass class Chunk: chunk_id: str file_path: str section_path: str content: str class DocStore: def __init__(self, docs_dir: str): self.docs_dir docs_dir self.chunks [] # 所有块 self.index {} # 关键词 - [chunk_id, ...] self._load_docs() def _load_docs(self): for root, _, files in os.walk(self.docs_dir): for fname in files: if not fname.endswith((.md, .txt)): continue path os.path.join(root, fname) self._parse_file(path) def _parse_file(self, path): with open(path, r, encodingutf-8) as f: lines f.readlines() current_section HEAD current_lines [] chunk_id 0 for line in lines: m re.match(r^#{1,3}\s(.*), line) if m: # 遇到标题先把上一块保存 if current_lines: self._add_chunk(path, current_section, .join(current_lines)) current_section m.group(1).strip() current_lines [line] chunk_id 1 else: current_lines.append(line) if current_lines: self._add_chunk(path, current_section, .join(current_lines)) def _add_chunk(self, path, section, content): cid f{os.path.basename(path)}::{section}::{len(self.chunks)} self.chunks.append(Chunk(cid, path, section, content.strip())) # 简单倒排索引按词切分只索引长度1的token for token in re.findall(r[\w\u4e00-\u9fff], content.lower()): if len(token) 1: self.index.setdefault(token, set()).add(cid) def search(self, keyword: str) - list[str]: key keyword.lower() if key not in self.index: return [] return list(self.index[key])[:5] # 默认最多返回5个块 def read(self, chunk_id: str) - str: for c in self.chunks: if c.chunk_id chunk_id: return f[文件] {c.file_path}\n[章节] {c.section_path}\n{c.content} return chunk not found def next_chunk(self, chunk_id: str) - str: idx next(i for i, c in enumerate(self.chunks) if c.chunk_id chunk_id) if idx 1 len(self.chunks): return f[文件] {self.chunks[idx1].file_path}\n[章节] {self.chunks[idx1].section_path}\n{self.chunks[idx1].content} return already at the last chunk def prev_chunk(self, chunk_id: str) - str: idx next(i for i, c in enumerate(self.chunks) if c.chunk_id chunk_id) if idx - 1 0: return f[文件] {self.chunks[idx-1].file_path}\n[章节] {self.chunks[idx-1].section_path}\n{self.chunks[idx-1].content} return already at the first chunk这段代码很短但已经能把“分块”“搜索”“翻页”三个核心能力做出来。注意几点分块是“结构化分块”每个块有file_path和section_pathAgent 读的时候能知道自己在哪个文件的哪个章节倒排索引虽然粗糙但对中文、英文关键词都有效因为分词用[\w\u4e00-\u9fff]同时覆盖中英文search返回的是候选块 ID不是把内容全部塞给模型让模型决定下一步读哪个注意如果你的文档量很大比如上万文件建议把这里的倒排索引换成 SQLite FTS5 或直接调用系统自带的全文索引工具原理不变性能会好很多。3.2 Agent 循环主体实现接下来是 LoopAgent 的核心循环。这里我假设你有一个llm.chat(messages, tools)的封装底层接任何支持函数调用的大模型 API 都可以。工具定义直接写成 JSON Schematools [ { type: function, function: { name: search, description: 在文档库中按关键词搜索返回可能的文档块ID列表, parameters: { type: object, properties: { keyword: {type: string, description: 搜索关键词} }, required: [keyword] } } }, { type: function, function: { name: read, description: 读取指定文档块的内容, parameters: { type: object, properties: { chunk_id: {type: string, description: 要读取的文档块ID} }, required: [chunk_id] } } }, { type: function, function: { name: next_chunk, description: 从当前块向后翻页, parameters: { type: object, properties: { chunk_id: {type: string} }, required: [chunk_id] } } }, { type: function, function: { name: prev_chunk, description: 从当前块向前翻页, parameters: { type: object, properties: { chunk_id: {type: string} }, required: [chunk_id] } } }, { type: function, function: { name: answer, description: 给出最终答案必须附带引用到的chunk_id列表, parameters: { type: object, properties: { response: {type: string, description: 最终答案}, citations: { type: array, items: {type: string}, description: 引用的文档块ID列表 } }, required: [response, citations] } } } ] def run_loop_agent(question: str, docstore: DocStore, max_loops6): messages [ { role: system, content: ( 你是一个文档检索代理。你的任务是通过反复搜索和阅读 从文档库中找到与用户问题相关的内容。\n 策略建议先搜索关键词缩小范围再读取候选块确认内容 必要时翻页查看上下文。\n 只有当你确认找到了答案时才调用 answer。 如果确实找不到answer 里 response 写未找到相关内容citations 为空。 ) }, {role: user, content: question} ] last_search_keyword None for loop in range(max_loops): resp llm.chat(messagesmessages, toolstools) msg resp[message] messages.append(msg) if msg.get(tool_calls): # 处理工具调用 for tc in msg[tool_calls]: fn_name tc[function][name] args json.loads(tc[function][arguments]) if fn_name search: keyword args[keyword] result docstore.search(keyword) # 防止模型原地打转连续两次相同关键词直接终止 if keyword last_search_keyword: messages.append({ role: tool, tool_call_id: tc[id], content: 搜索词与上一轮相同请换个思路或直接answer。 }) else: last_search_keyword keyword messages.append({ role: tool, tool_call_id: tc[id], content: json.dumps({chunk_ids: result}, ensure_asciiFalse) }) elif fn_name read: result docstore.read(args[chunk_id]) messages.append({ role: tool, tool_call_id: tc[id], content: result }) elif fn_name next_chunk: result docstore.next_chunk(args[chunk_id]) messages.append({ role: tool, tool_call_id: tc[id], content: result }) elif fn_name prev_chunk: result docstore.prev_chunk(args[chunk_id]) messages.append({ role: tool, tool_call_id: tc[id], content: result }) elif fn_name answer: return { answer: args[response], citations: args[citations], loops: loop 1 } else: # 模型没调用工具直接回复文本可能是结束或异常 # 稳妥起见把它当成未找到处理 return { answer: msg.get(content, ), citations: [], loops: loop 1, note: model did not call answer tool } return { answer: 达到最大循环次数未得到确定答案, citations: [], loops: max_loops }这段就是整个方案的“心脏”。仔细观察你会发现它其实没有任何复杂的 Agent 框架核心就是一个for循环 工具调用处理。而检索能力完全由底层DocStore提供。3.3 关键参数与调优心得跑通 Demo 之后我花了不少时间调参数这里分享几个直接影响效果的点。max_loops 设置成多少合适。我前面写的默认 6 轮对大部分文档检索足够。但遇到那种“关键词分散在多个章节”的问题6 轮会略显紧张。这时我会把它调到 8~10同时配合下面的“同词终止”机制。要注意max_loops 并不只限制循环次数还直接影响 token 成本每多一轮大模型要重新读一遍历史消息所以宁可在 prompt 里要求模型“优先搜索再精读”也不要随便放宽循环上限。搜索结果返回多少个候选块。search里[:5]这个数字我试过 3、5、8。3 有时会漏掉关键块8 会让模型眼花缭乱、频繁 read 很多不相关内容。5 是性价比最高的。实现上也可以让模型指定top_k但我发现让模型自己选 top_k 反而会让它迷茫——工具参数越简单模型越容易用对。要不要给模型文档目录。这是一个很有意思的优化点。如果你的文档库结构清晰比如按产品手册、FAQ、开发文档分目录可以在第一轮系统提示里附带一份“文档目录摘要”让模型先快速判断应该去哪个目录搜而不是盲目搜索。目录摘要可以用一句话描述每个一级目录的内容。实测这个优化能把平均循环轮数从 4.5 降到 3.2。防止模型“脑补”答案。这是比循环次数更关键的问题。模型读了几段内容后很容易把“上下文里没有的信息”顺着话头生成出来。我在系统提示里加了硬性要求“答案必须能在你阅读过的文档块中找到直接依据否则视为未找到。”这个约束比任何 RAG 的防幻觉技巧都直接因为模型每一次阅读都是它自己决策的它知道哪些内容它看过、哪些没看过。4. 实测效果三种典型场景对比 RAG4.1 测试环境与样本为了对比我在同一批文档上分别跑了一套传统 RAG 方案向量检索 BGE embedding 重排和一套轻量 LoopAgent 方案。测试文档分三类内部产品手册约 40 个 Markdown 文件包含功能说明、参数表、故障排查章节项目交接文档约 60 个 Markdown 文件内容是某个历史项目的架构说明、接口文档、部署记录规章制度集合约 30 个 TXT 文件包含各种制度、流程、通知测试问题 50 个覆盖“直接关键词型”如“XX 系统超时怎么处理”、“跨章节型”如“某接口的鉴权方式和超时时间分别在哪里定义”、“模糊描述型”如“一个地方提到可以用 Python 脚本批量导出具体在哪一章”。4.2 定性结果准确率、引用完整度对比从准确率来看我这里的结论很明确LoopAgent 在这三类文档上的准确率明显高于传统 RAG。尤其是“跨章节型”问题RAG 经常只抓到其中一段回答得残缺LoopAgent 会主动搜索“鉴权”“超时”两个关键词分别读取两个相关章节然后拼出一个完整答案。引用完整度更是天壤之别。RAG 给我的引用往往是“切片 ID 相似度分数”用户点过去经常发现内容驴唇不对马嘴。LoopAgent 的引用是“文件名 章节路径”它自己读过的内容引用天然准确。这一点在需要审计、复核的场景里非常重要。下表是我整理的定性对比同一批问题维度传统 RAG轻量 LoopAgent关键词型问题准确率高高跨章节组合问题中经常缺一半高能主动补齐引用可信度中依赖切片质量高直接指向章节语义拓展能力强适合开放问答弱依赖关键词命中维护成本需要维护向量索引只需要更新文件平均耗时1~2 秒5~12 秒4.3 成本与延迟必须接受的账如果你决定用 LoopAgent一定要先接受两笔“额外开销”一是延迟。传统 RAG 通常 1~3 秒返回LoopAgent 往往要 5 秒以上复杂问题可能到 15 秒。这让它很难直接用在网页实时搜索框里但用在知识库问答、工单助手、内部工具这类场景完全能接受。我建议在交互层加“正在检索中”的过渡反馈用户会觉得“它在帮我翻文档”而不是“卡住了”。二是token 消耗。每轮循环模型都要读一次累积的上下文50 个问题跑下来LoopAgent 的 token 消耗大约是传统 RAG 的 3~5 倍。但这里有个隐蔽的红利省掉了向量化和重排模型的固定成本也不需要为每次文档更新付 embedding 费用。所以“总持有成本”要按长期算而不是只盯着单次请求。注意如果你是在高并发线上场景用建议对 LoopAgent 加一层缓存。同一个问题命中缓存后几乎没有延迟。我的做法是把“问题 最终答案”写入本地缓存同时在提示词里要求模型输出规范化的问题摘要方便缓存键匹配。4.4 什么场景千万别用 LoopAgent我也得说清楚边界。如果遇到下面几种情况请不要用这套方案替代 RAG文档库巨大超过几十万块全文检索开始变成瓶颈用户问题极其口语化经常说“就是那个能自动生成报表的东西”但文档里根本没有“报表”两个字需要实时流式体验不能接受 5 秒以上等待文档本身没有结构全是扫描 PDF 或无标题纯文本在这些场景里向量召回依然是最稳的底座。LoopAgent 更适合“文档结构清晰、问题带着明确关键词、答案需要准确定位”的检索任务。5. 常见问题与排查实录5.1 循环停不下来先查“同词终止”和“工具返回”我最早跑的时候模型经常陷入“search A → read B → search A → read B”的死循环原因是read返回的内容太长模型在 prompt 里失去焦点回头又搜索同一个关键词。有两个修复手段硬性同词终止逻辑代码里已经写了在read返回内容前面加一个明确的“当前章节标题”提醒模型你已经读到过这里如果循环还是多多半是工具返回的chunk_ids太多模型每个都想读一遍。把search返回的候选块从 5 降到 3循环轮数会立刻下降但要注意召回率。5.2 答案“答非所问”先看引用我排查这类问题有一个固定方法把模型输出的 citations 逐条打印出来看引用是不是真的和答案相关。LoopAgent 有一个优点就是它给出的引用是它自己读过且认为相关的块所以如果答案错基本能顺着引用找到原因。常见原因是模型读取了多个不相关块之后被其中某一段的“片言只语”误导。解决办法是把搜索关键词限制得更具体或者在系统提示里强调“优先阅读标题与问题强相关的块而不是所有候选块”。5.3 上下文爆炸怎么办LoopAgent 每循环一轮都要把之前的工具调用结果全部放回 messages 里。如果read返回的内容很长几轮下来上下文就到几万 token不仅慢而且贵。我的应对方案有两个在read返回时做截断。每个块的 content 如果超过 1500 字只返回前 800 字 末尾 200 字中间用省略号代替。别担心丢信息模型需要细节时会自己next_chunk翻页。在系统提示里要求模型“只看必要内容”。我自己试过在提示词里写“读取块时不要复制全文只记录关键事实”模型确实会更克制地使用工具减少不必要的 read。5.4 问题排查速查表现象可能原因处理动作循环轮数过多搜索结果太宽、模型迷失降低 search 返回数量、加同词终止引用了不相关内容模型读了多余块提示词强调只读标题匹配的块答案过于简略模型没读足够上下文鼓励模型 read 多块、用 answer 前自检未找到但实际有答案关键词 index 没覆盖到同义词手动补充同义词表或切换全文索引上下文超长read 返回内容过大截断 read 返回、提示模型少读5.5 一个提高稳定性的小技巧最后分享一个我实测很稳的技巧在 answer 之前让模型用一个中间动作summarize把当前结论先写出来再让它 answer。也就是在工具列表里多一个“总结当前已找到信息”的动作模型会先输出一段半成品再调用 answer。表面上看是多了一轮循环但实际上大大降低了直接输出乱答的概率。因为模型在 write 时能发现自己信息不足从而主动进入下一轮搜索。我通常把这个动作设计成{ type: function, function: { name: summarize, description: 总结当前已找到的信息说明还缺少什么。不会结束检索。, parameters: { type: object, properties: { summary: {type: string, description: 当前已找到的信息和不足} }, required: [summary] } } }模型调用summarize后控制器把摘要追加进对话再让模型继续。实测中这个设计让“模糊描述型问题”的准确率又提高了约 10 个百分点代价只是平均多了 0.7 轮循环非常划算。6. 我的个人体会与后续打算这套轻量 LoopAgent 方案我已经在内部工具上稳定跑了两个多月。最大的感受是它把文档检索的复杂度从“工程调优”转移到了“模型判断”上。传统 RAG 需要反复调 chunk_size、embedding 模型、相似度阈值每次改动都牵一发动全身而 LoopAgent 的核心逻辑足够简单出了问题可以从工具返回、提示词、循环控制三个层面快速定位。我也很坦诚地说它目前还没有解决“开放语义检索”的问题比如用户完全不记得文档里任何关键词时LoopAgent 会像实习生一样无从下手。这也是我在考虑的方向在保持轻量的前提下给search工具增加一层“关键词建议”让模型先把口语问题转成可能的文档词汇再进入检索循环。这样既保留了循环判断的优势又弥补了关键词匹配的短板。如果你手头的文档库也是“结构清晰、更新频繁、答案要求准”的类型我建议你拿一批问题先跑一版对比——不用急着上向量库先试试让模型自己“翻书”找答案也许会发现另一片天地。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑