Rust实现Agent的RAG管道:从概念到工程实践
当我把一个用 Rust 写的 AI Agent 从“聊天玩具”推向“能真正处理本地资料”的时候第一个绕不开的问题就是上下文不能无限扩张。对话窗口再大也不可能把整个项目的文档、代码、历史记录全部塞进去。于是我需要 RAG。这个话题在 Python 社区已经被拆得非常细了但放到 Rust 生态里很多路径需要重新走一遍。这篇文章不是要复述 RAG 的概念而是想从 Agent 工程化的角度把它拆开RAG 在 Agent 里到底扮演什么角色为什么用 Rust 做 RAG 是一个值得认真考虑的选择以及从零开始搭一套最小管道时真正会在哪里卡住。先说结论。RAG 在 Rust 开发的 AI Agent 里不只是一套“文档问答”功能而是一种把模型知识边界收窄、让 Agent 每次决策都有事实依据的工程手段。用 Rust 实现它的价值不在于跑分更快而在于能把一整条检索增强管道编译成一个可分发、可嵌入、可长期维护的模块。这个判断会贯穿全文。1. 先搞明白RAG 在 Agent 里补的不是知识而是“可验证的记忆”1.1 一个常见的理解偏差很多人一上来就把 RAG 理解为“把文档向量化然后做一个高级搜索引擎”。这个理解不算错但很危险。搜索引擎给用户的是一组候选结果用户需要自己判断哪条有用而 Agent 直接给用户的是一个回答它必须对回答负责。如果只把 RAG 当成“搜索结果拼接后丢给模型”你得到的往往是一个看起来详细、实际上经不起追问的回答。一个更准确的理解是RAG 是一种让模型在回答时能够引用外部证据的机制。它改变的不是模型的知识量而是模型的知识来源。没有 RAG 的时候模型像一个凭记忆答题的学生错不错完全看记忆是否可靠。有 RAG 之后模型手里多了一份可以查阅的参考资料它可以选择先查证再回答。1.2 从“模型知道什么”到“模型能够查到什么”这里有一个很关键的心态转变。过去调模型时我们总在问模型是不是不知道这个领域要不要换个大模型要不要微调做了 RAG 之后你会发现很多问题不是模型不知道而是它在回答时没有机会去查。举个例子。如果你的 Agent 需要回答“这个项目的配置文件里哪些参数会影响日志级别”它其实不需要真的理解项目架构它只需要能定位到配置文件、提取相关段落、把内容放进上下文。这个能力与模型的“知识量”无关反而与检索系统的“命中率”强相关。所以在 Agent 架构里RAG 补的不是知识而是可验证的记忆。记忆可以来自文档、代码、数据库、API 返回体、甚至上一次对话的关键结论。只要检索链路能稳定地把相关片段捞出来模型就能在有限上下文里做出更可靠的判断。1.3 为什么 Agent 场景比单次问答更需要 RAG单次问答的 RAG 相对简单用户问一句系统检索一次模型回答一次。但 Agent 是多次决策、多步执行的它可能在一个任务里要理解需求、拆分步骤、调用工具、读取文件、生成内容中途还要根据中间结果调整计划。在这种场景下Agent 的每一步都可能产生新的“知识缺口”。比如任务一开始需要了解项目结构它需要检索目录和文档。写到某个函数时需要参考代码风格它需要检索历史代码。遇到报错时需要查错误码含义它需要检索错误日志或手册。如果这些检索动作全部堆在系统提示词里上下文马上会爆炸。更合理的做法是把 RAG 封装成 Agent 可以按需调用的工具让 Agent 自己决定什么时候查、查什么、查完之后把哪些内容带回主流程。这也是“Agentic RAG”这个说法的现实来源它强调的是检索动作由 Agent 自主编排而不是固定流程。2. 用 Rust 做 RAG不是为了快而是为了把 RAG 变成组件2.1 Python 方案与 Rust 方案的真正差异现在做 RAG 的主流技术栈几乎都在 Python 生态里比如用 LangChain 或 LlamaIndex 搭管道用 FastAPI 做服务用 Qdrant 或 Milvus 做向量库。这套方案成熟、文档多、上手快对一个快速验证原型的项目来说几乎是默认答案。那 Rust 的价值在哪里很多人第一反应是性能。但说实话RAG 管道的性能瓶颈通常在 embedding 模型的推理和向量检索服务上这两样并不因为调用方是 Rust 而有质的提升。真正值得关注的是另外几个点二进制分发Rust 能把整个 Agent 和 RAG 管道编译成单个可执行文件用户不需要安装 Python、不需要配虚拟环境、不需要管理依赖冲突。这对桌面工具、内网工具、边缘设备非常友好。嵌入式能力如果你的 Agent 本身就是一个 Rust 应用比如一个 CLI、一个 Tauri 桌面应用或者一个 actix-web/axum 服务那 RAG 不应该是一个独立 Python 进程而应该是一个可以被 Agent 直接调用的内部模块。依赖可控性Rust 生态里虽然也有各种 crate但整体依赖树比 Python 的数据科学栈轻不少。对于追求长期维护的项目这能减少很多环境层面的意外。当然代价也很明显开发速度慢、生态相对年轻、很多算法实验要自己写。下面这个表格是一个比较保守的对比。维度Python 方案Rust 方案生态成熟度高工具链丰富中等核心组件够用精细功能要自己补开发速度快适合原型验证慢类型系统和所有权模型需要额外时间安装分发需要 Python 环境可编成单二进制便于分发给非开发用户嵌入到现有应用需要启子进程或 HTTP 服务可以作为库模块直接嵌入长线维护依赖版本变化较快易碎依赖相对稳定但要关心 crate 维护状态2.2 判断标准什么情况下值得用 Rust 写 RAG我一般会按三个条件来判断。第一Agent 本身是不是 Rust 写的。如果 Agent 的调度、工具执行、状态管理已经在 Rust 里那 RAG 作为其核心工具也放进 Rust 会更顺。额外开一个 Python 服务意味着你要处理跨进程通信、异常同步、版本升级对齐这些复杂度很快会超过“Python 写 RAG 更省事”的收益。第二目标用户是否需要被分发软件。如果你做的是一个要给别人用的工具Rust 的单文件分发是一个巨大优势。用户不会关心你的 embedding 模型怎么部署他们只希望双击就能跑。第三你是不是愿意为性能控制投入时间。Rust 对内存、线程、IO 的控制力更强如果你要做流式读取、并发检索、内存索引缓存Rust 提供的手段更直接。但注意这是“愿意投入时间”的条件不是“免费获得”的承诺。反过来如果目前还在探索业务逻辑文档和资料处理方式一天一个变那先用 Python 把流程跑通远比在 Rust 里挣扎更明智。2.3 一个务实的组合Rust 写 AgentPython 只做离线数据处理还有一个经常被低估的混合方案用 Rust 写 Agent 主程序把离线数据处理留到 Python。在线管道要稳定、要快、要嵌入到产品里适合 Rust。离线管道要灵活要支持各种奇怪的文档格式、清洗规则、领域切分策略适合 Python。你可以先用 Python 脚本把一批 markdown、PDF、HTML 处理成结构化的 JSON 片段每个片段带上原始来源和元数据再用 Rust 写一个导入器把这些片段嵌入并写入向量库。这样你不用在 Rust 里重新实现 PDF 解析、表格抽取等复杂功能Rust 侧只需要处理已经结构化的文本。对于很多项目来说这是最务实的路径也是我不太推荐“一步到位全 Rust”的原因。3. 从最小管道跑通 RAG加载、切块、嵌入、存储、检索、生成3.1 六段管道先跑通再优化RAG 并不是一个单一算法而是一整条数据管道。无论用什么语言几乎都会走下面这条路径。加载读取文档得到原始文本。切块把长文本切成适合检索和生成的片段。嵌入把每个片段转成向量。存储把向量和原文、元数据一起写入向量库。检索根据用户问题生成查询向量找到最相似的片段。生成把检索结果作为上下文交给模型得到最终回答。这条管道拆开看都不难但难在每个环节都可能有隐藏的地雷。Rust 的好处是把这些环节变成明确的函数调用所有输入输出都要过类型系统。坏处是任何一个环节缺少合适的 crate你都要先补基础能力。所以在开始做之前我的建议是先把整条链路在脑子里走一遍明确哪些是现成的、哪些要自己写。不要一上来就想做多高级的混合检索、重排序、Graph RAG先把最简单的向量相似度检索跑通。3.2 一个最小技术栈参考这里给一个偏 Rust 的常见选型参考不涉及具体版本因为这类 crate 更新很快落地前一定要针对当前版本确认接口。环节常见选择说明文件读取std::fs、walkdir、ignore读取本地目录、忽略敏感文件文本切块自实现按字符/段落切分tiktoken-rs 估算 token一开始不需要引入复杂框架嵌入向量本地模型可考虑 candle 或调用 llama.cpp远程可调用 OpenAI 兼容 API关键是文档和查询必须用同一模型向量存储Qdrant、pgvector、sqlite-vec小规模可以用 sqlite-vec服务化用 Qdrant生成调用 OpenAI 兼容接口或本地 llama.cpp HTTP 服务Agent 和 LLM 之间通过 HTTP 解耦Agent 调度自实现循环或选择 agent 相关 crate早期先手写循环控制每一步这个表格不是一个“官方推荐”而是按照“尽可能复用成熟组件”的原则列出来的。向量库我倾向于独立部署因为 RAG 不只是给 Agent 用后期可能还会接入监控脚本、批量巡检、知识库管理页面独立服务更容易扩展。3.3 在 Rust 里把检索封装成 Agent 工具跑通管道之后下一步是把检索能力封装成 Agent 工具。下面是一个示意结构重点是让你理解接口边界不是真实 crate 的 API。// 示意结构RAG 检索工具 struct RagTool { vector_store: VectorStore, embedder: Embedder, } async fn search_knowledge(self, query: str) - VecDocument { // 1. 把用户 query 也转成向量 let q_vec self.embedder.embed(query).await?; // 2. 在向量库中召回最相关的片段 let docs self.vector_store.search(q_vec, 5).await?; // 3. 返回原文与元数据 docs.iter().map(|hit| hit.document.clone()).collect() }实际开发中你大概率会把这个过程拆成load_documents、chunk_text、embed_chunks、store_chunks、search_query几个函数。每个函数都单独测试尤其在切块和嵌入这两个环节写单元测试比到最后黑盒调试要省力得多。4. 决定 RAG 效果的不是模型而是切块和元数据4.1 切块策略不是越大越好很多第一次做 RAG 的人会直接按固定字符数做切块比如每 500 字切一块。这个做法在少数文档上能跑通但一旦文档结构复杂问题就来了。切片太短单个 fragment 语义不完整模型读到一半不知道在说什么切片太长向量被很多无关内容稀释检索召回时经常把不相关的内容排到前面。比较实用的思路是分层切先按文档结构标题、段落、列表、表格切成有语义边界的块再为超长块设置一个最大长度。比如 markdown 文档可以先用标题层级切分再对过长的章节按段落继续切。这样既保留了语义完整性也避免单个向量承载过多噪声。另外要注意 token 数量与字数的差别。Rust 生态里判断 token 数量可以用 tiktoken-rs 这类工具。中文场景下一个字符大概对应一个或不到一个 token但英文、代码、表格结构差异很大。最好在切块函数里加入 token 估算而不是只按字符数硬切。4.2 元数据让结果可追溯、可过滤、可混排切块只是第一步。真正决定 RAG 能不能长期使用的是元数据设计。每一条索引记录除了向量和原文之外至少要有这些字段来源文件路径标题或章节路径创建/更新时间文档类型如果是代码还要记录语言、文件路径、函数名有了这些字段你可以做三件事结果可追溯Agent 回答后可以带上来源用户能去看原文。过滤检索比如只检索某个目录、某个时间之后更新的文档、某个权限范围内的资料。混排与去重多个 fragment 来自同一文档时可以按结构层级合并或去重减少上下文冗余。很多失败的 RAG 项目不是死在向量检索环节而是死在“模型给出了答案但用户根本不知道答案来自哪里”。元数据是解决这个问题的唯一手段。4.3 更新和删除知识库不是只写一次如果你只是做一次离线文档问答那增量更新可以不管。但如果你把 RAG 作为 Agent 长期运行时的记忆系统更新和删除就是一个必须处理的工程问题。常见的坑是文档更新后旧 chunk 还在向量库里模型检索时同时看到新旧版本回答就会自相矛盾。所以在设计存储结构时每个 chunk 要绑定一个文档 ID更新时先按文档 ID 删除旧 chunk再写入新 chunk。在 Rust 侧这意味着你要有“索引版本”的概念。比如每次导入数据都用同一个 schema 版本如果嵌入模型变了旧索引必须重建否则会出现向量维度不一致或语义空间错位。不要小看这一点很多线上问题都是换模型之后忘记重建索引导致的。5. 把 RAG 接进 Agent 循环从“检索一次”到“Agent 决定是否检索”5.1 工具化让 Agent 自己判断何时需要外部知识在 Agent 架构里RAG 不应该是每个问题都强制执行的固定步骤而应该是 Agent 手里的一个工具。Agent 需要根据任务判断这个问题我直接凭上下文回答还是需要查资料如果需要查查哪部分这种设计有一个明显好处减少无效检索也减少上下文占用。否则用户问一句“你好”系统也去向量库捞五个片段既浪费 token又会干扰 Agent 对意图的判断。在最简单的实现里你可以先做一个规则判断当用户问题里出现“文档里”“项目里”“配置中”“按照之前”这类词就自动触发检索。更进一步可以让 Agent 自己决定但需要提示词和工具 schema 配合。下面是一个伪代码示意。// 伪代码Agent 主循环中按需调用 RAG 工具 loop { let next_action agent.decide_next_action(conversation_history).await?; match next_action { Action::Search(query) { let context rag_tool.search(query).await?; conversation_history.push(ContextFragment::from(context)); } Action::Reply(text) { println!({}, text); break; } } }实际项目中Agent 的决策逻辑会比这个复杂得多但你至少能看出 RAG 工具化之后的价值检索动作不再固定写在管道里而是变成 Agent 决策循环中的一个分支。5.2 多轮检索与追问RAG 工具化之后紧接着会出现一个新的问题Agent 什么时候停止检索比如用户问“帮我按这个项目的代码风格写一个文件解析函数”Agent 先要搞清楚“代码风格”指的是什么它可能检索代码目录然后要了解现有文件解析函数长什么样它可能继续检索