Files
agcore/docs/21-phase15-vector-store-persistence.md
T
徐涛 32d886f870 feat(memory): 新增 VectorStore 抽象与 RagPipeline 持久化管线
- 新增 src/memory/vector_store.rs(约 660 行):

  - VectorStore trait:批量 add / search / remove + add_one 默认实现

  - InMemoryVectorStore:Mutex<HashMap> + 余弦全量扫描(锁内克隆、锁外计算)

  - PersistentVectorStore:MemoryStore 包装,JSON blob 持久化,先持久化后内存

  - RagPipeline:split → embed → store 组合器(具体 struct,非 trait)

- 22 个内联测试覆盖 InMemory(10)/ Persistent(6)/ RagPipeline(4)/ 性能基准(2)

- 性能断言:search 10K 条 <100ms,PersistentVectorStore::new 加载 <500ms

- 标记 VectorRetriever / InMemoryVectorRetriever 为 #[deprecated(since="0.3.0")]

- memory.rs 追加 VectorStore 等 4 个类型的 re-export

- document_demo 从手动 VectorRetriever 循环迁移到 RagPipeline 两行调用

- 零新增外部依赖
2026-07-09 16:51:29 +08:00

1166 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.mdVectorRetriever trait)、20-phase14-document-and-embedding.mdDocument 系统 + Embedding 抽象)
- **对应**Roadmap §Phase 15v0.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 | 真实 ProviderPhase 15 不解决) |
| 向量检索抽象 | ✅ `VectorRetriever` traitPhase 11 | 持久化语义检索 |
| 向量检索内存实现 | ✅ `InMemoryVectorRetriever`Phase 11 | — |
| 向量持久化 | ❌ | `PersistentVectorStore`(本 Phase |
| RAG 管线编排 | ❌ | `RagPipeline`(本 Phase |
| SqliteStore 后端 | ✅ Phase 7 | — |
### 2.2 现有检索抽象对比
**VectorRetriever traitPhase 11,将被废弃)**
```rust
#[async_trait]
pub trait VectorRetriever: Send + Sync {
async fn index(&self, id: String, embeddings: Vec<f32>) -> Result<(), MemoryError>;
async fn search(&self, query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, 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<f32>]) -> Result<(), MemoryError>;
async fn search(&self, query: &[f32], k: usize) -> Result<Vec<(Document, f32)>, 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<Document>
RagPipeline::ingest (本 Phase) ← Embedding trait (src/llm/embedding.rs)
├── splitter.split() → Vec<Document> chunks
├── embedder.embed() → Vec<Vec<f32>>
└── store.add() → 持久化
RagPipeline::retrieve (本 Phase)
├── embedder.embed(query) → Vec<Vec<f32>>
└── 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<f32>])` | RagPipeline 一次 ingest 处理多个 chunk 时只需要一次 async call;批量语义明确 | 需要处理不等长输入 |
| B. 单条 add | `add(Document, Vec<f32>)` | 签名简单,没有不等长问题 | RagPipeline 遍历每个 chunk 逐个 awaitN 个 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<Vec<...>, 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 RagPipelinestruct 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` APIsave/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<Vec<(Doc,f32)>,ME>│ │
│ │ remove(ids) -> Result<(), ME> │ │
│ │ add_one(doc, emb) -> Result<(), ME> │ │
│ └──────────────┬───────────────────────────────┘ │
│ │ implements │
│ ┌──────────────┴───────────────┐ │
│ │ InMemoryVectorStore │ │
│ │ entries: HashMap │ │
│ │ dot() / cosine_similarity() │ │
│ └──────────────┬───────────────┘ │
│ │ wraps │
│ ┌──────────────┴───────────────┐ │
│ │ PersistentVectorStore │ │
│ │ inner: InMemoryVectorStore │ │
│ │ store: Arc<dyn MemoryStore> │ │
│ │ namespace: String │ │
│ │ add(): 内存 + SqliteStore 双写 │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ RagPipeline │ │
│ │ embedder: Arc<dyn Embedding>│ │
│ │ store: Arc<dyn VectorStore> │ │
│ │ splitter: Option<Splitter> │ │
│ │ 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<HashMap> 或 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<f32>])
-> 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<Vec<(Document, f32)>, MemoryError>;
/// 批量删除文档(幂等)。
///
/// 不存在的 id 静默忽略,不会返回错误。
async fn remove(&self, ids: &[String]) -> Result<(), MemoryError>;
/// 便捷方法:单条添加。
///
/// 等价于 `self.add(&[doc], &[emb]).await`。
async fn add_one(&self, doc: Document, emb: Vec<f32>)
-> Result<(), MemoryError> {
self.add(&[doc], &[emb]).await
}
}
```
**设计决策**
- `documents``embeddings` 的输入使用切片引用(`&[Document]``&[Vec<f32>]`)——零拷贝,调用方可以传入 `Vec` 的引用或数组切片 [高]
- `search` 返回 `Vec<(Document, f32)>` 而非 `Vec<(String, f32)>`——消除调用方维护 `id → Document` 映射的负担 [高]
- `remove` 幂等设计——不存在的 id 静默忽略,简化调用方逻辑(无需在删除前检查 id 是否存在) [高]
- 提供默认实现的 `add_one` 便捷方法——`&[x]` 语法足够精简,但 `add_one` 在链式调用中更清晰 [中]
#### InMemoryVectorStore
```rust
/// 内存向量存储 —— VectorStore 的引用实现。
///
/// 内部使用 `Mutex<HashMap<String, (Document, Vec<f32>)>>` 存储,
/// `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<HashMap<String, (Document, Vec<f32>)>>,
}
impl InMemoryVectorStore {
/// 创建一个空存储。
pub fn new() -> Self { /* ... */ }
/// 从预填充的 entries 构造(供 PersistentVectorStore 使用)。
pub(crate) fn with_entries(
entries: HashMap<String, (Document, Vec<f32>)>,
) -> 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<f32>)>
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<dyn MemoryStore>,
namespace: String,
}
```
**私有 VectorEntry 结构体**
```rust
#[derive(Serialize, Deserialize)]
struct VectorEntry {
doc_id: String,
content: String,
metadata: HashMap<String, String>,
mime_type: String,
embedding: Vec<f32>,
/// ISO 8601 创建时间(UTC),持久化 roundtrip 重建时保持原时间,
/// 避免 MemoryStore 的 TTL 淘汰策略误判。
created_at: String,
}
```
**构造方法 new()**(异步):
```rust
pub async fn new(store: Arc<dyn MemoryStore>, namespace: &str) -> Result<Self, MemoryError> {
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<RecursiveCharacterSplitter>`
/// - `Some(splitter)` → `ingest()` 先分割再嵌入(调用方传入原始文档)
/// - `None` → `ingest()` 跳过分割,直接嵌入(调用方已分好 chunk)
pub struct RagPipeline {
embedder: Arc<dyn Embedding>,
store: Arc<dyn VectorStore>,
splitter: Option<RecursiveCharacterSplitter>,
}
impl RagPipeline {
/// 创建新的 RAG 管线。
///
/// 不设置分割器时,`ingest()` 跳过分割阶段,
/// 调用方传入的 Document 应已是分割好的 chunk。
pub fn new(
embedder: Arc<dyn Embedding>,
store: Arc<dyn VectorStore>,
splitter: Option<RecursiveCharacterSplitter>,
) -> Self { /* ... */ }
/// 摄取文档:分割 → 向量化 → 存储。
///
/// 流程:
/// 1. 如果 splitter 存在,先分割文档为 chunks
/// 2. 提取所有 chunk 的 content 为 Vec<String>
/// 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<String> = 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<Vec<(Document, f32)>, 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.rstrait + InMemoryVectorStore
**涉及文件**`src/memory/vector_store.rs`(新增)
**前置依赖**:无(独立新增文件)
**工作量**M1-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
**工作量**M1-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` struct3 个字段: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.1VectorStore 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.3RagPipeline 存在)
**工作量**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 <phase15-commit>` |
### 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 15v0.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.rstrait + 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.rstrait + InMemoryVectorStore
**涉及文件**`src/memory/vector_store.rs`(新增)
**前置依赖**:无
**工作量**M1-2h
| # | 任务描述 | 涉及文件 | 前置依赖 | 工作量 | 风险 | 验收条件 |
|---|---------|---------|---------|--------|------|---------|
| 15.1.1 | 定义 `VectorStore` trait3 个必选方法(`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<HashMap<String, (Document, Vec<f32>)>>` 内部存储 + `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.1InMemoryVectorStore 存在)
**工作量**M1-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` struct3 个字段(`embedder: Arc<dyn Embedding>` / `store: Arc<dyn VectorStore>` / `splitter: Option<RecursiveCharacterSplitter>`+ `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.1VectorStore 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.3RagPipeline 存在)
**工作量**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 warningdoc 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.1VectorStore 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,预估 ≤500ms10K 条 JSON 反序列化约 200ms |
| JSON 序列化/反序列化 Embedding 大量 float 数据 | 低 | serde_json 序列化 `Vec<f32>` 是 O(n) 扫描,每条 <2KB10K 条约 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 | 全部检查项 |