oxidize_pdf/ai/chunking.rs
1//! Document chunking for RAG (Retrieval Augmented Generation)
2//!
3//! This module provides functionality to split PDF documents into smaller chunks
4//! suitable for processing with Large Language Models (LLMs). LLMs have token limits,
5//! so long documents need to be split into manageable pieces while preserving context.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use oxidize_pdf::ai::DocumentChunker;
11//! use oxidize_pdf::parser::{PdfReader, PdfDocument};
12//!
13//! # fn main() -> oxidize_pdf::Result<()> {
14//! let reader = PdfReader::open("large_document.pdf")?;
15//! let pdf_doc = PdfDocument::new(reader);
16//! let text_pages = pdf_doc.extract_text()?;
17//!
18//! let chunker = DocumentChunker::new(512, 50); // 512 tokens, 50 overlap
19//! let page_texts: Vec<(usize, String)> = text_pages.iter()
20//! .enumerate()
21//! .map(|(idx, page)| (idx + 1, page.text.clone()))
22//! .collect();
23//! let chunks = chunker.chunk_text_with_pages(&page_texts)?;
24//!
25//! println!("Created {} chunks", chunks.len());
26//! for chunk in &chunks {
27//! println!("Chunk {}: {} tokens", chunk.id, chunk.tokens);
28//! }
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::{Document, Result};
34use std::collections::HashMap;
35
36/// A chunk of a PDF document suitable for LLM processing
37///
38/// Each chunk represents a portion of the document's text with associated metadata
39/// that helps maintain context during retrieval and generation.
40#[derive(Debug, Clone)]
41pub struct DocumentChunk {
42 /// Unique identifier for this chunk (e.g., "chunk_0", "chunk_1")
43 pub id: String,
44
45 /// The text content of this chunk
46 pub content: String,
47
48 /// Estimated number of tokens in this chunk
49 pub tokens: usize,
50
51 /// Page numbers where this chunk's content appears (1-indexed)
52 pub page_numbers: Vec<usize>,
53
54 /// Index of this chunk in the sequence (0-indexed)
55 pub chunk_index: usize,
56
57 /// Additional metadata for this chunk
58 pub metadata: ChunkMetadata,
59}
60
61/// A language detected for a chunk or aggregated over a document.
62///
63/// `code` is the ISO 639-3 code (e.g. `"eng"`, `"spa"`, `"cmn"`). The
64/// underlying detector (`whatlang`) is an internal implementation detail and is
65/// not part of this public API.
66#[derive(Debug, Clone, PartialEq)]
67pub struct DetectedLanguage {
68 /// ISO 639-3 language code.
69 pub code: String,
70 /// Detector confidence in `[0.0, 1.0]`.
71 pub confidence: f32,
72 /// Whether the detector considers this detection reliable.
73 pub reliable: bool,
74}
75
76/// Detect the language of `text` using `whatlang`. Returns `None` only when
77/// `whatlang` cannot produce any detection (e.g. empty input). Detections are
78/// surfaced as-is, including unreliable ones — callers decide whether to trust a
79/// result using the `reliable` flag (and `confidence`). Unreliable detections on
80/// short or ambiguous text carry effectively-random codes, so consumers should
81/// gate routing on `reliable`.
82#[cfg(feature = "language-detection")]
83fn detect_chunk_language(text: &str) -> Option<DetectedLanguage> {
84 whatlang::detect(text).map(|info| DetectedLanguage {
85 code: info.lang().code().to_string(),
86 confidence: info.confidence() as f32,
87 reliable: info.is_reliable(),
88 })
89}
90
91/// No-op when the `language-detection` feature is disabled.
92#[cfg(not(feature = "language-detection"))]
93fn detect_chunk_language(_text: &str) -> Option<DetectedLanguage> {
94 None
95}
96
97/// Metadata for a document chunk
98#[derive(Debug, Clone, Default)]
99pub struct ChunkMetadata {
100 /// Position information about where this chunk appears in the document
101 pub position: ChunkPosition,
102
103 /// Confidence score for text extraction quality (0.0-1.0)
104 /// 1.0 = high confidence, 0.0 = low confidence
105 pub confidence: f32,
106
107 /// Whether this chunk respects sentence boundaries
108 pub sentence_boundary_respected: bool,
109
110 /// Detected language for this chunk, if language detection ran
111 /// (`DocumentChunker::with_language_detection(true)` + the
112 /// `language-detection` feature). `None` otherwise.
113 pub language: Option<DetectedLanguage>,
114}
115
116/// Position information for a chunk within the document
117#[derive(Debug, Clone, Default)]
118pub struct ChunkPosition {
119 /// Character offset where this chunk starts in the full document text
120 pub start_char: usize,
121
122 /// Character offset where this chunk ends in the full document text
123 pub end_char: usize,
124
125 /// First page number where this chunk appears (1-indexed)
126 pub first_page: usize,
127
128 /// Last page number where this chunk appears (1-indexed)
129 pub last_page: usize,
130}
131
132/// Configurable document chunker for splitting PDFs into LLM-friendly pieces
133///
134/// The chunker uses a simple fixed-size strategy with overlap to ensure context
135/// is preserved between consecutive chunks.
136///
137/// # Example
138///
139/// ```no_run
140/// use oxidize_pdf::ai::DocumentChunker;
141///
142/// // Create a chunker with 512 token chunks and 50 token overlap
143/// let chunker = DocumentChunker::new(512, 50);
144/// ```
145#[derive(Debug, Clone)]
146pub struct DocumentChunker {
147 /// Target size for each chunk in tokens
148 chunk_size: usize,
149
150 /// Number of tokens to overlap between consecutive chunks
151 overlap: usize,
152
153 /// Whether to run per-chunk language detection
154 detect_language: bool,
155}
156
157impl DocumentChunker {
158 /// Create a new document chunker with specified chunk size and overlap
159 ///
160 /// # Arguments
161 ///
162 /// * `chunk_size` - Target number of tokens per chunk (typical: 256-1024)
163 /// * `overlap` - Number of tokens to overlap between chunks (typical: 10-100)
164 ///
165 /// # Example
166 ///
167 /// ```
168 /// use oxidize_pdf::ai::DocumentChunker;
169 ///
170 /// // For GPT-3.5/4 with 4K context, use smaller chunks
171 /// let chunker = DocumentChunker::new(512, 50);
172 ///
173 /// // For Claude with 100K context, you can use larger chunks
174 /// let chunker_large = DocumentChunker::new(2048, 200);
175 /// ```
176 pub fn new(chunk_size: usize, overlap: usize) -> Self {
177 Self {
178 chunk_size,
179 overlap,
180 detect_language: false,
181 }
182 }
183
184 /// Create a default chunker with sensible defaults for most LLMs
185 ///
186 /// Uses 512 token chunks with 50 token overlap, which works well with
187 /// GPT-3.5, GPT-4, and similar models.
188 pub fn default() -> Self {
189 Self::new(512, 50)
190 }
191
192 /// Enable per-chunk language detection.
193 ///
194 /// Requires the `language-detection` feature; without it this flag is a
195 /// no-op and `ChunkMetadata::language` stays `None`. Disabled by default.
196 pub fn with_language_detection(mut self, enabled: bool) -> Self {
197 self.detect_language = enabled;
198 self
199 }
200
201 /// Dominant language across chunks that already carry a detected language,
202 /// weighted by chunk content length (chars). Returns `None` if no chunk has
203 /// a language.
204 ///
205 /// `confidence` is the length-weighted mean of the winning code's chunk
206 /// confidences; `reliable` is true if any contributing chunk for the winning
207 /// code was reliable.
208 pub fn document_language(chunks: &[DocumentChunk]) -> Option<DetectedLanguage> {
209 // Per-code accumulators: (total_weight, weighted_confidence_sum, any_reliable)
210 let mut acc: HashMap<String, (usize, f64, bool)> = HashMap::new();
211 for chunk in chunks {
212 if let Some(lang) = &chunk.metadata.language {
213 let weight = chunk.content.chars().count().max(1);
214 let entry = acc.entry(lang.code.clone()).or_insert((0, 0.0, false));
215 entry.0 += weight;
216 entry.1 += weight as f64 * lang.confidence as f64;
217 entry.2 |= lang.reliable;
218 }
219 }
220
221 // Winner = highest total weight; tie broken by code for determinism.
222 let (code, (total_weight, conf_sum, reliable)) = acc
223 .into_iter()
224 .max_by(|a, b| a.1 .0.cmp(&b.1 .0).then_with(|| b.0.cmp(&a.0)))?;
225
226 Some(DetectedLanguage {
227 code,
228 confidence: (conf_sum / total_weight as f64) as f32,
229 reliable,
230 })
231 }
232
233 /// Chunk a PDF document into pieces suitable for LLM processing
234 ///
235 /// # Arguments
236 ///
237 /// * `doc` - The PDF document to chunk
238 ///
239 /// # Returns
240 ///
241 /// A vector of `DocumentChunk` objects, each containing a portion of the document.
242 ///
243 /// # Example
244 ///
245 /// ```no_run
246 /// use oxidize_pdf::{Document, ai::DocumentChunker};
247 ///
248 /// # fn main() -> oxidize_pdf::Result<()> {
249 /// let doc = Document::new();
250 /// // Add pages to doc...
251 /// let chunker = DocumentChunker::new(512, 50);
252 /// let chunks = chunker.chunk_document(&doc)?;
253 ///
254 /// for chunk in chunks {
255 /// println!("Processing chunk {}: {} tokens", chunk.id, chunk.tokens);
256 /// // Send to LLM for processing...
257 /// }
258 /// # Ok(())
259 /// # }
260 /// ```
261 pub fn chunk_document(&self, doc: &Document) -> Result<Vec<DocumentChunk>> {
262 // Extract all text from the document
263 let full_text = doc.extract_text()?;
264
265 // Chunk the text
266 self.chunk_text(&full_text)
267 }
268
269 /// Chunk a text string into fixed-size pieces with overlap
270 ///
271 /// This is the core chunking algorithm that:
272 /// 1. Tokenizes the text (simple whitespace split)
273 /// 2. Creates chunks of `chunk_size` tokens
274 /// 3. Applies `overlap` tokens between consecutive chunks
275 /// 4. Respects sentence boundaries when possible
276 ///
277 /// # Arguments
278 ///
279 /// * `text` - The text to chunk
280 ///
281 /// # Returns
282 ///
283 /// A vector of `DocumentChunk` objects
284 ///
285 /// # Example
286 ///
287 /// ```
288 /// use oxidize_pdf::ai::DocumentChunker;
289 ///
290 /// let chunker = DocumentChunker::new(10, 2);
291 /// let text = "This is some sample text that will be chunked into smaller pieces";
292 /// let chunks = chunker.chunk_text(text).unwrap();
293 /// println!("Created {} chunks", chunks.len());
294 /// ```
295 pub fn chunk_text(&self, text: &str) -> Result<Vec<DocumentChunk>> {
296 // For simple text chunking, we don't have page information
297 self.chunk_text_internal(text, &[], 0)
298 }
299
300 /// Chunk text with page information for accurate page tracking
301 ///
302 /// # Arguments
303 ///
304 /// * `page_texts` - Vector of (page_number, text) tuples (1-indexed page numbers)
305 ///
306 /// # Returns
307 ///
308 /// A vector of `DocumentChunk` objects with page tracking
309 pub fn chunk_text_with_pages(
310 &self,
311 page_texts: &[(usize, String)],
312 ) -> Result<Vec<DocumentChunk>> {
313 // Combine all page texts with page markers
314 let mut full_text = String::new();
315 let mut page_boundaries = vec![0]; // Character positions where pages start
316
317 for (_page_num, text) in page_texts {
318 if !full_text.is_empty() {
319 full_text.push_str("\n\n"); // Page separator
320 }
321 full_text.push_str(text);
322 page_boundaries.push(full_text.len());
323 }
324
325 let page_numbers: Vec<usize> = page_texts.iter().map(|(num, _)| *num).collect();
326
327 // `page_texts` may be empty (e.g. a PDF from which no text extracted);
328 // fall back to page 0 rather than indexing into an empty vec.
329 let first_page = page_numbers.first().copied().unwrap_or(0);
330 self.chunk_text_internal(&full_text, &page_boundaries, first_page)
331 }
332
333 /// Internal chunking implementation with page tracking
334 fn chunk_text_internal(
335 &self,
336 text: &str,
337 page_boundaries: &[usize],
338 first_page: usize,
339 ) -> Result<Vec<DocumentChunk>> {
340 if text.is_empty() {
341 return Ok(Vec::new());
342 }
343
344 // Tokenize: simple whitespace split for now
345 // Enhancement: Use proper tokenizer (tiktoken) for accurate token counts
346 // Priority: MEDIUM - Current whitespace split provides estimates
347 // Accurate tokenization would require tiktoken-rs external dependency
348 // Target: v1.7.0 for LLM integration improvements
349 let tokens: Vec<&str> = text.split_whitespace().collect();
350
351 if tokens.is_empty() {
352 return Ok(Vec::new());
353 }
354
355 // Normalize degenerate configuration without changing the public
356 // constructor: a chunk must hold at least one token, and the overlap must
357 // leave room for `start` to advance by at least one token between chunks.
358 let chunk_size = self.chunk_size.max(1);
359 let overlap = self.overlap.min(chunk_size - 1);
360
361 let mut chunks = Vec::new();
362 let mut start = 0;
363 let mut chunk_idx = 0;
364 let mut char_offset = 0;
365
366 while start < tokens.len() {
367 // Calculate end position for this chunk
368 let mut end = (start + chunk_size).min(tokens.len());
369
370 // Try to respect sentence boundaries
371 let sentence_boundary_respected = if end < tokens.len() && end > start {
372 // Look for sentence endings in the last few tokens, but never
373 // before this chunk's own start: backtracking past `start` would
374 // collapse `end` to <= start, panicking on tokens[start..end] and
375 // stalling forward progress (#308).
376 let window_start = end.saturating_sub(10).max(start + 1);
377 let search_window = (window_start..end).rev();
378 let mut found_boundary = false;
379
380 for i in search_window {
381 let token = tokens[i];
382 if token.ends_with('.') || token.ends_with('!') || token.ends_with('?') {
383 end = i + 1; // Include the sentence-ending token
384 found_boundary = true;
385 break;
386 }
387 }
388 found_boundary
389 } else {
390 false
391 };
392
393 // Extract chunk tokens
394 let chunk_tokens = &tokens[start..end];
395
396 // Join tokens back into text
397 let content = chunk_tokens.join(" ");
398
399 // Detect language for this chunk (no-op unless enabled + feature on)
400 let language = if self.detect_language {
401 detect_chunk_language(&content)
402 } else {
403 None
404 };
405
406 // Calculate character positions
407 let start_char = char_offset;
408 let end_char = char_offset + content.len();
409 char_offset = end_char;
410
411 // Determine page numbers for this chunk
412 let (page_nums, first_pg, last_pg) = if page_boundaries.is_empty() {
413 (Vec::new(), 0, 0)
414 } else {
415 let mut pages = Vec::new();
416 let mut first = first_page;
417 let mut last = first_page;
418
419 for (idx, &boundary) in page_boundaries.iter().enumerate().skip(1) {
420 if start_char < boundary && end_char > page_boundaries[idx - 1] {
421 let page_num = first_page + idx - 1;
422 pages.push(page_num);
423 if pages.len() == 1 {
424 first = page_num;
425 }
426 last = page_num;
427 }
428 }
429
430 if pages.is_empty() {
431 // Chunk is beyond all tracked pages
432 pages.push(first_page);
433 first = first_page;
434 last = first_page;
435 }
436
437 (pages, first, last)
438 };
439
440 // Create chunk
441 let chunk = DocumentChunk {
442 id: format!("chunk_{}", chunk_idx),
443 content,
444 tokens: chunk_tokens.len(),
445 page_numbers: page_nums.clone(),
446 chunk_index: chunk_idx,
447 metadata: ChunkMetadata {
448 position: ChunkPosition {
449 start_char,
450 end_char,
451 first_page: first_pg,
452 last_page: last_pg,
453 },
454 confidence: 1.0, // Default high confidence for text-based chunking
455 sentence_boundary_respected,
456 language,
457 },
458 };
459
460 chunks.push(chunk);
461 chunk_idx += 1;
462
463 // Move start position with overlap
464 if end < tokens.len() {
465 // Apply overlap, but guarantee strict forward progress regardless
466 // of how far sentence-boundary backtracking pulled `end` back or
467 // how `overlap` compares to the chunk size (#308). `end > start`
468 // always holds here, so falling back to `start = end` advances.
469 let next_start = end.saturating_sub(overlap);
470 start = if next_start > start { next_start } else { end };
471 } else {
472 // Reached the end
473 break;
474 }
475 }
476
477 Ok(chunks)
478 }
479
480 /// Estimate the number of tokens in a text string
481 ///
482 /// Uses a simple approximation: 1 token ≈ 0.75 words (or ~1.33 tokens per word).
483 /// This is reasonably accurate for English text with GPT models.
484 ///
485 /// # Arguments
486 ///
487 /// * `text` - The text to estimate tokens for
488 ///
489 /// # Returns
490 ///
491 /// Estimated number of tokens
492 ///
493 /// # Note
494 ///
495 /// This is an approximation. For exact token counts, integrate with
496 /// a proper tokenizer like tiktoken.
497 pub fn estimate_tokens(text: &str) -> usize {
498 // Simple approximation: count words
499 // 1 token ≈ 0.75 words for English text
500 let words = text.split_whitespace().count();
501 ((words as f32) * 1.33) as usize
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn test_basic_chunking() {
511 let chunker = DocumentChunker::new(10, 2);
512
513 // Create text with exactly 25 words
514 let text = (0..25)
515 .map(|i| format!("word{}", i))
516 .collect::<Vec<_>>()
517 .join(" ");
518
519 let chunks = chunker.chunk_text(&text).unwrap();
520
521 // Should create 3 chunks:
522 // Chunk 0: words 0-9 (10 tokens)
523 // Chunk 1: words 8-17 (10 tokens, overlap of 2)
524 // Chunk 2: words 16-24 (9 tokens, overlap of 2)
525 assert_eq!(chunks.len(), 3, "Should create 3 chunks");
526
527 // Check first chunk
528 assert_eq!(chunks[0].tokens, 10);
529 assert_eq!(chunks[0].chunk_index, 0);
530 assert_eq!(chunks[0].id, "chunk_0");
531 assert_eq!(chunks[0].metadata.position.start_char, 0);
532
533 // Check second chunk
534 assert_eq!(chunks[1].tokens, 10);
535 assert_eq!(chunks[1].chunk_index, 1);
536
537 // Check third chunk
538 assert_eq!(chunks[2].tokens, 9);
539 assert_eq!(chunks[2].chunk_index, 2);
540 }
541
542 #[test]
543 fn test_overlap_preserves_context() {
544 let chunker = DocumentChunker::new(5, 2);
545
546 // Text: "a b c d e f g h i j"
547 let text = "a b c d e f g h i j";
548
549 let chunks = chunker.chunk_text(&text).unwrap();
550
551 // Chunk 0: a b c d e (positions 0-4)
552 // Chunk 1: d e f g h (positions 3-7, overlap of 2: d e)
553 // Chunk 2: g h i j (positions 6-9, overlap of 2: g h)
554
555 // Check overlap between chunk 0 and 1
556 let chunk0_end = chunks[0]
557 .content
558 .split_whitespace()
559 .rev()
560 .take(2)
561 .collect::<Vec<_>>();
562 let chunk1_start = chunks[1]
563 .content
564 .split_whitespace()
565 .take(2)
566 .collect::<Vec<_>>();
567
568 assert_eq!(chunk0_end, vec!["e", "d"]);
569 assert_eq!(chunk1_start, vec!["d", "e"]);
570 }
571
572 #[test]
573 fn test_empty_text() {
574 let chunker = DocumentChunker::new(10, 2);
575 let chunks = chunker.chunk_text("").unwrap();
576 assert_eq!(chunks.len(), 0);
577 }
578
579 #[test]
580 fn test_text_smaller_than_chunk_size() {
581 let chunker = DocumentChunker::new(100, 10);
582 let text = "just a few words";
583
584 let chunks = chunker.chunk_text(&text).unwrap();
585
586 assert_eq!(chunks.len(), 1);
587 assert_eq!(chunks[0].tokens, 4);
588 }
589
590 #[test]
591 fn test_token_estimation() {
592 // "hello world" = 2 words ≈ 2.66 tokens
593 let tokens = DocumentChunker::estimate_tokens("hello world");
594 assert!(
595 tokens >= 2 && tokens <= 3,
596 "Expected ~2-3 tokens, got {}",
597 tokens
598 );
599
600 // Empty text
601 assert_eq!(DocumentChunker::estimate_tokens(""), 0);
602
603 // Longer text: 100 words ≈ 133 tokens
604 let long_text = (0..100)
605 .map(|i| format!("word{}", i))
606 .collect::<Vec<_>>()
607 .join(" ");
608 let tokens_long = DocumentChunker::estimate_tokens(&long_text);
609 assert!(
610 tokens_long >= 120 && tokens_long <= 140,
611 "Expected ~133 tokens, got {}",
612 tokens_long
613 );
614 }
615
616 #[test]
617 fn test_chunk_ids_are_unique() {
618 let chunker = DocumentChunker::new(5, 1);
619 let text = (0..20)
620 .map(|i| format!("word{}", i))
621 .collect::<Vec<_>>()
622 .join(" ");
623
624 let chunks = chunker.chunk_text(&text).unwrap();
625
626 let ids: Vec<String> = chunks.iter().map(|c| c.id.clone()).collect();
627 let unique_ids: std::collections::HashSet<_> = ids.iter().collect();
628
629 assert_eq!(
630 ids.len(),
631 unique_ids.len(),
632 "All chunk IDs should be unique"
633 );
634 }
635
636 #[test]
637 fn test_sentence_boundary_detection() {
638 let chunker = DocumentChunker::new(10, 2);
639
640 let text = "This is the first sentence. This is the second sentence. This is the third sentence. And here is a fourth one.";
641
642 let chunks = chunker.chunk_text(&text).unwrap();
643
644 // At least some chunks should respect sentence boundaries
645 let has_boundary_respect = chunks
646 .iter()
647 .any(|c| c.metadata.sentence_boundary_respected);
648 assert!(
649 has_boundary_respect,
650 "At least some chunks should respect sentence boundaries"
651 );
652
653 // Check that sentences aren't broken in the middle (chunks should end with punctuation or be the last chunk)
654 for (i, chunk) in chunks.iter().enumerate() {
655 if i < chunks.len() - 1 && chunk.metadata.sentence_boundary_respected {
656 assert!(
657 chunk.content.ends_with('.')
658 || chunk.content.ends_with('!')
659 || chunk.content.ends_with('?'),
660 "Chunk {} should end with sentence punctuation",
661 i
662 );
663 }
664 }
665 }
666
667 #[test]
668 fn test_page_tracking() {
669 let chunker = DocumentChunker::new(10, 2);
670
671 let page_texts = vec![
672 (1, "This is page one content.".to_string()),
673 (2, "This is page two content.".to_string()),
674 (3, "This is page three content.".to_string()),
675 ];
676
677 let chunks = chunker.chunk_text_with_pages(&page_texts).unwrap();
678
679 // All chunks should have page information
680 for chunk in &chunks {
681 assert!(
682 !chunk.page_numbers.is_empty(),
683 "Chunk should have page numbers"
684 );
685 assert!(
686 chunk.metadata.position.first_page > 0,
687 "First page should be > 0"
688 );
689 assert!(
690 chunk.metadata.position.last_page > 0,
691 "Last page should be > 0"
692 );
693 }
694
695 // First chunk should start at page 1
696 assert_eq!(
697 chunks[0].metadata.position.first_page, 1,
698 "First chunk should start at page 1"
699 );
700 }
701
702 #[test]
703 fn test_metadata_position_tracking() {
704 let chunker = DocumentChunker::new(5, 1);
705
706 let text = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10";
707
708 let chunks = chunker.chunk_text(&text).unwrap();
709
710 // Check that positions are sequential and non-overlapping in character space
711 for i in 0..chunks.len() - 1 {
712 assert!(
713 chunks[i].metadata.position.end_char
714 <= chunks[i + 1].metadata.position.start_char + 10,
715 "Chunks should have reasonable character positions"
716 );
717 }
718
719 // First chunk should start at position 0
720 assert_eq!(chunks[0].metadata.position.start_char, 0);
721
722 // Each chunk should have a meaningful character range
723 for chunk in &chunks {
724 assert!(
725 chunk.metadata.position.end_char > chunk.metadata.position.start_char,
726 "End char should be greater than start char"
727 );
728 }
729 }
730
731 #[test]
732 fn test_confidence_scores() {
733 let chunker = DocumentChunker::new(10, 2);
734
735 let text = "This is a test document with multiple sentences.";
736
737 let chunks = chunker.chunk_text(&text).unwrap();
738
739 // All chunks should have confidence scores
740 for chunk in &chunks {
741 assert!(
742 chunk.metadata.confidence >= 0.0 && chunk.metadata.confidence <= 1.0,
743 "Confidence should be between 0.0 and 1.0"
744 );
745 }
746 }
747
748 #[test]
749 fn test_performance_100_pages() {
750 use std::time::Instant;
751
752 let chunker = DocumentChunker::new(512, 50);
753
754 // Generate 100 pages with ~200 words each (typical PDF page)
755 let page_texts: Vec<(usize, String)> = (1..=100)
756 .map(|page_num| {
757 let words: Vec<String> = (0..200).map(|i| format!("word{}", i)).collect();
758 (page_num, words.join(" "))
759 })
760 .collect();
761
762 let start = Instant::now();
763 let chunks = chunker.chunk_text_with_pages(&page_texts).unwrap();
764 let duration = start.elapsed();
765
766 tracing::debug!("Chunked 100 pages in {:?}", duration);
767 tracing::debug!("Created {} chunks", chunks.len());
768
769 // Target: < 500ms for 100 pages (relaxed for debug builds)
770 // In release mode this should be well under 100ms
771 assert!(
772 duration.as_millis() < 500,
773 "Chunking 100 pages took {:?}, should be < 500ms",
774 duration
775 );
776 }
777
778 /// Run `chunk_text` on a worker thread so a reintroduced infinite loop fails
779 /// the test with a timeout instead of hanging the whole test runner.
780 fn chunk_text_bounded(chunker: DocumentChunker, text: &str) -> Vec<DocumentChunk> {
781 use std::sync::mpsc;
782 use std::time::Duration;
783
784 let (tx, rx) = mpsc::channel();
785 let owned = text.to_string();
786 std::thread::spawn(move || {
787 let result = chunker.chunk_text(&owned);
788 // Ignore send errors: the receiver may have already timed out.
789 let _ = tx.send(result);
790 });
791
792 match rx.recv_timeout(Duration::from_secs(10)) {
793 Ok(result) => result.expect("chunk_text returned an error"),
794 Err(_) => panic!("chunk_text did not terminate within 10s (infinite loop, #308)"),
795 }
796 }
797
798 /// Reconstruct the set of source tokens covered by the chunks. Every input
799 /// token must appear in at least one chunk (coverage), proving the loop both
800 /// terminated and did not silently drop content.
801 fn assert_full_token_coverage(text: &str, chunks: &[DocumentChunk]) {
802 let mut covered = std::collections::HashSet::new();
803 for chunk in chunks {
804 for tok in chunk.content.split_whitespace() {
805 covered.insert(tok.to_string());
806 }
807 }
808 for tok in text.split_whitespace() {
809 assert!(
810 covered.contains(tok),
811 "token {:?} from the source is missing from all chunks",
812 tok
813 );
814 }
815 }
816
817 #[test]
818 fn test_no_infinite_loop_when_sentence_boundary_at_chunk_start() {
819 // Exact reproduction from issue #308: chunk_size=10, overlap=2, and the
820 // only sentence-ending token in the first window is at index 0 ("Hi.").
821 // Pre-fix, sentence-boundary backtracking set end=1, start=0 stayed put,
822 // and the loop never advanced.
823 let chunker = DocumentChunker::new(10, 2);
824 let text = "Hi. word word word word word word word word word word word word";
825
826 let chunks = chunk_text_bounded(chunker, text);
827
828 assert!(!chunks.is_empty(), "must produce at least one chunk");
829 assert_full_token_coverage(text, &chunks);
830 // Chunk indices must be strictly sequential (no repeats from a stalled loop).
831 for (i, chunk) in chunks.iter().enumerate() {
832 assert_eq!(chunk.chunk_index, i, "chunk indices must be sequential");
833 }
834 }
835
836 #[test]
837 fn test_no_infinite_loop_when_overlap_meets_or_exceeds_chunk_size() {
838 // overlap >= chunk_size leaves no room for the overlap to advance `start`.
839 // The loop must still make forward progress via the strict-advance guard.
840 let text = (0..30)
841 .map(|i| format!("word{}", i))
842 .collect::<Vec<_>>()
843 .join(" ");
844
845 for (chunk_size, overlap) in [(3usize, 5usize), (4, 4), (1, 10)] {
846 let chunker = DocumentChunker::new(chunk_size, overlap);
847 let chunks = chunk_text_bounded(chunker, &text);
848 assert!(
849 !chunks.is_empty(),
850 "chunk_size={chunk_size}, overlap={overlap}: must produce chunks"
851 );
852 assert_full_token_coverage(&text, &chunks);
853 }
854 }
855
856 #[test]
857 fn test_no_panic_when_chunk_size_below_search_window() {
858 // chunk_size < 10 means the raw sentence-boundary search window
859 // (end-10 .. end) can reach below `start`. The window lower bound must be
860 // clamped so `end` never collapses to <= start (would panic on the slice
861 // tokens[start..end]).
862 let text =
863 "first. second third fourth. fifth sixth seventh eighth. ninth tenth eleventh twelfth";
864 let chunker = DocumentChunker::new(4, 1);
865
866 let chunks = chunk_text_bounded(chunker, text);
867
868 assert!(!chunks.is_empty());
869 assert_full_token_coverage(text, &chunks);
870 // No empty chunks: every chunk holds at least one token.
871 for chunk in &chunks {
872 assert!(chunk.tokens >= 1, "chunk {} is empty", chunk.chunk_index);
873 }
874 }
875
876 #[test]
877 fn test_zero_chunk_size_terminates_with_coverage() {
878 // Degenerate chunk_size=0 must not loop forever; it is normalized to a
879 // minimum of one token per chunk.
880 let text = "alpha beta gamma delta epsilon";
881 let chunker = DocumentChunker::new(0, 0);
882
883 let chunks = chunk_text_bounded(chunker, text);
884
885 assert!(!chunks.is_empty());
886 assert_full_token_coverage(text, &chunks);
887 }
888
889 #[test]
890 fn test_sentence_boundary_still_respected_after_loop_fix() {
891 // Regression guard: the loop fix must not disable the sentence-boundary
892 // feature. With a period mid-window, the first chunk should end at the
893 // sentence boundary, not at the raw chunk_size cut.
894 let chunker = DocumentChunker::new(10, 2);
895 let text = "one two three four five. six seven eight nine ten eleven twelve thirteen";
896
897 let chunks = chunk_text_bounded(chunker, text);
898
899 assert!(chunks[0].metadata.sentence_boundary_respected);
900 assert!(
901 chunks[0].content.ends_with("five."),
902 "first chunk should end at the sentence boundary, got: {:?}",
903 chunks[0].content
904 );
905 assert_full_token_coverage(text, &chunks);
906 }
907}