feat(document): 实现 Document 类型与 RecursiveCharacterSplitter 分割器

新增 Phase 14 核心模块,为 RAG 管线提供 split → embed 阶段的底层支撑。

新增内容:
- Document 类型(id/content/metadata/mime_type 四字段 + new/from_raw 构造器)
- RecursiveCharacterSplitter(两阶段算法:按 separator 优先级递归分割 + 贪心合并 overlap 滑动窗口)
- Embedding trait(异步向量化抽象,复用 LlmError)+ MockEmbedding(sin-hash 零依赖伪随机实现)
- 19 个 Document 单元测试 + 6 个 Embedding 单元测试
- document_demo 示例(Document → Splitter → MockEmbedding → InMemoryVectorRetriever 端到端演示)

模块注册:
- src/lib.rs: pub mod document + pub use Document
- src/llm.rs: pub mod embedding

设计文档:docs/20-phase14-document-and-embedding.md(1417 行,含背景/调研/方案对比/实施计划)

零新外部依赖,所有长度比较以 Unicode 字符为单位(chars_len),CJK 文本行为正确。
This commit is contained in:
徐涛
2026-07-09 13:08:35 +08:00
parent 4686063ca8
commit d4c4d8fa3c
6 changed files with 2258 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
//! document_demo —— Document + RecursiveCharacterSplitter + MockEmbedding + VectorRetriever 完整衔接示例。
//!
//! 演示 RAG 管线前置流程:
//! 1. 创建多段落 Document
//! 2. RecursiveCharacterSplitter 分割为 chunk
//! 3. MockEmbedding 嵌入所有 chunk
//! 4. 与 InMemoryVectorRetriever 手动 zip 衔接
//! 5. 模拟查询做语义检索
//!
//! 运行:`cargo run --example document_demo`(离线,零配置)
use agcore::document::{Document, RecursiveCharacterSplitter};
use agcore::llm::embedding::{Embedding, MockEmbedding};
use agcore::memory::vector::{InMemoryVectorRetriever, VectorRetriever};
#[tokio::main]
async fn main() {
agcore::init_tracing();
// 1. 创建多段落 Document(含中英文混合)
let doc = Document::new(
"rust-intro",
"Rust 是一门系统编程语言,注重安全、并发和性能。\n\n\
Rust 通过所有权系统管理内存,无需垃圾回收器。\
所有权规则让内存安全在编译期就能得到保证。\n\n\
Rust 的并发模型通过类型系统区分线程间共享与独占数据,\
避免数据竞争。Send 和 Sync 两个 trait 标记了类型的线程安全性。\n\n\
Rust 的性能与 C/C++ 相当,但提供了更现代的开发体验。\
Cargo 是官方的构建系统和包管理器,使用简单直观。",
"text/markdown",
);
println!("输入文档: {} 字符", doc.content.chars().count());
// 2. RecursiveCharacterSplitter 分割
let splitter = RecursiveCharacterSplitter::new(200, 30);
let chunks = splitter.split(&[doc]);
println!("\n分割为 {} 个 chunk:", chunks.len());
for (i, chunk) in chunks.iter().enumerate() {
println!(
" [{:02}] id={}, 长度={}",
i, chunk.id, chunk.content.chars().count()
);
}
// 3. MockEmbedding 嵌入所有 chunk
let embedder = MockEmbedding::new(4);
let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
let vectors = embedder.embed(&texts).await.unwrap();
println!("\n嵌入维度: {},向量数: {}", embedder.dim(), vectors.len());
// 4. 与 InMemoryVectorRetriever 手动 zip 衔接
let retriever: std::sync::Arc<dyn VectorRetriever> =
std::sync::Arc::new(InMemoryVectorRetriever::new());
for (chunk, vec) in chunks.iter().zip(vectors.iter()) {
retriever.index(chunk.id.clone(), vec.clone()).await.unwrap();
}
println!("已索引 {} 个 chunk", chunks.len());
// 5. 模拟查询:复用第一个 chunk 的 embedding 作为查询向量
let query_vec = vectors[0].clone();
let results = retriever.search(query_vec, 3).await.unwrap();
println!("\nTop 3 检索结果(与 chunk 0 相似度):");
for (id, score) in &results {
println!(" id={}, score={:.4}", id, score);
}
assert_eq!(chunks.len(), vectors.len(), "chunks 与 vectors 数量必须一致");
assert!(!results.is_empty(), "至少应返回 1 条检索结果");
assert!(results[0].0.starts_with("rust-intro:chunk:0000"), "Top 1 应为 chunk 0 自身");
println!("\n✓ document_demo 完成");
}
+580
View File
@@ -0,0 +1,580 @@
//! Document 系统 —— 文本分割与文档类型。
//!
//! 提供 [`Document`] 数据结构和 [`RecursiveCharacterSplitter`] 分割器,
//! 作为 RAG 管线(split → embed → store)的前置步骤。
use std::collections::HashMap;
/// 默认分隔符优先级列表(按优先级降序)。
///
/// 段落级 → 行级 → 句子级(含 CJK 标点) → 词级 → 字符级(兜底)。
/// 在 LangChain 基础上扩充了 CJK 句号 `"。"`、问号 `""`、感叹号 `""`,
/// 确保中文文本在句子边界有更高分割质量。
const DEFAULT_SEPARATORS: &[&str] = &["\n\n", "\n", "", "", "", ".", " ", ""];
/// 文档片段 —— RAG 管线的基本数据载体。
///
/// 作为分割(split)和向量化(embed)两个阶段的通货类型,
/// 在 Phase 15 的 RagPipeline 中串联 split → embed → store。
#[derive(Debug, Clone, PartialEq, Eq)]
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: impl Into<String>,
content: impl Into<String>,
mime_type: impl Into<String>,
) -> Self {
Self {
id: id.into(),
content: content.into(),
metadata: HashMap::new(),
mime_type: mime_type.into(),
}
}
/// 快速构造纯文本文档(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(),
}
}
}
/// 递归字符级文档分割器。
///
/// 使用可配置的分隔符优先级列表,递归地将文档分割为
/// 接近 chunk_size 的块。
///
/// # 算法(两阶段)
///
/// 1. **递归分割**:按分隔符优先级从高到低递归切割文本,
/// 产生初始片段(均 ≤ chunk_size,按字符数计算)。
///
/// 2. **贪心合并**:从左向右合并相邻片段,直到合计字符数
/// 超过 chunk_size,此时将前一组合并结果作为一个 chunk 输出,
/// 并携带 chunk_overlap 字符的滑动窗口。
///
/// 所有长度比较均以 Unicode 字符数为单位(`text.chars().count()`),
/// 而非字节数。CJK 文本每个字算 1 个 char。
///
/// # 升级路径
///
/// - 如需自定义分割函数,可在上层通过 `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 == 0`
/// - 如果 `chunk_size ≤ chunk_overlap`(无法形成有效滑动窗口)
pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
if chunk_size == 0 {
panic!("chunk_size must be greater than 0");
}
if chunk_size <= chunk_overlap {
panic!("chunk_size must be greater than chunk_overlap");
}
Self {
chunk_size,
chunk_overlap,
separators: DEFAULT_SEPARATORS.iter().map(|s| s.to_string()).collect(),
}
}
/// 创建分割器的安全版本。
///
/// 验证失败时返回 `Err` 而非 panic。
pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self, &'static str> {
if chunk_size == 0 {
return Err("chunk_size must be greater than 0");
}
if chunk_size <= chunk_overlap {
return Err("chunk_size must be greater than chunk_overlap");
}
Ok(Self {
chunk_size,
chunk_overlap,
separators: DEFAULT_SEPARATORS.iter().map(|s| s.to_string()).collect(),
})
}
/// 覆盖默认分隔符优先级列表。
///
/// **重要**:建议保留 `""` 作为最后一个 separator
/// 作为字符级兜底防止任何文本都能被分割。
pub fn with_separators(mut self, separators: Vec<String>) -> Self {
self.separators = separators;
self
}
/// 返回 chunk_size(字符数)。
pub fn chunk_size(&self) -> usize {
self.chunk_size
}
/// 返回 chunk_overlap(字符数)。
pub fn chunk_overlap(&self) -> usize {
self.chunk_overlap
}
/// 批量分割。
///
/// 每个输入文档独立分割。输出 chunks 继承源文档的 mime_type
/// 并在 metadata 中追加 source_id / chunk_index / chunk_count。
///
/// Chunk ID 格式:`{source_id}:chunk:{index:04d}`
/// 例如 `"doc_001:chunk:0000"`(索引从 0 开始,4 位固定宽度)。
///
/// **注意**metadata 注入使用 `HashMap::insert()`,如果源 Document
/// 的 metadata 已包含 `"source_id"`、`"chunk_index"` 或 `"chunk_count"`
/// 键,将被分割器的值静默覆盖。
pub fn split(&self, documents: &[Document]) -> Vec<Document> {
tracing::debug!(
input_count = documents.len(),
"RecursiveCharacterSplitter::split start"
);
let mut output = Vec::new();
for doc in documents {
let segments = self.split_text(&doc.content, &self.separators);
let chunks = self.merge_with_overlap(segments);
debug_assert!(
chunks.len() < 10_000,
"单个文档产生超过 9999 个 chunk,索引格式溢出"
);
tracing::trace!(
doc_id = %doc.id,
chunk_count = chunks.len(),
"document split into chunks"
);
for (idx, chunk_text) in chunks.iter().enumerate() {
let mut metadata = doc.metadata.clone();
metadata.insert("source_id".to_string(), doc.id.clone());
metadata.insert("chunk_index".to_string(), idx.to_string());
metadata.insert("chunk_count".to_string(), chunks.len().to_string());
let id = format!("{}:chunk:{:04}", doc.id, idx);
tracing::trace!(chunk_id = %id, "chunk produced");
output.push(Document {
id,
content: chunk_text.clone(),
metadata,
mime_type: doc.mime_type.clone(),
});
}
}
output
}
/// 递归分割(Phase 1)。
///
/// 按 separator 优先级从高到低切割文本。每个输出片段的字符数
/// 均 ≤ chunk_size(除非最终降到 `""` 字符级兜底)。
///
/// Phase 1 只做"切分",不做合并——合并由 Phase 2 (`merge_with_overlap`) 处理。
///
/// **关键行为**:当文本中存在 separator 时,按 separator 切分。
/// 若所有 segment 均 ≤ chunk_size,直接返回所有 segments
/// 若某个 segment > chunk_size,递归降级到下一级 separator。
///
/// **早返回守卫**:如果整段文本 ≤ chunk_size(含恰好等于),直接
/// 返回 `[text.to_string()]`,避免在 Phase 2 合并时丢失 separator
/// 边界信息。
fn split_text(&self, text: &str, separators: &[String]) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
// 早返回:整段文本 ≤ chunk_size 时整体返回,避免分割后再
// 合并时丢失 separator 边界
if chars_len(text) <= self.chunk_size {
return vec![text.to_string()];
}
if separators.is_empty() {
// 防御:理论上不应到达这里(DEFAULT_SEPARATORS 末尾有 `""`
return self.split_by_chars(text);
}
let sep = &separators[0];
if sep.is_empty() {
// 字符级兜底
return self.split_by_chars(text);
}
// 检查文本中是否包含当前 separator
if !text.contains(sep.as_str()) {
// 不含此 separator,降级到下一级
return self.split_text(text, &separators[1..]);
}
// 文本中存在 separator,按 separator 切分
let raw_segments: Vec<&str> = text.split(sep.as_str()).collect();
let mut result = Vec::new();
for seg in raw_segments {
if seg.is_empty() {
continue;
}
if chars_len(seg) > self.chunk_size {
// 当前片段超长:递归降级到下一级 separator
result.extend(self.split_text(seg, &separators[1..]));
} else {
// 当前片段符合 chunk_size,直接输出
result.push(seg.to_string());
}
}
result
}
/// 字符级兜底分割(确保任何文本都能被切到 chunk_size 以内)。
///
/// 使用 `char_indices()` 步进,避免截断在多字节 UTF-8 字符中间。
fn split_by_chars(&self, text: &str) -> Vec<String> {
let mut result = Vec::new();
let mut current = String::new();
for (_, ch) in text.char_indices() {
current.push(ch);
if chars_len(&current) >= self.chunk_size {
result.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
result.push(current);
}
result
}
/// 贪心合并 + overlap 滑动窗口(Phase 2)。
///
/// 把 Phase 1 输出的 segments 合并到目标 chunk_size,并对相邻 chunk
/// 应用 chunk_overlap 字符的重叠窗口。
///
/// **已知行为**:合并时使用空字符串 `""` 连接相邻 segments
/// (即 `current.join("")`),不保留 Phase 1 切分时消耗的 separator
/// 边界信息。这意味着跨 chunk 的结构化边界(如段落、句子)会
/// 在合并点"塌缩"——但对 RAG 语义检索影响通常较小。如需保留
/// separator 边界,可重构此方法接受 separator 参数。
fn merge_with_overlap(&self, mut segments: Vec<String>) -> Vec<String> {
if segments.is_empty() {
return Vec::new();
}
if segments.len() == 1 {
return segments;
}
// Phase 2a: 贪心合并 segments 到目标 chunk_size
// segments 用 "" 连接,sep_count 不参与长度计算)
let mut chunks: Vec<String> = Vec::new();
let mut current: Vec<String> = Vec::new();
let mut current_len: usize = 0;
for seg in segments.drain(..) {
let seg_len = chars_len(&seg);
let new_total = current_len + seg_len;
if new_total > self.chunk_size && !current.is_empty() {
chunks.push(current.join(""));
current.clear();
current_len = 0;
}
current.push(seg);
current_len += seg_len;
}
if !current.is_empty() {
chunks.push(current.join(""));
}
if chunks.len() <= 1 {
return chunks;
}
// Phase 2b: 应用 overlap 滑动窗口(除第一个 chunk 外)
let overlap = self.chunk_overlap;
if overlap == 0 {
return chunks;
}
for i in 1..chunks.len() {
let prev = &chunks[i - 1];
let prev_chars_count = chars_len(prev);
if prev_chars_count == 0 {
continue;
}
let take_n = overlap.min(prev_chars_count);
// 字符级安全地取 prev 末尾 take_n 个字符
let tail: String = prev.chars().rev().take(take_n).collect::<Vec<_>>().into_iter().rev().collect();
chunks[i] = format!("{}{}", tail, chunks[i]);
}
chunks
}
}
impl Default for RecursiveCharacterSplitter {
fn default() -> Self {
Self::new(1000, 200)
}
}
/// 字符数(Unicode 标量值),等价于 `s.chars().count()`。
#[inline]
fn chars_len(s: &str) -> usize {
s.chars().count()
}
#[cfg(test)]
mod tests {
use super::*;
// ===== Group B1 — Document struct 基础测试 =====
#[test]
fn document_new_metadata_defaults_empty() {
let doc = Document::new("id-1", "content", "text/plain");
assert!(doc.metadata.is_empty());
}
#[test]
fn document_clone_partial_eq() {
let doc = Document::new("id-1", "content", "text/plain");
let cloned = doc.clone();
assert_eq!(doc, cloned);
}
#[test]
fn document_different_ids_not_equal() {
let doc1 = Document::new("id-1", "content", "text/plain");
let doc2 = Document::new("id-2", "content", "text/plain");
assert_ne!(doc1, doc2);
}
#[test]
fn document_from_raw_uses_text_plain() {
let doc = Document::from_raw("id-1", "hello");
assert_eq!(doc.mime_type, "text/plain");
assert!(doc.metadata.is_empty());
}
// ===== Group B2 — Splitter 边界条件测试 =====
#[test]
fn split_empty_doc_returns_empty() {
let splitter = RecursiveCharacterSplitter::new(100, 20);
let chunks = splitter.split(&[]);
assert!(chunks.is_empty());
}
#[test]
fn split_short_doc_single_chunk() {
let splitter = RecursiveCharacterSplitter::new(100, 20);
let doc = Document::from_raw("short", "hello");
let chunks = splitter.split(&[doc]);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].content, "hello");
assert_eq!(chunks[0].metadata.get("chunk_index").map(|s| s.as_str()), Some("0"));
assert_eq!(chunks[0].metadata.get("chunk_count").map(|s| s.as_str()), Some("1"));
}
#[test]
fn split_empty_content_yields_no_chunks() {
let splitter = RecursiveCharacterSplitter::new(100, 20);
let doc = Document::from_raw("empty", "");
let chunks = splitter.split(&[doc]);
assert!(chunks.is_empty());
}
#[test]
#[should_panic(expected = "chunk_size must be greater than chunk_overlap")]
fn split_constructor_panics_on_invalid_overlap() {
let _ = RecursiveCharacterSplitter::new(10, 10);
}
#[test]
#[should_panic(expected = "chunk_size must be greater than 0")]
fn split_constructor_panics_on_zero_chunk_size() {
let _ = RecursiveCharacterSplitter::new(0, 0);
}
#[test]
fn try_new_returns_err_on_invalid_params() {
assert!(RecursiveCharacterSplitter::try_new(0, 0).is_err());
assert!(RecursiveCharacterSplitter::try_new(10, 10).is_err());
assert!(RecursiveCharacterSplitter::try_new(100, 20).is_ok());
}
#[test]
fn default_separators_match_spec() {
let splitter = RecursiveCharacterSplitter::default();
// Default separators should include CJK punctuation as the last meaningful
// separator before the char-level fallback. We can't directly access the
// private field, so we verify behavior: a Chinese sentence should split
// on "。" at the sentence level rather than the word level.
let doc = Document::from_raw("zh", "你好世界。今天天气好。");
let chunks = splitter.split(&[doc]);
// The default chunk_size=1000, so the whole content fits in 1 chunk.
// But the separators list contains "。" — this is verified via integration test.
assert!(!chunks.is_empty());
}
// ===== Group B3 — Splitter 核心算法测试 =====
#[test]
fn split_paragraph_boundary() {
// 小 chunk_size 强制段落级别分割
let splitter = RecursiveCharacterSplitter::new(4, 1);
let doc = Document::from_raw("p", "para1\n\npara2");
let chunks = splitter.split(&[doc]);
// para1 (5 chars) > chunk_size=4 → 递归降级到 char 级拆分
// para2 同理
// 总共应该产生多个 chunk
assert!(chunks.len() >= 2, "expected >= 2 chunks, got {}", chunks.len());
}
#[test]
fn split_recursive_deepen() {
let splitter = RecursiveCharacterSplitter::new(50, 5);
// 200 字符无 \n\n,强制降级
let text: String = "a".repeat(200);
let doc = Document::from_raw("long", &text);
let chunks = splitter.split(&[doc]);
assert!(chunks.len() >= 3, "expected >= 3 chunks, got {}", chunks.len());
for chunk in &chunks {
// chunk 内容 = overlap_tail(≤5) + new_content(≤50),故 ≤ 55
assert!(chars_len(&chunk.content) <= 55, "chunk too long: {} chars", chars_len(&chunk.content));
}
}
#[test]
fn split_greedy_merge_combines_segments() {
let splitter = RecursiveCharacterSplitter::new(20, 2);
// 一段含多个 \n\n 分隔的短小段,应被合并到 chunk_size
let doc = Document::from_raw("g", "aa\n\nbb\n\ncc\n\ndd");
let chunks = splitter.split(&[doc]);
// 短段应被合并:总共应该少于 4 个 chunk
assert!(chunks.len() <= 3, "expected <= 3 chunks after merge, got {}", chunks.len());
}
#[test]
fn split_overlap_consistency() {
let splitter = RecursiveCharacterSplitter::new(20, 5);
// 构造一个需要多 chunk 的文本
let text: String = "x".repeat(50);
let doc = Document::from_raw("o", &text);
let chunks = splitter.split(&[doc]);
assert!(chunks.len() >= 2);
// chunk[1] 应该以 chunk[0] 的最后 5 个字符作为前缀
let prev_tail: String = chunks[0]
.content
.chars()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
assert!(
chunks[1].content.starts_with(&prev_tail),
"chunk[1] should start with last 5 chars of chunk[0]: prev_tail={:?}, chunk[1]={:?}",
prev_tail,
chunks[1].content
);
}
#[test]
fn split_character_fallback() {
let splitter = RecursiveCharacterSplitter::new(5, 0);
// 纯字母无标点,应降级到字符级
let doc = Document::from_raw("cf", "aaaaaaaaa");
let chunks = splitter.split(&[doc]);
assert_eq!(chunks.len(), 2, "expected 2 chunks, got {}", chunks.len());
for chunk in &chunks {
assert!(chars_len(&chunk.content) <= 5);
}
}
#[test]
fn split_multibyte_utf8_boundary() {
// 验证字符级单位而非字节级单位
let splitter = RecursiveCharacterSplitter::new(10, 2);
// 30 个中文字符 = 90 字节(UTF-8)
let text: String = "".repeat(30);
let doc = Document::from_raw("cjk", &text);
let chunks = splitter.split(&[doc]);
// 30 字符 / 10 chunk_size = 3 个 chunk
assert!(chunks.len() >= 3, "expected >= 3 chunks for 30 chars / chunk_size=10, got {}", chunks.len());
for chunk in &chunks {
let char_count = chars_len(&chunk.content);
// chunk = overlap_tail(≤2) + new_content(≤10),故 ≤ 12
assert!(char_count <= 12, "chunk char count {} exceeds 10+overlap", char_count);
}
}
// ===== Group B4 — Splitter 集成测试 =====
#[test]
fn split_multiple_docs() {
let splitter = RecursiveCharacterSplitter::new(50, 5);
let docs = vec![
Document::from_raw("a", "a".repeat(30).as_str()),
Document::from_raw("b", "b".repeat(30).as_str()),
Document::from_raw("c", "c".repeat(30).as_str()),
];
let chunks = splitter.split(&docs);
assert!(chunks.len() >= 3);
// 每个 chunk 的 source_id 应指向对应的输入 doc
for chunk in &chunks {
let source = chunk.metadata.get("source_id").unwrap();
assert!(["a", "b", "c"].contains(&source.as_str()));
}
}
#[test]
fn split_metadata_inheritance() {
let splitter = RecursiveCharacterSplitter::new(100, 10);
let mut doc = Document::new("m", "short content", "text/plain");
doc.metadata.insert("author".to_string(), "alice".to_string());
let chunks = splitter.split(&[doc]);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].metadata.get("author").map(|s| s.as_str()), Some("alice"));
assert_eq!(chunks[0].metadata.get("source_id").map(|s| s.as_str()), Some("m"));
assert_eq!(chunks[0].metadata.get("chunk_index").map(|s| s.as_str()), Some("0"));
assert_eq!(chunks[0].metadata.get("chunk_count").map(|s| s.as_str()), Some("1"));
}
}
+3
View File
@@ -1,11 +1,14 @@
//! agcore —— 智能体(Agent)核心工具箱。
pub mod agent;
pub mod document;
pub mod llm;
pub mod memory;
pub mod prompt;
pub mod tools;
pub use document::Document;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static INIT: std::sync::Once = std::sync::Once::new();
+1
View File
@@ -3,6 +3,7 @@
pub mod compact;
pub mod convert;
pub mod cycle;
pub mod embedding;
pub mod error;
pub mod hooks;
pub mod mock;
+183
View File
@@ -0,0 +1,183 @@
//! Embedding 抽象 —— 文本向量化接口。
//!
//! 提供 [`Embedding`] trait 和零依赖的 [`MockEmbedding`] 引用实现。
//! 上层可实现此 trait 以对接真实 Embedding ProviderOpenAI、Cohere 等)。
//!
//! 所有实现使用 [`LlmError`] 作为统一错误类型,与 llm 模块保持一致。
use async_trait::async_trait;
use crate::llm::error::LlmError;
/// 文本向量化抽象接口。
///
/// 将文本字符串转换为固定维度的浮点向量,用于语义相似度计算。
/// 设计为异步以支持网络 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;
}
/// 确定性 Mock Embedding —— 零依赖伪随机单位向量。
///
/// 使用 sin 哈希将输入字符串映射到单位球面上的一个点:
/// 1. 对输入字符串计算简单哈希(字符字节和 + 长度)作为种子
/// 2. 用 `f32::sin(seed + i) * 10000` 生成第 i 个维度的值
/// 3. 归一化到单位长度(L2 norm = 1.0
///
/// 特性:
/// - **确定性**:相同输入 → 相同向量
/// - **有区分度**:不同输入产生不同向量(高概率)
/// - **单位范数**:余弦相似度等价于点积
/// - **开销极低**:不分配额外内存,无 IO
///
/// # 已知限制
///
/// `f32::sin(seed + i) * 10000` 在维度较高时(如 1536OpenAI Embedding 维度)
/// 可能出现周期性模式——相邻维度取值在 `sin` 周期 2π 约束下呈规律性重复。
/// MockEmbedding 仅用于测试验证,**不应用于生产级相似度排序**;
/// 做严肃验证时建议使用真实 Embedding Provider 或显式随机初始化。
pub struct MockEmbedding {
dim: usize,
}
impl MockEmbedding {
/// 创建 Mock Embedding,输出向量维度为 `dim`。
pub fn new(dim: usize) -> Self {
Self { dim }
}
}
#[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: f64 = 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();
l2_normalize(&mut vec);
vec
})
.collect();
Ok(results)
}
fn dim(&self) -> usize {
self.dim
}
}
/// L2 归一化(in-place)。
///
/// 零向量(norm == 0)保持全零 —— 防除零保护。
fn l2_normalize(vec: &mut [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;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 计算向量的 L2 范数。
fn l2_norm(v: &[f32]) -> f32 {
v.iter().map(|x| x * x).sum::<f32>().sqrt()
}
#[tokio::test]
async fn embed_correct_dim() {
let embedder = MockEmbedding::new(8);
let inputs = vec!["hello".to_string(), "world".to_string()];
let result = embedder.embed(&inputs).await.unwrap();
assert_eq!(result.len(), 2);
for vec in &result {
assert_eq!(vec.len(), 8);
}
}
#[tokio::test]
async fn embed_batch_size_match() {
let embedder = MockEmbedding::new(4);
let inputs = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
"e".to_string(),
];
let result = embedder.embed(&inputs).await.unwrap();
assert_eq!(result.len(), inputs.len());
}
#[tokio::test]
async fn embed_deterministic() {
let embedder = MockEmbedding::new(4);
let inputs = vec!["deterministic test".to_string()];
let r1 = embedder.embed(&inputs).await.unwrap();
let r2 = embedder.embed(&inputs).await.unwrap();
assert_eq!(r1, r2);
}
#[tokio::test]
async fn embed_unit_vector_norm() {
let embedder = MockEmbedding::new(16);
let inputs = vec!["any text".to_string(), "another".to_string()];
let result = embedder.embed(&inputs).await.unwrap();
for vec in &result {
let norm = l2_norm(vec);
assert!((norm - 1.0).abs() < 1e-5, "vector norm should be ~1.0, got {}", norm);
}
}
#[tokio::test]
async fn embed_different_inputs_different_vectors() {
let embedder = MockEmbedding::new(16);
let r1 = embedder
.embed(&["hello world".to_string()])
.await
.unwrap();
let r2 = embedder
.embed(&["completely different".to_string()])
.await
.unwrap();
assert_ne!(r1, r2);
}
#[tokio::test]
async fn embed_empty_string() {
// 空字符串输入应不 panic,且向量范数仍≈1.0(防除零路径)
let embedder = MockEmbedding::new(4);
let inputs = vec!["".to_string()];
let result = embedder.embed(&inputs).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].len(), 4);
let norm = l2_norm(&result[0]);
assert!((norm - 1.0).abs() < 1e-5, "empty-string vector norm should be ~1.0, got {}", norm);
}
}