# Phase 15: 向量存储持久化(SqliteStore 后端) - **文档编号**:21 - **标题**:Phase 15 — 向量存储持久化 - **日期**:2026-07-09 - **状态**:待实施 - **涉及模块**:`memory/vector_store.rs`(新文件)、`memory/vector.rs`、`memory.rs`、`examples/document_demo.rs` - **关联文档**:roadmap.md(§Phase 15)、18-phase11-testing-and-retrieval.md(VectorRetriever trait)、20-phase14-document-and-embedding.md(Document 系统 + Embedding 抽象) - **对应**:Roadmap §Phase 15(v0.3.0 第三阶段) - **预估规模**:新增约 400 行核心代码 + 约 30 行示例修改 --- ## 1. 背景与目标 ### 1.1 背景 agcore v0.3.0 的目标是从「LLM 调用工具箱」升级为「能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统」。Roadmap §Phase 15 定位为 v0.3.0 的第三阶段,在 Phase 14 交付了 Document 类型和 Embedding 抽象之后,补齐 RAG 管线的**核心存储与检索层**。 当前 agcore 已有: - `VectorRetriever` trait(Phase 11)——语义向量检索抽象,支持 `index(id, embeddings)` 和 `search(query, k)` - `InMemoryVectorRetriever`(Phase 11)——基于 HashMap 的内存引用实现 - `Document` 类型 + `RecursiveCharacterSplitter`(Phase 14)——文档分割 - `Embedding` trait + `MockEmbedding`(Phase 14)——文本向量化抽象 这四个组件构成了 RAG 管线的「前置」和「后置」环节,但缺少中间的 **向量存储** 环节——一个能持久化保存向量和文档、支持进程重启后恢复数据的核心存储抽象。 ### 1.2 目标 1. 定义 `VectorStore` trait——统一向量存储的 `add` / `search` / `remove` 接口 [高] 2. 实现 `InMemoryVectorStore`——基于 HashMap 的内存引用实现,支持余弦相似度全量扫描 [高] 3. 实现 `PersistentVectorStore`——基于 SqliteStore 的持久化包装,R/W 时双向同步 [高] 4. 实现 `RagPipeline`——组合器,封装 `split → embed → store.add`(ingest)和 `embed → store.search`(retrieve) [高] 5. 标记 `VectorRetriever` 为废弃(`#[deprecated]`),引导迁移到 `VectorStore` [中] 6. 更新 `document_demo` 示例,从手动循环迁移到 `RagPipeline` 两行调用 [中] 7. 代码零新增外部依赖 [高] ### 1.3 适用范围 | 纳入 | 排除 | |------|------| | `VectorStore` trait 定义(add / search / remove) | ANN 近似最近邻索引(O(n) 扫描,<10K 向量足够) | | `InMemoryVectorStore`(HashMap + 余弦全量扫描) | `update` 方法(remove + add 即可) | | `PersistentVectorStore`(SqliteStore JSON blob 后端读写) | namespace 运行时 CRUD(构造参数固定) | | `RagPipeline`(具体 struct,非 trait) | 维度校验——调用方保证同一 VectorStore 实例写入的所有向量维度一致(通过 `Embedding::dim()` 确认);VectorStore 不做运行时校验 | | 零新增外部依赖 | `RagPipeline` trait 抽象(等第二种 pipeline 实现) | | `VectorRetriever` 废弃标记(`#[deprecated]`) | pgvector / Qdrant 后端 | --- ## 2. 当前状态分析 ### 2.1 现有 RAG 能力矩阵 | 维度 | 已实现 | 缺失 | |------|--------|------| | 文档分割 | ✅ `RecursiveCharacterSplitter`(Phase 14) | — | | 文本向量化 | ✅ `Embedding` trait + `MockEmbedding`(Phase 14) | 真实 Provider(Phase 15 不解决) | | 向量检索抽象 | ✅ `VectorRetriever` trait(Phase 11) | 持久化语义检索 | | 向量检索内存实现 | ✅ `InMemoryVectorRetriever`(Phase 11) | — | | 向量持久化 | ❌ | `PersistentVectorStore`(本 Phase) | | RAG 管线编排 | ❌ | `RagPipeline`(本 Phase) | | SqliteStore 后端 | ✅ Phase 7 | — | ### 2.2 现有检索抽象对比 **VectorRetriever trait(Phase 11,将被废弃)**: ```rust #[async_trait] pub trait VectorRetriever: Send + Sync { async fn index(&self, id: String, embeddings: Vec) -> Result<(), MemoryError>; async fn search(&self, query: Vec, k: usize) -> Result, MemoryError>; } ``` `index` 只接受单个 `(id, embeddings)` 对,不支持批量添加;`search` 返回 `(id, score)`,不附带文档内容。调用方必须自行维护 `id → Document` 的映射。 **VectorStore trait(本 Phase)**: ```rust #[async_trait] pub trait VectorStore: Send + Sync { async fn add(&self, documents: &[Document], embeddings: &[Vec]) -> Result<(), MemoryError>; async fn search(&self, query: &[f32], k: usize) -> Result, MemoryError>; async fn remove(&self, ids: &[String]) -> Result<(), MemoryError>; } ``` `add` 接受批量 `(doc, embedding)` 对;`search` 直接返回 `(Document, score)`,调用方无需额外映射。消除 Phase 11 设计中 `id → Document` 的隐式耦合。 ### 2.3 模块依赖关系 ``` 当前(Phase 14 完成后): Document + RecursiveCharacterSplitter (src/document.rs) ↓ output: Vec RagPipeline::ingest (本 Phase) ← Embedding trait (src/llm/embedding.rs) ├── splitter.split() → Vec chunks ├── embedder.embed() → Vec> └── store.add() → 持久化 ↓ RagPipeline::retrieve (本 Phase) ├── embedder.embed(query) → Vec> └── store.search() → Vec<(Document, f32)> VectorRetriever trait (src/memory/vector.rs) → #[deprecated] ↓ 消费端迁移 VectorStore trait (src/memory/vector_store.rs) ← 新 ├── InMemoryVectorStore ← 新 ├── PersistentVectorStore ← 新 (依赖 SqliteStore) └── 被 RagPipeline 消费 ← 新 ``` ### 2.4 风险识别 | 风险 | 等级 | 缓解措施 | |------|------|---------| | PersistentVectorStore 构造时全量加载阻塞 | 低 | `MemoryStore::list()` 由 SqliteStore 内部 `spawn_blocking` 卸载;10K 条预估 <500ms | | JSON 序列化/反序列化性能 | 低 | 每条向量存储为独立 MemoryItem,写入/读取均单条操作,无全量 JSON | | 大向量集(>10K 条)搜索 O(n) 性能 | 中 | 文档标注当前为全量扫描,提供升级路径到 ANN 索引 | | SqliteStore 非向量专用后端,查询不支持向量索引 | 低 | 设计目标即「小数据量够用」,超出规模换专用后端 | | `add()` 不等长输入部分写入已发生 | 中 | 截断处理 + 返回 `Err(InvalidInput)`,调用方可 `let _ =` 忽略 | | 向量维度不一致(不同 Embedding provider 混用) | 低 | VectorStore 不做运行时校验,调用方自行通过 `Embedding::dim()` 确认维度一致性 | | 调用方传入重复 `doc.id` 静默覆盖 | 低 | `HashMap::insert` 自然行为;等价于 upsert 语义,需在 doc comment 中注明 | | MemoryStore 淘汰策略误删向量条目 | 低 | VectorEntry 存储 `created_at` 保留原始时间,避免 TTL 误判;默认 `EvictionPolicy::None` 无害 | --- ## 3. 可选方案 ### 3.1 模块归属:vector_store.rs 放在哪里? | 方案 | 描述 | 优点 | 缺点 | |------|------|------|------| | **A. `memory/vector_store.rs`(推荐)** | 与 `memory/vector.rs`(现有 VectorRetriever)同目录 | 语义一致:向量存储属于记忆系统;复用现有 `MemoryError`、`MemoryStore` 依赖;文件路径短 | 目录已存在,初始化工作量小 | | B. 新顶层模块 `vector/` | 创建 `src/vector/mod.rs` + `vector_store.rs` | 语义独立,与 Document 顶层模块对等 | 增加顶层入口,增加模块声明开销;VectorStore 依赖 MemoryStore/MemoryError,放在 memory 外部反而增加耦合 | **结论**:选择 **方案 A**。Roadmap 原定 `src/vector/` 新模块,但实际分析发现 VectorStore 是记忆系统的自然扩展(类似 `KnowledgeStore` 放在 `memory/`),且 `VectorRetriever` 已在 `memory/vector.rs`。放在同一模块可以减少跨模块引用和 `use` 路径复杂度。 [高] ### 3.2 VectorStore trait:批量 vs 单条 | 方案 | 描述 | 优点 | 缺点 | |------|------|------|------| | **A. 批量 add(推荐)** | `add(&[Document], &[Vec])` | RagPipeline 一次 ingest 处理多个 chunk 时只需要一次 async call;批量语义明确 | 需要处理不等长输入 | | B. 单条 add | `add(Document, Vec)` | 签名简单,没有不等长问题 | RagPipeline 遍历每个 chunk 逐个 await,N 个 chunk 产生 N 次 async roundtrip | **结论**:选择 **方案 A**。提供 `add_one` 便捷方法作为语法糖。 [高] ### 3.3 search 返回值:元组 vs 命名结构体 | 方案 | 描述 | 优点 | 缺点 | |------|------|------|------| | **A. 元组(推荐)** | `Vec<(Document, f32)>` | 零新类型;符合 Rust 惯用风格(与 `VecRetriever::search` 一致);字段访问 `result.0.content` 直观 | .score 不如命名结构体 readable | | B. `ScoredDocument` 结构体 | `struct ScoredDocument { doc: Document, score: f32 }` | 语义清晰;可扩展字段 | YAGNI:当前没有需要扩展的额外字段;增加类型结构复杂度 | **结论**:选择 **方案 A**。保持与 Phase 11 `VectorRetriever::search` 返回 `Vec<(String, f32)>` 一致的设计语言,只是把 `String`(id)替换为 `Document`。 [高] ### 3.4 错误类型:复用 vs 新建 | 方案 | 描述 | 优点 | 缺点 | |------|------|------|------| | **A. 复用 MemoryError(推荐)** | 所有 VectorStore API 返回 `Result<(), MemoryError>` 和 `Result, MemoryError>` | 零新类型;已有 `InvalidInput`(不等长)、`NotFound`(删除不存在 id)、`Serialization`(JSON 反序列化)变体 | `MemoryError::Serialization` 的语义略宽,但 JSON 反序列化失败确实属于序列化错误 | | B. 新建 `VectorError` | 独立枚举:`InvalidInput` / `DimensionMismatch` / `Serialization` / `Storage` | 类型精确;不与 memory 错误耦合 | Phase 15 RagPipeline 需要同时处理 `MemoryError` + `VectorError` + `LlmError`;组合复杂度高 | **结论**:选择 **方案 A**。VectorStore 是 `memory/` 模块的直接组成部分,复用 MemoryError 自然合理。 [高] ### 3.5 RagPipeline:struct vs trait | 方案 | 描述 | 优点 | 缺点 | |------|------|------|------| | **A. 具体 struct(推荐)** | `RagPipeline { embedder, store, splitter }` | 代码量最少;当前只有一个实现 | 如果将来出现第二流水线(如多模态嵌入),需要抽象 | | B. `RagPipeline` trait | 定义 `ingest` / `retrieve` 接口 | 可插拔 | YAGNI:当前没有第二种 pipeline 需求;trait 一旦发布,签名变动是 breaking change | **结论**:选择 **方案 A**。YAGNI 原则。当出现第二种 pipeline 实现时,再从 struct 中提取 trait。 [高] ### 3.6 持久化格式:JSON blob vs 结构化列 | 方案 | 描述 | 优点 | 缺点 | |------|------|------|------| | **A. JSON blob(推荐)** | 每条向量存为 `MemoryItem`,content 为 `JSON.stringify(VectorEntry)` | 零 schema 迁移;利用现有 `MemoryStore` API(save/get/list/delete) | 无法用 SQL 直接查询向量内容(业务上也不需要有此需求) | | B. SqliteStore 专用表 | 在 sqlite_store.rs 中新增 `vectors` 表 | 查询效率高 | 突破 MemoryStore trait 抽象层,增加跨模块依赖;PersistentVectorStore 丧失后端无关性 | **结论**:选择 **方案 A**。`MemoryStore` 的 `list(prefix)` 可以按 namespace 过滤,足以支持构造时的全量加载。JSON blob 的序列化/反序列化成本在 <10K 向量场景下可以忽略。 [高] ### 3.7 模块扁平 vs 子目录 | 方案 | 描述 | 优点 | 缺点 | |------|------|------|------| | **A. 扁平(推荐)** | `src/memory/vector_store.rs` 单文件 | 约 400 行,一个文件包含 trait + 三个 struct + 内联测试 | 如果后期需要拆分为 `search/`、`persist/` 等子模块,需重构为目录 | | B. 目录预置 | `src/memory/vector_store/` 子目录 | 预留扩展空间 | 推测性设计;当前内容不足以分割 | **结论**:选择 **方案 A**。所有代码在一个文件中,结构清晰:trait 定义在前,实现在后,测试在 `#[cfg(test)]` 中。 [高] --- ## 4. 推荐方案 ### 4.1 整体架构 ``` Phase 15 交付 ┌─────────────────────────────────────────────────────┐ │ src/memory/vector_store.rs │ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ VectorStore trait │ │ │ │ add(docs, embeddings) -> Result<(), ME> │ │ │ │ search(query, k) -> Result,ME>│ │ │ │ remove(ids) -> Result<(), ME> │ │ │ │ add_one(doc, emb) -> Result<(), ME> │ │ │ └──────────────┬───────────────────────────────┘ │ │ │ implements │ │ ┌──────────────┴───────────────┐ │ │ │ InMemoryVectorStore │ │ │ │ entries: HashMap │ │ │ │ dot() / cosine_similarity() │ │ │ └──────────────┬───────────────┘ │ │ │ wraps │ │ ┌──────────────┴───────────────┐ │ │ │ PersistentVectorStore │ │ │ │ inner: InMemoryVectorStore │ │ │ │ store: Arc │ │ │ │ namespace: String │ │ │ │ add(): 内存 + SqliteStore 双写 │ │ │ └──────────────────────────────┘ │ │ │ │ ┌──────────────────────────────┐ │ │ │ RagPipeline │ │ │ │ embedder: Arc│ │ │ │ store: Arc │ │ │ │ splitter: Option │ │ │ │ ingest: split→embed→store │ │ │ │ retrieve: embed→search │ │ │ └──────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ 存储格式(SqliteStore 视角): MemoryItem.id = "vec:{namespace}:{doc_id}" MemoryItem.content = JSON(VectorEntry { doc_id, content, metadata, mime_type, embedding }) MemoryItem.metadata = {} ``` ### 4.2 核心类型设计 #### VectorStore trait ```rust /// 向量存储抽象 —— 语义检索的核心接口。 /// /// 提供文档-向量的批量添加、余弦相似度搜索、批量删除三个核心操作。 /// 所有实现必须满足 Send + Sync 以支持跨 .await 调用。 /// /// # 并发安全 /// /// 实现内部必须使用线程安全的容器(如 Mutex 或 RwLock), /// 允许跨多个 tokio task 共享 `&VectorStore` 引用。 #[async_trait] pub trait VectorStore: Send + Sync { /// 批量添加文档及其向量。 /// /// `documents` 和 `embeddings` 必须等长。不等长时: /// - 截取 `min(len)` 对处理(部分写入已发生) /// - 返回 `Err(MemoryError::InvalidInput)` 告知截断 /// - 调用方可以 `let _ = store.add(...)` 忽略错误 async fn add(&self, documents: &[Document], embeddings: &[Vec]) -> Result<(), MemoryError>; /// 检索与 `query` 向量最相似的 `k` 条记录。 /// /// 返回 `Vec<(Document, f32)>`,其中 `f32` 为余弦相似度分数, /// 取值范围 `[0.0, 1.0]`(对单位向量),按分数降序排列。 /// /// # 守卫 /// /// - 空索引 → 返回 `vec![]` /// - `k == 0` → 返回 `vec![]` /// - 零向量(norm ≈ 0)→ 返回 `vec![]` async fn search(&self, query: &[f32], k: usize) -> Result, MemoryError>; /// 批量删除文档(幂等)。 /// /// 不存在的 id 静默忽略,不会返回错误。 async fn remove(&self, ids: &[String]) -> Result<(), MemoryError>; /// 便捷方法:单条添加。 /// /// 等价于 `self.add(&[doc], &[emb]).await`。 async fn add_one(&self, doc: Document, emb: Vec) -> Result<(), MemoryError> { self.add(&[doc], &[emb]).await } } ``` **设计决策**: - `documents` 和 `embeddings` 的输入使用切片引用(`&[Document]`、`&[Vec]`)——零拷贝,调用方可以传入 `Vec` 的引用或数组切片 [高] - `search` 返回 `Vec<(Document, f32)>` 而非 `Vec<(String, f32)>`——消除调用方维护 `id → Document` 映射的负担 [高] - `remove` 幂等设计——不存在的 id 静默忽略,简化调用方逻辑(无需在删除前检查 id 是否存在) [高] - 提供默认实现的 `add_one` 便捷方法——`&[x]` 语法足够精简,但 `add_one` 在链式调用中更清晰 [中] #### InMemoryVectorStore ```rust /// 内存向量存储 —— VectorStore 的引用实现。 /// /// 内部使用 `Mutex)>>` 存储, /// `search()` 执行 O(n) 全量余弦相似度扫描,适用于 ≤10K 条向量的场景。 /// /// # 并发安全 /// /// 使用 `std::sync::Mutex`(非 tokio Mutex)。 /// /// **锁持有时间评估**: /// - `add()` / `remove()`:微秒级(HashMap 插入/删除操作) /// - `search()`:毫秒级(O(n) 全量扫描 + 余弦计算),对 10K 条 1536 维向量预估 1-10ms。 /// 实现时在锁内克隆数据快照到 Vec 后立即释放锁,在锁外进行余弦相似度计算, /// 避免长时间持有锁阻塞并发写操作。 pub struct InMemoryVectorStore { pub(crate) entries: Mutex)>>, } impl InMemoryVectorStore { /// 创建一个空存储。 pub fn new() -> Self { /* ... */ } /// 从预填充的 entries 构造(供 PersistentVectorStore 使用)。 pub(crate) fn with_entries( entries: HashMap)>, ) -> Self { /* ... */ } } ``` **add 实现逻辑**: ``` add(documents, embeddings): if documents.len() == embeddings.len(): for i in 0..len(doc): insert(doc[i], emb[i]) return Ok(()) else: tracing::warn!( docs = documents.len(), embs = embeddings.len(), "InMemoryVectorStore::add 长度不匹配,截断到 min" ) min_len = min(len(doc), len(emb)) for i in 0..min_len: insert(doc[i], emb[i]) return Err(InvalidInput) insert(doc, emb): entries.lock() → map.insert(doc.id.clone(), (doc, emb)) ``` > **与 `InMemoryVectorRetriever` 的行为差异**:旧 `VectorRetriever::index` 对不等长输入使用 `zip` 静默截断(无返回值),新 `VectorStore::add` 显式检查并返回 Err。迁移时调用方应检查返回的 Err 或确保输入等长。 **search 实现逻辑**: ``` search(query, k): if k == 0: return vec![] tracing::trace!("InMemoryVectorStore::search, k={k}") 对整个 query 向量做零向量检测(所有分量 ≈ 0.0) lock entries → 获取所有值的快照 Vec<(Document, Vec)> for each (doc, emb): score = cosine_similarity(query, emb) if score < 1e-10: continue (排除无关或空向量) if score < 1e-10: continue # 浮点除零保护:余弦相似度分母含 1e-10,正交或零向量产生趋零值,不构成有效结果 results.push((doc, score)) results.sort_by(|a, b| b.1.partial_cmp(&a.1)) // 降序 results.truncate(k) return results ``` **remove 实现逻辑**: ``` remove(ids): lock entries → map.retain(|key, _| !ids.contains(key)) // retain 在 Rust 中是对传入闭包返回 true 的元素保留 // 所以我们保留「不在 ids 中」的元素 tracing::debug!(count = ids.len(), "InMemoryVectorStore::remove") ``` #### 余弦相似度辅助函数 ```rust /// 点积。 fn dot(a: &[f32], b: &[f32]) -> f32 { debug_assert_eq!(a.len(), b.len(), "dot: 不等长向量 ({}/{}), 维度不匹配将导致静默截断", a.len(), b.len()); a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() } /// 余弦相似度,加 1e-10 防除零。 fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { let dot_product = dot(a, b); let norm_a = dot(a, a).sqrt(); let norm_b = dot(b, b).sqrt(); dot_product / (norm_a * norm_b + 1e-10) } ``` **设计决策**: - `dot()` 和 `cosine_similarity()` 为 `pub(crate)` 私有辅助函数——不暴露为公共 API,如需在其他模块使用,可通过 `InMemoryVectorStore::search` 间接调用 [中] - 使用 `std::sync::Mutex` 而非 `tokio::sync::Mutex`。`add`/`remove` 锁持有微秒级(HashMap 插入/删除),`search` 在锁内克隆数据快照后立即释放锁,在锁外计算余弦相似度(预估 1-10ms/10K 条),避免长时间持有锁阻塞并发写操作 [高] - 零向量守卫在 `search()` 中而非 `cosine_similarity()` 中——避免每次相似度计算都检测,只在入口做一次 [中] #### PersistentVectorStore ```rust /// 持久化向量存储 —— 基于 MemoryStore 的持久化包装。 /// /// # 架构 /// /// 运行时全量加载到 InMemoryVectorStore 做余弦搜索, /// 写操作(add/remove)同时同步到内存和后端 MemoryStore。 /// /// # 存储格式 /// /// 每条向量存为一条 MemoryItem: /// - `id`: `"vec:{namespace}:{doc_id}"`(colon-separated namespace 前缀) /// - `content`: JSON 序列化的 VectorEntry /// - `metadata`: 空 HashMap /// /// # 构造开销 /// /// `new()` 通过 `store.list(prefix)` 全量加载已有条目, /// 时间复杂度 O(N)(N 为已有向量数),适用于 ≤10K 条的场景。 pub struct PersistentVectorStore { inner: InMemoryVectorStore, store: Arc, namespace: String, } ``` **私有 VectorEntry 结构体**: ```rust #[derive(Serialize, Deserialize)] struct VectorEntry { doc_id: String, content: String, metadata: HashMap, mime_type: String, embedding: Vec, /// ISO 8601 创建时间(UTC),持久化 roundtrip 重建时保持原时间, /// 避免 MemoryStore 的 TTL 淘汰策略误判。 created_at: String, } ``` **构造方法 new()**(异步): ```rust pub async fn new(store: Arc, namespace: &str) -> Result { let prefix = format!("vec:{}:", namespace); let filter = MemoryFilter { prefix: Some(prefix.clone()), ..Default::default() }; // MemoryStore::list 内部由 SqliteStore 使用 spawn_blocking 卸载到阻塞线程池 tracing::debug!(namespace = %namespace, "PersistentVectorStore::new — 开始全量加载"); let items = store.list(&filter).await?; tracing::info!(count = items.len(), "PersistentVectorStore::new — 加载完成"); let mut entries = HashMap::new(); for item in items { let entry: VectorEntry = serde_json::from_str(&item.content) .map_err(|e| MemoryError::Serialization(e.to_string()))?; let doc = Document { id: entry.doc_id, content: entry.content, metadata: entry.metadata, mime_type: entry.mime_type, }; // created_at 保留在 VectorEntry 中用于 roundtrip 重建,TTL 淘汰由 MemoryStore 管理 // (VectorEntry.created_at 字段在反序列化时已自动填充) entries.insert(doc.id.clone(), (doc, entry.embedding)); } tracing::info!(entries = entries.len(), "PersistentVectorStore — 内存索引重建完成"); Ok(Self { inner: InMemoryVectorStore::with_entries(entries), store, namespace: namespace.to_string(), }) } ``` > **spawn_blocking 说明**:`MemoryStore::list()` 是 async trait 方法,`SqliteStore` 实现内部使用 `tokio::task::spawn_blocking` 将 SQLite 查询卸载到阻塞线程池。`PersistentVectorStore::new()` 本身在 async context 中调用即可,无需额外 spawn_blocking。加载耗时 ≈ SQLite 查询时间 + JSON 反序列化时间,10K 条向量预估在 200ms-500ms 范围内(O(n) 反序列化,每条 JSON <2KB)。 **add 双写路径**(先持久化后内存): ``` add(documents, embeddings): tracing::debug!(count = docs.len(), "PersistentVectorStore::add") 1. for each (doc, emb): entry = VectorEntry { doc_id, content, metadata, mime_type, embedding: emb, created_at: OffsetDateTime::now_utc() .format(&Rfc3339) // ISO 8601 字符串 } json = serde_json::to_string(&entry) key = format!("vec:{}:{}", self.namespace, doc.id) self.store.save(MemoryItem { id: key, content: json, metadata: {}, .. }) .await? // 先写持久化(失败时不污染内存) 2. self.inner.add(docs, embs).await? // 再写内存 3. Ok(()) ``` **search 完全委托**: ``` search(query, k): tracing::trace!("PersistentVectorStore::search, k={k}") self.inner.search(query, k).await // 纯内存搜索 ``` **remove 双删**(先删持久化后删内存): ``` remove(ids): tracing::debug!(count = ids.len(), "PersistentVectorStore::remove") 1. for each id: key = format!("vec:{}:{}", self.namespace, id) self.store.delete(&key).await? // 先删持久化(幂等) 2. self.inner.remove(ids).await? // 再删内存 3. Ok(()) ``` **设计决策**: - `new()` 使用 `async` 而非同步构造——`MemoryStore::list()` 是异步操作;即使调用方想立即 `.await`,异步构造也比同步构造 + 显式 `init()` 调用更安全(不会忘记调用 `init()`) [高] - 使用 `MemoryFilter { prefix: Some(...) }` 而非 `list_all` + 客户端过滤——SqliteStore 支持 prefix 索引扫描,减少网络传输量 [高] - `VectorEntry.doc_id` 使用 `doc.id` 的值而非重新生成——删除时可通过 id 反推 MemoryItem key [高] - 写入顺序:**先持久化后内存**——持久化写入失败时内存不被污染,下次构造 `PersistentVectorStore` 从持久化全量加载,自动忽略不完整的写入;内存写入失败时持久化已写入(产生「持久化有、内存无」的中间状态),但 `search` 读到的是持久化已确认子集而非超集,不会返回幽灵数据;`add` 和 `remove` 双写均遵守此顺序 [高] - 批量 add 的非原子性:`add` 的持久化阶段对 N 条文档执行 N 次独立 `store.save()`,非原子操作。第 i 条失败时前 i-1 条已持久化。这是已知限制,未来可通过引入 `MemoryStore::save_batch` 批量 API 优化。当前 `PersistentVectorStore::new()` 全量加载时能正确跳过缺失条目 [中] > **remove 幽灵数据窗口**:先 `store.delete()` 成功后、`inner.remove()` 前的窗口内,`search()` 委托到 `inner` 可能返回已被删除的条目(幽灵数据)。该窗口有限——重启后从持久化全量加载时幽灵条目自然消失。调用方如需精确状态,可在 `remove()` 返回后主动调用 `search()` 验证。 [中] **键命名示例**: | namespace | doc.id | MemoryItem id | |-----------|--------|---------------| | `"default"` | `"doc_001:chunk:0000"` | `"vec:default:doc_001:chunk:0000"` | | `"reports"` | `"rpt_42"` | `"vec:reports:rpt_42"` | | `"research"` | `"paper_xyz:chunk:0005"` | `"vec:research:paper_xyz:chunk:0005"` | **注意**:`doc.id` 中如果包含冒号(如 `doc_001:chunk:0000`),拼接后的 key 为 `vec:namespace:id` 三层结构。`MemoryFilter` 的 prefix 匹配使用 `prefix = format!("vec:{}:", namespace)`,即 `"vec:default:"`,可以匹配 `"vec:default:doc_001:chunk:0000"` 和 `"vec:default:other_id:with:colons"`。因为 prefix 只是字符串前缀匹配,中间包含冒号不影响匹配的正确性。 [高] #### RagPipeline ```rust /// RAG 管线组合器 —— 封装 split → embed → store 和 embed → search 两个核心流程。 /// /// # 使用方式 /// /// ```rust /// let pipeline = RagPipeline::new(embedder, store); /// pipeline.ingest(&documents).await?; /// let results = pipeline.retrieve("what is agcore?", 5).await?; /// ``` /// /// # 分割器 /// /// `splitter` 字段为 `Option`: /// - `Some(splitter)` → `ingest()` 先分割再嵌入(调用方传入原始文档) /// - `None` → `ingest()` 跳过分割,直接嵌入(调用方已分好 chunk) pub struct RagPipeline { embedder: Arc, store: Arc, splitter: Option, } impl RagPipeline { /// 创建新的 RAG 管线。 /// /// 不设置分割器时,`ingest()` 跳过分割阶段, /// 调用方传入的 Document 应已是分割好的 chunk。 pub fn new( embedder: Arc, store: Arc, splitter: Option, ) -> Self { /* ... */ } /// 摄取文档:分割 → 向量化 → 存储。 /// /// 流程: /// 1. 如果 splitter 存在,先分割文档为 chunks /// 2. 提取所有 chunk 的 content 为 Vec /// 3. embedder.embed() 批量向量化 /// 4. store.add() 批量存储 /// /// # 边界 /// /// - 空文档切片 → Ok(()), 无操作 /// - 分割后 chunk 为空 → Ok(()), 无操作 pub async fn ingest(&self, documents: &[Document]) -> Result<(), MemoryError> { // 分割(可选) let chunks = match &self.splitter { Some(splitter) => splitter.split(documents), None => documents.to_vec(), }; if chunks.is_empty() { return Ok(()); } // 向量化 let texts: Vec = chunks.iter().map(|d| d.content.clone()).collect(); let embeddings = self.embedder.embed(&texts) .await .map_err(|e| MemoryError::Storage(e.to_string()))?; // 存储 self.store.add(&chunks, &embeddings).await?; Ok(()) } /// 检索:向量化查询 → 向量相似度搜索。 /// /// # 边界 /// /// - 空字符串查询 → 返回 vec![](embed 产生零向量 → search 零向量守卫) pub async fn retrieve(&self, query: &str, k: usize) -> Result, MemoryError> { let embeddings = self.embedder.embed(&[query.to_string()]) .await .map_err(|e| MemoryError::Storage(e.to_string()))?; self.store.search(&embeddings[0], k).await } } ``` **设计决策**: - `embedder.embed()` 返回 `LlmError`,`store.add/search` 返回 `MemoryError`——在两个错误类型之间通过 `MemoryError::Storage(e.to_string())` 桥接 [高] - `splitter` 为 `Option`——兼顾「调用方已分割」和「需要管线自动分割」两种场景 [高] - `ingest` 和 `retrieve` 均返回 `MemoryError`——上层统一处理一种错误类型,简化调用方代码 [高] - `retrieve` 内部构造 `&[query.to_string()]`——接受 `&str` 而非 `&[String]`,接口更友好 [中] ### 4.3 边界情况处理 | 场景 | 行为 | 原因 | |------|------|------| | `add()` 输入 `documents.len() ≠ embeddings.len()` | 截取 `min(len)` 对处理,返回 `Err(InvalidInput)` | 不等长属于调用方错误;截取保证至少部分写入有效 | | `add()` 输入不等长截断后 | 调用方可 `let _ = store.add(...).await` 忽略 Err(部分数据已写入),或检查 Err 的 `Display` 文案了解截断数量 | 不等长时无法区分"全部写入"和"部分写入",调用方应评估业务容忍度 | | `add()` 重复 `doc.id` | 静默覆盖(upsert 语义)——后写入的 Document 和 embedding 替换前者 | HashMap insert 的自然行为,等价于 update | | `add()` 输入空切片 `(&[], &[])` | 空操作,返回 `Ok(())` | 合法输入 | | `remove()` 不存在的 id | 静默忽略(幂等) | 简化调用方逻辑 | | `remove()` 空切片 `(&[])` | 空操作,返回 `Ok(())` | 合法输入 | | `search()` 空索引 | 返回 `vec![]` | 无数据可查 | | `search()` `k = 0` | 返回 `vec![]` | 请求 0 条结果 | | `search()` 零向量查询 | 返回 `vec![]`(零向量守卫) | 零向量无法做有意义的相似度比较 | | `search()` 索引中所有向量与查询正交 | 返回 `vec![]`(相似度 ≤ 1e-10 全部过滤) | 无相关内容 | | `PersistentVectorStore::new()` 加载失败 | 返回 `Err(MemoryError::Storage)` | 依赖后端异常,不可恢复 | | JSON 反序列化失败 | 返回 `Err(MemoryError::Serialization)` | 数据损坏或 schema 变更 | | `ingest()` 空文档切片 | `Ok(())`,无操作 | 合法输入 | | `ingest()` 分割后 chunk 为空 | `Ok(())`,无操作 | 所有文档均为空内容 | | `add_one()` 内部委托 | 等价于 `add(&[doc], &[emb])` | 保持一致性 | ### 4.4 错误处理策略 | 场景 | 处理方式 | 原因 | |------|---------|------| | add() 不等长 | `Err(MemoryError::InvalidInput)`(含截断说明) | 调用方可选择 `let _ =` 忽略或检查错误 | | PersistentVectorStore::add 持久化写入失败 | `Err` 传播(内存未写入——先写持久化后写内存) | 先持久化后内存,持久化失败时内存不被污染,重启后自动忽略不完整写入 | | PersistentVectorStore::remove 持久化删除失败 | `Err` 传播(内存未删除——先删持久化后删内存) | 先持久化后内存,持久化失败时内存不受影响;尚未删除的条目可安全重试 | | PersistentVectorStore 构造加载失败 | `Err` 传播 | 启动失败应尽早暴露 | | JSON 序列化/反序列化失败 | `Err(MemoryError::Serialization)` | 数据损坏属不可恢复错误 | | MemoryStore 后端 IO 错误 | `Err` 传播 | 依赖底层错误,不转换 | | remove() 不存在的 id | 静默忽略(幂等) | 符合 MemoryStore::delete 的幂等语义 | | RagPipeline ingest 空文档列表 | `Ok(())`,无操作 | 合法输入 | | RagPipeline 中 `LlmError` | 桥接为 `MemoryError::Storage`(原始错误 via `.to_string()`) | 统一错误类型降低调用方复杂度 | ### 4.5 与现有系统的关系 ``` 前 Phase 11 (memory/vector.rs): VectorRetriever trait → #[deprecated(since = "0.3.0")] InMemoryVectorRetriever → #[deprecated(since = "0.3.0")] 本 Phase (memory/vector_store.rs): VectorStore trait → 新 InMemoryVectorStore → 新(语义等价于 InMemoryVectorRetriever) PersistentVectorStore → 新 RagPipeline → 新 Phase 14 消费: Document → VectorStore::add 输入类型 Embedding trait → RagPipeline::ingest/retrieve 调用 RecursiveCharacterSplitter → RagPipeline::ingest 可选前置 ``` --- ## 5. 实施计划 ### 5.1 文件与代码量估计 | 文件 | 角色 | 估计行数 | 新增/修改 | |------|------|---------|-----------| | `src/memory/vector_store.rs` | VectorStore trait + InMemoryVectorStore + PersistentVectorStore + RagPipeline + ~19 测试 | ~400 | **新增** | | `src/memory/vector.rs` | VectorRetriever + InMemoryVectorRetriever 加 deprecation 标注 | +4 | 修改 | | `src/memory.rs` | 追加 `pub mod vector_store` + re-export | +3 | 修改 | | `examples/document_demo.rs` | 从手动循环迁移到 RagPipeline 两行调用 | ~-30 | 修改 | | **合计** | | **~377** | **1 新增 + 3 修改** | ### 5.2 实施步骤 ``` Step 15.1 ──→ Step 15.2 ──→ Step 15.3 ──→ Step 15.4 ──→ Step 15.5 ──→ Step 15.6 vector_store 追加 追加 标记废弃 + 简化 全量验证 .rs Persistent RagPipeline 更新 re-export document_demo cargo test (trait + VectorStore + 测试 + 测试 cargo clippy InMemory) cargo doc ``` #### Step 15.1 — 创建 vector_store.rs(trait + InMemoryVectorStore) **涉及文件**:`src/memory/vector_store.rs`(新增) **前置依赖**:无(独立新增文件) **工作量**:M(1-2h) **验收条件**:`cargo build --all-targets` 通过 **内容**: 1. `VectorStore` trait 定义(`#[async_trait]`,3 个必选方法 + `add_one` 默认实现) 2. `InMemoryVectorStore` struct + `new()` / `with_entries()` 构造器 3. `VectorStore` for `InMemoryVectorStore` 的实现: - `add`:等长校验 → 遍历 zip → insert(不等长截断 + Err) - `search`:lock → 空/k=0/零向量守卫 → 全量余弦扫描 → sort → truncate - `remove`:lock → HashMap::retain 4. 私有辅助函数 `dot(a, b) → f32` 和 `cosine_similarity(a, b) → f32` 5. 内联测试(10 个):basic_add_and_search / search_empty_store / search_zero_vector / search_k_is_zero / search_orthogonal_vectors / add_mismatched_lengths / add_duplicate_id_upsert / remove_items / remove_nonexistent_id / concurrent_operations **验证**:`cargo build --all-targets` 编译通过。 #### Step 15.2 — 追加 PersistentVectorStore **涉及文件**:`src/memory/vector_store.rs` **前置依赖**:Step 15.1 **工作量**:M(1-2h) **验收条件**:持久化 roundtrip 测试(write → close → reopen → read)通过 **内容**: 1. 私有 `VectorEntry` 结构体(`#[derive(Serialize, Deserialize)]`) 2. `PersistentVectorStore` struct + `async fn new(store, namespace)` 构造方法 - `store.list(prefix="vec:{namespace}:")` 全量加载 - `serde_json::from_str` 反序列化 - `InMemoryVectorStore::with_entries()` 注入 3. `VectorStore` for `PersistentVectorStore` 实现: - `add`:序列化每个 VectorEntry → 先逐个 `store.save()`(先持久化)→ 再 `inner.add()`(后内存) - `search`:完全委托 `inner.search()` - `remove`:逐个 `store.delete()`(先删持久化)→ 再 `inner.remove()`(后删内存) **验证**:`cargo test -- memory::vector_store::persistent` 全部通过。 #### Step 15.3 — 追加 RagPipeline **涉及文件**:`src/memory/vector_store.rs` **前置依赖**:Step 15.2 **工作量**:S(< 1h) **验收条件**:端到端 ingest_and_retrieve 集成测试通过 **内容**: 1. `RagPipeline` struct(3 个字段:embedder / store / splitter) 2. `new()` 构造函数 3. `ingest()`:split(可选)→ embed → store.add 4. `retrieve()`:embed → store.search 5. 桥接 `LlmError → MemoryError::Storage` 6. 内联测试(4 个):ingest_and_retrieve + retrieve_empty_store + ingest_empty_docs(空文档切片返回 `Ok(())`)+ ingest_empty_split(分割后 chunk 为空返回 `Ok(())`) **验证**:`cargo test -- memory::vector_store::rag` 全部通过。 #### Step 15.4 — 标记 VectorRetriever 废弃 + 更新 re-export **涉及文件**:`src/memory/vector.rs`(修改)、`src/memory.rs`(修改) **前置依赖**:Step 15.1(VectorStore trait 存在) **工作量**:S(< 0.5h) **验收条件**:`cargo build` 项目内部零 deprecation warning(已使用 `#[allow(deprecated)]` 的测试除外) **内容**: - `src/memory/vector.rs`: - `VectorRetriever` trait 前加 `#[deprecated(since = "0.3.0", note = "请使用 memory::VectorStore")]` - `InMemoryVectorRetriever` struct 前加 `#[deprecated(since = "0.3.0", note = "请使用 memory::InMemoryVectorStore")]` - 内联测试前加 `#[allow(deprecated)]` - `src/memory.rs`: - 追加 `pub mod vector_store;` - 追加 re-export: - `pub use vector_store::VectorStore;` - `pub use vector_store::InMemoryVectorStore;` - `pub use vector_store::PersistentVectorStore;` - `pub use vector_store::RagPipeline;` **验证**:`cargo build --all-targets` 编译通过,`cargo clippy` 0 警告。 #### Step 15.5 — 简化 document_demo.rs **涉及文件**:`examples/document_demo.rs` **前置依赖**:Step 15.3(RagPipeline 存在) **工作量**:S(< 0.5h) **验收条件**:`cargo run --example document_demo` exit 0 **迁移概要**:移除手动 `InMemoryVectorRetriever` 循环 + id 映射,替换为 `RagPipeline::new(embedder, store, splitter)` → `ingest()` → `retrieve()` 两行调用。文件从 ~74 行缩减为 ~40 行。 **验证**:`cargo run --example document_demo` → exit 0。 #### Step 15.6 — 全量验证 **验证标准**: | 检查项 | 指标 | |--------|------| | `cargo build --all-targets` | ✅ 通过 | | `cargo test --all-targets` | ✅ 全绿(313+ → 332+,~19 个新测试) | | `cargo doc --no-deps` | ✅ 0 warning | | 零新外部依赖 | ✅ Cargo.toml 无修改 | | `cargo run --example document_demo` | ✅ exit 0 | | 性能基准验证 | ✅ `PersistentVectorStore::new` 10K 加载 <500ms;`InMemoryVectorStore::search` 10K 搜索 <100ms | --- ## 6. 测试计划 | 分组 | 测试 | 场景 | 验证点 | |------|------|------|--------| | InMemory | `basic_add_and_search` | 3 个 3 维向量,query 匹配 Top1 | 最相似文档的 id 和 score 正确 | | InMemory | `search_empty_store` | 空索引 + 任意 query | 返回 `vec![]` | | InMemory | `search_zero_vector` | 零向量查询(norm ≈ 0) | 零向量守卫 → `vec![]` | | InMemory | `add_mismatched_lengths` | 3 docs + 2 embs(不等长) | 截取 2 对处理 + 返回 `Err` | | InMemory | `remove_items` | add 2 → remove 1 → search | 已删除 id 不在结果中 | | InMemory | `remove_nonexistent_id` | remove 从未添加的 id | 静默忽略,返回 `Ok(())` | | InMemory | `concurrent_operations` | 10 并发生成 + 混合搜索 | 无 panic | | InMemory | `search_k_is_zero` | k=0 + 非空索引 | k=0 守卫 → 返回 `vec![]` | | InMemory | `add_duplicate_id_upsert` | 同一 doc.id 写入两次 | 第二次写入覆盖前者,search 返回 1 条 | | InMemory | `search_orthogonal_vectors` | 索引中向量与查询正交 | 相似度 < 1e-10 过滤 → 返回 `vec![]` | | Persistent | `persistent_roundtrip` | write 3 条 → 重建 store → read | 数据完整不丢 | | Persistent | `search_after_reload` | write → 重建 store → search | 重启后检索结果匹配 | | Persistent | `namespace_isolation` | 两个 namespace 各自写入 | 互不干扰,search 正确 | | Persistent | `concurrent_access` | 多 task 写入 + 搜索 | 无死锁 | | Persistent | `partial_add_recovery` | 写入 5 条,模拟第 3 条持久化失败,重建 store | 前 2 条存活,后 3 条缺失 | | Persistent | `new_empty_store` | 空 MemoryStore 构造 PersistentVectorStore | search 返回空结果 | | RagPipeline | `ingest_and_retrieve` | Document → embed → add → search 端到端 | 检索结果包含目标文档 | | RagPipeline | `ingest_empty_docs` | 空文档切片传入 ingest | 返回 `Ok(())` 不报错 | | RagPipeline | `ingest_empty_split` | 分割后 chunk 为空 | 返回 `Ok(())` 不报错 | | RagPipeline | `retrieve_empty_store` | 空 store + 任意查询 | 返回 `vec![]` | --- ## 7. 文件变更清单 | 文件 | 操作 | 说明 | |------|------|------| | `src/memory/vector_store.rs` | **新增** | ~400 行:VectorStore trait + InMemoryVectorStore + PersistentVectorStore + RagPipeline + 内联测试 | | `src/memory/vector.rs` | 修改 | +4 行:两个 deprecated 标注 + 测试 `#[allow(deprecated)]` | | `src/memory.rs` | 修改 | +3 行:module 声明 + 4 个 `pub use` re-export | | `examples/document_demo.rs` | 修改 | ~74 → ~40 行:移除手动 VectorRetriever,改为 RagPipeline 两行调用 | **验证标准**: | 检查项 | 指标 | | `cargo test --all-targets` | ✅ 全绿(313+ → 332+,~19 新测试) | | `cargo build --all-targets` | ✅ 通过 | | `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 | | `cargo doc --no-deps` | ✅ 0 warning | | `cargo run --example document_demo` | ✅ exit 0 | | 零新外部依赖 | ✅ Cargo.toml 无修改 | | 性能基准验证 | ✅ `PersistentVectorStore::new` 10K 加载 <500ms;`InMemoryVectorStore::search` 10K 搜索 <100ms | ## 8. 回滚方案 ### 8.1 代码回滚 | 场景 | 操作 | |------|------| | vector_store.rs 编译/测试失败 | `git checkout -- src/memory/vector_store.rs` | | deprecation 标注问题 | `git checkout -- src/memory/vector.rs src/memory.rs` | | document_demo 不工作 | `git checkout -- examples/document_demo.rs` | | 全量回滚 | `git revert ` | ### 8.2 数据回滚 `PersistentVectorStore` 使用 `vec:{namespace}:` 前缀写入 SqliteStore,回退到旧版本时需清理这些数据: ```sql -- 清理所有 Phase 15 写入的向量数据 DELETE FROM memory_items WHERE id LIKE 'vec:%'; -- 按 namespace 精确清理(如果只回滚特定向量集合) DELETE FROM memory_items WHERE id LIKE 'vec:my_knowledge:%'; ``` > **不可逆决策声明**: > - `VectorEntry` JSON 格式(字段名、embedding 序列化方式、`created_at` 格式)一旦发布,后续修改需考虑旧数据兼容性 > - `vec:{namespace}:{doc_id}` 键命名约定一旦外部消费者依赖此模式,更改需迁移 > - `VectorStore` trait 的 `add`/`search`/`remove` 签名一旦发布,修改是 breaking change > > 缓解措施:若需在 v0.4 中变更 JSON 格式,建议在 `VectorEntry` 中引入 `version` 字段(v1),反序列化时按版本路由解析逻辑。 ## 9. 不做的事 - ❌ ANN 索引(O(n) 扫描,<10K 向量足够) - ❌ `update` 方法(remove + add 即可) - ❌ namespace 运行时 CRUD(构造参数固定) - ❌ 向量维度校验(同现有策略,错误维度不 panic 但结果异常) - ❌ `RagPipeline` trait 抽象(等第二种 pipeline 实现时再提取) - ❌ pgvector / Qdrant 等专用向量数据库后端 --- ## 11. Roadmap 同步要点 实施完成后需同步更新 `docs/roadmap.md`: 1. **Phase 15 状态**:`⏳ 待实施` → `✅ Phase 15 全部交付物已完成` 2. **依赖关系图**:`P15["..."]:::pending` → `:::done` 3. **里程碑表**:`M11` 状态从 `⏳` → `✅ 2026-07-09` 4. **顶部当前状态**:追加 `Phase 15 完成` 5. **下一步行动**:指向 Phase 16 摘要自动生成 6. **已完成列表**:追加 `- ✅ Phase 15 向量存储持久化 — VectorStore trait + InMemoryVectorStore + PersistentVectorStore + RagPipeline` 7. **模块位置修正**:Phase 15 交付物描述中 `src/vector/` 修正为 `src/memory/vector_store.rs` 8. **文档链接**:追加 `docs/21-phase15-vector-store-persistence.md` --- ## 12. 参考来源 1. **Phase 11 VectorRetriever trait** — `docs/18-phase11-testing-and-retrieval.md` - 现有 `VectorRetriever` trait 设计,本 Phase 标记废弃并迁移到 `VectorStore` 2. **Phase 14 Document 系统 + Embedding 抽象** — `docs/20-phase14-document-and-embedding.md` - Document 类型和 Embedding trait 是本 Phase `RagPipeline` 的前置依赖 3. **Phase 7 SqliteStore** — `docs/14-phase7-sqlite-store.md` - `MemoryStore` trait 的 SQLite 后端,`PersistentVectorStore` 的持久化依赖 4. **Roadmap Phase 15 定义** — `docs/roadmap.md` §Phase 15(v0.3.0 第三阶段) - 交付物描述、设计要点、依赖关系 5. **MemoryError 错误类型** — `src/memory/error.rs` - 复用错误类型的设计依据(`InvalidInput` / `Serialization` / `Storage` / `NotFound`) 6. **MemoryStore trait** — `src/memory/store/mod.rs` - `save` / `get` / `delete` / `list` 接口签名,`PersistentVectorStore` 构造时依赖 `list(prefix)`,写入时依赖 `save` / `delete` --- ## 13. 详细实施计划 ### 13.1 任务依赖总图 ```mermaid flowchart LR classDef step fill:#e1f5fe,stroke:#0288d1 classDef verify fill:#e8f5e9,stroke:#388e3c subgraph Step15_1["Step 15.1 — vector_store.rs(trait + InMemory)"] 15_1_1["15.1.1 定义 VectorStore trait"] --> 15_1_2["15.1.2 实现 InMemoryVectorStore struct"] 15_1_2 --> 15_1_4["15.1.4 实现 dot / cosine_similarity"] 15_1_4 --> 15_1_3["15.1.3 实现 VectorStore for InMemoryVectorStore"] 15_1_3 --> 15_1_5["15.1.5 编写 10 个内联测试"] end subgraph Step15_2["Step 15.2 — 追加 PersistentVectorStore"] 15_2_1["15.2.1 定义 VectorEntry 结构体"] --> 15_2_2["15.2.2 实现 PersistentVectorStore + new()"] 15_2_2 --> 15_2_3["15.2.3 实现 VectorStore for PersistentVectorStore"] 15_2_3 --> 15_2_4["15.2.4 编写 6 个内联测试"] end subgraph Step15_3["Step 15.3 — 追加 RagPipeline"] 15_3_1["15.3.1 实现 RagPipeline struct + new()"] --> 15_3_2["15.3.2 实现 ingest()"] 15_3_1 --> 15_3_3["15.3.3 实现 retrieve()"] 15_3_2 --> 15_3_4["15.3.4 编写 4 个内联测试"] 15_3_3 --> 15_3_4 end subgraph Step15_4["Step 15.4 — 标记废弃 + 更新 re-export"] 15_4_1["15.4.1 VectorRetriever 加 #[deprecated]"] --> 15_4_2["15.4.2 InMemoryVectorRetriever 加 #[deprecated]"] 15_4_2 --> 15_4_3["15.4.3 memory.rs 追加 re-export"] end subgraph Step15_5["Step 15.5 — 简化 document_demo"] 15_5_1["15.5.1 迁移至 RagPipeline 两行调用"] end subgraph Step15_6["Step 15.6 — 全量验证"] 15_6_1["15.6.1 cargo build --all-targets"] 15_6_2["15.6.2 cargo test --all-targets"] 15_6_3["15.6.3 cargo clippy"] 15_6_4["15.6.4 cargo doc --no-deps"] 15_6_5["15.6.5 性能基准验证"] 15_6_6["15.6.6 cargo run --example document_demo"] 15_6_7["15.6.7 验证 Cargo.toml 零变更"] end 15_1_5 --> 15_2_1 15_2_4 --> 15_3_1 15_3_4 --> 15_5_1 15_1_5 --> 15_4_1 15_5_1 --> 15_6_1 class 15_1_1,15_1_2,15_1_3,15_1_4,15_1_5,15_2_1,15_2_2,15_2_3,15_2_4,15_3_1,15_3_2,15_3_3,15_3_4 step class 15_4_1,15_4_2,15_4_3,15_5_1 step class 15_6_1,15_6_2,15_6_3,15_6_4,15_6_5,15_6_6,15_6_7 verify ``` ### 13.2 Step 15.1 — 创建 vector_store.rs(trait + InMemoryVectorStore) **涉及文件**:`src/memory/vector_store.rs`(新增) **前置依赖**:无 **工作量**:M(1-2h) | # | 任务描述 | 涉及文件 | 前置依赖 | 工作量 | 风险 | 验收条件 | |---|---------|---------|---------|--------|------|---------| | 15.1.1 | 定义 `VectorStore` trait:3 个必选方法(`add` / `search` / `remove`)+ `add_one` 默认实现 + doc comments + `#[async_trait]` | `src/memory/vector_store.rs` | 无 | S | 低 | `cargo build` 通过;`cargo doc --no-deps` 0 warning(确认 intra-doc link 正确) | | 15.1.2 | 实现 `InMemoryVectorStore` struct:`Mutex)>>` 内部存储 + `new()` 和 `pub(crate) with_entries()` 构造器 | `src/memory/vector_store.rs` | 15.1.1 | S | 低 | 编译通过;`with_entries` 对外不可见 | | 15.1.4 | 实现私有辅助函数 `dot(a, b) → f32`(含 `debug_assert_eq!(a.len(), b.len())`)和 `cosine_similarity(a, b) → f32`(加 `1e-10` 防除零),`pub(crate)` 可见性 | `src/memory/vector_store.rs` | 15.1.2 | S | 低 | 编译通过;零向量与零向量 cosine_similarity 不 panic,返回 0.0(因 1e-10 防除零);正交向量接近 0.0 | | 15.1.3 | 实现 `VectorStore for InMemoryVectorStore`:`add()`(等长校验/截断+Err+tracing::warn!/Mutex insert)、`search()`(锁内克隆快照→释放锁→外余弦扫描→sort+truncate,含空/k=0/零向量守卫)、`remove()`(HashMap::retain) | `src/memory/vector_store.rs` | **15.1.4**(依赖 dot/cosine_similarity 辅助函数) | M | 中 | 单元测试通过;不等长输入返回 `Err(InvalidInput)` 且已写入部分数据;空索引返回 `vec![]`;`k=0` 返回 `vec![]` | | 15.1.5 | 编写 10 个内联测试:`basic_add_and_search` / `search_empty_store` / `search_zero_vector` / `search_k_is_zero` / `search_orthogonal_vectors` / `add_mismatched_lengths` / `add_duplicate_id_upsert` / `remove_items` / `remove_nonexistent_id` / `concurrent_operations` | `src/memory/vector_store.rs` | 15.1.3 | S | 低 | `cargo test -- InMemory` 全部通过;并发测试验证 10 个并发无 panic 且 search 结果计数正确(并发 add N 条后 search 返回 N 条);`remove_nonexistent_id` 验证删除不存在的 id 幂等静默忽略 | **说明**:Step 15.1 执行顺序:`15.1.1 → 15.1.2 → 15.1.4(辅助函数) → 15.1.3(依赖辅助函数的 impl) → 15.1.5(测试)`,严格串行。`add_mismatched_lengths` 测试需验证不等长截断语义——截取 `min(len)` 对处理并返回 `Err(InvalidInput)`。`remove_nonexistent_id` 测试验证幂等性——删除不存在的 id 应返回 `Ok(())`。 ### 13.3 Step 15.2 — 追加 PersistentVectorStore **涉及文件**:`src/memory/vector_store.rs` **前置依赖**:Step 15.1(InMemoryVectorStore 存在) **工作量**:M(1-2h) | # | 任务描述 | 涉及文件 | 前置依赖 | 工作量 | 风险 | 验收条件 | |---|---------|---------|---------|--------|------|---------| | 15.2.1 | 定义私有 `VectorEntry` 结构体:`#[derive(Serialize, Deserialize)]`,字段含 `doc_id` / `content` / `metadata` / `mime_type` / `embedding` / `created_at: String` | `src/memory/vector_store.rs` | 15.1.5 | S | 低 | 编译通过;结构体不可见(无 `pub`),仅文件内使用 | | 15.2.2 | 实现 `PersistentVectorStore` struct + `async fn new(store, namespace)`:prefix 过滤全量加载 → serde_json 反序列化 → InMemoryVectorStore::with_entries 注入 + tracing 埋点;在 doc comment 中标注 spawn_blocking 说明 | `src/memory/vector_store.rs` | 15.2.1 | S | 中 | 持久化 roundtrip 测试通过;加载完成后 tracing::info! 输出条目计数 | | 15.2.3 | 实现 `VectorStore for PersistentVectorStore`:`add`(序列化→逐个 `store.save()` 先写持久化→`inner.add()` 后写内存→tracing::debug!)、`search`(完全委托 `inner.search()`)、`remove`(逐个 `store.delete()` 先删持久化→`inner.remove()` 后删内存) | `src/memory/vector_store.rs` | 15.2.2 | M | 中 | 单元测试通过;add 写入后搜索返回预期结果;remove 后搜索不再返回已删条目 | | 15.2.4 | 编写 6 个内联测试:`persistent_roundtrip`(write→close→reopen→read)、`search_after_reload`、`namespace_isolation`、`concurrent_access`、`partial_add_recovery`(部分写入后重建验证存活数据)、`new_empty_store`(空存储构造后可用) | `src/memory/vector_store.rs` | 15.2.3 | S | 低 | `cargo test -- persistent` 全部通过;`namespace_isolation` 验证两个 namespace 互不干扰;`partial_add_recovery` 验证前 N 条持久化成功条目在重建后可检索;`new_empty_store` 验证构造后 `search` 返回空结果 | **说明**:`PersistentVectorStore::new()` 依赖 SqliteStore 的 `list(prefix)` 支持。若 `store.list()` 返回空 vec,构造过程仍应成功(空索引)。tracing 埋点需区分 debug(构造过程)和 info(加载完成)级别。 **注意**:`PersistentVectorStore::add` 是**非原子**操作(N 条文档执行 N 次独立 `store.save()`),第 i 条失败时前 i-1 条已持久化。需追加 `partial_add_recovery` 测试验证此恢复路径:写入 5 条 → 模拟第 3 条持久化失败 → 重建 store → 验证前 2 条存活。同时追加 `new_empty_store` 测试验证空 MemoryStore 构造后可用、search 返回空结果。 ### 13.4 Step 15.3 — 追加 RagPipeline **涉及文件**:`src/memory/vector_store.rs` **前置依赖**:Step 15.2 **工作量**:S(<1h) | # | 任务描述 | 涉及文件 | 前置依赖 | 工作量 | 风险 | 验收条件 | |---|---------|---------|---------|--------|------|---------| | 15.3.1 | 实现 `RagPipeline` struct:3 个字段(`embedder: Arc` / `store: Arc` / `splitter: Option`)+ `new()` 构造函数 + doc comment 说明用法 | `src/memory/vector_store.rs` | 15.2.4 | S | 低 | 编译通过;`new()` 中 splitter 为 `None` 合法 | | 15.3.2 | 实现 `ingest()`:splitter.split(可选)→ embedder.embed → store.add;空文档守卫(空切片和空 chunk 均返回 `Ok(())`);LlmError→MemoryError::Storage 桥接。**已知限制**:当前将所有 chunk 一次性传入 `embedder.embed()`,真实 Embedding Provider(如 OpenAI)有批量大小限制,调用方需自行控制单次 ingest 的文档数(如 20 条/批) | `src/memory/vector_store.rs` | 15.3.1 | S | 中 | 端到端 ingest 测试通过;空文档输入返回 `Ok(())` 不报错 | | 15.3.3 | 实现 `retrieve()`:embedder.embed(query) → store.search(query_vec, k);空字符串查询路径(零向量守卫→`vec![]`) | `src/memory/vector_store.rs` | 15.3.1 | S | 低 | 端到端 retrieve 测试通过;空字符串返回 `vec![]` | | 15.3.4 | 编写 4 个内联测试:`ingest_and_retrieve`、`retrieve_empty_store`、`ingest_empty_docs`(空文档切片返回 `Ok(())`)、`ingest_empty_split`(分割后 chunk 为空返回 `Ok(())`) | `src/memory/vector_store.rs` | 15.3.2 + 15.3.3 | S | 低 | `cargo test -- rag` 全部通过;`ingest_and_retrieve` 使用 MockEmbedding 保持确定性,验证检索结果包含目标文档;空输入测试验证边界守卫正确 | **说明**:`ingest()` 和 `retrieve()` 都在 `impl RagPipeline` 块中实现,非 trait 方法。错误桥接使用 `MemoryError::Storage(e.to_string())`,丢失原始错误类型信息——这是已知取舍,调用方可从 `.to_string()` 获取原始错误信息。 **注意**:需追加 `ingest_empty_docs` 和 `ingest_empty_split` 测试验证空文档切片和分割后 chunk 为空均返回 `Ok(())` 不报错(§4.3 边界定义)。 ### 13.5 Step 15.4 — 标记 VectorRetriever 废弃 + 更新 re-export **涉及文件**:`src/memory/vector.rs`、`src/memory.rs` **前置依赖**:Step 15.1(VectorStore trait 存在即可标记废弃) **工作量**:S(<0.5h) | # | 任务描述 | 涉及文件 | 前置依赖 | 工作量 | 风险 | 验收条件 | |---|---------|---------|---------|--------|------|---------| | 15.4.1 | `VectorRetriever` trait 前加 `#[deprecated(since = "0.3.0", note = "请使用 memory::VectorStore")]` | `src/memory/vector.rs` | 15.1.5 | S | 低 | 编译通过;使用 `VectorRetriever` 的地方产生 deprecation warning | | 15.4.2 | `InMemoryVectorRetriever` struct 前加 `#[deprecated(since = "0.3.0", note = "请使用 memory::InMemoryVectorStore")]`;内联测试前加 `#[allow(deprecated)]` | `src/memory/vector.rs` | 15.4.1 | S | 低 | deprecation warning 仅在测试中出现;`cargo build --all-targets` 零业务代码 warning | | 15.4.3 | `memory.rs` 追加 `pub mod vector_store;` + 4 行 `pub use vector_store::{VectorStore, InMemoryVectorStore, PersistentVectorStore, RagPipeline};` | `src/memory.rs` | 15.1.5 | S | 低 | `cargo build --all-targets` 编译通过;`cargo run --example document_demo` 编译通过 | **说明**:Step 15.4 只需 Step 15.1 完成即可开始(不依赖 15.2 和 15.3)。但为减少分支冲突,建议在 15.3 之后执行。`#[deprecated]` 标注使用 `since = "0.3.0"` 而非具体日期,与 Rust 惯例保持一致。`#[allow(deprecated)]` 只应出现在 `vector.rs` 的内联测试 `#[cfg(test)]` 块中,项目其他位置不应使用 `#[allow(deprecated)]`。 ### 13.6 Step 15.5 — 简化 document_demo.rs **涉及文件**:`examples/document_demo.rs` **前置依赖**:Step 15.3(RagPipeline 存在) **工作量**:S(<0.5h) | # | 任务描述 | 涉及文件 | 前置依赖 | 工作量 | 风险 | 验收条件 | |---|---------|---------|---------|--------|------|---------| | 15.5.1 | 将 `document_demo.rs` 从手动 VectorRetriever 循环(index→search 逐条操作 + id→Document 映射)改为 RagPipeline 两行调用(`pipeline.ingest()` → `pipeline.retrieve()`);文件从 ~74 行缩减为 ~40 行;移除所有 deprecation 相关 `#[allow]` | `examples/document_demo.rs` | 15.3.4 | S | 低 | `cargo run --example document_demo` exit 0;输出不包含 deprecation warning | **说明**:迁移后示例不再直接引用 VectorRetriever,依赖于 `memory::VectorStore` 和 `memory::RagPipeline`。示例需保持可读性——保留必要注释说明 ingest 和 retrieve 流程。文件中不再需要 `#[allow(deprecated)]`。 ### 13.7 Step 15.6 — 全量验证 **前置依赖**:Step 15.5 **工作量**:S(<0.5h) | # | 检查项 | 前置 | 验收条件 | |---|--------|------|---------| | 15.6.1 | `cargo build --all-targets` | 15.5.1 | ✅ 编译通过,零 warning(测试内 deprecation warning 除外) | | 15.6.2 | `cargo test --all-targets` | 15.6.1 | ✅ 全绿,预计 313+ → 332+(新增 ~19 个:9 InMemory + 6 Persistent + 4 RagPipeline);检查 flaky 测试重跑 | | 15.6.3 | `cargo clippy --all-targets -- -D warnings` | 15.6.2 | ✅ 0 警告 | | 15.6.4 | `cargo doc --no-deps` | 15.6.3 | ✅ 0 warning;doc comments 中引用的类型(如 `MemoryStore`、`Embedding`)有正确的 intra-doc links | | 15.6.5 | 性能基准验证 | 15.6.4 | ✅ `PersistentVectorStore::new` 加载 10K 条模拟数据耗时 <500ms(`std::time::Instant` 断言);`InMemoryVectorStore::search` 在 10K 条索引上搜索耗时 <100ms | | 15.6.6 | `cargo run --example document_demo` | 15.6.5 | ✅ exit 0,输出不包含错误或 panic | | 15.6.7 | 验证 `Cargo.toml` 零变更 | 15.6.6 | ✅ `git diff Cargo.toml` 无输出,无新增外部依赖 | ### 13.8 并行机会 - **Step 15.1 内部串行**:任务粒度不适合并行,每个任务的输出作为下一个的输入 - **Step 15.2** 必须在 Step 15.1 之后(依赖 InMemoryVectorStore) - **Step 15.3** 必须在 Step 15.2 之后(依赖 PersistentVectorStore 的写入——虽然 RagPipeline 用 `dyn VectorStore` 理论上可绑定 InMemoryVectorStore,但方案顺序决定了先实现持久化后实现组合器) - **Step 15.4** 只需 Step 15.1(VectorStore trait 存在即可标记废弃),理论上可与 Step 15.2 并行——但为减少编辑冲突(二者都修改 `memory/` 目录下的文件但不同文件),建议在 Step 15.3 之后执行 - **Step 15.5** 必须在 Step 15.3 之后(依赖 RagPipeline) - **结论:无有效并行机会** ### 13.9 风险与应对 | 风险 | 等级 | 应对 | |------|------|------| | `InMemoryVectorStore::search` 的 Mutex 锁竞争 | 低 | `add`/`remove` 锁持有微秒级;`search` 锁内克隆数据快照后立即释放锁,余弦计算在锁外执行(10K 条预估 1-10ms),不存在死锁场景 | | `PersistentVectorStore::new` 全量加载 10K+ 向量耗时 | 低 | SqliteStore::list 内部使用 spawn_blocking + WAL,预估 ≤500ms;10K 条 JSON 反序列化约 200ms | | JSON 序列化/反序列化 Embedding 大量 float 数据 | 低 | serde_json 序列化 `Vec` 是 O(n) 扫描,每条 <2KB,10K 条约 20MB JSON——在可接受范围内 | | `ingest_and_retrieve` 测试可能 flaky | 低 | 测试中使用 MockEmbedding(确定性),结果可预测,非 flaky | | `store.list(prefix)` 未按 `created_at` 排序 | 低 | SqliteStore 按 `ORDER BY created_at ASC` 排序;全量加载到 HashMap 后顺序无关 | | 15.4.1 标记 `#[deprecated]` 后项目外部依赖方产生 warning | 低 | v0.3.0 发布说明中列明废弃清单,提供迁移指南;保持两个 trait 并行可用一个 minor 版本 | | 不等长输入截断语义存在分歧 | 中 | 已通过单元测试(`add_mismatched_lengths`)锁定行为:截取 `min(len)` 对处理 + 返回 `Err(InvalidInput)` | | remove 幽灵数据窗口(持久化已删、内存未删) | 低 | 重启后从持久化全量加载,幽灵条目自然消失;已知限制已在 §4.2 文档中声明 | | 先写持久化后写内存的 add 路径中,内存写入失败 | 低 | 持久化已成功写入,内存失败导致搜索不包含该条目;重启后全量加载可恢复正常 | ### 13.10 验收检查清单 ``` □ 15.6.1 cargo build --all-targets │ 编译通过 □ 15.6.2 cargo test --all-targets │ 全绿(313+ → 332+,新增 ~19 个) □ 15.6.3 cargo clippy --all-targets │ 0 警告 □ 15.6.4 cargo doc --no-deps │ 0 warning □ 15.6.5 性能基准验证 │ PV::new 10K 加载 <500ms + search <100ms □ 15.6.6 cargo run --example document_demo│ exit 0 □ 15.6.7 Cargo.toml 零变更 │ 无新增外部依赖 ``` **各 Step 验证速查**: | Step | 快速验证命令 | |------|-------------| | 15.1 | `cargo build --all-targets` | | 15.2 | `cargo test -- persistent` | | 15.3 | `cargo test -- rag` | | 15.4 | `cargo build --all-targets`、`cargo clippy --all-targets` | | 15.5 | `cargo run --example document_demo` | | 15.6 | 全部检查项 |