1use tracing::warn;
14
15use crate::document::{Chunk, Document};
16use crate::error::{RagError, Result};
17
18fn floor_char_boundary(s: &str, index: usize) -> usize {
21 if index >= s.len() {
22 return s.len();
23 }
24 let mut i = index;
25 while i > 0 && !s.is_char_boundary(i) {
26 i -= 1;
27 }
28 i
29}
30
31fn next_char_boundary(s: &str, index: usize) -> usize {
33 s[index..].chars().next().map_or(s.len(), |c| index + c.len_utf8())
34}
35
36fn validate_sizes(chunk_size: usize, chunk_overlap: usize) -> Result<()> {
38 if chunk_size == 0 {
39 return Err(RagError::ConfigError("chunk_size must be greater than zero".to_string()));
40 }
41 if chunk_overlap >= chunk_size {
42 return Err(RagError::ConfigError(format!(
43 "chunk_overlap ({chunk_overlap}) must be less than chunk_size ({chunk_size})"
44 )));
45 }
46 Ok(())
47}
48
49fn normalize_sizes(chunker: &str, chunk_size: usize, chunk_overlap: usize) -> (usize, usize) {
55 match validate_sizes(chunk_size, chunk_overlap) {
56 Ok(()) => (chunk_size, chunk_overlap),
57 Err(error) => {
58 let size = chunk_size.max(1);
59 let overlap = if chunk_overlap < size { chunk_overlap } else { 0 };
60 warn!(
61 chunker,
62 error = %error,
63 chunk_size = size,
64 chunk_overlap = overlap,
65 "invalid chunker sizes; use try_new to reject them"
66 );
67 (size, overlap)
68 }
69 }
70}
71
72pub trait Chunker: Send + Sync {
77 fn chunk(&self, document: &Document) -> Vec<Chunk>;
82}
83
84#[derive(Debug, Clone)]
102pub struct FixedSizeChunker {
103 chunk_size: usize,
104 chunk_overlap: usize,
105}
106
107impl FixedSizeChunker {
108 pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
119 let (chunk_size, chunk_overlap) =
120 normalize_sizes("FixedSizeChunker", chunk_size, chunk_overlap);
121 Self { chunk_size, chunk_overlap }
122 }
123
124 pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self> {
145 validate_sizes(chunk_size, chunk_overlap)?;
146 Ok(Self { chunk_size, chunk_overlap })
147 }
148}
149
150impl Chunker for FixedSizeChunker {
151 fn chunk(&self, document: &Document) -> Vec<Chunk> {
152 split_by_size(&document.text, self.chunk_size, self.chunk_overlap)
153 .into_iter()
154 .enumerate()
155 .map(|(chunk_index, text)| {
156 let mut metadata = document.metadata.clone();
157 metadata.insert("chunk_index".to_string(), chunk_index.to_string());
158 Chunk {
159 id: format!("{}_{chunk_index}", document.id),
160 text,
161 embedding: Vec::new(),
162 metadata,
163 document_id: document.id.clone(),
164 }
165 })
166 .collect()
167 }
168}
169
170#[derive(Debug, Clone)]
186pub struct RecursiveChunker {
187 chunk_size: usize,
188 chunk_overlap: usize,
189}
190
191impl RecursiveChunker {
192 pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
203 let (chunk_size, chunk_overlap) =
204 normalize_sizes("RecursiveChunker", chunk_size, chunk_overlap);
205 Self { chunk_size, chunk_overlap }
206 }
207
208 pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self> {
229 validate_sizes(chunk_size, chunk_overlap)?;
230 Ok(Self { chunk_size, chunk_overlap })
231 }
232}
233
234fn split_and_merge(
238 text: &str,
239 chunk_size: usize,
240 chunk_overlap: usize,
241 separators: &[&str],
242) -> Vec<String> {
243 if text.len() <= chunk_size || separators.is_empty() {
244 return split_by_size(text, chunk_size, chunk_overlap);
245 }
246
247 let separator = separators[0];
248 let remaining_separators = &separators[1..];
249
250 let segments: Vec<&str> = if separator == " " {
251 text.split(' ').collect()
252 } else {
253 split_keeping_separator(text, separator)
254 };
255
256 let mut chunks = Vec::new();
257 let mut current = String::new();
258
259 for segment in segments {
260 if current.is_empty() {
261 current = segment.to_string();
262 } else if current.len() + segment.len() <= chunk_size {
263 current.push_str(segment);
264 } else {
265 if current.len() > chunk_size {
267 chunks.extend(split_and_merge(
268 ¤t,
269 chunk_size,
270 chunk_overlap,
271 remaining_separators,
272 ));
273 } else {
274 chunks.push(current);
275 }
276 current = segment.to_string();
278 }
279 }
280
281 if !current.is_empty() {
282 if current.len() > chunk_size {
283 chunks.extend(split_and_merge(
284 ¤t,
285 chunk_size,
286 chunk_overlap,
287 remaining_separators,
288 ));
289 } else {
290 chunks.push(current);
291 }
292 }
293
294 chunks
295}
296
297fn split_keeping_separator<'a>(text: &'a str, separator: &str) -> Vec<&'a str> {
299 let mut result = Vec::new();
300 let mut start = 0;
301
302 while let Some(pos) = text[start..].find(separator) {
303 let end = start + pos + separator.len();
304 result.push(&text[start..end]);
305 start = end;
306 }
307
308 if start < text.len() {
309 result.push(&text[start..]);
310 }
311
312 result
313}
314
315fn split_by_size(text: &str, chunk_size: usize, chunk_overlap: usize) -> Vec<String> {
322 let step = chunk_size.saturating_sub(chunk_overlap);
323 let mut chunks = Vec::new();
324 let mut start = 0;
325
326 while start < text.len() {
327 let mut end = floor_char_boundary(text, start.saturating_add(chunk_size));
328 if end <= start {
329 end = next_char_boundary(text, start);
330 }
331 chunks.push(text[start..end].to_string());
332
333 let next = floor_char_boundary(text, start.saturating_add(step));
334 start = if next > start { next } else { next_char_boundary(text, start) };
335 }
336
337 chunks
338}
339
340impl Chunker for RecursiveChunker {
341 fn chunk(&self, document: &Document) -> Vec<Chunk> {
342 if document.text.is_empty() {
343 return Vec::new();
344 }
345
346 let separators = ["\n\n", ". ", "! ", "? ", " "];
347 let raw_chunks =
348 split_and_merge(&document.text, self.chunk_size, self.chunk_overlap, &separators);
349
350 raw_chunks
351 .into_iter()
352 .enumerate()
353 .map(|(i, text)| {
354 let mut metadata = document.metadata.clone();
355 metadata.insert("chunk_index".to_string(), i.to_string());
356 Chunk {
357 id: format!("{}_{i}", document.id),
358 text,
359 embedding: Vec::new(),
360 metadata,
361 document_id: document.id.clone(),
362 }
363 })
364 .collect()
365 }
366}
367
368#[derive(Debug, Clone)]
383pub struct MarkdownChunker {
384 chunk_size: usize,
385 chunk_overlap: usize,
386}
387
388impl MarkdownChunker {
389 pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
400 let (chunk_size, chunk_overlap) =
401 normalize_sizes("MarkdownChunker", chunk_size, chunk_overlap);
402 Self { chunk_size, chunk_overlap }
403 }
404
405 pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self> {
426 validate_sizes(chunk_size, chunk_overlap)?;
427 Ok(Self { chunk_size, chunk_overlap })
428 }
429}
430
431struct MarkdownSection {
433 header_path: String,
434 text: String,
435}
436
437fn parse_markdown_sections(text: &str) -> Vec<MarkdownSection> {
439 let mut sections = Vec::new();
440 let mut headers: Vec<String> = Vec::new();
441 let mut current_body = String::new();
442 let mut current_header_path = String::new();
443
444 for line in text.lines() {
445 let trimmed = line.trim_start();
446 if trimmed.starts_with('#') {
447 if !current_body.is_empty() || !current_header_path.is_empty() {
449 sections.push(MarkdownSection {
450 header_path: current_header_path.clone(),
451 text: current_body.trim().to_string(),
452 });
453 current_body = String::new();
454 }
455
456 let level = trimmed.chars().take_while(|c| *c == '#').count();
458 let header_text = trimmed[level..].trim().to_string();
459
460 headers.truncate(level.saturating_sub(1));
462 headers.push(header_text);
463 current_header_path = headers.join(" > ");
464 } else {
465 if !current_body.is_empty() {
466 current_body.push('\n');
467 }
468 current_body.push_str(line);
469 }
470 }
471
472 if !current_body.is_empty() || !current_header_path.is_empty() {
474 sections.push(MarkdownSection {
475 header_path: current_header_path,
476 text: current_body.trim().to_string(),
477 });
478 }
479
480 sections
481}
482
483impl Chunker for MarkdownChunker {
484 fn chunk(&self, document: &Document) -> Vec<Chunk> {
485 if document.text.is_empty() {
486 return Vec::new();
487 }
488
489 let sections = parse_markdown_sections(&document.text);
490 let mut chunks = Vec::new();
491 let mut chunk_index = 0;
492
493 for section in sections {
494 let section_text = if section.header_path.is_empty() {
496 section.text.clone()
497 } else if section.text.is_empty() {
498 section.header_path.clone()
499 } else {
500 format!("{}\n{}", section.header_path, section.text)
501 };
502
503 if section_text.is_empty() {
504 continue;
505 }
506
507 let sub_chunks = if section_text.len() > self.chunk_size {
508 let separators = ["\n\n", ". ", "! ", "? ", " "];
510 split_and_merge(§ion_text, self.chunk_size, self.chunk_overlap, &separators)
511 } else {
512 vec![section_text]
513 };
514
515 for text in sub_chunks {
516 let mut metadata = document.metadata.clone();
517 metadata.insert("chunk_index".to_string(), chunk_index.to_string());
518 metadata.insert("header_path".to_string(), section.header_path.clone());
519
520 chunks.push(Chunk {
521 id: format!("{}_{chunk_index}", document.id),
522 text,
523 embedding: Vec::new(),
524 metadata,
525 document_id: document.id.clone(),
526 });
527 chunk_index += 1;
528 }
529 }
530
531 chunks
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::Document;
539 use proptest::prelude::*;
540 use std::collections::HashMap;
541
542 fn doc(text: &str) -> Document {
543 Document {
544 id: "test".to_string(),
545 text: text.to_string(),
546 metadata: HashMap::new(),
547 source_uri: None,
548 }
549 }
550
551 #[test]
552 fn fixed_chunker_utf8_multibyte() {
553 let chunker = FixedSizeChunker::new(5, 0);
557 let chunks = chunker.chunk(&doc("你好世界测试文本"));
558 for chunk in &chunks {
560 assert!(chunk.text.is_char_boundary(0));
561 let _ = chunk.text.chars().count();
563 }
564 assert!(chunks.len() > 1);
566 }
567
568 #[test]
569 fn fixed_chunker_utf8_emoji() {
570 let chunker = FixedSizeChunker::new(6, 0);
572 let chunks = chunker.chunk(&doc("🦀🚀🎉✨🌟💫"));
573 for chunk in &chunks {
574 let _ = chunk.text.chars().count();
575 }
576 assert!(chunks.len() > 1);
577 }
578
579 #[test]
580 fn fixed_chunker_utf8_mixed() {
581 let text = "Hello café 你好 🦀";
583 let chunker = FixedSizeChunker::new(7, 2);
584 let chunks = chunker.chunk(&doc(text));
585 for chunk in &chunks {
586 let _ = chunk.text.chars().count();
587 }
588 assert!(!chunks.is_empty());
589 }
590
591 #[test]
592 fn split_by_size_utf8() {
593 let text = "日本語のテスト文字列です";
594 let chunks = split_by_size(text, 10, 3);
595 for chunk in &chunks {
596 let _ = chunk.chars().count();
597 }
598 assert!(chunks.len() > 1);
599 }
600
601 #[test]
602 fn recursive_chunker_utf8() {
603 let text = "第一段落。这是中文文本。\n\n第二段落。更多中文内容在这里。";
604 let chunker = RecursiveChunker::new(15, 3);
605 let chunks = chunker.chunk(&doc(text));
606 for chunk in &chunks {
607 let _ = chunk.text.chars().count();
608 }
609 assert!(!chunks.is_empty());
610 }
611
612 fn texts(chunks: &[Chunk]) -> Vec<&str> {
613 chunks.iter().map(|c| c.text.as_str()).collect()
614 }
615
616 #[test]
617 fn fixed_chunker_advances_when_step_is_narrower_than_a_character() {
618 let chunks = FixedSizeChunker::new(5, 3).chunk(&doc("你好世界"));
620 assert_eq!(texts(&chunks), ["你", "好", "世", "界"]);
621
622 let chunks = FixedSizeChunker::new(6, 5).chunk(&doc("🦀🚀"));
624 assert_eq!(texts(&chunks), ["🦀", "🚀"]);
625 }
626
627 #[test]
628 fn fixed_chunker_emits_characters_wider_than_chunk_size_whole() {
629 let chunks = FixedSizeChunker::new(2, 0).chunk(&doc("a你b"));
630 assert_eq!(texts(&chunks), ["a", "你", "b"]);
631 }
632
633 #[test]
634 fn split_by_size_advances_when_step_is_narrower_than_a_character() {
635 assert_eq!(split_by_size("日本語", 4, 3), ["日", "本", "語"]);
636 }
637
638 #[test]
639 fn recursive_and_markdown_chunkers_terminate_on_narrow_steps() {
640 let text = "# 标题\n\n第一段落很长很长很长。第二句也很长很长。\n\n🦀🦀🦀🦀🦀🦀";
641 let recursive = RecursiveChunker::new(5, 4).chunk(&doc(text));
642 let markdown = MarkdownChunker::new(5, 4).chunk(&doc(text));
643 assert!(!recursive.is_empty());
644 assert!(!markdown.is_empty());
645 }
646
647 #[test]
648 fn try_new_rejects_invalid_sizes() {
649 for (size, overlap) in [(0, 0), (10, 10), (10, 11)] {
650 assert!(
651 matches!(FixedSizeChunker::try_new(size, overlap), Err(RagError::ConfigError(_))),
652 "FixedSizeChunker accepted ({size}, {overlap})"
653 );
654 assert!(
655 matches!(RecursiveChunker::try_new(size, overlap), Err(RagError::ConfigError(_))),
656 "RecursiveChunker accepted ({size}, {overlap})"
657 );
658 assert!(
659 matches!(MarkdownChunker::try_new(size, overlap), Err(RagError::ConfigError(_))),
660 "MarkdownChunker accepted ({size}, {overlap})"
661 );
662 }
663 assert!(FixedSizeChunker::try_new(10, 9).is_ok());
664 }
665
666 #[test]
667 fn new_normalizes_invalid_sizes_instead_of_truncating() {
668 let chunks = FixedSizeChunker::new(4, 4).chunk(&doc("abcdefghij"));
670 assert_eq!(texts(&chunks), ["abcd", "efgh", "ij"]);
671
672 let chunks = FixedSizeChunker::new(0, 0).chunk(&doc("abc"));
673 assert_eq!(texts(&chunks), ["a", "b", "c"]);
674 }
675
676 fn arb_text() -> impl Strategy<Value = String> {
677 prop_oneof![
678 "\\PC{0,120}",
679 "[a 你好世界🦀é\\n.!?#]{0,120}",
680 proptest::collection::vec(any::<char>(), 0..60)
681 .prop_map(|chars| chars.into_iter().collect::<String>()),
682 ]
683 }
684
685 proptest! {
686 #![proptest_config(ProptestConfig::with_cases(256))]
687
688 #[test]
691 fn prop_chunkers_terminate_for_any_text_and_sizes(
692 text in arb_text(),
693 chunk_size in 0usize..24,
694 chunk_overlap in 0usize..32,
695 ) {
696 let document = doc(&text);
697
698 let fixed = FixedSizeChunker::new(chunk_size, chunk_overlap).chunk(&document);
699 prop_assert!(fixed.len() <= text.len());
700 prop_assert!(fixed.iter().all(|c| !c.text.is_empty()));
701 if let (Some(first), Some(last)) = (fixed.first(), fixed.last()) {
702 prop_assert!(text.starts_with(&first.text));
703 prop_assert!(text.ends_with(&last.text));
704 }
705
706 let recursive = RecursiveChunker::new(chunk_size, chunk_overlap).chunk(&document);
707 prop_assert!(recursive.iter().all(|c| !c.text.is_empty()));
708
709 let markdown = MarkdownChunker::new(chunk_size, chunk_overlap).chunk(&document);
710 prop_assert!(markdown.iter().all(|c| !c.text.is_empty()));
711 }
712
713 #[test]
716 fn prop_fixed_chunks_without_overlap_partition_the_text(
717 text in arb_text(),
718 chunk_size in 1usize..24,
719 ) {
720 let chunks = FixedSizeChunker::try_new(chunk_size, 0)
721 .expect("valid sizes")
722 .chunk(&doc(&text));
723 prop_assert_eq!(texts(&chunks).concat(), text.clone());
724 for chunk in &chunks {
725 prop_assert!(
726 chunk.text.len() <= chunk_size || chunk.text.chars().count() == 1,
727 "chunk {:?} exceeds {} bytes", chunk.text, chunk_size
728 );
729 }
730 }
731 }
732}