Skip to main content

lc_shared/
splitter_types.rs

1// lc-shared/src/splitter_types.rs
2//! Text splitter types shared across crates.
3//!
4//! `TextSplitter` trait and `RecursiveCharacterSplitter` are needed by
5//! both `lc-vector-stores` and `lc-rag`, so they live here to break
6//! the circular dependency.
7
8use crate::document_types::Document;
9use serde_json::Value;
10
11/// Text splitter trait
12pub trait TextSplitter: Send + Sync {
13    /// Split text into chunks
14    fn split_text(&self, text: &str) -> Vec<String>;
15
16    /// Split a document into smaller documents
17    fn split_document(&self, document: &Document) -> Vec<Document> {
18        let chunks = self.split_text(&document.content);
19        chunks
20            .into_iter()
21            .enumerate()
22            .map(|(i, chunk)| {
23                let mut metadata = document.metadata.clone();
24                // Only insert the chunk index when the user hasn't already
25                // provided a "chunk" key — never silently overwrite it.
26                metadata
27                    .entry("chunk".to_string())
28                    .or_insert(Value::String(i.to_string()));
29
30                Document {
31                    content: chunk,
32                    metadata,
33                    id: None,
34                }
35            })
36            .collect()
37    }
38}
39
40/// Recursive character splitter
41///
42/// Splits text by separator priority, recursively trying smaller separators.
43pub struct RecursiveCharacterSplitter {
44    /// Chunk size (character count)
45    chunk_size: usize,
46
47    /// Chunk overlap (character count)
48    chunk_overlap: usize,
49
50    /// Separator list (by priority)
51    separators: Vec<String>,
52}
53
54impl RecursiveCharacterSplitter {
55    /// Create a new recursive character splitter
56    pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
57        Self {
58            chunk_size,
59            chunk_overlap,
60            separators: vec![
61                "\n\n".to_string(), // paragraph
62                "\n".to_string(),   // line
63                "。".to_string(),   // Chinese period
64                ".".to_string(),    // English period
65                " ".to_string(),    // space
66                "".to_string(),     // character
67            ],
68        }
69    }
70
71    /// Create with default parameters (chunk_size=1000, chunk_overlap=200)
72    pub fn with_defaults() -> Self {
73        Self::new(1000, 200)
74    }
75
76    /// Set custom separators
77    pub fn with_separators(mut self, separators: Vec<String>) -> Self {
78        self.separators = separators;
79        self
80    }
81
82    /// Split text (internal recursive method)
83    fn split_text_recursive(&self, text: &str, separators: &[String]) -> Vec<String> {
84        let mut chunks = Vec::new();
85
86        if text.is_empty() {
87            return chunks;
88        }
89
90        // If text is already small enough, return as-is
91        if text.chars().count() <= self.chunk_size {
92            chunks.push(text.to_string());
93            return chunks;
94        }
95
96        // Find a suitable separator
97        let separator = separators
98            .iter()
99            .find(|s| text.contains(s.as_str()))
100            .cloned()
101            .unwrap_or_default();
102
103        // Split by separator
104        let splits: Vec<String> = if separator.is_empty() {
105            text.chars().map(|c| c.to_string()).collect()
106        } else {
107            text.split(&separator).map(|s| s.to_string()).collect()
108        };
109
110        // Merge splits into chunks
111        let mut current_chunk = String::new();
112
113        for split in splits {
114            let split_with_sep = if separator.is_empty() {
115                split.clone()
116            } else if current_chunk.is_empty() {
117                split
118            } else {
119                format!("{}{}", separator, split)
120            };
121
122            // If a single split exceeds chunk size, recurse
123            if split_with_sep.chars().count() > self.chunk_size {
124                // Save current chunk first
125                if !current_chunk.is_empty() {
126                    chunks.push(current_chunk.clone());
127                    current_chunk.clear();
128                }
129
130                // Recurse with next separator
131                let next_separators = if separators.len() > 1 {
132                    &separators[1..]
133                } else {
134                    &[]
135                };
136
137                let sub_chunks = self.split_text_recursive(&split_with_sep, next_separators);
138                chunks.extend(sub_chunks);
139            } else if current_chunk.chars().count() + split_with_sep.chars().count()
140                > self.chunk_size
141            {
142                // Current chunk is full, save and start new
143                chunks.push(current_chunk.clone());
144                current_chunk = split_with_sep;
145            } else {
146                current_chunk.push_str(&split_with_sep);
147            }
148        }
149
150        if !current_chunk.is_empty() {
151            chunks.push(current_chunk);
152        }
153
154        chunks
155    }
156}
157
158impl TextSplitter for RecursiveCharacterSplitter {
159    fn split_text(&self, text: &str) -> Vec<String> {
160        let mut chunks = self.split_text_recursive(text, &self.separators);
161
162        // Handle overlap
163        if self.chunk_overlap > 0 && chunks.len() > 1 {
164            let mut overlapped = Vec::new();
165
166            for (i, chunk) in chunks.into_iter().enumerate() {
167                if i == 0 {
168                    overlapped.push(chunk);
169                } else {
170                    // Take overlap from end of previous chunk (using chars, not bytes)
171                    let prev = &overlapped[i - 1];
172                    let chars: Vec<char> = prev.chars().collect();
173                    let overlap_chars = chars.len().saturating_sub(self.chunk_overlap);
174                    let mut overlap: String = chars[overlap_chars..].iter().collect();
175
176                    // `chunk_size` is a hard cap: the prepended overlap counts
177                    // toward the quota, so trim the overlap from the back if
178                    // pushing it in would exceed `chunk_size`. This keeps
179                    // `chunk_size` a true upper bound on every emitted chunk.
180                    let budget = self.chunk_size.saturating_sub(chunk.chars().count());
181                    if overlap.chars().count() > budget {
182                        let ov_chars: Vec<char> = overlap.chars().collect();
183                        overlap = ov_chars[ov_chars.len().saturating_sub(budget)..]
184                            .iter()
185                            .collect();
186                    }
187
188                    overlapped.push(format!("{}{}", overlap, chunk));
189                }
190            }
191
192            chunks = overlapped;
193        }
194
195        chunks
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_recursive_splitter() {
205        let splitter = RecursiveCharacterSplitter::new(50, 10);
206
207        let text = "This is a sentence. This is another sentence. And a third one.";
208        let chunks = splitter.split_text(text);
209
210        assert!(!chunks.is_empty());
211        // chunk_size is a hard cap — even with overlap prepended
212        for chunk in &chunks {
213            assert!(chunk.chars().count() <= 50);
214        }
215    }
216
217    #[test]
218    fn test_split_document() {
219        let splitter = RecursiveCharacterSplitter::new(100, 20);
220
221        let doc = Document::new("First paragraph.\n\nSecond paragraph.\n\nThird paragraph.")
222            .with_metadata("source", "test");
223
224        let chunks = splitter.split_document(&doc);
225
226        assert!(!chunks.is_empty());
227        for (i, chunk) in chunks.iter().enumerate() {
228            assert!(chunk.metadata.contains_key("chunk"));
229            assert_eq!(
230                chunk.metadata.get("chunk"),
231                Some(&Value::String(i.to_string()))
232            );
233            assert_eq!(
234                chunk.metadata.get("source"),
235                Some(&Value::String("test".to_string()))
236            );
237        }
238    }
239
240    #[test]
241    fn test_split_document_preserves_user_chunk_key() {
242        let splitter = RecursiveCharacterSplitter::new(20, 5);
243
244        // User already numbered the chunks — split_document must not overwrite it
245        let doc = Document::new("This is a longer paragraph that gets split into several chunks.")
246            .with_metadata("chunk", "user-supplied");
247
248        let chunks = splitter.split_document(&doc);
249
250        assert!(!chunks.is_empty());
251        for chunk in &chunks {
252            assert_eq!(
253                chunk.metadata.get("chunk"),
254                Some(&Value::String("user-supplied".to_string()))
255            );
256        }
257    }
258
259    #[test]
260    fn test_empty_text() {
261        let splitter = RecursiveCharacterSplitter::new(100, 20);
262        let chunks = splitter.split_text("");
263        assert!(chunks.is_empty());
264    }
265
266    #[test]
267    fn test_small_text() {
268        let splitter = RecursiveCharacterSplitter::new(1000, 200);
269        let text = "Short text";
270        let chunks = splitter.split_text(text);
271        assert_eq!(chunks.len(), 1);
272        assert_eq!(chunks[0], "Short text");
273    }
274}