将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
64 KiB
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 已有:
VectorRetrievertrait(Phase 11)——语义向量检索抽象,支持index(id, embeddings)和search(query, k)InMemoryVectorRetriever(Phase 11)——基于 HashMap 的内存引用实现Document类型 +RecursiveCharacterSplitter(Phase 14)——文档分割Embeddingtrait +MockEmbedding(Phase 14)——文本向量化抽象
这四个组件构成了 RAG 管线的「前置」和「后置」环节,但缺少中间的 向量存储 环节——一个能持久化保存向量和文档、支持进程重启后恢复数据的核心存储抽象。
1.2 目标
- 定义
VectorStoretrait——统一向量存储的add/search/remove接口 [高] - 实现
InMemoryVectorStore——基于 HashMap 的内存引用实现,支持余弦相似度全量扫描 [高] - 实现
PersistentVectorStore——基于 SqliteStore 的持久化包装,R/W 时双向同步 [高] - 实现
RagPipeline——组合器,封装split → embed → store.add(ingest)和embed → store.search(retrieve) [高] - 标记
VectorRetriever为废弃(#[deprecated]),引导迁移到VectorStore[中] - 更新
document_demo示例,从手动循环迁移到RagPipeline两行调用 [中] - 代码零新增外部依赖 [高]
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,将被废弃):
#[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):
#[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
/// 向量存储抽象 —— 语义检索的核心接口。
///
/// 提供文档-向量的批量添加、余弦相似度搜索、批量删除三个核心操作。
/// 所有实现必须满足 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
/// 内存向量存储 —— 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")
余弦相似度辅助函数
/// 点积。
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
/// 持久化向量存储 —— 基于 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 结构体:
#[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()(异步):
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
/// 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 |
无相关内容 |
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 通过
内容:
VectorStoretrait 定义(#[async_trait],3 个必选方法 +add_one默认实现)InMemoryVectorStorestruct +new()/with_entries()构造器VectorStoreforInMemoryVectorStore的实现:add:等长校验 → 遍历 zip → insert(不等长截断 + Err)search:lock → 空/k=0/零向量守卫 → 全量余弦扫描 → sort → truncateremove:lock → HashMap::retain
- 私有辅助函数
dot(a, b) → f32和cosine_similarity(a, b) → f32 - 内联测试(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)通过
内容:
- 私有
VectorEntry结构体(#[derive(Serialize, Deserialize)]) PersistentVectorStorestruct +async fn new(store, namespace)构造方法store.list(prefix="vec:{namespace}:")全量加载serde_json::from_str反序列化InMemoryVectorStore::with_entries()注入
VectorStoreforPersistentVectorStore实现: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 集成测试通过
内容:
RagPipelinestruct(3 个字段:embedder / store / splitter)new()构造函数ingest():split(可选)→ embed → store.addretrieve():embed → store.search- 桥接
LlmError → MemoryError::Storage - 内联测试(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:VectorRetrievertrait 前加#[deprecated(since = "0.3.0", note = "请使用 memory::VectorStore")]InMemoryVectorRetrieverstruct 前加#[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,回退到旧版本时需清理这些数据:
-- 清理所有 Phase 15 写入的向量数据
DELETE FROM memory_items WHERE id LIKE 'vec:%';
-- 按 namespace 精确清理(如果只回滚特定向量集合)
DELETE FROM memory_items WHERE id LIKE 'vec:my_knowledge:%';
不可逆决策声明:
VectorEntryJSON 格式(字段名、embedding 序列化方式、created_at格式)一旦发布,后续修改需考虑旧数据兼容性vec:{namespace}:{doc_id}键命名约定一旦外部消费者依赖此模式,更改需迁移VectorStoretrait 的add/search/remove签名一旦发布,修改是 breaking change缓解措施:若需在 v0.4 中变更 JSON 格式,建议在
VectorEntry中引入version字段(v1),反序列化时按版本路由解析逻辑。
9. 不做的事
- ❌ ANN 索引(O(n) 扫描,<10K 向量足够)
- ❌
update方法(remove + add 即可) - ❌ namespace 运行时 CRUD(构造参数固定)
- ❌ 向量维度校验(同现有策略,错误维度不 panic 但结果异常)
- ❌
RagPipelinetrait 抽象(等第二种 pipeline 实现时再提取) - ❌ pgvector / Qdrant 等专用向量数据库后端
11. Roadmap 同步要点
实施完成后需同步更新 docs/roadmap.md:
- Phase 15 状态:
⏳ 待实施→✅ Phase 15 全部交付物已完成 - 依赖关系图:
P15["..."]:::pending→:::done - 里程碑表:
M11状态从⏳→✅ 2026-07-09 - 顶部当前状态:追加
Phase 15 完成 - 下一步行动:指向 Phase 16 摘要自动生成
- 已完成列表:追加
- ✅ Phase 15 向量存储持久化 — VectorStore trait + InMemoryVectorStore + PersistentVectorStore + RagPipeline - 模块位置修正:Phase 15 交付物描述中
src/vector/修正为src/memory/vector_store.rs - 文档链接:追加
docs/21-phase15-vector-store-persistence.md
12. 参考来源
-
Phase 11 VectorRetriever trait —
docs/18-phase11-testing-and-retrieval.md- 现有
VectorRetrievertrait 设计,本 Phase 标记废弃并迁移到VectorStore
- 现有
-
Phase 14 Document 系统 + Embedding 抽象 —
docs/20-phase14-document-and-embedding.md- Document 类型和 Embedding trait 是本 Phase
RagPipeline的前置依赖
- Document 类型和 Embedding trait 是本 Phase
-
Phase 7 SqliteStore —
docs/14-phase7-sqlite-store.mdMemoryStoretrait 的 SQLite 后端,PersistentVectorStore的持久化依赖
-
Roadmap Phase 15 定义 —
docs/roadmap.md§Phase 15(v0.3.0 第三阶段)- 交付物描述、设计要点、依赖关系
-
MemoryError 错误类型 —
src/memory/error.rs- 复用错误类型的设计依据(
InvalidInput/Serialization/Storage/NotFound)
- 复用错误类型的设计依据(
-
MemoryStore trait —
src/memory/store/mod.rssave/get/delete/list接口签名,PersistentVectorStore构造时依赖list(prefix),写入时依赖save/delete
13. 详细实施计划
13.1 任务依赖总图
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 | 全部检查项 |