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 w = Walker::default();
92 w.walk(&doc.nodes);
93 w.chunks
94 }
95}
96
97#[derive(Debug, Default)]
100struct Alloc {
101 texts: usize,
102 groups: usize,
103 tables: usize,
104 pictures: usize,
105 field_regions: usize,
106 field_items: usize,
107}
108
109impl Alloc {
110 fn text(&mut self) -> String {
111 let r = format!("#/texts/{}", self.texts);
112 self.texts += 1;
113 r
114 }
115 fn group(&mut self) -> String {
116 let r = format!("#/groups/{}", self.groups);
117 self.groups += 1;
118 r
119 }
120 fn table(&mut self) -> String {
121 let r = format!("#/tables/{}", self.tables);
122 self.tables += 1;
123 r
124 }
125 fn picture(&mut self) -> String {
126 let r = format!("#/pictures/{}", self.pictures);
127 self.pictures += 1;
128 r
129 }
130 fn field_region(&mut self) -> String {
131 let r = format!("#/field_regions/{}", self.field_regions);
132 self.field_regions += 1;
133 r
134 }
135 fn field_item(&mut self) -> String {
136 let r = format!("#/field_items/{}", self.field_items);
137 self.field_items += 1;
138 r
139 }
140}
141
142#[derive(Debug, Default)]
143struct Walker {
144 alloc: Alloc,
145 headings: BTreeMap<u8, String>,
148 chunks: Vec<DocChunk>,
149}
150
151impl Walker {
152 fn emit(&mut self, text: String, doc_items: Vec<ChunkItem>) {
153 if text.is_empty() {
154 return;
155 }
156 let headings: Vec<String> = self.headings.values().cloned().collect();
157 self.chunks.push(DocChunk {
158 text,
159 headings: (!headings.is_empty()).then_some(headings),
160 doc_items,
161 });
162 }
163
164 fn emit_inline(&mut self, md_text: &str, self_ref: String) {
168 self.emit_inline_with_runs(md_text, self_ref, &[]);
169 }
170
171 fn emit_inline_with_runs(
172 &mut self,
173 md_text: &str,
174 self_ref: String,
175 runs: &[crate::InlineRun],
176 ) {
177 let body = unescape_text(md_text);
178 if body.is_empty() {
179 return;
180 }
181 let segments: Vec<String> = inline_segments_tagged(md_text)
182 .into_iter()
183 .flat_map(|(text, is_plain)| {
184 if is_plain {
185 if let Some(split) = split_plain_by_runs(&text, runs) {
186 return split;
187 }
188 }
189 vec![text]
190 })
191 .collect();
192 let items: Vec<ChunkItem> = if segments.len() <= 1 {
193 vec![ChunkItem {
194 self_ref,
195 kind: ChunkItemKind::Text,
196 text: body.clone(),
197 }]
198 } else {
199 segments
200 .into_iter()
201 .map(|text| ChunkItem {
202 self_ref: self_ref.clone(),
203 kind: ChunkItemKind::Text,
204 text,
205 })
206 .collect()
207 };
208 self.emit(body, items);
209 }
210
211 fn set_heading(&mut self, doc_level: u8, text: String) {
212 self.headings.retain(|k, _| *k < doc_level);
213 self.headings.insert(doc_level, text);
214 }
215
216 fn walk(&mut self, nodes: &[Node]) {
217 let mut i = 0;
218 while i < nodes.len() {
219 if matches!(nodes[i], Node::ListItem { .. }) {
220 let start = i;
221 i += 1;
222 loop {
223 match nodes.get(i) {
224 Some(Node::ListItem { .. }) => i += 1,
225 Some(Node::Paragraph { text })
228 if text.is_empty()
229 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
230 {
231 i += 1
232 }
233 _ => break,
234 }
235 }
236 self.sibling_lists(&nodes[start..i]);
237 } else {
238 self.one(&nodes[i]);
239 i += 1;
240 }
241 }
242 }
243
244 fn sibling_lists(&mut self, run: &[Node]) {
248 let base = level_of(&run[0]);
249 let mut seg = 0;
250 let mut prev: Option<(bool, u64)> = None;
251 for k in 0..run.len() {
252 let Node::ListItem {
253 ordered,
254 number,
255 first_in_list,
256 level,
257 ..
258 } = &run[k]
259 else {
260 continue;
261 };
262 if *level != base {
263 continue; }
265 if k > seg {
266 if let Some((po, pn)) = prev {
267 if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
268 self.list(&run[seg..k]);
269 seg = k;
270 }
271 }
272 }
273 prev = Some((*ordered, *number));
274 }
275 self.list(&run[seg..]);
276 }
277
278 fn list(&mut self, items: &[Node]) {
282 self.alloc.group();
283 let mut chunk_items = Vec::new();
284 self.list_refs(items, &mut chunk_items);
285 let text = render_list(items);
286 self.emit(text, chunk_items);
287 }
288
289 fn list_refs(&mut self, items: &[Node], out: &mut Vec<ChunkItem>) {
293 let base = level_of(&items[0]);
294 let mut i = 0;
295 while i < items.len() {
296 let Node::ListItem {
297 ordered,
298 number,
299 text,
300 level,
301 layer,
302 ..
303 } = &items[i]
304 else {
305 i += 1;
306 continue;
307 };
308 if *level > base {
309 i += 1;
310 continue;
311 }
312 let item_ref = self.alloc.text();
313 let mut j = i + 1;
314 while j < items.len() && level_of(&items[j]) > base {
315 j += 1;
316 }
317 let has_nested = j > i + 1;
318 if layer.is_none() {
319 let marker = if *ordered {
320 format!("{number}.")
321 } else {
322 "-".to_string()
323 };
324 let segments = inline_segments(text);
328 if has_nested && segments.len() > 1 && text.contains("](") {
329 out.push(ChunkItem {
330 self_ref: item_ref.clone(),
331 kind: ChunkItemKind::Text,
332 text: format!("{marker} "),
333 });
334 for seg in segments {
335 out.push(ChunkItem {
336 self_ref: item_ref.clone(),
337 kind: ChunkItemKind::Text,
338 text: seg,
339 });
340 }
341 } else {
342 out.push(ChunkItem {
343 self_ref: item_ref.clone(),
344 kind: ChunkItemKind::Text,
345 text: format!("{marker} {}", unescape_text(text)),
346 });
347 }
348 }
349 if j > i + 1 {
352 self.nested_sibling_lists(&items[i + 1..j], out);
353 }
354 i = j;
355 }
356 }
357
358 fn nested_sibling_lists(&mut self, run: &[Node], out: &mut Vec<ChunkItem>) {
359 let base = level_of(&run[0]);
360 let mut seg = 0;
361 let mut prev: Option<(bool, u64)> = None;
362 for k in 0..run.len() {
363 let Node::ListItem {
364 ordered,
365 number,
366 first_in_list,
367 level,
368 ..
369 } = &run[k]
370 else {
371 continue;
372 };
373 if *level != base {
374 continue;
375 }
376 if k > seg {
377 if let Some((po, pn)) = prev {
378 if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
379 self.alloc.group();
380 self.list_refs(&run[seg..k], out);
381 seg = k;
382 }
383 }
384 }
385 prev = Some((*ordered, *number));
386 }
387 self.alloc.group();
388 self.list_refs(&run[seg..], out);
389 }
390
391 fn one(&mut self, node: &Node) {
392 match node {
393 Node::Heading { level, text } => {
394 let doc_level = if *level == 1 {
395 0
396 } else {
397 level.saturating_sub(1)
398 };
399 let self_ref = self.alloc.text();
400 let runs = crate::inline_runs_from_markdown(text);
406 if runs.len() <= 1 {
407 let plain = runs
408 .first()
409 .map(|r| r.text.clone())
410 .unwrap_or_else(|| text.clone());
411 self.set_heading(doc_level, unescape_text(&plain));
412 } else {
413 self.set_heading(doc_level, String::new());
414 let body = unescape_text(text);
415 self.emit(
416 body.clone(),
417 vec![ChunkItem {
418 self_ref,
419 kind: ChunkItemKind::Text,
420 text: body,
421 }],
422 );
423 }
424 }
425 Node::Paragraph { text } => {
426 let t = text.trim();
427 let self_ref = self.alloc.text();
428 if let Some(inner) = t
431 .strip_prefix("$$")
432 .and_then(|s| s.strip_suffix("$$"))
433 .filter(|s| !s.is_empty())
434 {
435 let body = format!("$${inner}$$");
436 self.emit(
437 body.clone(),
438 vec![ChunkItem {
439 self_ref,
440 kind: ChunkItemKind::Text,
441 text: body,
442 }],
443 );
444 return;
445 }
446 self.emit_inline(text, self_ref);
447 }
448 Node::CheckboxItem { checked, text } => {
449 let self_ref = self.alloc.text();
450 let mark = if *checked { "- [x] " } else { "- [ ] " };
451 let body = format!("{mark}{}", unescape_text(text));
452 self.emit(
453 body.clone(),
454 vec![ChunkItem {
455 self_ref,
456 kind: ChunkItemKind::Text,
457 text: body,
458 }],
459 );
460 }
461 Node::Code { text, .. } => {
462 let self_ref = self.alloc.text();
463 let body = format!("```\n{}\n```", unescape_text(text));
464 self.emit(
465 body.clone(),
466 vec![ChunkItem {
467 self_ref,
468 kind: ChunkItemKind::Text,
469 text: body,
470 }],
471 );
472 }
473 Node::Table(t) => {
474 let self_ref = self.alloc.table();
475 let body = triplet_table_text(t);
476 self.emit(
477 body.clone(),
478 vec![ChunkItem {
479 self_ref,
480 kind: ChunkItemKind::Table,
481 text: body,
482 }],
483 );
484 }
485 Node::Picture { caption, .. } => {
486 let cap = caption.as_deref().filter(|c| !c.is_empty());
487 let cap_item = cap.map(|c| ChunkItem {
488 self_ref: self.alloc.text(),
489 kind: ChunkItemKind::Text,
490 text: unescape_text(c),
491 });
492 self.alloc.picture();
493 if let Some(cap_item) = cap_item {
497 let body = cap_item.text.clone();
498 self.emit(body, vec![cap_item]);
499 }
500 }
501 Node::Chart {
502 kind,
503 table,
504 caption,
505 ..
506 } => {
507 let cap = caption.as_deref().filter(|c| !c.is_empty());
508 let cap_item = cap.map(|c| ChunkItem {
509 self_ref: self.alloc.text(),
510 kind: ChunkItemKind::Text,
511 text: unescape_text(c),
512 });
513 let pic_ref = self.alloc.picture();
514 let mut parts: Vec<String> = Vec::new();
518 if let Some(ci) = &cap_item {
519 parts.push(ci.text.clone());
520 }
521 parts.push(humanize_label(kind));
522 let grid = crate::markdown::render_table(table, false);
523 if !grid.is_empty() {
524 parts.push(unescape_text(&grid));
525 }
526 let body = parts.join("\n\n");
527 let pic_item = ChunkItem {
532 self_ref: pic_ref,
533 kind: ChunkItemKind::Picture,
534 text: body.clone(),
535 };
536 let items = match cap_item {
537 Some(mut ci) => {
538 ci.text = String::new();
539 vec![ci, pic_item]
540 }
541 None => vec![pic_item],
542 };
543 self.emit(body, items);
544 }
545 Node::Group { children, .. } => {
546 self.alloc.group();
549 self.walk(children);
550 }
551 Node::FieldRegion { items } => {
552 self.alloc.field_region();
555 for item in items {
556 self.alloc.field_item();
557 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
558 let self_ref = self.alloc.text();
559 let body = unescape_text(part);
560 self.emit(
561 body.clone(),
562 vec![ChunkItem {
563 self_ref,
564 kind: ChunkItemKind::Text,
565 text: body,
566 }],
567 );
568 }
569 }
570 }
571 Node::InlineGroup { md_text, runs, .. } => {
572 let self_ref = self.alloc.text();
573 self.emit_inline_with_runs(md_text, self_ref, runs);
574 }
575 Node::TextDump(text) => {
576 let self_ref = self.alloc.text();
577 let body = unescape_text(text);
578 self.emit(
579 body.clone(),
580 vec![ChunkItem {
581 self_ref,
582 kind: ChunkItemKind::Text,
583 text: body,
584 }],
585 );
586 }
587 Node::Located { inner, .. } => self.one(inner),
589 Node::Furniture { .. }
592 | Node::PageFurniture { .. }
593 | Node::PageBreak
594 | Node::DoclangOnly(_) => {}
595 Node::ListItem { .. } => unreachable!("list items are chunked in runs"),
596 }
597 }
598}
599
600fn level_of(node: &Node) -> u8 {
601 match node {
602 Node::ListItem { level, .. } => *level,
603 _ => 0,
604 }
605}
606
607fn render_list(items: &[Node]) -> String {
610 let mut lines: Vec<String> = Vec::new();
611 for item in items {
612 let Node::ListItem {
613 ordered,
614 number,
615 text,
616 level,
617 layer,
618 ..
619 } = item
620 else {
621 continue;
622 };
623 if layer.is_some() {
624 continue;
625 }
626 let indent = " ".repeat(*level as usize);
627 let marker = if *ordered {
628 format!("{number}.")
629 } else {
630 "-".to_string()
631 };
632 lines.push(format!("{indent}{marker} {}", unescape_text(text)));
633 }
634 lines.join("\n")
635}
636
637fn humanize_label(label: &str) -> String {
640 let text = label.replace('_', " ");
641 let mut chars = text.chars();
642 match chars.next() {
643 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
644 None => text,
645 }
646}
647
648fn triplet_table_text(t: &Table) -> String {
655 let rows: Vec<Vec<String>> = t
656 .rows
657 .iter()
658 .enumerate()
659 .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
660 .collect();
661 let num_rows = rows.len();
662 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
663 if num_rows == 0 || num_cols == 0 {
664 return String::new();
665 }
666 let cell = |r: usize, c: usize| -> &str {
667 rows.get(r)
668 .and_then(|row| row.get(c))
669 .map(String::as_str)
670 .unwrap_or("")
671 };
672
673 let cell_is_header = |r: usize, c: usize| -> bool {
677 let (mut r, mut c) = (r, c);
678 loop {
679 match &t.structure {
680 Some(s) if !s.col_header.is_empty() => {
681 return s
682 .col_header
683 .get(r)
684 .and_then(|row| row.get(c))
685 .copied()
686 .unwrap_or(false)
687 }
688 Some(s) => {
689 let cont = |g: &Vec<Vec<bool>>| {
690 g.get(r)
691 .and_then(|row| row.get(c))
692 .copied()
693 .unwrap_or(false)
694 };
695 if r > 0 && cont(&s.row_continuation) {
696 r -= 1;
697 continue;
698 }
699 if c > 0 && cont(&s.col_continuation) {
700 c -= 1;
701 continue;
702 }
703 return if s.header_row.is_empty() {
704 r == 0
705 } else {
706 s.header_row.get(r).copied().unwrap_or(false)
707 };
708 }
709 None => return r == 0,
710 }
711 }
712 };
713 let row_is_header = |r: usize| (0..num_cols).any(|c| cell_is_header(r, c));
714 let num_headers = (0..num_rows).take_while(|r| row_is_header(*r)).count();
715
716 let columns: Vec<String> = if num_headers > 0 {
719 (0..num_cols)
720 .map(|c| {
721 let mut name = String::new();
722 for r in 0..num_headers {
723 if !name.is_empty() {
724 name.push('.');
725 }
726 name.push_str(cell(r, c));
727 }
728 name
729 })
730 .collect()
731 } else {
732 (0..num_cols).map(|c| c.to_string()).collect()
733 };
734 let data_rows = num_headers..num_rows;
735 let n_data = data_rows.len();
736
737 if n_data == 0 {
739 return columns
740 .iter()
741 .map(|s| s.trim())
742 .filter(|s| !s.is_empty())
743 .collect::<Vec<_>>()
744 .join(". ");
745 }
746
747 let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
748 let text = if num_cols == 1 {
749 let col_name = data(0, 0).trim().to_string();
752 if n_data == 1 {
753 col_name
754 } else {
755 (1..n_data)
756 .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
757 .collect::<Vec<_>>()
758 .join(". ")
759 }
760 } else {
761 let mut parts = Vec::new();
763 for r in 0..n_data {
764 for (c, col_name) in columns.iter().enumerate().skip(1) {
765 parts.push(format!(
766 "{}, {} = {}",
767 data(r, 0).trim(),
768 col_name.trim(),
769 data(r, c).trim()
770 ));
771 }
772 }
773 parts.join(". ")
774 };
775 if !text.is_empty() {
776 return text;
777 }
778
779 (0..n_data)
782 .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
783 .map(|(r, c)| data(r, c).trim())
784 .filter(|s| !s.is_empty())
785 .collect::<Vec<_>>()
786 .join(". ")
787}
788
789fn inline_segments(md: &str) -> Vec<String> {
797 inline_segments_tagged(md)
798 .into_iter()
799 .map(|(t, _)| t)
800 .collect()
801}
802
803fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
807 let chars: Vec<char> = md.chars().collect();
808 let n = chars.len();
809 let find = |from: usize, pat: &str| -> Option<usize> {
810 let hay: String = chars[from..].iter().collect();
811 hay.find(pat).map(|p| from + hay[..p].chars().count())
812 };
813 let mut out: Vec<(String, bool)> = Vec::new();
814 let mut plain = String::new();
815 let mut after_span = false;
816
817 fn flush(
818 out: &mut Vec<(String, bool)>,
819 plain: &mut String,
820 before_span: bool,
821 after_span: bool,
822 ) {
823 let mut p = std::mem::take(plain);
824 if after_span {
825 if let Some(rest) = p.strip_prefix(' ') {
826 p = rest.to_string();
827 }
828 }
829 if before_span {
830 if let Some(rest) = p.strip_suffix(' ') {
831 p = rest.to_string();
832 }
833 }
834 if !p.is_empty() {
835 out.push((unescape_text(&p), true));
836 }
837 }
838
839 let mut i = 0;
840 while i < n {
841 let rest: String = chars[i..].iter().collect();
842 if chars[i] == '[' && !rest.starts_with("[](") {
844 if let Some(close) = find(i + 1, "](") {
845 if let Some(endp) = find(close + 2, ")") {
846 flush(&mut out, &mut plain, true, after_span);
847 out.push((
848 unescape_text(&chars[i..=endp].iter().collect::<String>()),
849 false,
850 ));
851 i = endp + 1;
852 after_span = true;
853 continue;
854 }
855 }
856 }
857 let mut matched = false;
860 for marker in ["***", "**", "*", "~~", "`"] {
861 if rest.starts_with(marker) {
862 let mlen = marker.chars().count();
863 if let Some(end) = find(i + mlen, marker) {
864 if end > i + mlen {
865 flush(&mut out, &mut plain, true, after_span);
866 if marker == "`" {
867 let inner: String = chars[i + 1..end].iter().collect();
868 out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
869 } else {
870 out.push((
871 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
872 false,
873 ));
874 }
875 i = end + mlen;
876 after_span = true;
877 matched = true;
878 }
879 }
880 break;
881 }
882 }
883 if matched {
884 continue;
885 }
886 if rest.starts_with("$$") {
889 plain.push_str("$$");
890 i += 2;
891 continue;
892 }
893 if chars[i] == '$' {
895 if let Some(end) = find(i + 1, "$") {
896 if end > i + 1 {
897 flush(&mut out, &mut plain, true, after_span);
898 let latex: String = chars[i + 1..end].iter().collect();
899 out.push((format!("$${latex}$$"), false));
900 i = end + 1;
901 after_span = true;
902 continue;
903 }
904 }
905 }
906 plain.push(chars[i]);
907 i += 1;
908 }
909 flush(&mut out, &mut plain, false, after_span);
910 if out.is_empty() {
911 out.push((unescape_text(md), true));
912 }
913 out
914}
915
916fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
921 let target = segment.trim();
922 if target.is_empty() {
923 return None;
924 }
925 let unmarked: Vec<&str> = runs
926 .iter()
927 .filter(|r| !r.bold && !r.italic && !r.strike && !r.code && !r.formula)
928 .map(|r| r.text.as_str())
929 .collect();
930 for start in 0..unmarked.len() {
931 let mut rest = target;
932 let mut taken: Vec<String> = Vec::new();
933 for t in &unmarked[start..] {
934 let t = t.trim();
935 if t.is_empty() {
936 continue;
937 }
938 match rest.strip_prefix(t) {
939 Some(r) => {
940 taken.push(unescape_text(t));
941 rest = r.trim_start();
942 if rest.is_empty() {
943 break;
944 }
945 }
946 None => break,
947 }
948 }
949 if rest.is_empty() && taken.len() >= 2 {
950 return Some(taken);
951 }
952 }
953 None
954}
955
956fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
963 if let Some(blocks) = t
964 .cell_blocks
965 .as_ref()
966 .and_then(|b| b.get(r))
967 .and_then(|row| row.get(c))
968 .filter(|b| !b.is_empty())
969 {
970 let mut parts: Vec<String> = Vec::new();
971 for node in blocks.iter() {
972 let part = block_chunk_text(node);
973 if !part.is_empty() {
974 parts.push(part);
975 }
976 }
977 return parts.join("\n\n");
978 }
979 let flat = t
980 .rows
981 .get(r)
982 .and_then(|row| row.get(c))
983 .map(String::as_str)
984 .unwrap_or("");
985 unescape_text(flat)
986 .replace("<!-- image -->", "")
987 .trim()
988 .to_string()
989}
990
991fn block_chunk_text(node: &Node) -> String {
993 match node {
994 Node::Paragraph { text } => unescape_text(text),
995 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
996 Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
997 Node::Table(inner) => triplet_table_text(inner),
998 Node::Picture { caption, .. } => caption
999 .as_deref()
1000 .filter(|c| !c.is_empty())
1001 .map(unescape_text)
1002 .unwrap_or_default(),
1003 Node::ListItem {
1004 ordered,
1005 number,
1006 text,
1007 ..
1008 } => {
1009 let marker = if *ordered {
1010 format!("{number}.")
1011 } else {
1012 "-".to_string()
1013 };
1014 format!("{marker} {}", unescape_text(text))
1015 }
1016 Node::CheckboxItem { checked, text } => {
1017 let mark = if *checked { "- [x] " } else { "- [ ] " };
1018 format!("{mark}{}", unescape_text(text))
1019 }
1020 Node::Heading { text, .. } => unescape_text(text),
1021 Node::Located { inner, .. } => block_chunk_text(inner),
1022 Node::Group { children, .. } => children
1023 .iter()
1024 .map(block_chunk_text)
1025 .filter(|s| !s.is_empty())
1026 .collect::<Vec<_>>()
1027 .join("\n"),
1028 _ => String::new(),
1029 }
1030}
1031
1032fn unescape_text(s: &str) -> String {
1035 s.replace("<", "<")
1036 .replace(">", ">")
1037 .replace("&", "&")
1038 .replace("\\_", "_")
1039}
1040
1041pub trait ChunkTokenizer {
1047 fn count_tokens(&self, text: &str) -> usize;
1049 fn max_tokens(&self) -> usize;
1051}
1052
1053pub struct HybridChunker<T: ChunkTokenizer> {
1057 tokenizer: T,
1058 merge_peers: bool,
1059}
1060
1061impl<T: ChunkTokenizer> HybridChunker<T> {
1062 pub fn new(tokenizer: T) -> Self {
1063 Self {
1064 tokenizer,
1065 merge_peers: true,
1066 }
1067 }
1068
1069 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1071 self.merge_peers = merge_peers;
1072 self
1073 }
1074
1075 pub fn max_tokens(&self) -> usize {
1076 self.tokenizer.max_tokens()
1077 }
1078
1079 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1081 let chunks = HierarchicalChunker.chunk(doc);
1082 let chunks: Vec<DocChunk> = chunks
1083 .into_iter()
1084 .flat_map(|c| self.split_by_doc_items(c))
1085 .collect();
1086 let chunks: Vec<DocChunk> = chunks
1087 .into_iter()
1088 .flat_map(|c| self.split_using_plain_text(c))
1089 .collect();
1090 if self.merge_peers {
1091 self.merge_matching(chunks)
1092 } else {
1093 chunks
1094 }
1095 }
1096
1097 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1098 self.tokenizer.count_tokens(&contextualize(chunk))
1099 }
1100
1101 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1104 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1105 let text = if chunk.doc_items.len() == 1 {
1106 chunk.text.clone()
1107 } else {
1108 doc_items
1109 .iter()
1110 .filter(|it| !it.text.is_empty())
1111 .map(|it| it.text.as_str())
1112 .collect::<Vec<_>>()
1113 .join("\n")
1114 };
1115 DocChunk {
1116 text,
1117 headings: chunk.headings.clone(),
1118 doc_items,
1119 }
1120 }
1121
1122 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1123 if chunk.doc_items.is_empty() {
1124 return vec![chunk];
1125 }
1126 let max = self.max_tokens();
1127 let num_items = chunk.doc_items.len();
1128 let mut chunks = Vec::new();
1129 let mut window_start = 0usize;
1130 let mut window_end = 0usize; while window_end < num_items {
1132 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1133 if self.count_chunk_tokens(&new_chunk) <= max {
1134 if window_end < num_items - 1 {
1135 window_end += 1;
1136 continue;
1137 } else {
1138 window_end = num_items; }
1140 } else if window_start == window_end {
1141 window_end += 1;
1144 window_start = window_end;
1145 } else {
1146 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1149 window_start = window_end;
1150 }
1151 chunks.push(new_chunk);
1152 }
1153 chunks
1154 }
1155
1156 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1157 let total = self.count_chunk_tokens(&chunk);
1158 let max = self.max_tokens();
1159 if total <= max {
1160 return vec![chunk];
1161 }
1162 let text_len = self.tokenizer.count_tokens(&chunk.text);
1163 let other_len = total - text_len;
1164 if other_len >= max {
1165 let stripped = DocChunk {
1167 headings: None,
1168 ..chunk
1169 };
1170 return self.split_using_plain_text(stripped);
1171 }
1172 let available = max - other_len;
1173
1174 let segments =
1175 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1176 let lines: Vec<String> = chunk
1183 .text
1184 .split('\n')
1185 .filter(|l| !l.trim().is_empty())
1186 .map(|l| l.to_string())
1187 .collect();
1188 line_chunk_text(&lines, &self.tokenizer, max)
1189 } else {
1190 semchunk(&chunk.text, available, &self.tokenizer)
1191 };
1192 segments
1193 .into_iter()
1194 .map(|s| DocChunk {
1195 text: s,
1196 headings: chunk.headings.clone(),
1197 doc_items: chunk.doc_items.clone(),
1198 })
1199 .collect()
1200 }
1201
1202 fn merge_matching(&self, chunks: Vec<DocChunk>) -> Vec<DocChunk> {
1204 let max = self.max_tokens();
1205 let num = chunks.len();
1206 let mut out = Vec::new();
1207 let mut window_start = 0usize;
1208 let mut window_end = 0usize;
1209 let mut current_headings: Option<Vec<String>> = None;
1210 let mut merged: Option<DocChunk> = None;
1211 while window_end < num {
1212 let chunk = &chunks[window_end];
1213 let mut ready_to_append = false;
1214 if window_start == window_end {
1215 current_headings = chunk.headings.clone();
1216 window_end += 1;
1217 } else {
1218 let window = &chunks[window_start..=window_end];
1219 let candidate = DocChunk {
1220 text: window
1221 .iter()
1222 .map(|c| c.text.as_str())
1223 .collect::<Vec<_>>()
1224 .join("\n"),
1225 headings: current_headings.clone(),
1226 doc_items: window
1227 .iter()
1228 .flat_map(|c| c.doc_items.iter().cloned())
1229 .collect(),
1230 };
1231 if chunk.headings == current_headings && self.count_chunk_tokens(&candidate) <= max
1232 {
1233 window_end += 1;
1234 merged = Some(candidate);
1235 } else {
1236 ready_to_append = true;
1237 }
1238 }
1239 if ready_to_append || window_end == num {
1240 if window_start + 1 == window_end {
1241 out.push(chunks[window_start].clone());
1242 } else {
1243 out.push(merged.take().expect("multi-chunk window has a merge"));
1244 }
1245 window_start = window_end;
1246 }
1247 }
1248 out
1249 }
1250}
1251
1252fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1262 let mut chunks: Vec<String> = Vec::new();
1263 let mut current = String::new();
1264 let mut current_len = 0usize;
1265
1266 for line in lines {
1267 let mut remaining: Vec<char> = line.chars().collect();
1268 loop {
1269 let rem_str: String = remaining.iter().collect();
1270 let line_tokens = tok.count_tokens(&rem_str);
1271 let available = max_tokens.saturating_sub(current_len);
1272
1273 if line_tokens <= available {
1274 current.push_str(&rem_str);
1275 current_len += line_tokens;
1276 break;
1277 }
1278 if line_tokens <= max_tokens {
1279 chunks.push(std::mem::take(&mut current));
1280 current_len = 0;
1281 continue;
1282 }
1283 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1285 let mut rest = rest;
1286 if take.is_empty() {
1287 if rest.is_empty() {
1288 break;
1289 }
1290 take = rest[..1].iter().collect();
1291 rest = rest[1..].to_vec();
1292 }
1293 current.push('\n');
1294 current.push_str(&take);
1295 chunks.push(std::mem::take(&mut current));
1296 current_len = 0;
1297 remaining = rest;
1298 }
1299 }
1300 if !current.is_empty() {
1301 chunks.push(current);
1302 }
1303 chunks
1304}
1305
1306fn split_by_token_limit<T: ChunkTokenizer>(
1310 text: &[char],
1311 token_limit: usize,
1312 tok: &T,
1313) -> (String, Vec<char>) {
1314 if token_limit == 0 || text.is_empty() {
1315 return (String::new(), text.to_vec());
1316 }
1317 let full: String = text.iter().collect();
1318 if tok.count_tokens(&full) <= token_limit {
1319 return (full, Vec::new());
1320 }
1321 let (mut lo, mut hi) = (0usize, text.len());
1322 let mut best: Option<usize> = None;
1323 while lo <= hi {
1324 let mid = (lo + hi) / 2;
1325 let head: String = text[..mid].iter().collect();
1326 if tok.count_tokens(&head) <= token_limit {
1327 best = Some(mid);
1328 lo = mid + 1;
1329 } else {
1330 if mid == 0 {
1331 break;
1332 }
1333 hi = mid - 1;
1334 }
1335 }
1336 let mut best_idx = match best {
1337 Some(b) if b > 0 => b,
1338 _ => return (String::new(), text.to_vec()),
1339 };
1340 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1342 if pos > 0 {
1343 best_idx = pos;
1344 }
1345 }
1346 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1347}
1348
1349const NON_WS_SPLITTERS: &[&str] = &[
1355 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1356 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1357];
1358
1359pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1363 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1364 let mut counter = |s: &str| -> usize {
1365 if let Some(n) = cache.get(s) {
1366 return *n;
1367 }
1368 let n = tok.count_tokens(s);
1369 cache.insert(s.to_string(), n);
1370 n
1371 };
1372 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1373 chunks
1375 .into_iter()
1376 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1377 .collect()
1378}
1379
1380fn semchunk_rec(
1383 text: &str,
1384 chunk_size: usize,
1385 counter: &mut dyn FnMut(&str) -> usize,
1386) -> Vec<String> {
1387 let (splitter, splitter_is_ws, splits) = split_text(text);
1388
1389 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1390 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1391 cum_lens.push(0usize);
1392 for l in &split_lens {
1393 cum_lens.push(cum_lens.last().unwrap() + l);
1394 }
1395 let num_splits_plus_one = splits.len() + 1;
1396
1397 let mut chunks: Vec<String> = Vec::new();
1398 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1399
1400 for i in 0..splits.len() {
1401 if skips.contains(&i) {
1402 continue;
1403 }
1404 let split = &splits[i];
1405 if counter(split) > chunk_size {
1406 let inner = semchunk_rec(split, chunk_size, counter);
1407 chunks.extend(inner);
1408 } else {
1409 let (end, merged) = merge_splits(
1410 &splits,
1411 &cum_lens,
1412 chunk_size,
1413 &splitter,
1414 counter,
1415 i,
1416 num_splits_plus_one,
1417 );
1418 for j in (i + 1)..end {
1419 skips.insert(j);
1420 }
1421 chunks.push(merged);
1422 }
1423 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1426 if !splitter_is_ws && !is_last {
1427 let with_splitter = format!(
1428 "{}{}",
1429 chunks.last().map(String::as_str).unwrap_or(""),
1430 splitter
1431 );
1432 if counter(&with_splitter) <= chunk_size {
1433 if let Some(last) = chunks.last_mut() {
1434 *last = with_splitter;
1435 } else {
1436 chunks.push(with_splitter);
1437 }
1438 } else {
1439 chunks.push(splitter.clone());
1440 }
1441 }
1442 }
1443 chunks
1444}
1445
1446fn merge_splits(
1449 splits: &[String],
1450 cum_lens: &[usize],
1451 chunk_size: usize,
1452 splitter: &str,
1453 counter: &mut dyn FnMut(&str) -> usize,
1454 start: usize,
1455 high_init: usize,
1456) -> (usize, String) {
1457 let mut average = 0.2f64;
1458 let mut low = start;
1459 let mut high = high_init;
1460 let offset = cum_lens[start];
1461 let mut target = offset as f64 + (chunk_size as f64 * average);
1462
1463 while low < high {
1464 let i = bisect_left(cum_lens, target, low, high);
1465 let midpoint = i.min(high - 1);
1466 let joined = splits[start..midpoint.max(start)].join(splitter);
1467 let tokens = counter(&joined);
1468 let local_cum = cum_lens[midpoint] - offset;
1469 if local_cum > 0 && tokens > 0 {
1470 average = local_cum as f64 / tokens as f64;
1471 target = offset as f64 + (chunk_size as f64 * average);
1472 }
1473 if tokens > chunk_size {
1474 high = midpoint;
1475 } else {
1476 low = midpoint + 1;
1477 }
1478 }
1479 let end = low - 1;
1480 (end, splits[start..end.max(start)].join(splitter))
1481}
1482
1483fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1484 while low < high {
1485 let mid = (low + high) / 2;
1486 if (sorted[mid] as f64) < target {
1487 low = mid + 1;
1488 } else {
1489 high = mid;
1490 }
1491 }
1492 low
1493}
1494
1495fn split_text(text: &str) -> (String, bool, Vec<String>) {
1497 if text.contains('\n') || text.contains('\r') {
1499 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1500 return (splitter.clone(), true, split_on(text, &splitter));
1501 }
1502 if text.contains('\t') {
1504 let splitter = longest_run(text, |c| c == '\t');
1505 return (splitter.clone(), true, split_on(text, &splitter));
1506 }
1507 if text.chars().any(char::is_whitespace) {
1509 let splitter = longest_run(text, char::is_whitespace);
1510 if splitter.chars().count() == 1 {
1511 for preceder in NON_WS_SPLITTERS {
1513 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1514 return (ws, true, parts);
1515 }
1516 }
1517 }
1518 return (splitter.clone(), true, split_on(text, &splitter));
1519 }
1520 for s in NON_WS_SPLITTERS {
1522 if text.contains(s) {
1523 return (s.to_string(), false, split_on(text, s));
1524 }
1525 }
1526 (
1528 String::new(),
1529 true,
1530 text.chars().map(|c| c.to_string()).collect(),
1531 )
1532}
1533
1534fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1536 let mut best = String::new();
1537 let mut cur = String::new();
1538 for c in text.chars() {
1539 if pred(c) {
1540 cur.push(c);
1541 } else {
1542 if cur.chars().count() > best.chars().count() {
1543 best = cur.clone();
1544 }
1545 cur.clear();
1546 }
1547 }
1548 if cur.chars().count() > best.chars().count() {
1549 best = cur;
1550 }
1551 best
1552}
1553
1554fn split_on(text: &str, splitter: &str) -> Vec<String> {
1555 text.split(splitter).map(str::to_string).collect()
1556}
1557
1558fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1562 let chars: Vec<char> = text.chars().collect();
1563 let p: Vec<char> = preceder.chars().collect();
1564 let mut ws: Option<char> = None;
1565 for i in p.len()..chars.len() {
1566 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1567 ws = Some(chars[i]);
1568 break;
1569 }
1570 }
1571 let ws = ws?;
1572 let mut parts = Vec::new();
1573 let mut cur = String::new();
1574 let mut i = 0usize;
1575 while i < chars.len() {
1576 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1577 parts.push(std::mem::take(&mut cur));
1578 i += 1;
1579 continue;
1580 }
1581 cur.push(chars[i]);
1582 i += 1;
1583 }
1584 parts.push(cur);
1585 Some((ws.to_string(), parts))
1586}
1587
1588#[cfg(feature = "chunking")]
1593mod hf {
1594 use super::ChunkTokenizer;
1595
1596 pub const DEFAULT_TOKENIZER_PATH: &str = "models/chunk/tokenizer.json";
1601
1602 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1607 if let Some(p) = explicit {
1608 return Ok(p.to_string());
1609 }
1610 if std::path::Path::new(DEFAULT_TOKENIZER_PATH).exists() {
1611 return Ok(DEFAULT_TOKENIZER_PATH.to_string());
1612 }
1613 Err(format!(
1614 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1615 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1616 scripts/install/download_dependencies.sh (or pass an explicit path)"
1617 ))
1618 }
1619
1620 pub struct HuggingFaceTokenizer {
1624 tok: tokenizers::Tokenizer,
1625 max_tokens: usize,
1626 }
1627
1628 impl HuggingFaceTokenizer {
1629 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1633 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1634 }
1635
1636 pub fn from_file(
1640 path: impl AsRef<std::path::Path>,
1641 max_tokens: usize,
1642 ) -> Result<Self, String> {
1643 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1644 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1645 let _ = tok.with_truncation(None);
1650 tok.with_padding(None);
1651 Ok(Self { tok, max_tokens })
1652 }
1653 }
1654
1655 impl ChunkTokenizer for HuggingFaceTokenizer {
1656 fn count_tokens(&self, text: &str) -> usize {
1657 self.tok
1658 .encode(text, false)
1659 .map(|e| e.get_tokens().len())
1660 .unwrap_or(0)
1661 }
1662 fn max_tokens(&self) -> usize {
1663 self.max_tokens
1664 }
1665 }
1666}
1667
1668#[cfg(feature = "chunking")]
1669pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1670
1671#[cfg(test)]
1672mod tests {
1673 use super::*;
1674
1675 struct WordTok(usize);
1677 impl ChunkTokenizer for WordTok {
1678 fn count_tokens(&self, text: &str) -> usize {
1679 text.split_whitespace().count()
1680 }
1681 fn max_tokens(&self) -> usize {
1682 self.0
1683 }
1684 }
1685
1686 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
1687 let mut d = DoclingDocument::new("t");
1688 for n in nodes {
1689 d.push(n);
1690 }
1691 d
1692 }
1693
1694 #[test]
1695 fn hierarchical_headings_and_items() {
1696 let doc = doc_with(vec![
1697 Node::Heading {
1698 level: 1,
1699 text: "Title".into(),
1700 },
1701 Node::Paragraph {
1702 text: "Intro".into(),
1703 },
1704 Node::Heading {
1705 level: 2,
1706 text: "Sec".into(),
1707 },
1708 Node::Paragraph {
1709 text: "Body".into(),
1710 },
1711 ]);
1712 let chunks = HierarchicalChunker.chunk(&doc);
1713 assert_eq!(chunks.len(), 2);
1714 assert_eq!(chunks[0].text, "Intro");
1715 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
1716 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
1717 assert_eq!(
1718 chunks[1].headings.as_deref(),
1719 Some(&["Title".into(), "Sec".into()][..])
1720 );
1721 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
1722 }
1723
1724 #[test]
1725 fn heading_shadowing_prunes_deeper_levels() {
1726 let doc = doc_with(vec![
1727 Node::Heading {
1728 level: 2,
1729 text: "A".into(),
1730 },
1731 Node::Heading {
1732 level: 3,
1733 text: "A.1".into(),
1734 },
1735 Node::Heading {
1736 level: 2,
1737 text: "B".into(),
1738 },
1739 Node::Paragraph { text: "p".into() },
1740 ]);
1741 let chunks = HierarchicalChunker.chunk(&doc);
1742 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
1743 }
1744
1745 #[test]
1746 fn triplet_table() {
1747 let t = Table {
1748 rows: vec![
1749 vec!["".into(), "Col1".into()],
1750 vec!["Row1".into(), "v".into()],
1751 ],
1752 ..Default::default()
1753 };
1754 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
1755 let single = Table {
1758 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
1759 ..Default::default()
1760 };
1761 assert_eq!(triplet_table_text(&single), "a = b");
1762 }
1763
1764 #[test]
1765 fn hybrid_merges_small_peers_and_splits_large() {
1766 let doc = doc_with(vec![
1767 Node::Heading {
1768 level: 2,
1769 text: "S".into(),
1770 },
1771 Node::Paragraph { text: "a b".into() },
1772 Node::Paragraph { text: "c d".into() },
1773 ]);
1774 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
1775 assert_eq!(chunks.len(), 1, "peers under one heading merge");
1776 assert_eq!(chunks[0].text, "a b\nc d");
1777
1778 let long = "w ".repeat(40).trim().to_string();
1779 let doc = doc_with(vec![Node::Paragraph { text: long }]);
1780 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
1781 assert!(chunks.len() > 1, "oversized paragraph splits");
1782 for c in &chunks {
1783 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
1784 }
1785 }
1786
1787 #[test]
1788 fn semchunk_prefers_newlines_then_sentences() {
1789 let tok = WordTok(4);
1790 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
1791 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
1792 }
1793}