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 for k in 0..run.len() {
273 let Node::ListItem {
274 first_in_list,
275 level,
276 ..
277 } = &run[k]
278 else {
279 continue;
280 };
281 if *level != base {
282 continue; }
284 if k > seg && *first_in_list {
286 self.list(&run[seg..k]);
287 seg = k;
288 }
289 }
290 self.list(&run[seg..]);
291 }
292
293 fn list(&mut self, items: &[Node]) {
297 self.alloc.group();
298 let mut chunk_items = Vec::new();
299 self.list_refs(items, &mut chunk_items);
300 let text = render_list(items);
301 self.emit(text, chunk_items);
302 }
303
304 fn list_refs(&mut self, items: &[Node], out: &mut Vec<ChunkItem>) {
308 let base = level_of(&items[0]);
309 let mut i = 0;
310 while i < items.len() {
311 let Node::ListItem {
312 ordered,
313 number,
314 text,
315 level,
316 layer,
317 ..
318 } = &items[i]
319 else {
320 i += 1;
321 continue;
322 };
323 if *level > base {
324 i += 1;
325 continue;
326 }
327 let item_ref = self.alloc.text();
328 let mut j = i + 1;
329 while j < items.len() && level_of(&items[j]) > base {
330 j += 1;
331 }
332 let has_nested = j > i + 1;
333 if layer.is_none() {
334 let marker = if *ordered {
335 format!("{number}.")
336 } else {
337 "-".to_string()
338 };
339 let has_pics = text.contains("<!-- image -->");
353 let text = strip_image_markers(text);
354 let text = text.as_str();
355 let segments = inline_segments(text);
356 if (has_nested || has_pics) && segments.len() > 1 && text.contains("](") {
357 out.push(ChunkItem {
358 self_ref: item_ref.clone(),
359 kind: ChunkItemKind::Text,
360 text: format!("{marker} "),
361 });
362 for seg in segments {
363 out.push(ChunkItem {
364 self_ref: item_ref.clone(),
365 kind: ChunkItemKind::Text,
366 text: seg,
367 });
368 }
369 } else {
370 out.push(ChunkItem {
371 self_ref: item_ref.clone(),
372 kind: ChunkItemKind::Text,
373 text: format!("{marker} {}", unescape_text(text)),
374 });
375 }
376 }
377 if j > i + 1 {
380 self.nested_sibling_lists(&items[i + 1..j], out);
381 }
382 i = j;
383 }
384 }
385
386 fn nested_sibling_lists(&mut self, run: &[Node], out: &mut Vec<ChunkItem>) {
387 let base = level_of(&run[0]);
388 let mut seg = 0;
389 for k in 0..run.len() {
390 let Node::ListItem {
391 first_in_list,
392 level,
393 ..
394 } = &run[k]
395 else {
396 continue;
397 };
398 if *level != base {
399 continue;
400 }
401 if k > seg && *first_in_list {
402 self.alloc.group();
403 self.list_refs(&run[seg..k], out);
404 seg = k;
405 }
406 }
407 self.alloc.group();
408 self.list_refs(&run[seg..], out);
409 }
410
411 fn one(&mut self, node: &Node) {
412 match node {
413 Node::Heading { level, text } => {
414 let doc_level = if *level == 1 {
415 0
416 } else {
417 level.saturating_sub(1)
418 };
419 let self_ref = self.alloc.text();
420 let runs = crate::inline_runs_from_markdown(text);
426 if runs.len() <= 1 {
427 let plain = runs
428 .first()
429 .map(|r| r.text.clone())
430 .unwrap_or_else(|| text.clone());
431 self.set_heading(doc_level, unescape_text(&plain));
432 } else {
433 self.set_heading(doc_level, String::new());
434 let body = unescape_text(text);
435 self.emit(
436 body.clone(),
437 vec![ChunkItem {
438 self_ref,
439 kind: ChunkItemKind::Text,
440 text: body,
441 }],
442 );
443 }
444 }
445 Node::Paragraph { text } => {
446 let t = text.trim();
447 let self_ref = self.alloc.text();
448 if let Some(inner) = t
451 .strip_prefix("$$")
452 .and_then(|s| s.strip_suffix("$$"))
453 .filter(|s| !s.is_empty())
454 {
455 let body = format!("$${inner}$$");
456 self.emit(
457 body.clone(),
458 vec![ChunkItem {
459 self_ref,
460 kind: ChunkItemKind::Text,
461 text: body,
462 }],
463 );
464 return;
465 }
466 self.emit_inline(text, self_ref);
467 }
468 Node::Caption { text, .. } => {
471 let self_ref = self.alloc.text();
472 self.emit_inline(text, self_ref);
473 }
474 Node::CheckboxItem { checked, text } => {
475 let self_ref = self.alloc.text();
476 let mark = if *checked { "- [x] " } else { "- [ ] " };
477 let body = format!("{mark}{}", 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::Formula { latex, .. } => {
490 let self_ref = self.alloc.text();
491 let body = format!("$${}$$", latex);
492 self.emit(
493 body.clone(),
494 vec![ChunkItem {
495 self_ref,
496 kind: ChunkItemKind::Text,
497 text: body,
498 }],
499 );
500 }
501 Node::Code { text, .. } => {
502 let self_ref = self.alloc.text();
503 let body = format!("```\n{}\n```", unescape_text(text));
504 self.emit(
505 body.clone(),
506 vec![ChunkItem {
507 self_ref,
508 kind: ChunkItemKind::Text,
509 text: body,
510 }],
511 );
512 }
513 Node::Table(t) => {
514 let self_ref = self.alloc.table();
515 let body = triplet_table_text(t);
516 self.emit(
517 body.clone(),
518 vec![ChunkItem {
519 self_ref,
520 kind: ChunkItemKind::Table,
521 text: body,
522 }],
523 );
524 }
525 Node::Picture { caption, .. } => {
526 let cap = caption.as_deref().filter(|c| !c.is_empty());
527 let cap_item = cap.map(|c| ChunkItem {
528 self_ref: self.alloc.text(),
529 kind: ChunkItemKind::Text,
530 text: unescape_text(c),
531 });
532 self.alloc.picture();
533 if let Some(cap_item) = cap_item {
537 let body = cap_item.text.clone();
538 self.emit(body, vec![cap_item]);
539 }
540 }
541 Node::Chart {
542 kind,
543 table,
544 caption,
545 ..
546 } => {
547 let cap = caption.as_deref().filter(|c| !c.is_empty());
548 let cap_item = cap.map(|c| ChunkItem {
549 self_ref: self.alloc.text(),
550 kind: ChunkItemKind::Text,
551 text: unescape_text(c),
552 });
553 let pic_ref = self.alloc.picture();
554 let mut parts: Vec<String> = Vec::new();
558 if let Some(ci) = &cap_item {
559 parts.push(ci.text.clone());
560 }
561 parts.push(humanize_label(kind));
562 let grid = crate::markdown::render_table(table, false);
563 if !grid.is_empty() {
564 parts.push(unescape_text(&grid));
565 }
566 let body = parts.join("\n\n");
567 let pic_item = ChunkItem {
572 self_ref: pic_ref,
573 kind: ChunkItemKind::Picture,
574 text: body.clone(),
575 };
576 let items = match cap_item {
577 Some(mut ci) => {
578 ci.text = String::new();
579 vec![ci, pic_item]
580 }
581 None => vec![pic_item],
582 };
583 self.emit(body, items);
584 }
585 Node::Group { layer: Some(_), .. } => {}
588 Node::Group { children, .. } => {
589 self.alloc.group();
592 self.walk(children);
593 }
594 Node::FieldRegion { items } => {
595 self.alloc.field_region();
598 for item in items {
599 self.alloc.field_item();
600 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
601 let self_ref = self.alloc.text();
602 let body = unescape_text(part);
603 self.emit(
604 body.clone(),
605 vec![ChunkItem {
606 self_ref,
607 kind: ChunkItemKind::Text,
608 text: body,
609 }],
610 );
611 }
612 }
613 }
614 Node::InlineGroup { md_text, runs, .. } => {
615 let self_ref = self.alloc.text();
616 self.emit_inline_with_runs(md_text, self_ref, runs);
617 }
618 Node::TextDump(text) => {
619 let self_ref = self.alloc.text();
620 let body = unescape_text(text);
621 self.emit(
622 body.clone(),
623 vec![ChunkItem {
624 self_ref,
625 kind: ChunkItemKind::Text,
626 text: body,
627 }],
628 );
629 }
630 Node::Located { inner, .. }
632 | Node::Prov { inner, .. }
633 | Node::Commented { inner, .. } => self.one(inner),
634 Node::CommentSection { .. }
637 | Node::Furniture { .. }
638 | Node::PageFurniture { .. }
639 | Node::PageBreak
640 | Node::PageInfo { .. }
641 | Node::DoclangOnly(_) => {}
642 Node::ListItem { .. } => self.sibling_lists(std::slice::from_ref(node)),
645 }
646 }
647}
648
649fn level_of(node: &Node) -> u8 {
650 match node {
651 Node::ListItem { level, .. } => *level,
652 _ => 0,
653 }
654}
655
656fn render_list(items: &[Node]) -> String {
659 let mut lines: Vec<String> = Vec::new();
660 for item in items {
661 let Node::ListItem {
662 ordered,
663 number,
664 text,
665 level,
666 layer,
667 ..
668 } = item
669 else {
670 continue;
671 };
672 if layer.is_some() {
673 continue;
674 }
675 let indent = " ".repeat(*level as usize);
676 let marker = if *ordered {
677 format!("{number}.")
678 } else {
679 "-".to_string()
680 };
681 lines.push(format!(
682 "{indent}{marker} {}",
683 unescape_text(&strip_image_markers(text))
684 ));
685 }
686 lines.join("\n")
687}
688
689fn strip_image_markers(text: &str) -> String {
694 if !text.contains("<!-- image -->") {
695 return text.to_string();
696 }
697 let cleaned: Vec<&str> = text
698 .split('\n')
699 .map(str::trim_end)
700 .filter(|l| *l != "<!-- image -->")
701 .collect();
702 cleaned.join("\n").trim_end().to_string()
703}
704
705fn humanize_label(label: &str) -> String {
708 let text = label.replace('_', " ");
709 let mut chars = text.chars();
710 match chars.next() {
711 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
712 None => text,
713 }
714}
715
716fn triplet_table_text(t: &Table) -> String {
723 let rows: Vec<Vec<String>> = t
724 .rows
725 .iter()
726 .enumerate()
727 .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
728 .collect();
729 let num_rows = rows.len();
730 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
731 if num_rows == 0 || num_cols == 0 {
732 return String::new();
733 }
734 let cell = |r: usize, c: usize| -> &str {
735 rows.get(r)
736 .and_then(|row| row.get(c))
737 .map(String::as_str)
738 .unwrap_or("")
739 };
740
741 let num_headers = {
747 let derived;
748 let cells: &[crate::TableCell] = match &t.cells {
749 Some(c) if !c.is_empty() => c,
750 _ => {
751 derived = t.derive_cells();
752 &derived
753 }
754 };
755 (0..num_rows)
756 .take_while(|&r| cells.iter().any(|c| c.column_header && c.start_row == r))
757 .count()
758 };
759
760 let columns: Vec<String> = if num_headers > 0 {
763 (0..num_cols)
764 .map(|c| {
765 let mut name = String::new();
766 for r in 0..num_headers {
767 if !name.is_empty() {
768 name.push('.');
769 }
770 name.push_str(cell(r, c));
771 }
772 name
773 })
774 .collect()
775 } else {
776 (0..num_cols).map(|c| c.to_string()).collect()
777 };
778 let data_rows = num_headers..num_rows;
779 let n_data = data_rows.len();
780
781 if n_data == 0 {
783 return columns
784 .iter()
785 .map(|s| s.trim())
786 .filter(|s| !s.is_empty())
787 .collect::<Vec<_>>()
788 .join(". ");
789 }
790
791 let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
792 let text = if num_cols == 1 {
793 let col_name = data(0, 0).trim().to_string();
796 if n_data == 1 {
797 col_name
798 } else {
799 (1..n_data)
800 .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
801 .collect::<Vec<_>>()
802 .join(". ")
803 }
804 } else {
805 let mut parts = Vec::new();
807 for r in 0..n_data {
808 for (c, col_name) in columns.iter().enumerate().skip(1) {
809 parts.push(format!(
810 "{}, {} = {}",
811 data(r, 0).trim(),
812 col_name.trim(),
813 data(r, c).trim()
814 ));
815 }
816 }
817 parts.join(". ")
818 };
819 if !text.is_empty() {
820 return text;
821 }
822
823 (0..n_data)
826 .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
827 .map(|(r, c)| data(r, c).trim())
828 .filter(|s| !s.is_empty())
829 .collect::<Vec<_>>()
830 .join(". ")
831}
832
833fn inline_segments(md: &str) -> Vec<String> {
841 inline_segments_tagged(md)
842 .into_iter()
843 .map(|(t, _)| t)
844 .collect()
845}
846
847fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
851 let chars: Vec<char> = md.chars().collect();
852 let n = chars.len();
853 let find = |from: usize, pat: &str| -> Option<usize> {
854 let hay: String = chars[from..].iter().collect();
855 hay.find(pat).map(|p| from + hay[..p].chars().count())
856 };
857 let mut out: Vec<(String, bool)> = Vec::new();
858 let mut plain = String::new();
859 let mut after_span = false;
860
861 fn flush(
862 out: &mut Vec<(String, bool)>,
863 plain: &mut String,
864 before_span: bool,
865 after_span: bool,
866 ) {
867 let mut p = std::mem::take(plain);
868 if after_span {
869 if let Some(rest) = p.strip_prefix(' ') {
870 p = rest.to_string();
871 }
872 }
873 if before_span {
874 if let Some(rest) = p.strip_suffix(' ') {
875 p = rest.to_string();
876 }
877 }
878 if !p.is_empty() {
879 out.push((unescape_text(&p), true));
880 }
881 }
882
883 let mut i = 0;
884 while i < n {
885 let rest: String = chars[i..].iter().collect();
886 if chars[i] == '[' && !rest.starts_with("[](") {
890 let balanced = |c: usize| {
894 let mut d = 0i32;
895 for &ch in &chars[i + 1..c] {
896 match ch {
897 '[' => d += 1,
898 ']' => d -= 1,
899 _ => {}
900 }
901 }
902 d == 0
903 };
904 if let Some(close) = find(i + 1, "](").filter(|&c| balanced(c)) {
905 let mut depth = 0usize;
906 let mut url_end = None;
907 for (k, &c) in chars.iter().enumerate().skip(close + 2) {
908 match c {
909 '(' => depth += 1,
910 ')' => {
911 if depth == 0 {
912 url_end = Some(k);
913 break;
914 }
915 depth -= 1;
916 }
917 _ => {}
918 }
919 }
920 if let Some(endp) = url_end {
921 flush(&mut out, &mut plain, true, after_span);
922 out.push((
923 unescape_text(&chars[i..=endp].iter().collect::<String>()),
924 false,
925 ));
926 i = endp + 1;
927 after_span = true;
928 continue;
929 }
930 }
931 }
932 let mut matched = false;
935 for marker in ["***", "**", "*", "~~", "`"] {
936 if rest.starts_with(marker) {
937 let mlen = marker.chars().count();
938 if let Some(end) = find(i + mlen, marker) {
939 let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
943 if end > i + mlen && !inner_blank {
944 flush(&mut out, &mut plain, true, after_span);
945 if marker == "`" {
946 let inner: String = chars[i + 1..end].iter().collect();
947 out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
948 } else {
949 out.push((
950 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
951 false,
952 ));
953 }
954 i = end + mlen;
955 after_span = true;
956 matched = true;
957 }
958 }
959 break;
960 }
961 }
962 if matched {
963 continue;
964 }
965 if rest.starts_with("$$") {
968 plain.push_str("$$");
969 i += 2;
970 continue;
971 }
972 if chars[i] == '$' {
974 if let Some(end) = find(i + 1, "$") {
975 if end > i + 1 {
976 flush(&mut out, &mut plain, true, after_span);
977 let latex: String = chars[i + 1..end].iter().collect();
978 out.push((format!("$${latex}$$"), false));
979 i = end + 1;
980 after_span = true;
981 continue;
982 }
983 }
984 }
985 plain.push(chars[i]);
986 i += 1;
987 }
988 flush(&mut out, &mut plain, false, after_span);
989 if out.is_empty() {
990 out.push((unescape_text(md), true));
991 }
992 out
993}
994
995fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1000 let target = segment.trim();
1001 if target.is_empty() {
1002 return None;
1003 }
1004 let plainish =
1005 |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1006 let fully_plain =
1007 |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1008 let unmarked: Vec<(&str, bool)> = runs
1009 .iter()
1010 .filter(|r| plainish(r))
1011 .map(|r| (r.text.as_str(), fully_plain(r)))
1012 .collect();
1013 for start in 0..unmarked.len() {
1014 let mut rest = target;
1015 let mut taken: Vec<(String, bool)> = Vec::new();
1016 for (t, fully) in &unmarked[start..] {
1017 let t = t.trim();
1018 if t.is_empty() {
1019 continue;
1020 }
1021 match rest.strip_prefix(t) {
1022 Some(r) => {
1023 taken.push((unescape_text(t), *fully));
1024 rest = r.trim_start();
1025 if rest.is_empty() {
1026 break;
1027 }
1028 }
1029 None => break,
1030 }
1031 }
1032 if rest.is_empty() && taken.len() >= 2 {
1033 let mut merged: Vec<(String, bool)> = Vec::new();
1038 for (t, fully) in taken {
1039 match merged.last_mut() {
1040 Some((last, true)) if fully => {
1041 last.push(' ');
1042 last.push_str(&t);
1043 }
1044 _ => merged.push((t, fully)),
1045 }
1046 }
1047 if merged.len() >= 2 {
1048 return Some(merged.into_iter().map(|(t, _)| t).collect());
1049 }
1050 return None;
1051 }
1052 }
1053 None
1054}
1055
1056fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
1063 if let Some(blocks) = t
1064 .cell_blocks
1065 .as_ref()
1066 .and_then(|b| b.get(r))
1067 .and_then(|row| row.get(c))
1068 .filter(|b| !b.is_empty())
1069 {
1070 let mut parts: Vec<String> = Vec::new();
1071 for node in blocks.iter() {
1072 let part = block_chunk_text(node);
1073 if !part.is_empty() {
1074 parts.push(part);
1075 }
1076 }
1077 return parts.join("\n\n");
1078 }
1079 let flat = t
1080 .rows
1081 .get(r)
1082 .and_then(|row| row.get(c))
1083 .map(String::as_str)
1084 .unwrap_or("");
1085 unescape_text(flat)
1086 .replace("<!-- image -->", "")
1087 .trim()
1088 .to_string()
1089}
1090
1091fn block_chunk_text(node: &Node) -> String {
1093 match node {
1094 Node::Paragraph { text } => unescape_text(text),
1095 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1096 Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1097 Node::Table(inner) => triplet_table_text(inner),
1098 Node::Picture { caption, .. } => caption
1099 .as_deref()
1100 .filter(|c| !c.is_empty())
1101 .map(unescape_text)
1102 .unwrap_or_default(),
1103 Node::ListItem {
1104 ordered,
1105 number,
1106 text,
1107 ..
1108 } => {
1109 let marker = if *ordered {
1110 format!("{number}.")
1111 } else {
1112 "-".to_string()
1113 };
1114 format!("{marker} {}", unescape_text(text))
1115 }
1116 Node::CheckboxItem { checked, text } => {
1117 let mark = if *checked { "- [x] " } else { "- [ ] " };
1118 format!("{mark}{}", unescape_text(text))
1119 }
1120 Node::Heading { text, .. } => unescape_text(text),
1121 Node::Located { inner, .. } | Node::Prov { inner, .. } | Node::Commented { inner, .. } => {
1122 block_chunk_text(inner)
1123 }
1124 Node::Group { layer: Some(_), .. } => String::new(),
1125 Node::Group { children, .. } => children
1126 .iter()
1127 .map(block_chunk_text)
1128 .filter(|s| !s.is_empty())
1129 .collect::<Vec<_>>()
1130 .join("\n"),
1131 _ => String::new(),
1132 }
1133}
1134
1135fn unescape_text(s: &str) -> String {
1138 s.replace("<", "<")
1139 .replace(">", ">")
1140 .replace("&", "&")
1141 .replace("\\_", "_")
1142}
1143
1144pub trait ChunkTokenizer {
1150 fn count_tokens(&self, text: &str) -> usize;
1152 fn max_tokens(&self) -> usize;
1154}
1155
1156pub struct HybridChunker<T: ChunkTokenizer> {
1160 tokenizer: T,
1161 merge_peers: bool,
1162}
1163
1164impl<T: ChunkTokenizer> HybridChunker<T> {
1165 pub fn new(tokenizer: T) -> Self {
1166 Self {
1167 tokenizer,
1168 merge_peers: true,
1169 }
1170 }
1171
1172 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1174 self.merge_peers = merge_peers;
1175 self
1176 }
1177
1178 pub fn max_tokens(&self) -> usize {
1179 self.tokenizer.max_tokens()
1180 }
1181
1182 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1184 let mut chunks = Vec::new();
1185 self.chunk_with(doc, &mut |c| {
1186 chunks.push(c);
1187 true
1188 });
1189 chunks
1190 }
1191
1192 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1199 let mut merger = PeerMerger::default();
1200 let mut alive = true;
1201 HierarchicalChunker.chunk_with(doc, &mut |c| {
1202 for split in self.split_by_doc_items(c) {
1203 for chunk in self.split_using_plain_text(split) {
1204 if !alive {
1205 return false;
1206 }
1207 alive = if self.merge_peers {
1208 self.merge_push(&mut merger, chunk, sink)
1209 } else {
1210 sink(chunk)
1211 };
1212 }
1213 }
1214 alive
1215 });
1216 if alive {
1217 self.merge_flush(&mut merger, sink);
1218 }
1219 }
1220
1221 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1222 self.tokenizer.count_tokens(&contextualize(chunk))
1223 }
1224
1225 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1228 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1229 let text = if chunk.doc_items.len() == 1 {
1230 chunk.text.clone()
1231 } else {
1232 doc_items
1233 .iter()
1234 .filter(|it| !it.text.is_empty())
1235 .map(|it| it.text.as_str())
1236 .collect::<Vec<_>>()
1237 .join("\n")
1238 };
1239 DocChunk {
1240 text,
1241 headings: chunk.headings.clone(),
1242 doc_items,
1243 }
1244 }
1245
1246 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1247 if chunk.doc_items.is_empty() {
1248 return vec![chunk];
1249 }
1250 let max = self.max_tokens();
1251 let num_items = chunk.doc_items.len();
1252 let mut chunks = Vec::new();
1253 let mut window_start = 0usize;
1254 let mut window_end = 0usize; while window_end < num_items {
1256 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1257 if self.count_chunk_tokens(&new_chunk) <= max {
1258 if window_end < num_items - 1 {
1259 window_end += 1;
1260 continue;
1261 } else {
1262 window_end = num_items; }
1264 } else if window_start == window_end {
1265 window_end += 1;
1268 window_start = window_end;
1269 } else {
1270 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1273 window_start = window_end;
1274 }
1275 chunks.push(new_chunk);
1276 }
1277 chunks
1278 }
1279
1280 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1281 let total = self.count_chunk_tokens(&chunk);
1282 let max = self.max_tokens();
1283 if total <= max {
1284 return vec![chunk];
1285 }
1286 let text_len = self.tokenizer.count_tokens(&chunk.text);
1287 let other_len = total - text_len;
1288 if other_len >= max {
1289 let stripped = DocChunk {
1291 headings: None,
1292 ..chunk
1293 };
1294 return self.split_using_plain_text(stripped);
1295 }
1296 let available = max - other_len;
1297
1298 let segments =
1299 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1300 let lines: Vec<String> = chunk
1307 .text
1308 .split('\n')
1309 .filter(|l| !l.trim().is_empty())
1310 .map(|l| l.to_string())
1311 .collect();
1312 line_chunk_text(&lines, &self.tokenizer, max)
1313 } else {
1314 semchunk(&chunk.text, available, &self.tokenizer)
1315 };
1316 segments
1317 .into_iter()
1318 .map(|s| DocChunk {
1319 text: s,
1320 headings: chunk.headings.clone(),
1321 doc_items: chunk.doc_items.clone(),
1322 })
1323 .collect()
1324 }
1325
1326 fn merge_push(
1332 &self,
1333 m: &mut PeerMerger,
1334 chunk: DocChunk,
1335 sink: &mut dyn FnMut(DocChunk) -> bool,
1336 ) -> bool {
1337 if m.window.is_empty() {
1338 m.window.push(chunk);
1339 return true;
1340 }
1341 let candidate = DocChunk {
1342 text: m
1343 .window
1344 .iter()
1345 .map(|c| c.text.as_str())
1346 .chain([chunk.text.as_str()])
1347 .collect::<Vec<_>>()
1348 .join("\n"),
1349 headings: m.window[0].headings.clone(),
1350 doc_items: m
1351 .window
1352 .iter()
1353 .flat_map(|c| c.doc_items.iter().cloned())
1354 .chain(chunk.doc_items.iter().cloned())
1355 .collect(),
1356 };
1357 if chunk.headings == m.window[0].headings
1358 && self.count_chunk_tokens(&candidate) <= self.max_tokens()
1359 {
1360 m.window.push(chunk);
1361 m.merged = Some(candidate);
1362 true
1363 } else {
1364 let alive = self.merge_flush(m, sink);
1365 m.window.push(chunk);
1366 alive
1367 }
1368 }
1369
1370 fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1374 let alive = if m.window.len() == 1 {
1375 sink(m.window.pop().expect("single-chunk window"))
1376 } else if !m.window.is_empty() {
1377 m.window.clear();
1378 sink(m.merged.take().expect("multi-chunk window has a merge"))
1379 } else {
1380 true
1381 };
1382 m.merged = None;
1383 alive
1384 }
1385}
1386
1387#[derive(Default)]
1389struct PeerMerger {
1390 window: Vec<DocChunk>,
1391 merged: Option<DocChunk>,
1392}
1393
1394fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1404 let mut chunks: Vec<String> = Vec::new();
1405 let mut current = String::new();
1406 let mut current_len = 0usize;
1407
1408 for line in lines {
1409 let mut remaining: Vec<char> = line.chars().collect();
1410 loop {
1411 let rem_str: String = remaining.iter().collect();
1412 let line_tokens = tok.count_tokens(&rem_str);
1413 let available = max_tokens.saturating_sub(current_len);
1414
1415 if line_tokens <= available {
1416 current.push_str(&rem_str);
1417 current_len += line_tokens;
1418 break;
1419 }
1420 if line_tokens <= max_tokens {
1421 chunks.push(std::mem::take(&mut current));
1422 current_len = 0;
1423 continue;
1424 }
1425 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1427 let mut rest = rest;
1428 if take.is_empty() {
1429 if rest.is_empty() {
1430 break;
1431 }
1432 take = rest[..1].iter().collect();
1433 rest = rest[1..].to_vec();
1434 }
1435 current.push('\n');
1436 current.push_str(&take);
1437 chunks.push(std::mem::take(&mut current));
1438 current_len = 0;
1439 remaining = rest;
1440 }
1441 }
1442 if !current.is_empty() {
1443 chunks.push(current);
1444 }
1445 chunks
1446}
1447
1448fn split_by_token_limit<T: ChunkTokenizer>(
1452 text: &[char],
1453 token_limit: usize,
1454 tok: &T,
1455) -> (String, Vec<char>) {
1456 if token_limit == 0 || text.is_empty() {
1457 return (String::new(), text.to_vec());
1458 }
1459 let full: String = text.iter().collect();
1460 if tok.count_tokens(&full) <= token_limit {
1461 return (full, Vec::new());
1462 }
1463 let (mut lo, mut hi) = (0usize, text.len());
1464 let mut best: Option<usize> = None;
1465 while lo <= hi {
1466 let mid = (lo + hi) / 2;
1467 let head: String = text[..mid].iter().collect();
1468 if tok.count_tokens(&head) <= token_limit {
1469 best = Some(mid);
1470 lo = mid + 1;
1471 } else {
1472 if mid == 0 {
1473 break;
1474 }
1475 hi = mid - 1;
1476 }
1477 }
1478 let mut best_idx = match best {
1479 Some(b) if b > 0 => b,
1480 _ => return (String::new(), text.to_vec()),
1481 };
1482 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1484 if pos > 0 {
1485 best_idx = pos;
1486 }
1487 }
1488 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1489}
1490
1491const NON_WS_SPLITTERS: &[&str] = &[
1497 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1498 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1499];
1500
1501pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1505 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1506 let mut counter = |s: &str| -> usize {
1507 if let Some(n) = cache.get(s) {
1508 return *n;
1509 }
1510 let n = tok.count_tokens(s);
1511 cache.insert(s.to_string(), n);
1512 n
1513 };
1514 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1515 chunks
1517 .into_iter()
1518 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1519 .collect()
1520}
1521
1522fn semchunk_rec(
1525 text: &str,
1526 chunk_size: usize,
1527 counter: &mut dyn FnMut(&str) -> usize,
1528) -> Vec<String> {
1529 let (splitter, splitter_is_ws, splits) = split_text(text);
1530
1531 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1532 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1533 cum_lens.push(0usize);
1534 for l in &split_lens {
1535 cum_lens.push(cum_lens.last().unwrap() + l);
1536 }
1537 let num_splits_plus_one = splits.len() + 1;
1538
1539 let mut chunks: Vec<String> = Vec::new();
1540 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1541
1542 for i in 0..splits.len() {
1543 if skips.contains(&i) {
1544 continue;
1545 }
1546 let split = &splits[i];
1547 if counter(split) > chunk_size {
1548 let inner = semchunk_rec(split, chunk_size, counter);
1549 chunks.extend(inner);
1550 } else {
1551 let (end, merged) = merge_splits(
1552 &splits,
1553 &cum_lens,
1554 chunk_size,
1555 &splitter,
1556 counter,
1557 i,
1558 num_splits_plus_one,
1559 );
1560 for j in (i + 1)..end {
1561 skips.insert(j);
1562 }
1563 chunks.push(merged);
1564 }
1565 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1568 if !splitter_is_ws && !is_last {
1569 let with_splitter = format!(
1570 "{}{}",
1571 chunks.last().map(String::as_str).unwrap_or(""),
1572 splitter
1573 );
1574 if counter(&with_splitter) <= chunk_size {
1575 if let Some(last) = chunks.last_mut() {
1576 *last = with_splitter;
1577 } else {
1578 chunks.push(with_splitter);
1579 }
1580 } else {
1581 chunks.push(splitter.clone());
1582 }
1583 }
1584 }
1585 chunks
1586}
1587
1588fn merge_splits(
1591 splits: &[String],
1592 cum_lens: &[usize],
1593 chunk_size: usize,
1594 splitter: &str,
1595 counter: &mut dyn FnMut(&str) -> usize,
1596 start: usize,
1597 high_init: usize,
1598) -> (usize, String) {
1599 let mut average = 0.2f64;
1600 let mut low = start;
1601 let mut high = high_init;
1602 let offset = cum_lens[start];
1603 let mut target = offset as f64 + (chunk_size as f64 * average);
1604
1605 while low < high {
1606 let i = bisect_left(cum_lens, target, low, high);
1607 let midpoint = i.min(high - 1);
1608 let joined = splits[start..midpoint.max(start)].join(splitter);
1609 let tokens = counter(&joined);
1610 let local_cum = cum_lens[midpoint] - offset;
1611 if local_cum > 0 && tokens > 0 {
1612 average = local_cum as f64 / tokens as f64;
1613 target = offset as f64 + (chunk_size as f64 * average);
1614 }
1615 if tokens > chunk_size {
1616 high = midpoint;
1617 } else {
1618 low = midpoint + 1;
1619 }
1620 }
1621 let end = low - 1;
1622 (end, splits[start..end.max(start)].join(splitter))
1623}
1624
1625fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1626 while low < high {
1627 let mid = (low + high) / 2;
1628 if (sorted[mid] as f64) < target {
1629 low = mid + 1;
1630 } else {
1631 high = mid;
1632 }
1633 }
1634 low
1635}
1636
1637fn split_text(text: &str) -> (String, bool, Vec<String>) {
1639 if text.contains('\n') || text.contains('\r') {
1641 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1642 return (splitter.clone(), true, split_on(text, &splitter));
1643 }
1644 if text.contains('\t') {
1646 let splitter = longest_run(text, |c| c == '\t');
1647 return (splitter.clone(), true, split_on(text, &splitter));
1648 }
1649 if text.chars().any(char::is_whitespace) {
1651 let splitter = longest_run(text, char::is_whitespace);
1652 if splitter.chars().count() == 1 {
1653 for preceder in NON_WS_SPLITTERS {
1655 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1656 return (ws, true, parts);
1657 }
1658 }
1659 }
1660 return (splitter.clone(), true, split_on(text, &splitter));
1661 }
1662 for s in NON_WS_SPLITTERS {
1664 if text.contains(s) {
1665 return (s.to_string(), false, split_on(text, s));
1666 }
1667 }
1668 (
1670 String::new(),
1671 true,
1672 text.chars().map(|c| c.to_string()).collect(),
1673 )
1674}
1675
1676fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1678 let mut best = String::new();
1679 let mut cur = String::new();
1680 for c in text.chars() {
1681 if pred(c) {
1682 cur.push(c);
1683 } else {
1684 if cur.chars().count() > best.chars().count() {
1685 best = cur.clone();
1686 }
1687 cur.clear();
1688 }
1689 }
1690 if cur.chars().count() > best.chars().count() {
1691 best = cur;
1692 }
1693 best
1694}
1695
1696fn split_on(text: &str, splitter: &str) -> Vec<String> {
1697 text.split(splitter).map(str::to_string).collect()
1698}
1699
1700fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1704 let chars: Vec<char> = text.chars().collect();
1705 let p: Vec<char> = preceder.chars().collect();
1706 let mut ws: Option<char> = None;
1707 for i in p.len()..chars.len() {
1708 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1709 ws = Some(chars[i]);
1710 break;
1711 }
1712 }
1713 let ws = ws?;
1714 let mut parts = Vec::new();
1715 let mut cur = String::new();
1716 let mut i = 0usize;
1717 while i < chars.len() {
1718 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1719 parts.push(std::mem::take(&mut cur));
1720 i += 1;
1721 continue;
1722 }
1723 cur.push(chars[i]);
1724 i += 1;
1725 }
1726 parts.push(cur);
1727 Some((ws.to_string(), parts))
1728}
1729
1730#[cfg(feature = "chunking")]
1735mod hf {
1736 use super::ChunkTokenizer;
1737
1738 pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1743
1744 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1749 if let Some(p) = explicit {
1750 return Ok(p.to_string());
1751 }
1752 let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1753 if std::path::Path::new(&resolved).exists() {
1754 return Ok(resolved);
1755 }
1756 Err(format!(
1757 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1758 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1759 scripts/install/download_dependencies.sh (or pass an explicit path)"
1760 ))
1761 }
1762
1763 pub struct HuggingFaceTokenizer {
1767 tok: tokenizers::Tokenizer,
1768 max_tokens: usize,
1769 }
1770
1771 impl HuggingFaceTokenizer {
1772 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1776 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1777 }
1778
1779 pub fn from_file(
1783 path: impl AsRef<std::path::Path>,
1784 max_tokens: usize,
1785 ) -> Result<Self, String> {
1786 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1787 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1788 let _ = tok.with_truncation(None);
1793 tok.with_padding(None);
1794 Ok(Self { tok, max_tokens })
1795 }
1796 }
1797
1798 impl ChunkTokenizer for HuggingFaceTokenizer {
1799 fn count_tokens(&self, text: &str) -> usize {
1800 self.tok
1801 .encode(text, false)
1802 .map(|e| e.get_tokens().len())
1803 .unwrap_or(0)
1804 }
1805 fn max_tokens(&self) -> usize {
1806 self.max_tokens
1807 }
1808 }
1809}
1810
1811#[cfg(feature = "chunking")]
1812pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1813
1814#[cfg(feature = "chunking")]
1819mod window {
1820 use super::DocChunk;
1821 use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1822
1823 #[derive(Debug, Clone, Default)]
1825 pub struct Section {
1826 pub heading_path: Vec<String>,
1829 pub words: Vec<String>,
1831 }
1832
1833 impl Section {
1834 pub fn heading_context(&self) -> String {
1837 if self.heading_path.is_empty() {
1838 String::new()
1839 } else {
1840 format!("# {}", self.heading_path.join(" > "))
1841 }
1842 }
1843 }
1844
1845 fn level_index(level: HeadingLevel) -> usize {
1846 match level {
1847 HeadingLevel::H1 => 1,
1848 HeadingLevel::H2 => 2,
1849 HeadingLevel::H3 => 3,
1850 HeadingLevel::H4 => 4,
1851 HeadingLevel::H5 => 5,
1852 HeadingLevel::H6 => 6,
1853 }
1854 }
1855
1856 pub fn parse_sections(markdown: &str) -> Vec<Section> {
1860 parse_sections_with_stack(markdown, Vec::new()).0
1861 }
1862
1863 pub fn parse_sections_with_stack(
1868 markdown: &str,
1869 initial_stack: Vec<String>,
1870 ) -> (Vec<Section>, Vec<String>) {
1871 let mut heading_stack: Vec<String> = initial_stack;
1872 let mut sections: Vec<Section> = Vec::new();
1873 let mut current = Section {
1876 heading_path: heading_stack
1877 .iter()
1878 .filter(|h| !h.is_empty())
1879 .cloned()
1880 .collect(),
1881 words: Vec::new(),
1882 };
1883
1884 let mut in_heading = false;
1885 let mut heading_level = 0usize;
1886 let mut heading_buf = String::new();
1887
1888 let push_words = |section: &mut Section, text: &str| {
1889 for w in text.split_whitespace() {
1890 section.words.push(w.to_string());
1891 }
1892 };
1893
1894 let flush = |sections: &mut Vec<Section>, section: &mut Section| {
1895 if !section.words.is_empty() {
1896 sections.push(std::mem::take(section));
1897 } else {
1898 *section = Section::default();
1899 }
1900 };
1901
1902 for event in Parser::new(markdown) {
1903 match event {
1904 Event::Start(Tag::Heading { level, .. }) => {
1905 in_heading = true;
1906 heading_level = level_index(level);
1907 heading_buf.clear();
1908 }
1909 Event::End(TagEnd::Heading(_)) => {
1910 in_heading = false;
1911 let idx = heading_level.saturating_sub(1);
1913 if heading_stack.len() <= idx {
1914 heading_stack.resize(idx + 1, String::new());
1915 } else {
1916 heading_stack.truncate(idx + 1);
1917 }
1918 heading_stack[idx] = heading_buf.trim().to_string();
1919 flush(&mut sections, &mut current);
1921 current.heading_path = heading_stack
1922 .iter()
1923 .filter(|h| !h.is_empty())
1924 .cloned()
1925 .collect();
1926 }
1927 Event::Text(t) | Event::Code(t) => {
1928 if in_heading {
1929 if !heading_buf.is_empty() {
1930 heading_buf.push(' ');
1931 }
1932 heading_buf.push_str(&t);
1933 } else {
1934 push_words(&mut current, &t);
1935 }
1936 }
1937 Event::SoftBreak | Event::HardBreak | Event::Rule => {}
1939 _ => {}
1940 }
1941 }
1942 flush(&mut sections, &mut current);
1943 (sections, heading_stack)
1944 }
1945
1946 #[derive(Debug, Clone)]
1952 pub struct WindowChunker {
1953 pub max_words: usize,
1955 pub overlap: f32,
1957 }
1958
1959 impl Default for WindowChunker {
1960 fn default() -> Self {
1961 WindowChunker {
1962 max_words: 300,
1963 overlap: 0.05,
1964 }
1965 }
1966 }
1967
1968 impl WindowChunker {
1969 pub fn new(max_words: usize, overlap: f32) -> Self {
1970 WindowChunker { max_words, overlap }
1971 }
1972
1973 fn word_budget(&self) -> usize {
1975 self.max_words.max(1)
1976 }
1977
1978 fn overlap_words(&self, budget: usize) -> usize {
1981 let o = (budget as f32 * self.overlap).round() as usize;
1982 o.min(budget.saturating_sub(1))
1983 }
1984
1985 pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
1987 let mut chunks = Vec::new();
1988 self.chunk_with(markdown, &mut |c| {
1989 chunks.push(c);
1990 true
1991 });
1992 chunks
1993 }
1994
1995 pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
1999 let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2000 for section in §ions {
2001 if !self.pack_section(section, sink) {
2002 return;
2003 }
2004 }
2005 }
2006
2007 pub fn pack_section(
2011 &self,
2012 section: &Section,
2013 sink: &mut dyn FnMut(DocChunk) -> bool,
2014 ) -> bool {
2015 let words = §ion.words;
2016 if words.is_empty() {
2017 return true;
2018 }
2019 let budget = self.word_budget();
2020 let step = budget - self.overlap_words(budget); let mut start = 0;
2022 loop {
2023 let end = (start + budget).min(words.len());
2024 let chunk = DocChunk {
2025 text: words[start..end].join(" "),
2026 headings: (!section.heading_path.is_empty())
2027 .then(|| section.heading_path.clone()),
2028 doc_items: Vec::new(),
2029 };
2030 if !sink(chunk) {
2031 return false;
2032 }
2033 if end >= words.len() {
2034 return true;
2035 }
2036 start += step;
2037 }
2038 }
2039
2040 pub fn contextualize(chunk: &DocChunk) -> String {
2046 match &chunk.headings {
2047 Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2048 _ => chunk.text.clone(),
2049 }
2050 }
2051 }
2052
2053 #[cfg(test)]
2054 mod tests {
2055 use super::*;
2056
2057 #[test]
2058 fn splits_on_headings_and_tracks_path() {
2059 let md = "\
2060intro words
2061# Chapter 1
2062para one
2063## Section 1.1
2064para two
2065# Chapter 2
2066para three";
2067 let secs = parse_sections(md);
2068 assert_eq!(secs.len(), 4);
2070 assert!(secs[0].heading_path.is_empty());
2071 assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2072 assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2073 assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2075 }
2076
2077 #[test]
2078 fn strips_markup_to_plain_words() {
2079 let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2080 let secs = parse_sections(md);
2081 let words = &secs[0].words;
2082 assert!(words.contains(&"bold".to_string()));
2083 assert!(words.contains(&"code".to_string()));
2084 assert!(words.contains(&"link".to_string()));
2085 assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2087 }
2088
2089 #[test]
2090 fn windows_overlap_and_never_cross_headings() {
2091 let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2092 let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2093 let chunker = WindowChunker::new(10, 0.2); let chunks = chunker.chunk(&md);
2095 let a: Vec<_> = chunks
2097 .iter()
2098 .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2099 .collect();
2100 assert_eq!(a.len(), 3);
2101 assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2102 assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2103 assert!(a[2].text.ends_with(" w24"));
2104 let b: Vec<_> = chunks
2106 .iter()
2107 .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2108 .collect();
2109 assert_eq!(b.len(), 1);
2110 assert_eq!(b[0].text, "short tail");
2111 assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2112 }
2113
2114 #[test]
2115 fn sink_false_cancels_the_window_walk() {
2116 let md = format!(
2117 "# A\n\n{}\n",
2118 (0..50)
2119 .map(|i| format!("w{i}"))
2120 .collect::<Vec<_>>()
2121 .join(" ")
2122 );
2123 let chunker = WindowChunker::new(10, 0.0);
2124 let mut n = 0;
2125 chunker.chunk_with(&md, &mut |_| {
2126 n += 1;
2127 false
2128 });
2129 assert_eq!(n, 1);
2130 }
2131 }
2132}
2133
2134#[cfg(feature = "chunking")]
2135pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2136
2137#[cfg(test)]
2138mod tests {
2139 use super::*;
2140
2141 struct WordTok(usize);
2143 impl ChunkTokenizer for WordTok {
2144 fn count_tokens(&self, text: &str) -> usize {
2145 text.split_whitespace().count()
2146 }
2147 fn max_tokens(&self) -> usize {
2148 self.0
2149 }
2150 }
2151
2152 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2153 let mut d = DoclingDocument::new("t");
2154 for n in nodes {
2155 d.push(n);
2156 }
2157 d
2158 }
2159
2160 #[test]
2161 fn hierarchical_headings_and_items() {
2162 let doc = doc_with(vec![
2163 Node::Heading {
2164 level: 1,
2165 text: "Title".into(),
2166 },
2167 Node::Paragraph {
2168 text: "Intro".into(),
2169 },
2170 Node::Heading {
2171 level: 2,
2172 text: "Sec".into(),
2173 },
2174 Node::Paragraph {
2175 text: "Body".into(),
2176 },
2177 ]);
2178 let chunks = HierarchicalChunker.chunk(&doc);
2179 assert_eq!(chunks.len(), 2);
2180 assert_eq!(chunks[0].text, "Intro");
2181 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2182 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2183 assert_eq!(
2184 chunks[1].headings.as_deref(),
2185 Some(&["Title".into(), "Sec".into()][..])
2186 );
2187 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2188 }
2189
2190 #[test]
2191 fn heading_shadowing_prunes_deeper_levels() {
2192 let doc = doc_with(vec![
2193 Node::Heading {
2194 level: 2,
2195 text: "A".into(),
2196 },
2197 Node::Heading {
2198 level: 3,
2199 text: "A.1".into(),
2200 },
2201 Node::Heading {
2202 level: 2,
2203 text: "B".into(),
2204 },
2205 Node::Paragraph { text: "p".into() },
2206 ]);
2207 let chunks = HierarchicalChunker.chunk(&doc);
2208 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2209 }
2210
2211 #[test]
2212 fn triplet_table() {
2213 let t = Table {
2214 rows: vec![
2215 vec!["".into(), "Col1".into()],
2216 vec!["Row1".into(), "v".into()],
2217 ],
2218 ..Default::default()
2219 };
2220 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2221 let single = Table {
2224 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2225 ..Default::default()
2226 };
2227 assert_eq!(triplet_table_text(&single), "a = b");
2228 }
2229
2230 #[test]
2231 fn hybrid_merges_small_peers_and_splits_large() {
2232 let doc = doc_with(vec![
2233 Node::Heading {
2234 level: 2,
2235 text: "S".into(),
2236 },
2237 Node::Paragraph { text: "a b".into() },
2238 Node::Paragraph { text: "c d".into() },
2239 ]);
2240 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2241 assert_eq!(chunks.len(), 1, "peers under one heading merge");
2242 assert_eq!(chunks[0].text, "a b\nc d");
2243
2244 let long = "w ".repeat(40).trim().to_string();
2245 let doc = doc_with(vec![Node::Paragraph { text: long }]);
2246 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2247 assert!(chunks.len() > 1, "oversized paragraph splits");
2248 for c in &chunks {
2249 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2250 }
2251 }
2252
2253 #[test]
2254 fn semchunk_prefers_newlines_then_sentences() {
2255 let tok = WordTok(4);
2256 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2257 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2258 }
2259}