Skip to main content

lc_rag/
semantic_splitter.rs

1// src/retrieval/semantic_splitter.rs
2//! 语义分块器
3//!
4//! 按语义相关性切分文本:先分句并嵌入,在相邻句向量相似度骤降处断块,
5//! 相比字符级分割能更好保留语义完整性,提升检索质量。
6//!
7//! 注:嵌入是异步操作,而 `TextSplitter` trait 是同步签名。为不破坏现有同步 trait,
8//! 本分块器提供独立的异步接口 `split_text` / `split_document`,不实现同步 `TextSplitter`。
9
10use lc_embeddings::{cosine_similarity, EmbeddingError, Embeddings};
11use lc_vector_stores::Document;
12
13/// 语义分块器
14///
15/// 在相邻句相似度低于 `breakpoint_threshold` 处断块;
16/// 累积长度超过 `max_chunk_size` 时强制断。
17pub struct SemanticSplitter<E> {
18    embeddings: E,
19    /// 相邻句相似度低于此阈值则断块
20    breakpoint_threshold: f32,
21    /// 单块最大字符数,超出强制断
22    max_chunk_size: usize,
23}
24
25impl<E: Embeddings> SemanticSplitter<E> {
26    /// 创建语义分块器
27    ///
28    /// # 参数
29    /// * `embeddings` - 嵌入模型
30    /// * `breakpoint_threshold` - 相邻句相似度断点阈值(0.0–1.0,越低越不易断)
31    /// * `max_chunk_size` - 单块最大字符数
32    pub fn new(embeddings: E, breakpoint_threshold: f32, max_chunk_size: usize) -> Self {
33        Self {
34            embeddings,
35            breakpoint_threshold,
36            max_chunk_size,
37        }
38    }
39
40    /// 使用默认参数创建(threshold=0.5, max=1000)
41    pub fn with_defaults(embeddings: E) -> Self {
42        Self::new(embeddings, 0.5, 1000)
43    }
44
45    /// 分句:支持中文(`。!?;`)与英文(`.!?\n`)
46    fn split_sentences(text: &str) -> Vec<String> {
47        let mut sentences = Vec::new();
48        let mut current = String::new();
49        for ch in text.chars() {
50            current.push(ch);
51            if matches!(ch, '。' | '!' | '?' | ';' | '\n' | '.' | '!' | '?') {
52                let trimmed = current.trim().to_string();
53                if !trimmed.is_empty() {
54                    sentences.push(trimmed);
55                }
56                current.clear();
57            }
58        }
59        let trimmed = current.trim().to_string();
60        if !trimmed.is_empty() {
61            sentences.push(trimmed);
62        }
63        sentences
64    }
65
66    /// 异步分块
67    pub async fn split_text(&self, text: &str) -> Result<Vec<String>, EmbeddingError> {
68        let sentences = Self::split_sentences(text);
69        if sentences.is_empty() {
70            return Ok(Vec::new());
71        }
72        if sentences.len() == 1 {
73            return Ok(vec![sentences.into_iter().next().unwrap_or_default()]);
74        }
75
76        // 批量嵌入
77        let refs: Vec<&str> = sentences.iter().map(|s| s.as_str()).collect();
78        let embeddings = self.embeddings.embed_documents(&refs).await?;
79
80        let mut chunks = Vec::new();
81        let mut current = sentences[0].clone();
82
83        for i in 1..sentences.len() {
84            let sim = cosine_similarity(&embeddings[i - 1], &embeddings[i]).unwrap_or(0.0);
85            let would_exceed = current.len() + sentences[i].len() + 1 > self.max_chunk_size;
86
87            if sim < self.breakpoint_threshold || would_exceed {
88                chunks.push(std::mem::take(&mut current));
89                current = sentences[i].clone();
90            } else {
91                current.push('\n');
92                current.push_str(&sentences[i]);
93            }
94        }
95        if !current.is_empty() {
96            chunks.push(current);
97        }
98        Ok(chunks)
99    }
100
101    /// 异步分块文档,保留 metadata(写入 chunk 序号)
102    pub async fn split_document(
103        &self,
104        document: &Document,
105    ) -> Result<Vec<Document>, EmbeddingError> {
106        let chunks = self.split_text(&document.content).await?;
107        Ok(chunks
108            .into_iter()
109            .enumerate()
110            .map(|(i, chunk)| {
111                let mut metadata = document.metadata.clone();
112                metadata.insert("chunk".to_string(), i.to_string().into());
113                Document {
114                    content: chunk,
115                    metadata,
116                    id: None,
117                }
118            })
119            .collect())
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use async_trait::async_trait;
127    use lc_embeddings::{EmbeddingError, Embeddings, MockEmbeddings};
128
129    /// 总是失败的嵌入,用于测试回退路径
130    struct FailingEmbeddings;
131    #[async_trait]
132    impl Embeddings for FailingEmbeddings {
133        async fn embed_query(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
134            Err(EmbeddingError::ApiError("intentional failure".to_string()))
135        }
136        fn dimension(&self) -> usize {
137            32
138        }
139        fn model_name(&self) -> &str {
140            "failing"
141        }
142    }
143
144    fn splitter(threshold: f32, max_chunk: usize) -> SemanticSplitter<MockEmbeddings> {
145        SemanticSplitter::new(MockEmbeddings::new(32), threshold, max_chunk)
146    }
147
148    #[tokio::test]
149    async fn test_empty_text() {
150        let s = splitter(0.5, 1000);
151        assert!(s.split_text("").await.unwrap().is_empty());
152    }
153
154    #[tokio::test]
155    async fn test_single_sentence() {
156        let s = splitter(0.5, 1000);
157        let chunks = s.split_text("只有一句没有标点").await.unwrap();
158        assert_eq!(chunks, vec!["只有一句没有标点".to_string()]);
159    }
160
161    #[tokio::test]
162    async fn test_chunks_contain_all_sentences() {
163        let s = splitter(0.5, 1000);
164        let text = "苹果是一种水果。香蕉是黄色的。樱桃很小。";
165        let chunks = s.split_text(text).await.unwrap();
166        assert!(!chunks.is_empty());
167        // 无论怎么断,每句都应出现在某个 chunk 中
168        let joined = chunks.join("");
169        assert!(joined.contains("苹果是一种水果"));
170        assert!(joined.contains("香蕉是黄色的"));
171        assert!(joined.contains("樱桃很小"));
172    }
173
174    #[tokio::test]
175    async fn test_max_chunk_size_enforces_break() {
176        // max_chunk 设很小,多句必然被强制断成多块
177        let s = splitter(0.0, 5);
178        let text = "AAAA。BBBB。CCCC。";
179        let chunks = s.split_text(text).await.unwrap();
180        assert!(
181            chunks.len() >= 2,
182            "max_chunk=5 应强制断块, 实际 {} 块",
183            chunks.len()
184        );
185    }
186
187    #[tokio::test]
188    async fn test_split_document_metadata() {
189        let s = splitter(0.0, 5);
190        let doc = Document::new("AAAA。BBBB。CCCC。").with_metadata("source", "test");
191        let chunks = s.split_document(&doc).await.unwrap();
192        assert!(chunks.len() >= 2);
193        for (i, c) in chunks.iter().enumerate() {
194            assert_eq!(
195                c.metadata.get("chunk"),
196                Some(&serde_json::Value::String(i.to_string()))
197            );
198            assert_eq!(
199                c.metadata.get("source"),
200                Some(&serde_json::Value::String("test".to_string()))
201            );
202            assert!(c.id.is_none());
203        }
204    }
205
206    #[tokio::test]
207    async fn test_embedding_failure_returns_error() {
208        // M54: embedding failure now returns an error instead of fallback
209        let s = SemanticSplitter::new(FailingEmbeddings, 0.5, 1000);
210        let text = "句子一。句子二。句子三。";
211        let result = s.split_text(text).await;
212        assert!(result.is_err());
213    }
214}