ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Rust实现RAG:为AI Agent打造本地知识库问答最小原型

Rust实现RAG:为AI Agent打造本地知识库问答最小原型 这次我们来看 Rust AI Agent 系列的第 8 篇RAG 简介。前几篇文章已经把 Agent 的动作闭环搭起来了模型能理解任务、能调用工具、能处理结果。但很多实际场景里Agent 面对的问题不是“不会调用工具”而是“缺少领域知识”。比如你问它某个内部项目的部署步骤它没读过你的项目文档只能靠训练数据里的泛化经验去猜。猜得对不对完全看运气。RAGRetrieval-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 需要 RAG2.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) - VecString { let mut chunks Vec::new(); let chars: Vecchar 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, ) - ResultVecf32 { 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::Vecf32(); 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_scoresa( query_vec: [f32], items: a [(String, Vecf32)], 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, ) - ResultString { 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: VecString, chunk_vectors: VecVecf32, } async fn ask_handler( State(state): StateAppState, Json(payload): JsonValue, ) - JsonValue { 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 系列的后续文章中展开。
返回列表