这次我们来看 Rust AI Agent 系列的第 8 篇:RAG 简介。前几篇文章已经把 Agent 的动作闭环搭起来了:模型能理解任务、能调用工具、能处理结果。但很多实际场景里,Agent 面对的问题不是“不会调用工具”,而是“缺少领域知识”。比如你问它某个内部项目的部署步骤,它没读过你的项目文档,只能靠训练数据里的泛化经验去猜。猜得对不对,完全看运气。
RAG(Retrieval-Augmented Generation,检索增强生成)就是解决这类问题的标准做法。核心思路很直接:让大模型在回答之前,先去外部知识库检索相关素材,再把素材拼进提示词,让模型基于素材回答。不需要重新微调模型,也能把私有文档、最新资料、企业内部知识接进 Agent。
这篇文章我会用 Rust 把一个最小 RAG 链路讲清楚:文档加载、文本切块、向量化、相似度检索、上下文拼接、生成回答,最后补一个 HTTP 接口示例和批量任务思路。目标读者是已经在做 Rust AI Agent、想让 Agent 具备“查资料”能力的开发者。看完这篇,你应该能自己搭出一个能跑的本地知识库问答最小原型。
1. RAG 核心能力速览
先把 RAG 在 Rust AI Agent 里的定位和核心要素列出来,方便快速判断这篇文章讲的内容适不适合你。
| 能力项 | 说明 |
|---|---|
| 解决的问题 | 大模型不知道私有知识、知识更新滞后、容易产生幻觉 |
| 核心链路 | 文档加载 -> 文本切块 -> 向量化 -> 向量存储 -> 相似度检索 -> 上下文拼接 -> 生成回答 |
| 关键组件 | 嵌入模型、向量存储、生成模型、Rust 服务层 |
| Rust 侧承担的任务 | 文档解析、文本切块、HTTP 调用模型服务、余弦相似度计算、接口服务暴露 |
| 模型来源 | 本地模型服务(Ollama / llama.cpp 等)或 OpenAI 兼容接口 |
| 部署形式 | Rust 二进制 + 模型服务 + 向量数据库 |
| 是否支持 API | 可以,用 axum / actix-web 暴露 HTTP 接口 |
| 是否支持批量任务 | 可以,按目录批量切块入库,也可以循环问题列表做离线批量问答 |
| 显存与内存门槛 | 取决于嵌入模型和生成模型的量化版本;只跑嵌入模型资源需求低,同时跑 7B 级生成模型需要按实际量化格式评估 |
| 适用场景 | 私有知识库问答、文档摘要、客服辅助、Agent 长期记忆、代码库问答 |
关于硬件门槛,这一篇不写死具体数字。原因是同一个模型在不同量化等级、不同上下文长度下,资源占用差异非常大。更稳妥的判断是:先选小模型跑通链路,再根据效果和资源换大模型。
2. 为什么 AI Agent 需要 RAG
2.1 大模型的“不知道”和“乱编造”
大模型的知识来自训练阶段,训练结束后,知识就固定了。你问它最近三个月的新文档内容,它大概率不知道;你硬问,它可能用看起来很合理的语气编一段答案。这就是幻觉问题。
RAG 不是改变模型本身,而是改变模型的输入。模型回答问题前,先从一个可控的外部知识库里检索到相关段落,把段落作为上下文塞进提示词。这样模型回答时至少是“基于你提供的资料”在回答,而不是凭空发挥。
2.2 让 Agent 拥有“可更新”的知识
Agent 的工具调用解决的是“我能执行什么操作”,RAG 解决的是“我能在回答中引用哪些事实”。两者是互补的。
举个例子:一个运维 Agent 被问到某个服务的重启流程。它可以通过工具去查 API,也可以通过 RAG 去检索内部运维手册。手册更新了,RAG 的检索结果就跟着更新,不需要重新训练模型。这就是 RAG 比微调更适合动态知识的原因之一。
2.3 RAG 不擅长什么
RAG 不是万能的。如果问题需要多步推理、需要从多个文档里综合信息,简单的“检索一段 + 拼接上下文”效果会打折扣。另外,如果文档本身质量很差、切块策略不合理,检索出来的内容可能相关性很低。这时候需要引入重排序(Rerank)、多路召回、甚至把 RAG 升级成 Agentic RAG,让 Agent 自己决定搜什么关键词、搜几次、读哪些片段。
从材料里也能看到,Agentic RAG 是当前非常热的方向。这一篇先打基础,把 RAG 的链路跑通,后面再讨论如何让 Agent 控制检索过程。
3. RAG 基础流程:从文档到回答的六步链路
RAG 的完整链路可以拆成六个环节。先看全貌,再逐个说明。
- 文档加载:读取 TXT、Markdown、PDF、Word、HTML 等格式。
- 文本切块:按固定长度或语义边界把长文档切成片段。
- 向量化:用嵌入模型把每个片段转成向量。
- 向量存储:把向量和原始文本存入内存、SQLite 或专用向量数据库。
- 相似度检索:把用户问题转成向量,用余弦相似度等距离度量找到 Top-K 片段。
- 生成回答:把 Top-K 片段拼成上下文,连同问题一起发给大模型。
3.1 文档加载与解析
TXT 和 Markdown 最简单,Rust 里直接读字符串即可。PDF 和 Word 需要引入专门库,例如pdf-extract、docx-rs等。刚开始做原型时,建议先用纯文本文件跑通链路,再逐步加格式解析。
3.2 文本切块策略
切块是 RAG 里影响效果最大的环节之一。
- 按固定字符数切:简单,但可能在句子中间截断。
- 带重叠窗口切:相邻块保留一部分重叠内容,能缓解边界信息丢失。
- 按段落或句子切:语义更完整,但实现复杂度略高。
- 按 Markdown 标题切:适合结构化文档。
选择切块大小要平衡检索精度和上下文占用:块太小,检索时缺少上下文;块太大,信息变杂,生成时占用的 token 也更多。常见的做法是先按段落切,再对过长段落做二次切分。原型阶段可以用 300 到 800 字符、重叠 50 到 100 字符的配置,再按效果调整。
3.3 向量化与相似度计算
嵌入模型把文本映射成高维向量。语义相近的文本,向量在空间里距离更近。典型做法是用余弦相似度衡量两个向量的接近程度。
3.4 生成回答的提示词设计
检索到的片段不能直接全塞给模型。需要设计一个系统提示词,告诉模型“只能基于资料回答,资料不足就说明不知道”,避免模型把检索到的片段和训练记忆混在一起。
4. 环境准备与前置条件
这一篇的示例代码不依赖重型框架,主要需要三部分:Rust 工具链、模型服务、可选的向量存储。
4.1 Rust 工具链
如果机器上还没有 Rust,用官方脚本安装 rustup。已经装过的可以直接检查版本。
# 安装 rustup curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # 加载环境变量 source "$HOME/.cargo/env" # 检查版本 rustc --version cargo --version从网络热词里可以看到,很多人在 Windows 上使用 Rust 时也在关注 Cargo 更新源和 MSVC 工具链问题。Windows 用户安装完成后,建议先编译一个最简单项目确认工具链可用,再继续后面的步骤。
4.2 本地模型服务
RAG 链路里有两个模型:嵌入模型和生成模型。最省事的本地部署方式是使用 Ollama 或 llama.cpp 启动本地服务。这里以 Ollama 为例,模型名需要按你本机实际拉取的结果调整:
# 启动 Ollama 服务 ollama serve # 拉取生成模型(按本机配置选择模型大小) ollama pull qwen2.5:7b # 拉取嵌入模型 ollama pull bge-m3如果你不想用 Ollama,也可以使用 llama.cpp 的 server 模式。不同服务端的接口路径不完全一样,示例代码里的base_url和请求体结构需要按实际服务端调整。
4.3 向量存储选型
原型阶段,直接把向量存在内存Vec里就够了。数据量变大后,可以换成:
- Qdrant:有官方 Rust 客户端,适合独立向量数据库。
- pgvector:产品里已经在用 PostgreSQL 时可以少引入一个组件。
- sqlite-vec:轻量,适合单机小规模部署。
- 本地文件序列化:把向量落到磁盘,重启后重新加载。
这一篇示例先用内存保存,逻辑最简单,方便理解 RAG 本身。
4.4 项目目录结构
建议在开始编码前先规划好目录:
minimal_rag/ ├── Cargo.toml ├── src/ │ ├── main.rs │ ├── chunk.rs │ ├── embed.rs │ ├── retrieve.rs │ └── generate.rs ├── data/ │ ├── knowledge.txt │ └── output/ └── questions.txtdata/knowledge.txt放测试文档,questions.txt放批量测试问题,output放问答结果。这样的结构后面扩展批量任务时不需要大改。
5. 用 Rust 实现最小 RAG 流程
这一节给出一个可运行的简化示例。注意:依赖版本、模型名称、接口路径需要按实际项目调整。为了让例子聚焦在 RAG 链路上,我刻意把 HTTP 调用写得很直白,没有封装成复杂 trait。
5.1 Cargo.toml 配置
[package] name = "minimal_rag" version = "0.1.0" edition = "2021" [dependencies] anyhow = "1" reqwest = { version = "0.12", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread"] }代码里用reqwest发 HTTP 请求,serde_json解析响应,tokio支撑异步运行时。如果你要接 Qdrant,再加入qdrant-client;如果用 axum 暴露接口,再加入axum。
5.2 文本切块模块
先实现一个简单的按字符切块函数,带重叠窗口:
// src/chunk.rs pub fn split_text(text: &str, chunk_size: usize, overlap: usize) -> Vec<String> { let mut chunks = Vec::new(); let chars: Vec<char> = text.chars().collect(); let total = chars.len(); let mut start = 0; while start < total { let end = (start + chunk_size).min(total); let chunk: String = chars[start..end].iter().collect(); chunks.push(chunk); if end == total { break; } if chunk_size <= overlap { break; } start += chunk_size - overlap; } chunks }这个实现按字符偏移切分,处理中文时不会把一个字符拆成两个 UTF-8 字节片段。后面可以考虑按句子或 Markdown 标题切,但作为最小原型已经够用了。
5.3 嵌入调用模块
这里用 OpenAI 兼容接口调用 Ollama,所以base_url需要带/v1。如果你的模型服务接口不一样,需要按实际路径修改。
// src/embed.rs use anyhow::Result; use serde_json::{json, Value}; pub async fn embed_text( client: &reqwest::Client, base_url: &str, model: &str, text: &str, ) -> Result<Vec<f32>> { let body = json!({ "model": model, "input": text, }); let resp = client .post(format!("{}/embeddings", base_url)) .json(&body) .send() .await?; let data: Value = resp.json().await?; let embedding = data["data"][0]["embedding"] .as_array() .ok_or_else(|| anyhow::anyhow!("embedding not found"))?; let vec = embedding .iter() .filter_map(|v| v.as_f64().map(|x| x as f32)) .collect::<Vec<f32>>(); Ok(vec) }注意:Ollama 的 OpenAI 兼容接口和原生接口返回结构不完全一样。这段代码以 OpenAI 兼容接口为例,使用/v1/embeddings路径。如果你用的是其他模型服务,一定要先看它的接口文档,确认返回字段,再解析。
5.4 相似度计算模块
RAG 原型阶段用余弦相似度就够了。向量维度很高,但计算量相比模型推理可以忽略。
// src/retrieve.rs pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { if a.len() != b.len() { return 0.0; } let mut dot = 0.0f32; let mut norm_a = 0.0f32; let mut norm_b = 0.0f32; for (x, y) in a.iter().zip(b.iter()) { dot += x * y; norm_a += x * x; norm_b += y * y; } if norm_a == 0.0 || norm_b == 0.0 { 0.0 } else { dot / (norm_a.sqrt() * norm_b.sqrt()) } } pub fn top_k_scores<'a>( query_vec: &[f32], items: &'a [(String, Vec<f32>)], k: usize, ) -> Vec<(f32, &'a str)> { let mut scored: Vec<(f32, &'a str)> = items .iter() .map(|(text, vec)| (cosine_similarity(query_vec, vec), text.as_str())) .collect(); scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); scored.truncate(k); scored }5.5 生成回答模块
生成回答部分调用chat/completions接口。提示词设计直接影响回答质量,这里示例强调“只基于资料回答”。
// src/generate.rs use anyhow::Result; use serde_json::{json, Value}; pub async fn generate_answer( client: &reqwest::Client, base_url: &str, model: &str, system_prompt: &str, user_prompt: &str, ) -> Result<String> { let body = json!({ "model": model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], "stream": false }); let resp = client .post(format!("{}/chat/completions", base_url)) .json(&body) .send() .await?; let data: Value = resp.json().await?; let answer = data["choices"][0]["message"]["content"] .as_str() .unwrap_or("") .to_string(); Ok(answer) }5.6 主流程链路
主流程把切块、嵌入、检索、生成串起来。先读入知识文件,切块后批量嵌入,再对问题做检索。
mod chunk; mod embed; mod generate; mod retrieve; use anyhow::Result; #[tokio::main] async fn main() -> Result<()> { // 本地模型服务地址,按实际环境调整 let base_url = "http://127.0.0.1:11434/v1"; let embed_model = "bge-m3"; let chat_model = "qwen2.5:7b"; let client = reqwest::Client::new(); // 1. 加载文档 let doc = std::fs::read_to_string("data/knowledge.txt")?; // 2. 切块 let chunks = chunk::split_text(&doc, 512, 64); println!("document chunks: {}", chunks.len()); // 3. 批量嵌入,保存在内存 let mut items = Vec::new(); for c in &chunks { let vec = embed::embed_text(&client, &base_url, embed_model, c).await?; items.push((c.clone(), vec)); } println!("indexed chunks: {}", items.len()); // 4. 问题嵌入 let question = "Rust 里怎么做 RAG?"; let q_vec = embed::embed_text(&client, &base_url, embed_model, question).await?; // 5. 检索 Top-3 let top = retrieve::top_k_scores(&q_vec, &items, 3); let context = top .iter() .map(|(_, text)| *text) .collect::<Vec<_>>() .join("\n\n"); // 6. 生成回答 let system_prompt = "你是一个知识库问答助手。请严格根据给定的资料回答问题。如果资料中没有答案,请直接说明资料不足,不要编造。"; let user_prompt = format!("资料:\n{}\n\n问题:{}", context, question); let answer = generate::generate_answer(&client, &base_url, chat_model, system_prompt, &user_prompt).await?; println!("answer: {}", answer); Ok(()) }这段代码是完整可读的流程,但要注意几个点:
- 文档文件不存在时会直接报错,原型里用了
?传播错误。 - 批量嵌入是循环串行的,文档多时速度较慢,后续可以改成并发控制。
- 所有向量都放在内存里,重启进程后需要重新嵌入。
跑通这段代码后,你就已经有一个“最小 RAG 原型”了。可以换不同文档、不同问题看效果差异。
6. 用 axum 暴露 RAG 接口
原型跑通后,下一步通常是服务化。把 RAG 封装成 HTTP 接口后,可以让 Web 前端、聊天机器人、自动化脚本统一调用。网络热词里反复出现 actix-web,说明 Rust 社区在 Web API 服务上确实有很强的实践需求。这里用 axum 写一个最小示例,逻辑和 actix-web 是相通的。
6.1 添加 axum 依赖
[dependencies] axum = "0.7"注意:axum 0.7 和 0.8 的接口写法有差异,请以你实际使用的版本为准。
6.2 最小接口服务
use axum::{extract::State, routing::post, Json, Router}; use serde_json::{json, Value}; #[derive(Clone)] struct AppState { client: reqwest::Client, base_url: String, embed_model: String, chat_model: String, // 生产环境这里应该换成真正的向量索引结构 chunks: Vec<String>, chunk_vectors: Vec<Vec<f32>>, } async fn ask_handler( State(state): State<AppState>, Json(payload): Json<Value>, ) -> Json<Value> { let question = payload["question"].as_str().unwrap_or("").to_string(); // 这里省略:调用 embed_text、top_k_scores、generate_answer // 返回值先给一个占位 Json(json!({ "question": question, "answer": "placeholder", })) } #[tokio::main] async fn main() { let state = AppState { client: reqwest::Client::new(), base_url: "http://127.0.0.1:11434/v1".to_string(), embed_model: "bge-m3".to_string(), chat_model: "qwen2.5:7b".to_string(), chunks: Vec::new(), chunk_vectors: Vec::new(), }; let app = Router::new() .route("/ask", post(ask_handler)) .with_state(state); // 监听地址按需设置,生产环境建议不要直接绑定 0.0.0.0 let listener = tokio::net::TcpListener::bind("127.0.0.1:8080") .await .unwrap(); axum::serve(listener, app).await.unwrap(); }这个示例只展示了接口骨架,没有把检索和生成逻辑放进去,避免代码过长。你可以把第 5 节的调用链嵌入ask_handler,把参数从 JSON 里取出来,返回回答结果。
6.3 curl 调用测试
服务启动后,用 curl 发一个请求验证:
curl -X POST http://127.0.0.1:8080/ask \ -H "Content-Type: application/json" \ -d '{"question": "Rust 里怎么做 RAG?"}'正常响应结构类似:
{ "question": "Rust 里怎么做 RAG?", "answer": "在 Rust 里做 RAG 需要先切块、向量化,再检索相似片段并拼接上下文发给大模型。" }6.4 批量任务设计
服务化之后,批量任务就容易做了。两种常见批量场景:
- 批量入库:遍历一个目录下的所有文档,逐个切块、嵌入、保存到向量存储。
- 批量问答:从一个
questions.txt文件里逐行读问题,对每个问题走一遍 RAG 流程,结果写入output/answers.txt。
批量任务要注意失败恢复。建议每条记录写入后立即落盘,并用序号标记进度,避免中途失败后从头再来。如果用 rayon 做并行检索,要注意向量库连接的线程安全性。
7. 资源占用与性能观察
7.1 三个主要资源瓶颈
RAG 链路里资源占用最大的三个环节是嵌入模型推理、生成模型推理、向量检索。
- 嵌入阶段:文档多时,嵌入调用次数多,耗时和文档总长度成正比。这一阶段主要是 CPU 或 GPU 的密集计算。
- 生成阶段:单次回答耗时主要由生成模型大小、上下文长度和输出长度决定。
- 检索阶段:内存向量库在数据量小的时候基本无感,数据量到几十万条后要观察内存占用和排序耗时。
7.2 如何观察资源占用
本地调试时,可以用系统工具查看:
# Linux / macOS top -o %MEM # 只看特定进程 ps aux | grep minimal_rag如果模型跑在 GPU 上,用nvidia-smi观察显存占用。显存占用会随上下文长度波动,批量任务期间尤其要留意显存是否被长时间占满。
更稳妥的判断是:先用小模型、小文档跑通流程,再用真实数据量压测。不要一开始就上长文档大全量索引,否则问题定位会变得很困难。
7.3 降低资源占用的方向
- 切块长度设小一点,减少单次嵌入和生成时的 token 数量。
- 使用量化版本更小的模型。
- 批量嵌入时限制并发数,避免内存暴涨。
- 向量存储开启持久化后,重启进程就不需要重新嵌入全部文档。
8. 常见问题与排查方法
Rust RAG 原型阶段最容易遇到的问题集中在模型服务、接口路径、文档路径和内存管理几个方面。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 启动后连接模型服务失败 | 模型服务未启动或地址端口错误 | 检查ollama serve是否运行,curl 测试模型服务地址 | 启动模型服务,修正base_url |
| 嵌入结果为空 | 嵌入模型名错误或接口响应结构变化 | 打印完整响应内容 | 确认嵌入模型已拉取,修正 JSON 字段路径 |
| 生成回答为空 | 生成模型未拉取或聊天接口路径错误 | 用 curl 直接调用chat/completions | 拉取模型,检查请求体结构 |
| 检索结果完全不相关 | 切块太大、文档太杂或嵌入模型效果差 | 打印检索到的 Top-K 文本内容 | 调整切块策略,增加文档预处理,换更强的嵌入模型 |
| 中文切块出现乱码 | 按字节切分导致 UTF-8 字符被截断 | 检查切块函数是否按字符边界切 | 使用chars()按字符处理 |
| 批量任务中途报错 | 某条数据格式异常或网络抖动 | 加日志,打印当前处理序号 | 逐条落盘,支持断点续跑 |
| 接口端口被占用 | 本地已有其他服务监听同一端口 | 用netstat或lsof查看端口占用 | 更换监听端口 |
| 内存占用持续上涨 | 向量全部驻留内存且没有释放 | 观察任务结束后内存是否回落 | 使用向量数据库或文件持久化,避免单进程持有全量向量 |
9. 最佳实践与使用建议
9.1 先小参数跑通,再扩大规模
第一次验证 RAG 时,建议用一篇 1000 字左右的文档、切块 256 字符左右、Top-2 检索。这样能快速确认链路通不通。链路通了之后再调参数、加文档、设计批量任务。
9.2 切块策略要按文档类型设计
通用固定长度切块只适合原型。真实项目里,Markdown 文档按标题切、代码文档按代码块边界切、表格数据按行切,效果会比固定长度好很多。切块时最好保留元数据,比如来源文件名、章节标题,方便检索结果溯源。
9.3 用重排序提升检索质量
简单余弦相似度检索可能把语义相近但不精确的内容放在前面。数据量上来后,可以加一个重排序模型,把 Top-K 候选重新排序。Rust 侧可以调用独立的 rerank 服务,或者在向量数据库的检索结果上再做一层过滤。
9.4 检索质量需要持续评估
RAG 不是“搭完就完事”的。要准备一组标准问题,定期跑一遍,观察回答是否能命中文档关键信息。发现问题后,先判断是检索问题还是生成问题:打印检索到的 Top-K 文本,看内容是否相关。如果检索到的内容不相关,问题在检索侧;如果检索内容相关但回答不对,问题在提示词或生成模型。
9.5 合规与安全边界
使用 RAG 时要特别注意知识库来源的合法性和隐私边界。
- 只能索引你有权使用的文档,涉及企业机密、个人隐私的数据必须评估访问权限。
- 本地部署模型可以降低数据外泄风险,但不等于绝对安全,服务端的访问控制仍然要做。
- 生产环境暴露 HTTP 接口时,不要直接绑定公网地址,至少加一层 API Key 或认证。
- 如果文档中包含人脸、声音、版权素材,商用前必须确认授权情况。
10. 总结与下一步
这一篇把 RAG 的最小闭环讲清楚了:从文档切块、向量化、相似度检索,到上下文拼接、生成回答,再到 HTTP 接口和批量任务思路。Rust 在这个链路里的角色不是替代模型服务,而是把文档处理、检索逻辑和接口编排做成一个可靠、可部署的服务。用 Rust 做 Agent 的好处是,最后交付的是一个单一二进制文件,资源占用可控,部署时不用在一台机器上装一堆运行时依赖。
最容易踩的坑有两个:一是模型服务的接口路径和响应结构与代码假设不一致;二是切块太随意导致检索结果相关性差。第一个坑通过打印响应日志就能解决,第二个坑需要持续调切块参数并检查检索结果。
下一步值得继续做的方向有三个:把内存向量存储替换成 Qdrant 或 pgvector,实现数据持久化;加入重排序模型,提升检索精度;把检索回路交给 Agent 控制,做成 Agentic RAG,让 Agent 能自己决定检索词和阅读深度。这些内容我会在 Rust AI Agent 系列的后续文章中展开。