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    ///
57    /// `chunk_size` is clamped to a minimum of 1: a value of `0` — whether
58    /// passed directly or via a user-supplied separator list that shrank the
59    /// effective window to nothing — used to make a single character always
60    /// exceed the quota and recurse forever, blowing the stack.
61    pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
62        Self {
63            chunk_size: chunk_size.max(1),
64            chunk_overlap,
65            separators: vec![
66                "\n\n".to_string(), // paragraph
67                "\n".to_string(),   // line
68                "。".to_string(),   // Chinese period
69                ".".to_string(),    // English period
70                " ".to_string(),    // space
71                "".to_string(),     // character
72            ],
73        }
74    }
75
76    /// Create with default parameters (chunk_size=1000, chunk_overlap=200)
77    pub fn with_defaults() -> Self {
78        Self::new(1000, 200)
79    }
80
81    /// Set custom separators
82    pub fn with_separators(mut self, separators: Vec<String>) -> Self {
83        self.separators = separators;
84        self
85    }
86
87    /// Split text (internal recursive method)
88    fn split_text_recursive(&self, text: &str, separators: &[String]) -> Vec<String> {
89        let mut chunks = Vec::new();
90
91        if text.is_empty() {
92            return chunks;
93        }
94
95        // If text is already small enough, return as-is
96        if text.chars().count() <= self.chunk_size {
97            chunks.push(text.to_string());
98            return chunks;
99        }
100
101        // Find a suitable separator
102        let separator = separators
103            .iter()
104            .find(|s| text.contains(s.as_str()))
105            .cloned()
106            .unwrap_or_default();
107
108        // Split by separator
109        let splits: Vec<String> = if separator.is_empty() {
110            text.chars().map(|c| c.to_string()).collect()
111        } else {
112            text.split(&separator).map(|s| s.to_string()).collect()
113        };
114
115        // Merge splits into chunks
116        let mut current_chunk = String::new();
117
118        for split in splits {
119            let split_with_sep = if separator.is_empty() {
120                split.clone()
121            } else if current_chunk.is_empty() {
122                split
123            } else {
124                format!("{}{}", separator, split)
125            };
126
127            // If a single split exceeds chunk size, recurse
128            if split_with_sep.chars().count() > self.chunk_size {
129                // Save current chunk first
130                if !current_chunk.is_empty() {
131                    chunks.push(current_chunk.clone());
132                    current_chunk.clear();
133                }
134
135                // Recurse with next separator
136                let next_separators = if separators.len() > 1 {
137                    &separators[1..]
138                } else {
139                    &[]
140                };
141
142                // Hard stop when separators are exhausted: splitting by
143                // character and pushing each piece guarantees termination
144                // even if a piece still exceeds `chunk_size`. Without this
145                // guard, an empty separator slice (a degenerate config) was
146                // reached by a too-large split and recursed forever.
147                if next_separators.is_empty() {
148                    for c in split_with_sep.chars() {
149                        chunks.push(c.to_string());
150                    }
151                } else {
152                    let sub_chunks = self.split_text_recursive(&split_with_sep, next_separators);
153                    chunks.extend(sub_chunks);
154                }
155            } else if current_chunk.chars().count() + split_with_sep.chars().count()
156                > self.chunk_size
157            {
158                // Current chunk is full, save and start new
159                chunks.push(current_chunk.clone());
160                current_chunk = split_with_sep;
161            } else {
162                current_chunk.push_str(&split_with_sep);
163            }
164        }
165
166        if !current_chunk.is_empty() {
167            chunks.push(current_chunk);
168        }
169
170        chunks
171    }
172}
173
174impl TextSplitter for RecursiveCharacterSplitter {
175    fn split_text(&self, text: &str) -> Vec<String> {
176        let mut chunks = self.split_text_recursive(text, &self.separators);
177
178        // Handle overlap
179        if self.chunk_overlap > 0 && chunks.len() > 1 {
180            let mut overlapped = Vec::new();
181
182            for (i, chunk) in chunks.into_iter().enumerate() {
183                if i == 0 {
184                    overlapped.push(chunk);
185                } else {
186                    // Take overlap from end of previous chunk (using chars, not bytes)
187                    let prev = &overlapped[i - 1];
188                    let chars: Vec<char> = prev.chars().collect();
189                    let overlap_chars = chars.len().saturating_sub(self.chunk_overlap);
190                    let mut overlap: String = chars[overlap_chars..].iter().collect();
191
192                    // `chunk_size` is a hard cap: the prepended overlap counts
193                    // toward the quota, so trim the overlap from the back if
194                    // pushing it in would exceed `chunk_size`. This keeps
195                    // `chunk_size` a true upper bound on every emitted chunk.
196                    let budget = self.chunk_size.saturating_sub(chunk.chars().count());
197                    if overlap.chars().count() > budget {
198                        let ov_chars: Vec<char> = overlap.chars().collect();
199                        overlap = ov_chars[ov_chars.len().saturating_sub(budget)..]
200                            .iter()
201                            .collect();
202                    }
203
204                    overlapped.push(format!("{}{}", overlap, chunk));
205                }
206            }
207
208            chunks = overlapped;
209        }
210
211        chunks
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_recursive_splitter() {
221        let splitter = RecursiveCharacterSplitter::new(50, 10);
222
223        let text = "This is a sentence. This is another sentence. And a third one.";
224        let chunks = splitter.split_text(text);
225
226        assert!(!chunks.is_empty());
227        // chunk_size is a hard cap — even with overlap prepended
228        for chunk in &chunks {
229            assert!(chunk.chars().count() <= 50);
230        }
231    }
232
233    #[test]
234    fn test_split_document() {
235        let splitter = RecursiveCharacterSplitter::new(100, 20);
236
237        let doc = Document::new("First paragraph.\n\nSecond paragraph.\n\nThird paragraph.")
238            .with_metadata("source", "test");
239
240        let chunks = splitter.split_document(&doc);
241
242        assert!(!chunks.is_empty());
243        for (i, chunk) in chunks.iter().enumerate() {
244            assert!(chunk.metadata.contains_key("chunk"));
245            assert_eq!(
246                chunk.metadata.get("chunk"),
247                Some(&Value::String(i.to_string()))
248            );
249            assert_eq!(
250                chunk.metadata.get("source"),
251                Some(&Value::String("test".to_string()))
252            );
253        }
254    }
255
256    #[test]
257    fn test_split_document_preserves_user_chunk_key() {
258        let splitter = RecursiveCharacterSplitter::new(20, 5);
259
260        // User already numbered the chunks — split_document must not overwrite it
261        let doc = Document::new("This is a longer paragraph that gets split into several chunks.")
262            .with_metadata("chunk", "user-supplied");
263
264        let chunks = splitter.split_document(&doc);
265
266        assert!(!chunks.is_empty());
267        for chunk in &chunks {
268            assert_eq!(
269                chunk.metadata.get("chunk"),
270                Some(&Value::String("user-supplied".to_string()))
271            );
272        }
273    }
274
275    #[test]
276    fn test_empty_text() {
277        let splitter = RecursiveCharacterSplitter::new(100, 20);
278        let chunks = splitter.split_text("");
279        assert!(chunks.is_empty());
280    }
281
282    #[test]
283    fn test_small_text() {
284        let splitter = RecursiveCharacterSplitter::new(1000, 200);
285        let text = "Short text";
286        let chunks = splitter.split_text(text);
287        assert_eq!(chunks.len(), 1);
288        assert_eq!(chunks[0], "Short text");
289    }
290
291    #[test]
292    fn test_chunk_size_zero_does_not_stack_overflow() {
293        // Regression for the infinite-recursion stack overflow: chunk_size = 0
294        // used to make every single character exceed the quota forever.
295        let splitter = RecursiveCharacterSplitter::new(0, 0);
296        let chunks = splitter.split_text("This text is long enough to need splitting");
297
298        assert!(!chunks.is_empty());
299        // chunk_size is clamped to >= 1, so every emitted chunk is <= 1 char.
300        for chunk in &chunks {
301            assert!(chunk.chars().count() <= 1);
302        }
303    }
304}