ARTICLE DETAIL

资讯详情

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

Rust AI Agent 接入 RAG:从原理到最小闭环实现

Rust AI Agent 接入 RAG:从原理到最小闭环实现 在 Rust 里开发 AI Agent写到“知识接入”这一步时几乎一定会遇到 RAG。RAG 的全称是 Retrieval-Augmented Generation中文常叫检索增强生成它解决的是模型记忆与业务知识之间的边界问题。上一次已经把 Agent 的基本调用链路打通了这次的主题是把 RAG 接入到 Agent 中先讲清楚原理再准备 Rust 环境最后实现一个最小可运行的检索-生成闭环。RAG 并不是一个单独的库也不是一个大模型它更像一组工程组件的组合文档加载、文本切块、向量化、索引存储、相似度检索、提示词拼接。Rust 的优势在于这些组件之间的编排可以写得非常稳定并发控制、超时处理、错误恢复都不需要依赖额外运行时。对于需要长时间运行的 Agent 服务来说这个特性非常重要。这一篇适合已经了解 Rust 基本语法、正在学习 AI Agent 开发的读者。如果你已经具备一个简单的 Agent 调用框架但还不清楚怎么把知识库塞进提示词或者不确定文档应该如何切块、向量应该怎么存那这篇正好能帮你把整条链路理顺。1. 先理解 RAG 在 Rust AI Agent 中的定位1.1 RAG 解决什么问题先看一个常见场景。你自己维护了一个内部运维手册里面记录了服务启动命令、常见告警处理方式、数据库迁移脚本。Agent 需要根据用户问题回答“这个报错该怎么处理”。如果直接调用大模型模型没有见过你的内部文档只能给出通用建议甚至可能凭空编造步骤。RAG 的思路是在调用大模型之前先从外部知识库中检索出与问题最相关的资料片段把这些片段放到提示词里再让模型基于这些资料作答。这样模型不需要记忆你的文档只需要理解并加工检索到的内容。这个机制对 Agent 尤其重要。Agent 的主循环通常包含任务拆解、工具调用和结果整理。如果 Agent 内部没有知识库访问能力它只能依赖模型自身参数里的知识这会导致两个问题私有知识无法进入模型。文档更新后模型不会自动同步。RAG 把知识获取过程变成了“查文档 读文档”Agent 每次回答时都能拿到最新内容。1.2 典型 RAG 工作链路一条标准的 RAG 链路可以拆成离线索引和在线检索两段。离线索引阶段读取原始文档支持 txt、markdown、pdf、html 等格式。把长文档切分成若干合适粒度的文本块。调用 embedding 模型把每个文本块转成向量。把向量和文本块写入向量数据库同时保存文档编号、来源等元数据。在线检索阶段接收用户问题。对问题做同样的 embedding得到查询向量。在向量数据库中执行相似度搜索取回 TopK 个候选文本块。对候选文本块做必要的重排。把检索结果组装成提示词再交给大模型生成答案。用一句话概括离线把知识变成“索引”在线把问题变成“查询”最后让模型在限定上下文里作答。1.3 RAG 与微调、长上下文的对比很多人在搭建知识问答时都会纠结是用 RAG还是微调模型还是直接把文档塞进长上下文窗口。方案适用场景更新成本主要代价与 Agent 的配合RAG私有文档、频繁更新、需要可追溯来源低重新索引即可需要维护检索质量天然适合工具调用Agent 可以按需检索微调模型能力固化、领域术语强绑定高需要整理训练集并重新训练成本高更新慢较难临时改变知识范围长上下文单次文档量小、可以接受较高延迟和成本低token 成本高大量无关内容会干扰生成适合一次性的总结需求RAG 不是要替代微调。如果模型在特定领域的表达风格和术语理解长期固定微调仍有价值。但 RAG 更灵活更适合 Agent 这种需要频繁切换任务场景的系统。实际项目里也经常把两者结合用微调优化模型能力用 RAG 补充动态知识。1.4 Rust 在 RAG 链路中的位置在 Rust 生态里RAG 相关的核心组件通常来自三部分向量数据库客户端例如 Qdrant、Milvus、Redis 向量模块的客户端。embedding 推理既可以通过 HTTP 调用远程模型服务也可以使用本地推理库。文本生成通常走 OpenAI 兼容接口或本地推理服务。Rust 在这条链路里更适合作为胶水层和服务端层。Agent 的调度逻辑、检索模块、超时控制、并发请求处理都可以用 Rust 实现。向量数据库本身通常是独立服务Rust 代码通过客户端连接它。不要一上来就追求用 Rust 从头实现 embedding 模型。Rust 当然有能力承载推理但在多数业务场景中把模型推理交给成熟的推理服务把业务编排写在 Rust 里开发效率会高很多。2. 环境准备Rust 工具链与项目依赖2.1 Rust 工具链安装与 Windows 注意事项如果你还没安装 Rust先通过 rustup 安装工具链。Linux 和 macOS 上直接执行 rustup 安装脚本即可。Windows 上有两种常见工具链选择x86_64-pc-windows-msvc默认工具链依赖 Visual Studio 的 C 构建工具。x86_64-pc-windows-gnu使用 GNU 工具链不需要完整安装 MSVC。有的 Windows 机器没有安装完整 Visual Studio安装 MSVC 工具链时会遇到链接器缺失的问题。如果不想安装 MSVC 关联组件可以改用 GNU 工具链。安装时按提示选择安装完成后用下面的命令确认工具链rustc --version cargo --version rustup show如果默认下载源速度较慢可以通过环境变量指向镜像源。不同的网络环境对 rustup 默认地址的连通性不同具体镜像地址以社区教程为准。不要修改系统 PATH 里的关键值只需设置当前用户环境变量。2.2 创建项目与添加依赖以学习为例创建一个独立项目cargo new rust-agent-rag cd rust-agent-ragCargo.toml里先加入这一篇会用到的基础依赖。这里的版本号只是示例落地前需要去 crates.io 上确认实际最新版本和 API 变化。[package] name rust-agent-rag version 0.1.0 edition 2021 [dependencies] tokio { version 1, features [full] } serde { version 1, features [derive] } serde_json 1 anyhow 1 reqwest { version 0.12, features [json] } qdrant-client 1 fastembed 3tokio用于处理异步任务。RAG 链路里大量操作是网络 IO包括调用 embedding 服务、查询向量数据库、请求大模型用异步运行时可以避免线程空等。reqwest负责 HTTP 请求qdrant-client是 Qdrant 向量数据库的官方客户端fastembed可以加载本地 embedding 模型。如果你的 embedding 和生成模型都以 HTTP 接口形式提供那么fastembed可以不加只需要保留reqwest。这里先保留本地 embedding 选项方便后续切换。2.3 项目目录结构后面代码会涉及多个模块提前规划结构rust-agent-rag/ ├── Cargo.toml ├── src/ │ ├── main.rs │ ├── lib.rs │ ├── document.rs │ ├── chunker.rs │ ├── embedder.rs │ ├── vector_store.rs │ ├── retriever.rs │ └── prompt.rs ├── tests/ │ └── retrieval_tests.rs └── data/ ├── raw/ └── chunks/document.rs定义文档和文本块结构chunker.rs负责把长文档切开embedder.rs封装 embedding 调用vector_store.rs封装向量数据库写入和查询retriever.rs组装检索流程prompt.rs负责把检索结果拼到提示词里。在代码还很少的时候这个结构看起来有点重。但一旦开始接真实数据你会发现在 main.rs 里堆所有逻辑会非常难排查。早期拆好边界后续新增重排、过滤、日志都会轻松很多。3. 写一个最小 RAG 流程从文档到检索再到提示词3.1 用 Trait 定义组件边界Rust 里适合用 trait 定义 RAG 组件的边界。这样不同实现可以互相替换单元测试也能用假实现代替真实服务和模型。在document.rs里定义基础类型use std::collections::HashMap; #[derive(Debug, Clone)] pub struct Document { pub id: String, pub text: String, pub metadata: HashMapString, String, } #[derive(Debug, Clone)] pub struct Chunk { pub document_id: String, pub chunk_index: usize, pub text: String, pub metadata: HashMapString, String, } #[derive(Debug, Clone)] pub struct ScoredDocument { pub chunk: Chunk, pub score: f32, }Document是原始文档Chunk是切块后的文本块ScoredDocument是检索结果加上相似度分数。在lib.rs里定义接口pub trait Chunker { fn chunk(self, document: Document) - VecChunk; } pub trait Embedder { fn embed_texts(self, texts: [String]) - anyhow::ResultVecVecf32; fn embed_query(self, query: str) - anyhow::ResultVecf32; } pub trait VectorStore { fn create_collection_if_not_exists(self, collection: str, dim: u64) - anyhow::Result(); fn upsert_chunks(self, collection: str, chunks: [Chunk], vectors: [Vecf32]) - anyhow::Result(); fn search(self, collection: str, query_vector: [f32], top_k: u64) - anyhow::ResultVecScoredDocument; }这里把 embedding 分成embed_texts和embed_query。很多模型对文档和查询会使用不同的指令前缀分开定义更贴近实践。3.2 文档加载与切块实现先实现一个简单的固定长度切块器。这里要特别提醒字符切块只是用于演示流程实际项目里应该优先按段落或句子边界切避免把一个语义点从中间截断。在chunker.rs里use crate::document::{Chunk, Document}; use crate::Chunker; use std::collections::HashMap; pub struct FixedSizeChunker { pub size: usize, pub overlap: usize, } impl FixedSizeChunker { pub fn new(size: usize, overlap: usize) - Self { assert!(size overlap, size must be greater than overlap); Self { size, overlap } } } impl Chunker for FixedSizeChunker { fn chunk(self, document: Document) - VecChunk { let chars: Vecchar document.text.chars().collect(); let mut chunks Vec::new(); let mut start 0usize; let mut index 0usize; let step self.size - self.overlap; while start chars.len() { let end (start self.size).min(chars.len()); let text: String chars[start..end].iter().collect(); chunks.push(Chunk { document_id: document.id.clone(), chunk_index: index, text, metadata: document.metadata.clone(), }); if end chars.len() { break; } start step; index 1; } chunks } }关键点是使用chars()而不是直接对字节切片。Rust 的字符串是 UTF-8 编码直接text[start..end]在中文、Emoji 等场景下很容易因为切到字符中间而 panic。按char处理会慢一点但更安全。overlap的作用是让前后块之间保留一段重叠文本。假设 size 是 500 字overlap 是 50 字那么第二块从第一块的 450 字开始这样跨块边界的信息不至于完全丢失。3.3 调用 embedding 模型embedding 有两条路径。第一条是调用远程模型服务第二条是本地加载模型。远程服务更容易上手也更容易替换模型。在embedder.rs里封装一个 HTTP 调用use crate::Embedder; use anyhow::Context; use reqwest::Client; pub struct HttpEmbedder { client: Client, endpoint: String, model: String, api_key: OptionString, } impl HttpEmbedder { pub fn new(endpoint: String, model: String, api_key: OptionString) - Self { Self { client: Client::new(), endpoint, model, api_key, } } } #[async_trait::async_trait] impl Embedder for HttpEmbedder { async fn embed_texts(self, texts: [String]) - anyhow::ResultVecVecf32 { let mut request self .client .post(self.endpoint) .json(serde_json::json!({ model: self.model, input: texts, })); if let Some(key) self.api_key { request request.bearer_auth(key); } let response: serde_json::Value request .send() .await .with_context(|| embedding request failed)? .json() .await .with_context(|| parse embedding response failed)?; let data response[data] .as_array() .context(embedding response missing data)?; let mut result Vec::with_capacity(data.len()); for item in data { let vec_value item[embedding] .as_array() .context(embedding item missing embedding field)?; let vector vec_value .iter() .map(|v| v.as_f64().unwrap_or(0.0) as f32) .collect::Vecf32(); result.push(vector); } Ok(result) } async fn embed_query(self, query: str) - anyhow::ResultVecf32 { let mut vecs self.embed_texts([query.to_string()]).await?; Ok(vecs.remove(0)) } }注意这里用了#[async_trait::async_trait]需要在Cargo.toml里加async-trait依赖。异步 trait 目前在 Rust 主版本里还不稳定async-trait是常见做法。你可能会问为什么要通过环境变量或配置传入 endpoint而不是直接写死因为学习环境可能用的是本地模型服务测试环境可能用到内部共享服务生产环境可能切换成云厂商接口。硬编码会破坏可移植性。3.4 向量库接入以 Qdrant 为例Qdrant 可以通过 Docker 快速启动docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant:latest6333 是 REST API 端口6334 是 gRPC 端口。qdrant-client默认走 gRPC所以代码里连接的是 6334。在vector_store.rs里封装 Qdrant 操作use crate::document::{Chunk, ScoredDocument}; use crate::VectorStore; use anyhow::Context; use qdrant_client::qdrant::{ CreateCollectionBuilder, Distance, PointStruct, SearchPointsBuilder, UpsertPointsBuilder, VectorParamsBuilder, }; use qdrant_client::Qdrant; pub struct QdrantStore { client: Qdrant, } impl QdrantStore { pub fn new(url: String) - anyhow::ResultSelf { let client Qdrant::from_url(url).build()?; Ok(Self { client }) } } #[async_trait::async_trait] impl VectorStore for QdrantStore { async fn create_collection_if_not_exists(self, collection: str, dim: u64) - anyhow::Result() { let collections self.client.list_collections().await?; let exists collections .collections .iter() .any(|c| c.name collection); if !exists { self.client .create_collection( CreateCollectionBuilder::new(collection) .vectors_config(VectorParamsBuilder::new(dim, Distance::Cosine)), ) .await?; } Ok(()) } async fn upsert_chunks(self, collection: str, chunks: [Chunk], vectors: [Vecf32]) - anyhow::Result() { let points chunks .iter() .zip(vectors.iter()) .enumerate() .map(|(idx, (chunk, vector))| { PointStruct::new( format!({}-{}, chunk.document_id, chunk.chunk_index), vector.clone(), serde_json::json!({ document_id: chunk.document_id, chunk_index: chunk.chunk_index, text: chunk.text, metadata: chunk.metadata, }), ) }) .collect::Vec_(); self.client .upsert(UpsertPointsBuilder::new(collection).points(points)) .await .context(upsert chunks failed)?; Ok(()) } async fn search(self, collection: str, query_vector: [f32], top_k: u64) - anyhow::ResultVecScoredDocument { let result self .client .search(SearchPointsBuilder::new(collection, query_vector.to_vec(), top_k)) .await .context(search failed)?; let mut scored Vec::new(); for point in result.result { let text point.payload.get(text).and_then(|v| v.as_str()).unwrap_or_default(); let document_id point.payload.get(document_id).and_then(|v| v.as_str()).unwrap_or_default().to_string(); let chunk_index point.payload.get(chunk_index).and_then(|v| v.as_u64()).unwrap_or_default() as usize; let chunk Chunk { document_id, chunk_index, text: text.to_string(), metadata: Default::default(), }; scored.push(ScoredDocument { chunk, score: point.score, }); } Ok(scored) } }qdrant-client的 API 在不同版本里会有调整尤其是 Builder 模式方法的参数位置。如果编译报错先看 rust-analyzer 给出的方法签名再对照当前 crate 文档修改。读取 payload 时使用了unwrap_or_default这会丢失一些元数据。实际项目中建议用serde_json::from_value把 payload 解析成自定义结构体这样类型更安全。3.5 制作 RAG 检索管线在retriever.rs里组合上面的组件use crate::document::{Document, ScoredDocument}; use crate::{Chunker, Embedder, VectorStore}; pub struct RAGPipeline { chunker: Boxdyn Chunker, embedder: Boxdyn Embedder, vector_store: Boxdyn VectorStore, collection: String, } impl RAGPipeline { pub fn new( chunker: Boxdyn Chunker, embedder: Boxdyn Embedder, vector_store: Boxdyn VectorStore, collection: String, ) - Self { Self { chunker, embedder, vector_store, collection, } } pub async fn index_document(self, document: Document) - anyhow::Result() { let chunks self.chunker.chunk(document); let texts chunks.iter().map(|c| c.text.clone()).collect::Vec_(); let vectors self.embedder.embed_texts(texts).await?; self.vector_store.upsert_chunks(self.collection, chunks, vectors).await } pub async fn retrieve(self, query: str, top_k: u64) - anyhow::ResultVecScoredDocument { let query_vector self.embedder.embed_query(query).await?; let results self.vector_store.search(self.collection, query_vector, top_k).await?; Ok(results) } }这个RAGPipeline目前只负责索引和检索。它不直接生成提示词也不负责调用大模型。保持单一职责后面接 Agent 时会更方便。3.6 检索结果如何拼进 Agent 提示词在prompt.rs里定义提示词模板use crate::document::ScoredDocument; pub fn build_rag_prompt(query: str, results: [ScoredDocument]) - String { let mut context_parts Vec::new(); for (i, item) in results.iter().enumerate() { context_parts.push(format!( [{}] 来源{}片段{}\n{}, i 1, item.chunk.document_id, item.chunk.chunk_index, item.chunk.text )); } let context context_parts.join(\n\n); format!( 你是一个 AI Agent。下面是知识库中检索到的资料如果资料不足请直接说无法回答。\n\ 请严格基于资料给出答案不要编造不存在的操作步骤。\n\n\ 资料\n{context}\n\n\ 问题{query}\n ) }提示词里包含两个关键设计。一是明确要求模型“基于资料回答”降低幻觉概率。二是要求“资料不足时直接说无法回答”避免强行拼凑答案。4. 关键参数、切块策略与向量索引怎么选择4.1 embedding 模型选择embedding 模型的输出向量维度会直接影响向量库集合配置。不同模型对中文支持程度差异很大选择时至少确认三件事模型是否擅长目标语言。输出维度是多少。是否区分查询和文档的输入方式。常见的选择矩阵如下模型类型优势注意事项云厂商 embedding API部署简单服务稳定数据会发送到外部服务注意隐私边界开源中文 embedding 模型中文效果往往更好可本地部署需要自己管理推理服务使用时要匹配模型说明中的查询指令通用多语言模型多语言资料可统一索引维度通常较大存储成本高如果输入材料没有明确版本落地前先确认依赖版本。特别是本地模型发行方会在模型说明里注明推荐的编码方式比如加了“查询”前缀和“文档”前缀再调用模型检索效果会有差异。4.2 chunk_size、overlap、TopK 和 score_threshold参数含义常见范围调大影响调小影响chunk_size单个文本块长度300-800 字或按语义段落上下文语义更完整但噪声增加定位更精确但可能丢失上下文overlap相邻块重叠长度50-150 字跨块信息不容易丢但索引冗余增加减少冗余但可能切断上下文top_k检索返回的候选块数3-8信息更多生成可能引入不相关内容更聚焦但可能漏掉相关内容score_threshold相似度分数阈值0.3-0.7视距离类型而定过滤掉不相关结果容易返回大量无关结果这些参数彼此关系紧密。chunk_size 增大后一个块里的信息变多但检索到的块可能只命中其中一小段其他部分成为噪声。chunk_size 减小后单个块更精准但必要的上下文可能被切断需要 overlap 来补偿。建议按“段落优先、长度兜底”的方式切块而不是只按字符数硬切。可以先按 markdown 标题分节再按空行分段最后对超长段落按长度切。4.3 向量距离类型Qdrant 创建集合时需要指定距离类型。距离类型语义适用场景Cosine比较向量方向忽略长度文本 embedding 最常用Dot点积未归一化时受向量长度影响模型本身做了归一化时可用Euclidean欧式距离越小越相似某些图像或数值特征场景对绝大多数文本 embedding推荐 Cosine。如果你的 embedding 服务已经返回归一化向量Dot 和 Cosine 结果上等价。4.4 学习环境与生产环境差异学习阶段可以把配置写死在代码里或者通过.env文件读取。生产环境至少要额外考虑配置外置化不能把服务地址、模型名称、集合名称写死在代码里。索引脚本和在线检索脚本分离避免 Agent 请求时同时触发大量文档写入。向量数据库的备份和重建流程模型升级后维度变化通常需要重建集合。权限控制不是所有 Agent 用户都应有向量库写入权限。日志和监控记录每次检索的 top_k 结果、耗时和相似度分数方便定位问题。5. 运行验证准备测试数据并检查检索效果5.1 准备一份最小测试资料在data/raw/ops.md里写一段模拟运维文档# 服务启动流程 1. 修改配置文件 /etc/app/config.yml。 2. 运行 systemctl start app-service。 3. 查看日志 /var/log/app-service/app.log。 # 常见报错 如果出现 502 错误先检查网关模块是否健康。 如果出现数据库连接超时检查数据库连接池配置和网络连通性。这段文本比较短实际使用中应该准备几十篇甚至上百篇文档才能评估切块和检索是否有效。学习阶段用几段足够跑通流程。5.2 在主程序里串起全流程在main.rs里写一个测试入口use rust_agent_rag::chunker::FixedSizeChunker; use rust_agent_rag::document::Document; use rust_agent_rag::prompt::build_rag_prompt; use rust_agent_rag::retriever::RAGPipeline; use rust_agent_rag::vector_store::QdrantStore; use rust_agent_rag::embedder::HttpEmbedder; #[tokio::main] async fn main() - anyhow::Result() { let collection ops_docs.to_string(); let chunker FixedSizeChunker::new(100, 20); let embedder HttpEmbedder::new( http://localhost:8888/embed.to_string(), model-name.to_string(), None, ); let store QdrantStore::new(http://localhost:6334.to_string())?; store.create_collection_if_not_exists(collection, 1024).await?; let pipeline RAGPipeline::new(Box::new(chunker), Box::new(embedder), Box::new(store), collection); let doc Document { id: ops-001.to_string(), text: std::fs::read_to_string(data/raw/ops.md)?, metadata: Default::default(), }; pipeline.index_document(doc).await?; println!(index done); let results pipeline.retrieve(服务启动失败怎么办, 3).await?; for item in results { println!(score: {:.4}, document: {}, chunk: {}, item.score, item.chunk.document_id, item.chunk.chunk_index); println!(---); println!({}, item.chunk.text); println!(---); } let prompt build_rag_prompt(服务启动失败怎么办, results); println!({}, prompt); Ok(()) }这段代码会依次执行建档、切块、向量化、写入、检索、提示词拼接。你不需要在第一次运行就接上真实大模型先确认检索结果和提示词模板正确再接生成阶段。5.3 从日志判断检索质量运行后你可能看到类似输出score: 0.8321, document: ops-001, chunk: 0 --- # 服务启动流程 1. 修改配置文件 /etc/app/config.yml。 ...判断标准不是分数越高越好而是看前三名结果是否真的与问题相关。如果“服务启动失败怎么办”返回的是数据库连接超时那段说明检索结果有问题需要调整切块策略或 query 前缀。5.4 量化评估命中率与 MRR为了客观评估准备一组“问题 - 正确答案所在文档”的测试数据。假设有 N 个测试问题每个问题对应一个期望命中的 chunk。命中率计算方式每个问题检索 TopK如果返回结果里包含期望 chunk则记为 1否则为 0最后求平均值。MRR 计算方式对每个问题看期望 chunk 排在返回结果第几位如果没有命中则倒数记为 0最后求平均倒数排名。pub fn hit_rate(results: [Vecusize]) - f64 { let hits results.iter().filter(|r| !r.is_empty()).count(); hits as f64 / results.len() as f64 } pub fn mrr(results: [Vecusize]) - f64 { let mut sum 0.0; for r in results { if let Some(idx) r.first() { sum 1.0 / (idx 1) as f64; } } sum / results.len() as f64 }这里向量里的数字表示期望 chunk 在返回结果中的位置。实际项目中你可能需要人工标注答案来源或者用大模型自动判断检索结果是否包含关键信息。6. 常见问题排查从报错到检索质量差6.1 Rust 工具链或 Cargo 下载失败现象rustup安装时下载卡住或者cargo build拉取 crates 时长时间无响应。检查步骤运行rustc --version确认工具链是否安装。运行rustup show查看当前默认工具链。检查~/.cargo/config.toml是否配置了镜像源。解决办法在用户级 Cargo 配置里替换 crates 下载源。不同镜像源的地址可能变化配置时以当前可用的源为准。[source.crates-io] replace-with mirror [source.mirror] registry sparsehttps://mirrors.example.com/index/配置完成后重新执行cargo clean再cargo build。如果镜像源不可用改为官方源并启用稀疏索引。6.2 Windows 上 cargo 链接失败提示找不到 link.exe现象安装默认 MSVC 工具链后编译 Rust 项目时链接阶段报错。可能原因系统只有 Rust 工具链没有安装 MSVC 的 C 生成工具。解决方式安装 GNU 工具链并把它设为默认rustup toolchain install stable-x86_64-pc-windows-gnu rustup default stable-x86_64-pc-windows-gnu需要注意部分系统库对 GNU 工具链的支持情况不同如果项目使用了较多 Windows 原生依赖还是建议安装完整 MSVC 环境。6.3 向量维度不匹配现象创建集合时配置的维度是 1024embedding 服务返回的向量维度是 768查询时报错或者写入时报向量维度不一致。排查方式打印模型返回向量的长度。查看集合配置里的向量维度。如果已经是无效集合删除后按正确维度重建。这个错误最常见的原因是升级 embedding 模型后忘记重建索引。6.4 中文文本切块时出现乱码或 panic现象Rust 程序在字符串切片时 panic提示 boundary。原因直接按字节索引切割 UTF-8 字符串很容易切到字符中间。解决方式使用chars()迭代后再重组文本。遇到中英文混排、标点符号、Emoji 时都不要按字节硬切。6.5 检索结果不相关现象查询词与文档关键词相似但检索回的 chunk 不是想要的段落。可能原因chunk_size 太大一个块里包含多个主题。chunk_size 太小句子被切断。embedding 模型不擅长当前语言。查询词和文档措辞差异大。没有执行重排。排查和解决原因检查方式处理建议切块粒度不合适打印前几条检索结果看文本是否完整调整 chunk_size 和 overlap优先按段落切查询指令未匹配查看 embedding 模型文档区分 query 和 document 的输入模板分数阈值过高打印相似度分数分布降低阈值或取消阈值先观察需要重排观察 Top5 中相关结果位置增加交叉编码器重排6.6 索引写入后检索不到新数据现象文档已经调用 upsert 成功但检索时没有返回任何内容。检查点是否使用了同一个集合名称。写入时是否指定了 collection检索时是否写错。是否在写入后立即查询Qdrant 默认可能有一定延迟但大多数情况下写入后很快可查。是否通过向量库管理后台能看到point数据。如果写入时指定了 payload 字段名但检索时读取的是另一个字段名也会出现“写入成功、读不出来”的情况。建议把 payload 字段名统一写成常量避免拼写不一致。7. 从基础 RAG 走向 Agentic RAG7.1 基础 RAG 的局限性前面实现的流程是“一次检索一次生成”的线性结构。它适合大多数知识问答场景但用在 AI Agent 里会暴露几个限制Agent 无法决定什么时候该检索什么时候不该检索。第一次检索失败后不能自动重写查询。复杂问题需要拆成多个子问题子问题答案还要聚合线性 RAG 做不到。如果用户连续追问Agent 没有历史会话记忆与当前检索结果的整合逻辑。基础 RAG 更像是“检索工具”而 Agentic RAG 是把检索放进 Agent 决策循环里。7.2 Agentic RAG 的典型行为Agentic RAG 的关键步骤可能包括判断用户问题是否需要外部知识。把原始查询改写成更适合检索的形式。选择检索哪类知识库比如向量库、结构化数据库、知识图谱。根据第一轮答案判断是否需要二次检索。综合多次检索结果后再生成最终答案。在 Rust 里这通常表现为一个循环pub struct AgentLoop { pub max_iterations: usize, pub tools: VecBoxdyn Tool, } #[async_trait::async_trait] pub trait Tool { fn name(self) - str; async fn run(self, input: str) - anyhow::ResultString; }RAG 检索可以作为其中一个Tool实现。Agent 主循环决定调用它时才去执行向量检索。这样就把 RAG 从“固定流程”变成了“Agent 的可用工具”。7.3 向量检索与知识图谱的关系最近经常看到 RAG、知识图谱与向量数据库放在一起讨论。向量检索擅长找语义相近的片段但它不理解实体之间的关系。比如“A 服务依赖 B 服务B 服务挂了会影响哪个上游系统”这类问题单纯靠向量检索很难准确回答。知识图谱可以记录实体和关系比如“服务 A 依赖服务 B”“服务 B 部署在节点 C 上”。Agent 可以先从图谱里定位实体关系再从向量库中检索对应文档两者结合能显著提高复杂问题回答质量。Ontology RAG 指的是在检索时借助本体定义把知识按固定类别和属性组织起来。它比纯关键词更准确但需要人工整理结构。Rust 项目里可以通过图数据库或先构建边表来存储关系检索时用图查询脚本配合向量检索。7.4 在 Rust 中预留扩展能力如果后续要升级到 Agentic RAG现在就要注意模块边界。不要让RAGPipeline直接调用大模型也不要让向量存储接口里混入重排逻辑。更好的设计是检索组件只返回候选结果和分数。重排组件接收候选结果输出排序后的结果。Agent 主循环决定是否调用检索和重排。提示词组件只负责格式化。这样将来加工具、加图谱、加查询改写都不需要重写已有模块。8. 最佳实践与下一步建议8.1 一套可以复用的上线检查清单开发完 RAG 模块后按下面清单逐项检查文档来源是否清晰是否有明确的更新策略。文本切块是否优先按段落和标题边界而不只是按长度硬切。embedding 模型是否区分了文档输入和查询输入。向量集合的维度是否与 embedding 模型一致。检索结果是否打印了分数和来源方便人工抽查。提示词是否明确要求模型基于资料回答。是否设置了合理的超时和重试。知识库更新是否走独立索引流程而不是每次在线请求时重新索引。日志是否记录了 query、top_k 结果、耗时和异常信息。是否有权限控制防止普通调用方写入向量库。其中最容易忽略的是文档更新策略。RAG 上线后文档会不断变化。如果不写“文档变更 - 重新切块 - 重新写入”的自动化流程知识库内容就会逐渐过期。8.2 代码实现建议写 RAG 代码时不要把所有逻辑堆到 main.rs。至少拆成数据模型、切块、embedding、存储、检索、提示词这六个部分。在正式项目中优先给每个组件定义 trait。这样可以做到本地用假 embedding 服务测试索引流程。测试代码用内存 Map 模拟向量存储。不同业务可以复用同一套检索逻辑。具体到编写细节注意以下几点远程 API 调用要设置超时时间避免模型服务卡死导致 Agent 无法响应。embedding 的 batch size 不宜过大一次发几十条文本可能触发服务端限制建议按 16 或 32 分批。大文档索引时要监控内存占用不要把整个文件夹一次性读进内存。向量库集合名称、embedding 模型名称、维度建议写入配置不要硬编码。8.3 下一步可以扩展的方向这一篇完成了 RAG 的基础闭环接下来的扩展方向可以从浅到深排列。第一接入真实生成模型。你已经有了检索结果和提示词下一步就是把提示词发给文本生成模型接口把生成的答案返回给用户。第二增加重排。基础向量检索的 TopK 结果里可能混杂不相关内容重排可以显著提升最终答案质量。重排模型通常比 embedding 模型更重但只需要对 TopK 候选结果排序开销可控。第三做查询改写。对用户问题先做一次改写把口语化表达转成更适合检索的书面表达再调用 embedding。这个步骤在问答场景中非常有效。第四引入 Agentic RAG。让 Agent 自己决定是否需要检索、检索几次、要不要换一个检索库。这需要先实现 Agent 的工具调用能力和上下文管理能力。第五尝试 GraphRAG 或 Ontology RAG。如果业务中存在大量实体关系可以在向量检索之外增加图谱查询把结构化关系和非结构化文本结合起来。RAG 在 Rust AI Agent 中不是终点它是 Agent 获取外部知识的一种标准方式。先跑通最小闭环再逐步加查询改写、重排、多轮检索你的系统才能真正适应复杂的真实问题。下一篇可以继续聚焦 Agent 的工具调用与检索结果的整合把这一篇留下的生成阶段补完整。
返回列表