数据截至 (上游 commit 65b4508389c8)
第 4 章 · RAG、Embedding 与结构化抽取
本章讲什么: 模型的知识停在训练截止日,也不知道你的私有数据。RAG(检索增强生成)就是「先从知识库里查出相关片段,再连问题一起喂给模型」。本章讲 Rig 怎么把「向量化 → 存 → 检索 → 喂给 agent」这条链统一起来,外加让模型返回结构化数据的
Extractor。
4.1 先建直觉:RAG 一条链
准备阶段(离线):
你的文档 ──► Embedding 模型向量化 ──► 存进向量库
查询阶段(在线):
用户问题 ──► 向量化 ──► 向量库里找最相近的 N 条 ──► 连问题一起喂给 agent ──► 回答
Rig 把这条链上的每一环都做成统一特征:向量化是 EmbeddingModel,「哪些数据要向量化」是 Embed,检索是 VectorStoreIndex。换向量库只换最后一个的实现。
4.2 标注要向量化什么:Embed 特征
一个结构体里往往只有部分字段需要参与语义检索(比如一篇文章,只有正文该向量化,ID 和时间戳不该)。Embed 特征让类型自己声明「哪些内容进向量」(crates/rig-core/src/embeddings/embed.rs:65):
// 示意,摘自 crates/rig-core/src/embeddings/embed.rs:43 文档示例
fn embed(&self, embedder: &mut TextEmbedder) -> Result<(), EmbedError> {
embedder.embed(self.content.clone()); // 只把 content 送去向量化
Ok(())
}
TextEmbedder 是个收集器(crates/rig-core/src/embeddings/embed.rs:73):你往里 embed(text) 推若干段文本,它收集起来。一个对象可以推多段——支持「一个文档从多个语义方向被检索」。常见基础类型(String 等)都有现成实现,复杂类型可以用 #[derive(Embed)](rig-derive crate 的 embed.rs)自动生成。
4.3 批量向量化:EmbeddingsBuilder 与 EmbeddingModel
EmbeddingModel 是向量化模型的统一特征(crates/rig-core/src/embeddings/embedding.rs:71),有个关键关联常量 MAX_DOCUMENTS(crates/rig-core/src/embeddings/embedding.rs:73)——每个供应商单次 API 能塞的文档数上限不同。
EmbeddingsBuilder 负责批量向量化(crates/rig-core/src/embeddings/builder.rs:51)。它按 MAX_DOCUMENTS 自动分批调 API,你不用操心批次大小。用法(RAG 文档示例在 crates/rig-agent/src/agent/mod.rs:56 起):
// 示意,摘自 crates/rig-agent/src/agent/mod.rs RAG 文档示例
let embeddings = EmbeddingsBuilder::new(embedding_model.clone())
.documents(vec!["定义A ...", "定义B ...", "定义C ..."])? // 一批文档
.build().await?; // 分批向量化
vector_store.add_documents(embeddings); // 存进向量库
4.4 检索的统一接口:VectorStoreIndex
检索由 VectorStoreIndex 特征统一(crates/rig-core/src/vector_store/mod.rs:129):
// 示意,摘自 crates/rig-core/src/vector_store/mod.rs:129 VectorStoreIndex
pub trait VectorStoreIndex: WasmCompatSend + WasmCompatSync {
type Filter: SearchFilter; // 该后端的过滤类型
fn top_n<T: Deserialize>(&self, req: VectorSearchRequest<Self::Filter>)
-> impl Future<Output = Result<Vec<(f64, String, T)>, ...>>; // 查最相近的 n 条
fn top_n_ids(&self, req: VectorSearchRequest<Self::Filter>)
-> impl Future<Output = Result<Vec<(f64, String)>, ...>>; // 只要 ID + 分数(更省)
}
新版把查询参数收进了一个 VectorSearchRequest(query、取样数 samples、过滤条件都放请求里,用 builder 构造),还引入了按后端定义的 Filter 关联类型——比裸传 (query, n) 能表达更多检索语义。两个方法仍是 RAG 检索的全部:top_n 返回「分数 + ID + 反序列化后的对象」,top_n_ids 只返回分数和 ID(不需要取回原文时更省)。MongoDB、Qdrant、Postgres 等约 10 个后端各实现一遍这个特征,检索语义就统一了。
还有一个 VectorStoreIndexDyn(crates/rig-core/src/vector_store/mod.rs:151)——和第 3 章工具的类型擦除同一个套路,做动态存放以便运行期组装检索策略。核心库自带一个 InMemoryVectorStore(crates/rig-core/src/vector_store/in_memory_store.rs)用于测试和小规模场景,还带一个 LSH(局部敏感哈希)实现(lsh.rs)加速近似检索。
4.5 RAG 落到 agent:dynamic_context 与 retrieved_tools
把检索接进 agent,靠 AgentBuilder 的两个方法(crates/rig-agent/src/agent/builder.rs):
| 方法 | 干什么 | 位置 |
|---|---|---|
dynamic_context(n, index) | 每次调模型前,从 index 检索 top-n 文档,作为上下文注入 | crates/rig-agent/src/agent/builder.rs:181 |
retrieved_tools(n, index, toolset) | 每次 prompt 时,从 index 检索 top-n 工具,动态提供给模型 | crates/rig-agent/src/agent/builder.rs:549 |
「静态」和「动态」的区别是理解 RAG agent 的关键:
静态上下文 static_context ── 永远提供(每次请求都带上)
动态上下文 dynamic_context ── 调模型前按相似度现查(RAG)
静态工具 ── 永远可用
按检索提供 retrieved_tools ── prompt 时按相似度现查(工具太多时,只给相关的)
dynamic_context 解决「知识库太大塞不进上下文窗口」——只检索最相关的几条。retrieved_tools 解决「工具太多,全给模型会干扰选择」——按当前问题只暴露相关工具。
检索的挂接方式值得一学(本版有变化):dynamic_context 不再改请求组装代码,而是注册一个内部 completion-call hook——DynamicContext 结构体(crates/rig-agent/src/agent/builder.rs:22)在 on_completion_call 里拿 prompt 的第一段文本(没有就回退到最近一条文本历史)发起 top_n 查询,把命中文档作为 extra_context 补丁贴进本轮请求(crates/rig-agent/src/agent/builder.rs:31 起);检索失败会在发出任何 provider 请求之前停掉整个 run(CompletionCallAction::stop,crates/rig-agent/src/agent/builder.rs:61)。注意方法名的一处演变:旧版叫 dynamic_tools(n, index, toolset),本版改名 retrieved_tools;dynamic_tool/dynamic_tools 这两个名字如今给了「上下文无关的动态工具」(DynamicTool,crates/rig-agent/src/tool/mod.rs:386),别被名字骗了。
这里回扣第 2 章:hook 产出的补丁,由共享驱动循环在组装 CompletionRequest 时消费——drive_agent 先跑 completion-call hooks 拿到 request_patch(crates/rig-agent/src/agent/prompt_request/streaming.rs:511),再把它交给 build_prepared_completion_request(crates/rig-agent/src/agent/completion.rs:237)拼出最终请求。检索依然发生在「状态机吐 CallModel 之后、真正发请求之前」。
4.6 结构化抽取:Extractor
RAG 是「让模型知道更多」,Extractor 是「让模型的输出更规整」——把自由文本变成能反序列化的 Rust 结构体(crates/rig-agent/src/extractor.rs:76,Extractor)。它现在住在 rig-agent crate(随 agent 运行时一起拆过去的),类型参数只剩 T——模型被收进它内部持有的 agent 里。
直觉:你想从一段话里抽出 { city, temperature, conditions }。传统做法是让模型输出 JSON 再手动解析、失败了再重试。Extractor 把这套封装好:
// 示意,基于 crates/rig-agent/src/extractor.rs Extractor::extract
let extractor = client.extractor::<WeatherForecast>(openai::GPT_5_2) // 传模型名,内部转成 ExtractorBuilder
.retries(2).build();
let forecast: WeatherForecast = extractor.extract("纽约今天怎么样").await?;
它的实现套路变了,但精神没变:构建时把目标类型 T 的 JSON Schema 挂成输出 schema,并直接钉死 OutputMode::Tool、tool_choice(Required)(ExtractorBuilder::from_model_handle,crates/rig-agent/src/extractor.rs:368 起,.output_schema::<T>() 在 :377)。抽取时用 agent 的 runner 注册一个名为 submit 的合成输出工具、max_turns(1) 单轮驱动模型(extract_json_with_usage,crates/rig-agent/src/extractor.rs:228),模型调 submit = 提交结构化结果,框架把参数反序列化成 T;解析失败或模型没调 submit,按 retries 字段整轮重来(retry_extract 循环,crates/rig-agent/src/extractor.rs:190)。这和第 5 章要讲的 OutputMode::Tool 是同一套机制。
Extractor 有多个变体覆盖不同需求:extract_with_chat_history(带历史)、extract_with_usage(同时返回 token 用量)等(crates/rig-agent/src/extractor.rs:287 起,extract / extract_with_chat_history / extract_with_usage)。用量统计跨重试累计——包括「模型收到了账单但没调 submit」的失败尝试。
第 1 章提过的 TypedPrompt::prompt_typed::<T>() 是更轻量的入口(crates/rig-agent/src/completion.rs:166)——不显式造 Extractor,直接在 agent 上要结构化输出。两者殊途同归。