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::Formula { latex, .. } => {
464 let self_ref = self.alloc.text();
465 let body = format!("$${}$$", latex);
466 self.emit(
467 body.clone(),
468 vec![ChunkItem {
469 self_ref,
470 kind: ChunkItemKind::Text,
471 text: body,
472 }],
473 );
474 }
475 Node::Code { text, .. } => {
476 let self_ref = self.alloc.text();
477 let body = format!("```\n{}\n```", unescape_text(text));
478 self.emit(
479 body.clone(),
480 vec![ChunkItem {
481 self_ref,
482 kind: ChunkItemKind::Text,
483 text: body,
484 }],
485 );
486 }
487 Node::Table(t) => {
488 let self_ref = self.alloc.table();
489 let body = triplet_table_text(t);
490 self.emit(
491 body.clone(),
492 vec![ChunkItem {
493 self_ref,
494 kind: ChunkItemKind::Table,
495 text: body,
496 }],
497 );
498 }
499 Node::Picture { caption, .. } => {
500 let cap = caption.as_deref().filter(|c| !c.is_empty());
501 let cap_item = cap.map(|c| ChunkItem {
502 self_ref: self.alloc.text(),
503 kind: ChunkItemKind::Text,
504 text: unescape_text(c),
505 });
506 self.alloc.picture();
507 if let Some(cap_item) = cap_item {
511 let body = cap_item.text.clone();
512 self.emit(body, vec![cap_item]);
513 }
514 }
515 Node::Chart {
516 kind,
517 table,
518 caption,
519 ..
520 } => {
521 let cap = caption.as_deref().filter(|c| !c.is_empty());
522 let cap_item = cap.map(|c| ChunkItem {
523 self_ref: self.alloc.text(),
524 kind: ChunkItemKind::Text,
525 text: unescape_text(c),
526 });
527 let pic_ref = self.alloc.picture();
528 let mut parts: Vec<String> = Vec::new();
532 if let Some(ci) = &cap_item {
533 parts.push(ci.text.clone());
534 }
535 parts.push(humanize_label(kind));
536 let grid = crate::markdown::render_table(table, false);
537 if !grid.is_empty() {
538 parts.push(unescape_text(&grid));
539 }
540 let body = parts.join("\n\n");
541 let pic_item = ChunkItem {
546 self_ref: pic_ref,
547 kind: ChunkItemKind::Picture,
548 text: body.clone(),
549 };
550 let items = match cap_item {
551 Some(mut ci) => {
552 ci.text = String::new();
553 vec![ci, pic_item]
554 }
555 None => vec![pic_item],
556 };
557 self.emit(body, items);
558 }
559 Node::Group { children, .. } => {
560 self.alloc.group();
563 self.walk(children);
564 }
565 Node::FieldRegion { items } => {
566 self.alloc.field_region();
569 for item in items {
570 self.alloc.field_item();
571 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
572 let self_ref = self.alloc.text();
573 let body = unescape_text(part);
574 self.emit(
575 body.clone(),
576 vec![ChunkItem {
577 self_ref,
578 kind: ChunkItemKind::Text,
579 text: body,
580 }],
581 );
582 }
583 }
584 }
585 Node::InlineGroup { md_text, runs, .. } => {
586 let self_ref = self.alloc.text();
587 self.emit_inline_with_runs(md_text, self_ref, runs);
588 }
589 Node::TextDump(text) => {
590 let self_ref = self.alloc.text();
591 let body = unescape_text(text);
592 self.emit(
593 body.clone(),
594 vec![ChunkItem {
595 self_ref,
596 kind: ChunkItemKind::Text,
597 text: body,
598 }],
599 );
600 }
601 Node::Located { inner, .. } => self.one(inner),
603 Node::Furniture { .. }
606 | Node::PageFurniture { .. }
607 | Node::PageBreak
608 | Node::DoclangOnly(_) => {}
609 Node::ListItem { .. } => unreachable!("list items are chunked in runs"),
610 }
611 }
612}
613
614fn level_of(node: &Node) -> u8 {
615 match node {
616 Node::ListItem { level, .. } => *level,
617 _ => 0,
618 }
619}
620
621fn render_list(items: &[Node]) -> String {
624 let mut lines: Vec<String> = Vec::new();
625 for item in items {
626 let Node::ListItem {
627 ordered,
628 number,
629 text,
630 level,
631 layer,
632 ..
633 } = item
634 else {
635 continue;
636 };
637 if layer.is_some() {
638 continue;
639 }
640 let indent = " ".repeat(*level as usize);
641 let marker = if *ordered {
642 format!("{number}.")
643 } else {
644 "-".to_string()
645 };
646 lines.push(format!("{indent}{marker} {}", unescape_text(text)));
647 }
648 lines.join("\n")
649}
650
651fn humanize_label(label: &str) -> String {
654 let text = label.replace('_', " ");
655 let mut chars = text.chars();
656 match chars.next() {
657 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
658 None => text,
659 }
660}
661
662fn triplet_table_text(t: &Table) -> String {
669 let rows: Vec<Vec<String>> = t
670 .rows
671 .iter()
672 .enumerate()
673 .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
674 .collect();
675 let num_rows = rows.len();
676 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
677 if num_rows == 0 || num_cols == 0 {
678 return String::new();
679 }
680 let cell = |r: usize, c: usize| -> &str {
681 rows.get(r)
682 .and_then(|row| row.get(c))
683 .map(String::as_str)
684 .unwrap_or("")
685 };
686
687 let cell_is_header = |r: usize, c: usize| -> bool {
691 let (mut r, mut c) = (r, c);
692 loop {
693 match &t.structure {
694 Some(s) if !s.col_header.is_empty() => {
695 return s
696 .col_header
697 .get(r)
698 .and_then(|row| row.get(c))
699 .copied()
700 .unwrap_or(false)
701 }
702 Some(s) => {
703 let cont = |g: &Vec<Vec<bool>>| {
704 g.get(r)
705 .and_then(|row| row.get(c))
706 .copied()
707 .unwrap_or(false)
708 };
709 if r > 0 && cont(&s.row_continuation) {
710 r -= 1;
711 continue;
712 }
713 if c > 0 && cont(&s.col_continuation) {
714 c -= 1;
715 continue;
716 }
717 return if s.header_row.is_empty() {
718 r == 0
719 } else {
720 s.header_row.get(r).copied().unwrap_or(false)
721 };
722 }
723 None => return r == 0,
724 }
725 }
726 };
727 let row_is_header = |r: usize| (0..num_cols).any(|c| cell_is_header(r, c));
728 let num_headers = (0..num_rows).take_while(|r| row_is_header(*r)).count();
729
730 let columns: Vec<String> = if num_headers > 0 {
733 (0..num_cols)
734 .map(|c| {
735 let mut name = String::new();
736 for r in 0..num_headers {
737 if !name.is_empty() {
738 name.push('.');
739 }
740 name.push_str(cell(r, c));
741 }
742 name
743 })
744 .collect()
745 } else {
746 (0..num_cols).map(|c| c.to_string()).collect()
747 };
748 let data_rows = num_headers..num_rows;
749 let n_data = data_rows.len();
750
751 if n_data == 0 {
753 return columns
754 .iter()
755 .map(|s| s.trim())
756 .filter(|s| !s.is_empty())
757 .collect::<Vec<_>>()
758 .join(". ");
759 }
760
761 let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
762 let text = if num_cols == 1 {
763 let col_name = data(0, 0).trim().to_string();
766 if n_data == 1 {
767 col_name
768 } else {
769 (1..n_data)
770 .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
771 .collect::<Vec<_>>()
772 .join(". ")
773 }
774 } else {
775 let mut parts = Vec::new();
777 for r in 0..n_data {
778 for (c, col_name) in columns.iter().enumerate().skip(1) {
779 parts.push(format!(
780 "{}, {} = {}",
781 data(r, 0).trim(),
782 col_name.trim(),
783 data(r, c).trim()
784 ));
785 }
786 }
787 parts.join(". ")
788 };
789 if !text.is_empty() {
790 return text;
791 }
792
793 (0..n_data)
796 .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
797 .map(|(r, c)| data(r, c).trim())
798 .filter(|s| !s.is_empty())
799 .collect::<Vec<_>>()
800 .join(". ")
801}
802
803fn inline_segments(md: &str) -> Vec<String> {
811 inline_segments_tagged(md)
812 .into_iter()
813 .map(|(t, _)| t)
814 .collect()
815}
816
817fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
821 let chars: Vec<char> = md.chars().collect();
822 let n = chars.len();
823 let find = |from: usize, pat: &str| -> Option<usize> {
824 let hay: String = chars[from..].iter().collect();
825 hay.find(pat).map(|p| from + hay[..p].chars().count())
826 };
827 let mut out: Vec<(String, bool)> = Vec::new();
828 let mut plain = String::new();
829 let mut after_span = false;
830
831 fn flush(
832 out: &mut Vec<(String, bool)>,
833 plain: &mut String,
834 before_span: bool,
835 after_span: bool,
836 ) {
837 let mut p = std::mem::take(plain);
838 if after_span {
839 if let Some(rest) = p.strip_prefix(' ') {
840 p = rest.to_string();
841 }
842 }
843 if before_span {
844 if let Some(rest) = p.strip_suffix(' ') {
845 p = rest.to_string();
846 }
847 }
848 if !p.is_empty() {
849 out.push((unescape_text(&p), true));
850 }
851 }
852
853 let mut i = 0;
854 while i < n {
855 let rest: String = chars[i..].iter().collect();
856 if chars[i] == '[' && !rest.starts_with("[](") {
858 if let Some(close) = find(i + 1, "](") {
859 if let Some(endp) = find(close + 2, ")") {
860 flush(&mut out, &mut plain, true, after_span);
861 out.push((
862 unescape_text(&chars[i..=endp].iter().collect::<String>()),
863 false,
864 ));
865 i = endp + 1;
866 after_span = true;
867 continue;
868 }
869 }
870 }
871 let mut matched = false;
874 for marker in ["***", "**", "*", "~~", "`"] {
875 if rest.starts_with(marker) {
876 let mlen = marker.chars().count();
877 if let Some(end) = find(i + mlen, marker) {
878 if end > i + mlen {
879 flush(&mut out, &mut plain, true, after_span);
880 if marker == "`" {
881 let inner: String = chars[i + 1..end].iter().collect();
882 out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
883 } else {
884 out.push((
885 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
886 false,
887 ));
888 }
889 i = end + mlen;
890 after_span = true;
891 matched = true;
892 }
893 }
894 break;
895 }
896 }
897 if matched {
898 continue;
899 }
900 if rest.starts_with("$$") {
903 plain.push_str("$$");
904 i += 2;
905 continue;
906 }
907 if chars[i] == '$' {
909 if let Some(end) = find(i + 1, "$") {
910 if end > i + 1 {
911 flush(&mut out, &mut plain, true, after_span);
912 let latex: String = chars[i + 1..end].iter().collect();
913 out.push((format!("$${latex}$$"), false));
914 i = end + 1;
915 after_span = true;
916 continue;
917 }
918 }
919 }
920 plain.push(chars[i]);
921 i += 1;
922 }
923 flush(&mut out, &mut plain, false, after_span);
924 if out.is_empty() {
925 out.push((unescape_text(md), true));
926 }
927 out
928}
929
930fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
935 let target = segment.trim();
936 if target.is_empty() {
937 return None;
938 }
939 let unmarked: Vec<&str> = runs
940 .iter()
941 .filter(|r| !r.bold && !r.italic && !r.strike && !r.code && !r.formula)
942 .map(|r| r.text.as_str())
943 .collect();
944 for start in 0..unmarked.len() {
945 let mut rest = target;
946 let mut taken: Vec<String> = Vec::new();
947 for t in &unmarked[start..] {
948 let t = t.trim();
949 if t.is_empty() {
950 continue;
951 }
952 match rest.strip_prefix(t) {
953 Some(r) => {
954 taken.push(unescape_text(t));
955 rest = r.trim_start();
956 if rest.is_empty() {
957 break;
958 }
959 }
960 None => break,
961 }
962 }
963 if rest.is_empty() && taken.len() >= 2 {
964 return Some(taken);
965 }
966 }
967 None
968}
969
970fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
977 if let Some(blocks) = t
978 .cell_blocks
979 .as_ref()
980 .and_then(|b| b.get(r))
981 .and_then(|row| row.get(c))
982 .filter(|b| !b.is_empty())
983 {
984 let mut parts: Vec<String> = Vec::new();
985 for node in blocks.iter() {
986 let part = block_chunk_text(node);
987 if !part.is_empty() {
988 parts.push(part);
989 }
990 }
991 return parts.join("\n\n");
992 }
993 let flat = t
994 .rows
995 .get(r)
996 .and_then(|row| row.get(c))
997 .map(String::as_str)
998 .unwrap_or("");
999 unescape_text(flat)
1000 .replace("<!-- image -->", "")
1001 .trim()
1002 .to_string()
1003}
1004
1005fn block_chunk_text(node: &Node) -> String {
1007 match node {
1008 Node::Paragraph { text } => unescape_text(text),
1009 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1010 Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1011 Node::Table(inner) => triplet_table_text(inner),
1012 Node::Picture { caption, .. } => caption
1013 .as_deref()
1014 .filter(|c| !c.is_empty())
1015 .map(unescape_text)
1016 .unwrap_or_default(),
1017 Node::ListItem {
1018 ordered,
1019 number,
1020 text,
1021 ..
1022 } => {
1023 let marker = if *ordered {
1024 format!("{number}.")
1025 } else {
1026 "-".to_string()
1027 };
1028 format!("{marker} {}", unescape_text(text))
1029 }
1030 Node::CheckboxItem { checked, text } => {
1031 let mark = if *checked { "- [x] " } else { "- [ ] " };
1032 format!("{mark}{}", unescape_text(text))
1033 }
1034 Node::Heading { text, .. } => unescape_text(text),
1035 Node::Located { inner, .. } => block_chunk_text(inner),
1036 Node::Group { children, .. } => children
1037 .iter()
1038 .map(block_chunk_text)
1039 .filter(|s| !s.is_empty())
1040 .collect::<Vec<_>>()
1041 .join("\n"),
1042 _ => String::new(),
1043 }
1044}
1045
1046fn unescape_text(s: &str) -> String {
1049 s.replace("<", "<")
1050 .replace(">", ">")
1051 .replace("&", "&")
1052 .replace("\\_", "_")
1053}
1054
1055pub trait ChunkTokenizer {
1061 fn count_tokens(&self, text: &str) -> usize;
1063 fn max_tokens(&self) -> usize;
1065}
1066
1067pub struct HybridChunker<T: ChunkTokenizer> {
1071 tokenizer: T,
1072 merge_peers: bool,
1073}
1074
1075impl<T: ChunkTokenizer> HybridChunker<T> {
1076 pub fn new(tokenizer: T) -> Self {
1077 Self {
1078 tokenizer,
1079 merge_peers: true,
1080 }
1081 }
1082
1083 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1085 self.merge_peers = merge_peers;
1086 self
1087 }
1088
1089 pub fn max_tokens(&self) -> usize {
1090 self.tokenizer.max_tokens()
1091 }
1092
1093 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1095 let chunks = HierarchicalChunker.chunk(doc);
1096 let chunks: Vec<DocChunk> = chunks
1097 .into_iter()
1098 .flat_map(|c| self.split_by_doc_items(c))
1099 .collect();
1100 let chunks: Vec<DocChunk> = chunks
1101 .into_iter()
1102 .flat_map(|c| self.split_using_plain_text(c))
1103 .collect();
1104 if self.merge_peers {
1105 self.merge_matching(chunks)
1106 } else {
1107 chunks
1108 }
1109 }
1110
1111 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1112 self.tokenizer.count_tokens(&contextualize(chunk))
1113 }
1114
1115 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1118 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1119 let text = if chunk.doc_items.len() == 1 {
1120 chunk.text.clone()
1121 } else {
1122 doc_items
1123 .iter()
1124 .filter(|it| !it.text.is_empty())
1125 .map(|it| it.text.as_str())
1126 .collect::<Vec<_>>()
1127 .join("\n")
1128 };
1129 DocChunk {
1130 text,
1131 headings: chunk.headings.clone(),
1132 doc_items,
1133 }
1134 }
1135
1136 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1137 if chunk.doc_items.is_empty() {
1138 return vec![chunk];
1139 }
1140 let max = self.max_tokens();
1141 let num_items = chunk.doc_items.len();
1142 let mut chunks = Vec::new();
1143 let mut window_start = 0usize;
1144 let mut window_end = 0usize; while window_end < num_items {
1146 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1147 if self.count_chunk_tokens(&new_chunk) <= max {
1148 if window_end < num_items - 1 {
1149 window_end += 1;
1150 continue;
1151 } else {
1152 window_end = num_items; }
1154 } else if window_start == window_end {
1155 window_end += 1;
1158 window_start = window_end;
1159 } else {
1160 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1163 window_start = window_end;
1164 }
1165 chunks.push(new_chunk);
1166 }
1167 chunks
1168 }
1169
1170 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1171 let total = self.count_chunk_tokens(&chunk);
1172 let max = self.max_tokens();
1173 if total <= max {
1174 return vec![chunk];
1175 }
1176 let text_len = self.tokenizer.count_tokens(&chunk.text);
1177 let other_len = total - text_len;
1178 if other_len >= max {
1179 let stripped = DocChunk {
1181 headings: None,
1182 ..chunk
1183 };
1184 return self.split_using_plain_text(stripped);
1185 }
1186 let available = max - other_len;
1187
1188 let segments =
1189 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1190 let lines: Vec<String> = chunk
1197 .text
1198 .split('\n')
1199 .filter(|l| !l.trim().is_empty())
1200 .map(|l| l.to_string())
1201 .collect();
1202 line_chunk_text(&lines, &self.tokenizer, max)
1203 } else {
1204 semchunk(&chunk.text, available, &self.tokenizer)
1205 };
1206 segments
1207 .into_iter()
1208 .map(|s| DocChunk {
1209 text: s,
1210 headings: chunk.headings.clone(),
1211 doc_items: chunk.doc_items.clone(),
1212 })
1213 .collect()
1214 }
1215
1216 fn merge_matching(&self, chunks: Vec<DocChunk>) -> Vec<DocChunk> {
1218 let max = self.max_tokens();
1219 let num = chunks.len();
1220 let mut out = Vec::new();
1221 let mut window_start = 0usize;
1222 let mut window_end = 0usize;
1223 let mut current_headings: Option<Vec<String>> = None;
1224 let mut merged: Option<DocChunk> = None;
1225 while window_end < num {
1226 let chunk = &chunks[window_end];
1227 let mut ready_to_append = false;
1228 if window_start == window_end {
1229 current_headings = chunk.headings.clone();
1230 window_end += 1;
1231 } else {
1232 let window = &chunks[window_start..=window_end];
1233 let candidate = DocChunk {
1234 text: window
1235 .iter()
1236 .map(|c| c.text.as_str())
1237 .collect::<Vec<_>>()
1238 .join("\n"),
1239 headings: current_headings.clone(),
1240 doc_items: window
1241 .iter()
1242 .flat_map(|c| c.doc_items.iter().cloned())
1243 .collect(),
1244 };
1245 if chunk.headings == current_headings && self.count_chunk_tokens(&candidate) <= max
1246 {
1247 window_end += 1;
1248 merged = Some(candidate);
1249 } else {
1250 ready_to_append = true;
1251 }
1252 }
1253 if ready_to_append || window_end == num {
1254 if window_start + 1 == window_end {
1255 out.push(chunks[window_start].clone());
1256 } else {
1257 out.push(merged.take().expect("multi-chunk window has a merge"));
1258 }
1259 window_start = window_end;
1260 }
1261 }
1262 out
1263 }
1264}
1265
1266fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1276 let mut chunks: Vec<String> = Vec::new();
1277 let mut current = String::new();
1278 let mut current_len = 0usize;
1279
1280 for line in lines {
1281 let mut remaining: Vec<char> = line.chars().collect();
1282 loop {
1283 let rem_str: String = remaining.iter().collect();
1284 let line_tokens = tok.count_tokens(&rem_str);
1285 let available = max_tokens.saturating_sub(current_len);
1286
1287 if line_tokens <= available {
1288 current.push_str(&rem_str);
1289 current_len += line_tokens;
1290 break;
1291 }
1292 if line_tokens <= max_tokens {
1293 chunks.push(std::mem::take(&mut current));
1294 current_len = 0;
1295 continue;
1296 }
1297 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1299 let mut rest = rest;
1300 if take.is_empty() {
1301 if rest.is_empty() {
1302 break;
1303 }
1304 take = rest[..1].iter().collect();
1305 rest = rest[1..].to_vec();
1306 }
1307 current.push('\n');
1308 current.push_str(&take);
1309 chunks.push(std::mem::take(&mut current));
1310 current_len = 0;
1311 remaining = rest;
1312 }
1313 }
1314 if !current.is_empty() {
1315 chunks.push(current);
1316 }
1317 chunks
1318}
1319
1320fn split_by_token_limit<T: ChunkTokenizer>(
1324 text: &[char],
1325 token_limit: usize,
1326 tok: &T,
1327) -> (String, Vec<char>) {
1328 if token_limit == 0 || text.is_empty() {
1329 return (String::new(), text.to_vec());
1330 }
1331 let full: String = text.iter().collect();
1332 if tok.count_tokens(&full) <= token_limit {
1333 return (full, Vec::new());
1334 }
1335 let (mut lo, mut hi) = (0usize, text.len());
1336 let mut best: Option<usize> = None;
1337 while lo <= hi {
1338 let mid = (lo + hi) / 2;
1339 let head: String = text[..mid].iter().collect();
1340 if tok.count_tokens(&head) <= token_limit {
1341 best = Some(mid);
1342 lo = mid + 1;
1343 } else {
1344 if mid == 0 {
1345 break;
1346 }
1347 hi = mid - 1;
1348 }
1349 }
1350 let mut best_idx = match best {
1351 Some(b) if b > 0 => b,
1352 _ => return (String::new(), text.to_vec()),
1353 };
1354 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1356 if pos > 0 {
1357 best_idx = pos;
1358 }
1359 }
1360 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1361}
1362
1363const NON_WS_SPLITTERS: &[&str] = &[
1369 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1370 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1371];
1372
1373pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1377 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1378 let mut counter = |s: &str| -> usize {
1379 if let Some(n) = cache.get(s) {
1380 return *n;
1381 }
1382 let n = tok.count_tokens(s);
1383 cache.insert(s.to_string(), n);
1384 n
1385 };
1386 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1387 chunks
1389 .into_iter()
1390 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1391 .collect()
1392}
1393
1394fn semchunk_rec(
1397 text: &str,
1398 chunk_size: usize,
1399 counter: &mut dyn FnMut(&str) -> usize,
1400) -> Vec<String> {
1401 let (splitter, splitter_is_ws, splits) = split_text(text);
1402
1403 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1404 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1405 cum_lens.push(0usize);
1406 for l in &split_lens {
1407 cum_lens.push(cum_lens.last().unwrap() + l);
1408 }
1409 let num_splits_plus_one = splits.len() + 1;
1410
1411 let mut chunks: Vec<String> = Vec::new();
1412 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1413
1414 for i in 0..splits.len() {
1415 if skips.contains(&i) {
1416 continue;
1417 }
1418 let split = &splits[i];
1419 if counter(split) > chunk_size {
1420 let inner = semchunk_rec(split, chunk_size, counter);
1421 chunks.extend(inner);
1422 } else {
1423 let (end, merged) = merge_splits(
1424 &splits,
1425 &cum_lens,
1426 chunk_size,
1427 &splitter,
1428 counter,
1429 i,
1430 num_splits_plus_one,
1431 );
1432 for j in (i + 1)..end {
1433 skips.insert(j);
1434 }
1435 chunks.push(merged);
1436 }
1437 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1440 if !splitter_is_ws && !is_last {
1441 let with_splitter = format!(
1442 "{}{}",
1443 chunks.last().map(String::as_str).unwrap_or(""),
1444 splitter
1445 );
1446 if counter(&with_splitter) <= chunk_size {
1447 if let Some(last) = chunks.last_mut() {
1448 *last = with_splitter;
1449 } else {
1450 chunks.push(with_splitter);
1451 }
1452 } else {
1453 chunks.push(splitter.clone());
1454 }
1455 }
1456 }
1457 chunks
1458}
1459
1460fn merge_splits(
1463 splits: &[String],
1464 cum_lens: &[usize],
1465 chunk_size: usize,
1466 splitter: &str,
1467 counter: &mut dyn FnMut(&str) -> usize,
1468 start: usize,
1469 high_init: usize,
1470) -> (usize, String) {
1471 let mut average = 0.2f64;
1472 let mut low = start;
1473 let mut high = high_init;
1474 let offset = cum_lens[start];
1475 let mut target = offset as f64 + (chunk_size as f64 * average);
1476
1477 while low < high {
1478 let i = bisect_left(cum_lens, target, low, high);
1479 let midpoint = i.min(high - 1);
1480 let joined = splits[start..midpoint.max(start)].join(splitter);
1481 let tokens = counter(&joined);
1482 let local_cum = cum_lens[midpoint] - offset;
1483 if local_cum > 0 && tokens > 0 {
1484 average = local_cum as f64 / tokens as f64;
1485 target = offset as f64 + (chunk_size as f64 * average);
1486 }
1487 if tokens > chunk_size {
1488 high = midpoint;
1489 } else {
1490 low = midpoint + 1;
1491 }
1492 }
1493 let end = low - 1;
1494 (end, splits[start..end.max(start)].join(splitter))
1495}
1496
1497fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1498 while low < high {
1499 let mid = (low + high) / 2;
1500 if (sorted[mid] as f64) < target {
1501 low = mid + 1;
1502 } else {
1503 high = mid;
1504 }
1505 }
1506 low
1507}
1508
1509fn split_text(text: &str) -> (String, bool, Vec<String>) {
1511 if text.contains('\n') || text.contains('\r') {
1513 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1514 return (splitter.clone(), true, split_on(text, &splitter));
1515 }
1516 if text.contains('\t') {
1518 let splitter = longest_run(text, |c| c == '\t');
1519 return (splitter.clone(), true, split_on(text, &splitter));
1520 }
1521 if text.chars().any(char::is_whitespace) {
1523 let splitter = longest_run(text, char::is_whitespace);
1524 if splitter.chars().count() == 1 {
1525 for preceder in NON_WS_SPLITTERS {
1527 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1528 return (ws, true, parts);
1529 }
1530 }
1531 }
1532 return (splitter.clone(), true, split_on(text, &splitter));
1533 }
1534 for s in NON_WS_SPLITTERS {
1536 if text.contains(s) {
1537 return (s.to_string(), false, split_on(text, s));
1538 }
1539 }
1540 (
1542 String::new(),
1543 true,
1544 text.chars().map(|c| c.to_string()).collect(),
1545 )
1546}
1547
1548fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1550 let mut best = String::new();
1551 let mut cur = String::new();
1552 for c in text.chars() {
1553 if pred(c) {
1554 cur.push(c);
1555 } else {
1556 if cur.chars().count() > best.chars().count() {
1557 best = cur.clone();
1558 }
1559 cur.clear();
1560 }
1561 }
1562 if cur.chars().count() > best.chars().count() {
1563 best = cur;
1564 }
1565 best
1566}
1567
1568fn split_on(text: &str, splitter: &str) -> Vec<String> {
1569 text.split(splitter).map(str::to_string).collect()
1570}
1571
1572fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1576 let chars: Vec<char> = text.chars().collect();
1577 let p: Vec<char> = preceder.chars().collect();
1578 let mut ws: Option<char> = None;
1579 for i in p.len()..chars.len() {
1580 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1581 ws = Some(chars[i]);
1582 break;
1583 }
1584 }
1585 let ws = ws?;
1586 let mut parts = Vec::new();
1587 let mut cur = String::new();
1588 let mut i = 0usize;
1589 while i < chars.len() {
1590 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1591 parts.push(std::mem::take(&mut cur));
1592 i += 1;
1593 continue;
1594 }
1595 cur.push(chars[i]);
1596 i += 1;
1597 }
1598 parts.push(cur);
1599 Some((ws.to_string(), parts))
1600}
1601
1602#[cfg(feature = "chunking")]
1607mod hf {
1608 use super::ChunkTokenizer;
1609
1610 pub const DEFAULT_TOKENIZER_PATH: &str = "models/chunk/tokenizer.json";
1615
1616 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1621 if let Some(p) = explicit {
1622 return Ok(p.to_string());
1623 }
1624 if std::path::Path::new(DEFAULT_TOKENIZER_PATH).exists() {
1625 return Ok(DEFAULT_TOKENIZER_PATH.to_string());
1626 }
1627 Err(format!(
1628 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1629 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1630 scripts/install/download_dependencies.sh (or pass an explicit path)"
1631 ))
1632 }
1633
1634 pub struct HuggingFaceTokenizer {
1638 tok: tokenizers::Tokenizer,
1639 max_tokens: usize,
1640 }
1641
1642 impl HuggingFaceTokenizer {
1643 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1647 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1648 }
1649
1650 pub fn from_file(
1654 path: impl AsRef<std::path::Path>,
1655 max_tokens: usize,
1656 ) -> Result<Self, String> {
1657 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1658 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1659 let _ = tok.with_truncation(None);
1664 tok.with_padding(None);
1665 Ok(Self { tok, max_tokens })
1666 }
1667 }
1668
1669 impl ChunkTokenizer for HuggingFaceTokenizer {
1670 fn count_tokens(&self, text: &str) -> usize {
1671 self.tok
1672 .encode(text, false)
1673 .map(|e| e.get_tokens().len())
1674 .unwrap_or(0)
1675 }
1676 fn max_tokens(&self) -> usize {
1677 self.max_tokens
1678 }
1679 }
1680}
1681
1682#[cfg(feature = "chunking")]
1683pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1684
1685#[cfg(test)]
1686mod tests {
1687 use super::*;
1688
1689 struct WordTok(usize);
1691 impl ChunkTokenizer for WordTok {
1692 fn count_tokens(&self, text: &str) -> usize {
1693 text.split_whitespace().count()
1694 }
1695 fn max_tokens(&self) -> usize {
1696 self.0
1697 }
1698 }
1699
1700 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
1701 let mut d = DoclingDocument::new("t");
1702 for n in nodes {
1703 d.push(n);
1704 }
1705 d
1706 }
1707
1708 #[test]
1709 fn hierarchical_headings_and_items() {
1710 let doc = doc_with(vec![
1711 Node::Heading {
1712 level: 1,
1713 text: "Title".into(),
1714 },
1715 Node::Paragraph {
1716 text: "Intro".into(),
1717 },
1718 Node::Heading {
1719 level: 2,
1720 text: "Sec".into(),
1721 },
1722 Node::Paragraph {
1723 text: "Body".into(),
1724 },
1725 ]);
1726 let chunks = HierarchicalChunker.chunk(&doc);
1727 assert_eq!(chunks.len(), 2);
1728 assert_eq!(chunks[0].text, "Intro");
1729 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
1730 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
1731 assert_eq!(
1732 chunks[1].headings.as_deref(),
1733 Some(&["Title".into(), "Sec".into()][..])
1734 );
1735 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
1736 }
1737
1738 #[test]
1739 fn heading_shadowing_prunes_deeper_levels() {
1740 let doc = doc_with(vec![
1741 Node::Heading {
1742 level: 2,
1743 text: "A".into(),
1744 },
1745 Node::Heading {
1746 level: 3,
1747 text: "A.1".into(),
1748 },
1749 Node::Heading {
1750 level: 2,
1751 text: "B".into(),
1752 },
1753 Node::Paragraph { text: "p".into() },
1754 ]);
1755 let chunks = HierarchicalChunker.chunk(&doc);
1756 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
1757 }
1758
1759 #[test]
1760 fn triplet_table() {
1761 let t = Table {
1762 rows: vec![
1763 vec!["".into(), "Col1".into()],
1764 vec!["Row1".into(), "v".into()],
1765 ],
1766 ..Default::default()
1767 };
1768 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
1769 let single = Table {
1772 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
1773 ..Default::default()
1774 };
1775 assert_eq!(triplet_table_text(&single), "a = b");
1776 }
1777
1778 #[test]
1779 fn hybrid_merges_small_peers_and_splits_large() {
1780 let doc = doc_with(vec![
1781 Node::Heading {
1782 level: 2,
1783 text: "S".into(),
1784 },
1785 Node::Paragraph { text: "a b".into() },
1786 Node::Paragraph { text: "c d".into() },
1787 ]);
1788 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
1789 assert_eq!(chunks.len(), 1, "peers under one heading merge");
1790 assert_eq!(chunks[0].text, "a b\nc d");
1791
1792 let long = "w ".repeat(40).trim().to_string();
1793 let doc = doc_with(vec![Node::Paragraph { text: long }]);
1794 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
1795 assert!(chunks.len() > 1, "oversized paragraph splits");
1796 for c in &chunks {
1797 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
1798 }
1799 }
1800
1801 #[test]
1802 fn semchunk_prefers_newlines_then_sentences() {
1803 let tok = WordTok(4);
1804 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
1805 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
1806 }
1807}