use super::character::merge_splits;
pub struct RecursiveCharacterTextSplitter {
chunk_size: usize,
chunk_overlap: usize,
separators: Vec<String>,
}
impl RecursiveCharacterTextSplitter {
pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
Self {
chunk_size,
chunk_overlap,
separators: vec![
"\n\n".to_owned(),
"\n".to_owned(),
" ".to_owned(),
String::new(),
],
}
}
pub const fn with_separators(
chunk_size: usize,
chunk_overlap: usize,
separators: Vec<String>,
) -> Self {
Self {
chunk_size,
chunk_overlap,
separators,
}
}
pub fn split_text(&self, text: &str) -> Vec<String> {
self.split_recursive(text, &self.separators)
}
fn split_recursive(&self, text: &str, separators: &[String]) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
if text.len() <= self.chunk_size {
return vec![text.to_owned()];
}
let (separator, remaining_seps) = Self::find_separator(text, separators);
let sep_str: &str = separator;
let pieces: Vec<&str> = if sep_str.is_empty() {
text.char_indices()
.map(|(i, c)| &text[i..i + c.len_utf8()])
.collect()
} else {
text.split(sep_str).collect()
};
let mut final_chunks = Vec::new();
let mut good_pieces: Vec<&str> = Vec::new();
for &piece in &pieces {
if piece.len() <= self.chunk_size {
good_pieces.push(piece);
} else {
if !good_pieces.is_empty() {
let merged =
merge_splits(&good_pieces, sep_str, self.chunk_size, self.chunk_overlap);
final_chunks.extend(merged);
good_pieces.clear();
}
let sub_chunks = self.split_recursive(piece, remaining_seps);
final_chunks.extend(sub_chunks);
}
}
if !good_pieces.is_empty() {
let merged = merge_splits(&good_pieces, sep_str, self.chunk_size, self.chunk_overlap);
final_chunks.extend(merged);
}
final_chunks
}
fn find_separator<'a>(text: &str, separators: &'a [String]) -> (&'a str, &'a [String]) {
for (i, sep) in separators.iter().enumerate() {
if sep.is_empty() || text.contains(sep.as_str()) {
let remaining = if i + 1 < separators.len() {
&separators[i + 1..]
} else {
&[]
};
return (sep, remaining);
}
}
separators.last().map_or(("", &[]), |last| (last, &[]))
}
}
impl Default for RecursiveCharacterTextSplitter {
fn default() -> Self {
Self::new(1000, 200)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn splits_on_paragraph_boundaries_first() {
let splitter = RecursiveCharacterTextSplitter::new(25, 0);
let text = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.";
let chunks = splitter.split_text(text);
assert!(
chunks.len() >= 2,
"expected >= 2 chunks, got {}: {chunks:?}",
chunks.len()
);
for chunk in &chunks {
assert!(
chunk.len() <= 25,
"chunk too long: '{chunk}' (len={})",
chunk.len()
);
}
}
#[test]
fn falls_back_to_line_separator() {
let splitter = RecursiveCharacterTextSplitter::new(30, 0);
let text = "Line one\nLine two\nLine three\nLine four";
let chunks = splitter.split_text(text);
assert!(chunks.len() >= 2);
}
#[test]
fn falls_back_to_space_separator() {
let splitter = RecursiveCharacterTextSplitter::new(10, 0);
let text = "one two three four five";
let chunks = splitter.split_text(text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
assert!(chunk.len() <= 10, "chunk '{chunk}' exceeds limit");
}
}
#[test]
fn empty_text_returns_empty() {
let splitter = RecursiveCharacterTextSplitter::new(10, 0);
let chunks = splitter.split_text("");
assert!(chunks.is_empty());
}
#[test]
fn text_under_limit_returns_single_chunk() {
let splitter = RecursiveCharacterTextSplitter::new(100, 0);
let chunks = splitter.split_text("Short text.");
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0], "Short text.");
}
#[test]
fn custom_separators() {
let splitter = RecursiveCharacterTextSplitter::with_separators(
20,
0,
vec!["---".to_owned(), " ".to_owned()],
);
let text = "Part A---Part B---Part C";
let chunks = splitter.split_text(text);
assert!(chunks.len() >= 2);
}
#[test]
fn respects_chunk_overlap() {
let splitter = RecursiveCharacterTextSplitter::new(20, 5);
let text = "aaaa\n\nbbbb\n\ncccc\n\ndddd";
let chunks = splitter.split_text(text);
assert!(chunks.len() >= 2);
}
}