Files
agcore/design/pdd/20-phase14-document-and-embedding.md
T
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00

1418 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 14: Document 系统 + Embedding 抽象
- **文档编号**20
- **标题**Phase 14 — Document 系统 + Embedding 抽象
- **日期**2026-07-09
- **状态**:待实施
- **涉及模块**`document.rs`(新顶层模块)、`llm/embedding`llm 子模块)、`llm.rs``lib.rs`
- **关联文档**roadmap.md(§Phase 14)、18-phase11-testing-and-retrieval.mdVectorRetriever trait
- **对应**Roadmap §Phase 14v0.3.0 第二阶段)
- **预估规模**:新增约 270 行核心代码 + 约 50 行示例代码
---
## 1. 背景与目标
### 1.1 背景
agcore v0.3.0 的目标是从「LLM 调用工具箱」升级为「能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统」。Roadmap §Phase 14 定位为 v0.3.0 的第二阶段,补齐 LangChain 7 大组件中最明显的缺口——**Document 类型和分割器**以及 **Embedding 抽象**
当前 agcore 已有:
- `VectorRetriever` trait(Phase 11)——语义向量检索抽象,支持 `index(id, embeddings)``search(query, k)`
- `MemoryRetriever`Phase 3)——基于 TextOverlap Dice 系数的关键词检索
- `KnowledgeStore`Phase 3)——知识页面存储
这三个组件构成了检索能力的基础,但缺少两个前置环节:
1. **文档分割**:原始文本从文件/网络加载后,如何分割成结构化、可索引的文档片段?
2. **向量化**:分割后的文本片段如何通过 Embedding 模型转化为向量,才能喂给 `VectorRetriever`
Phase 14 不解决 RAG 管线的完整编排(那是 Phase 15 的职责),只交付两个底层组件——分割和向量化抽象。
### 1.2 目标
1. 定义 `Document` 核心类型,作为整个文档处理管线的数据载体 [高]
2. 实现 `RecursiveCharacterSplitter`——递归字符级分割器,支持 `chunk_size``chunk_overlap`、自定义 `separators` 优先级 [高]
3. 定义 `Embedding` trait——异步向量化抽象接口,不与任何具体 Provider 绑定 [高]
4. 交付 `MockEmbedding`——零依赖、确定性伪随机向量引用实现,用于测试和离线验证 [高]
5. 所有代码零新外部依赖,仅依赖 Rust stdlib [高]
### 1.3 适用范围
| 纳入 | 排除 |
|------|------|
| `Document` 纯数据结构(id/content/metadata/mime_type | `DocumentLoader` / `FsLoader`(应用层用 `fs::read_to_string` |
| `RecursiveCharacterSplitter`(单实现,无 trait | `Splitter` traitYAGNI,只有一个实现) |
| `Embedding` trait + `MockEmbedding` | 真实 Embedding ProviderOpenAI/Cohere/等,Phase 15 或后续) |
| 同步、纯 CPU 分割 | 多线程/流式分割(当前场景不需要) |
| 确定性 chunk ID 格式(Phase 15 dedup 可追溯) | unicode-segmentation cratedocumented upgrade path |
---
## 2. 当前状态分析
### 2.1 现有检索能力矩阵
| 维度 | 已实现 | 缺失 |
|------|--------|------|
| 文本相似度检索 | ✅ `MemoryRetriever`(Dice 系数) | 语义相似度 |
| 向量检索抽象 | ✅ `VectorRetriever` trait | 向量化入口(embed |
| 向量检索参考实现 | ✅ `InMemoryVectorRetriever` | — |
| 知识页面管理 | ✅ `KnowledgeStore` | 文档分割/分块 |
| 持久化 | ✅ `SqliteStore` + `InMemoryStore` | 向量嵌入存储(Phase 15 |
| RAG 管线编排 | ❌ | `RagPipeline`Phase 15 |
### 2.2 文档处理缺口
现有代码假设调用方已经拥有「文档片段」——`KnowledgeStore` 接受 `PageIndexEntry``VectorRetriever` 接受 `(id, Vec<f32>)`。但如何从一篇长文档(如 Markdown 说明书、PDF 提取后的纯文本、代码库文档)变成片段,agcore 完全没有支持。
### 2.3 Embedding 缺口
`VectorRetriever::index(id, embeddings)` 要求调用方提供 `Vec<f32>`,但缺少一个**标准化的嵌入入口**。调用方要么自己调外部 API 拿到向量再传给 agcore,要么硬编码 mock 向量。统一 `Embedding` trait 能让上层管线(Phase 15 `RagPipeline`)编写通用的 **split → embed → store** 流程,而不绑定具体 Provider。
### 2.4 模块依赖关系
```
当前(Phase 13:
VectorRetriever trait (memory/vector.rs)
↓ 消费者
Phase 15 RagPipeline (暂无)
缺失环节:
文本 → [Document] → [Splitter] → [Document chunks] → [Embedding trait] → [Vec<f32>] → [VectorRetriever]
↑ Phase 14 ↑ Phase 14 ↑ Phase 14 ↑ Phase 14 ↑ Phase 11 已有
```
### 2.5 风险识别
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| 分割算法参数敏感,不同文档类型需要不同配置 | 中 | 提供 `Default` + `with_separators` 自定义 |
| `chunk_overlap` 可能导致循环 | 低 | 构造函数 `chunk_size ≤ chunk_overlap` 时 panic(编程错误) |
| 无 unicode-segmentation,中文分割边界不准 | 低 | documented upgrade path,文档中标注 `" "` 边界适用于 CJK |
| `Embedding` trait 与 `LlmError` 的关联不够自然 | 低 | Embedding 属于 LLM 能力范畴,归入 `llm` 模块合理 |
---
## 3. 调研发现
### 3.1 LangChain 文档分割器设计
LangChain 的 `RecursiveCharacterSplitter` 是当前社区最广泛使用的分割策略,其核心思路:
1. 维护一个分隔符优先级列表(`["\n\n", "\n", ".", " ", ""]`
2. 从最高优先级分隔符开始,递归分割
3. 当某级分隔符产生的块仍大于 `chunk_size` 时,降级到下一级分隔符继续递归
4. 最后通过贪心合并和 overlap 滑动窗口把过小的块组合成目标大小
**agcore 设计选择**:采用类似算法但简化实现——去掉 LangChain 的 `length_function` 配置(固定字符数)和 `is_separator_regex`(统一字符串匹配),只保留核心递归 + 合并两阶段。 [高]
> **chunk_size 单位说明**:本方案中 `chunk_size` 均以 **Unicode 标量字符(char** 为单位,而非字节数(bytes)。Rust 中 `char` 是 Unicode 标量值,`text.chars().count()` 返回真正的字符数。CJK 文本每个字算 1 个 char(而非 UTF-8 的 3 字节),与用户直观期望一致。
### 3.2 社区 Embedding trait 设计
| 项目 | trait 签名 | 特点 |
|------|-----------|------|
| LangChain.rs | `fn embed(&self, inputs: &[Document]) -> Result<Vec<Vec<f32>>>` | 同步,绑定 Document |
| Rust 社区(rig/llm-chain | `async fn embed_texts(&self, texts: &[String]) -> Result<Vec<Vec<f32>>>` | 异步,通用 `&[String]` |
| OpenAI API | `POST /embeddings` 批量请求 | 网络 IO,天然异步 |
**agcore 设计选择**:采用 `async fn embed(&self, input: &[String])` 签名——Rust 异步生态的主流选择,不绑定 `Document` 让 trait 更通用,`&[String]` 支持批量嵌入。 [高]
### 3.3 向量化伪随机实现调研
Mock/Dummy Embedding 在社区中有两种主流做法:
| 方案 | 代表项目 | 特点 |
|------|---------|------|
| 固定向量(全 1 或全 0 | LangChain.rs test | 简单但不同输入产生相同向量,无法区分 |
| 基于 hash 的确定性向量 | 自研模式 | 输入不同 → 向量不同,Unit norm 保证余弦相似度有语义 |
| 真正的随机(thread_rng) | 一些测试 | 非确定性,导致 flaky 测试 |
**agcore 设计选择**:基于 sin 的确定性哈希——`f32::sin(x) * 10000` 作为伪随机数生成器,对每个输入文本的字符哈希产生固定种子,确保不同输入产生不同向量,且 norm 归一化到单位长度。 [高]
---
## 4. 可选方案
### 4.1 模块归属:Document 放在哪里?
| 方案 | 描述 | 优点 | 缺点 |
|------|------|------|------|
| **A. 顶层模块 `src/document.rs`** | 与 `agent/``llm/``memory/` 同级 | 语义独立,不后属于任何现有模块;扩展自由(Loader/Splitter 子模块) | 新增一个顶层入口 |
| B. `memory/` 子模块 | 放在 `src/memory/document.rs` | 接近 `KnowledgeStore`Document 常用于记忆系统 | Document 不依赖 MemoryStoreVectorStorePhase 15)也可能引用 Document |
| C. `llm/` 子模块 | 放在 `src/llm/document.rs` | 靠近 Embedding | Document 是纯数据结构,不涉及 LLM 调用 |
**结论**:选择 **方案 A**。Document 是一个独立领域概念(类似 LangChain 的 `Document` 是第一等类型),不应被任何一个现有模块拥有。Phase 15 的 `RagPipeline``VectorStore`、以及 `MemoryRetriever` 都可能引用它。 [高]
### 4.2 Splitter trait:需要抽象吗?
| 方案 | 描述 | 优点 | 缺点 |
|------|------|------|------|
| **A. 无 trait(推荐)** | `RecursiveCharacterSplitter` 是具体 struct | 代码量最少;一个实现不改设计 | 如果将来出现第二分割器(MarkdownHeaderSplitter),需要补 trait |
| B. `Splitter` trait | 定义 `fn split(&self, docs: &[Document]) -> Vec<Document>` | 可插拔,LC 风格 | 目前只有一个实现,属 YAGNI;trait 一旦发布,改签名是 breaking change |
**结论**:选择 **方案 A**。YAGNI 原则。Roadmap 明确无第二分割器需求。如果将来出现第二实现,用 `with_custom_splitter` 接口保留向上兼容的扩展点。 [高]
### 4.3 Embedding trait:异步 vs 同步?
| 方案 | 描述 | 优点 | 缺点 |
|------|------|------|------|
| **A. 异步(推荐)** | `async fn embed(&self, input: &[String]) -> Result<...>` | 真实 Provider 涉及 HTTP IO,必须异步;与 ML 推理线程池解耦 | 需要 `async-trait` crate(已有依赖),`MockEmbedding` 需要 `async` 块 |
| B. 同步 | `fn embed(&self, input: &[String]) -> Result<...>` | 简化 MockEmbedding 实现 | 真实 ProviderOpenAI API)无法绕过 async,反向适配增加复杂度 |
**结论**:选择 **方案 A**`async-trait` 已是 agcore 现有依赖(用于 `LlmProvider` / `BaseTool` / `VectorRetriever`),零额外成本。MockEmbedding 在 async fn 中返回 `Ok(...)`,开销可以忽略。 [高]
### 4.4 Embedding 错误类型:新类型 vs 复用 LlmError
| 方案 | 描述 | 优点 | 缺点 |
|------|------|------|------|
| **A. 复用 LlmError(推荐)** | `fn embed(...) -> Result<Vec<Vec<f32>>, LlmError>` | 零新类型,现有错误处理体系复用 | Embedding 可能产生与 LLM 调用不同的错误(无效维度等),需要 `LlmError::Other` 兜底 |
| B. 新 `EmbeddingError` | 独立枚举:`ApiError` / `InvalidDimension` / `BatchTooLarge` | 类型精确 | Phase 15 `RagPipeline` 需要同时处理 EmbeddingError + MemoryError + LlmError,组合复杂度高 |
**结论**:选择 **方案 A**。Embedding 在 agcore 中属于 LLM 能力范畴(`llm/embedding.rs`),复用 LlmError 减少桥接开销。`MockEmbedding` 没有真实 IO,不出错,`LlmError` 仅作为 trait 签名约束。 [高]
### 4.5 Chunk ID 格式
| 方案 | 格式 | 优点 | 缺点 |
|------|------|------|------|
| **A. UUID(不推荐)** | `"uuid-v4"` | 全球唯一 | 不可读;无法追溯源文档;Phase 15 dedup 需要额外字段 |
| **B. `{source_id}:chunk:{index:04d}`(推荐)** | `"doc_001:chunk:0000"` | 人类可读;从 ID 可直接推导源文档和块序号;index 固定 4 位数字保证字典序 | 如果源文档超过 10000 块,位数溢出;实际场景中 4 位足够(10000 块 ~ 1000 万字符 ~ 200 万字) |
| C. URL 编码 | `"doc%3A001%3Achunk%3A0000"` | — | 不可读,不推荐 |
**结论**:选择 **方案 B**。确定性格式对 Phase 15 的 `RagPipeline::ingest` 幂等性至关重要(相同源文档重新分割产生相同 ID,可以跳过已索引的块)。4 位索引上限 9999 块,按每块 1000 字符计算对应 ~1000 万字符文档,在文本 RAG 场景中已足够。超长文档可用 `chunk_size` 调大缓解。 [高]
### 4.6 文件结构:扁平 vs 子目录
| 方案 | 描述 | 优点 | 缺点 |
|------|------|------|------|
| **A. 扁平(推荐)** | `src/document.rs` 单文件 | 约 200 行,单一职责 | 如果后期需要 `document/loader.rs` 等子模块,需要重构为目录 |
| B. 目录预置 | `src/document/mod.rs` + 空子文件 | 预留了扩展空间 | 推测性设计;当前内容不足以分割到子文件 |
**结论**:选择 **方案 A**。Ponytail 原则——最少文件,最短 diff。`Document` + `RecursiveCharacterSplitter` 加上内联测试约 200 行,一个文件足够。需要子模块时再重构。 [高]
> **Roadmap 同步**Roadmap §Phase 14 交付物 1 原写为 `src/document/`,实施前应更新为 `src/document.rs`(单文件)。
### 4.7 分割算法对比
| 方面 | 递归 + 贪心合并(推荐) | LangChain 严格递归 | 固定大小滑动窗口 |
|------|------------------------|-------------------|-----------------|
| 算法复杂度 | O(n) | O(n) | O(n) |
| 语义保留度 | 高(优先按段落/句子切割) | 高 | 低(可能切断句子中间) |
| 实现复杂度 | 中等(两阶段可理解) | 高(递归嵌套回调多) | 低(纯粹的字节截断) |
| 可调参数 | separator 优先级 + chunk_size + overlap | 同上 + length_function | chunk_size + overlap |
| 适用场景 | 通用文档 | 通用文档 + 自定义长度函数 | 快速原型,不考虑语义 |
**结论**:选择 **方案 A**(递归 + 贪心合并)的两阶段算法。原因:
1. 第一阶段(递归)+ 第二阶段(贪心合并)比纯递归更适应非均匀长度的文档;
2. 贪心合并可以自然保证除最后一段外每段接近 `chunk_size`
3. Overlap 窗口在合并阶段后追加到相邻块,语义连续。 [高]
---
## 5. 推荐方案
### 5.1 整体架构
```
Phase 14 交付
┌─────────────────────────────────┐
│ src/document.rs │
│ ┌───────────┐ ┌─────────────┐ │
│ │ Document │ │RecursiveChar│ │
│ │ (struct) │ │Splitter │ │
│ │ id │ │ chunk_size │ │
│ │ content │ │ chunk_overlp│ │
│ │ metadata │ │ separators │ │
│ │ mime_type │ │ split() │ │
│ └───────────┘ └─────────────┘ │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ src/llm/embedding.rs │
│ ┌──────────────┐ ┌───────────┐ │
│ │ Embedding │ │MockEmbedd │ │
│ │ trait │ │(reference)│ │
│ │ embed() │ │ sin-hash │ │
│ │ dim() │ │ unit norm │ │
│ └──────────────┘ └───────────┘ │
└─────────────────────────────────┘
Phase 15 消费(参考)
┌──────────────────────────────┐
│ RagPipeline │
│ ingest: split→embed→store │
│ retrieve: embed→search │
└──────────────────────────────┘
```
### 5.2 核心类型设计
#### Document`src/document.rs`
```rust
/// 文档片段 —— RAG 管线的基本数据载体。
///
/// 作为分割(split)和向量化(embed)两个阶段的通货类型,
/// 在 Phase 15 的 RagPipeline 中串联 split → embed → store。
#[derive(Debug, Clone, PartialEq)]
pub struct Document {
/// 文档唯一标识。
pub id: String,
/// 文档文本内容。
pub content: String,
/// 元数据标签(键值对,可用作过滤、溯源、分类)。
pub metadata: HashMap<String, String>,
/// MIME 类型,标识内容格式(如 "text/plain", "text/markdown")。
pub mime_type: String,
}
impl Document {
/// 创建一个新文档。元数据默认初始化为空。
///
/// 分割器产生的 chunks 会自动继承源文档 mime_type
/// 并在 metadata 中追加 source_id / chunk_index / chunk_count。
pub fn new(id: String, content: String, mime_type: String) -> Self { /* ... */ }
/// 快速构造纯文本文档(mime_type 默认为 "text/plain")。
/// 适用于大多数无需指定媒体类型的场景。
/// 注意:签名使用 `impl Into<String>` 与实施计划 A1 保持一致(接受 `&str` 或 `String`)。
pub fn from_raw(id: impl Into<String>, content: impl Into<String>) -> Self { /* ... */ }
}
```
**设计决策**
- `metadata` 类型为 `HashMap<String, String>` 而非 `HashMap<String, Value>`——metadata 定位是过滤标签/分类字段,扁平键值对足够;若 Phase 15 需要嵌套结构(如 `{"source": {"url": "...", "line": 42}}`),届时可改为 `HashMap<String, serde_json::Value>` 或自定义 Metadata 类型,当前保留最简单形态 [高]
- 三个必填字段(id/content/mime_type)均通过 `new()` 构造,metadata 为可选参数(默认空) [高]
- `from_raw()` 为高频使用场景提供快捷入口——90% 的文档为纯文本,无需每次指定 mime_type [高]
- `clone` + `partial_eq` derive 让上层可以安全地缓存、比较文档 [中]
#### RecursiveCharacterSplitter`src/document.rs`
```rust
/// 递归字符级文档分割器。
///
/// 使用可配置的分隔符优先级列表,递归地将文档分割为
/// 接近 chunk_size 的块。未尾块按剩余字符数自然结束。
///
/// # 算法(两阶段)
///
/// 1. **递归分割**:按分隔符优先级从高到低递归切割文本,
/// 产生初始片段(均 ≤ chunk_size)。
///
/// 2. **贪心合并**:从左向右合并相邻片段,直到合计长度
/// 超过 chunk_size,此时将前一组合并结果作为一个 chunk 输出,
/// 并携带 chunk_overlap 字符的滑动窗口。
///
/// # 升级路径
///
/// - 如需自定义分割函数,可在上层通过 `with_custom_splitter`
/// 扩展(当前未实现,预留升级路径)。
/// - 如需 unicode 感知的句子分割(如中文句号、省略号),
/// 可在 separators 中加入对应字符串,或将下游替换为
/// 基于 unicode-segmentation crate 的自定义分割器。
#[derive(Debug, Clone)]
pub struct RecursiveCharacterSplitter {
chunk_size: usize,
chunk_overlap: usize,
separators: Vec<String>,
}
impl RecursiveCharacterSplitter {
/// 创建分割器。
///
/// # Panics
///
/// 如果 chunk_size ≤ chunk_overlap(无法形成有效滑动窗口)。
pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self { /* ... */ }
/// 创建分割器的安全版本。
///
/// 当 chunk_size ≤ chunk_overlap 时返回 `Err` 而非 panic。
/// 适用于从配置文件等运行时来源读取参数的场景。
pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self, &'static str> { /* ... */ }
/// 覆盖默认分隔符优先级列表。
pub fn with_separators(self, separators: Vec<String>) -> Self { /* ... */ }
/// 批量分割。
///
/// 每个输入文档独立分割。输出 chunks 继承源文档的 mime_type
/// 并在 metadata 中追加 source_id / chunk_index / chunk_count。
///
/// Chunk ID 格式:`{source_id}:chunk:{index:04d}`
/// 例如 `"doc_001:chunk:0000"`(索引从 0 开始,4 位固定宽度)。
///
/// 入口处有 `tracing::debug!` 埋点记录输入文档数和输出 chunk 数。
/// Chunk 数超过 9999 时 panicdebug 模式),防止 ID 格式溢出。
pub fn split(&self, documents: &[Document]) -> Vec<Document> { /* ... */ }
}
impl Default for RecursiveCharacterSplitter {
fn default() -> Self {
Self {
chunk_size: 1000,
chunk_overlap: 200,
// 分隔符按优先级降序排列:
// 段落级 → 行级 → 句子级(含 CJK 标点) → 词级 → 字符级(兜底)
separators: vec!["\n\n", "\n", "", "", "", ".", " ", ""]
.into_iter().map(String::from).collect(),
}
}
}
```
**设计决策**
-`Splitter` traitYAGNI),直接使用具体 struct [高]
- `chunk_size ≤ chunk_overlap``new()` 时 panic——属于调用方编程错误;同时提供 `try_new()` 安全路径供运行时配置使用,符合 Rust 库惯例(`new()` panic + `try_new()` Result [高]
- `with_separators` 使用 `self`move)而非 `&mut self`——builder 风格,不破坏默认行为的高阶使用 [中]
- `separators` 类型为 `Vec<String>` 而非 `&[&str]`——owned,避免生命周期污染 [高]
- 默认分隔符列表在 LangChain 基础上扩充了 CJK 标点(`。` `` ``),避免中文文本跳过句子级直接退化为空格分割 [高]
- `split()` 入口加 `tracing::debug!` 埋点,输出 `tracing::trace!` 记录每个 chunk 长度,提升可观测性 [中]
- Chunk ID 超过 9999 时触发 `debug_assert!`(仅 debug 模式),保护 `{:04d}` 格式不溢出 [高]
#### Embedding trait`src/llm/embedding.rs`
```rust
/// 文本向量化抽象接口。
///
/// 将文本字符串转换为固定维度的浮点向量,用于语义相似度计算。
/// 设计为异步以支持网络 IO(如 OpenAI Embedding API)。
///
/// 使用 [`LlmError`] 作为统一错误类型,与 llm 模块保持一致。
///
/// # 实现要求
///
/// - `embed()` 返回的向量外层的 Vec 长度必须等于输入切片长度(一对一映射)
/// - 内层 Vec 长度必须等于 `dim()` 返回值
/// - 调用方应保证输入非空(空切片返回空外层 Vec,不报错)
///
/// # 稳定性
///
/// 实验性 APIv0.3.x),方法签名可能在 v0.4 中调整。
#[async_trait]
pub trait Embedding: Send + Sync {
/// 批量向量化。
///
/// 返回 `Vec<Vec<f32>>`,第 i 个内层向量对应 `input[i]`。
async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>;
/// 返回向量维度。
fn dim(&self) -> usize;
}
```
**设计决策**
- 使用 `&[String]` 而非 `&[&str]`——owned 输入更常见于批量请求场景 [中]
- 使用 `LlmError` 而非新类型——无 IO 的 MockEmbedding 不产生错误,trait 签名仅为真实 Provider 预留 [高]
- `dim()` 作为关联函数而非常量——不同实现可返回不同维度,无需泛型参数 [高]
#### MockEmbedding`src/llm/embedding.rs`
```rust
/// 确定性 Mock Embedding —— 零依赖伪随机单位向量。
///
/// 使用 sin 哈希将输入字符串映射到单位球面上的一个点:
/// 1. 对输入字符串计算简单哈希(字符和 + len)
/// 2. 用 `f32::sin(seed + i) * 10000` 生成第 i 个维度的值
/// 3. 归一化到单位长度(L2 norm = 1.0
///
/// 特性:
/// - **确定性**:相同 seed + 相同输入 → 相同向量
/// - **有区分度**:不同输入产生不同向量(高概率)
/// - **单位范数**:余弦相似度等价于点积
/// - **开销极低**:不分配额外内存,无 IO
pub struct MockEmbedding {
dim: usize,
}
impl MockEmbedding {
pub fn new(dim: usize) -> Self { /* ... */ }
}
#[async_trait]
impl Embedding for MockEmbedding {
async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError> {
let results: Vec<Vec<f32>> = input.iter().map(|text| {
let seed = text.bytes().map(|b| b as f64).sum::<f64>() + text.len() as f64;
let mut vec: Vec<f32> = (0..self.dim)
.map(|i| f32::sin(seed as f32 + i as f32) * 10000.0)
.collect();
let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
// 防除零
if norm > 1e-10 {
vec.iter_mut().for_each(|x| *x /= norm);
}
vec
}).collect();
Ok(results)
}
fn dim(&self) -> usize { self.dim }
}
```
**设计决策**
- 零外部依赖,纯 stdlib 算法 [高]
- Sin 哈希保证不同输入产生充分不同的向量(但无密码学安全性——不需要) [高]
- L2 归一化到单位长度,余弦相似度 ≈ 点积 [高]
- 防除零保护(极低概率的空文本种子导致零向量) [高]
- **已知限制**`f32::sin(seed + i) * 10000` 在维度较高时(如 1536OpenAI Embedding 维度)可能出现周期性模式——相邻维度取值在 `sin` 周期 2π 约束下呈规律性重复。MockEmbedding 仅用于测试验证,不应用于生产级相似度排序;做严肃验证时建议使用真实 Embedding Provider 或显式随机初始化 [中]
### 5.3 分割算法详情
两阶段算法伪代码:
> **chunk_size 单位**:以下伪代码中 `chars_count(s)` 表示 Unicode 字符数(Rust `s.chars().count()`),
> 非字节数(`s.len()`)。所有长度比较均以字符数为准。
```
Phase 1 — 递归分割(recursive_split
function split_text(text, chunk_size, separators, depth):
if chars_count(text) ≤ chunk_size:
return [text]
if depth ≥ separators.len():
// 已降到最低级分隔符(""),按 chunk_size 字符数硬截断
// 内部用 char_indices() 确保不截断在多字节字符中间
return split_by_chars(text, chunk_size)
separator = separators[depth]
segments = text.split(separator)
result = []
for seg in segments:
if seg.is_empty(): continue
if chars_count(seg) ≤ chunk_size:
result.push(seg)
else:
// 递归降级到下一级分隔符
result.extend(split_text(seg, chunk_size, separators, depth + 1))
return result
Phase 2 — 贪心合并(greedy_merge
function merge_with_overlap(segments, chunk_size, chunk_overlap):
chunks = []
current_chunk = String::new()
for seg in segments:
if chars_count(current_chunk) + chars_count(seg) ≤ chunk_size:
// 可以合并到当前块
current_chunk.push(seg)
else:
// 当前块已满,输出
chunks.push(current_chunk)
// overlap:取前一块末尾 chunk_overlap 字符作为新块前缀
let overlap_tail = last_n_chars(current_chunk, chunk_overlap)
current_chunk = overlap_tail + seg
// 最后一块
if !current_chunk.is_empty():
chunks.push(current_chunk)
return chunks
入口函数 split(documents):
for doc in documents:
segments = recursive_split(doc.content, chunk_size, separators, 0)
chunks = merge_with_overlap(segments, chunk_size, chunk_overlap)
debug_assert!(chunks.len() < 10000, "chunk count exceeds 9999 limit")
index = 0
for chunk in chunks:
output.push(Document {
id: format!("{}:chunk:{:04d}", doc.id, index),
content: chunk,
metadata: {
// 继承源 metadata
...doc.metadata,
// 追加追踪字段
"source_id": doc.id.clone(),
"chunk_index": index.to_string(),
"chunk_count": chunks.len().to_string(),
},
mime_type: doc.mime_type.clone(),
})
index += 1
return output
```
**关键边界**
- 空文档 → 返回空 Vec(不产生 chunk [高]
- 短文档(内容 ≤ chunk_size)→ 单个 chunkchunk_count = 1chunk_index = 0 [高]
- 文档长度正好等于 chunk_size → 单个 chunkoverlap 不影响(没有前一块) [高]
- Overlap 从上一块末尾截取 `chunk_overlap` 字符,确保相邻块有语义重叠 [高]
- 字符级 fallbackseparators 降到 `""`)确保任何文本都能被分割 [高]
### 5.4 错误处理策略
| 场景 | 处理方式 | 原因 |
|------|---------|------|
| `chunk_size ≤ chunk_overlap` | `panic!`(构造函数) | 编程错误,不可能在运行时合法出现 |
| 空输入文档切片 | 返回空 Vec | 合法输入,分割 0 个文档得 0 个结果 |
| 空文本内容 | 不产生 chunk | 空内容无法分割 |
| metadata 传入空 HashMap | 正常使用 | 合法状态,示例中常见 |
### 5.5 测试策略
#### Document 类型(~3 个测试)
| 测试 | 场景 | 验证点 |
|------|------|--------|
| `document_new_metadata_defaults_empty` | `new()` 构造后 metadata 为空 | 合约:默认行为 |
| `document_clone_partial_eq` | 两个相同字段的文档 | Derive 正确性 |
| `document_different_ids_not_equal` | 不同 id 的文档 | PartialEq 区分 |
#### RecursiveCharacterSplitter~10 个测试)
| 测试 | 场景 | 验证点 |
|------|------|--------|
| `split_empty_doc_returns_empty` | 空文档切片 `&[]` | 空输入安全 |
| `split_short_doc_single_chunk` | 短文档(≤ chunk_size | 单 chunk 输出 + chunk_count = 1 |
| `split_paragraph_boundary` | 多段落文本按 `\n\n` 分割 | 递归首层有效 |
| `split_recursive_deepen` | 无段落的长文本降级到 `\n`/`.`/` ` 切割 | 递归降级 + separator 优先级 |
| `split_greedy_merge_combines_segments` | 小片段合并到目标大小 | 合并逻辑 |
| `split_overlap_consistency` | Overlap 滑动窗口内容验证 | 相邻 chunk 末尾与前 chunk 重叠 |
| `split_character_fallback` | 无标点的纯字母文本 | 字符级 fallback(`""`)有效 |
| `split_multiple_docs` | 同时分割多个文档 | 批量分割正确性 |
| `split_metadata_inheritance` | 输出 chunk 的 metadata | source_id / chunk_index / chunk_count + 继承 |
| `split_constructor_validation` | `chunk_size ≤ chunk_overlap` | `#[should_panic]` |
| `split_default_separators` | `Default::default()` 创建的分割器 | 默认值合约 |
#### Embedding + MockEmbedding~5 个测试)
| 测试 | 场景 | 验证点 |
|------|------|--------|
| `embed_correct_dim` | 输出向量维度 | `vec.len() == dim()` |
| `embed_batch_size_match` | 输入 3 个文本 → 输出 3 个向量 | 一对一映射 |
| `embed_deterministic` | 同一输入两次调用 | 向量全等 |
| `embed_unit_vector_norm` | L2 范数约等于 1.0 | 归一化正确 |
| `embed_different_inputs_different_vectors` | 两个不同输入 | 向量不相等(有区分度) |
| `embed_empty_string` | 空字符串输入 `&[""]` | 向量 dim 正确 + norm≈1.0(防除零路径) |
### 5.6 与现有系统的关系
```
VectorRetriever trait (Phase 11, memory/vector.rs)
fn index(id: String, embeddings: Vec<f32>) -> Result<(), MemoryError>
fn search(query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, MemoryError>
Embedding trait (Phase 14, llm/embedding.rs)
fn embed(input: &[String]) -> Result<Vec<Vec<f32>>, LlmError> ← 新
fn dim() -> usize ← 新
RagPipeline (Phase 15)
ingest(docs) = split → embed → store.add
retrieve(query) = embed → store.search
↑ 组合器,桥接 Embedding (LlmError) 与 VectorRetriever (MemoryError)
```
两个 trait 的错误类型不同(`LlmError` vs `MemoryError`),Phase 15 的 `RagPipeline` 需要定义自己的 `RagError` 来统一包装。Phase 14 不做这个桥接。
---
## 6. 实施计划
### 6.1 文件与代码量估计
| 文件 | 角色 | 估计行数 | 新增/修改 |
|------|------|---------|-----------|
| `src/document.rs` | Document + RecursiveCharacterSplitter + ~10 测试 | ~185 | 新增 |
| `src/llm/embedding.rs` | Embedding trait + MockEmbedding + ~5 测试 | ~85 | 新增 |
| `src/lib.rs` | `pub mod document;` | +1 | 修改 |
| `src/llm.rs` | `pub mod embedding;` | +1 | 修改 |
| `examples/document_demo.rs` | 文档分割 + Embedding demo | ~50 | 新增 |
| **合计** | | **~322** | **2 修改 + 3 新增** |
### 6.2 实施步骤
```
Step 14.1 ──→ Step 14.2 ──→ Step 14.3 ──→ Step 14.4 ──→ Step 14.5 ──→ Step 14.6 ──→ Step 14.7
document.rs 追加 split() 追加测试 embedding.rs 追加测试 lib.rs+llm.rs document_demo
(Document) (RecursiveChar cargo test (Embedding cargo test 模块注册 cargo run
+ 基础) Splitter) trait + cargo build --example
cargo build Mock) --all-targets document_demo
```
#### Step 14.1 — Document 类型(`cargo build` 验证)
- 新增 `src/document.rs`
- `Document` struct + `new()` 构造函数
- 5 个 trait derive`Debug`, `Clone`, `PartialEq`
- `use std::collections::HashMap;`
**验证**`cargo build` 通过。
```rust
// Step 14.1 产物示意
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Document {
pub id: String,
pub content: String,
pub metadata: HashMap<String, String>,
pub mime_type: String,
}
impl Document {
pub fn new(id: String, content: String, mime_type: String) -> Self {
Self {
id,
content,
metadata: HashMap::new(),
mime_type,
}
}
/// 快速构造纯文本文档(mime_type 默认为 "text/plain")。
pub fn from_raw(id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
id: id.into(),
content: content.into(),
metadata: HashMap::new(),
mime_type: "text/plain".into(),
}
}
}
```
#### Step 14.2 — RecursiveCharacterSplitter + split()`cargo build` 验证)
- 追加 `RecursiveCharacterSplitter` struct + 三个方法
- 两阶段分割算法实现(递归分割 + 贪心合并)
- 默认 `separators`
**验证**`cargo build` 通过。
#### Step 14.3 — 文档模块内联测试(`cargo test` 验证)
- `#[cfg(test)] mod tests { ... }` 追加到 `src/document.rs`
- ~10 个测试用例覆盖所有边界
**验证**`cargo test -- document::` 全部通过。
#### Step 14.4 — Embedding trait + MockEmbedding`cargo build` 验证)
- 新增 `src/llm/embedding.rs`
- `Embedding` trait`async-trait` 宏)
- `MockEmbedding` struct + `new()` + trait impl
- `use async_trait::async_trait; use crate::llm::error::LlmError;`
**验证**`cargo build` 通过。
#### Step 14.5 — Embedding 内联测试(`cargo test` 验证)
- `#[cfg(test)] mod tests { ... }` 追加到 `src/llm/embedding.rs`
- ~6 个测试用例(含空字符串边界)
**验证**`cargo test -- embedding::` 全部通过。
#### Step 14.6 — 模块注册 + 重导出(`cargo build --all-targets` 验证)
- `src/lib.rs` 追加 `pub mod document;` + `pub use document::Document;`(方便用户以 `agcore::Document` 路径引用,与 `agcore::MemoryError` 等现有模式一致)
- `src/llm.rs` 追加 `pub mod embedding;`
**验证**`cargo build --all-targets` + `cargo test` 全绿 + `cargo clippy --all-targets -- -D warnings` 0 警告。
#### Step 14.7 — document_demo 示例(`cargo run --example document_demo` 验证)
- 新增 `examples/document_demo.rs`
- 展示完整 RAG 管线前置流程:创建 Document → RecursiveCharacterSplitter 分割 → MockEmbedding 向量化 → 与 `VectorRetriever::index` 手动 zip 衔接
- 具体步骤:
1. 创建多段落 Document(含中英文混合文本)
2. RecursiveCharacterSplitter::new(200, 30) 分割 -> chunks
3. MockEmbedding::new(4) 嵌入所有 chunk -> vectors
4. 遍历 chunks.zip(vectors),调用 `InMemoryVectorRetriever::index(chunk.id, vec)`
5. 用其中一个 chunk 内容 mock 查询向量,调用 `retriever.search(query, 3)`
6. 输出检索结果
- 退出码 0
**验证**`cargo run --example document_demo` → exit 0。
### 6.3 验证标准
| 检查项 | 指标 |
|--------|------|
| `cargo build --all-targets` | ✅ 通过 |
| `cargo test --all-targets` | ✅ **286+ → 304+**~18 个新测试,全绿) |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| `cargo run --example document_demo` | ✅ exit 0 |
| 零新外部依赖 | ✅ Cargo.toml 无修改 |
| Document tests | ✅ ~12 个覆盖所有边界(含 UTF-8 多字节) |
| Embedding tests | ✅ ~6 个覆盖 dim/batch/deterministic/norm/different/empty |
### 6.4 回滚方案
如 Phase 14 的实施导致回归:
| 场景 | 操作 |
|------|------|
| Step 14.1-14.3document.rs)编译/测试失败 | `git checkout -- src/document.rs`,排除 document 模块问题 |
| Step 14.4-14.5embedding.rs)编译/测试失败 | `git checkout -- src/llm/embedding.rs`,排除 embedding 模块问题 |
| Step 14.6(模块注册)导致冲突 | `git checkout -- src/lib.rs src/llm.rs`,回退模块注册 |
| Step 14.7 示例异常 | `git checkout -- examples/document_demo.rs`,删除示例 |
| 全量回滚 | `git revert <phase14-commit>` |
### 6.5 里程碑
| 里程碑 | 完成条件 | 验证指标 |
|--------|---------|---------|
| **M10** | Phase 14 全部交付 | Document + RecursiveCharacterSplitter 分割结果验证、MockEmbedding 测试通过 |
---
## 7. 参考来源
1. **LangChain RecursiveCharacterTextSplitter 源码** — 算法原型参考,agcore 简化实现(去掉了 `length_function``is_separator_regex` 配置)
- https://api.python.langchain.com/en/latest/character/langchain_text_splitters.RecursiveCharacterTextSplitter.html
2. **Roadmap Phase 14 定义**`docs/roadmap.md` §Phase 14v0.3.0 第二阶段)
- 交付物描述、设计要点、依赖关系
3. **Phase 11 VectorRetriever trait**`docs/18-phase11-testing-and-retrieval.md`
- 已有 `VectorRetriever` trait 设计,Phase 14 的 `Embedding` 为其输入上游
4. **LlmError 类型定义**`src/llm/error.rs`
- 复用错误类型的设计依据
5. **LangChain 文本分割器社区讨论** — 滑动窗口策略和 overlap 选择基准
- https://js.langchain.com/docs/modules/data_connection/document_transformers/
## 8. 实施计划(详细版)
### 8.1 任务总览
| 分组 | 任务数 | 涉及文件 | 预估总行数 |
|------|--------|---------|-----------|
| A — Document 模块(类型 + 分割器) | 6 | `src/document.rs` | ~120 |
| B — Document 测试 | 4 | `src/document.rs`(内联) | ~110 |
| C — Embedding 模块(trait + mock | 2 | `src/llm/embedding.rs` | ~50 |
| D — Embedding 测试 | 1 | `src/llm/embedding.rs`(内联) | ~45 |
| E — 模块注册 | 1 | `src/lib.rs` + `src/llm.rs` | ~2 |
| F — 集成示例 | 1 | `examples/document_demo.rs` | ~50 |
| **合计** | **15** | **5 个文件** | **~377** |
### 8.2 任务拆解
#### Group A — Document 模块(`src/document.rs`
> **A1 — Document 类型定义** [S] [低]
>
> **涉及文件**`src/document.rs`
>
> **前置依赖**:无
>
> **工作量**S< 1h
>
> **风险**:低
>
> **验收条件**`cargo build` 通过,Document 的 Debug/Clone/PartialEq 派生正确,`cargo doc --document-private-items` 无警告
>
> **任务说明**
>
> 创建 `src/document.rs`,定义 `Document` struct 包含 4 个公开字段:
>
> | 字段 | 类型 | 默认值 | 说明 |
> |------|------|--------|------|
> | `id` | `String` | 必传 | 全局唯一标识 |
> | `content` | `String` | 必传 | 文档正文 |
> | `metadata` | `HashMap<String, String>` | `HashMap::new()` | 键值对元数据 |
> | `mime_type` | `String` | 必传 | MIME 类型标识 |
>
> 实现两个构造函数:
>
> - `new(id: String, content: String, mime_type: String) -> Self` — metadata 默认为空 HashMap
> - `from_raw(id: impl Into<String>, content: impl Into<String>) -> Self` — mime_type 默认为 `"text/plain"`,适用于纯文本场景(与设计规范 §5.2 一致)
>
> 派生 trait`Debug`、`Clone`、`PartialEq`(核心三个,必选);`Eq`(与 `PartialEq` 天然配对,推荐添加);`Default`(产生 id="" 的空文档哨兵,可选但实用——注意该派生超出 §5 原始设计,如确认保留需同步更新 §5.2 的 derive 列表)。
>
> 参照 Step 14.1 的代码示例作为起点,增加 `from_raw()` 和 `Default`/`Eq` derive。
> **A2 — RecursiveCharacterSplitter 构造器** [S] [低]
>
> **涉及文件**`src/document.rs`
>
> **前置依赖**A1
>
> **工作量**S< 1h
>
> **风险**:低
>
> **验收条件**`cargo build` 通过,所有构造器编译正确,`Default` 默认值符合合约
>
> **任务说明**
>
> 在 `src/document.rs` 中追加 `RecursiveCharacterSplitter` struct 定义及构造器族:
>
> ```rust
> pub struct RecursiveCharacterSplitter {
> chunk_size: usize,
> chunk_overlap: usize,
> separators: Vec<String>,
> }
> ```
>
> 实现以下构造器:
>
> - `new(chunk_size: usize, chunk_overlap: usize) -> Self` — 使用默认 separators`chunk_size ≤ chunk_overlap` 或 `chunk_size = 0` 时 `panic!`(编程错误)
> - `try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self, String>` — 与 `new()` 相同但返回 `Result`,验证 `chunk_size > chunk_overlap` 且 `chunk_size > 0`,失败返回对应的 `Err` 消息
> - `Default` trait — `chunk_size=1000``chunk_overlap=200`,使用默认 separators
> - `with_separators(mut self, separators: Vec<String>) -> Self` — Builder 风格,替换默认 separators 列表并返回 Self
>
> 默认 `separators` 常量(按优先级降序):
>
> ```rust
> /// 段落级 → 行级 → 句子级(含 CJK 标点) → 词级 → 字符级(兜底)
> const DEFAULT_SEPARATORS: &[&str] = &["\n\n", "\n", "。", "", "", ".", " ", ""];
> ```
>
> 作为模块私有常量定义。该列表与 §5.2 `Default` 实现一致(在 LangChain 基础上扩充了 CJK 句号 `"。"`、问号 `""`、感叹号 `""`,确保中文文本在句子边界有更高分割质量)。
> **A3 — 递归分割 Phase 1`split_text`** [M] [中]
>
> **涉及文件**`src/document.rs`
>
> **前置依赖**A2
>
> **工作量**M12h
>
> **风险**:中 — 递归算法容易栈溢出或无限循环,需确保每一步都缩小问题规模
>
> **验收条件**`cargo build` 通过,使用模拟数据验证:多段落文本在首层 separator 处切开、无分隔符文本降级到字符级、每个 segment 不超过 `chunk_size`、空文本返回空 Vec
>
> **任务说明**
>
> 实现 `RecursiveCharacterSplitter` 的私有递归辅助方法 `split_text(&self, text: &str, separators: &[&str]) -> Vec<String>`
>
> **算法**(所有长度比较均以 Unicode 字符数为单位,参见 §3.1/§5.3 单位说明):
>
> 辅助函数 `chars_len(s: &str) -> usize` 等价于 Rust 的 `s.chars().count()`。
>
> 1. 若 `chars_len(text) <= self.chunk_size`,直接返回 `vec![text.to_string()]`(无需切分)
> 2. 取出 `separators` 列表的第一个 separator `sep`
> 3. 若 `sep` 为空字符串 `""`:退化为字符级分割 —— 按 `chunk_size` 步长用 `char_indices()` 截取子串,返回分段列表(**兜底保证**)。`char_indices()` 返回 `(byte_index, char)`,确保截断边界落在完整字符上,不会切在多字节 UTF-8 中间
> 4. 用 `text.split(sep)` 获得片段列表
> 5. 遍历片段列表,逐个收集到一个临时累加器:
> - 若 `chars_len(累加器) + chars_len(当前片段) ≤ chunk_size`,追加到累加器
> - 若 `chars_len(当前片段) > chunk_size`:先将累加器中的内容作为一个 segment 输出,再对当前片段递归调用 `split_text(当前片段, &separators[1..])`(跳到下一个 separator
> - 若 `chars_len(累加器) + chars_len(当前片段) > chunk_size` 但 `chars_len(当前片段) ≤ chunk_size`:将累加器内容作为一个 segment 输出,以当前片段为新的累加器
> 6. 处理完所有片段后,若累加器非空,将其作为最后一个 segment 输出
>
> **关键边界**
>
> - 空文本 `""` → 空 `Vec`(不产生任何 segment
> - separators 耗尽(`separators.is_empty()`)→ 直接 `vec![text.to_string()]`,不应 panic
> - 单字符无限递归保护:`sep` 降级到 `""` 后,`char_indices()` 步进保证收敛,每次迭代至少消耗 1 个字符
> - 递归深度自然受限:每次递归消耗 1 个 separatorseparators 列表长度 ~7 层(含 `""` 兜底),最深 7 层,无需额外 depth 哨兵
> **A4 — 贪心合并 Phase 2`merge_with_overlap`** [M] [中]
>
> **涉及文件**`src/document.rs`
>
> **前置依赖**A3
>
> **工作量**M12h
>
> **风险**:中 — overlap 切片边界容易 off-by-one,滑动窗口索引需仔细验证
>
> **验收条件**`cargo build` 通过,相邻 chunk 末尾 overlap 内容匹配、chunk 总数符合预期、单个 segment 超过 chunk_size 时独立成块
>
> **任务说明**
>
> 实现 `RecursiveCharacterSplitter` 的私有方法 `merge_with_overlap(&self, segments: Vec<String>) -> Vec<String>`
>
> **算法**(所有长度比较以 Unicode 字符数为单位,同 A3):
>
> 1. 初始化空 `chunks: Vec<String>`,空累加器 `current = String::new()`
> 2. 从左到右遍历 `segments`
> - 若 `chars_len(current) + chars_len(segment) ≤ chunk_size`:将 segment 追加到 `current`
> - 否则:将 `current` 推入 `chunks`,用新 segment 重置 `current`
> 3. 遍历结束后若 `current` 非空,推入 `chunks`
> 4. **Overlap 应用**(除第一个 chunk 外):
> - 对 `chunks[i]`i ≥ 1),从 `chunks[i-1]` 末尾截取 **最后 `chunk_overlap` 个字符**作为前缀拼接到 `chunks[i]` 开头
> - 实现方式:`chars().rev().take(chunk_overlap).collect::<Vec<_>>().into_iter().rev().collect()`(取最后 N 字符,字符级安全)
> - 或 `char_indices().rev().nth(chunk_overlap-1)` 定位起始字节位置后截取
> - 注意 `chunk_overlap` 是字符数,不能直接用字节索引 `chunks[i-1].len()` 截取——后者对 CJK 文本会错误地少取字符
> - 例如 `chunk_size=20``chunk_overlap=4`,前一块末尾 `" ...世界"`,当前块头部为 `"some text..."` → 拼接后 `"世界some text..."`(多字节字符安全)
> 5. 返回最终 `chunks`
>
> **边界处理**
>
> - 空 segments → 空 Vec
> - 单个 segment 直接返回 `vec![segment]`(无需合并,无 overlap 可应用)
> - `chunk_overlap = 0` → 跳过 overlap 步骤,等价于纯贪心合并
> - Overlap 长度不能超过前一块实际字符数(用 `take(chunk_overlap).count()` 隐含截断,参见 Rust `Iter::take` 语义)
> **A5 — `split()` 入口方法** [S] [低]
>
> **涉及文件**`src/document.rs`
>
> **前置依赖**A4
>
> **工作量**S< 1h
>
> **风险**:低
>
> **验收条件**`cargo build` 通过,对 `&[Document]` 调用 `split()` 返回 `Vec<Document>`chunk 数量正确,metadata 继承正确
>
> **任务说明**
>
> 实现 `RecursiveCharacterSplitter` 的公开方法 `split(&self, documents: &[Document]) -> Vec<Document>`
>
> ```rust
> pub fn split(&self, documents: &[Document]) -> Vec<Document> {
> // ... 实现
> }
> ```
>
> **逻辑**
>
> 1. 若 `documents.is_empty()`,返回空 Vec
> 2. 对每个输入 Document
> a. 调用 `split_text(&doc.content, &self.separators)` 获得 segments
> b. 调用 `merge_with_overlap(segments)` 获得 chunks(纯文本列表)
> c. 将每个 chunk 文本包装为新的 Document
> - `id``format!("{}:chunk:{:04}", doc.id, chunk_index)`,四位零填充保证字典序排序正确(最多支持 9999 个 chunk
> - `content`chunk 文本
> - `metadata`:继承输入 Document 的所有 metadata,额外注入 3 个键:
> - `"source_id"` → `doc.id.clone()`
> - `"chunk_index"` → 当前 chunk 在组内的索引(0-based
> - `"chunk_count"` → 该 Document 产生的 chunk 总数
> - `mime_type`:继承输入 Document 的 mime_type
> d. 所有 chunk 追加到输出 Vec
> 3. 返回所有 chunk 的扁平列表
>
> **注意**metadata 注入使用 `HashMap::insert()`,如果源 Document 的 metadata 已包含 `"source_id"`、`"chunk_index"` 或 `"chunk_count"` 键,将被分割器的值静默覆盖。建议在 `split()` 的文档注释中注明此行为。
>
> **额外合约**
>
> ```rust
> debug_assert!(
> chunks.len() < 10_000,
> "单个文档产生超过 9999 个 chunk,索引格式溢出"
> );
> ```
> **A6 — Tracing 埋点** [S] [低]
>
> **涉及文件**`src/document.rs`
>
> **前置依赖**A5
>
> **工作量**S< 1h
>
> **风险**:低(仅追加日志宏,不改变逻辑)
>
> **验收条件**`cargo build` 通过,启用 `agcore=debug` 追踪时可见分割日志
>
> **任务说明**
>
> 在 `split()` 方法中追加 `tracing` 宏调用:
>
> - `split()` 入口:`tracing::debug!(input_count = documents.len(), "RecursiveCharacterSplitter::split start")`
> - 每个输入 Document 处理完成:`tracing::trace!(doc_id = %doc.id, chunk_count = chunks.len(), "document split into chunks")`
> - 每个 chunk 生成(可选,在循环内部):`tracing::trace!(chunk_id = %chunk.id, "chunk produced")` —— 使用 `tracing::trace!` 而非 `debug!`,避免高基数日志污染生产输出
>
> 确保 `use tracing;` 在文件头部。
>
> 验证方式:编写一个快速测试(或手动运行)设置 `RUST_LOG=agcore=debug cargo run --example ...` 观察日志输出。
#### Group B — Document 测试(`src/document.rs`,内联 `#[cfg(test)]`
> **B1 — Document struct 基础测试** [S] [低]
>
> **涉及文件**`src/document.rs`
>
> **前置依赖**A1
>
> **工作量**S< 1h
>
> **风险**:低
>
> **验收条件**`cargo test -- document::` 包含 3 个测试全部通过
>
> **任务说明**
>
| 测试函数 | 场景 | 断言 |
|---------|------|------|
| `document_new_metadata_defaults_empty` | 用 `new()` 构造 Document | `doc.metadata.is_empty()` |
| `document_clone_partial_eq` | 克隆一个 Document 并比较 | `doc == doc_clone`,字段值逐项相等 |
| `document_different_ids_not_equal` | 两个仅 id 不同的 Document | `doc1 != doc2``PartialEq` 区分不同 identity |
|
> 这些测试验证 struct derive 的正确性和构造函数的合约行为。
> **B2 — Splitter 边界条件测试** [S] [低]
>
> **涉及文件**`src/document.rs`
>
> **前置依赖**A2、A5splitter 结构体 + split 入口)
>
> **工作量**S< 1h
>
> **风险**:低
>
> **验收条件**:4 个测试全部通过
>
> **任务说明**
>
| 测试函数 | 场景 | 断言 |
|---------|------|------|
| `split_empty_doc_returns_empty` | 传入空切片 `&[]` | `splitter.split(&[])` 返回空 `Vec` |
| `split_short_doc_single_chunk` | 短文档(5 字符)vs `chunk_size=100` | 返回 1 个 chunk`chunk_count` = 1`chunk_index` = 0 |
| `split_constructor_validation` | `RecursiveCharacterSplitter::new(10, 10)` | `#[should_panic(expected = "chunk_size must be greater than chunk_overlap")]` |
| `split_default_separators` | `RecursiveCharacterSplitter::default()` | 内部 separators 等于 `DEFAULT_SEPARATORS` |
> **B3 — Splitter 核心算法测试** [M] [中]
>
> **涉及文件**`src/document.rs`
>
> **前置依赖**A5
>
> **工作量**M12h
>
> **风险**:中 — 算法测试需要精心构造输入,确保每次降级和合并边界都被覆盖
>
> **验收条件**:6 个算法测试全部通过(含 UTF-8 多字节边界)
>
> **任务说明**
>
| 测试函数 | 场景 | 构造方法 | 断言 |
|---------|------|---------|------|
| `split_paragraph_boundary` | 两段落(`"aaa\\n\\nbbb"`),`chunk_size=100` | 首层 `\n\n` 切开 | 返回 2 个 chunk |
| `split_recursive_deepen` | 无段落的长单行(200 字符,无 `\n\n`),`chunk_size=50` | 降级到 `\n``.`` ``""` 切割 | 每个 chunk ≤ 50 字符,至少产生 3 个 chunk |
| `split_greedy_merge_combines_segments` | 多个短片段("a", "b", "c"),`chunk_size=5` | Phase 1 产出小片段,Phase 2 合并为 `"abc"` | 合并后 chunk 长度 ≤ 5,片段数少于输入 segment 数 |
| `split_overlap_consistency` | 长文本迫使多 chunk`chunk_size=20, overlap=5` | 验证 `chunks[i].starts_with(chunks[i-1].ends_with(overlap))` | 相邻 chunk 有正确的 overlap 窗口 |
| `split_character_fallback` | 纯字母无标点("aaaaaaaaa..."),`chunk_size=5` | 一路降级到 `""` separator | 每个 chunk ≤ 5 字符,使用 `char_indices()` 正确截断 UTF-8 安全 |
| `split_multibyte_utf8_boundary` | 中文文本("你好世界..."),`chunk_size=10` | 验证字符级单位而非字节级单位 | 每个 chunk 内容 `chars().count() ≤ 10`;字节长度可不均匀(因多字节字符边界)但不影响字符计数;CJK 文本各字 3 字节 UTF-8,但 chunk_size=10 仍返回 10 个中文字符 |
> **B4 — Splitter 集成测试** [S] [低]
>
> **涉及文件**`src/document.rs`
>
> **前置依赖**A5
>
> **工作量**S< 1h
>
> **风险**:低
>
> **验收条件**:2 个集成测试全部通过
>
> **任务说明**
>
| 测试函数 | 场景 | 断言 |
|---------|------|------|
| `split_multiple_docs` | 传入 3 个 Document 同时分割 | 返回的 chunk 总数 ≥ 3,每个 chunk 的 `source_id` 指向正确的输入文档 |
| `split_metadata_inheritance` | 输入 Document 带 `{"author": "test"}` 元数据 | 所有 chunk 继承 `author`,且额外包含 `source_id`/`chunk_index`/`chunk_count` |
#### Group C — Embedding 模块(`src/llm/embedding.rs`
> **C1 — Embedding trait + MockEmbedding 结构体** [S] [低]
>
> **涉及文件**`src/llm/embedding.rs`
>
> **前置依赖**:无(但需了解 `LlmError` 的错误定义模式)
>
> **工作量**S< 1h
>
> **风险**:低
>
> **验收条件**`cargo build` 通过,trait 定义编译正确,`MockEmbedding` struct 可实例化
>
> **任务说明**
>
> 创建 `src/llm/embedding.rs`,定义 `Embedding` trait 和 `MockEmbedding` struct
>
> **Embedding trait**(使用 `async-trait` 宏,与项目中 `LLMBackend` 等现有 trait 风格一致):
>
> ```rust
> use async_trait::async_trait;
> use crate::llm::error::LlmError;
>
> #[async_trait]
> pub trait Embedding: Send + Sync {
> /// 将一组文本转换为向量表示。
> async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>;
>
> /// 返回输出向量的维度。
> fn dim(&self) -> usize;
> }
> ```
>
> **MockEmbedding struct**
>
> ```rust
> pub struct MockEmbedding {
> dim: usize,
> }
>
> impl MockEmbedding {
> pub fn new(dim: usize) -> Self {
> Self { dim }
> }
> }
> ```
> **C2 — MockEmbedding `Embedding` 实现** [S] [低]
>
> **涉及文件**`src/llm/embedding.rs`
>
> **前置依赖**C1
>
> **工作量**S< 1h
>
> **风险**:低(sin-hash 算法简单,不涉及外部依赖)
>
> **验收条件**`cargo build` 通过,MockEmbedding 的 embed 方法返回正确维度的向量,L2 归一化后范数约等于 1.0
>
> **任务说明**
>
> 为 `MockEmbedding` 实现 `Embedding` trait
>
> **Sin-hash 算法**(与设计规范 §5.2 一致,确定性伪随机向量生成):
>
> ```rust
> fn mock_vector(text: &str, dim: usize) -> Vec<f32> {
> // 简单哈希:字符字节值和 + 文本长度作为种子
> let seed: f64 = text.bytes().map(|b| b as f64).sum::<f64>() + text.len() as f64;
> let mut vec: Vec<f32> = (0..dim)
> .map(|i| f32::sin(seed as f32 + i as f32) * 10000.0)
> .collect();
> l2_normalize(&mut vec);
> vec
> }
> ```
>
> **L2 归一化**
>
> ```rust
> fn l2_normalize(vec: &mut Vec<f32>) {
> let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
> if norm > f32::EPSILON {
> for x in vec.iter_mut() {
> *x /= norm;
> }
> }
> // 零向量(norm == 0)保持全零 — 防除零
> }
> ```
>
> 返回结果使用 `Ok(...)` 包装,符合 `Result<Vec<Vec<f32>>, LlmError>` 签名。
#### Group D — Embedding 测试
> **D1 — Embedding 功能测试(6 个)** [S] [低]
>
> **涉及文件**`src/llm/embedding.rs`
>
> **前置依赖**C2
>
> **工作量**S< 1h
>
> **风险**:低
>
> **验收条件**`cargo test -- embedding::` 6 个测试全部通过
>
> **任务说明**
>
| 测试函数 | 场景 | 断言 |
|---------|------|------|
| `embed_correct_dim` | `MockEmbedding::new(8).embed(&["hello"])` | `vec.len() == 8` |
| `embed_batch_size_match` | 输入 `["a", "b", "c"]` | 返回 `Vec` 长度 = 3 |
| `embed_deterministic` | 对同一输入 `["hello"]` 连续调用两次 | 两次结果逐元素全等(确定性保证) |
| `embed_unit_vector_norm` | 任意非空输入 | L2 范数 ≈ 1.0(`abs(norm - 1.0) < 1e-5` |
| `embed_different_inputs_different_vectors` | `["abc"]` vs `["def"]` | 两个向量不相等(有区分度) |
| `embed_empty_string` | 输入 `[""]` | 向量 dim 正确,norm ≈ 1.0(验证零除 guard 路径) |
#### Group E — 模块注册
> **E1 — 在 lib.rs / llm.rs 注册新模块** [S] [低]
>
> **涉及文件**`src/lib.rs` + `src/llm.rs`
>
> **前置依赖**A1`document.rs` 存在)、C1`embedding.rs` 存在)
>
> **工作量**S< 0.5h
>
> **风险**:低
>
> **验收条件**`cargo build --all-targets` 通过,`cargo clippy --all-targets -- -D warnings` 0 警告
>
> **任务说明**
>
> 在 `src/lib.rs` 中追加 2 行:
>
> ```rust
> pub mod document;
> pub use document::Document;
> ```
>
> 在 `src/llm.rs` 中追加 1 行:
>
> ```rust
> pub mod embedding;
> ```
>
> `pub use document::Document` 允许用户以 `agcore::Document` 引用,与现有 `agcore::MemoryError`、`agcore::LlmError` 等重导出模式一致。执行完整验证:
>
> ```bash
> cargo build --all-targets
> cargo test --all-targets
> cargo clippy --all-targets -- -D warnings
> ```
#### Group F — 集成示例
> **F1 — `document_demo` 完整管线示例** [M] [中]
>
> **涉及文件**`examples/document_demo.rs`
>
> **前置依赖**E1、Phase 11 的 `InMemoryVectorRetriever`
>
> **工作量**M12h
>
> **风险**:中 — 需要同时引用 3 个模块(document / embedding / memory),不同模块的错误类型需手动处理
>
> **验收条件**`cargo run --example document_demo` → exit 0,输出显示分割前后统计信息和检索结果
>
> **任务说明**
>
> 创建 `examples/document_demo.rs`,展示 Document → Splitter → Embedding → VectorRetriever 的完整管线流程:
>
> ```rust
> // 伪代码结构
> use agcore::document::{Document, RecursiveCharacterSplitter};
> use agcore::llm::embedding::{Embedding, MockEmbedding};
> use agcore::memory::InMemoryVectorRetriever;
>
> #[tokio::main]
> async fn main() {
> // 1. 创建多段落 Document(含中英文混合文本,至少 5 个段落)
> let docs = vec![
> Document::new("doc1", "...长文本...", "text/plain"),
> ];
>
> // 2. 分割
> let splitter = RecursiveCharacterSplitter::new(200, 30);
> let chunks = splitter.split(&docs);
>
> // 3. 嵌入
> let embedder = MockEmbedding::new(4);
> let chunk_texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
> let vectors = embedder.embed(&chunk_texts).await.unwrap();
>
> // 4. 索引到 VectorRetriever
> let mut retriever = InMemoryVectorRetriever::new();
> for (chunk, vec) in chunks.iter().zip(vectors.iter()) {
> retriever.index(chunk.id.clone(), vec.clone()).unwrap();
> }
>
> // 5. 检索验证
> let query_vec = vectors[0].clone();
> let results = retriever.search(query_vec, 3).unwrap();
>
> // 6. 输出
> println!("=== 分割结果 ===");
> println!("输入文档: {} 个", docs.len());
> println!("输出 chunk: {} 个", chunks.len());
> for chunk in &chunks {
> println!(" [{}] {}...", chunk.id, &chunk.content[..20.min(chunk.content.len())]);
> }
> println!("=== 检索结果 ===");
> for (id, score) in &results {
> println!(" {}: score={}", id, score);
> }
> }
> ```
>
> **注意事项**
>
> - 必须 `use InMemoryVectorRetriever` —— 检查其公开 API 签名(`new()`、`index()`、`search()`),适配可能的方法名差异
> - Example 需要 `#[tokio::main]`,确保 `Cargo.toml` 中 `[[example]]` 声明正确(或依赖已有的 examples 配置模式)
> - 退出码为 0`main()` 不 panic、不返回错误)
### 8.3 执行顺序
```
时序图(Mermaid):
flowchart LR
A1 --> A2 --> A3 --> A4 --> A5 --> A6
A6 --> B1 --> B2 --> B3 --> B4
C1 --> C2 --> D1
A1 -.->|C 组与 A 组并行| C1
B4 --> E1
D1 --> E1
E1 --> F1
```
**串行依赖(不可并行)**
| 路径 | 原因 |
|------|------|
| A1 → A2 → A3 → A4 → A5 → A6 | 后续任务依赖前面的 struct、方法或算法 |
| A5 → B1/B2/B3/B4 | 测试依赖 split() 入口可用 |
| C1 → C2 → D1 | trait → impl → test 顺序依赖 |
| B4 + D1 → E1 | 模块注册需两模块均完成 |
| E1 → F1 | 示例依赖所有模块注册完毕 |
**验证阶段**
| 阶段 | 触发条件 | 验证命令 |
|------|---------|---------|
| V1 | A6A 组代码完整) | `cargo build` |
| V2 | B4(所有文档测试通过) | `cargo test -- document::` |
| V3 | C2C 组代码完整) | `cargo build` |
| V4 | D1(所有 Embedding 测试通过) | `cargo test -- embedding::` |
| V5 | E1(模块注册完成) | `cargo build --all-targets && cargo clippy --all-targets -- -D warnings` |
| V6 | F1(示例运行) | `cargo run --example document_demo` → exit 0 |
### 8.4 并行机会
| 并行组 | 任务 | 说明 |
|--------|------|------|
| **路径 A ↔ 路径 C** | A1A6 ‖ C1C2 | Document 模块和 Embedding 模块零耦合,可由不同开发者同时实现 |
| **A6 + B1** | 代码完善后立即开始测试 | A6 完成(A 组代码冻结)后即可开始 B1,无需等待 B4 全部完成;但 B1 依赖 A1 已稳定 |
| **C2 + 准备 E1** | Embedding 实现完成后可立即准备注册 | E1 需要 A1 和 C1 的文件存在,不要求测试通过 |
**不可并行(阻塞依赖)**
| 阻塞 | 原因 |
|------|------|
| B1/B2/B3/B4 必须在 A6 之后 | 测试依赖 A1–A6 的全部代码 |
| D1 必须在 C2 之后 | 测试依赖 Embedding impl |
| E1 必须在 A1 + C1 之后 | 文件必须存在 |
| F1 必须在 E1 之后 | 模块必须注册才能被示例引用 |
### 8.5 风险与应对
| 风险 | 等级 | 影响 | 应对 |
|------|------|------|------|
| **递归分割无限循环** | 高 | A3 无法收敛,测试挂起或栈溢出 | 确保 `""` 是 separators 的最后一个兜底值;`char_indices()` 步进保证每次至少消耗一个字符;为递归层数添加 `debug_assert!(depth < 100)` 哨兵 |
| **Overlap 切片 off-by-one** | 中 | B3 的 `split_overlap_consistency` 测试失败 | 用已知文本手动追踪切片边界;优先使用字符索引而非字节索引;编写独立小测试验证 `chunks[1]` 的前缀等于 `chunks[0]` 的后 n 个字符 |
| **MockEmbedding 未归一化** | 中 | 单元向量 norm 测试失败,下游 VectorRetriever 余弦相似度错误 | 在 `mock_vector()` 末尾强制调用 `l2_normalize()``norm == 0` 时跳过归一化(零除保护);`embed_empty_string` 测试覆盖零向量路径 |
| **InMemoryVectorRetriever API 不兼容** | 中 | F1 示例编译失败,`index()`/`search()` 签名不匹配 | 实现 F1 前先阅读 `src/memory/vector.rs` 确认 `InMemoryVectorRetriever` 的公开方法签名;必要时用 `.clone()` 适配所有权模型 |
| **示例中 Embedding 异步上下文** | 低 | `main()` 需要 `tokio::runtime``#[tokio::main]` | 确认项目目前 examples 使用的异步模式(检查现有 example:`#[tokio::main]` 或手动 `block_on`);保持风格一致 |
| **chunk ID 索引格式溢出** | 低 | 单个文档 > 9999 个 chunk 导致 ID 重复 | `debug_assert!(chunks.len() < 10000)` 已在 A5 中定义;`{:04}` 格式最高支持 9999,超过时触发断言,提示开发者增大 chunk_size |
### 8.6 工作量汇总
| 分组 | S | M | L | XL |
|------|---|---|---|-----|
| A — Document 模块 | A1, A2, A5, A6 | A3, A4 | — | — |
| B — Document 测试 | B1, B2, B4 | B3 | — | — |
| C — Embedding 模块 | C1, C2 | — | — | — |
| D — Embedding 测试 | D1 | — | — | — |
| E — 模块注册 | E1 | — | — | — |
| F — 集成示例 | — | F1 | — | — |
| **合计** | **10** | **5** | **0** | **0** |
| **预估总工时** | ~8h | ~8h | — | — |
| **总计** | **~16h2 人·日)** | | | |
**工时估算依据**:S 任务平均 ~0.8h,M 任务平均 ~1.5h。含代码编写、本地编译调试、测试通过、clippy 清理。不含方案评审和 Code Review 时间。