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