Skip to main content

docling_rag/chunk/
mod.rs

1//! Markdown-aware chunking.
2//!
3//! The chunker parses Markdown into *sections* (split at every heading, carrying a
4//! heading path), then slides a fixed-size window with fractional overlap over the
5//! words of each section. A chunk never crosses a heading boundary, and each chunk
6//! is prefixed with its heading path so the embedded text keeps its context.
7//!
8//! The section parsing and windowing live in `docling::chunker::WindowChunker`
9//! (shared with the Python/Node bindings); this module maps its chunks onto
10//! retrievable [`Chunk`]s and adds the incremental [`StreamingChunker`] buffer.
11
12use 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
19/// Chunk a converted document with docling's structure-aware chunkers
20/// (`RAG_CHUNKER=hierarchical|hybrid`), mapping each `DocChunk` onto a
21/// retrievable [`Chunk`]. The embedded text is docling's `contextualize()`
22/// rendering (heading path + chunk body), the analogue of the window chunker's
23/// heading-context prefix; the heading path and source item refs are kept as
24/// chunk metadata.
25pub 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
40/// Streaming [`docling_chunks`]: `sink` receives each retrievable [`Chunk`] as
41/// the docling chunkers produce it, so chunks can flow into embedding while
42/// the rest of the document is still being chunked. A `false` return from
43/// `sink` cancels the chunking.
44pub 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            // RAG_CHUNK_TOKENIZER, or the download script's default location
69            // (models/chunk/tokenizer.json) when unset.
70            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
83/// Words per token, used to convert a token budget into a word budget when
84/// `unit == Token` (English text averages ≈1.3 tokens/word).
85const WORDS_PER_TOKEN: f32 = 0.75;
86
87/// A configured Markdown chunker.
88#[derive(Debug, Clone)]
89pub struct Chunker {
90    /// Target chunk size, measured in `unit`s (default 300).
91    pub size: usize,
92    /// Fractional overlap between consecutive chunks (default 0.05 = 5%).
93    pub overlap: f32,
94    /// The unit `size`/`overlap` are measured in.
95    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    /// Build a chunker from the resolved config.
110    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    /// The window size in *words*, derived from the configured unit.
119    fn word_budget(&self) -> usize {
120        match self.unit {
121            ChunkUnit::Word => self.size.max(1),
122            // Interpret `size` as a token budget and convert to words.
123            ChunkUnit::Token => ((self.size as f32 * WORDS_PER_TOKEN).round() as usize).max(1),
124        }
125    }
126
127    /// Report a word count back in the configured unit (for `Chunk::token_count`).
128    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    /// Chunk a Markdown document into [`Chunk`]s owned by `doc_id`.
136    ///
137    /// Implemented over [`StreamingChunker`] so batch and streaming ingestion
138    /// share one code path (and cannot diverge).
139    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    /// Start an incremental chunking session for one document. Feed Markdown
147    /// pieces with [`StreamingChunker::push`] as they arrive from the parser and
148    /// collect completed chunks immediately; call [`StreamingChunker::finish`]
149    /// after the last piece.
150    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    /// Slide the window over one completed section, appending chunks.
162    /// The windowing is `docling::chunker::WindowChunker`'s; this maps each
163    /// window onto a retrievable [`Chunk`] (ordinal, configured units,
164    /// heading-context-prefixed text, heading metadata).
165    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
187/// Incremental Markdown chunker: buffers streamed pieces and emits chunks for
188/// every section completed so far. A section only completes at the next heading
189/// (chunks never cross headings), so the buffer is cut at the start of the last
190/// heading line — everything before it is fully-formed blocks. Heading lines
191/// inside code fences are not cut points.
192pub struct StreamingChunker {
193    chunker: Chunker,
194    doc_id: String,
195    /// Markdown received but not yet chunked (the still-open tail section).
196    buffer: String,
197    /// Heading path in effect at the start of `buffer`.
198    heading_stack: Vec<String>,
199    /// Next chunk ordinal.
200    ordinal: i64,
201    /// Whether `buffer` starts inside a ``` code fence.
202    in_fence: bool,
203}
204
205impl StreamingChunker {
206    /// Feed the next piece of Markdown; returns chunks for sections it completed.
207    pub fn push(&mut self, piece: &str) -> Vec<Chunk> {
208        self.buffer.push_str(piece);
209
210        // Find the byte offset of the START of the last ATX-heading line outside
211        // a code fence (cut point). An ATX heading always terminates the block
212        // before it, so everything ahead of the cut is complete Markdown.
213        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            // Only complete lines can be judged — a trailing fragment like "#"
218            // might still grow into "#hashtag" (paragraph text, not a heading).
219            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        // The cut line is a heading outside any fence, so the tail starts unfenced.
235        self.in_fence = false;
236        self.emit(&flushed)
237    }
238
239    /// Chunk whatever is left in the buffer (the final section).
240    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 &sections {
254            self.chunker
255                .pack_section(&self.doc_id, section, &mut self.ordinal, &mut out);
256        }
257        out
258    }
259}
260
261/// CommonMark allows up to 3 leading spaces before block markers (4+ means an
262/// indented code block).
263fn 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
274/// A ``` / ~~~ code-fence marker line.
275fn is_fence_marker(line: &str) -> bool {
276    let t = strip_up_to_3_spaces(line);
277    t.starts_with("```") || t.starts_with("~~~")
278}
279
280/// A real ATX heading: 1–6 `#` followed by whitespace or end-of-line
281/// (`#hashtag` is paragraph text, not a heading).
282fn 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    /// Batch chunking and streaming chunking must produce identical chunks
297    /// (text, ordinal, metadata) no matter where the input is split.
298    #[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        // Split at every possible byte boundary pair (two cuts => three pieces).
323        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                // Split only at char boundaries (guaranteed here: ASCII test doc).
331                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        // Body is everything after the blank line that follows the heading context.
348        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        // One section, 100 words, size 20, overlap 0.10 => step 18.
355        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        // Every non-final chunk carries exactly `size` body words.
366        for c in &chunks[..chunks.len() - 1] {
367            assert_eq!(body_word_count(c), 20, "chunk body size");
368        }
369        // Consecutive chunks overlap by budget-step = 2 words.
370        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        // Two sections, each small => exactly two chunks.
401        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        // 40 tokens ≈ 30 words per chunk.
422        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        // token_count is reported back in tokens (30 words / 0.75 = 40).
430        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        // Embedded text is the contextualized rendering (heading path + body).
457        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        // Ordinals are the chunk sequence.
465        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        // With no explicit path the resolver falls back to the download
471        // script's default location — only its absence is an error.
472        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        // The sink receives the same chunks in the same order...
492        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        // ...and a false return cancels after the first chunk.
504        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}