Skip to main content

lc_rag/
semantic_splitter.rs

1// src/retrieval/semantic_splitter.rs
2//! Semantic chunking
3//!
4//! Splits text by semantic relevance: sentences are embedded first, and a chunk boundary is
5//! cut where the cosine similarity between adjacent sentences drops sharply. Compared to
6//! character-level splitting this preserves semantic integrity better and improves retrieval.
7//!
8//! Note: embedding is an async operation while the `TextSplitter` trait has a sync signature.
9//! To keep the existing sync trait intact, this chunker exposes standalone async interfaces
10//! `split_text` / `split_document` instead of implementing the sync `TextSplitter`.
11
12use lc_embeddings::{cosine_similarity, EmbeddingError, Embeddings};
13use lc_vector_stores::Document;
14
15/// Semantic chunker
16///
17/// Cuts a chunk where the similarity between adjacent sentences drops below
18/// `breakpoint_threshold`; forces a break when the accumulated length exceeds
19/// `max_chunk_size`.
20pub struct SemanticSplitter<E> {
21    embeddings: E,
22    /// Break a chunk when adjacent-sentence similarity drops below this threshold
23    breakpoint_threshold: f32,
24    /// Maximum characters per chunk; exceeding it forces a break
25    max_chunk_size: usize,
26}
27
28impl<E: Embeddings> SemanticSplitter<E> {
29    /// Creates a semantic chunker
30    ///
31    /// # Arguments
32    /// * `embeddings` - the embedding model
33    /// * `breakpoint_threshold` - the breakpoint threshold for adjacent-sentence similarity
34    ///   (0.0–1.0; lower is harder to break)
35    /// * `max_chunk_size` - the maximum characters per chunk
36    pub fn new(embeddings: E, breakpoint_threshold: f32, max_chunk_size: usize) -> Self {
37        Self {
38            embeddings,
39            breakpoint_threshold,
40            max_chunk_size,
41        }
42    }
43
44    /// Creates with default parameters (threshold=0.5, max=1000)
45    pub fn with_defaults(embeddings: E) -> Self {
46        Self::new(embeddings, 0.5, 1000)
47    }
48
49    /// Splits into sentences: supports Chinese and English sentence-final punctuation
50    fn split_sentences(text: &str) -> Vec<String> {
51        let mut sentences = Vec::new();
52        let mut current = String::new();
53        for ch in text.chars() {
54            current.push(ch);
55            if matches!(ch, '。' | '!' | '?' | ';' | '\n' | '.' | '!' | '?') {
56                let trimmed = current.trim().to_string();
57                if !trimmed.is_empty() {
58                    sentences.push(trimmed);
59                }
60                current.clear();
61            }
62        }
63        let trimmed = current.trim().to_string();
64        if !trimmed.is_empty() {
65            sentences.push(trimmed);
66        }
67        sentences
68    }
69
70    /// Chunks text asynchronously
71    pub async fn split_text(&self, text: &str) -> Result<Vec<String>, EmbeddingError> {
72        let sentences = Self::split_sentences(text);
73        if sentences.is_empty() {
74            return Ok(Vec::new());
75        }
76        if sentences.len() == 1 {
77            return Ok(vec![sentences.into_iter().next().unwrap_or_default()]);
78        }
79
80        // Embed in batch
81        let refs: Vec<&str> = sentences.iter().map(|s| s.as_str()).collect();
82        let embeddings = self.embeddings.embed_documents(&refs).await?;
83
84        let mut chunks = Vec::new();
85        let mut current = sentences[0].clone();
86
87        for i in 1..sentences.len() {
88            let sim = cosine_similarity(&embeddings[i - 1], &embeddings[i]).unwrap_or(0.0);
89            let would_exceed = current.len() + sentences[i].len() + 1 > self.max_chunk_size;
90
91            if sim < self.breakpoint_threshold || would_exceed {
92                chunks.push(std::mem::take(&mut current));
93                current = sentences[i].clone();
94            } else {
95                current.push('\n');
96                current.push_str(&sentences[i]);
97            }
98        }
99        if !current.is_empty() {
100            chunks.push(current);
101        }
102        Ok(chunks)
103    }
104
105    /// Chunks a document asynchronously, preserving metadata (writes the chunk index)
106    pub async fn split_document(
107        &self,
108        document: &Document,
109    ) -> Result<Vec<Document>, EmbeddingError> {
110        let chunks = self.split_text(&document.content).await?;
111        Ok(chunks
112            .into_iter()
113            .enumerate()
114            .map(|(i, chunk)| {
115                let mut metadata = document.metadata.clone();
116                metadata.insert("chunk".to_string(), i.to_string().into());
117                Document {
118                    content: chunk,
119                    metadata,
120                    id: None,
121                }
122            })
123            .collect())
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use async_trait::async_trait;
131    use lc_embeddings::{EmbeddingError, Embeddings, MockEmbeddings};
132
133    /// An embedding that always fails, used to test the error path
134    struct FailingEmbeddings;
135    #[async_trait]
136    impl Embeddings for FailingEmbeddings {
137        async fn embed_query(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
138            Err(EmbeddingError::ApiError("intentional failure".to_string()))
139        }
140        fn dimension(&self) -> usize {
141            32
142        }
143        fn model_name(&self) -> &str {
144            "failing"
145        }
146    }
147
148    fn splitter(threshold: f32, max_chunk: usize) -> SemanticSplitter<MockEmbeddings> {
149        SemanticSplitter::new(MockEmbeddings::new(32), threshold, max_chunk)
150    }
151
152    #[tokio::test]
153    async fn test_empty_text() {
154        let s = splitter(0.5, 1000);
155        assert!(s.split_text("").await.unwrap().is_empty());
156    }
157
158    #[tokio::test]
159    async fn test_single_sentence() {
160        let s = splitter(0.5, 1000);
161        let chunks = s.split_text("只有一句没有标点").await.unwrap();
162        assert_eq!(chunks, vec!["只有一句没有标点".to_string()]);
163    }
164
165    #[tokio::test]
166    async fn test_chunks_contain_all_sentences() {
167        let s = splitter(0.5, 1000);
168        let text = "苹果是一种水果。香蕉是黄色的。樱桃很小。";
169        let chunks = s.split_text(text).await.unwrap();
170        assert!(!chunks.is_empty());
171        // However it splits, every sentence should appear in some chunk
172        let joined = chunks.join("");
173        assert!(joined.contains("苹果是一种水果"));
174        assert!(joined.contains("香蕉是黄色的"));
175        assert!(joined.contains("樱桃很小"));
176    }
177
178    #[tokio::test]
179    async fn test_max_chunk_size_enforces_break() {
180        // max_chunk is tiny, so multiple sentences are necessarily forced into several chunks
181        let s = splitter(0.0, 5);
182        let text = "AAAA。BBBB。CCCC。";
183        let chunks = s.split_text(text).await.unwrap();
184        assert!(
185            chunks.len() >= 2,
186            "max_chunk=5 应强制断块, 实际 {} 块",
187            chunks.len()
188        );
189    }
190
191    #[tokio::test]
192    async fn test_split_document_metadata() {
193        let s = splitter(0.0, 5);
194        let doc = Document::new("AAAA。BBBB。CCCC。").with_metadata("source", "test");
195        let chunks = s.split_document(&doc).await.unwrap();
196        assert!(chunks.len() >= 2);
197        for (i, c) in chunks.iter().enumerate() {
198            assert_eq!(
199                c.metadata.get("chunk"),
200                Some(&serde_json::Value::String(i.to_string()))
201            );
202            assert_eq!(
203                c.metadata.get("source"),
204                Some(&serde_json::Value::String("test".to_string()))
205            );
206            assert!(c.id.is_none());
207        }
208    }
209
210    #[tokio::test]
211    async fn test_embedding_failure_returns_error() {
212        // M54: embedding failure now returns an error instead of fallback
213        let s = SemanticSplitter::new(FailingEmbeddings, 0.5, 1000);
214        let text = "句子一。句子二。句子三。";
215        let result = s.split_text(text).await;
216        assert!(result.is_err());
217    }
218}