ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Agent增强版智能知识库:从RAG到多轮检索与工具调用的实践

Agent增强版智能知识库:从RAG到多轮检索与工具调用的实践 1. 为什么是“增强版”从一个我自己翻车的RAG说起Agent实践系列写到第三篇正好把最近折腾的增强版智能知识库做个总结。先给个结论如果只是想搭个能回答问题的知识库demo那传统RAG就够了但如果你想让知识库真正成为智能助手而不是“搜索拼接器”那必须引入Agent的规划、记忆和工具调用能力。这也是“增强版”这三个字的核心意义——不是简单地在向量库里做检索而是把知识库本身变成Agent的一个工具同时让Agent带着一定的策略去用这个工具。我先讲一个翻车场景。之前我搭过一个RAG问答机器人喂了几十篇团队内部文档。单独问“XX服务的超时时间是多少”回答得挺好。可一旦换个问法“线上XX服务最近老是报超时你能帮我分析可能的原因顺便查一下相关的配置和监控指标吗”普通RAG就崩了。它只会切一段最相关的文本回答不会先拆解这个复杂问题不会分别检索“服务拓扑”“超时配置”“监控指标”三类知识更不会在信息不足时主动发起二次检索。最终输出是东拼西凑的一段话里面甚至混入了不同版本的配置参数让人完全不敢信。这个场景就是典型的“知识库有了但不会用”。增强版智能知识库要解决的就是把“有知识”升级成“会用知识”。从技术角度看它至少做了三件普通RAG没做的事一是把检索封装成Agent可感知的工具二是给Agent提供多步推理和多次检索的决策空间三是加入记忆机制让同一会话内的追问、上下文关联成为可能。下面我按自己的实践经历把从架构设计到落地实现的关键步骤都拆开讲。2. 架构怎么搭Agent、工具、记忆谁先谁后2.1 不是所有东西都要做成Agent网上聊Agent的帖子铺天盖地什么“万物皆Agent”“多Agent协作”看得人头皮发麻。我的建议是静下心来想清楚你的知识库到底需要多大规模的“智能”如果你只是问“文档里写了什么”一个带工具调用的简单Agent就够了不需要上多Agent编排不需要复杂的记忆框架。真正需要增强的地方是让知识库能够回答“需要综合多篇文档、多个数据源才能回答”的问题。我用的方案是三层结构底层是知识库数据层包含原始文档、清洗后的文本、分块后的chunk、向量索引。中间是检索层对外暴露向量检索、关键词检索、混合检索以及重排序能力。上层是Agent编排层负责理解用户意图、拆解子问题、决策调用哪个检索工具、整合结果、追加追问。这三层里工作量占比大概是数据层40%检索层30%Agent层30%。很多人一上来就研究Agent框架结果底层数据一塌糊涂分块没做好检索召回不准Agent再怎么编排也是垃圾进垃圾出。2.2 框架选型LangChain、LlamaIndex还是Dify主力框架我试过LangChain、LlamaIndex和Dify也自己写过轻量工具调用。现在很多人在讨论Agent框架和编排引擎甚至提到Harness这类概念我的理解是框架本质是胶水关键是看它能不能帮你把“工具调用”“记忆管理”“模型对话”这三个环节串起来。我整理了一张对比表可以直观看到差异框架定位对知识库场景的支持上手难度适合谁LangChain通用Agent开发框架提供大量检索器、工具封装、记忆组件灵活度高中等偏上愿意写代码、需要深度定制的团队LlamaIndex数据框架擅长文档索引、查询引擎、知识检索与Agent可集成中等核心诉求是知识库检索Agent只做辅助Dify低代码平台可视化编排知识库检索与Agent流程内置RAG管道低快速验证、非技术人员、中小团队自研工具调用完全可控用一个model call循环实现Agent不依赖框架高想彻底搞懂原理或框架无法满足特殊需求我最后选了“自研Agent核心 自建检索层”的组合不是刻意挑战框架而是因为这次的增强版知识库对工具调用的关注点太定制了。我需要控制检索工具的返回格式、控制Agent每次调用后是否继续检索、控制上下文窗口的使用。LangChain这几个环节都能做但像我这种控制欲比较强的人被抽象层挡住了会很别扭。2.3 增强版到底“增强”在哪里如果拿普通RAG做对比增强版的知识库在五个方面做了升级检索从单次变成多次Agent可以连续调用检索工具逐步逼近答案。从单一向量检索变成混合检索兼顾语义相似度和关键词精确匹配。引入重排序模型把召回的多条结果重新排序保证送给大模型的信息密度高。增加记忆通道同一次会话里可以记住前面的问题和回答支持递进式追问。增加了评测和反馈闭环不再是“看起来能回答就行”而是用一组测试问题评估召回质量和回答准确率。这五点里面最后一项最容易被忽略。我可以直言不讳地说没有评测集的Agent知识库项目基本都是在自我感动。后面我会单独讲怎么构建一个轻量评测集这个灵感也来自网上关于Agent评测集构建的讨论——但那篇文章偏重通用场景我觉得知识库场景有更朴素的评测方法。3. 文档处理链路分块和向量化的坑我一个个踩过来3.1 别急着分块先做文档“体检”增强版的起点不是Agent是文档。我第一次做知识库时直接拿PDF扔进去结果一堆乱码、页眉页脚、表格错位的问题。后来总结了一个经验无论用什么框架文档处理链路一定包含这四步缺一不可格式转换与内容抽取把PDF、Word、HTML里的正文和表格提取出来去掉页眉页脚、导航栏、重复信息。清洗与标准化统一编码、统一单位处理全角半角保留必要的排版结构。结构识别识别标题、段落、列表、表格尽量保留文档原有的层级关系。分块基于结构和长度双重限制把长文本切成适合向量化的chunk。重点是第四步。分块质量直接决定检索召回质量这里没有银弹必须针对你的文档类型调参数。3.2 chunk_size和overlap的实战调参关于分块网上推荐最多的参数组合是chunk_size512、overlap50但那是通用参数不是原则。我自己的经验是根据文档内容类型去选策略。如果文档是操作手册一个步骤最好作为一个独立chunk不要硬切。如果文档是技术方案或论文按章节和段落切保留小节标题。如果文档是聊天记录或工单记录每一条记录做一个chunk加上时间和ID前缀。代码层面我用的LangChain的RecursiveCharacterTextSplitter做基础切分但加了自定义的段落分组逻辑from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size512, chunk_overlap64, separators[\n\n, \n, 。, , ], ) chunks text_splitter.split_text(document)但单纯靠长度切分会有问题。比如一份配置说明文档中“超时时间”和“重试次数”如果被切到两个chunk检索时只召回其中一个Agent就很容易给出片面的答案。后来我在切分前先做了一次“语义段落合并”把属于同一主题的段落预先打包再送入长度切分。这样既保证了语义完整性又控制了chunk长度。一个很好用的技巧是在每个chunk开头补齐它的标题路径例如“数据库配置 / 连接池 / 最大空闲连接数”这会显著提升后续检索的效果因为embedding模型在处理这类带层级前缀的文本时比对单纯的一句话理解得更准。3.3 embedding和向量库选型embedding模型我试过OpenAI的text-embedding-3-small也试过开源的BGE、M3E还试过多模态模型。坦白讲如果你处理的是中文为主的技术文档开源中文embedding的效果不一定输给闭源接口而且没有费用和隐私问题。我最终用的是BGE系列模型具体是bge-large-zh-v1.5512维度离线部署。它对中文长文本的语义理解足够稳。如果你的文档以英文为主或包含大量代码可以考虑支持代码的模型比如使用voyage-code或者sentence-transformers中针对代码优化的模型具体需要自己测试。向量库选择了Milvus因为数据量大、需要后续增量更新。如果只是几千个chunkQdrant或Chroma也完全够用。比较推荐按团队规模去选型个人玩用Chroma小团队用Qdrant必须在生产环境考虑高可用和过滤的时候上Milvus。3.4 混合检索和重排序把召回质量拉上来纯向量检索的弱点是“字面上完全不同但语义接近”的查询容易抓不住比如用户问“连接不上”文档里写的是“网络超时”语义上有关联但向量召回效果可能并不稳定。增强版的做法是同时做向量检索和BM25关键词检索然后用重排序模型合并结果。我实现了一个很简单的混合检索函数def hybrid_search(query: str, top_k: int 10): vector_results vector_store.similarity_search(query, ktop_k) keyword_results keyword_index.search(query, ktop_k) candidates merge_results( vector_results, keyword_results, methodrbf, # 也可以用weighted_sum weights[0.5, 0.5], ) reranked_results reranker.rerank(query, candidates, top_n5) return reranked_results重排序我用的bge-reranker效果非常显著。之前纯向量检索时正确答案经常排在第三第四位喂给大模型后被更多无用信息淹没加reranker之后正确答案基本稳定在前两位。这一步是增强版知识库和普通RAG拉开差距的关键操作没做过重排序的强烈建议去试一下。4. Agent核心把检索封装成工具并赋予规划能力4.1 工具是怎么被“看见”的Agent智能化程度再高也不可能自己知道有哪些检索能力放在哪里。必须把知识库检索能力抽象成工具并提供一段让模型可以理解的工具描述。这里最有价值的经验是工具描述写得具体一点包含“什么场景用、输入什么、返回什么”。按OpenAI function calling的格式我的检索工具定义大概长这样{ name: search_knowledge_base, description: 在内部技术知识库中检索指定主题内容适用于问题涉及产品配置、操作手册、故障排查、代码示例等场景。输入query应为问题中的关键信息越具体越好。, parameters: { type: object, properties: { query: { type: string, description: 需要检索的关键内容例如数据库连接池配置 }, top_k: { type: integer, description: 返回结果数量默认5 } }, required: [query] } }这段描述起到了很大作用。之前我把描述写成“search”模型经常在不需要检索的时候也去调用返回一堆无关内容。后来改成上面的详细描述模型会自己判断“这个问题需不需要查知识库”至少在非知识类问题上的误调用少了很多。4.2 Agent的多步推理与多次检索增强版知识库最关键的能力是允许Agent连续多次调用检索工具。我把它设计成一个简单的“计划-执行-观察”循环用户提问后Agent先分析问题涉及哪些子主题判断是否需要检索。第一次调用search_knowledge_base传入子主题对应的query。模型观察返回结果如果信息不足会再构造新的query继续检索。所有检索完成后模型综合多轮检索结果生成最终答案。为了演示这个流程我把一次真实的执行轨迹简化如下用户提问: 新环境部署后提示连接池耗尽怎么排查 第1次工具调用: query 连接池耗尽 错误日志 返回: 客户端报错、服务端pool配置相关文档 第2次工具调用: query 连接池参数 tuning 最大线程数 返回: 线程池参数说明、集群规模建议 第3次工具调用: query 新环境部署 配置检查清单 返回: 部署前检查项、常见环境配置差异 最终回答: 综合三次检索结果输出排查步骤并指出新环境最大的可能是默认连接池太小建议优先检查配置。这个例子看起来平平无奇但这就是增强版的核心变化它不是把用户问题一次性切分后并行检索而是由模型根据每一次的返回结果动态决定下一步。这种能力在应对“多跳问题”时非常关键。4.3 记忆机制让知识库不再是“每次失忆”普通RAG每次问答都是独立的用户问完“连接池参数怎么调”接着问“那权限呢”模型完全不知道“那”指的是什么。增强版必须支持会话级记忆和长期偏好记忆。我用了两种记忆通道短期会话记忆保存本轮对话的原始问题和最终回答用于支持指代消解和上下文关联。长期事实记忆当用户在对话中明确提到“我们环境是K8s部署”“用MySQL 8.0”等信息时把这类关键词写入一个独立记忆库。后续对话不需要用户重复Agent也会优先查询记忆库再触发知识库检索。很多人觉得记忆应该交给大模型的长上下文这个思路不现实。长上下文不是不好但会让模型把精力分散在历史信息上而且Token消耗很恐怖。更好的做法是“总结式记忆”每轮对话结束后用模型生成一段简洁的摘要存起来需要时再把摘要放回上下文窗口。我还踩过一个坑把记忆库和知识库用同一个向量库结果检索时经常混入历史对话内容导致答案张冠李戴。后来我把两个向量库物理隔离或用不同的collection区分问题立刻解决。这也是为什么我反复强调架构边界要清晰的原因。5. 从0到落地增强版知识库的完整搭建过程5.1 技术栈和目录结构我给的参考实现基于Python 3.10核心依赖是LangChain、Milvus、BGE embedding和Qwen模型接口也可以用OpenAI兼容接口。代码组织如下agent-knowledge-base/ ├── config.yaml ├── ingest.py # 文档解析、清洗、分块、向量化 ├── retriever.py # 混合检索 重排序 ├── tools.py # 封装检索工具 ├── agent.py # Agent循环与工具调用 ├── memory.py # 会话记忆与长期记忆 ├── eval_set.jsonl # 评测问题集 └── run.py # 启动入口5.2 第一步构建知识库索引库我把文档解析、分块、向量化放在ingest.py里完成。这个脚本要能重复执行而且最好支持增量更新。from langchain.document_loaders import DirectoryLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain.embeddings import HuggingFaceEmbeddings from langchain.vectorstores import Milvus loader DirectoryLoader(./docs, glob**/*.md, show_progressTrue) docs loader.load() # 自定义清洗: 去掉页眉、忽略空行、合并短段落 cleaned_docs clean_documents(docs) # 语义段落合并 长度切分 grouped_chunks group_by_semantic_sections(cleaned_docs) splitter RecursiveCharacterTextSplitter( chunk_size512, chunk_overlap64, ) chunks splitter.split_documents(grouped_chunks) # 为每个chunk补充标题路径前缀 for chunk in chunks: chunk.page_content f[{chunk.metadata.get(heading_path, )}] {chunk.page_content} embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-large-zh-v1.5, encode_kwargs{normalize_embeddings: True}, ) vector_store Milvus.from_documents( documentschunks, embeddingembeddings, collection_namekb_docs, connection_args{host: localhost, port: 19530}, )这里有一个关键细节给chunk补充[层级标题]前缀之后召回效果提升明显但也会占一些token。如果分块后的chunk普遍偏长可以只保留一级和二级标题没必要把整条路径全放进去。5.3 第二步实现混合检索器retriever.py中我封装了向量检索、关键词检索和重排序。关键词检索我直接用了Elasticsearch的BM25也可以使用SQLite FTS5做轻量替代。重排序模型用bge-reranker-base。class HybridRetriever: def __init__(self, vector_store, es_index, reranker_model): self.vector_store vector_store self.es_index es_index self.reranker_model reranker_model def retrieve(self, query: str, top_k: int 5): vector_results self.vector_store.similarity_search( query, ktop_k ) keyword_results self.es_search(query, ktop_k) merged self._rbf_merge(vector_results, keyword_results) if self.reranker_model: merged self.reranker_model.rerank(query, merged, top_ntop_k) return merged注意_rbf_merge我用了RBF分数融合避免两种检索结果数量级不一致。简单加权其实也能用但RBF方法更平滑尤其是向量分数分布很密集的时候加权容易让关键词检索完全失效。这个细节没有写在任何官方文档里是我自己调试时的发现。5.4 第三步Agent循环与工具调用agent.py是核心我用了最简单的ReAct风格实现不依赖重型agent框架。核心思想就是“不断把工具结果喂给模型让模型决定下一步”。messages [{role: system, content: SYSTEM_PROMPT}] messages.append({role: user, content: user_question}) # 会话记忆回填 if session_memory: messages[0][content] \n\n历史摘要 session_memory for step in range(MAX_ITERATIONS): response llm.chat(messagesmessages, tools[search_knowledge_base_schema]) msg response.choices[0].message if not msg.tool_calls: answer msg.content break messages.append(msg) for tool_call in msg.tool_calls: if tool_call.function.name search_knowledge_base: args json.loads(tool_call.function.arguments) results retriever.retrieve(args[query], args.get(top_k, 5)) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(results, ensure_asciiFalse), }) # 保存本轮摘要到记忆库 session_memory summarize(messages)在一个完整测试里用户问“我们的服务用的是哪个数据库版本最新的兼容性要求是什么”上面的循环实际执行了3次检索第一次找到技术栈文档第二次翻到数据库版本说明第三次检索了客户端兼容性矩阵。这就是增强版的正常表现。5.5 第四步评测集与基线指标最后也是最重要的一步构建评测集。你不需要几十万条测试数据按知识库的实际场景做20到50个问题就够了。我的评测集是JSONL格式每条包括问题、涉及文档、期望答案要点、难度等级。{question: 连接池耗尽时应该先检查哪些参数, expected_points: [数据库连接池大小, 并发请求数, 超时时间], difficulty: medium} {question: 如何查看服务运行日志, expected_points: [日志路径, 日志级别, tail命令], difficulty: easy}评测时我做了两件事一是看Agent最终回答是否覆盖期望要点二是看检索召回结果中是否包含正确的chunk。这两个指标分开看能快速定位是检索层问题还是Agent调用问题。我记得第一次评测结果惨不忍睹easy问题准确率大概85%medium只有45%困难问题几乎全军覆没。后来发现是分块策略过于机械很多相关段落被切散。改进分块并增加混合检索后medium准确率提升到了70%以上。所以评测集不是摆设它真的能帮你量化每一次改动到底有没有效果。6. 过程中踩过的坑和排查思路6.1 召回为空或召回结果完全无关这种问题90%出在embedding模型与文本语言不匹配或者查询描述太短。排查顺序如下先检查query是否太口语化、信息量太少比如“怎么搞”这种问题需要先让Agent把query改写为“部署环境nginx配置报错 排查方法”。再检查chunk是否做了标题前缀没有前缀的情况下上下文不完整很容易导致向量偏移。最后检查切分粒度是否太大。chunk超过1000字向量语义就会被稀释建议保持常用的500字上下。我曾经处理过一个工单系统知识库用户问“登录失败”文档里写的是“会话超时”纯向量检索始终召回不到。后来加入BM25关键词检索后虽然“登录失败”和“会话超时”也不同但BM25能在更大范围内匹配到“超时”相关文本挽回了部分召回。6.2 Agent陷入无意义的工具调用循环常见表现是Agent反复调用同一个工具每次都传入不同的query但每次拿到的结果都不满意于是一直绕圈。这个问题有两个诱因一是工具返回结果太差Agent没有足够信息终止二是模型不知道何时应该停止。我的解决办法是加上迭代和终止条件MAX_ITERATIONS 5 MIN_RETRIEVAL_SCORE 0.3在工具返回时除了chunk内容还附上一个检索得分。当连续两次检索的最大得分都低于阈值时Agent会生成“知识库中没有足够信息”的答复而不是继续空转。这个方法简单有效比硬性规定调用次数更优雅。6.3 上下文窗口被塞满回答质量下降知识库检索通常一次返回5个chunk一个chunk平均300字就是1500字左右。但如果Agent执行多次检索累积的上下文会非常巨大。我第一次实测时一个问题最终带了8轮工具结果上下文达到8000多字模型开始把不同来源的矛盾信息混在一起。后来我做了两个优化每轮工具结果只保留前N个chunk默认3个其余截断。在最终生成答案前对多轮检索结果再做一次“聚拢”。用一句话概括每个工具结果的核心内容而不是把全文丢给模型。实现上很简单就是在工具结果返回前先让大模型生成一段不超过50字的摘要。这个summarize步骤虽然增加了一次模型调用但能节省后续大量token同时避免上下文污染。6.4 知识库更新之后旧信息仍然被召回工程上很容易忽略的一点。文档改版后旧的chunk和新的chunk会同时存在。如果目标文档说“超时时间为5秒”改版后变成“超时时间为10秒”向量库可能同时召回这两个文本Agent就不知道该信哪个。我的方案是给每个chunk写入版本号和生效时间工具返回结果时带上元数据。如果同一主题出现两个版本Agent可以依据版本号优先选择最新内容或者明确告知用户“检索到不同版本请确认使用哪个”。这听起来简单但实际在知识库管理中特别有用。结合内容更新频率还可以定期清理超过N个版本的chunk避免向量库膨胀。7. 最后一个建议先做减法再做加法很多人在接触Agent之后第一反应就是堆能力加多Agent协作、加复杂的长期记忆、加一堆技能插件。从我的实践看增强版智能知识库的基础是“能稳定回答80%的常规问题”而不是“让模型看起来什么都会”。我最后给团队的建议永远是先搭建一个最小闭环文档处理链路、混合检索、重排序、一个会调工具的Agent把这四件事做到可控再考虑加记忆、加多Agent、加自动评测。我自己现在的这套增强版知识库文档量大概200份评测集70个问题整个运行占用的资源也不算高。它最让我满意的一点是终于可以在用户追问“那这个和前面那个参数有什么关系”的时候给出真正结合上下文的回答而不是又被当成一个全新的问题重新检索一遍。这次实践也让我更确信一件事Agent再怎么强大最后拼的还是数据工程的基本功。把文档清洗干净、把分块策略调对、把检索质量把关再让Agent在这套干净的基础上做规划效果远比直接往Agent框架里塞原始文档好得多。如果你的知识库也在往“增强版”方向走我建议你先别急着跑模型回去看看你的分块和召回管线把地基打好Agent自然会给你惊喜。
RELATED READING

延伸阅读

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