传统RAG不够用?用LoopAgent实现多轮检索提升文档问答准确率

发布时间:2026/10/17 8:31:07
传统RAG不够用?用LoopAgent实现多轮检索提升文档问答准确率 先说个结论如果你手头的文档检索场景里问题大多是答案分散在多处、提问方式跟原文写法差异很大或者需要查完一个线索才能继续查下一个那传统 RAG 那种一次切块、一次召回、一次生成的套路大概率会把你折腾得够呛。我最近在一个内部知识库项目里把原来基于向量库的 RAG 整体替换成了一套轻量 LoopAgent检索准确率反而上去了延迟也没高多少。这篇文章就把我为什么换、怎么设计、完整实现代码、参数调优过程和踩过的坑全部整理出来给正在 RAG 和 Agent 两条路线之间纠结的朋友一个可直接参考的样本。LoopAgent 说起来不玄乎——它就是让 LLM 在一个循环里反复做三件事根据当前提问去检索、让模型自己评估检索结果够不够、不够就用还缺什么去改写提问再检索一次直到攒够信息才生成最终答案。它解决的是 RAG 最核心的痛点单次召回的质量上限取决于你对用户问题的理解深度。而理解深度这件事恰恰是 LLM 最擅长、而向量相似度最不擅长的。1. 为什么我会用 LoopAgent 去替换 RAG1.1 传统 RAG 在实际使用中的三个老大难第一个老大难是切分粒度导致的语义孤岛。中文知识库的文档基本都是长文比如操作手册、产品说明、规章制度动辄几千字。你不可能把整篇文档塞进上下文只能切块。一但切了块问题就来了文档里写着先执行 A再执行 B最后检查 C这三步常常分散在不同的章节、不同的页面上。向量检索 top-5 召回回来的往往是内容最相似的其中一块另外两块信息直接丢失。问答系统的答案质量从起步就锁死了。第二个老大难是提问方式和原文写法之间的语言鸿沟。用户不会按文档作者的口径提问。文档里写的是调整抗锯齿采样率以改善边缘锯齿用户问的是图片边缘怎么变光滑。向量嵌入对近义词和同义表达有一定的宽容度但真的有限特别是这种完全不同描述、同一实体的情况语义相似度往往并不高。我实测过好的 embedding 模型能救回一部分但绝不是全部。第三个老大难是多跳问题。所谓多跳就是回答一个问题需要先查到线索 A再由线索 A 去查 B。举个我做过的例子知识库里有一份设备巡检记录用户问上个月哪台设备故障次数最多这个问题要先定位上个月的时间范围、再按设备聚合故障记录包含了两层推理。传统 RAG 检索一次就结束了拿回来的片段没法形成推理链模型只能靠猜。1.2 LoopAgent 的直觉把检索变成问诊LoopAgent 的思路朴素得像问诊。医生接诊不会一上来就把血常规、CT、B 超全做了而是先问症状、做初步检查根据结果决定下一步查什么。我让 LLM 也这样干先拿用户的原始问题去检索一轮拿到几段候选资料然后让模型自己读一遍判断这些资料能不能回答问题如果能还缺什么信息模型说缺什么就把这个缺口转成新的检索词再来一轮。如此反复直到模型认为信息够了或者达到了我设定的循环上限。这相当于把一次性的检索决策拆成了多轮小决策每一轮都在修正方向。最关键的是这个方向修正完全不依赖额外的 rerank 模型或复杂的语义理解模块——它就是让 LLM 用自己已有的理解能力去判断检索结果和自己的问题之间还差多远。1.3 轻量到底体现在哪我强调轻量是有原因的。完整的 RAG 方案通常要上向量数据库、Embedding 模型、Reranker 三个重型组件有的还加 GraphRAG。而我这套 LoopAgent检索层用 BM25 加中文分词就扛住了不需要向量库不引入额外的重排模型让 LLM 自己兼任评估器不用任何 Agent 框架核心逻辑几十行 Python 写完循环过程中不落库、不建图所有状态在内存里流转完就结束。整套东西跑在一个很普通的服务里连数据库都没新增。对一个中小规模知识库几千到几万篇文档来说这个成本优势是很实在的。2. 整体设计与选型思路2.1 核心闭环检索-评估-改写-再检索整个 LoopAgent 的闭环设计其实就四步检索用当前 query 在文档库中召回 top-k 候选片段评估把片段拼成上下文让 LLM 判断是否能回答用户问题输出一个结构化结果包含enough、missing两个字段改写如果enoughfalse把missing里描述的缺口转写成一条新的检索 query循环带上新 query 回到第 1 步直到enoughtrue或达到最大步数。这个闭环最大的好处是每一步都有明确的输入输出方便加日志、加监控、出问题好排查。比那些黑盒式的 Agent 编排直观多了。2.2 检索层选型为什么我先用 BM25 而不是向量检索很多人听到文档检索第一反应就是向量库但这次的场景让我重新审视了这个惯性。这个知识库的特征是领域术语密集、文档风格偏正式、用户的提问往往包含和原文高度重合的关键词。比如用户问服务器启动失败如何排查文档里大概率就有启动失败这四个字。BM25 对这种精确匹配极其敏感命中即得分不需要经过 embedding 模型的语义压缩。另一个原因是可控性。BM25 的分数可以明确解释这个词命中了、那个词没命中。向量相似度则是个黑盒你很难定位为什么召回结果里混进了毫无关系的段落。对一个需要长期维护的项目来说可解释性就是可维护性。但我也得说清楚边界如果语料变成了用户描述和原文几乎没有词汇重叠的场景比如客服闲聊、口语化提问特别多、同义表达极其丰富那 BM25 会明显吃力该上向量还得上。我在后面第 4 节会结合实测数据说明这个边界在哪。2.3 为什么不套现成的 Agent 框架现在市面上能编排 Agent 的框架不少我也试过其中两个最后都推倒自己写了。原因有三一是调试成本。框架帮你封装好了工具调用、记忆、规划这些抽象但一但检索效果不好你得先搞清楚是框架的规划出了问题还是检索工具出了问题还是模型调用出了问题。排查链路长到让人头大。自己写循环每一步都是显式代码出问题一眼就能定位。二是控制粒度。我需要精确控制每次改写 query 的格式、评估输出的 schema、循环终止的阈值。这些在框架里往往要通过一堆回调函数和配置项去实现绕来绕去不如直接写循环。三是依赖臃肿。为了用一个循环逻辑引入一整套框架依赖光环境兼容问题就够喝一壶的。轻量方案的朴素美德就是能不用框架就不用。2.4 这套方案适用的规模边界说句实在话LoopAgent 不是万能的。它适合的语料规模我个人实测是几千到几万篇级别。再往大走BM25 的检索效率和准确率都会下降而且循环里的每轮检索都要扫一遍倒排索引文档多了延迟会成倍上涨。如果语料到了十万篇以上我建议还是在保留循环思想的前提下把检索层换成向量库或者混合检索。这个工程改造后面只要替换掉检索函数就行闭环逻辑完全不用动。3. 核心实现从零搭一个可用的 LoopAgent3.1 文档预处理与索引构建先说文档怎么进库。我用的方式是先把 PDF/Word 转成纯文本然后按标题层级 段落做切块。这里有个很重要的细节切块时一定要保留块所在的章节路径比如3.2 故障排查 3.2.1 启动失败。后面把它拼进检索上下文里模型读到这段来自故障排查章节会比读裸文本更容易判断信息够不够。切块代码我写得很简单核心逻辑就是按目录结构和空行划分def chunk_document(text: str, max_chars: int 800) - list[dict]: chunks [] current_chapter current_chunk for line in text.split(\n): line line.strip() if not line: continue # 识别标题行更新章节路径 if line.startswith(第) and 章 in line[:4]: current_chapter line continue if len(current_chunk) len(line) max_chars: chunks.append({ chapter: current_chapter, content: current_chunk.strip() }) current_chunk line else: current_chunk \n line if current_chunk: chunks.append({chapter: current_chapter, content: current_chunk.strip()}) return chunks每篇文档切完块之后把所有块的content字段拼成一个列表作为 BM25 索引的语料库。同时保留一个chunk_id - 元信息的映射表供后续检索回溯来源。3.2 基于 BM25 的检索函数中文 BM25 必须先分词。词表大小直接决定索引质量我用 jieba 默认词库然后追加了一份领域术语自定义词典。这一步不要省专业词汇比如光模块、误码率这种东西jieba 默认切法经常会切成光/模块误码/率命中率直接打折。import jieba from rank_bm25 import BM25Okapi class BM25Retriever: def __init__(self, chunks: list[dict], custom_dict_path: str | None None): if custom_dict_path: jieba.load_userdict(custom_dict_path) self.chunks chunks self.corpus [list(jieba.cut(c[content])) for c in chunks] self.bm25 BM25Okapi(self.corpus) def retrieve(self, query: str, top_k: int 5): tokens list(jieba.cut(query)) scores self.bm25.get_scores(tokens) ranked sorted( zip(self.chunks, scores), keylambda x: x[1], reverseTrue )[:top_k] return [ { content: chunks[content], chapter: chunks[chapter], score: round(score, 4) } for chunks, score in ranked ]检索函数返回的不只是文本内容还带上了章节路径和得分。这两个字段在后面的评估环节很关键。3.3 Agent 循环主逻辑循环主逻辑是整个方案的心脏。我用一个自定义的 LLM 封装要求模型输出 JSON格式稳定。评估提示词我反复调了好几版最后定下来的方案包括三要素用户问题、候选片段、评估输出规范。class LoopAgent: def __init__(self, retriever, llm, max_steps3, top_k5): self.retriever retriever self.llm llm self.max_steps max_steps self.top_k top_k def _build_eval_prompt(self, question, chunks): context \n\n.join( f[来源{c[chapter]}]\n{c[content]} for c in chunks ) return f你是一个文档检索评估助手。用户的问题是{question} 以下是检索到的候选资料 {context} 请判断这些资料是否足以回答用户的问题。输出 JSON {{ enough: true/false, missing: 如果不够说明缺少哪方面的信息如果够填空字符串, next_query: 如果不够给出下一步应检索的关键词如果够填空字符串 }} def _eval(self, question, chunks): prompt self._build_eval_prompt(question, chunks) resp self.llm.chat_json(prompt) return resp def run(self, question: str): query question collected [] for step in range(self.max_steps): new_chunks self.retriever.retrieve(query, top_kself.top_k) # 去重已经收集过的片段不再重复累积 seen_ids {c[content] for c in collected} for c in new_chunks: if c[content] not in seen_ids: collected.append(c) resp self._eval(question, collected) if resp[enough]: return self._final_answer(question, collected, resp) query resp[next_query] # 达到最大步数用已收集资料生成答案 return self._final_answer(question, collected, resp) def _final_answer(self, question, chunks, eval_resp): context \n\n.join( f[来源{c[chapter]}]\n{c[content]} for c in chunks ) prompt f基于以下资料回答用户问题{question} 资料 {context} 请用中文回答并在回答末尾列出引用的资料来源章节。 return self.llm.chat(prompt)这段代码里有一个我一开始忽略、后来补上的细节去重。循环过程里第 2 轮的 query 和第 1 轮很可能高度相似BM25 召回的片段会有大量重叠。不加去重的话重复片段会占用上下文空间还会干扰模型判断。所以我维护了一个collected列表按内容去重。这个修改直接让上下文有效利用率提升了 30% 以上。3.4 评估提示词的设计要点评估提示词是整个方案里最容易翻车的地方。我第一版让模型直接回答资料够不够结果它老说不够因为模型天生倾向谨慎。后来我加了几个约束明确告诉它不需要补充与问题无关的背景信息要求它区分资料里没有和资料里明确提到但表述不同——后者其实已经够了输出必须走 JSON 结构化next_query必须是一个检索关键词而不是一段话否则下轮检索效果很差。改完之后评估结果的稳定性明显提升。我另外做了个后置校验如果同一next_query连续出现两次说明模型陷入了原地打转直接终止循环避免浪费调用和延迟。4. 实测效果与关键参数调优4.1 三类场景的对比测试我拿这个方案跑了一个内部知识库的测试集大概 130 道题覆盖三类典型场景场景 A单段点查答案集中在某个文档的某一段RAG 和 LoopAgent 都能答对场景 B跨段聚合答案需要融合多个段落甚至多个文档的信息场景 C多跳推理需要先查到线索再查目标。对比维度是答案完整率判分规则是人工看答案是否涵盖所有必要信息点。结果如下场景传统 RAG向量 top-5LoopAgentBM25 3轮循环A 单段点查91%89%B 跨段聚合54%81%C 多跳推理38%76%值得注意的细节是场景 A 里 LoopAgent 反而略低。原因不难理解BM25 对同义改写不如向量检索灵活单段点查这种高度依赖语义匹配的场景向量模型确实有优势。但场景 B 和 C 的优势差距大到足以让人忽略这一点牺牲。在真实业务里用户问的难题恰恰都集中在 B 和 C 这两类。4.2 三个关键参数的调优记录最大循环步数 max_steps我试了 1 到 5 轮。1 轮退化成普通 BM25 检索C 场景完整率掉到 41%2 轮能到 63%3 轮能到 76%4 轮以上基本没有提升反而因为第 4 轮召回的片段往往是前面轮次已经覆盖过的内容徒增延迟。最终定在 3。这个参数本质上是你对问题复杂度的预判如果业务里问题普遍简单设 2 更划算。单次召回 top_ktop_k 从 3 调到 5B 场景提升最明显因为跨段聚合至少需要两段以上信息在同一轮被捞到调到 8 之后提升微弱但每轮传给评估器的 token 数涨了 60%成本和延迟都不划算。定在 5。评估 LLM 的温度评估环节我直接把温度调到 0因为评估是判断题不需要创造性。改写 query 的环节同样用低温避免 query 漂移。只有最终生成答案那个环节温度才放到 0.3让回答略带自然感又不至于乱编。4.3 成本与延迟的账这是很多人最关心的问题。我的实测数据单次问题平均循环步数 2.7 轮每轮调用一次 LLM 评估加上一次最终的答案生成平均 3.7 次 LLM 调用。而传统 RAG 只有一次生成调用调用次数是 LoopAgent 的约四分之一。单看调用次数LoopAgent 成本确实更高。但要看总账。项目原来用向量 RAG 时为了把 B、C 场景的准确率拉到能用的水平我得不断调切分策略、换 embedding 模型、加 reranker成了个无底洞。LoopAgent 方案把所有复杂度收敛到一个循环逻辑里检索层退化成一个简单的 BM25整体算下来基础设施成本反而降了LLM 调用成本的上浮完全在可接受范围内。如果你的业务对单次回答延迟有硬性要求比如 1 秒以内这套方案就不合适了我在第 5 节会说清楚。5. 踩坑记录与排查技巧5.1 常见问题速查表症状根因解决办法模型一直说信息不足循环跑满上限评估提示词太宽松模型天生倾向谨慎提示词里明确只要资料间接涵盖了问题关键点就算足够第二次检索的 query 开始跑偏next_query 输出是一段话而非关键词约束输出格式为不超过 8 个词的检索关键词同一 query 反复出现进入死循环评估模型找不到新方向原地打转记录每轮 query重复出现即提前终止召回片段大量重复BM25 对近义 query 的命中高度重叠按内容去重后再拼上下文专业术语分词错误导致召回不到jieba 默认词库不认识领域词汇加载自定义词典把所有领域术语加进去上下文超过模型窗口多轮累加片段过多每轮限制单片段字数限制 collected 总数在 10 个以内5.2 三个让我印象深刻的 bug第一个是评估模型自嗨。一开始我用默认提示词模型特别喜欢把资料里没提到误判成资料不足导致几乎每一道题都要跑满 3 轮才肯结束。后来我在评估提示词里加了这么一句话如果资料中没有的信息对回答问题不是必须的就视为信息足够。这句话直接把 B 场景的平均步数从 2.9 压到了 2.3。第二个是 query 漂移。第 1 轮检索拿到的片段可能里包含很多子话题模型在评估时会把我感兴趣的子话题和回答问题必需的子话题混在一起导致 next_query 变成探索性的新方向而不是补全缺口。我的修正办法是评估提示词要求模型先写一句当前已覆盖的信息再写 missing最后才生成 next_query。这个先总结再补缺的顺序强制模型理清逻辑query 漂移现象基本消失。第三个是专业术语的分词问题。知识库里大量出现链路聚合端口镜像这类词jieba 默认会切成链路/聚合/端口/镜像。用户搜链路聚合时四个字被切成三段BM25 的匹配就碎了。后来我把这一百多个术语整理成自定义词典导入 jieba召回准确率立刻涨了一截。这个动作成本极低收益却很明显我建议所有做中文知识库的朋友都别省这一步。5.3 什么时候千万别用 LoopAgent我踩坑之后总结了几条红线遇到这些情况我绝对不会用这套方案单次问答延迟要求很严比如客服机器人要求 1 秒内响应。LoopAgent 最少也要两三次 LLM 串行调用延迟天生劣势这不是调参能解决的语料规模超过十万篇并且还在快速增长。BM25 的索引维护和检索效率都会吃紧建议改向量库或混合检索问题几乎都是单段点查没有跨段和多跳需求。那 LoopAgent 的循环纯属浪费传统 RAG 已经很好了LLM 调用成本极其敏感。每道题多出两三次调用量大起来是笔不小的开销得先算清这笔账再决定。我自己在实际项目里最终保留的做法是先跑一遍 BM25 检索如果最高分超过某个阈值说明用户问题很可能和某段原文直接匹配就直接走传统 RAG 的单次生成只有分数偏低或评估不通过时才进入 LoopAgent 的循环。这个混合兜底策略把简单问题的延迟压回了单次调用的水平同时保住了复杂问题的准确率。算是我这轮折腾里最满意的一个收尾方案。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询