只要你真在 CrewAI 里搓过智能体大概率会撞上这堵墙模型推理再强也读不到你本地目录里的那些文档。目录 RAG 搜索要解决的正是“让智能体先翻资料、再开口回答”这个闭环——把某个文件夹下的文档变成可检索的知识库让 Agent 在回答前先去向量库里捞相关内容再基于检索结果组织答案。这项目拿来练手特别合适既能摸清 RAG 链路里每一个环节的脾气又能把 CrewAI 的 Tool、Agent、Task、Crew 这套编排逻辑完整跑通适合刚接触智能体开发、或者已经跑通过 Hello World 但想加一点真实工程感的开发者。我把整个实现拆成了四块为什么在 CrewAI 里做目录搜索要这么设计、RAG 链路里每个环节怎么选参数、代码怎么在工具类里把检索封装成智能体可调用的能力、以及我实测下来最坑的几个问题。下面直接进入正题。1. 项目拆解CrewAI 里为什么偏偏需要目录 RAG 搜索1.1 这个项目到底在解决什么单看“目录 RAG 搜索”六个字容易误以为就是调一个向量检索接口。但放在 CrewAI 的语境里它实际解决的是三个层次的问题第一模型知识有截止日期你没资格假设智能体知道你的项目文档里写了什么第二私域知识本地 README、接口说明、运维手册模型根本没见过你必须主动喂第三即使把文档塞进上下文CrewAI 的多智能体编排也需要一个“按需检索”的机制而不是每次对话都把全部文档 load 进 memory。我见过不少人第一版方案是直接把整个目录的文档拼进 prompt目录小的时候还能忍目录一涨上下文窗口立刻报警费用翻着倍涨而且无关内容会严重稀释模型对关键信息的注意力。RAG 的思路是反过来的——平时只维护索引提问时才去捞最相关的几段原文让上下文里永远只出现该出现的内容。用大白话讲前面的路是把一整本书都摊在桌上让模型自己找RAG 是让一个图书管理员先帮你翻到指定页码。1.2 为什么这件事要放在 CrewAI 里做单独写一个 Python 脚本也能做目录检索但放进 CrewAI 之后行为模式发生了本质变化检索不再是“你调一个函数”而是变成了智能体的一个能力。Agent 可以根据用户的问法自己决定要不要调用、什么时候调用、调用几次。举例来说用户问“配置文件的默认路径是什么”Agent 会先判断这属于文档类问题于是触发工具去向量库检索而不是硬靠模型瞎编。CrewAI 里这个机制的核心是 Tool。工具被绑定到 Agent 上Agent 在 Task 执行过程中会基于工具的描述自主决定“我现在需要查资料”然后调用它、拿到返回结果再写最终答案。目录 RAG 搜索就是把“检索本地目录文档”包装成一个标准工具让智能体在回答链路里变成先查后写的模式。这个模式的价值在于你可以同时安排一个负责检索的研究员 Agent和一个负责整理答案的写作 Agent让它们各干各的活。1.3 关于方案选型的几个备选对比我最初考虑过三条路线一是完全不引入向量库用关键词搜索grep在目录里硬匹配二是用 LangChain 的 QA chain 直接做三才是现在这套“CrewAI 自定义检索工具”的组合。关键词搜索的问题在于查询和文档用词不一致比如文档里写“存储库”用户问“仓库”grep 直接扑空而且 PDF、扫描件、多级子目录混在一起时纯文本搜基本没法用。LangChain 的 QA chain 倒是把 RAG 封装好了但它把检索和回答绑得太死你想让检索结果进 CrewAI 的 Agent 上下文、再让 Agent 自主决定怎么组织回答就得多绕一层。最后选的方案是自己写工具类里面封装 Chroma 向量检索逻辑工具的输入输出定义得清清楚楚Agent 调用起来既灵活又可控。这也是我建议你在类似项目里优先考虑的结构——检索是检索回答是回答中间层由智能体自己决策。2. 目录 RAG 的完整链路从文件扫描到向量召回2.1 第一步目录扫描与文档加载很多人一上来就写“遍历目录、读文件”觉得这一步不用动脑实际上目录 RAG 里一半的坑都在这里。你需要定义清楚哪些文件该被加载、哪些不该被加载。我的做法是维护一个白名单扩展名默认开启.md、.txt、.pdf、.docx其他的一律跳过二进制文件、图片、临时文件比如.tmp、.swp直接过滤掉。用pathlib递归遍历时还要处理几个隐藏问题目录里可能有符号链接导致的循环引用有需要忽略的构建目录node_modules、dist有编码不是 UTF-8 的文件。我实际写的时候会给每个文件套一层异常捕获读到失败就记录路径然后跳过绝不让一个坏文件把整个索引流程打断。from pathlib import Path def scan_directory(root_dir: str, allowed_suffixes: set) - list[Path]: files [] for p in Path(root_dir).rglob(*): if p.is_file() and p.suffix.lower() in allowed_suffixes: if any(part in {node_modules, dist, .git, __pycache__} for part in p.parts): continue files.append(p) return files如果你在大规模目录上做这件事建议再加一层“文件变更检测”只处理新增或修改过的文件否则每次全量重建索引会越来越慢。小目录无所谓目录到几百兆的时候增量更新就是刚需了。2.2 第二步分块策略——决定检索精度的隐形开关其实把文档切碎这件事远比你想象的更影响最后效果。切得太小比如每块 64 个 token一个完整的操作步骤被拦腰截断检索召回的信息缺胳膊少腿切得太大比如每块 2000 个 token一块里混了多个主题向量表示的语义被稀释检索结果自然也不精准。我实测下来的经验值通用文本用 512 token 一块、相邻块之间重叠 64~96 token能在“上下文完整性”和“语义聚焦度”之间拿到一个不错的平衡点。为什么需要重叠因为很多关键信息正好跨在切分边界上重叠区间相当于给边界内容留了一份副本避免漏检。抄一个可以直接用的切分代码用langchain_text_splitters的递归字符切分器就行from langchain_text_splitters import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size512, chunk_overlap80, separators[\n\n, \n, 。, , ], ) chunks splitter.split_text(original_text)需要注意的是中文文本的切分分隔符要专门调默认分隔符对英文友好对中文支持一般我上面的 separators 就是按中文逻辑排的优先级。如果你处理的是 Markdown 或 HTML还可以用按标题结构切分的 splitter让每个 chunk 自动带上标题作为上下文检索效果会更好一点。2.3 第三步Embedding 与向量库选型Embedding 模型是整个 RAG 链路里最影响“感觉对不对”的组件。面向中文文档我测试过 OpenAI 的 embedding 接口以及开源的bge-m3、m3e这类模型结论是开源小模型在通用中文场景下完全够用而且可以本地跑、不依赖外部接口。但同一个模型在不同领域的表现差异巨大你最好拿自己的真实文档做一轮小规模相似度评测——抽几组问题看检索出来的 Top 5 是不是你想要的内容。向量库方面目录规模在几万块以内我推荐直接上 Chroma它支持嵌入式运行数据持久化到本地目录API 简单写代码时心智负担很小。等你的索引超过几十万块再考虑 FAISS 这类高性能库或者上专门的向量数据库服务。用生活类比来说Chroma 是“自带储物柜的图书馆”适合个人和小团队用FAISS 是“只有书架的仓库”性能好但一切都得自己打理。建立索引时别忘了把元数据一并存进去这个太重要了至少要存source文件路径和chunk_index块序号最好再存一个文件标题。有了元数据检索结果才能标注出处回答时引用来源才有底气。2.4 第四步检索召回——别再闭眼设 Top K召回的参数没有标准答案网上动不动就写top_k5但实际用下来你会发现这个数字跟你的文档粒度、问题类型直接相关。我习惯把n_results设在 5~8然后在返回结果里保留相似度分数后续可以对结果做一次阈值过滤把明显不相关的噪声剔除。阈值怎么定别拍脑袋定 0.5 或 0.7。我给的建议是拿 20 个真实问题跑一遍检索把每个问题的相似度分数打印出来看分布再去定阈值。不同 Embedding 模型的距离分布差异很大有的模型结果集中在 0.3~0.6有的在 0.5~0.85同一个数值在不同模型下意义完全不同。盲目定一个全局阈值要么召回为空要么垃圾结果浩如烟海。还有一个容易被忽略的点如果想要更稳的召回可以做“混合检索”——向量相似度召回一遍BM25 关键词召回一遍然后两份结果合并去重再交给后续环节。关键词检索擅长精确名词匹配比如 API 名称、报错码向量检索擅长语义近似两者正好互补。目录里如果有很多代码接口说明这个方法的效果提升会非常明显。2.5 第五步检索结果的重排序与上下文拼装第一次跑通 RAG 的人经常看到一种尴尬场面检索回来的 5 块内容里只有 2 块真正有用另外 3 块是“看起来相关但实际没啥用”的内容。向量检索的召回是“粗排”它的定位是尽量别漏至于精确排序就需要一个重排序环节。最简单的重排序是让 LLM 来做把所有候选内容丢给模型让模型从中挑出与问题最相关的段落再组织回答。这个方案不需要额外组件缺点是每次都要多花一次模型调用。想要更标准化的方案可以接 cross-encoder 重排序模型它会对“问题-文档块”逐对打分精度比向量相似度高一截但需要本地部署或调用外部队列。我的建议是先跑通粗排LLM 过滤等确认链路没有其他瓶颈再上重排序别一上来就叠全套。上下文拼装也有讲究检索到的多块内容如果直接拼接块与块之间没有分隔信息模型容易把它们当成一段连续文章。我生成上下文时会给每块内容加一个来源标记比如[文档1] 来源docs/installation.md第3段 内容xxxxx [文档2] 来源docs/faq.md第7段 内容xxxxx这样模型在组织回答时能清楚知道哪句话出自哪份文档引用时不会张冠李戴。3. 实操落地把检索封装成 CrewAI 工具再接上 Agent 与 Task3.1 自定义工具的骨架结构CrewAI 的BaseTool是所有自定义工具的基类你要做的核心事情有三件给工具起名字、写清楚它什么时候该被调用、实现_run方法。这三件事里最玄学的就是description因为 Agent 不会读你的代码它只读你的描述。描述写得含糊Agent 该调用时不调用描述写得过于宽泛Agent 不该调用时乱调用。我自己的写法是第一句说明工具作用范围第二句列出典型触发场景第三句暗示“当用户问题与此相关时必须使用”。尽量具体不要出现“查询相关信息”这种口水话。下面是我在项目里实际用的工具类骨架from typing import Type from pydantic import BaseModel, Field from crewai_tools import BaseTool class DirectoryRAGToolArgs(BaseModel): question: str Field(description用户提出的问题或需要检索的主题描述) class DirectoryRAGTool(BaseTool): name: str directory_rag_search description: str ( 在本地产品文档目录中进行语义检索。 当用户询问安装配置、参数含义、接口定义、使用步骤、 故障排查等需要查阅文档的问题时必须先调用本工具 再基于检索结果回答。 ) args_schema: Type[BaseModel] DirectoryRAGToolArgs def _run(self, question: str) - str: return self._search(question)这里有个容易踩的坑_run方法的入参名必须和 args_schema 里的字段名一致。我见过有人 schema 里定义query_run里写question结果工具一调用就报参数错误。这个错不仔细看堆栈很难发现因为它是在 Agent 调用时动态触发的。3.2 在 _run 方法里接入 Chroma 检索和结果格式化实现_run的时候我习惯把“检索逻辑”和“结果格式化”分成两层。检索逻辑负责从 Chroma 里取回原始文档块结果格式化负责把文档块拼成上面说的带来源标记的上下文文本。这样后续换向量库、加重排序都只改一层。from chromadb.config import Settings import chromadb class DirectoryRAGTool(BaseTool): # ... 上述 name/description/args_schema 省略 collection_name: str product_docs persist_directory: str ./.chroma_db def _search(self, query: str, top_k: int 5) - str: client chromadb.PersistentClient(pathself.persist_directory) collection client.get_or_create_collection(self.collection_name) result collection.query( query_texts[query], n_resultstop_k, include[documents, metadatas, distances], ) blocks [] if not result[documents] or not result[documents][0]: return 未能在文档目录中检索到相关内容请确认问题是否属于文档覆盖范围。 for rank, (doc, meta, dist) in enumerate( zip(result[documents][0], result[metadatas][0], result[distances][0]), 1 ): src meta.get(source, unknown) section meta.get(chunk_index, ?) blocks.append(f[{rank}] 来源{src}第{section}段\n{doc}\n) return \n.join(blocks)有一点需要说明Chroma 的距离指标如果你没显式指定默认可能是 L2 距离数值越小代表越相近这和 Cosine 相似度越大越相近正好反着。如果你后续要做阈值过滤一定要先确认你用的距离函数是哪一个别把 0.3 这种“很相似”的距离当成“不相关”过滤掉了。3.3 建立索引的辅助脚本工具做的是检索但前提是索引得先建好。我单独写了一个初始化脚本把目录扫描、切分、Embedding、写入 Chroma 四步串起来。这一步和 CrewAI 本身没关系但它决定了整个 RAG 能不能用到实处。import chromadb from langchain_text_splitters import RecursiveCharacterTextSplitter def build_index(root_dir: str, collection_name: str, persist_dir: str): client chromadb.PersistentClient(pathpersist_dir) collection client.get_or_create_collection(collection_name) splitter RecursiveCharacterTextSplitter( chunk_size512, chunk_overlap80, separators[\n\n, \n, 。, , ], ) for file_path in scan_directory(root_dir, {.md, .txt, .pdf, .docx}): text load_text(file_path) chunks splitter.split_text(text) if not chunks: continue ids [f{file_path}#{i} for i in range(len(chunks))] metadatas [{source: str(file_path), chunk_index: str(i)} for i in range(len(chunks))] collection.add(documentschunks, idsids, metadatasmetadatas) print(findexed {file_path}: {len(chunks)} chunks)这个脚本里的load_text函数需要根据文件后缀分别调用对应的解析库PDF 和 DOCX 不能当纯文本直接读。建索引的时候我强烈建议开个日志输出看一眼每个文件被切成了多少块如果某些文件块数异常多大概率是格式解析出了问题比如把整个页面的所有文字全粘连成了一块超长文本。3.4 创建 Agent 和 TaskPython 里串起整个 Crew索引有了工具有了回到 CrewAI 的主场。定义 Agent 时role、goal、backstory这三角色参数要围绕“文档检索与问答”来写tools列表里挂上我们的检索工具。Task 的description要显式告诉 Agent 它的工作流是“先检索、再回答”。from crewai import Agent, Crew, Process, Task rag_tool DirectoryRAGTool() docs_agent Agent( role文档研究员, goal根据用户的提问从本地文档库中检索最相关的内容并给出有据可依的回答, backstory你是团队里最熟悉产品知识库的文档专家检索资料时严谨细致回答时严格依据原文。, tools[rag_tool], verboseTrue, ) answer_task Task( description( 用户的问题是{question}\n 请先调用 directory_rag_search 工具检索相关文档 回答必须基于检索返回的内容同时标注每条结论对应的文档来源。 如果检索结果不足以回答问题请明确告知用户不要编造内容。 ), expected_output包含引用来源的完整回答结构清晰事实准确, agentdocs_agent, tools[rag_tool], ) crew Crew( agents[docs_agent], tasks[answer_task], processProcess.sequential, verboseTrue, ) result crew.kickoff(inputs{question: 如何配置默认的日志输出路径})注意这里的question是 Task description 模板里的一个输入变量kickoff时通过inputs传进去。实际执行时Agent 会自动判断应该先调用检索工具等拿到工具返回的文档块之后再写回答。你可以在 verbose 输出里看到 Agent 的决策过程先Action: directory_rag_search再Final Answer。3.5 一次真实的执行链路效果按上面的代码跑一遍“如何配置默认的日志输出路径”这个问题我拿到过的执行链路长这样Task 启动后Agent 先思考当前问题需要文档支撑于是调用工具工具在 Chroma 里命中若干文档块后返回带来源标记的上下文Agent 看到上下文后组织回答最终答案里明确写了“根据 config.md 第 2 段的说明日志路径由 config.yaml 中的 log_path 字段控制默认值为 ./logs”。这个结果比直接让模型硬答要扎实得多因为关键信息是从文档里捞出来的不是模型“觉得可能是”。如果检索结果为空Agent 也会按 Task 描述里的约束如实说“没有在文档中找到相关信息”而不是强行编造。这个行为约束是写在 Task description 里的写不写差别很大。4. 实测问题盘点跑目录 RAG 搜索你大概率会遇到的暗坑4.1 Agent 死活不调用工具答案全靠模型硬编这个问题出现的频率最高。可能的原因有三个一是工具描述写得不够明确Agent 没意识到当前问题需要检索二是 Task description 里没有强制要求使用工具三是 Agent 判断自己的内置知识“足够”回答于是跳过工具。我排查的时候先看 verbose 日志里 Agent 的 Thought 过程如果它压根没提检索工具八成是描述问题如果它提了但没用多半是 Task 描述里缺了“必须调用”的指令。解决办法很直接Agent 描述的末尾加一句“当任何问题可能涉及产品文档内容时你都应该先检索而不是直接回答”Task 描述里写“调用 directory_rag_search 是你回答的前提条件”。实测下来这组约束能把漏调用率压到很低。4.2 工具 description 写得太宽泛无关问题也被拿去检索反过来也有问题——description 里写“查询所有信息”结果用户问一句“你好”Agent 都去文档里翻一遍。浪费倒还在其次关键是这些无关检索结果会污染上下文让模型顾此失彼。解决办法是给工具描述划定明确边界它只负责检索文档目录不负责闲聊、不负责代码生成、不负责逻辑推理。该用的时候用不该用的时候别凑热闹。4.3 检索出来的内容太碎完全答不到点子上这个问题的根源大概率在分块策略而不在向量库。512 token 的块对于三言两语的问答也许够用但对于“如何部署一套完整服务”这种流程性问题单块内容永远只覆盖一个局部环节模型很难根据碎片拼出完整流程。我后来改的方案是在分块时保留文档标题层级信息或者直接把块大小调到 768~1024 token让每一块包含更完整的语义单元。另一个有效技巧是“父子分块”父块是较大的段落单元用于回答子块是小段用于检索检索命中子块后把对应的父块内容喂给模型。4.4 返回的相似度分数含义搞错阈值过滤全乱套前面提过Chroma 默认距离函数可能是 L2数值越小越相关。如果你在代码里用where distance 0.5过滤看起来是“只要高度相关的内容”实际上把大量相关文档全过滤掉了。用 Cosine 距离时又要反过来处理。我强烈建议在代码里显式指定 Collection 的 metadata比如{hnsw:space: cosine}这样行为可预期。collection client.get_or_create_collection( nameproduct_docs, metadata{hnsw:space: cosine}, )排查相似度问题时打印一个完整的问题检索结果把每条命中的距离和对应文档内容打出来看比凭空猜阈值有效一百倍。4.5 索引重建策略什么时候全量、什么时候增量目录内容发生变化后索引不会自动更新。CrewAI 本身不负责索引同步所以你需要自己定一个策略。我的做法是把建索引脚本做成可重复执行的幂等操作每次重建时按文件路径生成稳定 ID旧的 ID 会被覆盖被删掉的文件可以通过比对 ID 集合手动清理。小目录直接全量重建就行一分钟内搞定大目录建议记录每个文件最近修改时间只重新索引改动过的文件。4.6 上下文拼装导致模型“张冠李戴”当返回的多块内容里有多份文档都提到同一概念比如多份文档都写了“默认端口是 8080”模型可能把 A 文档的上下文跟 B 文档的结论混在一起。给每块内容加来源标记能显著缓解这个问题我实际观察到的效果是标记来源之后模型在回答时会主动说“根据 A 文档…而 B 文档里则提到…”而不是含糊地把所有信息揉成一段话。另一个有效的写法是在 Task 描述里显式要求“如果不同文档描述存在冲突请分别列出并说明差异”这样模型就有明确的规则来处理多源信息冲突而不是自己悄悄选一个它觉得对的。5. 几个让目录 RAG 更好用的进阶技巧5.1 让检索工具支持一次查多个问题再合并用户的问题往往不是单一主题比如“服务启动失败如何看日志怎么排查配置错误”——这里面至少包含“启动失败”“日志查看”“配置排查”三个检索意图。单次用整个问题去检索向量表示会被三个主题搅合在一起。我后来试过在工具内部做查询分解让 LLM 先把用户问题拆成 2~3 个子问题每个子问题分别检索然后合并去重。这个改动加在工具内部Agent 完全无感知但回答质量提升明显。5.2 用 HyDE 提高检索命中率HyDEHypothetical Document Embeddings是个思路很清奇的技巧检索之前先让 LLM 基于用户问题生成一段“假设性的答案文档”然后用这段假设文档去做向量检索而不是直接用原始问题。因为原始问题通常太短和文档的正文字面差异大而假设文档的句式、词汇更接近真实文档检索命中率更高。当然代价是多一次额外的 LLM 调用实测在中文文档场景下对模糊查询的提升值得这个开销。5.3 在 Agent 工具链里同时挂多检索工具做路由目录 RAG 完全可以拆成多个工具、多个向量库比如一个工具管技术文档一个工具管 FAQ一个工具管变更记录。Agent 根据问题的性质自己做路由。这个方案的好处是每个集合的向量空间更纯粹不会因为文档类型太杂而互相干扰。但前提是每个工具的 description 要把自己的范围写清晰否则 Agent 会乱选。我实际跑下来工具数量控制在 3~5 个以内时路由准确率最稳。5.4 索引更新后的小规模回归测试每次调整分块参数、换 Embedding 模型、改重排逻辑之后强烈建议准备一组固定的测试问题逐个跑一遍并人工打分。我一般准备 20~30 个覆盖不同文档主题的测试问题记录“Top 5 命中率”和“最终回答质量分”任何改动都得让这两个指标不回退才上线。这一步看起来笨但能让你在折腾了一整天参数之后仍然清楚自己到底是在优化还是在倒退。一些体会说实话目录 RAG 搜索这个项目RAG 的部分和 CrewAI 的部分一样重要但大多数人会把注意力全放在 Agent 编排上最后发现效果瓶颈往往出在检索侧分块太粗、阈值乱设、元数据缺失、Embedding 选型不合适。我个人建议的顺序是先把独立的建索引和检索脚本跑通用带日志的检索函数手测 10 个问题确认召回结果满意了再封装成工具接入 CrewAI。这样排障时不必同时面对“是工具没调好还是检索没做好”两个未知数。如果你接下来想扩展这个项目可以沿着两个方向走一是把工具参数从单一 question 扩展成支持 top_k 和过滤条件让 Agent 拥有更强的检索控制力二是给回答结果的每个句子生成引用锚点直接指向文档中的具体段落。这两个方向做扎实了你的目录 RAG 就能从一个 demo 变成真正有说服力的智能体工具。