Skip to main content

domtree/serialize/
chunk.rs

1//! RAG chunking of the Markdown output: [`Dom::to_markdown_chunks`] and
2//! [`NodeRef::to_markdown_chunks`], behind the `markdown` feature.
3//!
4//! Works on the finished Markdown: it is cut into blocks at blank lines
5//! (never inside a fenced code block), sections start at top-level ATX
6//! headings, and blocks are packed greedily into chunks under the budget.
7//! Linear in the size of the Markdown, and so is the output: heading titles
8//! are capped at `MAX_TITLE` characters, so the heading path every chunk
9//! carries is bounded.
10
11use alloc::string::String;
12use alloc::vec::Vec;
13use core::mem;
14
15use super::is_space;
16use crate::tree::{Dom, NodeRef};
17
18/// The longest heading title kept in a [`Chunk::heading_path`], in characters;
19/// a longer title is cut there and marked with `…`. Every chunk carries its
20/// whole path (at most six titles), so without a cap a few huge headings over
21/// many small chunks would make the output quadratic in the input.
22const MAX_TITLE: usize = 256;
23
24/// A piece of a document's Markdown, small enough for an embedding model or a
25/// prompt, with the headings it sits under — so every chunk stands on its
26/// own. Built by [`Dom::to_markdown_chunks`].
27///
28/// With the `serde` feature, `Chunk` implements `serde::Serialize`
29/// (`{"heading_path": [...], "markdown": "..."}`).
30#[non_exhaustive]
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct Chunk {
33    /// The plain-text titles of the headings this chunk sits under, outermost
34    /// first (`["Guide", "Install"]`). Empty before the first heading. A title
35    /// longer than 256 characters is cut to its first 256, followed by `…`
36    /// (the heading line itself stays whole in [`Chunk::markdown`]).
37    pub heading_path: Vec<String>,
38    /// The chunk's Markdown. It starts with its section's heading line when
39    /// it is the first chunk of that section.
40    pub markdown: String,
41}
42
43impl Chunk {
44    /// The heading path as one line of context, joined with `" > "` — e.g.
45    /// `"Guide > Install"` — to prepend to the chunk when embedding it. Empty
46    /// when the chunk comes before any heading.
47    ///
48    /// ```
49    /// let dom = domtree::parse("<h1>Guide</h1><h2>Install</h2><p>Run it.</p>");
50    /// let chunks = dom.to_markdown_chunks(1000);
51    /// assert_eq!(chunks[0].context(), "Guide > Install");
52    /// ```
53    pub fn context(&self) -> String {
54        self.heading_path.join(" > ")
55    }
56}
57
58impl Dom {
59    /// The document's Markdown ([`Dom::to_markdown`]) split into chunks of at
60    /// most `max_chars` characters (Unicode scalar values) for retrieval
61    /// (RAG), each carrying its heading path.
62    ///
63    /// - **Sections.** A chunk never spans two sections: each top-level
64    ///   heading (`#` … `######`) starts a new chunk, and its plain-text title
65    ///   joins the [`Chunk::heading_path`] (replacing any heading of the same
66    ///   or a deeper level). A heading directly followed by a deeper one stays
67    ///   in the same chunk, so `# Guide` / `## Install` / text is one chunk.
68    ///   Titles in the path are capped at 256 characters (cut, then `…`).
69    /// - **Packing.** Within a section, whole blocks (paragraphs, lists,
70    ///   quotes, tables, fenced code) are packed greedily, separated by a
71    ///   blank line, while the chunk stays within `max_chars`. Small sections
72    ///   are not merged with their neighbours.
73    /// - **Oversized blocks.** A block that alone exceeds `max_chars` is split
74    ///   at line boundaries. A split code block is re-fenced and a split table
75    ///   repeats its header rows in every piece, so each piece is valid
76    ///   Markdown on its own (when the fence or header leaves room for a
77    ///   line; otherwise lines are split plainly).
78    ///
79    /// Every chunk is at most `max_chars` long, except one made of a single
80    /// line that is itself longer — lines are never cut (a long paragraph is
81    /// one line). A section heading that cannot share a chunk with the first
82    /// block under it becomes a chunk of its own. No content is lost or
83    /// reordered: concatenating the chunks gives back the Markdown, plus any
84    /// repeated fences and table headers. `max_chars == 0` is allowed (every
85    /// line becomes its own chunk). Blank chunks are never returned.
86    ///
87    /// Work and output are linear in the size of the Markdown: every chunk
88    /// holds at least one line of it, and its heading path at most six capped
89    /// titles.
90    ///
91    /// ```
92    /// let dom = domtree::parse(
93    ///     "<h1>Guide</h1><p>Welcome.</p>\
94    ///      <h2>Install</h2><p>Run the installer.</p><pre>cargo add dom-tree-rs</pre>\
95    ///      <h2>Usage</h2><p>Parse, then query.</p>",
96    /// );
97    /// let chunks = dom.to_markdown_chunks(500);
98    /// let parts: Vec<_> = chunks.iter().map(|c| (c.context(), c.markdown.as_str())).collect();
99    /// assert_eq!(parts, [
100    ///     ("Guide".to_string(), "# Guide\n\nWelcome."),
101    ///     ("Guide > Install".to_string(), "## Install\n\nRun the installer.\n\n```\ncargo add dom-tree-rs\n```"),
102    ///     ("Guide > Usage".to_string(), "## Usage\n\nParse, then query."),
103    /// ]);
104    /// ```
105    pub fn to_markdown_chunks(&self, max_chars: usize) -> Vec<Chunk> {
106        chunk_markdown(&self.to_markdown(), max_chars)
107    }
108}
109
110impl NodeRef<'_> {
111    /// This node's subtree as Markdown chunks; see [`Dom::to_markdown_chunks`].
112    ///
113    /// ```
114    /// let dom = domtree::parse(
115    ///     "<nav>Home</nav><main><h2>Notes</h2><p>One.</p><p>Two.</p></main>",
116    /// );
117    /// let main = dom.find_by_tag("main").next().unwrap();
118    /// let chunks = main.to_markdown_chunks(14);
119    /// assert_eq!(chunks.len(), 2);
120    /// assert_eq!(chunks[0].markdown, "## Notes\n\nOne.");
121    /// assert_eq!(chunks[1].markdown, "Two.");
122    /// assert_eq!(chunks[1].heading_path, ["Notes"]);
123    /// ```
124    pub fn to_markdown_chunks(&self, max_chars: usize) -> Vec<Chunk> {
125        chunk_markdown(&self.to_markdown(), max_chars)
126    }
127}
128
129#[derive(Clone, Copy, PartialEq, Eq)]
130enum Kind {
131    Heading(u8),
132    /// A fenced code block at the top level: first and last lines are fences.
133    Fence,
134    /// A GFM table: a header row and a delimiter row, then body rows.
135    Table,
136    Text,
137}
138
139struct Block<'a> {
140    lines: Vec<&'a str>,
141    kind: Kind,
142}
143
144fn chunk_markdown(md: &str, max: usize) -> Vec<Chunk> {
145    let mut packer = Packer {
146        max,
147        ..Packer::default()
148    };
149    for block in blocks(md) {
150        let text = block.lines.join("\n");
151        let len = text.chars().count();
152        match block.kind {
153            Kind::Heading(level) => packer.heading(level, text, len),
154            _ => packer.block(&block, text, len),
155        }
156    }
157    packer.flush();
158    packer.chunks
159}
160
161#[derive(Default)]
162struct Packer {
163    max: usize,
164    chunks: Vec<Chunk>,
165    /// The open headings: level and plain-text title.
166    path: Vec<(u8, String)>,
167    cur: String,
168    /// `cur`'s length in characters.
169    len: usize,
170    /// `cur` holds something besides headings.
171    body: bool,
172    /// The level of the last heading in `cur`.
173    level: Option<u8>,
174}
175
176impl Packer {
177    fn fits(&self, len: usize) -> bool {
178        if self.cur.is_empty() {
179            len <= self.max
180        } else {
181            self.len.saturating_add(2).saturating_add(len) <= self.max
182        }
183    }
184
185    fn append(&mut self, text: &str, len: usize) {
186        if !self.cur.is_empty() {
187            self.cur.push_str("\n\n");
188            self.len = self.len.saturating_add(2);
189        }
190        self.cur.push_str(text);
191        self.len = self.len.saturating_add(len);
192    }
193
194    fn flush(&mut self) {
195        let markdown = mem::take(&mut self.cur);
196        self.len = 0;
197        self.body = false;
198        self.level = None;
199        if markdown.chars().any(|c| !c.is_whitespace()) {
200            self.chunks.push(Chunk {
201                heading_path: self.path.iter().map(|(_, t)| t.clone()).collect(),
202                markdown,
203            });
204        }
205    }
206
207    fn heading(&mut self, level: u8, line: String, len: usize) {
208        if self.body || self.level.is_some_and(|l| level <= l) || !self.fits(len) {
209            self.flush();
210        }
211        while self.path.last().is_some_and(|&(l, _)| l >= level) {
212            self.path.pop();
213        }
214        self.path.push((level, cap_title(heading_text(&line))));
215        self.append(&line, len);
216        self.level = Some(level);
217    }
218
219    fn block(&mut self, block: &Block<'_>, text: String, len: usize) {
220        if self.fits(len) {
221            self.append(&text, len);
222            self.body = true;
223            return;
224        }
225        if len <= self.max {
226            self.flush();
227            self.append(&text, len);
228            self.body = true;
229            return;
230        }
231        // Too big for any chunk: split it, keeping a pending heading with the
232        // first piece when that piece fits next to it.
233        let room = if self.cur.is_empty() || self.body {
234            0
235        } else {
236            self.max.saturating_sub(self.len.saturating_add(2))
237        };
238        let mut pieces = Vec::new();
239        if room > 0 {
240            pieces = split(block, room, self.max);
241            if pieces.first().is_some_and(|&(_, l)| l > room) {
242                pieces.clear();
243            }
244        }
245        if pieces.is_empty() {
246            self.flush();
247            pieces = split(block, self.max, self.max);
248        }
249        for (i, (piece, piece_len)) in pieces.into_iter().enumerate() {
250            if i > 0 {
251                self.flush();
252            }
253            self.append(&piece, piece_len);
254            self.body = true;
255        }
256    }
257}
258
259/// Split an oversized block at line boundaries into pieces of at most
260/// `first` (the first piece) and `max` characters, except where a single
261/// line is longer. Fenced code is re-fenced and tables repeat their header
262/// in each piece when that leaves room for a line.
263fn split(block: &Block<'_>, first: usize, max: usize) -> Vec<(String, usize)> {
264    let chars = |s: &str| s.chars().count();
265    // Characters of lines joined with `\n`.
266    let size =
267        |ls: &[&str]| ls.iter().map(|l| chars(l)).sum::<usize>() + ls.len().saturating_sub(1);
268    let lines = block.lines.as_slice();
269    // Repeated in every piece: a code block's fence lines, a table's header
270    // and delimiter rows.
271    let (head, body, tail): (&[&str], &[&str], &[&str]) = match block.kind {
272        Kind::Fence if lines.len() > 2 => {
273            let (open, rest) = lines.split_at_checked(1).unwrap_or((&[], lines));
274            let last = rest.len().saturating_sub(1);
275            let (body, close) = rest.split_at_checked(last).unwrap_or((rest, &[]));
276            (open, body, close)
277        }
278        Kind::Table if lines.len() > 2 => {
279            let (head, body) = lines.split_at_checked(2).unwrap_or((&[], lines));
280            (head, body, &[])
281        }
282        _ => (&[], lines, &[]),
283    };
284    let longest = body.iter().map(|l| chars(l)).max().unwrap_or(0);
285    let tail_len = |tail: &[&str]| if tail.is_empty() { 0 } else { 1 + size(tail) };
286    // Repeating only pays if every piece still has room for a line.
287    let wrap = !head.is_empty() && size(head) + 1 + longest + tail_len(tail) <= max;
288    let (head, body, tail) = if wrap {
289        (head, body, tail)
290    } else {
291        (&[][..], lines, &[][..])
292    };
293    let (head_text, head_len) = (head.join("\n"), size(head));
294    let (tail_text, tail_len) = (tail.join("\n"), tail_len(tail));
295
296    let mut pieces = Vec::new();
297    let mut limit = first;
298    let mut piece = head_text.clone();
299    let mut len = head_len;
300    let mut count = head.len(); // lines in `piece`
301    let mut has_body = false;
302    for line in body {
303        let line_len = chars(line);
304        let sep = usize::from(count > 0);
305        if has_body && len + sep + line_len + tail_len > limit {
306            if !tail.is_empty() {
307                piece.push('\n');
308                piece.push_str(&tail_text);
309                len += tail_len;
310            }
311            pieces.push((mem::replace(&mut piece, head_text.clone()), len));
312            len = head_len;
313            count = head.len();
314            limit = max;
315        }
316        if count > 0 {
317            piece.push('\n');
318            len += 1;
319        }
320        piece.push_str(line);
321        len += line_len;
322        count += 1;
323        has_body = true;
324    }
325    if !tail.is_empty() {
326        piece.push('\n');
327        piece.push_str(&tail_text);
328        len += tail_len;
329    }
330    pieces.push((piece, len));
331    pieces
332}
333
334/// Cut Markdown into blocks at blank lines, keeping fenced code blocks whole;
335/// each top-level ATX heading is a block of its own.
336fn blocks(md: &str) -> Vec<Block<'_>> {
337    let mut out = Vec::new();
338    let mut cur: Vec<&str> = Vec::new();
339    let mut fence: Option<usize> = None;
340    for line in md.split('\n') {
341        if let Some(open) = fence {
342            cur.push(line);
343            if closes_fence(line, open) {
344                fence = None;
345            }
346            continue;
347        }
348        if line.trim().is_empty() {
349            push_block(&mut out, &mut cur);
350            continue;
351        }
352        if let Some(level) = heading_level(line) {
353            push_block(&mut out, &mut cur);
354            out.push(Block {
355                lines: alloc::vec![line],
356                kind: Kind::Heading(level),
357            });
358            continue;
359        }
360        fence = opens_fence(line);
361        cur.push(line);
362    }
363    push_block(&mut out, &mut cur);
364    out
365}
366
367fn push_block<'a>(out: &mut Vec<Block<'a>>, cur: &mut Vec<&'a str>) {
368    if cur.is_empty() {
369        return;
370    }
371    let lines = mem::take(cur);
372    let first = lines.first().copied().unwrap_or("");
373    let last = lines.last().copied().unwrap_or("");
374    let fence = (lines.len() >= 2 && first.starts_with("```"))
375        .then(|| opens_fence(first))
376        .flatten()
377        .is_some_and(|n| !last.starts_with(' ') && closes_fence(last, n));
378    let table = first.starts_with('|')
379        && lines.get(1).is_some_and(|l| {
380            l.starts_with('|') && l.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
381        });
382    let kind = if fence {
383        Kind::Fence
384    } else if table {
385        Kind::Table
386    } else {
387        Kind::Text
388    };
389    out.push(Block { lines, kind });
390}
391
392/// The level of an ATX heading line (`## Title`), if `line` is one.
393fn heading_level(line: &str) -> Option<u8> {
394    let hashes = line.bytes().take_while(|&b| b == b'#').count();
395    let after = line.get(hashes..)?;
396    ((1..=6).contains(&hashes) && after.starts_with(' ')).then_some(hashes as u8)
397}
398
399/// Strip the container prefixes a line can carry inside block quotes and
400/// list items (`> `, `- `, `1. `, indentation).
401fn content(line: &str) -> &str {
402    let mut rest = line;
403    loop {
404        let trimmed = rest.trim_start_matches(' ');
405        if let Some(r) = trimmed.strip_prefix('>') {
406            rest = r.strip_prefix(' ').unwrap_or(r);
407            continue;
408        }
409        if let Some(r) = trimmed
410            .strip_prefix("- ")
411            .or_else(|| trimmed.strip_prefix("* "))
412            .or_else(|| trimmed.strip_prefix("+ "))
413        {
414            rest = r;
415            continue;
416        }
417        let digits = trimmed.bytes().take_while(u8::is_ascii_digit).count();
418        if (1..=9).contains(&digits) {
419            if let Some(r) = trimmed
420                .get(digits..)
421                .and_then(|r| r.strip_prefix(". ").or_else(|| r.strip_prefix(") ")))
422            {
423                rest = r;
424                continue;
425            }
426        }
427        return trimmed;
428    }
429}
430
431/// A fence opening line: three or more backticks and an info string without
432/// backticks. Returns the fence length.
433fn opens_fence(line: &str) -> Option<usize> {
434    let line = content(line);
435    let n = line.bytes().take_while(|&b| b == b'`').count();
436    (n >= 3 && !line.get(n..).is_some_and(|info| info.contains('`'))).then_some(n)
437}
438
439/// A line closing a fence of `open` backticks.
440fn closes_fence(line: &str, open: usize) -> bool {
441    let line = content(line).trim_end();
442    line.len() >= open && line.bytes().all(|b| b == b'`')
443}
444
445/// The plain text of an ATX heading line: markers, emphasis, link
446/// destinations and escapes removed, whitespace collapsed.
447fn heading_text(line: &str) -> String {
448    let mut rest = line.trim_start_matches('#');
449    let mut out = String::with_capacity(rest.len());
450    while let Some(c) = rest.chars().next() {
451        let after = rest.get(c.len_utf8()..).unwrap_or("");
452        match c {
453            '\\' => match after.chars().next() {
454                Some(n) if n.is_ascii_punctuation() => {
455                    out.push(n);
456                    rest = after.get(1..).unwrap_or("");
457                    continue;
458                }
459                _ => out.push('\\'),
460            },
461            '`' => {
462                let n = rest.bytes().take_while(|&b| b == b'`').count();
463                let body = rest.get(n..).unwrap_or("");
464                match code_span(body, n) {
465                    Some((code, after_span)) => {
466                        out.push_str(code);
467                        rest = after_span;
468                    }
469                    None => {
470                        out.push_str(rest.get(..n).unwrap_or(""));
471                        rest = body;
472                    }
473                }
474                continue;
475            }
476            '*' | '~' | '[' => {}
477            '!' if after.starts_with('[') => {}
478            ']' if after.starts_with('(') => {
479                rest = skip_destination(after);
480                continue;
481            }
482            ']' => {}
483            c => out.push(c),
484        }
485        rest = after;
486    }
487    let mut text = String::with_capacity(out.len());
488    for word in out.split(is_space).filter(|w| !w.is_empty()) {
489        if !text.is_empty() {
490            text.push(' ');
491        }
492        text.push_str(word);
493    }
494    text
495}
496
497/// `title` cut to its first [`MAX_TITLE`] characters, plus `…`, if longer.
498fn cap_title(mut title: String) -> String {
499    if let Some((end, _)) = title.char_indices().nth(MAX_TITLE) {
500        title.truncate(end);
501        let kept = title.trim_end_matches(is_space).len();
502        title.truncate(kept);
503        title.push('…');
504    }
505    title
506}
507
508/// The content of a code span whose opening run of `n` backticks preceded
509/// `body`, and the text after its closing run.
510fn code_span(body: &str, n: usize) -> Option<(&str, &str)> {
511    let bytes = body.as_bytes();
512    let mut i = 0;
513    while i < bytes.len() {
514        if bytes.get(i) != Some(&b'`') {
515            i += 1;
516            continue;
517        }
518        let start = i;
519        while bytes.get(i) == Some(&b'`') {
520            i += 1;
521        }
522        if i - start == n {
523            let code = body.get(..start)?;
524            let code = match code.strip_prefix(' ').and_then(|c| c.strip_suffix(' ')) {
525                Some(inner) if !inner.trim().is_empty() => inner,
526                _ => code,
527            };
528            return Some((code, body.get(i..)?));
529        }
530    }
531    None
532}
533
534/// Skip a link destination and title, `(…)`, returning what follows.
535fn skip_destination(rest: &str) -> &str {
536    let mut angle = false;
537    let mut quote = false;
538    let mut escaped = false;
539    for (i, c) in rest.char_indices().skip(1) {
540        if escaped {
541            escaped = false;
542            continue;
543        }
544        match c {
545            '\\' => escaped = true,
546            '<' if !quote && i == 1 => angle = true,
547            '>' if angle => angle = false,
548            '"' if !angle => quote = !quote,
549            ')' if !angle && !quote => return rest.get(i + 1..).unwrap_or(""),
550            _ => {}
551        }
552    }
553    ""
554}
555
556#[cfg(feature = "serde")]
557impl serde::Serialize for Chunk {
558    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
559        use serde::ser::SerializeStruct;
560        let mut st = s.serialize_struct("Chunk", 2)?;
561        st.serialize_field("heading_path", &self.heading_path)?;
562        st.serialize_field("markdown", &self.markdown)?;
563        st.end()
564    }
565}