28ca43ccb2
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
1418 lines
64 KiB
Markdown
1418 lines
64 KiB
Markdown
# 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.md(VectorRetriever trait)
|
||
- **对应**:Roadmap §Phase 14(v0.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` trait(YAGNI,只有一个实现) |
|
||
| `Embedding` trait + `MockEmbedding` | 真实 Embedding Provider(OpenAI/Cohere/等,Phase 15 或后续) |
|
||
| 同步、纯 CPU 分割 | 多线程/流式分割(当前场景不需要) |
|
||
| 确定性 chunk ID 格式(Phase 15 dedup 可追溯) | unicode-segmentation crate(documented 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 不依赖 MemoryStore;VectorStore(Phase 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 实现 | 真实 Provider(OpenAI 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 时 panic(debug 模式),防止 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` trait(YAGNI),直接使用具体 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,不报错)
|
||
///
|
||
/// # 稳定性
|
||
///
|
||
/// 实验性 API(v0.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` 在维度较高时(如 1536,OpenAI 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)→ 单个 chunk,chunk_count = 1,chunk_index = 0 [高]
|
||
- 文档长度正好等于 chunk_size → 单个 chunk,overlap 不影响(没有前一块) [高]
|
||
- Overlap 从上一块末尾截取 `chunk_overlap` 字符,确保相邻块有语义重叠 [高]
|
||
- 字符级 fallback(separators 降到 `""`)确保任何文本都能被分割 [高]
|
||
|
||
### 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.3(document.rs)编译/测试失败 | `git checkout -- src/document.rs`,排除 document 模块问题 |
|
||
| Step 14.4-14.5(embedding.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 14(v0.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
|
||
>
|
||
> **工作量**:M(1–2h)
|
||
>
|
||
> **风险**:中 — 递归算法容易栈溢出或无限循环,需确保每一步都缩小问题规模
|
||
>
|
||
> **验收条件**:`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 个 separator,separators 列表长度 ~7 层(含 `""` 兜底),最深 7 层,无需额外 depth 哨兵
|
||
|
||
> **A4 — 贪心合并 Phase 2(`merge_with_overlap`)** [M] [中]
|
||
>
|
||
> **涉及文件**:`src/document.rs`
|
||
>
|
||
> **前置依赖**:A3
|
||
>
|
||
> **工作量**:M(1–2h)
|
||
>
|
||
> **风险**:中 — 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、A5(splitter 结构体 + 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
|
||
>
|
||
> **工作量**:M(1–2h)
|
||
>
|
||
> **风险**:中 — 算法测试需要精心构造输入,确保每次降级和合并边界都被覆盖
|
||
>
|
||
> **验收条件**: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`
|
||
>
|
||
> **工作量**:M(1–2h)
|
||
>
|
||
> **风险**:中 — 需要同时引用 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 | A6(A 组代码完整) | `cargo build` |
|
||
| V2 | B4(所有文档测试通过) | `cargo test -- document::` |
|
||
| V3 | C2(C 组代码完整) | `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** | A1–A6 ‖ C1–C2 | 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 | — | — |
|
||
| **总计** | **~16h(2 人·日)** | | | |
|
||
|
||
**工时估算依据**:S 任务平均 ~0.8h,M 任务平均 ~1.5h。含代码编写、本地编译调试、测试通过、clippy 清理。不含方案评审和 Code Review 时间。
|