# 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)`。但如何从一篇长文档(如 Markdown 说明书、PDF 提取后的纯文本、代码库文档)变成片段,agcore 完全没有支持。 ### 2.3 Embedding 缺口 `VectorRetriever::index(id, embeddings)` 要求调用方提供 `Vec`,但缺少一个**标准化的嵌入入口**。调用方要么自己调外部 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] → [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>>` | 同步,绑定 Document | | Rust 社区(rig/llm-chain) | `async fn embed_texts(&self, texts: &[String]) -> Result>>` | 异步,通用 `&[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` | 可插拔,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>, 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, /// 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` 与实施计划 A1 保持一致(接受 `&str` 或 `String`)。 pub fn from_raw(id: impl Into, content: impl Into) -> Self { /* ... */ } } ``` **设计决策**: - `metadata` 类型为 `HashMap` 而非 `HashMap`——metadata 定位是过滤标签/分类字段,扁平键值对足够;若 Phase 15 需要嵌套结构(如 `{"source": {"url": "...", "line": 42}}`),届时可改为 `HashMap` 或自定义 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, } 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 { /* ... */ } /// 覆盖默认分隔符优先级列表。 pub fn with_separators(self, separators: Vec) -> 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 { /* ... */ } } 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` 而非 `&[&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>`,第 i 个内层向量对应 `input[i]`。 async fn embed(&self, input: &[String]) -> Result>, 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>, LlmError> { let results: Vec> = input.iter().map(|text| { let seed = text.bytes().map(|b| b as f64).sum::() + text.len() as f64; let mut vec: Vec = (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::().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) -> Result<(), MemoryError> fn search(query: Vec, k: usize) -> Result, MemoryError> Embedding trait (Phase 14, llm/embedding.rs) fn embed(input: &[String]) -> Result>, 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, 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, content: impl Into) -> 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 ` | ### 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` | `HashMap::new()` | 键值对元数据 | > | `mime_type` | `String` | 必传 | MIME 类型标识 | > > 实现两个构造函数: > > - `new(id: String, content: String, mime_type: String) -> Self` — metadata 默认为空 HashMap > - `from_raw(id: impl Into, content: impl Into) -> 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, > } > ``` > > 实现以下构造器: > > - `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` — 与 `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) -> 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`: > > **算法**(所有长度比较均以 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) -> Vec`: > > **算法**(所有长度比较以 Unicode 字符数为单位,同 A3): > > 1. 初始化空 `chunks: Vec`,空累加器 `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::>().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`,chunk 数量正确,metadata 继承正确 > > **任务说明**: > > 实现 `RecursiveCharacterSplitter` 的公开方法 `split(&self, documents: &[Document]) -> Vec`: > > ```rust > pub fn split(&self, documents: &[Document]) -> Vec { > // ... 实现 > } > ``` > > **逻辑**: > > 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>, 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 { > // 简单哈希:字符字节值和 + 文本长度作为种子 > let seed: f64 = text.bytes().map(|b| b as f64).sum::() + text.len() as f64; > let mut vec: Vec = (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) { > let norm: f32 = vec.iter().map(|x| x * x).sum::().sqrt(); > if norm > f32::EPSILON { > for x in vec.iter_mut() { > *x /= norm; > } > } > // 零向量(norm == 0)保持全零 — 防除零 > } > ``` > > 返回结果使用 `Ok(...)` 包装,符合 `Result>, 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 = 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 时间。