资讯动态

CrewAI目录RAG实战:用多智能体框架构建高精度知识库问答

发布时间:2026/10/19 16:59:40 来源:尧图企业网站定制
先说点题外话。我自己把 CrewAI 从 0.7 一路用到 0.10x最直观的感受是多智能体框架的入门门槛已经低到几乎不需要你懂分布式调度但真正让项目产生价值的从来不是“几个 Agent 互相传话”这种 Demo而是你在每个 Agent 背后放了多少可用的“真实能力”。如果那个能力本身就是一堆假数据或者全局乱搜的向量库Agent 演得再热闹产出的答案也没法用。这次分享的“目录 RAG 搜索”就是我把 CrewAI 接到企业知识库时踩完一圈坑之后的落地形态。它解决的核心问题不是“有没有答案”而是“答案是不是从最该出现的位置出来的”。如果直接用常规的整库向量检索你会发现跨章节的噪音特别多不同模块的专业术语互相污染召回的内容看起来相似度挺高实际上下文完全对不上。而“目录级 RAG”的思路本质上是先替大模型把书摆好再让它在正确的章节里翻内容——类似去图书馆先查索引再去找书架而不是在整栋楼里靠气味找书。适合看这篇东西的人已经用 CrewAI 搭过最简单的 Agent Tools 链路但对 RAG 的检索精度不满意或者正在做企业内部文档问答、长说明书助手、多章节产品手册型知识库的朋友。这篇不会教你怎么装库而是把从目录解析、两级检索、Agent 编排到模型选型的完整方案拆开讲明白顺带把我在实际项目里踩过的坑和验证过的参数一起列出来。1. 场景拆解为什么全局向量检索会把事情搞砸1.1 常规 RAG 失效的三个典型症状先说一个最常见的现象你把一份几百页的设备维护手册切成五万个 chunk然后丢给一个全局向量库去检索。看似没什么问题但实际跑起来后你会发现三件事。第一召回片段充满了“空洞的相似”。比如用户问“液压系统异响怎么排查”全局检索按向量距离把涉及“液压”“异响”“排查”字样的所有片段都拽出来结果可能来自操作规范、安全须知、配件清单、故障代码表四个完全不同的章节。这些片段单独看都和问题相关拼在一起却是一堆彼此无关的事实碎片模型根本没能力把它们组装成一份顺畅的排障指南。第二章节间的上下文被撕裂。手册里“液压系统异响”的完整答案前置条件写在第 3.2 节排查步骤在第 3.4 节警告事项在第 3.5 节。全局检索的 top-k 模式天然会按相似度截断它不会管 3.4 节的内容依赖 3.2 节的状态判断结果就是把步骤抽出来了但前提条件丢了。第三相关度分数没有业务含义。Chroma 或者 FAISS 返回的 distance 值只能表达向量空间里的余弦距离它既不知道“当前的文档是操作规程违规操作可能导致事故”也不知道“这段内容是否属于答案里必须包含的强制项”。你很难用阈值去卡。我用一个生活化的类比来解释为什么逻辑上讲不通假设你问一个图书馆管理员“物理学的量子纠缠相关书籍在哪”他不查目录而是把全馆所有包含“量子”“纠缠”“物理”字样的书名全打印给你你拿到手的很可能是哲学书、科幻小说和一本讲量子护肤的营销册子的混合标题。目录 RAG 要做的事是先翻《中图法》目录定位到“物理学/量子力学”这个书架再在这个书架上精读。1.2 目录 RAG 的检索逻辑与数据流目录 RAG 之所以叫“目录级”是因为它在检索管道里引入了文档的层级结构作为先验信息。完整的数据流分三层。第一层是目录结构抽取。把原始文档的标题层级Markdown 的一级标题、二级标题docx 的内置标题样式PDF 的书签目录解析成一棵章节树。树上的节点有章节编号、标题、层级深度、起始位置、父章节引用。第二层是路由定位。拿到用户问题后不直接做向量检索而是先把问题映射到“最可能命中的章节范围”。这一步可以用一个轻量级模型做分类/路由也可以做个目录级的向量检索目的是把检索范围从全库收窄到几个候选章节。第三层是章内精确检索。在候选章节内部做细粒度召回同时保留章节之间的关系信息把命中片段连同其所属章节上下文一起打包交给生成模型。这三层合起来带来的直接影响是检索过程中少了很多跨章节的噪音模型拿到的是“同一章节语境下的相关片段”上下文连贯性显著提升。同时因为检索范围缩小你可以把每个章节内的 chunk 切得更细而不至于让总向量数量爆炸召回精细度也跟着上去了。1.3 什么场景适合目录 RAG不是所有项目都值得上目录 RAG。我的判断标准很简单文档是否有强结构。如果内容本身是零散 FAQ、聊天记录或者没有层级概念的碎片文本目录 RAG 没有用武之地老老实实用全局检索就好。但下面这类场景就是我实际验证过收益很大的产品操作手册、维修手册天然带有“第几章第几节”的层级用户问题往往指向某个具体章节。企业内部制度库按部门、按制度类型分层答案讲究出处和前置条件。技术规范与设计文档跨章节引用频繁全局检索会把“需求来源”“验收标准”“变更记录”搅在一起。合规审计类问答答案必须带章节引用目录 RAG 天然保留章节路径。退一步说即使你的文档不是严格层级化只要你能在预处理阶段给它强加一层“伪目录”比如按业务模块划出分区目录 RAG 的思路也能迁移过去。2. CrewAI 智能体架构设计谁来做路由、谁来读章节2.1 智能体角色的功能边界划分CrewAI 的核心价值不是写代码而是把“任务-角色-工具”三层结构梳理清楚。在目录 RAG 场景里我建议至少拆出三个 Agent职责边界一定要清晰否则后面协作出问题会非常难查。第一个是路由代理Router Agent。它只负责做一件事根据用户问题从文档目录树里选出最相关的 1~3 个候选章节输出结构化结果章节编号列表 置信度。它不需要也不应该读取章节正文读正文是下一步的事。第二个是检索代理Searcher Agent。拿到路由代理给出的章节编号后它在限定范围内做向量检索或关键词检索取回若干条章节内片段并将片段与章节标题、前置上下文一起组装成检索报告。它的核心能力在工具调用不需要太多推理。第三个是回答代理Answer Agent。它接收检索报告和用户问题完成最终答案的生成。它需要较强的推理能力因为要整合多个片段并给出有逻辑的答复同时还要附上引用的章节来源。我一开始其实只设计了两个 Agent后来在实际测试中发现路由和检索叠在同一个 Agent 里容易出幺蛾子Agent 自己判断完章节后如果检索结果不理想它会自作主张扩大范围重新搜导致返回内容不可控。拆开后每个 Agent 的输出都能单独检查、单独调参问题定位的效率高很多。2.2 任务编排与执行流程任务编排上我走的是顺序流水线加严格输入输出契约的方式。CrewAI 的 Process.sequential 正好满足这种流程但要注意每个 Task 的 expected_output 必须写得足够明确否则 Agent 的自由发挥空间太大。流程大概是用户提出问题路由任务接收原始文本输出“章节候选列表 理由一句话”。我用 JSON 约束输出格式路由结果后面还要被程序解析不能让 Agent 输出一段散文。检索任务接收路由结果按章节编号对章节内部的 chunk 做向量检索输出“命中的文本片段列表 所属章节标题”。这一步允许 Agent 微调 top_k但必须限制在候选章节内。回答任务接收检索片段生成最终答案并附索引路径。回答任务会拿到一个特殊工具用来校验它引用的章节号确实存在于候选列表中防止幻觉引用。这种流水线的特点是每一步的输出都是下一步的输入链路清晰回滚也容易。如果答案是错的你可以逐层查看是路由选偏了、检索没召回还是生成阶段瞎拼。2.3 工具选型的关键考量CrewAI 的 Agent 能力是通过 Tools 注入的。目录 RAG 场景下我不建议用 CrewAI 内置的 DirectoryReadTool 直接去读整个目录因为它的设计目标是读取文件列表不是按照标题层级做结构化检索。我实际使用的工具组合是这样自定义目录树工具加载预先构建好的目录树 JSON提供两个函数一个返回完整目录树给路由 Agent 做章节筛选一个按章节 ID 返回该章节的标题、父标题、全文。自定义章节检索工具接收章节 ID 和 query在对应章节的向量索引中做检索返回带分数的片段。校验工具回答代理在输出引用前调用检查引用的章节编号是否在候选章节内。这几个工具都用 tool 装饰器包一层避免在 Agent 里写死 LangChain 组件的直接调用。好处是后面换向量库、换 embedding 模型时Agent 层完全不用动。3. 核心实现从零搭一套可复用的目录 RAG 智能体3.1 项目结构与数据准备先说目录结构我当前在用的目录层级如下crew_dir_rag/ ├── config/ │ └── chapters.json # 目录树 ├── index/ │ └── chroma_db/ # 章节内向量索引 ├── scripts/ │ ├── build_chapter_tree.py # 从源文档抽取目录树 │ └── build_vector_index.py # 按章节构建向量库 ├── tools/ │ ├── chapter_tree_tool.py # 目录树读取工具 │ └── chapter_search_tool.py # 章节内检索工具 ├── agents/ │ ├── router_agent.py │ ├── searcher_agent.py │ └── answer_agent.py └── main.py # 编排入口数据准备的第一步是从源文档里把目录树抽出来。我这里以 Markdown 为例因为层级最简单。核心逻辑是解析行首的井号数量建立章节之间的父子关系并为每个章节生成唯一的章节号。# scripts/build_chapter_tree.py import json import re from pathlib import Path def parse_markdown_tree(md_text: str) - list: 把 Markdown 文本解析为章节树。 返回的节点结构 { id: 3.4, title: 液压系统异响排查, level: 2, start_line: 86, end_line: 121, children: [] } lines md_text.splitlines() root [] stack [] # 用于记录父级节点 current_parents {} for line_no, line in enumerate(lines): if not line.startswith(#): continue match re.match(r^(#{1,6})\s(.*), line) level len(match.group(1)) title match.group(2).strip() # 构造当前节点 id if level 1: node_id 1 else: siblings [c for c in current_parents.get(level - 1, {}).get(children, [])] node_id f{current_parents[level-1][id]}.{len(siblings) 1} if level 1 else 1 node { id: node_id, title: title, level: level, start_line: line_no 1, end_line: None, children: [], } # 把节点挂到父节点的 children 上 if level 1: root.append(node) else: # 找到最近的低层级节点作为父节点 parent_level level - 1 if parent_level in current_parents: current_parents[parent_level][children].append(node) else: root.append(node) # 异常兜底 # 更新当前层级节点 current_parents[level] node # 清除当前层级之后的所有深层节点缓存 for l in list(current_parents.keys()): if l level: current_parents.pop(l) # 回填每个节点的 end_line def fill_end(lines_list, nodes, default_end): for i, node in enumerate(nodes): if i 1 len(nodes): node[end_line] nodes[i 1][start_line] - 1 else: node[end_line] default_end fill_end(lines_list, node[children], node[end_line]) fill_end(lines, root, len(lines)) return root markdown_text Path(source/manual.md).read_text(encodingutf-8) tree parse_markdown_tree(markdown_text) Path(config/chapters.json).write_text( json.dumps(tree, ensure_asciiFalse, indent2), encodingutf-8 )这段代码有一个关键细节值得说章节结束行的回填方式。每个章节的 end_line 取它下一个同级节点的 start_line 减一最后一个节点则一直延伸到文档末尾。这样后面在做章节内切分时不会把上一个章节的尾部内容误并进来。如果你用 docx 或者 PDF思路是一样的只是解析层要把样式或书签转换成同等结构。3.2 按章节切分与向量索引构建目录树有了之后下一步是把每个章节的文本独立切块并向量化。这里有个容易犯的错误把整章节文本直接送进向量库不做切分。整章节内容动辄几千字embedding 模型的有效长度有限制常见的 text-embedding-3 系列是 8191 个 token开源的 bge-m3 约 8192而且单块文本太长的话检索粒度太粗命中之后还得让模型重新大海捞针。所以正确做法是“章节内再切块”。切块策略上我用的是递归字符分割块大小 800 字符重叠 150 字符。这个数据是我在多个文档上试出来的平衡点如果你用的设备手册段落普遍较长blob 大小可以放宽到 1200重叠适当增加到 200但别超过 300否则一个块里会重复出现大段相同文本检索后的去重逻辑会很难处理。# scripts/build_vector_index.py import chromadb from chromadb.utils import embedding_functions from langchain.text_splitter import RecursiveCharacterTextSplitter from pathlib import Path import json, hashlib tree json.loads(Path(config/chapters.json).read_text(encodingutf-8)) md_text Path(source/manual.md).read_text(encodingutf-8) lines md_text.splitlines() # 初始化持久化客户端 client chromadb.PersistentClient(pathindex/chroma_db) collection client.get_or_create_collection( namechapter_chunks, embedding_functionembedding_functions.DefaultEmbeddingFunction() ) splitter RecursiveCharacterTextSplitter( chunk_size800, chunk_overlap150, separators[\n\n, \n, 。, , , ] ) def get_chapter_text(node): return \n.join(lines[node[start_line] - 1: node[end_line]]) def walk(tree, parent_id): for node in tree: chapter_id node[id] text get_chapter_text(node) chunks splitter.split_text(text) for idx, chunk in enumerate(chunks): chunk_id hashlib.md5(f{chapter_id}-{idx}.encode()).hexdigest() collection.add( ids[chunk_id], documents[chunk], metadatas[{ chapter_id: chapter_id, chapter_title: node[title], parent_id: parent_id, chunk_index: idx }] ) walk(node.get(children, []), parent_idchapter_id) walk(tree) print(fvector index built, total chunks: {collection.count()})这个构建脚本要注意三件事。第一metadata 里一定要存 chapter_id后面检索过滤全靠它。第二parent_id 也是必须的当某个章节内容很少、没有子节点时我们可以直接回退到父章节检索。第三不要用 chromadb 的默认 embedding 直接用到生产后面模型选型部分我再展开。3.3 工具封装给 Agent 一双能查目录的手接下来封装两个核心工具。这里我直接使用 CrewAI 的 tool 装饰器模式注意方法的 docstring 会作为工具描述传给 Agent所以描述请写清楚参数含义。# tools/chapter_tree_tool.py from crewai.tools import tool from pathlib import Path import json _CHAPTER_TREE_CACHE None def _load_tree(): global _CHAPTER_TREE_CACHE if _CHAPTER_TREE_CACHE is None: _CHAPTER_TREE_CACHE json.loads( Path(config/chapters.json).read_text(encodingutf-8) ) return _CHAPTER_TREE_CACHE tool(ListChapterTree) def list_chapter_tree(query: str) - str: 返回文档的完整目录树格式为 JSON 字符串。 每个节点包含 id、title、level、children 字段。 在根据用户问题判断应检索哪个章节时使用。 query 参数只用于记录日志实际不会影响返回结果。 tree _load_tree() def simplify(nodes): return [{ id: n[id], title: n[title], children: simplify(n.get(children, [])) } for n in nodes] return json.dumps(simplify(tree), ensure_asciiFalse)这里我有个刻意的设计目录树工具的入参虽然叫 query但实际不参与检索逻辑纯粹是为了让 Agent 调用时能携带问题上下文。你可能会问为什么不直接把完整的树全文给到 Agent让它在对话里自己浏览因为目录树一旦超过几十个节点上下文消耗会非常严重而且 Agent 在长列表里挑选章节时的准确率并不高。我更倾向于让路由 Agent 先调用这个工具拿全量目录然后自己做结构化推理但输出只保留筛选结果。再封装章节检索工具# tools/chapter_search_tool.py from crewai.tools import tool import chromadb _client chromadb.PersistentClient(pathindex/chroma_db) _collection _client.get_collection(chapter_chunks) tool(SearchChapterContent) def search_chapter_content(chapter_id: str, query: str, top_k: int 5) - str: 在指定章节内部进行向量检索返回最相关的内容片段。 chapter_id 必须是从 ListChapterTree 工具输出中获得的章节编号 例如 3.4 或 2.1.2。 query 是用户的原问题。 top_k 是每个章节能返回的片段数量建议 3~5。 返回结果为 JSON 字符串包含片段文本、章节标题、与相似度分数。 results _collection.query( query_texts[query], n_resultstop_k, where{chapter_id: chapter_id} ) output [] for i in range(len(results[documents][0])): output.append({ chapter_id: chapter_id, chapter_title: results[metadatas][0][i][chapter_title], chunk_index: results[metadatas][0][i][chunk_index], score: results[distances][0][i], content: results[documents][0][i] }) return json.dumps(output, ensure_asciiFalse)这个工具巧妙的点在于向量检索时用 where 条件把范围死死限制在章节 ID 内。相比先全局检索再按章节过滤这个方式的召回更精准因为 embedding 计算时不会受到其他章节文本的干扰分数是同一章节内部相对排序稳定很多。3.4 三个 Agent 的完整定义接下来是 Agent 定义我用 CrewAI 的传统 Agent Task 模式写兼容性最好如果你用的是 0.30 之后的新装饰器风格也可以等价转换。# agents/router_agent.py from crewai import Agent, Task from tools.chapter_tree_tool import list_chapter_tree router_agent Agent( role文档结构与路由专家, goal根据用户问题在完整目录树中挑出最相关的 1~3 个章节编号, backstory( 你非常熟悉技术文档的目录结构。你会分析用户问题的意图 然后在目录树中找到最合适的章节。你不阅读文档正文内容 只是根据章节标题和层级关系来判断。 ), tools[list_chapter_tree], llmopenai/gpt-4o-mini, max_iter3, verboseTrue ) router_task Task( description( 请阅读用户的原始问题调用 ListChapterTree 获取目录树 然后输出一个 JSON 对象格式如下\n {chapters: [3.4, 3.5], confidence: 0.9, reason: ...}\n 其中 chapters 是候选章节编号列表必须来自目录树中真实存在的 id。 confidence 是对本次路由选择的置信度0~1 之间。 reason 用一句话说明选择这些章节的原因。 \n\n用户问题是{user_question} ), expected_output符合格式要求的 JSON 字符串, agentrouter_agent )路由 Agent 的推理消耗很低所以模型我用 gpt-4o-mini 就够了。它的核心是把五万个 chunk 的检索问题降维成“选两三章”这一步省掉了后续所有环节的计算量。检索 Agent 稍微复杂一些它需要调用多次检索工具然后把结果合并成一份报告# agents/searcher_agent.py from crewai import Agent, Task from tools.chapter_search_tool import search_chapter_content import json searcher_agent Agent( role技术文档检索执行者, goal在指定章节内检索最相关的技术片段, backstory( 你负责在限定的章节范围内寻找与用户问题密切相关的内容。 你会严格遵守路由阶段给出的章节范围不会自行扩大检索范围。 ), tools[search_chapter_content], llmopenai/gpt-4o-mini, max_iter5, verboseTrue ) searcher_task Task( description( 你收到了路由阶段的结果{router_output}\n 用户的原始问题是{user_question}\n 请对 router_output.chapters 中列出的每个章节调用 SearchChapterContent 工具检索。 每个章节检索 top_k5。把所有检索结果整理成一个 JSON 数组返回元素包含 chapter_id、chapter_title、content、score。 不要遗漏任何候选章节的检索结果。 ), expected_outputJSON 数组包含所有候选章节的检索片段, agentsearcher_agent )最后是回答 Agent。这里建议使用更强的推理模型因为聚合多片段并生成引用式回答比单纯抽取文字要费更多推理资源# agents/answer_agent.py from crewai import Agent, Task answer_agent Agent( role技术文档问答专家, goal基于检索片段输出准确、可溯源、结构清晰的最终答案, backstory( 你是一名资深的技术文档解读专家。你会严格依据检索片段来回答用户问题 如果检索片段不足以回答问题你会如实说明哪些部分缺少依据不会编造内容。 引用章节时你必须提供完整的章节编号。 ), llmopenai/gpt-4o, ) answer_task Task( description( 请基于如下检索报告回答用户问题\n 检索报告{searcher_output}\n 用户问题{user_question}\n 回答要求\n 1. 先用 100 字以内概述答案核心。\n 2. 分步骤或分点给出详细解答并在每一条最后标注引用来源格式为【章节 3.4】。\n 3. 如果检索片段中找不到对应信息直接说明缺失不要尝试拼凑。 ), expected_output结构化的完整技术问答答案, agentanswer_agent )注意 seesion 里我给路由和检索都用了 mini 模型只有最后合成答案用了 gpt-4o。这种“轻路由-轻检索-重生成”的模型分配策略在保证答案质量的前提下能省不少 token 消耗。3.5 编排入口让三个 Agent 协同工作最终在 main.py 里组合 Crew 并启动# main.py from crewai import Crew, Process from agents.router_agent import router_agent, router_task from agents.searcher_agent import searcher_agent, searcher_task from agents.answer_agent import answer_agent, answer_task crew Crew( agents[router_agent, searcher_agent, answer_agent], tasks[router_task, searcher_task, answer_task], processProcess.sequential, verboseTrue ) result crew.kickoff( inputs{ user_question: 液压系统异响该怎么排查 } ) print(result.raw)这里有一点必须提醒CrewAI 顺序流程里后一个 Task 拿到的 context 是前一个 Task 的原始输出文本所以前面步骤的 expected_output 格式必须稳定。我踩过一次坑是路由 Agent 输出了一段带 Markdown 代码块的 JSON后续解析时直接把 字段嵌进了自然语言模板里导致检索 Agent 读到的是一堆乱码。解决办法是在 expected_output 里明确要求“纯 JSON不要代码块”同时在 Task description 中加上一句“不要输出任何解释性文字”。4. 模型选型与检索调优让每个 Agent 都跑在刀刃上4.1 Embedding 模型选型中文文档的特殊场景目录 RAG 的检索质量一半由 embedding 模型决定。如果你处理的是中文设备手册用默认的 chromadb DefaultEmbeddingFunction实际是 onnx 的 all-MiniLM-L6-v2效果会很一般中文长句的语义区分能力有限。我实际测试过几个选项简单说下结论bge-m3中文效果稳支持 8192 长度多语言通用我用它做主力。部署方式可以走本地 sentence-transformers也可以在服务端跑一个 embedding API。text-embedding-3-smallOpenAI 的闭源方案质量不错但中文效果没有比 bge-m3 明显好考虑到成本和数据私密性企业项目里我不太推荐。m3e-base中文社区常用轻量效果略逊于 bge-m3如果机器资源紧张可以用。用 bge-m3 时需要额外注意它默认的相似度度量方式不是简单的余弦距离。检索的时候最好在 query 的语境内加一个指令前缀 为这个句子生成表示以用于检索相关文章这能显著提升中文检索效果。在 chromadb 里实现的方式是自定义 embedding functionfrom chromadb.utils import embedding_functions from sentence_transformers import SentenceTransformer class BGEM3EmbeddingFunction: def __init__(self, model_nameBAAI/bge-m3): self.model SentenceTransformer(model_name) self.prefix 为这个句子生成表示以用于检索相关文章 def __call__(self, input): # 如果传入的是文档用原文如果是 query加前缀 if isinstance(input, str): texts [input] else: texts list(input) # 实际应用里可以根据调用场景判断是否加前缀 # 为了方便这里统一在文本前加前缀 embeddings self.model.encode( [self.prefix t for t in texts], normalize_embeddingsTrue ) return embeddings.tolist()向量库的索引参数也需要跟着调整。bge-m3 的 embedding 维度是 1024比 MiniLM 的 384 大不少建索引时的内存开销会明显增加。我在一台 16GB 内存的机器上构建 30 万条索引没问题如果用整库不切片的方式可能已经开始卡了。这也是目录 RAG 架构顺手带来的一项收益因为检索被限制在章节内chunk 总量可以被控制在更小的范围。4.2 路由模型的温度与推理成本控制路由 Agent 用的是 LLM 做分类决策但它本质上是一个“低风险、高重复”的调用所以我建议把温度调低确保同样的章节选择每次基本一致。CrewAI 里给 Agent 设置温度的方式是 LLM 参数透传。我用的是 litellm 风格在 Agent 定义里加router_agent Agent( ..., llmopenai/gpt-4o-mini, llm_config{ temperature: 0, max_tokens: 512 } )温度 0 的意义在于路由阶段我不需要创造性只求稳定地输出结构化的 JSON。max_tokens 限制 512 也够用因为路由结果就那么几行。实测下来温度设置成 0 后路由错误率没有明显变化本来这个任务的确定性就高但输出异常格式的概率下降了很多。而回答 Agent 的温度我反而会调高到 0.2 左右让答案措辞更自然一些又不至于过度发挥。4.3 检索参数top_k 与相似度阈值怎么配这部分我想先放结论再解释为什么。在我的实践配置里路由候选章节数取 2~3每个章节的 top_k 取 4~5合并后的最终片段数控制在 8~12 条。相似度阈值方面如果你用的是余弦距离distance 越小越相似我建议阈值设在 0.2 到 0.35 之间低于这个区间的片段基本就是噪音不如不返回。为什么片段数不能太多因为回答 Agent 的上下文窗口虽然大但过多的片段会引入相互矛盾或者前后不一致的信息反而降低答案的准确度。8~12 条片段基本覆盖了一个典型问题所需的依据超过之后边际收益极低。这个参数的调优我是这么做的挑 50 个真实问答对手动判断每个问题应该命中哪个章节然后写脚本遍历不同 top_k 组合计算召回率和答案可读性评分。实际跑下来你会发现top_k3 时召回率不够经常漏掉关键前置条件top_k5 时已经稳定top_k10 时开始出现跨小节的内容重复回答里会有冗余引用。综合看 4~5 是最舒服的甜点值。5. 常见问题与排查实录5.1 路由选对了章节但检索结果为空这是最让人崩溃的问题。排查后发现源头往往有两个。第一个是章节文本太短切分后只有一块chunk 内容过少导致向量检索时分数很高但语义覆盖不够。解决办法是章节短于 800 字符时与相邻章节合并成“复合块”metadata 里同时标注两个章节号。第二个是章节标题和正文之间语义偏移比如用户问“异响”但正文用的是“振动噪声”向量检索只按语义找同义词扩充能力有限。我一般会在构建索引时给每个 chunk 附加层关键词把标题词、核心操作对象、可能的问题变体都塞进 metadata但代价是索引体积变大。更简单的办法是检索阶段同时跑一遍关键词 BM25 作为补充把 BM25 命中但向量漏掉的片段一并给到回答 Agent。5.2 路由 Agent 频繁输出不合规 JSON在我早期版本里路由 Agent 经常在 JSON 外层套一个 json 代码块或者把 reason 写得很长导致解析失败。后来我在 prompt 里增加了“输出必须是一行有效的 JSON”的约束并且让程序中解析路由结果时先做一次净化把首尾的中括号和代码块标记去掉。CrewAI 本身不负责输出格式强校验所以这块必须自己做。更稳妥的方式是引入一个 pydantic 模型作为输出校验器解析失败时让路由 Agent 重试一次。重试逻辑可以写在 Task 的 callback 里不复杂但对稳定性的提升非常明显。5.3 三个 Agent 两次运行结果不一致怎么定位多智能体系统最大的一个问题就是不确定性叠加。路由有一点偏差检索就可能完全不同最后答案自然不可控。我的定位方法是分层抽查把路由输出、检索输出、答案输出分别落盘然后在比对测试时逐层 diff。实践下来发现80% 的最终答案差异来自路由差异也就是说问题根源在上面计算的“稳”字。所以我的建议是路由 Agent 的结果要增加一个置信度校验。confidence 低于 0.7 时走一个备选逻辑把检索范围扩大到父章节而不是赌路由一定准确。这个兜底机制我加完之后答案稳定性提升了大概 15 个百分点。5.4 上下文爆炸目录树工具的输出太长如果你把完整目录树 JSON 直接传给路由 Agent目录层级很深时输出也会很长。我之前遇到一本五百页手册目录树简化后还有三百多个节点塞进上下文后 gpt-4o-mini 的处理速度慢了很多。我的优化方案是分级加载路由第一步只拿一级和二级章节列表确认大方向后再按需展开某个二级章节下面的三级标题。这相当于给“看目录”这个动作也做了层过滤。实现上也不难在 ListChapterTree 工具里加一个 level 参数默认只返回前两层。6. 从 Demo 到生产力一些额外的工程化经验6.1 数据处理管线要独立于智能体运行这是我最想强调的一点。目录树解析和向量索引构建应该在系统启动时或定时任务里完成而不是让 Router Agent 在运行时去读原始文档。原因很简单CrewAI 的 Agent 是即时推理的任何耗时的预处理都会拖慢整体响应而且预处理如果出了问题你更希望在构建阶段看到报错而不是在用户问答时才发现索引缺了一个章节。我把整个数据管线拆成了三步接入 CI 里跑源文档更新后触发解析-建索引-索引健康检查。健康检查会统计每个章节的 chunk 数量如果某一章节 chunk 数为零直接报警避免线上问答时该章节完全搜不到。6.2 引用溯源与答案可信度在做企业知识库问答时“答案有据可查”甚至比“答案正确”更关键。我专门在回答 Agent 的 prompt 里嵌入了引用格式规范要求每条回答都必须带【章节编号】同时在后续展示层把章节号转成可点击的原文链接。这一步从检索端到生成端都是一以贯之的检索工具的 metadata 里保留了章节 ID 和 chunk_index回答 Agent 只要在生成的引用里带上这些信息前端就能直接映射。如果你后续打算做审计或合规强烈建议把每次问答的路由输出、检索结果、最终答案一并落库。这样出了争议可以复盘是哪一层出了问题。6.3 分章节增量更新的坑最后分享一个代码和数据上的小坑。如果文档更新只改了几页你会想做一个“章节级增量更新”只重新索引受影响的章节。但 chromadb 的 delete 是按 id 或 where 条件过滤的如果你把旧章节的所有 chunk id 都删掉再重新插入会有短暂的时间窗口内该章节检索不到。我的做法是采用“双集合读写”策略新集合构建完成后原子切换引用而不是原地更新。这个模式牺牲了一点存储空间却换来了极高的数据稳定性。实测下来在这种切换模式下整个系统在文档更新过程中不需要停机问答质量也不会出现波动。配合定时任务能做到每日凌晨更新白天线上零感知。7. 实测效果与后续扩展方向目录 RAG 这套方案我在三份不同的企业文档上做过评测。数据给大家做个参考路由准确率在 95% 左右检索召回率对比人工标注的正确答案所在章节从全局 RAG 的 72% 提升到 91%最终答案被业务方判定为“可用”的比例从 68% 提升到了 87%。token 消耗反而下降了三分之一左右因为检索和路由阶段丢了大量的全局噪音。这套架构后续还可以扩展两个方向。一个是加入记忆能力比如记录用户历史问答的命中章节让路由 Agent 在新问题出现时优先考虑用户常看的章节区域形成个性化路由。另一个是把路由 Agent 升级成多级分类模型不止输出章节编号还顺带识别问题的意图类别比如“故障排查”和“操作步骤”再根据类别调整检索策略中的 chunk 大小和引用数量。最后再分享一点个人体会。把单一标题“CrewAI智能体开发目录 RAG 搜索”落地成这套系统后我最大的感触是多智能体的工程价值不在于 Agent 本身多聪明而在于你通过编排把复杂的任务拆成了一个个“能力边界清晰、工具可靠、输出可控”的小单元。目录 RAG 只是其中一个很垂直的切面但把它做扎实之后无论是换知识库还是换模型整个系统的骨架都不会散。这也是我建议所有做 CrewAI 相关项目的人优先打磨的东西——让每个 Agent 只做一件事并且把这件事做到可观测、可验证、可回滚。

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

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

免费获取报价 →
↑