1use crate::config::{ChunkUnit, ChunkerKind};
13use crate::model::Chunk;
14use crate::{RagError, Result};
15use docling::chunker::{parse_sections_with_stack, WindowChunker};
16
17pub use docling::chunker::Section;
18
19pub fn docling_chunks(
26 doc_id: &str,
27 document: &docling::DoclingDocument,
28 kind: ChunkerKind,
29 tokenizer: Option<&str>,
30 max_tokens: usize,
31) -> Result<Vec<Chunk>> {
32 let mut chunks = Vec::new();
33 docling_chunks_with(doc_id, document, kind, tokenizer, max_tokens, &mut |c| {
34 chunks.push(c);
35 true
36 })?;
37 Ok(chunks)
38}
39
40pub fn docling_chunks_with(
45 doc_id: &str,
46 document: &docling::DoclingDocument,
47 kind: ChunkerKind,
48 tokenizer: Option<&str>,
49 max_tokens: usize,
50 sink: &mut dyn FnMut(Chunk) -> bool,
51) -> Result<()> {
52 use docling::chunker::{contextualize, DocChunk, HierarchicalChunker, HybridChunker};
53 let mut index: i64 = 0;
54 let mut map_sink = |c: DocChunk| -> bool {
55 let text = contextualize(&c);
56 let words = text.split_whitespace().count();
57 let mut chunk = Chunk::new(doc_id, index, text, words as i64);
58 chunk.metadata = serde_json::json!({
59 "headings": c.headings,
60 "doc_items": c.doc_items.iter().map(|d| d.self_ref.clone()).collect::<Vec<_>>(),
61 });
62 index += 1;
63 sink(chunk)
64 };
65 match kind {
66 ChunkerKind::Hierarchical => HierarchicalChunker.chunk_with(document, &mut map_sink),
67 ChunkerKind::Hybrid => {
68 let tok = docling::chunker::HuggingFaceTokenizer::resolve(tokenizer, max_tokens)
71 .map_err(RagError::config)?;
72 HybridChunker::new(tok).chunk_with(document, &mut map_sink)
73 }
74 ChunkerKind::Window => {
75 return Err(RagError::config(
76 "docling_chunks handles the hierarchical/hybrid chunkers only",
77 ))
78 }
79 }
80 Ok(())
81}
82
83const WORDS_PER_TOKEN: f32 = 0.75;
86
87#[derive(Debug, Clone)]
89pub struct Chunker {
90 pub size: usize,
92 pub overlap: f32,
94 pub unit: ChunkUnit,
96}
97
98impl Default for Chunker {
99 fn default() -> Self {
100 Chunker {
101 size: 300,
102 overlap: 0.05,
103 unit: ChunkUnit::Word,
104 }
105 }
106}
107
108impl Chunker {
109 pub fn from_config(cfg: &crate::RagConfig) -> Self {
111 Chunker {
112 size: cfg.chunk_size,
113 overlap: cfg.chunk_overlap,
114 unit: cfg.chunk_unit,
115 }
116 }
117
118 fn word_budget(&self) -> usize {
120 match self.unit {
121 ChunkUnit::Word => self.size.max(1),
122 ChunkUnit::Token => ((self.size as f32 * WORDS_PER_TOKEN).round() as usize).max(1),
124 }
125 }
126
127 fn to_units(&self, words: usize) -> i64 {
129 match self.unit {
130 ChunkUnit::Word => words as i64,
131 ChunkUnit::Token => (words as f32 / WORDS_PER_TOKEN).round() as i64,
132 }
133 }
134
135 pub fn chunk(&self, doc_id: &str, markdown: &str) -> Vec<Chunk> {
140 let mut streaming = self.streaming(doc_id);
141 let mut chunks = streaming.push(markdown);
142 chunks.extend(streaming.finish());
143 chunks
144 }
145
146 pub fn streaming(&self, doc_id: &str) -> StreamingChunker {
151 StreamingChunker {
152 chunker: self.clone(),
153 doc_id: doc_id.to_string(),
154 buffer: String::new(),
155 heading_stack: Vec::new(),
156 ordinal: 0,
157 in_fence: false,
158 }
159 }
160
161 fn pack_section(
166 &self,
167 doc_id: &str,
168 section: &Section,
169 ordinal: &mut i64,
170 out: &mut Vec<Chunk>,
171 ) {
172 let window = WindowChunker::new(self.word_budget(), self.overlap);
173 window.pack_section(section, &mut |c| {
174 let words = c.text.split_whitespace().count();
175 let text = WindowChunker::contextualize(&c);
176 let mut chunk = Chunk::new(doc_id, *ordinal, text, self.to_units(words));
177 if let Some(headings) = &c.headings {
178 chunk.metadata = serde_json::json!({ "headings": headings });
179 }
180 out.push(chunk);
181 *ordinal += 1;
182 true
183 });
184 }
185}
186
187pub struct StreamingChunker {
193 chunker: Chunker,
194 doc_id: String,
195 buffer: String,
197 heading_stack: Vec<String>,
199 ordinal: i64,
201 in_fence: bool,
203}
204
205impl StreamingChunker {
206 pub fn push(&mut self, piece: &str) -> Vec<Chunk> {
208 self.buffer.push_str(piece);
209
210 let mut fence_open = self.in_fence;
214 let mut cut: Option<usize> = None;
215 let mut offset = 0;
216 for line in self.buffer.split_inclusive('\n') {
217 let complete = line.ends_with('\n');
220 if is_fence_marker(line) {
221 if complete {
222 fence_open = !fence_open;
223 }
224 } else if complete && !fence_open && offset > 0 && is_atx_heading(line) {
225 cut = Some(offset);
226 }
227 offset += line.len();
228 }
229
230 let Some(cut) = cut else {
231 return Vec::new();
232 };
233 let flushed: String = self.buffer.drain(..cut).collect();
234 self.in_fence = false;
236 self.emit(&flushed)
237 }
238
239 pub fn finish(&mut self) -> Vec<Chunk> {
241 let rest = std::mem::take(&mut self.buffer);
242 if rest.trim().is_empty() {
243 return Vec::new();
244 }
245 self.emit(&rest)
246 }
247
248 fn emit(&mut self, markdown: &str) -> Vec<Chunk> {
249 let (sections, stack) =
250 parse_sections_with_stack(markdown, std::mem::take(&mut self.heading_stack));
251 self.heading_stack = stack;
252 let mut out = Vec::new();
253 for section in §ions {
254 self.chunker
255 .pack_section(&self.doc_id, section, &mut self.ordinal, &mut out);
256 }
257 out
258 }
259}
260
261fn strip_up_to_3_spaces(line: &str) -> &str {
264 let mut s = line;
265 for _ in 0..3 {
266 match s.strip_prefix(' ') {
267 Some(rest) => s = rest,
268 None => break,
269 }
270 }
271 s
272}
273
274fn is_fence_marker(line: &str) -> bool {
276 let t = strip_up_to_3_spaces(line);
277 t.starts_with("```") || t.starts_with("~~~")
278}
279
280fn is_atx_heading(line: &str) -> bool {
283 let t = strip_up_to_3_spaces(line);
284 let hashes = t.bytes().take_while(|b| *b == b'#').count();
285 (1..=6).contains(&hashes)
286 && matches!(
287 t.as_bytes().get(hashes),
288 None | Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r')
289 )
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
299 fn streaming_equals_batch_at_awkward_splits() {
300 let md = "\
301intro words before any heading
302# Chapter 1
303alpha beta gamma delta epsilon zeta eta theta
304## Section 1.1
305```
306# not a heading, just code
307code line two
308```
309more prose after the fence with several words here
310#hashtag is paragraph text, not a heading
311# Chapter 2
312final words of the document";
313
314 let chunker = Chunker {
315 size: 8,
316 overlap: 0.25,
317 unit: ChunkUnit::Word,
318 };
319 let batch = chunker.chunk("doc", md);
320 assert!(batch.len() > 3, "test doc should produce several chunks");
321
322 for step in [1usize, 3, 7, 11, 24] {
324 let mut streaming = chunker.streaming("doc");
325 let mut got = Vec::new();
326 let bytes = md.as_bytes();
327 let mut i = 0;
328 while i < bytes.len() {
329 let end = (i + step).min(bytes.len());
330 got.extend(streaming.push(std::str::from_utf8(&bytes[i..end]).unwrap()));
332 i = end;
333 }
334 got.extend(streaming.finish());
335
336 assert_eq!(got.len(), batch.len(), "chunk count differs at step {step}");
337 for (g, b) in got.iter().zip(&batch) {
338 assert_eq!(g.text, b.text, "text differs at step {step}");
339 assert_eq!(g.ordinal, b.ordinal, "ordinal differs at step {step}");
340 assert_eq!(g.metadata, b.metadata, "metadata differs at step {step}");
341 assert_eq!(g.token_count, b.token_count);
342 }
343 }
344 }
345
346 fn body_word_count(c: &Chunk) -> usize {
347 let body = c.text.rsplit("\n\n").next().unwrap_or(&c.text);
349 body.split_whitespace().count()
350 }
351
352 #[test]
353 fn respects_size_and_overlap() {
354 let words: Vec<String> = (0..100).map(|i| format!("w{i}")).collect();
356 let md = format!("# Title\n\n{}", words.join(" "));
357 let chunker = Chunker {
358 size: 20,
359 overlap: 0.10,
360 unit: ChunkUnit::Word,
361 };
362 let chunks = chunker.chunk("doc", &md);
363
364 assert!(chunks.len() > 1);
365 for c in &chunks[..chunks.len() - 1] {
367 assert_eq!(body_word_count(c), 20, "chunk body size");
368 }
369 let first_body: Vec<&str> = chunks[0]
371 .text
372 .rsplit("\n\n")
373 .next()
374 .unwrap()
375 .split_whitespace()
376 .collect();
377 let second_body: Vec<&str> = chunks[1]
378 .text
379 .rsplit("\n\n")
380 .next()
381 .unwrap()
382 .split_whitespace()
383 .collect();
384 assert_eq!(
385 &first_body[18..20],
386 &second_body[0..2],
387 "overlap tail carried forward"
388 );
389 }
390
391 #[test]
392 fn never_crosses_heading_boundary() {
393 let md = "# A\n\nalpha beta gamma\n\n# B\n\ndelta epsilon";
394 let chunker = Chunker {
395 size: 100,
396 overlap: 0.05,
397 unit: ChunkUnit::Word,
398 };
399 let chunks = chunker.chunk("doc", md);
400 assert_eq!(chunks.len(), 2);
402 assert!(chunks[0].text.contains("alpha") && !chunks[0].text.contains("delta"));
403 assert!(chunks[1].text.contains("delta") && !chunks[1].text.contains("alpha"));
404 }
405
406 #[test]
407 fn prepends_heading_context() {
408 let md = "# Guide\n\n## Setup\n\ninstall the thing";
409 let chunker = Chunker::default();
410 let chunks = chunker.chunk("doc", md);
411 assert_eq!(chunks.len(), 1);
412 assert!(chunks[0].text.starts_with("# Guide > Setup"));
413 assert!(chunks[0].text.contains("install the thing"));
414 assert_eq!(chunks[0].metadata["headings"][0], "Guide");
415 }
416
417 #[test]
418 fn token_unit_converts_budget() {
419 let words: Vec<String> = (0..80).map(|i| format!("w{i}")).collect();
420 let md = words.join(" ");
421 let chunker = Chunker {
423 size: 40,
424 overlap: 0.0,
425 unit: ChunkUnit::Token,
426 };
427 let chunks = chunker.chunk("doc", &md);
428 assert_eq!(body_word_count(&chunks[0]), 30);
429 assert_eq!(chunks[0].token_count, 40);
431 }
432}
433
434#[cfg(test)]
435mod docling_chunker_tests {
436 use super::*;
437
438 fn convert(md: &str) -> docling::DoclingDocument {
439 let src = docling::SourceDocument::from_bytes("t.md", docling::InputFormat::Md, md.into());
440 docling::DocumentConverter::new()
441 .convert(src)
442 .expect("convert")
443 .document
444 }
445
446 #[test]
447 fn hierarchical_maps_docchunks_onto_rag_chunks() {
448 let doc = convert("# Guide\n\n## Setup\n\nInstall the tools.\n\n- clone\n- build\n");
449 let chunks =
450 docling_chunks("doc-1", &doc, ChunkerKind::Hierarchical, None, 0).expect("chunk");
451 assert!(chunks.len() >= 2);
452 let setup = chunks
453 .iter()
454 .find(|c| c.text.contains("Install"))
455 .expect("setup chunk");
456 assert_eq!(setup.text, "Guide\nSetup\nInstall the tools.");
458 assert_eq!(setup.doc_id, "doc-1");
459 assert_eq!(setup.metadata["headings"][1], "Setup");
460 assert!(setup.metadata["doc_items"][0]
461 .as_str()
462 .unwrap()
463 .starts_with("#/"));
464 assert!(chunks.windows(2).all(|w| w[0].ordinal + 1 == w[1].ordinal));
466 }
467
468 #[test]
469 fn hybrid_without_tokenizer_is_a_config_error() {
470 if std::path::Path::new(docling::chunker::DEFAULT_TOKENIZER_PATH).exists() {
473 return;
474 }
475 let doc = convert("# A\n\ntext\n");
476 assert!(docling_chunks("d", &doc, ChunkerKind::Hybrid, None, 256).is_err());
477 }
478
479 #[test]
480 fn window_kind_is_rejected() {
481 let doc = convert("# A\n\ntext\n");
482 assert!(docling_chunks("d", &doc, ChunkerKind::Window, None, 0).is_err());
483 }
484
485 #[test]
486 fn streaming_sink_sees_the_batch_chunks_and_can_cancel() {
487 let doc = convert("# Guide\n\n## Setup\n\nInstall the tools.\n\n- clone\n- build\n");
488 let all = docling_chunks("d", &doc, ChunkerKind::Hierarchical, None, 0).expect("chunk");
489 assert!(all.len() >= 2);
490
491 let mut streamed = Vec::new();
493 docling_chunks_with("d", &doc, ChunkerKind::Hierarchical, None, 0, &mut |c| {
494 streamed.push(c);
495 true
496 })
497 .expect("stream");
498 assert_eq!(
499 streamed.iter().map(|c| &c.text).collect::<Vec<_>>(),
500 all.iter().map(|c| &c.text).collect::<Vec<_>>()
501 );
502
503 let mut n = 0;
505 docling_chunks_with("d", &doc, ChunkerKind::Hierarchical, None, 0, &mut |_| {
506 n += 1;
507 false
508 })
509 .expect("cancel");
510 assert_eq!(n, 1);
511 }
512}