将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
64 KiB
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 已有:
VectorRetrievertrait(Phase 11)——语义向量检索抽象,支持index(id, embeddings)和search(query, k)MemoryRetriever(Phase 3)——基于 TextOverlap Dice 系数的关键词检索KnowledgeStore(Phase 3)——知识页面存储
这三个组件构成了检索能力的基础,但缺少两个前置环节:
- 文档分割:原始文本从文件/网络加载后,如何分割成结构化、可索引的文档片段?
- 向量化:分割后的文本片段如何通过 Embedding 模型转化为向量,才能喂给
VectorRetriever?
Phase 14 不解决 RAG 管线的完整编排(那是 Phase 15 的职责),只交付两个底层组件——分割和向量化抽象。
1.2 目标
- 定义
Document核心类型,作为整个文档处理管线的数据载体 [高] - 实现
RecursiveCharacterSplitter——递归字符级分割器,支持chunk_size、chunk_overlap、自定义separators优先级 [高] - 定义
Embeddingtrait——异步向量化抽象接口,不与任何具体 Provider 绑定 [高] - 交付
MockEmbedding——零依赖、确定性伪随机向量引用实现,用于测试和离线验证 [高] - 所有代码零新外部依赖,仅依赖 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 是当前社区最广泛使用的分割策略,其核心思路:
- 维护一个分隔符优先级列表(
["\n\n", "\n", ".", " ", ""]) - 从最高优先级分隔符开始,递归分割
- 当某级分隔符产生的块仍大于
chunk_size时,降级到下一级分隔符继续递归 - 最后通过贪心合并和 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(递归 + 贪心合并)的两阶段算法。原因:
- 第一阶段(递归)+ 第二阶段(贪心合并)比纯递归更适应非均匀长度的文档;
- 贪心合并可以自然保证除最后一段外每段接近
chunk_size; - 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)
/// 文档片段 —— 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_eqderive 让上层可以安全地缓存、比较文档 [中]
RecursiveCharacterSplitter(src/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 时 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(),
}
}
}
设计决策:
- 无
Splittertrait(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)
/// 文本向量化抽象接口。
///
/// 将文本字符串转换为固定维度的浮点向量,用于语义相似度计算。
/// 设计为异步以支持网络 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)
/// 确定性 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 字符数(Rusts.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 Documentstruct +new()构造函数- 5 个 trait derive:
Debug,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 验证)
- 追加
RecursiveCharacterSplitterstruct + 三个方法 - 两阶段分割算法实现(递归分割 + 贪心合并)
- 默认
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 Embeddingtrait(async-trait宏)MockEmbeddingstruct +new()+ trait impluse 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 衔接 - 具体步骤:
- 创建多段落 Document(含中英文混合文本)
- RecursiveCharacterSplitter::new(200, 30) 分割 -> chunks
- MockEmbedding::new(4) 嵌入所有 chunk -> vectors
- 遍历 chunks.zip(vectors),调用
InMemoryVectorRetriever::index(chunk.id, vec) - 用其中一个 chunk 内容 mock 查询向量,调用
retriever.search(query, 3) - 输出检索结果
- 退出码 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. 参考来源
-
LangChain RecursiveCharacterTextSplitter 源码 — 算法原型参考,agcore 简化实现(去掉了
length_function和is_separator_regex配置) -
Roadmap Phase 14 定义 —
docs/roadmap.md§Phase 14(v0.3.0 第二阶段)- 交付物描述、设计要点、依赖关系
-
Phase 11 VectorRetriever trait —
docs/18-phase11-testing-and-retrieval.md- 已有
VectorRetrievertrait 设计,Phase 14 的Embedding为其输入上游
- 已有
-
LlmError 类型定义 —
src/llm/error.rs- 复用错误类型的设计依据
-
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,定义Documentstruct 包含 4 个公开字段:
字段 类型 默认值 说明 idString必传 全局唯一标识 contentString必传 文档正文 metadataHashMap<String, String>HashMap::new()键值对元数据 mime_typeString必传 MIME 类型标识 实现两个构造函数:
new(id: String, content: String, mime_type: String) -> Self— metadata 默认为空 HashMapfrom_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/Eqderive。
A2 — RecursiveCharacterSplitter 构造器 [S] [低]
涉及文件:
src/document.rs前置依赖:A1
工作量:S(< 1h)
风险:低
验收条件:
cargo build通过,所有构造器编译正确,Default默认值符合合约任务说明:
在
src/document.rs中追加RecursiveCharacterSplitterstruct 定义及构造器族: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消息Defaulttrait —chunk_size=1000,chunk_overlap=200,使用默认 separatorswith_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 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()。
- 若
chars_len(text) <= self.chunk_size,直接返回vec- 取出
separators列表的第一个 separatorsep- 若
sep为空字符串"":退化为字符级分割 —— 按chunk_size步长用char_indices()截取子串,返回分段列表(兜底保证)。char_indices()返回(byte_index, char),确保截断边界落在完整字符上,不会切在多字节 UTF-8 中间- 用
text.split(sep)获得片段列表- 遍历片段列表,逐个收集到一个临时累加器:
- 若
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 输出,以当前片段为新的累加器- 处理完所有片段后,若累加器非空,将其作为最后一个 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):
- 初始化空
chunks: Vec<String>,空累加器current = String::new()- 从左到右遍历
segments:
- 若
chars_len(current) + chars_len(segment) ≤ chunk_size:将 segment 追加到current- 否则:将
current推入chunks,用新 segment 重置current- 遍历结束后若
current非空,推入chunks- 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..."(多字节字符安全)- 返回最终
chunks边界处理:
- 空 segments → 空 Vec
- 单个 segment 直接返回
vecchunk_overlap = 0→ 跳过 overlap 步骤,等价于纯贪心合并- Overlap 长度不能超过前一块实际字符数(用
take(chunk_overlap).count()隐含截断,参见 RustIter::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> { // ... 实现 }逻辑:
- 若
documents.is_empty(),返回空 Vec- 对每个输入 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- 返回所有 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 != 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 定义编译正确,MockEmbeddingstruct 可实例化任务说明:
创建
src/llm/embedding.rs,定义Embeddingtrait 和MockEmbeddingstruct: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实现Embeddingtrait: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.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 warnings0 警告任务说明:
在
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::MemoryError、agcore::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工作量:M(1–2h)
风险:中 — 需要同时引用 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 配置模式)- 退出码为 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 时间。