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 has_pics = text.contains("<!-- image -->");
360 let text = strip_image_markers(text);
361 let text = text.as_str();
362 let segments = inline_segments(text);
363 if (has_nested || has_pics) && segments.len() > 1 && text.contains("](") {
364 out.push(ChunkItem {
365 self_ref: item_ref.clone(),
366 kind: ChunkItemKind::Text,
367 text: format!("{marker} "),
368 });
369 for seg in segments {
370 out.push(ChunkItem {
371 self_ref: item_ref.clone(),
372 kind: ChunkItemKind::Text,
373 text: seg,
374 });
375 }
376 } else {
377 out.push(ChunkItem {
378 self_ref: item_ref.clone(),
379 kind: ChunkItemKind::Text,
380 text: format!("{marker} {}", unescape_text(text)),
381 });
382 }
383 }
384 if j > i + 1 {
387 self.nested_sibling_lists(&items[i + 1..j], out);
388 }
389 i = j;
390 }
391 }
392
393 fn nested_sibling_lists(&mut self, run: &[Node], out: &mut Vec<ChunkItem>) {
394 let base = level_of(&run[0]);
395 let mut seg = 0;
396 let mut prev: Option<(bool, u64)> = None;
397 for k in 0..run.len() {
398 let Node::ListItem {
399 ordered,
400 number,
401 first_in_list,
402 level,
403 ..
404 } = &run[k]
405 else {
406 continue;
407 };
408 if *level != base {
409 continue;
410 }
411 if k > seg {
412 if let Some((po, pn)) = prev {
413 if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
414 self.alloc.group();
415 self.list_refs(&run[seg..k], out);
416 seg = k;
417 }
418 }
419 }
420 prev = Some((*ordered, *number));
421 }
422 self.alloc.group();
423 self.list_refs(&run[seg..], out);
424 }
425
426 fn one(&mut self, node: &Node) {
427 match node {
428 Node::Heading { level, text } => {
429 let doc_level = if *level == 1 {
430 0
431 } else {
432 level.saturating_sub(1)
433 };
434 let self_ref = self.alloc.text();
435 let runs = crate::inline_runs_from_markdown(text);
441 if runs.len() <= 1 {
442 let plain = runs
443 .first()
444 .map(|r| r.text.clone())
445 .unwrap_or_else(|| text.clone());
446 self.set_heading(doc_level, unescape_text(&plain));
447 } else {
448 self.set_heading(doc_level, String::new());
449 let body = unescape_text(text);
450 self.emit(
451 body.clone(),
452 vec![ChunkItem {
453 self_ref,
454 kind: ChunkItemKind::Text,
455 text: body,
456 }],
457 );
458 }
459 }
460 Node::Paragraph { text } => {
461 let t = text.trim();
462 let self_ref = self.alloc.text();
463 if let Some(inner) = t
466 .strip_prefix("$$")
467 .and_then(|s| s.strip_suffix("$$"))
468 .filter(|s| !s.is_empty())
469 {
470 let body = format!("$${inner}$$");
471 self.emit(
472 body.clone(),
473 vec![ChunkItem {
474 self_ref,
475 kind: ChunkItemKind::Text,
476 text: body,
477 }],
478 );
479 return;
480 }
481 self.emit_inline(text, self_ref);
482 }
483 Node::Caption { text, .. } => {
486 let self_ref = self.alloc.text();
487 self.emit_inline(text, self_ref);
488 }
489 Node::CheckboxItem { checked, text } => {
490 let self_ref = self.alloc.text();
491 let mark = if *checked { "- [x] " } else { "- [ ] " };
492 let body = format!("{mark}{}", unescape_text(text));
493 self.emit(
494 body.clone(),
495 vec![ChunkItem {
496 self_ref,
497 kind: ChunkItemKind::Text,
498 text: body,
499 }],
500 );
501 }
502 Node::Formula { latex, .. } => {
505 let self_ref = self.alloc.text();
506 let body = format!("$${}$$", latex);
507 self.emit(
508 body.clone(),
509 vec![ChunkItem {
510 self_ref,
511 kind: ChunkItemKind::Text,
512 text: body,
513 }],
514 );
515 }
516 Node::Code { text, .. } => {
517 let self_ref = self.alloc.text();
518 let body = format!("```\n{}\n```", unescape_text(text));
519 self.emit(
520 body.clone(),
521 vec![ChunkItem {
522 self_ref,
523 kind: ChunkItemKind::Text,
524 text: body,
525 }],
526 );
527 }
528 Node::Table(t) => {
529 let self_ref = self.alloc.table();
530 let body = triplet_table_text(t);
531 self.emit(
532 body.clone(),
533 vec![ChunkItem {
534 self_ref,
535 kind: ChunkItemKind::Table,
536 text: body,
537 }],
538 );
539 }
540 Node::Picture { caption, .. } => {
541 let cap = caption.as_deref().filter(|c| !c.is_empty());
542 let cap_item = cap.map(|c| ChunkItem {
543 self_ref: self.alloc.text(),
544 kind: ChunkItemKind::Text,
545 text: unescape_text(c),
546 });
547 self.alloc.picture();
548 if let Some(cap_item) = cap_item {
552 let body = cap_item.text.clone();
553 self.emit(body, vec![cap_item]);
554 }
555 }
556 Node::Chart {
557 kind,
558 table,
559 caption,
560 ..
561 } => {
562 let cap = caption.as_deref().filter(|c| !c.is_empty());
563 let cap_item = cap.map(|c| ChunkItem {
564 self_ref: self.alloc.text(),
565 kind: ChunkItemKind::Text,
566 text: unescape_text(c),
567 });
568 let pic_ref = self.alloc.picture();
569 let mut parts: Vec<String> = Vec::new();
573 if let Some(ci) = &cap_item {
574 parts.push(ci.text.clone());
575 }
576 parts.push(humanize_label(kind));
577 let grid = crate::markdown::render_table(table, false);
578 if !grid.is_empty() {
579 parts.push(unescape_text(&grid));
580 }
581 let body = parts.join("\n\n");
582 let pic_item = ChunkItem {
587 self_ref: pic_ref,
588 kind: ChunkItemKind::Picture,
589 text: body.clone(),
590 };
591 let items = match cap_item {
592 Some(mut ci) => {
593 ci.text = String::new();
594 vec![ci, pic_item]
595 }
596 None => vec![pic_item],
597 };
598 self.emit(body, items);
599 }
600 Node::Group { layer: Some(_), .. } => {}
603 Node::Group { children, .. } => {
604 self.alloc.group();
607 self.walk(children);
608 }
609 Node::FieldRegion { items } => {
610 self.alloc.field_region();
613 for item in items {
614 self.alloc.field_item();
615 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
616 let self_ref = self.alloc.text();
617 let body = unescape_text(part);
618 self.emit(
619 body.clone(),
620 vec![ChunkItem {
621 self_ref,
622 kind: ChunkItemKind::Text,
623 text: body,
624 }],
625 );
626 }
627 }
628 }
629 Node::InlineGroup { md_text, runs, .. } => {
630 let self_ref = self.alloc.text();
631 self.emit_inline_with_runs(md_text, self_ref, runs);
632 }
633 Node::TextDump(text) => {
634 let self_ref = self.alloc.text();
635 let body = unescape_text(text);
636 self.emit(
637 body.clone(),
638 vec![ChunkItem {
639 self_ref,
640 kind: ChunkItemKind::Text,
641 text: body,
642 }],
643 );
644 }
645 Node::Located { inner, .. }
647 | Node::Prov { inner, .. }
648 | Node::Commented { inner, .. } => self.one(inner),
649 Node::CommentSection { .. }
652 | Node::Furniture { .. }
653 | Node::PageFurniture { .. }
654 | Node::PageBreak
655 | Node::PageInfo { .. }
656 | Node::DoclangOnly(_) => {}
657 Node::ListItem { .. } => self.sibling_lists(std::slice::from_ref(node)),
660 }
661 }
662}
663
664fn level_of(node: &Node) -> u8 {
665 match node {
666 Node::ListItem { level, .. } => *level,
667 _ => 0,
668 }
669}
670
671fn render_list(items: &[Node]) -> String {
674 let mut lines: Vec<String> = Vec::new();
675 for item in items {
676 let Node::ListItem {
677 ordered,
678 number,
679 text,
680 level,
681 layer,
682 ..
683 } = item
684 else {
685 continue;
686 };
687 if layer.is_some() {
688 continue;
689 }
690 let indent = " ".repeat(*level as usize);
691 let marker = if *ordered {
692 format!("{number}.")
693 } else {
694 "-".to_string()
695 };
696 lines.push(format!(
697 "{indent}{marker} {}",
698 unescape_text(&strip_image_markers(text))
699 ));
700 }
701 lines.join("\n")
702}
703
704fn strip_image_markers(text: &str) -> String {
709 if !text.contains("<!-- image -->") {
710 return text.to_string();
711 }
712 let cleaned: Vec<&str> = text
713 .split('\n')
714 .map(str::trim_end)
715 .filter(|l| *l != "<!-- image -->")
716 .collect();
717 cleaned.join("\n").trim_end().to_string()
718}
719
720fn humanize_label(label: &str) -> String {
723 let text = label.replace('_', " ");
724 let mut chars = text.chars();
725 match chars.next() {
726 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
727 None => text,
728 }
729}
730
731fn triplet_table_text(t: &Table) -> String {
738 let rows: Vec<Vec<String>> = t
739 .rows
740 .iter()
741 .enumerate()
742 .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
743 .collect();
744 let num_rows = rows.len();
745 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
746 if num_rows == 0 || num_cols == 0 {
747 return String::new();
748 }
749 let cell = |r: usize, c: usize| -> &str {
750 rows.get(r)
751 .and_then(|row| row.get(c))
752 .map(String::as_str)
753 .unwrap_or("")
754 };
755
756 let num_headers = {
762 let derived;
763 let cells: &[crate::TableCell] = match &t.cells {
764 Some(c) if !c.is_empty() => c,
765 _ => {
766 derived = t.derive_cells();
767 &derived
768 }
769 };
770 (0..num_rows)
771 .take_while(|&r| cells.iter().any(|c| c.column_header && c.start_row == r))
772 .count()
773 };
774
775 let columns: Vec<String> = if num_headers > 0 {
778 (0..num_cols)
779 .map(|c| {
780 let mut name = String::new();
781 for r in 0..num_headers {
782 if !name.is_empty() {
783 name.push('.');
784 }
785 name.push_str(cell(r, c));
786 }
787 name
788 })
789 .collect()
790 } else {
791 (0..num_cols).map(|c| c.to_string()).collect()
792 };
793 let data_rows = num_headers..num_rows;
794 let n_data = data_rows.len();
795
796 if n_data == 0 {
798 return columns
799 .iter()
800 .map(|s| s.trim())
801 .filter(|s| !s.is_empty())
802 .collect::<Vec<_>>()
803 .join(". ");
804 }
805
806 let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
807 let text = if num_cols == 1 {
808 let col_name = data(0, 0).trim().to_string();
811 if n_data == 1 {
812 col_name
813 } else {
814 (1..n_data)
815 .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
816 .collect::<Vec<_>>()
817 .join(". ")
818 }
819 } else {
820 let mut parts = Vec::new();
822 for r in 0..n_data {
823 for (c, col_name) in columns.iter().enumerate().skip(1) {
824 parts.push(format!(
825 "{}, {} = {}",
826 data(r, 0).trim(),
827 col_name.trim(),
828 data(r, c).trim()
829 ));
830 }
831 }
832 parts.join(". ")
833 };
834 if !text.is_empty() {
835 return text;
836 }
837
838 (0..n_data)
841 .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
842 .map(|(r, c)| data(r, c).trim())
843 .filter(|s| !s.is_empty())
844 .collect::<Vec<_>>()
845 .join(". ")
846}
847
848fn inline_segments(md: &str) -> Vec<String> {
856 inline_segments_tagged(md)
857 .into_iter()
858 .map(|(t, _)| t)
859 .collect()
860}
861
862fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
866 let chars: Vec<char> = md.chars().collect();
867 let n = chars.len();
868 let find = |from: usize, pat: &str| -> Option<usize> {
869 let hay: String = chars[from..].iter().collect();
870 hay.find(pat).map(|p| from + hay[..p].chars().count())
871 };
872 let mut out: Vec<(String, bool)> = Vec::new();
873 let mut plain = String::new();
874 let mut after_span = false;
875
876 fn flush(
877 out: &mut Vec<(String, bool)>,
878 plain: &mut String,
879 before_span: bool,
880 after_span: bool,
881 ) {
882 let mut p = std::mem::take(plain);
883 if after_span {
884 if let Some(rest) = p.strip_prefix(' ') {
885 p = rest.to_string();
886 }
887 }
888 if before_span {
889 if let Some(rest) = p.strip_suffix(' ') {
890 p = rest.to_string();
891 }
892 }
893 if !p.is_empty() {
894 out.push((unescape_text(&p), true));
895 }
896 }
897
898 let mut i = 0;
899 while i < n {
900 let rest: String = chars[i..].iter().collect();
901 if chars[i] == '[' && !rest.starts_with("[](") {
905 let balanced = |c: usize| {
909 let mut d = 0i32;
910 for &ch in &chars[i + 1..c] {
911 match ch {
912 '[' => d += 1,
913 ']' => d -= 1,
914 _ => {}
915 }
916 }
917 d == 0
918 };
919 if let Some(close) = find(i + 1, "](").filter(|&c| balanced(c)) {
920 let mut depth = 0usize;
921 let mut url_end = None;
922 for (k, &c) in chars.iter().enumerate().skip(close + 2) {
923 match c {
924 '(' => depth += 1,
925 ')' => {
926 if depth == 0 {
927 url_end = Some(k);
928 break;
929 }
930 depth -= 1;
931 }
932 _ => {}
933 }
934 }
935 if let Some(endp) = url_end {
936 flush(&mut out, &mut plain, true, after_span);
937 out.push((
938 unescape_text(&chars[i..=endp].iter().collect::<String>()),
939 false,
940 ));
941 i = endp + 1;
942 after_span = true;
943 continue;
944 }
945 }
946 }
947 let mut matched = false;
950 for marker in ["***", "**", "*", "~~", "`"] {
951 if rest.starts_with(marker) {
952 let mlen = marker.chars().count();
953 if let Some(end) = find(i + mlen, marker) {
954 let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
958 if end > i + mlen && !inner_blank {
959 flush(&mut out, &mut plain, true, after_span);
960 if marker == "`" {
961 let inner: String = chars[i + 1..end].iter().collect();
962 out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
963 } else {
964 out.push((
965 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
966 false,
967 ));
968 }
969 i = end + mlen;
970 after_span = true;
971 matched = true;
972 }
973 }
974 break;
975 }
976 }
977 if matched {
978 continue;
979 }
980 if rest.starts_with("$$") {
983 plain.push_str("$$");
984 i += 2;
985 continue;
986 }
987 if chars[i] == '$' {
989 if let Some(end) = find(i + 1, "$") {
990 if end > i + 1 {
991 flush(&mut out, &mut plain, true, after_span);
992 let latex: String = chars[i + 1..end].iter().collect();
993 out.push((format!("$${latex}$$"), false));
994 i = end + 1;
995 after_span = true;
996 continue;
997 }
998 }
999 }
1000 plain.push(chars[i]);
1001 i += 1;
1002 }
1003 flush(&mut out, &mut plain, false, after_span);
1004 if out.is_empty() {
1005 out.push((unescape_text(md), true));
1006 }
1007 out
1008}
1009
1010fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1015 let target = segment.trim();
1016 if target.is_empty() {
1017 return None;
1018 }
1019 let plainish =
1020 |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1021 let fully_plain =
1022 |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1023 let unmarked: Vec<(&str, bool)> = runs
1024 .iter()
1025 .filter(|r| plainish(r))
1026 .map(|r| (r.text.as_str(), fully_plain(r)))
1027 .collect();
1028 for start in 0..unmarked.len() {
1029 let mut rest = target;
1030 let mut taken: Vec<(String, bool)> = Vec::new();
1031 for (t, fully) in &unmarked[start..] {
1032 let t = t.trim();
1033 if t.is_empty() {
1034 continue;
1035 }
1036 match rest.strip_prefix(t) {
1037 Some(r) => {
1038 taken.push((unescape_text(t), *fully));
1039 rest = r.trim_start();
1040 if rest.is_empty() {
1041 break;
1042 }
1043 }
1044 None => break,
1045 }
1046 }
1047 if rest.is_empty() && taken.len() >= 2 {
1048 let mut merged: Vec<(String, bool)> = Vec::new();
1053 for (t, fully) in taken {
1054 match merged.last_mut() {
1055 Some((last, true)) if fully => {
1056 last.push(' ');
1057 last.push_str(&t);
1058 }
1059 _ => merged.push((t, fully)),
1060 }
1061 }
1062 if merged.len() >= 2 {
1063 return Some(merged.into_iter().map(|(t, _)| t).collect());
1064 }
1065 return None;
1066 }
1067 }
1068 None
1069}
1070
1071fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
1078 if let Some(blocks) = t
1079 .cell_blocks
1080 .as_ref()
1081 .and_then(|b| b.get(r))
1082 .and_then(|row| row.get(c))
1083 .filter(|b| !b.is_empty())
1084 {
1085 let mut parts: Vec<String> = Vec::new();
1086 for node in blocks.iter() {
1087 let part = block_chunk_text(node);
1088 if !part.is_empty() {
1089 parts.push(part);
1090 }
1091 }
1092 return parts.join("\n\n");
1093 }
1094 let flat = t
1095 .rows
1096 .get(r)
1097 .and_then(|row| row.get(c))
1098 .map(String::as_str)
1099 .unwrap_or("");
1100 unescape_text(flat)
1101 .replace("<!-- image -->", "")
1102 .trim()
1103 .to_string()
1104}
1105
1106fn block_chunk_text(node: &Node) -> String {
1108 match node {
1109 Node::Paragraph { text } => unescape_text(text),
1110 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1111 Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1112 Node::Table(inner) => triplet_table_text(inner),
1113 Node::Picture { caption, .. } => caption
1114 .as_deref()
1115 .filter(|c| !c.is_empty())
1116 .map(unescape_text)
1117 .unwrap_or_default(),
1118 Node::ListItem {
1119 ordered,
1120 number,
1121 text,
1122 ..
1123 } => {
1124 let marker = if *ordered {
1125 format!("{number}.")
1126 } else {
1127 "-".to_string()
1128 };
1129 format!("{marker} {}", unescape_text(text))
1130 }
1131 Node::CheckboxItem { checked, text } => {
1132 let mark = if *checked { "- [x] " } else { "- [ ] " };
1133 format!("{mark}{}", unescape_text(text))
1134 }
1135 Node::Heading { text, .. } => unescape_text(text),
1136 Node::Located { inner, .. } | Node::Prov { inner, .. } | Node::Commented { inner, .. } => {
1137 block_chunk_text(inner)
1138 }
1139 Node::Group { layer: Some(_), .. } => String::new(),
1140 Node::Group { children, .. } => children
1141 .iter()
1142 .map(block_chunk_text)
1143 .filter(|s| !s.is_empty())
1144 .collect::<Vec<_>>()
1145 .join("\n"),
1146 _ => String::new(),
1147 }
1148}
1149
1150fn unescape_text(s: &str) -> String {
1153 s.replace("<", "<")
1154 .replace(">", ">")
1155 .replace("&", "&")
1156 .replace("\\_", "_")
1157}
1158
1159pub trait ChunkTokenizer {
1165 fn count_tokens(&self, text: &str) -> usize;
1167 fn max_tokens(&self) -> usize;
1169}
1170
1171pub struct HybridChunker<T: ChunkTokenizer> {
1175 tokenizer: T,
1176 merge_peers: bool,
1177}
1178
1179impl<T: ChunkTokenizer> HybridChunker<T> {
1180 pub fn new(tokenizer: T) -> Self {
1181 Self {
1182 tokenizer,
1183 merge_peers: true,
1184 }
1185 }
1186
1187 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1189 self.merge_peers = merge_peers;
1190 self
1191 }
1192
1193 pub fn max_tokens(&self) -> usize {
1194 self.tokenizer.max_tokens()
1195 }
1196
1197 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1199 let mut chunks = Vec::new();
1200 self.chunk_with(doc, &mut |c| {
1201 chunks.push(c);
1202 true
1203 });
1204 chunks
1205 }
1206
1207 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1214 let mut merger = PeerMerger::default();
1215 let mut alive = true;
1216 HierarchicalChunker.chunk_with(doc, &mut |c| {
1217 for split in self.split_by_doc_items(c) {
1218 for chunk in self.split_using_plain_text(split) {
1219 if !alive {
1220 return false;
1221 }
1222 alive = if self.merge_peers {
1223 self.merge_push(&mut merger, chunk, sink)
1224 } else {
1225 sink(chunk)
1226 };
1227 }
1228 }
1229 alive
1230 });
1231 if alive {
1232 self.merge_flush(&mut merger, sink);
1233 }
1234 }
1235
1236 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1237 self.tokenizer.count_tokens(&contextualize(chunk))
1238 }
1239
1240 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1243 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1244 let text = if chunk.doc_items.len() == 1 {
1245 chunk.text.clone()
1246 } else {
1247 doc_items
1248 .iter()
1249 .filter(|it| !it.text.is_empty())
1250 .map(|it| it.text.as_str())
1251 .collect::<Vec<_>>()
1252 .join("\n")
1253 };
1254 DocChunk {
1255 text,
1256 headings: chunk.headings.clone(),
1257 doc_items,
1258 }
1259 }
1260
1261 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1262 if chunk.doc_items.is_empty() {
1263 return vec![chunk];
1264 }
1265 let max = self.max_tokens();
1266 let num_items = chunk.doc_items.len();
1267 let mut chunks = Vec::new();
1268 let mut window_start = 0usize;
1269 let mut window_end = 0usize; while window_end < num_items {
1271 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1272 if self.count_chunk_tokens(&new_chunk) <= max {
1273 if window_end < num_items - 1 {
1274 window_end += 1;
1275 continue;
1276 } else {
1277 window_end = num_items; }
1279 } else if window_start == window_end {
1280 window_end += 1;
1283 window_start = window_end;
1284 } else {
1285 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1288 window_start = window_end;
1289 }
1290 chunks.push(new_chunk);
1291 }
1292 chunks
1293 }
1294
1295 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1296 let total = self.count_chunk_tokens(&chunk);
1297 let max = self.max_tokens();
1298 if total <= max {
1299 return vec![chunk];
1300 }
1301 let text_len = self.tokenizer.count_tokens(&chunk.text);
1302 let other_len = total - text_len;
1303 if other_len >= max {
1304 let stripped = DocChunk {
1306 headings: None,
1307 ..chunk
1308 };
1309 return self.split_using_plain_text(stripped);
1310 }
1311 let available = max - other_len;
1312
1313 let segments =
1314 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1315 let lines: Vec<String> = chunk
1322 .text
1323 .split('\n')
1324 .filter(|l| !l.trim().is_empty())
1325 .map(|l| l.to_string())
1326 .collect();
1327 line_chunk_text(&lines, &self.tokenizer, max)
1328 } else {
1329 semchunk(&chunk.text, available, &self.tokenizer)
1330 };
1331 segments
1332 .into_iter()
1333 .map(|s| DocChunk {
1334 text: s,
1335 headings: chunk.headings.clone(),
1336 doc_items: chunk.doc_items.clone(),
1337 })
1338 .collect()
1339 }
1340
1341 fn merge_push(
1347 &self,
1348 m: &mut PeerMerger,
1349 chunk: DocChunk,
1350 sink: &mut dyn FnMut(DocChunk) -> bool,
1351 ) -> bool {
1352 if m.window.is_empty() {
1353 m.window.push(chunk);
1354 return true;
1355 }
1356 let candidate = DocChunk {
1357 text: m
1358 .window
1359 .iter()
1360 .map(|c| c.text.as_str())
1361 .chain([chunk.text.as_str()])
1362 .collect::<Vec<_>>()
1363 .join("\n"),
1364 headings: m.window[0].headings.clone(),
1365 doc_items: m
1366 .window
1367 .iter()
1368 .flat_map(|c| c.doc_items.iter().cloned())
1369 .chain(chunk.doc_items.iter().cloned())
1370 .collect(),
1371 };
1372 if chunk.headings == m.window[0].headings
1373 && self.count_chunk_tokens(&candidate) <= self.max_tokens()
1374 {
1375 m.window.push(chunk);
1376 m.merged = Some(candidate);
1377 true
1378 } else {
1379 let alive = self.merge_flush(m, sink);
1380 m.window.push(chunk);
1381 alive
1382 }
1383 }
1384
1385 fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1389 let alive = if m.window.len() == 1 {
1390 sink(m.window.pop().expect("single-chunk window"))
1391 } else if !m.window.is_empty() {
1392 m.window.clear();
1393 sink(m.merged.take().expect("multi-chunk window has a merge"))
1394 } else {
1395 true
1396 };
1397 m.merged = None;
1398 alive
1399 }
1400}
1401
1402#[derive(Default)]
1404struct PeerMerger {
1405 window: Vec<DocChunk>,
1406 merged: Option<DocChunk>,
1407}
1408
1409fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1419 let mut chunks: Vec<String> = Vec::new();
1420 let mut current = String::new();
1421 let mut current_len = 0usize;
1422
1423 for line in lines {
1424 let mut remaining: Vec<char> = line.chars().collect();
1425 loop {
1426 let rem_str: String = remaining.iter().collect();
1427 let line_tokens = tok.count_tokens(&rem_str);
1428 let available = max_tokens.saturating_sub(current_len);
1429
1430 if line_tokens <= available {
1431 current.push_str(&rem_str);
1432 current_len += line_tokens;
1433 break;
1434 }
1435 if line_tokens <= max_tokens {
1436 chunks.push(std::mem::take(&mut current));
1437 current_len = 0;
1438 continue;
1439 }
1440 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1442 let mut rest = rest;
1443 if take.is_empty() {
1444 if rest.is_empty() {
1445 break;
1446 }
1447 take = rest[..1].iter().collect();
1448 rest = rest[1..].to_vec();
1449 }
1450 current.push('\n');
1451 current.push_str(&take);
1452 chunks.push(std::mem::take(&mut current));
1453 current_len = 0;
1454 remaining = rest;
1455 }
1456 }
1457 if !current.is_empty() {
1458 chunks.push(current);
1459 }
1460 chunks
1461}
1462
1463fn split_by_token_limit<T: ChunkTokenizer>(
1467 text: &[char],
1468 token_limit: usize,
1469 tok: &T,
1470) -> (String, Vec<char>) {
1471 if token_limit == 0 || text.is_empty() {
1472 return (String::new(), text.to_vec());
1473 }
1474 let full: String = text.iter().collect();
1475 if tok.count_tokens(&full) <= token_limit {
1476 return (full, Vec::new());
1477 }
1478 let (mut lo, mut hi) = (0usize, text.len());
1479 let mut best: Option<usize> = None;
1480 while lo <= hi {
1481 let mid = (lo + hi) / 2;
1482 let head: String = text[..mid].iter().collect();
1483 if tok.count_tokens(&head) <= token_limit {
1484 best = Some(mid);
1485 lo = mid + 1;
1486 } else {
1487 if mid == 0 {
1488 break;
1489 }
1490 hi = mid - 1;
1491 }
1492 }
1493 let mut best_idx = match best {
1494 Some(b) if b > 0 => b,
1495 _ => return (String::new(), text.to_vec()),
1496 };
1497 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1499 if pos > 0 {
1500 best_idx = pos;
1501 }
1502 }
1503 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1504}
1505
1506const NON_WS_SPLITTERS: &[&str] = &[
1512 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1513 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1514];
1515
1516pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1520 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1521 let mut counter = |s: &str| -> usize {
1522 if let Some(n) = cache.get(s) {
1523 return *n;
1524 }
1525 let n = tok.count_tokens(s);
1526 cache.insert(s.to_string(), n);
1527 n
1528 };
1529 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1530 chunks
1532 .into_iter()
1533 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1534 .collect()
1535}
1536
1537fn semchunk_rec(
1540 text: &str,
1541 chunk_size: usize,
1542 counter: &mut dyn FnMut(&str) -> usize,
1543) -> Vec<String> {
1544 let (splitter, splitter_is_ws, splits) = split_text(text);
1545
1546 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1547 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1548 cum_lens.push(0usize);
1549 for l in &split_lens {
1550 cum_lens.push(cum_lens.last().unwrap() + l);
1551 }
1552 let num_splits_plus_one = splits.len() + 1;
1553
1554 let mut chunks: Vec<String> = Vec::new();
1555 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1556
1557 for i in 0..splits.len() {
1558 if skips.contains(&i) {
1559 continue;
1560 }
1561 let split = &splits[i];
1562 if counter(split) > chunk_size {
1563 let inner = semchunk_rec(split, chunk_size, counter);
1564 chunks.extend(inner);
1565 } else {
1566 let (end, merged) = merge_splits(
1567 &splits,
1568 &cum_lens,
1569 chunk_size,
1570 &splitter,
1571 counter,
1572 i,
1573 num_splits_plus_one,
1574 );
1575 for j in (i + 1)..end {
1576 skips.insert(j);
1577 }
1578 chunks.push(merged);
1579 }
1580 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1583 if !splitter_is_ws && !is_last {
1584 let with_splitter = format!(
1585 "{}{}",
1586 chunks.last().map(String::as_str).unwrap_or(""),
1587 splitter
1588 );
1589 if counter(&with_splitter) <= chunk_size {
1590 if let Some(last) = chunks.last_mut() {
1591 *last = with_splitter;
1592 } else {
1593 chunks.push(with_splitter);
1594 }
1595 } else {
1596 chunks.push(splitter.clone());
1597 }
1598 }
1599 }
1600 chunks
1601}
1602
1603fn merge_splits(
1606 splits: &[String],
1607 cum_lens: &[usize],
1608 chunk_size: usize,
1609 splitter: &str,
1610 counter: &mut dyn FnMut(&str) -> usize,
1611 start: usize,
1612 high_init: usize,
1613) -> (usize, String) {
1614 let mut average = 0.2f64;
1615 let mut low = start;
1616 let mut high = high_init;
1617 let offset = cum_lens[start];
1618 let mut target = offset as f64 + (chunk_size as f64 * average);
1619
1620 while low < high {
1621 let i = bisect_left(cum_lens, target, low, high);
1622 let midpoint = i.min(high - 1);
1623 let joined = splits[start..midpoint.max(start)].join(splitter);
1624 let tokens = counter(&joined);
1625 let local_cum = cum_lens[midpoint] - offset;
1626 if local_cum > 0 && tokens > 0 {
1627 average = local_cum as f64 / tokens as f64;
1628 target = offset as f64 + (chunk_size as f64 * average);
1629 }
1630 if tokens > chunk_size {
1631 high = midpoint;
1632 } else {
1633 low = midpoint + 1;
1634 }
1635 }
1636 let end = low - 1;
1637 (end, splits[start..end.max(start)].join(splitter))
1638}
1639
1640fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1641 while low < high {
1642 let mid = (low + high) / 2;
1643 if (sorted[mid] as f64) < target {
1644 low = mid + 1;
1645 } else {
1646 high = mid;
1647 }
1648 }
1649 low
1650}
1651
1652fn split_text(text: &str) -> (String, bool, Vec<String>) {
1654 if text.contains('\n') || text.contains('\r') {
1656 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1657 return (splitter.clone(), true, split_on(text, &splitter));
1658 }
1659 if text.contains('\t') {
1661 let splitter = longest_run(text, |c| c == '\t');
1662 return (splitter.clone(), true, split_on(text, &splitter));
1663 }
1664 if text.chars().any(char::is_whitespace) {
1666 let splitter = longest_run(text, char::is_whitespace);
1667 if splitter.chars().count() == 1 {
1668 for preceder in NON_WS_SPLITTERS {
1670 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1671 return (ws, true, parts);
1672 }
1673 }
1674 }
1675 return (splitter.clone(), true, split_on(text, &splitter));
1676 }
1677 for s in NON_WS_SPLITTERS {
1679 if text.contains(s) {
1680 return (s.to_string(), false, split_on(text, s));
1681 }
1682 }
1683 (
1685 String::new(),
1686 true,
1687 text.chars().map(|c| c.to_string()).collect(),
1688 )
1689}
1690
1691fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1693 let mut best = String::new();
1694 let mut cur = String::new();
1695 for c in text.chars() {
1696 if pred(c) {
1697 cur.push(c);
1698 } else {
1699 if cur.chars().count() > best.chars().count() {
1700 best = cur.clone();
1701 }
1702 cur.clear();
1703 }
1704 }
1705 if cur.chars().count() > best.chars().count() {
1706 best = cur;
1707 }
1708 best
1709}
1710
1711fn split_on(text: &str, splitter: &str) -> Vec<String> {
1712 text.split(splitter).map(str::to_string).collect()
1713}
1714
1715fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1719 let chars: Vec<char> = text.chars().collect();
1720 let p: Vec<char> = preceder.chars().collect();
1721 let mut ws: Option<char> = None;
1722 for i in p.len()..chars.len() {
1723 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1724 ws = Some(chars[i]);
1725 break;
1726 }
1727 }
1728 let ws = ws?;
1729 let mut parts = Vec::new();
1730 let mut cur = String::new();
1731 let mut i = 0usize;
1732 while i < chars.len() {
1733 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1734 parts.push(std::mem::take(&mut cur));
1735 i += 1;
1736 continue;
1737 }
1738 cur.push(chars[i]);
1739 i += 1;
1740 }
1741 parts.push(cur);
1742 Some((ws.to_string(), parts))
1743}
1744
1745#[cfg(feature = "chunking")]
1750mod hf {
1751 use super::ChunkTokenizer;
1752
1753 pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1758
1759 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1764 if let Some(p) = explicit {
1765 return Ok(p.to_string());
1766 }
1767 let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1768 if std::path::Path::new(&resolved).exists() {
1769 return Ok(resolved);
1770 }
1771 Err(format!(
1772 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1773 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1774 scripts/install/download_dependencies.sh (or pass an explicit path)"
1775 ))
1776 }
1777
1778 pub struct HuggingFaceTokenizer {
1782 tok: tokenizers::Tokenizer,
1783 max_tokens: usize,
1784 }
1785
1786 impl HuggingFaceTokenizer {
1787 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1791 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1792 }
1793
1794 pub fn from_file(
1798 path: impl AsRef<std::path::Path>,
1799 max_tokens: usize,
1800 ) -> Result<Self, String> {
1801 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1802 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1803 let _ = tok.with_truncation(None);
1808 tok.with_padding(None);
1809 Ok(Self { tok, max_tokens })
1810 }
1811 }
1812
1813 impl ChunkTokenizer for HuggingFaceTokenizer {
1814 fn count_tokens(&self, text: &str) -> usize {
1815 self.tok
1816 .encode(text, false)
1817 .map(|e| e.get_tokens().len())
1818 .unwrap_or(0)
1819 }
1820 fn max_tokens(&self) -> usize {
1821 self.max_tokens
1822 }
1823 }
1824}
1825
1826#[cfg(feature = "chunking")]
1827pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1828
1829#[cfg(feature = "chunking")]
1834mod window {
1835 use super::DocChunk;
1836 use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1837
1838 #[derive(Debug, Clone, Default)]
1840 pub struct Section {
1841 pub heading_path: Vec<String>,
1844 pub words: Vec<String>,
1846 }
1847
1848 impl Section {
1849 pub fn heading_context(&self) -> String {
1852 if self.heading_path.is_empty() {
1853 String::new()
1854 } else {
1855 format!("# {}", self.heading_path.join(" > "))
1856 }
1857 }
1858 }
1859
1860 fn level_index(level: HeadingLevel) -> usize {
1861 match level {
1862 HeadingLevel::H1 => 1,
1863 HeadingLevel::H2 => 2,
1864 HeadingLevel::H3 => 3,
1865 HeadingLevel::H4 => 4,
1866 HeadingLevel::H5 => 5,
1867 HeadingLevel::H6 => 6,
1868 }
1869 }
1870
1871 pub fn parse_sections(markdown: &str) -> Vec<Section> {
1875 parse_sections_with_stack(markdown, Vec::new()).0
1876 }
1877
1878 pub fn parse_sections_with_stack(
1883 markdown: &str,
1884 initial_stack: Vec<String>,
1885 ) -> (Vec<Section>, Vec<String>) {
1886 let mut heading_stack: Vec<String> = initial_stack;
1887 let mut sections: Vec<Section> = Vec::new();
1888 let mut current = Section {
1891 heading_path: heading_stack
1892 .iter()
1893 .filter(|h| !h.is_empty())
1894 .cloned()
1895 .collect(),
1896 words: Vec::new(),
1897 };
1898
1899 let mut in_heading = false;
1900 let mut heading_level = 0usize;
1901 let mut heading_buf = String::new();
1902
1903 let push_words = |section: &mut Section, text: &str| {
1904 for w in text.split_whitespace() {
1905 section.words.push(w.to_string());
1906 }
1907 };
1908
1909 let flush = |sections: &mut Vec<Section>, section: &mut Section| {
1910 if !section.words.is_empty() {
1911 sections.push(std::mem::take(section));
1912 } else {
1913 *section = Section::default();
1914 }
1915 };
1916
1917 for event in Parser::new(markdown) {
1918 match event {
1919 Event::Start(Tag::Heading { level, .. }) => {
1920 in_heading = true;
1921 heading_level = level_index(level);
1922 heading_buf.clear();
1923 }
1924 Event::End(TagEnd::Heading(_)) => {
1925 in_heading = false;
1926 let idx = heading_level.saturating_sub(1);
1928 if heading_stack.len() <= idx {
1929 heading_stack.resize(idx + 1, String::new());
1930 } else {
1931 heading_stack.truncate(idx + 1);
1932 }
1933 heading_stack[idx] = heading_buf.trim().to_string();
1934 flush(&mut sections, &mut current);
1936 current.heading_path = heading_stack
1937 .iter()
1938 .filter(|h| !h.is_empty())
1939 .cloned()
1940 .collect();
1941 }
1942 Event::Text(t) | Event::Code(t) => {
1943 if in_heading {
1944 if !heading_buf.is_empty() {
1945 heading_buf.push(' ');
1946 }
1947 heading_buf.push_str(&t);
1948 } else {
1949 push_words(&mut current, &t);
1950 }
1951 }
1952 Event::SoftBreak | Event::HardBreak | Event::Rule => {}
1954 _ => {}
1955 }
1956 }
1957 flush(&mut sections, &mut current);
1958 (sections, heading_stack)
1959 }
1960
1961 #[derive(Debug, Clone)]
1967 pub struct WindowChunker {
1968 pub max_words: usize,
1970 pub overlap: f32,
1972 }
1973
1974 impl Default for WindowChunker {
1975 fn default() -> Self {
1976 WindowChunker {
1977 max_words: 300,
1978 overlap: 0.05,
1979 }
1980 }
1981 }
1982
1983 impl WindowChunker {
1984 pub fn new(max_words: usize, overlap: f32) -> Self {
1985 WindowChunker { max_words, overlap }
1986 }
1987
1988 fn word_budget(&self) -> usize {
1990 self.max_words.max(1)
1991 }
1992
1993 fn overlap_words(&self, budget: usize) -> usize {
1996 let o = (budget as f32 * self.overlap).round() as usize;
1997 o.min(budget.saturating_sub(1))
1998 }
1999
2000 pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
2002 let mut chunks = Vec::new();
2003 self.chunk_with(markdown, &mut |c| {
2004 chunks.push(c);
2005 true
2006 });
2007 chunks
2008 }
2009
2010 pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2014 let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2015 for section in §ions {
2016 if !self.pack_section(section, sink) {
2017 return;
2018 }
2019 }
2020 }
2021
2022 pub fn pack_section(
2026 &self,
2027 section: &Section,
2028 sink: &mut dyn FnMut(DocChunk) -> bool,
2029 ) -> bool {
2030 let words = §ion.words;
2031 if words.is_empty() {
2032 return true;
2033 }
2034 let budget = self.word_budget();
2035 let step = budget - self.overlap_words(budget); let mut start = 0;
2037 loop {
2038 let end = (start + budget).min(words.len());
2039 let chunk = DocChunk {
2040 text: words[start..end].join(" "),
2041 headings: (!section.heading_path.is_empty())
2042 .then(|| section.heading_path.clone()),
2043 doc_items: Vec::new(),
2044 };
2045 if !sink(chunk) {
2046 return false;
2047 }
2048 if end >= words.len() {
2049 return true;
2050 }
2051 start += step;
2052 }
2053 }
2054
2055 pub fn contextualize(chunk: &DocChunk) -> String {
2061 match &chunk.headings {
2062 Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2063 _ => chunk.text.clone(),
2064 }
2065 }
2066 }
2067
2068 #[cfg(test)]
2069 mod tests {
2070 use super::*;
2071
2072 #[test]
2073 fn splits_on_headings_and_tracks_path() {
2074 let md = "\
2075intro words
2076# Chapter 1
2077para one
2078## Section 1.1
2079para two
2080# Chapter 2
2081para three";
2082 let secs = parse_sections(md);
2083 assert_eq!(secs.len(), 4);
2085 assert!(secs[0].heading_path.is_empty());
2086 assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2087 assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2088 assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2090 }
2091
2092 #[test]
2093 fn strips_markup_to_plain_words() {
2094 let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2095 let secs = parse_sections(md);
2096 let words = &secs[0].words;
2097 assert!(words.contains(&"bold".to_string()));
2098 assert!(words.contains(&"code".to_string()));
2099 assert!(words.contains(&"link".to_string()));
2100 assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2102 }
2103
2104 #[test]
2105 fn windows_overlap_and_never_cross_headings() {
2106 let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2107 let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2108 let chunker = WindowChunker::new(10, 0.2); let chunks = chunker.chunk(&md);
2110 let a: Vec<_> = chunks
2112 .iter()
2113 .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2114 .collect();
2115 assert_eq!(a.len(), 3);
2116 assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2117 assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2118 assert!(a[2].text.ends_with(" w24"));
2119 let b: Vec<_> = chunks
2121 .iter()
2122 .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2123 .collect();
2124 assert_eq!(b.len(), 1);
2125 assert_eq!(b[0].text, "short tail");
2126 assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2127 }
2128
2129 #[test]
2130 fn sink_false_cancels_the_window_walk() {
2131 let md = format!(
2132 "# A\n\n{}\n",
2133 (0..50)
2134 .map(|i| format!("w{i}"))
2135 .collect::<Vec<_>>()
2136 .join(" ")
2137 );
2138 let chunker = WindowChunker::new(10, 0.0);
2139 let mut n = 0;
2140 chunker.chunk_with(&md, &mut |_| {
2141 n += 1;
2142 false
2143 });
2144 assert_eq!(n, 1);
2145 }
2146 }
2147}
2148
2149#[cfg(feature = "chunking")]
2150pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2151
2152#[cfg(test)]
2153mod tests {
2154 use super::*;
2155
2156 struct WordTok(usize);
2158 impl ChunkTokenizer for WordTok {
2159 fn count_tokens(&self, text: &str) -> usize {
2160 text.split_whitespace().count()
2161 }
2162 fn max_tokens(&self) -> usize {
2163 self.0
2164 }
2165 }
2166
2167 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2168 let mut d = DoclingDocument::new("t");
2169 for n in nodes {
2170 d.push(n);
2171 }
2172 d
2173 }
2174
2175 #[test]
2176 fn hierarchical_headings_and_items() {
2177 let doc = doc_with(vec![
2178 Node::Heading {
2179 level: 1,
2180 text: "Title".into(),
2181 },
2182 Node::Paragraph {
2183 text: "Intro".into(),
2184 },
2185 Node::Heading {
2186 level: 2,
2187 text: "Sec".into(),
2188 },
2189 Node::Paragraph {
2190 text: "Body".into(),
2191 },
2192 ]);
2193 let chunks = HierarchicalChunker.chunk(&doc);
2194 assert_eq!(chunks.len(), 2);
2195 assert_eq!(chunks[0].text, "Intro");
2196 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2197 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2198 assert_eq!(
2199 chunks[1].headings.as_deref(),
2200 Some(&["Title".into(), "Sec".into()][..])
2201 );
2202 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2203 }
2204
2205 #[test]
2206 fn heading_shadowing_prunes_deeper_levels() {
2207 let doc = doc_with(vec![
2208 Node::Heading {
2209 level: 2,
2210 text: "A".into(),
2211 },
2212 Node::Heading {
2213 level: 3,
2214 text: "A.1".into(),
2215 },
2216 Node::Heading {
2217 level: 2,
2218 text: "B".into(),
2219 },
2220 Node::Paragraph { text: "p".into() },
2221 ]);
2222 let chunks = HierarchicalChunker.chunk(&doc);
2223 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2224 }
2225
2226 #[test]
2227 fn triplet_table() {
2228 let t = Table {
2229 rows: vec![
2230 vec!["".into(), "Col1".into()],
2231 vec!["Row1".into(), "v".into()],
2232 ],
2233 ..Default::default()
2234 };
2235 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2236 let single = Table {
2239 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2240 ..Default::default()
2241 };
2242 assert_eq!(triplet_table_text(&single), "a = b");
2243 }
2244
2245 #[test]
2246 fn hybrid_merges_small_peers_and_splits_large() {
2247 let doc = doc_with(vec![
2248 Node::Heading {
2249 level: 2,
2250 text: "S".into(),
2251 },
2252 Node::Paragraph { text: "a b".into() },
2253 Node::Paragraph { text: "c d".into() },
2254 ]);
2255 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2256 assert_eq!(chunks.len(), 1, "peers under one heading merge");
2257 assert_eq!(chunks[0].text, "a b\nc d");
2258
2259 let long = "w ".repeat(40).trim().to_string();
2260 let doc = doc_with(vec![Node::Paragraph { text: long }]);
2261 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2262 assert!(chunks.len() > 1, "oversized paragraph splits");
2263 for c in &chunks {
2264 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2265 }
2266 }
2267
2268 #[test]
2269 fn semchunk_prefers_newlines_then_sentences() {
2270 let tok = WordTok(4);
2271 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2272 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2273 }
2274}