32d886f870
- 新增 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 两行调用 - 零新增外部依赖
1166 lines
64 KiB
Markdown
1166 lines
64 KiB
Markdown
# 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<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 逐个 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<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 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<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
|
||
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.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 <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 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<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.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<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.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<f32>` 是 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 | 全部检查项 |
|