1use std::collections::BTreeMap;
24
25use crate::document::{DoclingDocument, Node, Table};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ChunkItemKind {
31 Text,
33 Table,
36 Picture,
38}
39
40#[derive(Debug, Clone, PartialEq)]
43pub struct ChunkItem {
44 pub self_ref: String,
47 pub kind: ChunkItemKind,
48 pub text: String,
52}
53
54#[derive(Debug, Clone, PartialEq)]
56pub struct DocChunk {
57 pub text: String,
60 pub headings: Option<Vec<String>>,
63 pub doc_items: Vec<ChunkItem>,
65}
66
67pub fn contextualize(chunk: &DocChunk) -> String {
70 let mut parts: Vec<&str> = Vec::new();
71 if let Some(h) = &chunk.headings {
72 parts.extend(h.iter().map(String::as_str));
73 }
74 parts.push(&chunk.text);
75 parts.join("\n")
76}
77
78#[derive(Debug, Clone, Default)]
86pub struct HierarchicalChunker;
87
88impl HierarchicalChunker {
89 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
91 let mut chunks = Vec::new();
92 self.chunk_with(doc, &mut |c| {
93 chunks.push(c);
94 true
95 });
96 chunks
97 }
98
99 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
105 let mut w = Walker {
106 alloc: Alloc::default(),
107 headings: BTreeMap::new(),
108 stopped: false,
109 sink,
110 };
111 w.walk(&doc.nodes);
112 }
113}
114
115#[derive(Debug, Default)]
118struct Alloc {
119 texts: usize,
120 groups: usize,
121 tables: usize,
122 pictures: usize,
123 field_regions: usize,
124 field_items: usize,
125}
126
127impl Alloc {
128 fn text(&mut self) -> String {
129 let r = format!("#/texts/{}", self.texts);
130 self.texts += 1;
131 r
132 }
133 fn group(&mut self) -> String {
134 let r = format!("#/groups/{}", self.groups);
135 self.groups += 1;
136 r
137 }
138 fn table(&mut self) -> String {
139 let r = format!("#/tables/{}", self.tables);
140 self.tables += 1;
141 r
142 }
143 fn picture(&mut self) -> String {
144 let r = format!("#/pictures/{}", self.pictures);
145 self.pictures += 1;
146 r
147 }
148 fn field_region(&mut self) -> String {
149 let r = format!("#/field_regions/{}", self.field_regions);
150 self.field_regions += 1;
151 r
152 }
153 fn field_item(&mut self) -> String {
154 let r = format!("#/field_items/{}", self.field_items);
155 self.field_items += 1;
156 r
157 }
158}
159
160struct Walker<'s> {
161 alloc: Alloc,
162 headings: BTreeMap<u8, String>,
165 stopped: bool,
167 sink: &'s mut dyn FnMut(DocChunk) -> bool,
168}
169
170impl Walker<'_> {
171 fn emit(&mut self, text: String, doc_items: Vec<ChunkItem>) {
172 if self.stopped || text.is_empty() {
173 return;
174 }
175 let headings: Vec<String> = self.headings.values().cloned().collect();
176 self.stopped = !(self.sink)(DocChunk {
177 text,
178 headings: (!headings.is_empty()).then_some(headings),
179 doc_items,
180 });
181 }
182
183 fn emit_inline(&mut self, md_text: &str, self_ref: String) {
187 self.emit_inline_with_runs(md_text, self_ref, &[]);
188 }
189
190 fn emit_inline_with_runs(
191 &mut self,
192 md_text: &str,
193 self_ref: String,
194 runs: &[crate::InlineRun],
195 ) {
196 let body = unescape_text(md_text);
197 if body.is_empty() {
198 return;
199 }
200 let segments: Vec<String> = inline_segments_tagged(md_text)
201 .into_iter()
202 .flat_map(|(text, is_plain)| {
203 if is_plain {
204 if let Some(split) = split_plain_by_runs(&text, runs) {
205 return split;
206 }
207 }
208 vec![text]
209 })
210 .collect();
211 let items: Vec<ChunkItem> = if segments.len() <= 1 {
212 vec![ChunkItem {
213 self_ref,
214 kind: ChunkItemKind::Text,
215 text: body.clone(),
216 }]
217 } else {
218 segments
219 .into_iter()
220 .map(|text| ChunkItem {
221 self_ref: self_ref.clone(),
222 kind: ChunkItemKind::Text,
223 text,
224 })
225 .collect()
226 };
227 self.emit(body, items);
228 }
229
230 fn set_heading(&mut self, doc_level: u8, text: String) {
231 self.headings.retain(|k, _| *k < doc_level);
232 self.headings.insert(doc_level, text);
233 }
234
235 fn walk(&mut self, nodes: &[Node]) {
236 let mut i = 0;
237 while i < nodes.len() {
238 if self.stopped {
239 return;
240 }
241 if matches!(nodes[i], Node::ListItem { .. }) {
242 let start = i;
243 i += 1;
244 loop {
245 match nodes.get(i) {
246 Some(Node::ListItem { .. }) => i += 1,
247 Some(Node::Paragraph { text })
250 if text.is_empty()
251 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
252 {
253 i += 1
254 }
255 _ => break,
256 }
257 }
258 self.sibling_lists(&nodes[start..i]);
259 } else {
260 self.one(&nodes[i]);
261 i += 1;
262 }
263 }
264 }
265
266 fn sibling_lists(&mut self, run: &[Node]) {
270 let base = level_of(&run[0]);
271 let mut seg = 0;
272 let mut prev: Option<(bool, u64)> = None;
273 for k in 0..run.len() {
274 let Node::ListItem {
275 ordered,
276 number,
277 first_in_list,
278 level,
279 ..
280 } = &run[k]
281 else {
282 continue;
283 };
284 if *level != base {
285 continue; }
287 if k > seg {
288 if let Some((po, pn)) = prev {
289 if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
290 self.list(&run[seg..k]);
291 seg = k;
292 }
293 }
294 }
295 prev = Some((*ordered, *number));
296 }
297 self.list(&run[seg..]);
298 }
299
300 fn list(&mut self, items: &[Node]) {
304 self.alloc.group();
305 let mut chunk_items = Vec::new();
306 self.list_refs(items, &mut chunk_items);
307 let text = render_list(items);
308 self.emit(text, chunk_items);
309 }
310
311 fn list_refs(&mut self, items: &[Node], out: &mut Vec<ChunkItem>) {
315 let base = level_of(&items[0]);
316 let mut i = 0;
317 while i < items.len() {
318 let Node::ListItem {
319 ordered,
320 number,
321 text,
322 level,
323 layer,
324 ..
325 } = &items[i]
326 else {
327 i += 1;
328 continue;
329 };
330 if *level > base {
331 i += 1;
332 continue;
333 }
334 let item_ref = self.alloc.text();
335 let mut j = i + 1;
336 while j < items.len() && level_of(&items[j]) > base {
337 j += 1;
338 }
339 let has_nested = j > i + 1;
340 if layer.is_none() {
341 let marker = if *ordered {
342 format!("{number}.")
343 } else {
344 "-".to_string()
345 };
346 let segments = inline_segments(text);
350 if has_nested && segments.len() > 1 && text.contains("](") {
351 out.push(ChunkItem {
352 self_ref: item_ref.clone(),
353 kind: ChunkItemKind::Text,
354 text: format!("{marker} "),
355 });
356 for seg in segments {
357 out.push(ChunkItem {
358 self_ref: item_ref.clone(),
359 kind: ChunkItemKind::Text,
360 text: seg,
361 });
362 }
363 } else {
364 out.push(ChunkItem {
365 self_ref: item_ref.clone(),
366 kind: ChunkItemKind::Text,
367 text: format!("{marker} {}", unescape_text(text)),
368 });
369 }
370 }
371 if j > i + 1 {
374 self.nested_sibling_lists(&items[i + 1..j], out);
375 }
376 i = j;
377 }
378 }
379
380 fn nested_sibling_lists(&mut self, run: &[Node], out: &mut Vec<ChunkItem>) {
381 let base = level_of(&run[0]);
382 let mut seg = 0;
383 let mut prev: Option<(bool, u64)> = None;
384 for k in 0..run.len() {
385 let Node::ListItem {
386 ordered,
387 number,
388 first_in_list,
389 level,
390 ..
391 } = &run[k]
392 else {
393 continue;
394 };
395 if *level != base {
396 continue;
397 }
398 if k > seg {
399 if let Some((po, pn)) = prev {
400 if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
401 self.alloc.group();
402 self.list_refs(&run[seg..k], out);
403 seg = k;
404 }
405 }
406 }
407 prev = Some((*ordered, *number));
408 }
409 self.alloc.group();
410 self.list_refs(&run[seg..], out);
411 }
412
413 fn one(&mut self, node: &Node) {
414 match node {
415 Node::Heading { level, text } => {
416 let doc_level = if *level == 1 {
417 0
418 } else {
419 level.saturating_sub(1)
420 };
421 let self_ref = self.alloc.text();
422 let runs = crate::inline_runs_from_markdown(text);
428 if runs.len() <= 1 {
429 let plain = runs
430 .first()
431 .map(|r| r.text.clone())
432 .unwrap_or_else(|| text.clone());
433 self.set_heading(doc_level, unescape_text(&plain));
434 } else {
435 self.set_heading(doc_level, String::new());
436 let body = unescape_text(text);
437 self.emit(
438 body.clone(),
439 vec![ChunkItem {
440 self_ref,
441 kind: ChunkItemKind::Text,
442 text: body,
443 }],
444 );
445 }
446 }
447 Node::Paragraph { text } => {
448 let t = text.trim();
449 let self_ref = self.alloc.text();
450 if let Some(inner) = t
453 .strip_prefix("$$")
454 .and_then(|s| s.strip_suffix("$$"))
455 .filter(|s| !s.is_empty())
456 {
457 let body = format!("$${inner}$$");
458 self.emit(
459 body.clone(),
460 vec![ChunkItem {
461 self_ref,
462 kind: ChunkItemKind::Text,
463 text: body,
464 }],
465 );
466 return;
467 }
468 self.emit_inline(text, self_ref);
469 }
470 Node::CheckboxItem { checked, text } => {
471 let self_ref = self.alloc.text();
472 let mark = if *checked { "- [x] " } else { "- [ ] " };
473 let body = format!("{mark}{}", unescape_text(text));
474 self.emit(
475 body.clone(),
476 vec![ChunkItem {
477 self_ref,
478 kind: ChunkItemKind::Text,
479 text: body,
480 }],
481 );
482 }
483 Node::Formula { latex, .. } => {
486 let self_ref = self.alloc.text();
487 let body = format!("$${}$$", latex);
488 self.emit(
489 body.clone(),
490 vec![ChunkItem {
491 self_ref,
492 kind: ChunkItemKind::Text,
493 text: body,
494 }],
495 );
496 }
497 Node::Code { text, .. } => {
498 let self_ref = self.alloc.text();
499 let body = format!("```\n{}\n```", unescape_text(text));
500 self.emit(
501 body.clone(),
502 vec![ChunkItem {
503 self_ref,
504 kind: ChunkItemKind::Text,
505 text: body,
506 }],
507 );
508 }
509 Node::Table(t) => {
510 let self_ref = self.alloc.table();
511 let body = triplet_table_text(t);
512 self.emit(
513 body.clone(),
514 vec![ChunkItem {
515 self_ref,
516 kind: ChunkItemKind::Table,
517 text: body,
518 }],
519 );
520 }
521 Node::Picture { caption, .. } => {
522 let cap = caption.as_deref().filter(|c| !c.is_empty());
523 let cap_item = cap.map(|c| ChunkItem {
524 self_ref: self.alloc.text(),
525 kind: ChunkItemKind::Text,
526 text: unescape_text(c),
527 });
528 self.alloc.picture();
529 if let Some(cap_item) = cap_item {
533 let body = cap_item.text.clone();
534 self.emit(body, vec![cap_item]);
535 }
536 }
537 Node::Chart {
538 kind,
539 table,
540 caption,
541 ..
542 } => {
543 let cap = caption.as_deref().filter(|c| !c.is_empty());
544 let cap_item = cap.map(|c| ChunkItem {
545 self_ref: self.alloc.text(),
546 kind: ChunkItemKind::Text,
547 text: unescape_text(c),
548 });
549 let pic_ref = self.alloc.picture();
550 let mut parts: Vec<String> = Vec::new();
554 if let Some(ci) = &cap_item {
555 parts.push(ci.text.clone());
556 }
557 parts.push(humanize_label(kind));
558 let grid = crate::markdown::render_table(table, false);
559 if !grid.is_empty() {
560 parts.push(unescape_text(&grid));
561 }
562 let body = parts.join("\n\n");
563 let pic_item = ChunkItem {
568 self_ref: pic_ref,
569 kind: ChunkItemKind::Picture,
570 text: body.clone(),
571 };
572 let items = match cap_item {
573 Some(mut ci) => {
574 ci.text = String::new();
575 vec![ci, pic_item]
576 }
577 None => vec![pic_item],
578 };
579 self.emit(body, items);
580 }
581 Node::Group { children, .. } => {
582 self.alloc.group();
585 self.walk(children);
586 }
587 Node::FieldRegion { items } => {
588 self.alloc.field_region();
591 for item in items {
592 self.alloc.field_item();
593 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
594 let self_ref = self.alloc.text();
595 let body = unescape_text(part);
596 self.emit(
597 body.clone(),
598 vec![ChunkItem {
599 self_ref,
600 kind: ChunkItemKind::Text,
601 text: body,
602 }],
603 );
604 }
605 }
606 }
607 Node::InlineGroup { md_text, runs, .. } => {
608 let self_ref = self.alloc.text();
609 self.emit_inline_with_runs(md_text, self_ref, runs);
610 }
611 Node::TextDump(text) => {
612 let self_ref = self.alloc.text();
613 let body = unescape_text(text);
614 self.emit(
615 body.clone(),
616 vec![ChunkItem {
617 self_ref,
618 kind: ChunkItemKind::Text,
619 text: body,
620 }],
621 );
622 }
623 Node::Located { inner, .. } => self.one(inner),
625 Node::Furniture { .. }
628 | Node::PageFurniture { .. }
629 | Node::PageBreak
630 | Node::DoclangOnly(_) => {}
631 Node::ListItem { .. } => unreachable!("list items are chunked in runs"),
632 }
633 }
634}
635
636fn level_of(node: &Node) -> u8 {
637 match node {
638 Node::ListItem { level, .. } => *level,
639 _ => 0,
640 }
641}
642
643fn render_list(items: &[Node]) -> String {
646 let mut lines: Vec<String> = Vec::new();
647 for item in items {
648 let Node::ListItem {
649 ordered,
650 number,
651 text,
652 level,
653 layer,
654 ..
655 } = item
656 else {
657 continue;
658 };
659 if layer.is_some() {
660 continue;
661 }
662 let indent = " ".repeat(*level as usize);
663 let marker = if *ordered {
664 format!("{number}.")
665 } else {
666 "-".to_string()
667 };
668 lines.push(format!("{indent}{marker} {}", unescape_text(text)));
669 }
670 lines.join("\n")
671}
672
673fn humanize_label(label: &str) -> String {
676 let text = label.replace('_', " ");
677 let mut chars = text.chars();
678 match chars.next() {
679 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
680 None => text,
681 }
682}
683
684fn triplet_table_text(t: &Table) -> String {
691 let rows: Vec<Vec<String>> = t
692 .rows
693 .iter()
694 .enumerate()
695 .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
696 .collect();
697 let num_rows = rows.len();
698 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
699 if num_rows == 0 || num_cols == 0 {
700 return String::new();
701 }
702 let cell = |r: usize, c: usize| -> &str {
703 rows.get(r)
704 .and_then(|row| row.get(c))
705 .map(String::as_str)
706 .unwrap_or("")
707 };
708
709 let cell_is_header = |r: usize, c: usize| -> bool {
713 let (mut r, mut c) = (r, c);
714 loop {
715 match &t.structure {
716 Some(s) if !s.col_header.is_empty() => {
717 return s
718 .col_header
719 .get(r)
720 .and_then(|row| row.get(c))
721 .copied()
722 .unwrap_or(false)
723 }
724 Some(s) => {
725 let cont = |g: &Vec<Vec<bool>>| {
726 g.get(r)
727 .and_then(|row| row.get(c))
728 .copied()
729 .unwrap_or(false)
730 };
731 if r > 0 && cont(&s.row_continuation) {
732 r -= 1;
733 continue;
734 }
735 if c > 0 && cont(&s.col_continuation) {
736 c -= 1;
737 continue;
738 }
739 return if s.header_row.is_empty() {
740 r == 0
741 } else {
742 s.header_row.get(r).copied().unwrap_or(false)
743 };
744 }
745 None => return r == 0,
746 }
747 }
748 };
749 let row_is_header = |r: usize| (0..num_cols).any(|c| cell_is_header(r, c));
750 let num_headers = (0..num_rows).take_while(|r| row_is_header(*r)).count();
751
752 let columns: Vec<String> = if num_headers > 0 {
755 (0..num_cols)
756 .map(|c| {
757 let mut name = String::new();
758 for r in 0..num_headers {
759 if !name.is_empty() {
760 name.push('.');
761 }
762 name.push_str(cell(r, c));
763 }
764 name
765 })
766 .collect()
767 } else {
768 (0..num_cols).map(|c| c.to_string()).collect()
769 };
770 let data_rows = num_headers..num_rows;
771 let n_data = data_rows.len();
772
773 if n_data == 0 {
775 return columns
776 .iter()
777 .map(|s| s.trim())
778 .filter(|s| !s.is_empty())
779 .collect::<Vec<_>>()
780 .join(". ");
781 }
782
783 let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
784 let text = if num_cols == 1 {
785 let col_name = data(0, 0).trim().to_string();
788 if n_data == 1 {
789 col_name
790 } else {
791 (1..n_data)
792 .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
793 .collect::<Vec<_>>()
794 .join(". ")
795 }
796 } else {
797 let mut parts = Vec::new();
799 for r in 0..n_data {
800 for (c, col_name) in columns.iter().enumerate().skip(1) {
801 parts.push(format!(
802 "{}, {} = {}",
803 data(r, 0).trim(),
804 col_name.trim(),
805 data(r, c).trim()
806 ));
807 }
808 }
809 parts.join(". ")
810 };
811 if !text.is_empty() {
812 return text;
813 }
814
815 (0..n_data)
818 .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
819 .map(|(r, c)| data(r, c).trim())
820 .filter(|s| !s.is_empty())
821 .collect::<Vec<_>>()
822 .join(". ")
823}
824
825fn inline_segments(md: &str) -> Vec<String> {
833 inline_segments_tagged(md)
834 .into_iter()
835 .map(|(t, _)| t)
836 .collect()
837}
838
839fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
843 let chars: Vec<char> = md.chars().collect();
844 let n = chars.len();
845 let find = |from: usize, pat: &str| -> Option<usize> {
846 let hay: String = chars[from..].iter().collect();
847 hay.find(pat).map(|p| from + hay[..p].chars().count())
848 };
849 let mut out: Vec<(String, bool)> = Vec::new();
850 let mut plain = String::new();
851 let mut after_span = false;
852
853 fn flush(
854 out: &mut Vec<(String, bool)>,
855 plain: &mut String,
856 before_span: bool,
857 after_span: bool,
858 ) {
859 let mut p = std::mem::take(plain);
860 if after_span {
861 if let Some(rest) = p.strip_prefix(' ') {
862 p = rest.to_string();
863 }
864 }
865 if before_span {
866 if let Some(rest) = p.strip_suffix(' ') {
867 p = rest.to_string();
868 }
869 }
870 if !p.is_empty() {
871 out.push((unescape_text(&p), true));
872 }
873 }
874
875 let mut i = 0;
876 while i < n {
877 let rest: String = chars[i..].iter().collect();
878 if chars[i] == '[' && !rest.starts_with("[](") {
880 if let Some(close) = find(i + 1, "](") {
881 if let Some(endp) = find(close + 2, ")") {
882 flush(&mut out, &mut plain, true, after_span);
883 out.push((
884 unescape_text(&chars[i..=endp].iter().collect::<String>()),
885 false,
886 ));
887 i = endp + 1;
888 after_span = true;
889 continue;
890 }
891 }
892 }
893 let mut matched = false;
896 for marker in ["***", "**", "*", "~~", "`"] {
897 if rest.starts_with(marker) {
898 let mlen = marker.chars().count();
899 if let Some(end) = find(i + mlen, marker) {
900 if end > i + mlen {
901 flush(&mut out, &mut plain, true, after_span);
902 if marker == "`" {
903 let inner: String = chars[i + 1..end].iter().collect();
904 out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
905 } else {
906 out.push((
907 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
908 false,
909 ));
910 }
911 i = end + mlen;
912 after_span = true;
913 matched = true;
914 }
915 }
916 break;
917 }
918 }
919 if matched {
920 continue;
921 }
922 if rest.starts_with("$$") {
925 plain.push_str("$$");
926 i += 2;
927 continue;
928 }
929 if chars[i] == '$' {
931 if let Some(end) = find(i + 1, "$") {
932 if end > i + 1 {
933 flush(&mut out, &mut plain, true, after_span);
934 let latex: String = chars[i + 1..end].iter().collect();
935 out.push((format!("$${latex}$$"), false));
936 i = end + 1;
937 after_span = true;
938 continue;
939 }
940 }
941 }
942 plain.push(chars[i]);
943 i += 1;
944 }
945 flush(&mut out, &mut plain, false, after_span);
946 if out.is_empty() {
947 out.push((unescape_text(md), true));
948 }
949 out
950}
951
952fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
957 let target = segment.trim();
958 if target.is_empty() {
959 return None;
960 }
961 let unmarked: Vec<&str> = runs
962 .iter()
963 .filter(|r| !r.bold && !r.italic && !r.strike && !r.code && !r.formula)
964 .map(|r| r.text.as_str())
965 .collect();
966 for start in 0..unmarked.len() {
967 let mut rest = target;
968 let mut taken: Vec<String> = Vec::new();
969 for t in &unmarked[start..] {
970 let t = t.trim();
971 if t.is_empty() {
972 continue;
973 }
974 match rest.strip_prefix(t) {
975 Some(r) => {
976 taken.push(unescape_text(t));
977 rest = r.trim_start();
978 if rest.is_empty() {
979 break;
980 }
981 }
982 None => break,
983 }
984 }
985 if rest.is_empty() && taken.len() >= 2 {
986 return Some(taken);
987 }
988 }
989 None
990}
991
992fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
999 if let Some(blocks) = t
1000 .cell_blocks
1001 .as_ref()
1002 .and_then(|b| b.get(r))
1003 .and_then(|row| row.get(c))
1004 .filter(|b| !b.is_empty())
1005 {
1006 let mut parts: Vec<String> = Vec::new();
1007 for node in blocks.iter() {
1008 let part = block_chunk_text(node);
1009 if !part.is_empty() {
1010 parts.push(part);
1011 }
1012 }
1013 return parts.join("\n\n");
1014 }
1015 let flat = t
1016 .rows
1017 .get(r)
1018 .and_then(|row| row.get(c))
1019 .map(String::as_str)
1020 .unwrap_or("");
1021 unescape_text(flat)
1022 .replace("<!-- image -->", "")
1023 .trim()
1024 .to_string()
1025}
1026
1027fn block_chunk_text(node: &Node) -> String {
1029 match node {
1030 Node::Paragraph { text } => unescape_text(text),
1031 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1032 Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1033 Node::Table(inner) => triplet_table_text(inner),
1034 Node::Picture { caption, .. } => caption
1035 .as_deref()
1036 .filter(|c| !c.is_empty())
1037 .map(unescape_text)
1038 .unwrap_or_default(),
1039 Node::ListItem {
1040 ordered,
1041 number,
1042 text,
1043 ..
1044 } => {
1045 let marker = if *ordered {
1046 format!("{number}.")
1047 } else {
1048 "-".to_string()
1049 };
1050 format!("{marker} {}", unescape_text(text))
1051 }
1052 Node::CheckboxItem { checked, text } => {
1053 let mark = if *checked { "- [x] " } else { "- [ ] " };
1054 format!("{mark}{}", unescape_text(text))
1055 }
1056 Node::Heading { text, .. } => unescape_text(text),
1057 Node::Located { inner, .. } => block_chunk_text(inner),
1058 Node::Group { children, .. } => children
1059 .iter()
1060 .map(block_chunk_text)
1061 .filter(|s| !s.is_empty())
1062 .collect::<Vec<_>>()
1063 .join("\n"),
1064 _ => String::new(),
1065 }
1066}
1067
1068fn unescape_text(s: &str) -> String {
1071 s.replace("<", "<")
1072 .replace(">", ">")
1073 .replace("&", "&")
1074 .replace("\\_", "_")
1075}
1076
1077pub trait ChunkTokenizer {
1083 fn count_tokens(&self, text: &str) -> usize;
1085 fn max_tokens(&self) -> usize;
1087}
1088
1089pub struct HybridChunker<T: ChunkTokenizer> {
1093 tokenizer: T,
1094 merge_peers: bool,
1095}
1096
1097impl<T: ChunkTokenizer> HybridChunker<T> {
1098 pub fn new(tokenizer: T) -> Self {
1099 Self {
1100 tokenizer,
1101 merge_peers: true,
1102 }
1103 }
1104
1105 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1107 self.merge_peers = merge_peers;
1108 self
1109 }
1110
1111 pub fn max_tokens(&self) -> usize {
1112 self.tokenizer.max_tokens()
1113 }
1114
1115 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1117 let mut chunks = Vec::new();
1118 self.chunk_with(doc, &mut |c| {
1119 chunks.push(c);
1120 true
1121 });
1122 chunks
1123 }
1124
1125 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1132 let mut merger = PeerMerger::default();
1133 let mut alive = true;
1134 HierarchicalChunker.chunk_with(doc, &mut |c| {
1135 for split in self.split_by_doc_items(c) {
1136 for chunk in self.split_using_plain_text(split) {
1137 if !alive {
1138 return false;
1139 }
1140 alive = if self.merge_peers {
1141 self.merge_push(&mut merger, chunk, sink)
1142 } else {
1143 sink(chunk)
1144 };
1145 }
1146 }
1147 alive
1148 });
1149 if alive {
1150 self.merge_flush(&mut merger, sink);
1151 }
1152 }
1153
1154 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1155 self.tokenizer.count_tokens(&contextualize(chunk))
1156 }
1157
1158 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1161 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1162 let text = if chunk.doc_items.len() == 1 {
1163 chunk.text.clone()
1164 } else {
1165 doc_items
1166 .iter()
1167 .filter(|it| !it.text.is_empty())
1168 .map(|it| it.text.as_str())
1169 .collect::<Vec<_>>()
1170 .join("\n")
1171 };
1172 DocChunk {
1173 text,
1174 headings: chunk.headings.clone(),
1175 doc_items,
1176 }
1177 }
1178
1179 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1180 if chunk.doc_items.is_empty() {
1181 return vec![chunk];
1182 }
1183 let max = self.max_tokens();
1184 let num_items = chunk.doc_items.len();
1185 let mut chunks = Vec::new();
1186 let mut window_start = 0usize;
1187 let mut window_end = 0usize; while window_end < num_items {
1189 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1190 if self.count_chunk_tokens(&new_chunk) <= max {
1191 if window_end < num_items - 1 {
1192 window_end += 1;
1193 continue;
1194 } else {
1195 window_end = num_items; }
1197 } else if window_start == window_end {
1198 window_end += 1;
1201 window_start = window_end;
1202 } else {
1203 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1206 window_start = window_end;
1207 }
1208 chunks.push(new_chunk);
1209 }
1210 chunks
1211 }
1212
1213 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1214 let total = self.count_chunk_tokens(&chunk);
1215 let max = self.max_tokens();
1216 if total <= max {
1217 return vec![chunk];
1218 }
1219 let text_len = self.tokenizer.count_tokens(&chunk.text);
1220 let other_len = total - text_len;
1221 if other_len >= max {
1222 let stripped = DocChunk {
1224 headings: None,
1225 ..chunk
1226 };
1227 return self.split_using_plain_text(stripped);
1228 }
1229 let available = max - other_len;
1230
1231 let segments =
1232 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1233 let lines: Vec<String> = chunk
1240 .text
1241 .split('\n')
1242 .filter(|l| !l.trim().is_empty())
1243 .map(|l| l.to_string())
1244 .collect();
1245 line_chunk_text(&lines, &self.tokenizer, max)
1246 } else {
1247 semchunk(&chunk.text, available, &self.tokenizer)
1248 };
1249 segments
1250 .into_iter()
1251 .map(|s| DocChunk {
1252 text: s,
1253 headings: chunk.headings.clone(),
1254 doc_items: chunk.doc_items.clone(),
1255 })
1256 .collect()
1257 }
1258
1259 fn merge_push(
1265 &self,
1266 m: &mut PeerMerger,
1267 chunk: DocChunk,
1268 sink: &mut dyn FnMut(DocChunk) -> bool,
1269 ) -> bool {
1270 if m.window.is_empty() {
1271 m.window.push(chunk);
1272 return true;
1273 }
1274 let candidate = DocChunk {
1275 text: m
1276 .window
1277 .iter()
1278 .map(|c| c.text.as_str())
1279 .chain([chunk.text.as_str()])
1280 .collect::<Vec<_>>()
1281 .join("\n"),
1282 headings: m.window[0].headings.clone(),
1283 doc_items: m
1284 .window
1285 .iter()
1286 .flat_map(|c| c.doc_items.iter().cloned())
1287 .chain(chunk.doc_items.iter().cloned())
1288 .collect(),
1289 };
1290 if chunk.headings == m.window[0].headings
1291 && self.count_chunk_tokens(&candidate) <= self.max_tokens()
1292 {
1293 m.window.push(chunk);
1294 m.merged = Some(candidate);
1295 true
1296 } else {
1297 let alive = self.merge_flush(m, sink);
1298 m.window.push(chunk);
1299 alive
1300 }
1301 }
1302
1303 fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1307 let alive = if m.window.len() == 1 {
1308 sink(m.window.pop().expect("single-chunk window"))
1309 } else if !m.window.is_empty() {
1310 m.window.clear();
1311 sink(m.merged.take().expect("multi-chunk window has a merge"))
1312 } else {
1313 true
1314 };
1315 m.merged = None;
1316 alive
1317 }
1318}
1319
1320#[derive(Default)]
1322struct PeerMerger {
1323 window: Vec<DocChunk>,
1324 merged: Option<DocChunk>,
1325}
1326
1327fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1337 let mut chunks: Vec<String> = Vec::new();
1338 let mut current = String::new();
1339 let mut current_len = 0usize;
1340
1341 for line in lines {
1342 let mut remaining: Vec<char> = line.chars().collect();
1343 loop {
1344 let rem_str: String = remaining.iter().collect();
1345 let line_tokens = tok.count_tokens(&rem_str);
1346 let available = max_tokens.saturating_sub(current_len);
1347
1348 if line_tokens <= available {
1349 current.push_str(&rem_str);
1350 current_len += line_tokens;
1351 break;
1352 }
1353 if line_tokens <= max_tokens {
1354 chunks.push(std::mem::take(&mut current));
1355 current_len = 0;
1356 continue;
1357 }
1358 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1360 let mut rest = rest;
1361 if take.is_empty() {
1362 if rest.is_empty() {
1363 break;
1364 }
1365 take = rest[..1].iter().collect();
1366 rest = rest[1..].to_vec();
1367 }
1368 current.push('\n');
1369 current.push_str(&take);
1370 chunks.push(std::mem::take(&mut current));
1371 current_len = 0;
1372 remaining = rest;
1373 }
1374 }
1375 if !current.is_empty() {
1376 chunks.push(current);
1377 }
1378 chunks
1379}
1380
1381fn split_by_token_limit<T: ChunkTokenizer>(
1385 text: &[char],
1386 token_limit: usize,
1387 tok: &T,
1388) -> (String, Vec<char>) {
1389 if token_limit == 0 || text.is_empty() {
1390 return (String::new(), text.to_vec());
1391 }
1392 let full: String = text.iter().collect();
1393 if tok.count_tokens(&full) <= token_limit {
1394 return (full, Vec::new());
1395 }
1396 let (mut lo, mut hi) = (0usize, text.len());
1397 let mut best: Option<usize> = None;
1398 while lo <= hi {
1399 let mid = (lo + hi) / 2;
1400 let head: String = text[..mid].iter().collect();
1401 if tok.count_tokens(&head) <= token_limit {
1402 best = Some(mid);
1403 lo = mid + 1;
1404 } else {
1405 if mid == 0 {
1406 break;
1407 }
1408 hi = mid - 1;
1409 }
1410 }
1411 let mut best_idx = match best {
1412 Some(b) if b > 0 => b,
1413 _ => return (String::new(), text.to_vec()),
1414 };
1415 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1417 if pos > 0 {
1418 best_idx = pos;
1419 }
1420 }
1421 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1422}
1423
1424const NON_WS_SPLITTERS: &[&str] = &[
1430 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1431 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1432];
1433
1434pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1438 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1439 let mut counter = |s: &str| -> usize {
1440 if let Some(n) = cache.get(s) {
1441 return *n;
1442 }
1443 let n = tok.count_tokens(s);
1444 cache.insert(s.to_string(), n);
1445 n
1446 };
1447 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1448 chunks
1450 .into_iter()
1451 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1452 .collect()
1453}
1454
1455fn semchunk_rec(
1458 text: &str,
1459 chunk_size: usize,
1460 counter: &mut dyn FnMut(&str) -> usize,
1461) -> Vec<String> {
1462 let (splitter, splitter_is_ws, splits) = split_text(text);
1463
1464 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1465 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1466 cum_lens.push(0usize);
1467 for l in &split_lens {
1468 cum_lens.push(cum_lens.last().unwrap() + l);
1469 }
1470 let num_splits_plus_one = splits.len() + 1;
1471
1472 let mut chunks: Vec<String> = Vec::new();
1473 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1474
1475 for i in 0..splits.len() {
1476 if skips.contains(&i) {
1477 continue;
1478 }
1479 let split = &splits[i];
1480 if counter(split) > chunk_size {
1481 let inner = semchunk_rec(split, chunk_size, counter);
1482 chunks.extend(inner);
1483 } else {
1484 let (end, merged) = merge_splits(
1485 &splits,
1486 &cum_lens,
1487 chunk_size,
1488 &splitter,
1489 counter,
1490 i,
1491 num_splits_plus_one,
1492 );
1493 for j in (i + 1)..end {
1494 skips.insert(j);
1495 }
1496 chunks.push(merged);
1497 }
1498 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1501 if !splitter_is_ws && !is_last {
1502 let with_splitter = format!(
1503 "{}{}",
1504 chunks.last().map(String::as_str).unwrap_or(""),
1505 splitter
1506 );
1507 if counter(&with_splitter) <= chunk_size {
1508 if let Some(last) = chunks.last_mut() {
1509 *last = with_splitter;
1510 } else {
1511 chunks.push(with_splitter);
1512 }
1513 } else {
1514 chunks.push(splitter.clone());
1515 }
1516 }
1517 }
1518 chunks
1519}
1520
1521fn merge_splits(
1524 splits: &[String],
1525 cum_lens: &[usize],
1526 chunk_size: usize,
1527 splitter: &str,
1528 counter: &mut dyn FnMut(&str) -> usize,
1529 start: usize,
1530 high_init: usize,
1531) -> (usize, String) {
1532 let mut average = 0.2f64;
1533 let mut low = start;
1534 let mut high = high_init;
1535 let offset = cum_lens[start];
1536 let mut target = offset as f64 + (chunk_size as f64 * average);
1537
1538 while low < high {
1539 let i = bisect_left(cum_lens, target, low, high);
1540 let midpoint = i.min(high - 1);
1541 let joined = splits[start..midpoint.max(start)].join(splitter);
1542 let tokens = counter(&joined);
1543 let local_cum = cum_lens[midpoint] - offset;
1544 if local_cum > 0 && tokens > 0 {
1545 average = local_cum as f64 / tokens as f64;
1546 target = offset as f64 + (chunk_size as f64 * average);
1547 }
1548 if tokens > chunk_size {
1549 high = midpoint;
1550 } else {
1551 low = midpoint + 1;
1552 }
1553 }
1554 let end = low - 1;
1555 (end, splits[start..end.max(start)].join(splitter))
1556}
1557
1558fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1559 while low < high {
1560 let mid = (low + high) / 2;
1561 if (sorted[mid] as f64) < target {
1562 low = mid + 1;
1563 } else {
1564 high = mid;
1565 }
1566 }
1567 low
1568}
1569
1570fn split_text(text: &str) -> (String, bool, Vec<String>) {
1572 if text.contains('\n') || text.contains('\r') {
1574 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1575 return (splitter.clone(), true, split_on(text, &splitter));
1576 }
1577 if text.contains('\t') {
1579 let splitter = longest_run(text, |c| c == '\t');
1580 return (splitter.clone(), true, split_on(text, &splitter));
1581 }
1582 if text.chars().any(char::is_whitespace) {
1584 let splitter = longest_run(text, char::is_whitespace);
1585 if splitter.chars().count() == 1 {
1586 for preceder in NON_WS_SPLITTERS {
1588 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1589 return (ws, true, parts);
1590 }
1591 }
1592 }
1593 return (splitter.clone(), true, split_on(text, &splitter));
1594 }
1595 for s in NON_WS_SPLITTERS {
1597 if text.contains(s) {
1598 return (s.to_string(), false, split_on(text, s));
1599 }
1600 }
1601 (
1603 String::new(),
1604 true,
1605 text.chars().map(|c| c.to_string()).collect(),
1606 )
1607}
1608
1609fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1611 let mut best = String::new();
1612 let mut cur = String::new();
1613 for c in text.chars() {
1614 if pred(c) {
1615 cur.push(c);
1616 } else {
1617 if cur.chars().count() > best.chars().count() {
1618 best = cur.clone();
1619 }
1620 cur.clear();
1621 }
1622 }
1623 if cur.chars().count() > best.chars().count() {
1624 best = cur;
1625 }
1626 best
1627}
1628
1629fn split_on(text: &str, splitter: &str) -> Vec<String> {
1630 text.split(splitter).map(str::to_string).collect()
1631}
1632
1633fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1637 let chars: Vec<char> = text.chars().collect();
1638 let p: Vec<char> = preceder.chars().collect();
1639 let mut ws: Option<char> = None;
1640 for i in p.len()..chars.len() {
1641 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1642 ws = Some(chars[i]);
1643 break;
1644 }
1645 }
1646 let ws = ws?;
1647 let mut parts = Vec::new();
1648 let mut cur = String::new();
1649 let mut i = 0usize;
1650 while i < chars.len() {
1651 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1652 parts.push(std::mem::take(&mut cur));
1653 i += 1;
1654 continue;
1655 }
1656 cur.push(chars[i]);
1657 i += 1;
1658 }
1659 parts.push(cur);
1660 Some((ws.to_string(), parts))
1661}
1662
1663#[cfg(feature = "chunking")]
1668mod hf {
1669 use super::ChunkTokenizer;
1670
1671 pub const DEFAULT_TOKENIZER_PATH: &str = "models/chunk/tokenizer.json";
1676
1677 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1682 if let Some(p) = explicit {
1683 return Ok(p.to_string());
1684 }
1685 if std::path::Path::new(DEFAULT_TOKENIZER_PATH).exists() {
1686 return Ok(DEFAULT_TOKENIZER_PATH.to_string());
1687 }
1688 Err(format!(
1689 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1690 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1691 scripts/install/download_dependencies.sh (or pass an explicit path)"
1692 ))
1693 }
1694
1695 pub struct HuggingFaceTokenizer {
1699 tok: tokenizers::Tokenizer,
1700 max_tokens: usize,
1701 }
1702
1703 impl HuggingFaceTokenizer {
1704 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1708 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1709 }
1710
1711 pub fn from_file(
1715 path: impl AsRef<std::path::Path>,
1716 max_tokens: usize,
1717 ) -> Result<Self, String> {
1718 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1719 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1720 let _ = tok.with_truncation(None);
1725 tok.with_padding(None);
1726 Ok(Self { tok, max_tokens })
1727 }
1728 }
1729
1730 impl ChunkTokenizer for HuggingFaceTokenizer {
1731 fn count_tokens(&self, text: &str) -> usize {
1732 self.tok
1733 .encode(text, false)
1734 .map(|e| e.get_tokens().len())
1735 .unwrap_or(0)
1736 }
1737 fn max_tokens(&self) -> usize {
1738 self.max_tokens
1739 }
1740 }
1741}
1742
1743#[cfg(feature = "chunking")]
1744pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1745
1746#[cfg(feature = "chunking")]
1751mod window {
1752 use super::DocChunk;
1753 use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1754
1755 #[derive(Debug, Clone, Default)]
1757 pub struct Section {
1758 pub heading_path: Vec<String>,
1761 pub words: Vec<String>,
1763 }
1764
1765 impl Section {
1766 pub fn heading_context(&self) -> String {
1769 if self.heading_path.is_empty() {
1770 String::new()
1771 } else {
1772 format!("# {}", self.heading_path.join(" > "))
1773 }
1774 }
1775 }
1776
1777 fn level_index(level: HeadingLevel) -> usize {
1778 match level {
1779 HeadingLevel::H1 => 1,
1780 HeadingLevel::H2 => 2,
1781 HeadingLevel::H3 => 3,
1782 HeadingLevel::H4 => 4,
1783 HeadingLevel::H5 => 5,
1784 HeadingLevel::H6 => 6,
1785 }
1786 }
1787
1788 pub fn parse_sections(markdown: &str) -> Vec<Section> {
1792 parse_sections_with_stack(markdown, Vec::new()).0
1793 }
1794
1795 pub fn parse_sections_with_stack(
1800 markdown: &str,
1801 initial_stack: Vec<String>,
1802 ) -> (Vec<Section>, Vec<String>) {
1803 let mut heading_stack: Vec<String> = initial_stack;
1804 let mut sections: Vec<Section> = Vec::new();
1805 let mut current = Section {
1808 heading_path: heading_stack
1809 .iter()
1810 .filter(|h| !h.is_empty())
1811 .cloned()
1812 .collect(),
1813 words: Vec::new(),
1814 };
1815
1816 let mut in_heading = false;
1817 let mut heading_level = 0usize;
1818 let mut heading_buf = String::new();
1819
1820 let push_words = |section: &mut Section, text: &str| {
1821 for w in text.split_whitespace() {
1822 section.words.push(w.to_string());
1823 }
1824 };
1825
1826 let flush = |sections: &mut Vec<Section>, section: &mut Section| {
1827 if !section.words.is_empty() {
1828 sections.push(std::mem::take(section));
1829 } else {
1830 *section = Section::default();
1831 }
1832 };
1833
1834 for event in Parser::new(markdown) {
1835 match event {
1836 Event::Start(Tag::Heading { level, .. }) => {
1837 in_heading = true;
1838 heading_level = level_index(level);
1839 heading_buf.clear();
1840 }
1841 Event::End(TagEnd::Heading(_)) => {
1842 in_heading = false;
1843 let idx = heading_level.saturating_sub(1);
1845 if heading_stack.len() <= idx {
1846 heading_stack.resize(idx + 1, String::new());
1847 } else {
1848 heading_stack.truncate(idx + 1);
1849 }
1850 heading_stack[idx] = heading_buf.trim().to_string();
1851 flush(&mut sections, &mut current);
1853 current.heading_path = heading_stack
1854 .iter()
1855 .filter(|h| !h.is_empty())
1856 .cloned()
1857 .collect();
1858 }
1859 Event::Text(t) | Event::Code(t) => {
1860 if in_heading {
1861 if !heading_buf.is_empty() {
1862 heading_buf.push(' ');
1863 }
1864 heading_buf.push_str(&t);
1865 } else {
1866 push_words(&mut current, &t);
1867 }
1868 }
1869 Event::SoftBreak | Event::HardBreak | Event::Rule => {}
1871 _ => {}
1872 }
1873 }
1874 flush(&mut sections, &mut current);
1875 (sections, heading_stack)
1876 }
1877
1878 #[derive(Debug, Clone)]
1884 pub struct WindowChunker {
1885 pub max_words: usize,
1887 pub overlap: f32,
1889 }
1890
1891 impl Default for WindowChunker {
1892 fn default() -> Self {
1893 WindowChunker {
1894 max_words: 300,
1895 overlap: 0.05,
1896 }
1897 }
1898 }
1899
1900 impl WindowChunker {
1901 pub fn new(max_words: usize, overlap: f32) -> Self {
1902 WindowChunker { max_words, overlap }
1903 }
1904
1905 fn word_budget(&self) -> usize {
1907 self.max_words.max(1)
1908 }
1909
1910 fn overlap_words(&self, budget: usize) -> usize {
1913 let o = (budget as f32 * self.overlap).round() as usize;
1914 o.min(budget.saturating_sub(1))
1915 }
1916
1917 pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
1919 let mut chunks = Vec::new();
1920 self.chunk_with(markdown, &mut |c| {
1921 chunks.push(c);
1922 true
1923 });
1924 chunks
1925 }
1926
1927 pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
1931 let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
1932 for section in §ions {
1933 if !self.pack_section(section, sink) {
1934 return;
1935 }
1936 }
1937 }
1938
1939 pub fn pack_section(
1943 &self,
1944 section: &Section,
1945 sink: &mut dyn FnMut(DocChunk) -> bool,
1946 ) -> bool {
1947 let words = §ion.words;
1948 if words.is_empty() {
1949 return true;
1950 }
1951 let budget = self.word_budget();
1952 let step = budget - self.overlap_words(budget); let mut start = 0;
1954 loop {
1955 let end = (start + budget).min(words.len());
1956 let chunk = DocChunk {
1957 text: words[start..end].join(" "),
1958 headings: (!section.heading_path.is_empty())
1959 .then(|| section.heading_path.clone()),
1960 doc_items: Vec::new(),
1961 };
1962 if !sink(chunk) {
1963 return false;
1964 }
1965 if end >= words.len() {
1966 return true;
1967 }
1968 start += step;
1969 }
1970 }
1971
1972 pub fn contextualize(chunk: &DocChunk) -> String {
1978 match &chunk.headings {
1979 Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
1980 _ => chunk.text.clone(),
1981 }
1982 }
1983 }
1984
1985 #[cfg(test)]
1986 mod tests {
1987 use super::*;
1988
1989 #[test]
1990 fn splits_on_headings_and_tracks_path() {
1991 let md = "\
1992intro words
1993# Chapter 1
1994para one
1995## Section 1.1
1996para two
1997# Chapter 2
1998para three";
1999 let secs = parse_sections(md);
2000 assert_eq!(secs.len(), 4);
2002 assert!(secs[0].heading_path.is_empty());
2003 assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2004 assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2005 assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2007 }
2008
2009 #[test]
2010 fn strips_markup_to_plain_words() {
2011 let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2012 let secs = parse_sections(md);
2013 let words = &secs[0].words;
2014 assert!(words.contains(&"bold".to_string()));
2015 assert!(words.contains(&"code".to_string()));
2016 assert!(words.contains(&"link".to_string()));
2017 assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2019 }
2020
2021 #[test]
2022 fn windows_overlap_and_never_cross_headings() {
2023 let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2024 let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2025 let chunker = WindowChunker::new(10, 0.2); let chunks = chunker.chunk(&md);
2027 let a: Vec<_> = chunks
2029 .iter()
2030 .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2031 .collect();
2032 assert_eq!(a.len(), 3);
2033 assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2034 assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2035 assert!(a[2].text.ends_with(" w24"));
2036 let b: Vec<_> = chunks
2038 .iter()
2039 .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2040 .collect();
2041 assert_eq!(b.len(), 1);
2042 assert_eq!(b[0].text, "short tail");
2043 assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2044 }
2045
2046 #[test]
2047 fn sink_false_cancels_the_window_walk() {
2048 let md = format!(
2049 "# A\n\n{}\n",
2050 (0..50)
2051 .map(|i| format!("w{i}"))
2052 .collect::<Vec<_>>()
2053 .join(" ")
2054 );
2055 let chunker = WindowChunker::new(10, 0.0);
2056 let mut n = 0;
2057 chunker.chunk_with(&md, &mut |_| {
2058 n += 1;
2059 false
2060 });
2061 assert_eq!(n, 1);
2062 }
2063 }
2064}
2065
2066#[cfg(feature = "chunking")]
2067pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2068
2069#[cfg(test)]
2070mod tests {
2071 use super::*;
2072
2073 struct WordTok(usize);
2075 impl ChunkTokenizer for WordTok {
2076 fn count_tokens(&self, text: &str) -> usize {
2077 text.split_whitespace().count()
2078 }
2079 fn max_tokens(&self) -> usize {
2080 self.0
2081 }
2082 }
2083
2084 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2085 let mut d = DoclingDocument::new("t");
2086 for n in nodes {
2087 d.push(n);
2088 }
2089 d
2090 }
2091
2092 #[test]
2093 fn hierarchical_headings_and_items() {
2094 let doc = doc_with(vec![
2095 Node::Heading {
2096 level: 1,
2097 text: "Title".into(),
2098 },
2099 Node::Paragraph {
2100 text: "Intro".into(),
2101 },
2102 Node::Heading {
2103 level: 2,
2104 text: "Sec".into(),
2105 },
2106 Node::Paragraph {
2107 text: "Body".into(),
2108 },
2109 ]);
2110 let chunks = HierarchicalChunker.chunk(&doc);
2111 assert_eq!(chunks.len(), 2);
2112 assert_eq!(chunks[0].text, "Intro");
2113 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2114 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2115 assert_eq!(
2116 chunks[1].headings.as_deref(),
2117 Some(&["Title".into(), "Sec".into()][..])
2118 );
2119 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2120 }
2121
2122 #[test]
2123 fn heading_shadowing_prunes_deeper_levels() {
2124 let doc = doc_with(vec![
2125 Node::Heading {
2126 level: 2,
2127 text: "A".into(),
2128 },
2129 Node::Heading {
2130 level: 3,
2131 text: "A.1".into(),
2132 },
2133 Node::Heading {
2134 level: 2,
2135 text: "B".into(),
2136 },
2137 Node::Paragraph { text: "p".into() },
2138 ]);
2139 let chunks = HierarchicalChunker.chunk(&doc);
2140 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2141 }
2142
2143 #[test]
2144 fn triplet_table() {
2145 let t = Table {
2146 rows: vec![
2147 vec!["".into(), "Col1".into()],
2148 vec!["Row1".into(), "v".into()],
2149 ],
2150 ..Default::default()
2151 };
2152 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2153 let single = Table {
2156 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2157 ..Default::default()
2158 };
2159 assert_eq!(triplet_table_text(&single), "a = b");
2160 }
2161
2162 #[test]
2163 fn hybrid_merges_small_peers_and_splits_large() {
2164 let doc = doc_with(vec![
2165 Node::Heading {
2166 level: 2,
2167 text: "S".into(),
2168 },
2169 Node::Paragraph { text: "a b".into() },
2170 Node::Paragraph { text: "c d".into() },
2171 ]);
2172 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2173 assert_eq!(chunks.len(), 1, "peers under one heading merge");
2174 assert_eq!(chunks[0].text, "a b\nc d");
2175
2176 let long = "w ".repeat(40).trim().to_string();
2177 let doc = doc_with(vec![Node::Paragraph { text: long }]);
2178 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2179 assert!(chunks.len() > 1, "oversized paragraph splits");
2180 for c in &chunks {
2181 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2182 }
2183 }
2184
2185 #[test]
2186 fn semchunk_prefers_newlines_then_sentences() {
2187 let tok = WordTok(4);
2188 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2189 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2190 }
2191}