1use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "lowercase")]
20pub enum ChunkBoundary {
21 Paragraph,
24 Sentence,
26 Fixed,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
32#[serde(default)]
33#[schemars(transform = crate::schema::strip_int_formats)]
34pub struct ChunkPolicy {
35 pub max_chunk_bytes: usize,
37 pub overlap_bytes: usize,
40 pub boundary: ChunkBoundary,
42}
43
44impl Default for ChunkPolicy {
45 fn default() -> Self {
46 Self {
47 max_chunk_bytes: 2_048,
48 overlap_bytes: 0,
49 boundary: ChunkBoundary::Paragraph,
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct TextChunk {
57 pub text: String,
59 pub byte_range: core::ops::Range<usize>,
61 pub index: usize,
63}
64
65#[must_use]
67pub fn chunk_text(text: &str, policy: &ChunkPolicy) -> Vec<TextChunk> {
68 if text.is_empty() {
69 return Vec::new();
70 }
71 let max = policy.max_chunk_bytes.max(1);
72 if text.len() <= max {
73 return vec![TextChunk {
74 text: text.to_owned(),
75 byte_range: 0..text.len(),
76 index: 0,
77 }];
78 }
79 let ranges = pack_units(text, &units(text, policy.boundary), max);
80 assemble(text, &ranges, policy.overlap_bytes)
81}
82
83struct Unit {
91 range: core::ops::Range<usize>,
92 atomic: bool,
93}
94
95fn units(text: &str, boundary: ChunkBoundary) -> Vec<Unit> {
98 let mut units = Vec::new();
99 for segment in fence_segments(text) {
100 match segment {
101 Segment::Fence(range) => units.push(Unit {
102 range,
103 atomic: true,
104 }),
105 Segment::Plain(range) => split_plain(text, range, boundary, &mut units),
106 }
107 }
108 units
109}
110
111pub(crate) enum Segment {
119 Fence(core::ops::Range<usize>),
121 Plain(core::ops::Range<usize>),
123}
124
125pub(crate) fn fence_segments(text: &str) -> Vec<Segment> {
130 let mut segments = Vec::new();
131 let mut cursor = 0_usize;
132 let mut fence_start: Option<usize> = None;
133 let mut line_start = 0_usize;
134 for line in text.split_inclusive('\n') {
135 let opens_or_closes = line.trim_start().starts_with("```");
136 let line_end = line_start + line.len();
137 match (fence_start, opens_or_closes) {
138 (None, true) => {
139 if line_start > cursor {
140 segments.push(Segment::Plain(cursor..line_start));
141 }
142 fence_start = Some(line_start);
143 }
144 (Some(start), true) => {
145 segments.push(Segment::Fence(start..line_end));
146 fence_start = None;
147 cursor = line_end;
148 }
149 _ => {}
150 }
151 line_start = line_end;
152 }
153 push_tail(&mut segments, fence_start, cursor, text.len());
154 segments
155}
156
157fn push_tail(segments: &mut Vec<Segment>, fence_start: Option<usize>, cursor: usize, end: usize) {
160 match fence_start {
161 Some(start) => segments.push(Segment::Fence(start..end)),
162 None if cursor < end => segments.push(Segment::Plain(cursor..end)),
163 None => {}
164 }
165}
166
167fn split_plain(
171 text: &str,
172 range: core::ops::Range<usize>,
173 boundary: ChunkBoundary,
174 out: &mut Vec<Unit>,
175) {
176 let slice = &text[range.clone()];
177 let mut piece_start = 0_usize;
178 for cut in boundary_cuts(slice, boundary) {
179 out.push(Unit {
180 range: range.start + piece_start..range.start + cut,
181 atomic: false,
182 });
183 piece_start = cut;
184 }
185 if piece_start < slice.len() {
186 out.push(Unit {
187 range: range.start + piece_start..range.end,
188 atomic: false,
189 });
190 }
191}
192
193fn boundary_cuts(slice: &str, boundary: ChunkBoundary) -> Vec<usize> {
196 match boundary {
197 ChunkBoundary::Paragraph => paragraph_cuts(slice),
198 ChunkBoundary::Sentence => sentence_cuts(slice),
199 ChunkBoundary::Fixed => Vec::new(),
200 }
201}
202
203fn paragraph_cuts(slice: &str) -> Vec<usize> {
205 let mut cuts = Vec::new();
206 let bytes = slice.as_bytes();
207 let mut i = 0_usize;
208 while let Some(found) = find_from(bytes, i, b"\n\n") {
209 let mut end = found + 2;
210 while bytes.get(end) == Some(&b'\n') {
211 end += 1;
212 }
213 cuts.push(end);
214 i = end;
215 }
216 cuts
217}
218
219fn sentence_cuts(slice: &str) -> Vec<usize> {
221 let mut cuts = Vec::new();
222 let mut previous: Option<char> = None;
223 for (offset, ch) in slice.char_indices() {
224 let after_ender = matches!(previous, Some('.' | '!' | '?'));
225 if after_ender && ch.is_whitespace() {
226 cuts.push(offset + ch.len_utf8());
227 }
228 previous = Some(ch);
229 }
230 cuts
231}
232
233fn find_from(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
235 haystack
236 .get(from..)?
237 .windows(needle.len())
238 .position(|window| window == needle)
239 .map(|position| from + position)
240}
241
242fn pack_units(text: &str, units: &[Unit], max: usize) -> Vec<core::ops::Range<usize>> {
246 let mut chunks: Vec<core::ops::Range<usize>> = Vec::new();
247 let mut open: Option<core::ops::Range<usize>> = None;
248 for unit in units {
249 if unit.range.len() > max {
250 flush(&mut chunks, &mut open);
251 append_oversized(text, unit, max, &mut chunks);
252 } else {
253 open = Some(merge_or_flush(&mut chunks, open, unit.range.clone(), max));
254 }
255 }
256 flush(&mut chunks, &mut open);
257 chunks
258}
259
260fn merge_or_flush(
263 chunks: &mut Vec<core::ops::Range<usize>>,
264 open: Option<core::ops::Range<usize>>,
265 unit: core::ops::Range<usize>,
266 max: usize,
267) -> core::ops::Range<usize> {
268 match open {
269 Some(range) if unit.end - range.start <= max => range.start..unit.end,
270 Some(range) => {
271 chunks.push(range);
272 unit
273 }
274 None => unit,
275 }
276}
277
278fn flush(chunks: &mut Vec<core::ops::Range<usize>>, open: &mut Option<core::ops::Range<usize>>) {
280 if let Some(range) = open.take() {
281 chunks.push(range);
282 }
283}
284
285fn append_oversized(
289 text: &str,
290 unit: &Unit,
291 max: usize,
292 chunks: &mut Vec<core::ops::Range<usize>>,
293) {
294 if unit.atomic {
295 chunks.push(unit.range.clone());
296 return;
297 }
298 let mut start = unit.range.start;
299 while start < unit.range.end {
300 let floored = char_floor(text, (start + max).min(unit.range.end));
301 let end = if floored > start {
304 floored
305 } else {
306 char_ceil(text, start + 1).min(unit.range.end)
307 };
308 chunks.push(start..end);
309 start = end;
310 }
311}
312
313fn char_floor(text: &str, at: usize) -> usize {
315 let mut boundary = at.min(text.len());
316 while !text.is_char_boundary(boundary) {
317 boundary -= 1;
318 }
319 boundary
320}
321
322fn assemble(
325 text: &str,
326 ranges: &[core::ops::Range<usize>],
327 overlap_bytes: usize,
328) -> Vec<TextChunk> {
329 ranges
330 .iter()
331 .enumerate()
332 .map(|(index, range)| {
333 let mut chunk_text = String::new();
334 if overlap_bytes > 0 && index > 0 {
335 let overlap_start = char_ceil(text, range.start.saturating_sub(overlap_bytes));
336 chunk_text.push_str(&text[overlap_start..range.start]);
337 }
338 chunk_text.push_str(&text[range.clone()]);
339 TextChunk {
340 text: chunk_text,
341 byte_range: range.clone(),
342 index,
343 }
344 })
345 .collect()
346}
347
348fn char_ceil(text: &str, at: usize) -> usize {
350 let mut boundary = at.min(text.len());
351 while !text.is_char_boundary(boundary) {
352 boundary += 1;
353 }
354 boundary
355}
356
357#[cfg(test)]
358#[path = "chunk_tests.rs"]
359mod tests;