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

64 KiB
Raw Permalink Blame History

Phase 14: Document 系统 + Embedding 抽象

  • 文档编号20
  • 标题Phase 14 — Document 系统 + Embedding 抽象
  • 日期2026-07-09
  • 状态:待实施
  • 涉及模块document.rs(新顶层模块)、llm/embeddingllm 子模块)、llm.rslib.rs
  • 关联文档roadmap.md(§Phase 14)、18-phase11-testing-and-retrieval.mdVectorRetriever trait
  • 对应Roadmap §Phase 14v0.3.0 第二阶段)
  • 预估规模:新增约 270 行核心代码 + 约 50 行示例代码

1. 背景与目标

1.1 背景

agcore v0.3.0 的目标是从「LLM 调用工具箱」升级为「能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统」。Roadmap §Phase 14 定位为 v0.3.0 的第二阶段,补齐 LangChain 7 大组件中最明显的缺口——Document 类型和分割器以及 Embedding 抽象

当前 agcore 已有:

  • VectorRetriever trait(Phase 11)——语义向量检索抽象,支持 index(id, embeddings)search(query, k)
  • MemoryRetrieverPhase 3)——基于 TextOverlap Dice 系数的关键词检索
  • KnowledgeStorePhase 3)——知识页面存储

这三个组件构成了检索能力的基础,但缺少两个前置环节:

  1. 文档分割:原始文本从文件/网络加载后,如何分割成结构化、可索引的文档片段?
  2. 向量化:分割后的文本片段如何通过 Embedding 模型转化为向量,才能喂给 VectorRetriever

Phase 14 不解决 RAG 管线的完整编排(那是 Phase 15 的职责),只交付两个底层组件——分割和向量化抽象。

1.2 目标

  1. 定义 Document 核心类型,作为整个文档处理管线的数据载体 [高]
  2. 实现 RecursiveCharacterSplitter——递归字符级分割器,支持 chunk_sizechunk_overlap、自定义 separators 优先级 [高]
  3. 定义 Embedding trait——异步向量化抽象接口,不与任何具体 Provider 绑定 [高]
  4. 交付 MockEmbedding——零依赖、确定性伪随机向量引用实现,用于测试和离线验证 [高]
  5. 所有代码零新外部依赖,仅依赖 Rust stdlib [高]

1.3 适用范围

纳入 排除
Document 纯数据结构(id/content/metadata/mime_type DocumentLoader / FsLoader(应用层用 fs::read_to_string
RecursiveCharacterSplitter(单实现,无 trait Splitter traitYAGNI,只有一个实现)
Embedding trait + MockEmbedding 真实 Embedding ProviderOpenAI/Cohere/等,Phase 15 或后续)
同步、纯 CPU 分割 多线程/流式分割(当前场景不需要)
确定性 chunk ID 格式(Phase 15 dedup 可追溯) unicode-segmentation cratedocumented upgrade path

2. 当前状态分析

2.1 现有检索能力矩阵

维度 已实现 缺失
文本相似度检索 MemoryRetrieverDice 系数) 语义相似度
向量检索抽象 VectorRetriever trait 向量化入口(embed
向量检索参考实现 InMemoryVectorRetriever
知识页面管理 KnowledgeStore 文档分割/分块
持久化 SqliteStore + InMemoryStore 向量嵌入存储(Phase 15
RAG 管线编排 RagPipelinePhase 15

2.2 文档处理缺口

现有代码假设调用方已经拥有「文档片段」——KnowledgeStore 接受 PageIndexEntryVectorRetriever 接受 (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 接近 KnowledgeStoreDocument 常用于记忆系统 Document 不依赖 MemoryStoreVectorStorePhase 15)也可能引用 Document
C. llm/ 子模块 放在 src/llm/document.rs 靠近 Embedding Document 是纯数据结构,不涉及 LLM 调用

结论:选择 方案 A。Document 是一个独立领域概念(类似 LangChain 的 Document 是第一等类型),不应被任何一个现有模块拥有。Phase 15 的 RagPipelineVectorStore、以及 MemoryRetriever 都可能引用它。 [高]

4.2 Splitter trait:需要抽象吗?

方案 描述 优点 缺点
A. 无 trait(推荐) RecursiveCharacterSplitter 是具体 struct 代码量最少;一个实现不改设计 如果将来出现第二分割器(MarkdownHeaderSplitter),需要补 trait
B. Splitter trait 定义 fn split(&self, docs: &[Document]) -> Vec<Document> 可插拔,LC 风格 目前只有一个实现,属 YAGNI;trait 一旦发布,改签名是 breaking change

结论:选择 方案 A。YAGNI 原则。Roadmap 明确无第二分割器需求。如果将来出现第二实现,用 with_custom_splitter 接口保留向上兼容的扩展点。 [高]

4.3 Embedding trait:异步 vs 同步?

方案 描述 优点 缺点
A. 异步(推荐) async fn embed(&self, input: &[String]) -> Result<...> 真实 Provider 涉及 HTTP IO,必须异步;与 ML 推理线程池解耦 需要 async-trait crate(已有依赖),MockEmbedding 需要 async
B. 同步 fn embed(&self, input: &[String]) -> Result<...> 简化 MockEmbedding 实现 真实 ProviderOpenAI API)无法绕过 async,反向适配增加复杂度

结论:选择 方案 Aasync-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 核心类型设计

Documentsrc/document.rs

/// 文档片段 —— 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 让上层可以安全地缓存、比较文档 [中]

RecursiveCharacterSplittersrc/document.rs

/// 递归字符级文档分割器。
///
/// 使用可配置的分隔符优先级列表,递归地将文档分割为
/// 接近 chunk_size 的块。未尾块按剩余字符数自然结束。
///
/// # 算法(两阶段)
///
/// 1. **递归分割**:按分隔符优先级从高到低递归切割文本,
///    产生初始片段(均 ≤ chunk_size)。
///
/// 2. **贪心合并**:从左向右合并相邻片段,直到合计长度
///    超过 chunk_size,此时将前一组合并结果作为一个 chunk 输出,
///    并携带 chunk_overlap 字符的滑动窗口。
///
/// # 升级路径
///
/// - 如需自定义分割函数,可在上层通过 `with_custom_splitter`
///   扩展(当前未实现,预留升级路径)。
/// - 如需 unicode 感知的句子分割(如中文句号、省略号),
///   可在 separators 中加入对应字符串,或将下游替换为
///   基于 unicode-segmentation crate 的自定义分割器。
#[derive(Debug, Clone)]
pub struct RecursiveCharacterSplitter {
    chunk_size: usize,
    chunk_overlap: usize,
    separators: Vec<String>,
}

impl RecursiveCharacterSplitter {
    /// 创建分割器。
    ///
    /// # Panics
    ///
    /// 如果 chunk_size ≤ chunk_overlap(无法形成有效滑动窗口)。
    pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self { /* ... */ }

    /// 创建分割器的安全版本。
    ///
    /// 当 chunk_size ≤ chunk_overlap 时返回 `Err` 而非 panic。
    /// 适用于从配置文件等运行时来源读取参数的场景。
    pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self, &'static str> { /* ... */ }

    /// 覆盖默认分隔符优先级列表。
    pub fn with_separators(self, separators: Vec<String>) -> Self { /* ... */ }

    /// 批量分割。
    ///
    /// 每个输入文档独立分割。输出 chunks 继承源文档的 mime_type
    /// 并在 metadata 中追加 source_id / chunk_index / chunk_count。
    ///
    /// Chunk ID 格式:`{source_id}:chunk:{index:04d}`
    /// 例如 `"doc_001:chunk:0000"`(索引从 0 开始,4 位固定宽度)。
    ///
    /// 入口处有 `tracing::debug!` 埋点记录输入文档数和输出 chunk 数。
    /// Chunk 数超过 9999 时 panicdebug 模式),防止 ID 格式溢出。
    pub fn split(&self, documents: &[Document]) -> Vec<Document> { /* ... */ }
}

impl Default for RecursiveCharacterSplitter {
    fn default() -> Self {
        Self {
            chunk_size: 1000,
            chunk_overlap: 200,
            // 分隔符按优先级降序排列:
            // 段落级 → 行级 → 句子级(含 CJK 标点) → 词级 → 字符级(兜底)
            separators: vec!["\n\n", "\n", "。", "", "", ".", " ", ""]
                .into_iter().map(String::from).collect(),
        }
    }
}

设计决策

  • Splitter traitYAGNI),直接使用具体 struct [高]
  • chunk_size ≤ chunk_overlapnew() 时 panic——属于调用方编程错误;同时提供 try_new() 安全路径供运行时配置使用,符合 Rust 库惯例(new() panic + try_new() Result [高]
  • with_separators 使用 selfmove)而非 &mut self——builder 风格,不破坏默认行为的高阶使用 [中]
  • separators 类型为 Vec<String> 而非 &[&str]——owned,避免生命周期污染 [高]
  • 默认分隔符列表在 LangChain 基础上扩充了 CJK 标点( ),避免中文文本跳过句子级直接退化为空格分割 [高]
  • split() 入口加 tracing::debug! 埋点,输出 tracing::trace! 记录每个 chunk 长度,提升可观测性 [中]
  • Chunk ID 超过 9999 时触发 debug_assert!(仅 debug 模式),保护 {:04d} 格式不溢出 [高]

Embedding traitsrc/llm/embedding.rs

/// 文本向量化抽象接口。
///
/// 将文本字符串转换为固定维度的浮点向量,用于语义相似度计算。
/// 设计为异步以支持网络 IO(如 OpenAI Embedding API)。
///
/// 使用 [`LlmError`] 作为统一错误类型,与 llm 模块保持一致。
///
/// # 实现要求
///
/// - `embed()` 返回的向量外层的 Vec 长度必须等于输入切片长度(一对一映射)
/// - 内层 Vec 长度必须等于 `dim()` 返回值
/// - 调用方应保证输入非空(空切片返回空外层 Vec,不报错)
///
/// # 稳定性
///
/// 实验性 APIv0.3.x),方法签名可能在 v0.4 中调整。
#[async_trait]
pub trait Embedding: Send + Sync {
    /// 批量向量化。
    ///
    /// 返回 `Vec<Vec<f32>>`,第 i 个内层向量对应 `input[i]`。
    async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>;

    /// 返回向量维度。
    fn dim(&self) -> usize;
}

设计决策

  • 使用 &[String] 而非 &[&str]——owned 输入更常见于批量请求场景 [中]
  • 使用 LlmError 而非新类型——无 IO 的 MockEmbedding 不产生错误,trait 签名仅为真实 Provider 预留 [高]
  • dim() 作为关联函数而非常量——不同实现可返回不同维度,无需泛型参数 [高]

MockEmbeddingsrc/llm/embedding.rs

/// 确定性 Mock Embedding —— 零依赖伪随机单位向量。
///
/// 使用 sin 哈希将输入字符串映射到单位球面上的一个点:
/// 1. 对输入字符串计算简单哈希(字符和 + len)
/// 2. 用 `f32::sin(seed + i) * 10000` 生成第 i 个维度的值
/// 3. 归一化到单位长度(L2 norm = 1.0
///
/// 特性:
/// - **确定性**:相同 seed + 相同输入 → 相同向量
/// - **有区分度**:不同输入产生不同向量(高概率)
/// - **单位范数**:余弦相似度等价于点积
/// - **开销极低**:不分配额外内存,无 IO
pub struct MockEmbedding {
    dim: usize,
}

impl MockEmbedding {
    pub fn new(dim: usize) -> Self { /* ... */ }
}

#[async_trait]
impl Embedding for MockEmbedding {
    async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError> {
        let results: Vec<Vec<f32>> = input.iter().map(|text| {
            let seed = text.bytes().map(|b| b as f64).sum::<f64>() + text.len() as f64;
            let mut vec: Vec<f32> = (0..self.dim)
                .map(|i| f32::sin(seed as f32 + i as f32) * 10000.0)
                .collect();
            let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
            // 防除零
            if norm > 1e-10 {
                vec.iter_mut().for_each(|x| *x /= norm);
            }
            vec
        }).collect();
        Ok(results)
    }

    fn dim(&self) -> usize { self.dim }
}

设计决策

  • 零外部依赖,纯 stdlib 算法 [高]
  • Sin 哈希保证不同输入产生充分不同的向量(但无密码学安全性——不需要) [高]
  • L2 归一化到单位长度,余弦相似度 ≈ 点积 [高]
  • 防除零保护(极低概率的空文本种子导致零向量) [高]
  • 已知限制f32::sin(seed + i) * 10000 在维度较高时(如 1536OpenAI Embedding 维度)可能出现周期性模式——相邻维度取值在 sin 周期 2π 约束下呈规律性重复。MockEmbedding 仅用于测试验证,不应用于生产级相似度排序;做严肃验证时建议使用真实 Embedding Provider 或显式随机初始化 [中]

5.3 分割算法详情

两阶段算法伪代码:

chunk_size 单位:以下伪代码中 chars_count(s) 表示 Unicode 字符数(Rust s.chars().count()), 非字节数(s.len())。所有长度比较均以字符数为准。

Phase 1 — 递归分割(recursive_split

function split_text(text, chunk_size, separators, depth):
    if chars_count(text) ≤ chunk_size:
        return [text]

    if depth ≥ separators.len():
        // 已降到最低级分隔符(""),按 chunk_size 字符数硬截断
        // 内部用 char_indices() 确保不截断在多字节字符中间
        return split_by_chars(text, chunk_size)

    separator = separators[depth]
    segments = text.split(separator)

    result = []
    for seg in segments:
        if seg.is_empty(): continue
        if chars_count(seg) ≤ chunk_size:
            result.push(seg)
        else:
            // 递归降级到下一级分隔符
            result.extend(split_text(seg, chunk_size, separators, depth + 1))
    return result


Phase 2 — 贪心合并(greedy_merge

function merge_with_overlap(segments, chunk_size, chunk_overlap):
    chunks = []
    current_chunk = String::new()

    for seg in segments:
        if chars_count(current_chunk) + chars_count(seg) ≤ chunk_size:
            // 可以合并到当前块
            current_chunk.push(seg)
        else:
            // 当前块已满,输出
            chunks.push(current_chunk)
            // overlap:取前一块末尾 chunk_overlap 字符作为新块前缀
            let overlap_tail = last_n_chars(current_chunk, chunk_overlap)
            current_chunk = overlap_tail + seg

    // 最后一块
    if !current_chunk.is_empty():
        chunks.push(current_chunk)

    return chunks


入口函数 split(documents):
    for doc in documents:
        segments = recursive_split(doc.content, chunk_size, separators, 0)
        chunks = merge_with_overlap(segments, chunk_size, chunk_overlap)
        debug_assert!(chunks.len() < 10000, "chunk count exceeds 9999 limit")
        index = 0
        for chunk in chunks:
            output.push(Document {
                id: format!("{}:chunk:{:04d}", doc.id, index),
                content: chunk,
                metadata: {
                    // 继承源 metadata
                    ...doc.metadata,
                    // 追加追踪字段
                    "source_id": doc.id.clone(),
                    "chunk_index": index.to_string(),
                    "chunk_count": chunks.len().to_string(),
                },
                mime_type: doc.mime_type.clone(),
            })
            index += 1
    return output

关键边界

  • 空文档 → 返回空 Vec(不产生 chunk) [高]
  • 短文档(内容 ≤ chunk_size)→ 单个 chunkchunk_count = 1chunk_index = 0 [高]
  • 文档长度正好等于 chunk_size → 单个 chunkoverlap 不影响(没有前一块) [高]
  • Overlap 从上一块末尾截取 chunk_overlap 字符,确保相邻块有语义重叠 [高]
  • 字符级 fallbackseparators 降到 "")确保任何文本都能被分割 [高]

5.4 错误处理策略

场景 处理方式 原因
chunk_size ≤ chunk_overlap panic!(构造函数) 编程错误,不可能在运行时合法出现
空输入文档切片 返回空 Vec 合法输入,分割 0 个文档得 0 个结果
空文本内容 不产生 chunk 空内容无法分割
metadata 传入空 HashMap 正常使用 合法状态,示例中常见

5.5 测试策略

Document 类型(~3 个测试)

测试 场景 验证点
document_new_metadata_defaults_empty new() 构造后 metadata 为空 合约:默认行为
document_clone_partial_eq 两个相同字段的文档 Derive 正确性
document_different_ids_not_equal 不同 id 的文档 PartialEq 区分

RecursiveCharacterSplitter~10 个测试)

测试 场景 验证点
split_empty_doc_returns_empty 空文档切片 &[] 空输入安全
split_short_doc_single_chunk 短文档(≤ chunk_size 单 chunk 输出 + chunk_count = 1
split_paragraph_boundary 多段落文本按 \n\n 分割 递归首层有效
split_recursive_deepen 无段落的长文本降级到 \n/./ 切割 递归降级 + separator 优先级
split_greedy_merge_combines_segments 小片段合并到目标大小 合并逻辑
split_overlap_consistency Overlap 滑动窗口内容验证 相邻 chunk 末尾与前 chunk 重叠
split_character_fallback 无标点的纯字母文本 字符级 fallback"")有效
split_multiple_docs 同时分割多个文档 批量分割正确性
split_metadata_inheritance 输出 chunk 的 metadata source_id / chunk_index / chunk_count + 继承
split_constructor_validation chunk_size ≤ chunk_overlap #[should_panic]
split_default_separators Default::default() 创建的分割器 默认值合约

Embedding + MockEmbedding~5 个测试)

测试 场景 验证点
embed_correct_dim 输出向量维度 vec.len() == dim()
embed_batch_size_match 输入 3 个文本 → 输出 3 个向量 一对一映射
embed_deterministic 同一输入两次调用 向量全等
embed_unit_vector_norm L2 范数约等于 1.0 归一化正确
embed_different_inputs_different_vectors 两个不同输入 向量不相等(有区分度)
embed_empty_string 空字符串输入 &[""] 向量 dim 正确 + norm≈1.0(防除零路径)

5.6 与现有系统的关系

VectorRetriever trait (Phase 11, memory/vector.rs)
    fn index(id: String, embeddings: Vec<f32>) -> Result<(), MemoryError>
    fn search(query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, MemoryError>

Embedding trait (Phase 14, llm/embedding.rs)
    fn embed(input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>    ← 新
    fn dim() -> usize                                                  ← 新

RagPipeline (Phase 15)
    ingest(docs) = split → embed → store.add
    retrieve(query) = embed → store.search
    ↑ 组合器,桥接 Embedding (LlmError) 与 VectorRetriever (MemoryError)

两个 trait 的错误类型不同(LlmError vs MemoryError),Phase 15 的 RagPipeline 需要定义自己的 RagError 来统一包装。Phase 14 不做这个桥接。


6. 实施计划

6.1 文件与代码量估计

文件 角色 估计行数 新增/修改
src/document.rs Document + RecursiveCharacterSplitter + ~10 测试 ~185 新增
src/llm/embedding.rs Embedding trait + MockEmbedding + ~5 测试 ~85 新增
src/lib.rs pub mod document; +1 修改
src/llm.rs pub mod embedding; +1 修改
examples/document_demo.rs 文档分割 + Embedding demo ~50 新增
合计 ~322 2 修改 + 3 新增

6.2 实施步骤

Step 14.1 ──→ Step 14.2 ──→ Step 14.3 ──→ Step 14.4 ──→ Step 14.5 ──→ Step 14.6 ──→ Step 14.7
  document.rs   追加 split()    追加测试      embedding.rs   追加测试      lib.rs+llm.rs   document_demo
  (Document)    (RecursiveChar  cargo test    (Embedding      cargo test    模块注册         cargo run
   + 基础)       Splitter)                     trait +                       cargo build     --example
                  cargo build                  Mock)                          --all-targets   document_demo

Step 14.1 — Document 类型(cargo build 验证)

  • 新增 src/document.rs
  • Document struct + new() 构造函数
  • 5 个 trait deriveDebug, Clone, PartialEq
  • use std::collections::HashMap;

验证cargo build 通过。

// 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 + MockEmbeddingcargo build 验证)

  • 新增 src/llm/embedding.rs
  • Embedding traitasync-trait 宏)
  • MockEmbedding struct + new() + trait impl
  • use async_trait::async_trait; use crate::llm::error::LlmError;

验证cargo build 通过。

Step 14.5 — Embedding 内联测试(cargo test 验证)

  • #[cfg(test)] mod tests { ... } 追加到 src/llm/embedding.rs
  • ~6 个测试用例(含空字符串边界)

验证cargo test -- embedding:: 全部通过。

Step 14.6 — 模块注册 + 重导出(cargo build --all-targets 验证)

  • src/lib.rs 追加 pub mod document; + pub use document::Document;(方便用户以 agcore::Document 路径引用,与 agcore::MemoryError 等现有模式一致)
  • src/llm.rs 追加 pub mod embedding;

验证cargo build --all-targets + cargo test 全绿 + cargo clippy --all-targets -- -D warnings 0 警告。

Step 14.7 — document_demo 示例(cargo run --example document_demo 验证)

  • 新增 examples/document_demo.rs
  • 展示完整 RAG 管线前置流程:创建 Document → RecursiveCharacterSplitter 分割 → MockEmbedding 向量化 → 与 VectorRetriever::index 手动 zip 衔接
  • 具体步骤:
    1. 创建多段落 Document(含中英文混合文本)
    2. RecursiveCharacterSplitter::new(200, 30) 分割 -> chunks
    3. MockEmbedding::new(4) 嵌入所有 chunk -> vectors
    4. 遍历 chunks.zip(vectors),调用 InMemoryVectorRetriever::index(chunk.id, vec)
    5. 用其中一个 chunk 内容 mock 查询向量,调用 retriever.search(query, 3)
    6. 输出检索结果
  • 退出码 0

验证cargo run --example document_demo → exit 0。

6.3 验证标准

检查项 指标
cargo build --all-targets 通过
cargo test --all-targets 286+ → 304+~18 个新测试,全绿)
cargo clippy --all-targets -- -D warnings 0 警告
cargo run --example document_demo exit 0
零新外部依赖 Cargo.toml 无修改
Document tests ~12 个覆盖所有边界(含 UTF-8 多字节)
Embedding tests ~6 个覆盖 dim/batch/deterministic/norm/different/empty

6.4 回滚方案

如 Phase 14 的实施导致回归:

场景 操作
Step 14.1-14.3document.rs)编译/测试失败 git checkout -- src/document.rs,排除 document 模块问题
Step 14.4-14.5embedding.rs)编译/测试失败 git checkout -- src/llm/embedding.rs,排除 embedding 模块问题
Step 14.6(模块注册)导致冲突 git checkout -- src/lib.rs src/llm.rs,回退模块注册
Step 14.7 示例异常 git checkout -- examples/document_demo.rs,删除示例
全量回滚 git revert <phase14-commit>

6.5 里程碑

里程碑 完成条件 验证指标
M10 Phase 14 全部交付 Document + RecursiveCharacterSplitter 分割结果验证、MockEmbedding 测试通过

7. 参考来源

  1. LangChain RecursiveCharacterTextSplitter 源码 — 算法原型参考,agcore 简化实现(去掉了 length_functionis_separator_regex 配置)

  2. Roadmap Phase 14 定义docs/roadmap.md §Phase 14v0.3.0 第二阶段)

    • 交付物描述、设计要点、依赖关系
  3. Phase 11 VectorRetriever traitdocs/18-phase11-testing-and-retrieval.md

    • 已有 VectorRetriever trait 设计,Phase 14 的 Embedding 为其输入上游
  4. LlmError 类型定义src/llm/error.rs

    • 复用错误类型的设计依据
  5. LangChain 文本分割器社区讨论 — 滑动窗口策略和 overlap 选择基准

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 一致)

派生 traitDebugClonePartialEq(核心三个,必选);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 定义及构造器族:

pub struct RecursiveCharacterSplitter {
    chunk_size: usize,
    chunk_overlap: usize,
    separators: Vec<String>,
}

实现以下构造器:

  • new(chunk_size: usize, chunk_overlap: usize) -> Self — 使用默认 separatorschunk_size ≤ chunk_overlapchunk_size = 0panic!(编程错误)
  • try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self, String> — 与 new() 相同但返回 Result,验证 chunk_size > chunk_overlapchunk_size > 0,失败返回对应的 Err 消息
  • Default trait — chunk_size=1000chunk_overlap=200,使用默认 separators
  • with_separators(mut self, separators: Vec<String>) -> Self — Builder 风格,替换默认 separators 列表并返回 Self

默认 separators 常量(按优先级降序):

/// 段落级 → 行级 → 句子级(含 CJK 标点) → 词级 → 字符级(兜底)
const DEFAULT_SEPARATORS: &[&str] = &["\n\n", "\n", "。", "", "", ".", " ", ""];

作为模块私有常量定义。该列表与 §5.2 Default 实现一致(在 LangChain 基础上扩充了 CJK 句号 "。"、问号 ""、感叹号 "",确保中文文本在句子边界有更高分割质量)。

A3 — 递归分割 Phase 1split_text [M] [中]

涉及文件src/document.rs

前置依赖A2

工作量M12h

风险:中 — 递归算法容易栈溢出或无限循环,需确保每一步都缩小问题规模

验收条件cargo build 通过,使用模拟数据验证:多段落文本在首层 separator 处切开、无分隔符文本降级到字符级、每个 segment 不超过 chunk_size、空文本返回空 Vec

任务说明

实现 RecursiveCharacterSplitter 的私有递归辅助方法 split_text(&self, text: &str, separators: &[&str]) -> Vec<String>

算法(所有长度比较均以 Unicode 字符数为单位,参见 §3.1/§5.3 单位说明):

辅助函数 chars_len(s: &str) -> usize 等价于 Rust 的 s.chars().count()

  1. chars_len(text) <= self.chunk_size,直接返回 vec![text.to_string()](无需切分)
  2. 取出 separators 列表的第一个 separator sep
  3. sep 为空字符串 "":退化为字符级分割 —— 按 chunk_size 步长用 char_indices() 截取子串,返回分段列表(兜底保证)。char_indices() 返回 (byte_index, char),确保截断边界落在完整字符上,不会切在多字节 UTF-8 中间
  4. text.split(sep) 获得片段列表
  5. 遍历片段列表,逐个收集到一个临时累加器:
    • chars_len(累加器) + chars_len(当前片段) ≤ chunk_size,追加到累加器
    • chars_len(当前片段) > chunk_size:先将累加器中的内容作为一个 segment 输出,再对当前片段递归调用 split_text(当前片段, &separators[1..])(跳到下一个 separator
    • chars_len(累加器) + chars_len(当前片段) > chunk_sizechars_len(当前片段) ≤ chunk_size:将累加器内容作为一个 segment 输出,以当前片段为新的累加器
  6. 处理完所有片段后,若累加器非空,将其作为最后一个 segment 输出

关键边界

  • 空文本 "" → 空 Vec(不产生任何 segment
  • separators 耗尽(separators.is_empty())→ 直接 vec![text.to_string()],不应 panic
  • 单字符无限递归保护:sep 降级到 "" 后,char_indices() 步进保证收敛,每次迭代至少消耗 1 个字符
  • 递归深度自然受限:每次递归消耗 1 个 separatorseparators 列表长度 ~7 层(含 "" 兜底),最深 7 层,无需额外 depth 哨兵

A4 — 贪心合并 Phase 2merge_with_overlap [M] [中]

涉及文件src/document.rs

前置依赖A3

工作量M12h

风险:中 — overlap 切片边界容易 off-by-one,滑动窗口索引需仔细验证

验收条件cargo build 通过,相邻 chunk 末尾 overlap 内容匹配、chunk 总数符合预期、单个 segment 超过 chunk_size 时独立成块

任务说明

实现 RecursiveCharacterSplitter 的私有方法 merge_with_overlap(&self, segments: Vec<String>) -> Vec<String>

算法(所有长度比较以 Unicode 字符数为单位,同 A3):

  1. 初始化空 chunks: Vec<String>,空累加器 current = String::new()
  2. 从左到右遍历 segments
    • chars_len(current) + chars_len(segment) ≤ chunk_size:将 segment 追加到 current
    • 否则:将 current 推入 chunks,用新 segment 重置 current
  3. 遍历结束后若 current 非空,推入 chunks
  4. Overlap 应用(除第一个 chunk 外):
    • chunks[i]i ≥ 1),从 chunks[i-1] 末尾截取 最后 chunk_overlap 个字符作为前缀拼接到 chunks[i] 开头
    • 实现方式:chars().rev().take(chunk_overlap).collect::<Vec<_>>().into_iter().rev().collect()(取最后 N 字符,字符级安全)
    • char_indices().rev().nth(chunk_overlap-1) 定位起始字节位置后截取
    • 注意 chunk_overlap 是字符数,不能直接用字节索引 chunks[i-1].len() 截取——后者对 CJK 文本会错误地少取字符
    • 例如 chunk_size=20chunk_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>

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
    • idformat!("{}:chunk:{:04}", doc.id, chunk_index),四位零填充保证字典序排序正确(最多支持 9999 个 chunk)
    • contentchunk 文本
    • 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() 的文档注释中注明此行为。

额外合约

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 != doc2PartialEq 区分不同 identity

这些测试验证 struct derive 的正确性和构造函数的合约行为。

B2 — Splitter 边界条件测试 [S] [低]

涉及文件src/document.rs

前置依赖A2、A5splitter 结构体 + split 入口)

工作量S< 1h

风险:低

验收条件4 个测试全部通过

任务说明

测试函数 场景 断言
split_empty_doc_returns_empty 传入空切片 &[] splitter.split(&[]) 返回空 Vec
split_short_doc_single_chunk 短文档(5 字符)vs chunk_size=100 返回 1 个 chunkchunk_count = 1chunk_index = 0
split_constructor_validation RecursiveCharacterSplitter::new(10, 10) #[should_panic(expected = "chunk_size must be greater than chunk_overlap")]
split_default_separators RecursiveCharacterSplitter::default() 内部 separators 等于 DEFAULT_SEPARATORS

B3 — Splitter 核心算法测试 [M] [中]

涉及文件src/document.rs

前置依赖A5

工作量M12h

风险:中 — 算法测试需要精心构造输入,确保每次降级和合并边界都被覆盖

验收条件:6 个算法测试全部通过(含 UTF-8 多字节边界)

任务说明

测试函数 场景 构造方法 断言
split_paragraph_boundary 两段落("aaa\\n\\nbbb"),chunk_size=100 首层 \n\n 切开 返回 2 个 chunk
split_recursive_deepen 无段落的长单行(200 字符,无 \n\n),chunk_size=50 降级到 \n. "" 切割 每个 chunk ≤ 50 字符,至少产生 3 个 chunk
split_greedy_merge_combines_segments 多个短片段("a", "b", "c"),chunk_size=5 Phase 1 产出小片段,Phase 2 合并为 "abc" 合并后 chunk 长度 ≤ 5,片段数少于输入 segment 数
split_overlap_consistency 长文本迫使多 chunkchunk_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 风格一致):

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

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 一致,确定性伪随机向量生成):

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 归一化

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.0abs(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

前置依赖A1document.rs 存在)、C1embedding.rs 存在)

工作量S< 0.5h

风险:低

验收条件cargo build --all-targets 通过,cargo clippy --all-targets -- -D warnings 0 警告

任务说明

src/lib.rs 中追加 2 行:

pub mod document;
pub use document::Document;

src/llm.rs 中追加 1 行:

pub mod embedding;

pub use document::Document 允许用户以 agcore::Document 引用,与现有 agcore::MemoryErroragcore::LlmError 等重导出模式一致。执行完整验证:

cargo build --all-targets
cargo test --all-targets
cargo clippy --all-targets -- -D warnings

Group F — 集成示例

F1 — document_demo 完整管线示例 [M] [中]

涉及文件examples/document_demo.rs

前置依赖E1、Phase 11 的 InMemoryVectorRetriever

工作量M12h

风险:中 — 需要同时引用 3 个模块(document / embedding / memory),不同模块的错误类型需手动处理

验收条件cargo run --example document_demo → exit 0,输出显示分割前后统计信息和检索结果

任务说明

创建 examples/document_demo.rs,展示 Document → Splitter → Embedding → VectorRetriever 的完整管线流程:

// 伪代码结构
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 配置模式)
  • 退出码为 0main() 不 panic、不返回错误)

8.3 执行顺序

时序图(Mermaid):

flowchart LR
    A1 --> A2 --> A3 --> A4 --> A5 --> A6
    A6 --> B1 --> B2 --> B3 --> B4
    C1 --> C2 --> D1
    A1 -.->|C 组与 A 组并行| C1
    B4 --> E1
    D1 --> E1
    E1 --> F1

串行依赖(不可并行)

路径 原因
A1 → A2 → A3 → A4 → A5 → A6 后续任务依赖前面的 struct、方法或算法
A5 → B1/B2/B3/B4 测试依赖 split() 入口可用
C1 → C2 → D1 trait → impl → test 顺序依赖
B4 + D1 → E1 模块注册需两模块均完成
E1 → F1 示例依赖所有模块注册完毕

验证阶段

阶段 触发条件 验证命令
V1 A6A 组代码完整) cargo build
V2 B4(所有文档测试通过) cargo test -- document::
V3 C2C 组代码完整) cargo build
V4 D1(所有 Embedding 测试通过) cargo test -- embedding::
V5 E1(模块注册完成) cargo build --all-targets && cargo clippy --all-targets -- -D warnings
V6 F1(示例运行) cargo run --example document_demo → exit 0

8.4 并行机会

并行组 任务 说明
路径 A ↔ 路径 C A1A6 ‖ C1C2 Document 模块和 Embedding 模块零耦合,可由不同开发者同时实现
A6 + B1 代码完善后立即开始测试 A6 完成(A 组代码冻结)后即可开始 B1,无需等待 B4 全部完成;但 B1 依赖 A1 已稳定
C2 + 准备 E1 Embedding 实现完成后可立即准备注册 E1 需要 A1 和 C1 的文件存在,不要求测试通过

不可并行(阻塞依赖)

阻塞 原因
B1/B2/B3/B4 必须在 A6 之后 测试依赖 A1A6 的全部代码
D1 必须在 C2 之后 测试依赖 Embedding impl
E1 必须在 A1 + C1 之后 文件必须存在
F1 必须在 E1 之后 模块必须注册才能被示例引用

8.5 风险与应对

风险 等级 影响 应对
递归分割无限循环 A3 无法收敛,测试挂起或栈溢出 确保 "" 是 separators 的最后一个兜底值;char_indices() 步进保证每次至少消耗一个字符;为递归层数添加 debug_assert!(depth < 100) 哨兵
Overlap 切片 off-by-one B3 的 split_overlap_consistency 测试失败 用已知文本手动追踪切片边界;优先使用字符索引而非字节索引;编写独立小测试验证 chunks[1] 的前缀等于 chunks[0] 的后 n 个字符
MockEmbedding 未归一化 单元向量 norm 测试失败,下游 VectorRetriever 余弦相似度错误 mock_vector() 末尾强制调用 l2_normalize()norm == 0 时跳过归一化(零除保护);embed_empty_string 测试覆盖零向量路径
InMemoryVectorRetriever API 不兼容 F1 示例编译失败,index()/search() 签名不匹配 实现 F1 前先阅读 src/memory/vector.rs 确认 InMemoryVectorRetriever 的公开方法签名;必要时用 .clone() 适配所有权模型
示例中 Embedding 异步上下文 main() 需要 tokio::runtime#[tokio::main] 确认项目目前 examples 使用的异步模式(检查现有 example:#[tokio::main] 或手动 block_on);保持风格一致
chunk ID 索引格式溢出 单个文档 > 9999 个 chunk 导致 ID 重复 debug_assert!(chunks.len() < 10000) 已在 A5 中定义;{:04} 格式最高支持 9999,超过时触发断言,提示开发者增大 chunk_size

8.6 工作量汇总

分组 S M L XL
A — Document 模块 A1, A2, A5, A6 A3, A4
B — Document 测试 B1, B2, B4 B3
C — Embedding 模块 C1, C2
D — Embedding 测试 D1
E — 模块注册 E1
F — 集成示例 F1
合计 10 5 0 0
预估总工时 ~8h ~8h
总计 ~16h2 人·日)

工时估算依据:S 任务平均 ~0.8h,M 任务平均 ~1.5h。含代码编写、本地编译调试、测试通过、clippy 清理。不含方案评审和 Code Review 时间。