☰
Rust实现Agent的RAG管道:从概念到工程实践
2026/10/16 7:39:22 网站建设 项目流程

当我把一个用 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 而有质的提升。真正值得关注的是另外几个点:

  1. 二进制分发:Rust 能把整个 Agent 和 RAG 管道编译成单个可执行文件,用户不需要安装 Python、不需要配虚拟环境、不需要管理依赖冲突。这对桌面工具、内网工具、边缘设备非常友好。
  2. 嵌入式能力:如果你的 Agent 本身就是一个 Rust 应用,比如一个 CLI、一个 Tauri 桌面应用,或者一个 actix-web/axum 服务,那 RAG 不应该是一个独立 Python 进程,而应该是一个可以被 Agent 直接调用的内部模块。
  3. 依赖可控性: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 写 Agent,Python 只做离线数据处理

还有一个经常被低估的混合方案:用 Rust 写 Agent 主程序,把离线数据处理留到 Python。

在线管道要稳定、要快、要嵌入到产品里,适合 Rust。

离线管道要灵活,要支持各种奇怪的文档格式、清洗规则、领域切分策略,适合 Python。你可以先用 Python 脚本把一批 markdown、PDF、HTML 处理成结构化的 JSON 片段,每个片段带上原始来源和元数据,再用 Rust 写一个导入器把这些片段嵌入并写入向量库。

这样你不用在 Rust 里重新实现 PDF 解析、表格抽取等复杂功能,Rust 侧只需要处理已经结构化的文本。对于很多项目来说,这是最务实的路径,也是我不太推荐“一步到位全 Rust”的原因。

3. 从最小管道跑通 RAG:加载、切块、嵌入、存储、检索、生成

3.1 六段管道先跑通再优化

RAG 并不是一个单一算法,而是一整条数据管道。无论用什么语言,几乎都会走下面这条路径。

  1. 加载:读取文档,得到原始文本。
  2. 切块:把长文本切成适合检索和生成的片段。
  3. 嵌入:把每个片段转成向量。
  4. 存储:把向量和原文、元数据一起写入向量库。
  5. 检索:根据用户问题生成查询向量,找到最相似的片段。
  6. 生成:把检索结果作为上下文交给模型,得到最终回答。

这条管道拆开看都不难,但难在每个环节都可能有隐藏的地雷。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) -> Vec<Document> { // 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 能不能长期使用的是元数据设计。

每一条索引记录,除了向量和原文之外,至少要有这些字段:

  • 来源文件路径
  • 标题或章节路径
  • 创建/更新时间
  • 文档类型
  • 如果是代码,还要记录语言、文件路径、函数名

有了这些字段,你可以做三件事:

  1. 结果可追溯:Agent 回答后可以带上来源,用户能去看原文。
  2. 过滤检索:比如只检索某个目录、某个时间之后更新的文档、某个权限范围内的资料。
  3. 混排与去重:多个 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 先要搞清楚“代码风格”指的是什么,它可能检索代码目录;然后要了解现有文件解析函数长什么样,它可能继续检索

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询