llm_kernel/tokens/chunk.rs
1//! Document chunking by sentence boundary and token budget.
2//!
3//! Splits text into token-budgeted chunks along sentence boundaries, with
4//! optional overlap between consecutive chunks so boundary context is
5//! preserved. Sentence terminators are script-aware: ASCII `.`, `!`, `?` and
6//! the CJK full-stop/exclamation/question marks (U+3002, U+FF01, U+FF1F).
7//! Newlines also act as unit boundaries.
8//!
9//! Token counts reuse [`crate::tokens::estimate_tokens`]; this module adds no
10//! new dependencies.
11
12use crate::tokens::estimate_tokens;
13
14/// Options controlling sentence-aware chunking.
15#[derive(Debug, Clone, Copy)]
16pub struct ChunkOptions {
17 /// Maximum estimated tokens per chunk.
18 pub max_tokens: usize,
19 /// Target token overlap between consecutive chunks.
20 pub overlap_tokens: usize,
21}
22
23impl ChunkOptions {
24 /// Create chunking options with the given token budget and overlap.
25 ///
26 /// `max_tokens` must be greater than zero; `overlap_tokens` may be zero to
27 /// disable overlap. If `overlap_tokens` is greater than or equal to
28 /// `max_tokens` it is clamped down to `max_tokens - 1` so progress is still
29 /// guaranteed.
30 pub fn new(max_tokens: usize, overlap_tokens: usize) -> Self {
31 let max_tokens = max_tokens.max(1);
32 // Clamp overlap so it never fully equals the budget, otherwise the
33 // overlap window alone could saturate a chunk and stall progress.
34 let overlap_tokens = overlap_tokens.min(max_tokens.saturating_sub(1));
35 Self {
36 max_tokens,
37 overlap_tokens,
38 }
39 }
40}
41
42impl Default for ChunkOptions {
43 /// Default options: 512 tokens per chunk with 64 tokens of overlap.
44 ///
45 /// A sensible middle ground for short-context retrieval: chunks are small
46 /// enough to feed most embedding models, while the overlap keeps adjacent
47 /// chunks sharing context across their boundary.
48 fn default() -> Self {
49 Self::new(512, 64)
50 }
51}
52
53/// Returns `true` if `ch` is a sentence terminator.
54///
55/// Covers ASCII `.`, `!`, `?` and the CJK full-width forms U+3002 (。),
56/// U+FF01 (!), and U+FF1F (?).
57fn is_terminator(ch: char) -> bool {
58 matches!(ch, '.' | '!' | '?' | '\u{3002}' | '\u{FF01}' | '\u{FF1F}')
59}
60
61/// Splits `text` into trimmed, non-empty sentence units.
62///
63/// A unit is the text up to and including the next terminator (or the text
64/// bounded by a newline). Terminators stay attached to their sentence; lines
65/// separated by newlines become their own units.
66fn segment(text: &str) -> Vec<String> {
67 let mut units = Vec::new();
68 let mut current = String::new();
69
70 for ch in text.chars() {
71 if ch == '\n' || ch == '\r' {
72 // A newline ends the current unit without keeping the newline.
73 let trimmed = current.trim();
74 if !trimmed.is_empty() {
75 units.push(trimmed.to_string());
76 }
77 current.clear();
78 continue;
79 }
80 current.push(ch);
81 if is_terminator(ch) {
82 let trimmed = current.trim();
83 if !trimmed.is_empty() {
84 units.push(trimmed.to_string());
85 }
86 current.clear();
87 }
88 }
89
90 let trimmed = current.trim();
91 if !trimmed.is_empty() {
92 units.push(trimmed.to_string());
93 }
94
95 units
96}
97
98/// Split text into token-budgeted chunks along sentence boundaries.
99///
100/// The algorithm packs sentence units greedily into a chunk until adding the
101/// next unit would exceed [`ChunkOptions::max_tokens`]. When a new chunk
102/// starts, trailing units from the just-pushed chunk are carried over as
103/// overlap so consecutive chunks share boundary context. Overlap is
104/// best-effort but never stalls: up to [`ChunkOptions::overlap_tokens`] of
105/// trailing units are carried when they fit; if even the budget is exceeded
106/// the last unit is still carried (so context is preserved); overlap is
107/// skipped only when the emitted chunk was a single unit or `overlap_tokens`
108/// is zero.
109///
110/// A single unit that on its own exceeds the budget is emitted as its own
111/// chunk — content is never dropped. Empty or whitespace-only input yields an
112/// empty vector.
113///
114/// **Performance:** the running window is re-estimated with
115/// [`crate::tokens::estimate_tokens`] on each unit addition, so the cost is
116/// O(k²) in the number of units per chunk. This is fine for typical retrieval
117/// chunk sizes (hundreds of tokens); very large inputs with many tiny units
118/// are not the intended use case.
119pub fn chunk_text(text: &str, opts: &ChunkOptions) -> Vec<String> {
120 let units = segment(text);
121 if units.is_empty() {
122 return Vec::new();
123 }
124
125 let mut chunks: Vec<String> = Vec::new();
126 // Inclusive index range [start..=end] of units packed into the active chunk.
127 // `end` is the unit currently being tested for inclusion.
128 let mut start: usize = 0;
129 let mut end: usize = 0;
130
131 while end < units.len() {
132 let running: String = units[start..=end].join(" ");
133 let running_tokens = estimate_tokens(&running);
134
135 if running_tokens <= opts.max_tokens {
136 // Fits — extend by one more unit on the next iteration.
137 end += 1;
138 continue;
139 }
140
141 // Exceeds budget. A lone overlong unit is emitted on its own.
142 if start == end {
143 chunks.push(running);
144 end += 1;
145 start = end;
146 continue;
147 }
148
149 // The window [start..end) fits; emit it without `end`.
150 let chunk_str: String = units[start..end].join(" ");
151 chunks.push(chunk_str);
152
153 // Seed the next chunk with the overlap window of the just-pushed chunk,
154 // then resume testing at the overflowing unit `end`.
155 let next_start = overlap_start(&units, start, end, opts.overlap_tokens);
156 // Decide where the next chunk starts. Overlap is best-effort but never
157 // stalls progress:
158 // - No overlap requested, or the emitted chunk was a single unit:
159 // carrying it would just re-emit that unit, so advance fresh to `end`.
160 // - `next_start <= start` means the whole chunk fits the overlap
161 // budget; keep the final unit as minimal overlap instead of dropping
162 // it (`end - start >= 2` here, so `end - 1 > start`: progress holds).
163 // - Otherwise take the trailing units that fit the overlap budget.
164 // A final guard: if only the last unit is carried (`s == end - 1`) and
165 // it cannot combine with the overflowing unit, drop it — otherwise it
166 // would be re-emitted as a redundant lone-unit chunk.
167 start = if opts.overlap_tokens == 0 || end - start <= 1 {
168 end
169 } else {
170 let s = if next_start <= start {
171 end - 1
172 } else {
173 next_start
174 };
175 if s == end - 1 && estimate_tokens(&units[end - 1..=end].join(" ")) > opts.max_tokens {
176 end
177 } else {
178 s
179 }
180 };
181 }
182
183 // Emit any trailing packed units.
184 if start < units.len() {
185 let chunk_str: String = units[start..].join(" ");
186 let trimmed = chunk_str.trim();
187 if !trimmed.is_empty() {
188 chunks.push(trimmed.to_string());
189 }
190 }
191
192 chunks
193}
194
195/// Picks the inclusive start index for the next chunk so that trailing units of
196/// the just-pushed chunk `[prev_start..prev_end)` are carried as overlap.
197///
198/// Walks backward from `prev_end` collecting units until their combined
199/// estimate exceeds `overlap_tokens`, returning the earliest index whose slice
200/// still fits the budget. This is a *candidate*: [`chunk_text`] applies a final
201/// progress/redundancy guard (see its docs) that may advance past it.
202fn overlap_start(
203 units: &[String],
204 prev_start: usize,
205 prev_end: usize,
206 overlap_tokens: usize,
207) -> usize {
208 if overlap_tokens == 0 || prev_end == 0 {
209 return prev_end;
210 }
211 // Walk backward from prev_end, accumulating units into the overlap window.
212 let mut acc = String::new();
213 let mut new_start = prev_end;
214 for i in (prev_start..prev_end).rev() {
215 let candidate = if acc.is_empty() {
216 units[i].clone()
217 } else {
218 format!("{} {}", units[i], acc)
219 };
220 if estimate_tokens(&candidate) > overlap_tokens {
221 break;
222 }
223 acc = candidate;
224 new_start = i;
225 }
226 // Guarantee at least one unit of overlap when overlap_tokens > 0, but never
227 // move the start earlier than prev_start (would duplicate the whole chunk).
228 if new_start == prev_end {
229 new_start = prev_end.saturating_sub(1).max(prev_start);
230 }
231 new_start
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn empty_input_yields_no_chunks() {
240 let opts = ChunkOptions::new(100, 0);
241 assert!(chunk_text("", &opts).is_empty());
242 }
243
244 #[test]
245 fn whitespace_only_yields_no_chunks() {
246 let opts = ChunkOptions::new(100, 0);
247 assert!(chunk_text(" \n\t \n", &opts).is_empty());
248 }
249
250 #[test]
251 fn latin_packs_within_budget() {
252 let text = "The quick brown fox jumps. A lazy dog sleeps under the tree. \
253 Birds sing in the morning light. Rain falls softly on the roof. \
254 Children play in the garden all day.";
255 let opts = ChunkOptions::new(20, 8);
256 let chunks = chunk_text(text, &opts);
257 assert!(
258 chunks.len() > 1,
259 "expected multiple chunks, got {}",
260 chunks.len()
261 );
262 for (i, c) in chunks.iter().enumerate() {
263 let est = estimate_tokens(c);
264 assert!(
265 est <= opts.max_tokens,
266 "chunk {i} has {est} tokens > max {}",
267 opts.max_tokens
268 );
269 }
270 }
271
272 #[test]
273 fn latin_chunks_share_overlap() {
274 let text = "The quick brown fox jumps. A lazy dog sleeps under the tree. \
275 Birds sing in the morning light. Rain falls softly on the roof. \
276 Children play in the garden all day.";
277 let opts = ChunkOptions::new(20, 8);
278 let chunks = chunk_text(text, &opts);
279 assert!(chunks.len() > 1);
280 for w in chunks.windows(2) {
281 // Extract the last sentence of the first chunk and confirm it is
282 // carried into the next chunk as overlap context.
283 let last_sentence = w[0]
284 .split(['.', '!', '?'])
285 .rfind(|s| !s.trim().is_empty())
286 .unwrap_or("")
287 .trim();
288 assert!(
289 !last_sentence.is_empty(),
290 "expected a trailing sentence to carry into overlap"
291 );
292 assert!(
293 w[1].contains(last_sentence),
294 "overlap missing: '{last_sentence}' not in next chunk"
295 );
296 }
297 }
298
299 #[test]
300 fn cjk_fullstop_splits_and_packs() {
301 // Five short clauses separated by the CJK full-stop U+3002.
302 let text =
303 "今日は晴れます。明日は雨が降る。明後日は風が強い。夜は涼しいです。朝はとても寒い。";
304 let opts = ChunkOptions::new(10, 4);
305 let chunks = chunk_text(text, &opts);
306 assert!(
307 chunks.len() > 1,
308 "expected multiple CJK chunks, got {}",
309 chunks.len()
310 );
311 // Reassembling the chunks should preserve all five clauses.
312 let joined = chunks.join("");
313 for clause in [
314 "今日は晴れます",
315 "明日は雨が降る",
316 "明後日は風が強い",
317 "夜は涼しいです",
318 "朝はとても寒い",
319 ] {
320 assert!(joined.contains(clause), "clause '{clause}' was dropped");
321 }
322 for (i, c) in chunks.iter().enumerate() {
323 let est = estimate_tokens(c);
324 assert!(
325 est <= opts.max_tokens,
326 "CJK chunk {i} has {est} tokens > max {}",
327 opts.max_tokens
328 );
329 }
330 }
331
332 #[test]
333 fn cjk_exclamation_and_question_terminate() {
334 // U+FF01 (!) and U+FF1F (?) should also act as terminators.
335 let text = "行きます!何をしますか?帰りましょう。";
336 let opts = ChunkOptions::new(8, 0);
337 let chunks = chunk_text(text, &opts);
338 // With no overlap, all content is preserved across chunks.
339 let joined = chunks.join("");
340 for clause in ["行きます", "何をしますか", "帰りましょう"] {
341 assert!(joined.contains(clause), "clause '{clause}' was dropped");
342 }
343 }
344
345 #[test]
346 fn single_overlong_unit_emitted_unchanged() {
347 // A long run with no terminators and a tiny budget.
348 let text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789";
349 let opts = ChunkOptions::new(3, 0);
350 let chunks = chunk_text(text, &opts);
351 assert_eq!(
352 chunks.len(),
353 1,
354 "a lone overlong unit must be a single chunk"
355 );
356 assert_eq!(chunks[0], text, "content must be preserved verbatim");
357 }
358
359 #[test]
360 fn default_options_are_sensible() {
361 let opts = ChunkOptions::default();
362 assert_eq!(opts.max_tokens, 512);
363 assert_eq!(opts.overlap_tokens, 64);
364 assert!(opts.overlap_tokens < opts.max_tokens);
365 }
366
367 #[test]
368 fn overlap_clamped_below_max() {
369 // overlap equal to max must be clamped so progress is still possible.
370 let opts = ChunkOptions::new(10, 10);
371 assert!(opts.overlap_tokens < opts.max_tokens);
372 let text = "One. Two. Three. Four. Five. Six. Seven. Eight.";
373 let chunks = chunk_text(text, &opts);
374 assert!(chunks.len() > 1, "clamped overlap must still split");
375 }
376
377 #[test]
378 fn newlines_split_units() {
379 let text = "first line\nsecond line\nthird line";
380 let opts = ChunkOptions::new(5, 0);
381 let chunks = chunk_text(text, &opts);
382 assert!(chunks.len() > 1, "newlines should produce multiple units");
383 let joined = chunks.join(" ");
384 for line in ["first line", "second line", "third line"] {
385 assert!(joined.contains(line), "line '{line}' was dropped");
386 }
387 }
388
389 #[test]
390 fn overlap_preserved_for_small_chunks() {
391 // Short sentences with an overlap budget close to the chunk budget:
392 // each packed chunk is smaller than the overlap window, which
393 // previously caused overlap to be silently dropped. The final sentence
394 // of each chunk must now carry into the next.
395 let text = "One. Two. Three. Four. Five. Six. Seven. Eight. Nine. Ten. \
396 Eleven. Twelve.";
397 let opts = ChunkOptions::new(8, 7);
398 let chunks = chunk_text(text, &opts);
399 assert!(chunks.len() > 1, "expected a split, got {}", chunks.len());
400 for w in chunks.windows(2) {
401 let last = w[0]
402 .split(['.', '!', '?'])
403 .rfind(|s| !s.trim().is_empty())
404 .unwrap_or("")
405 .trim();
406 assert!(
407 !last.is_empty() && w[1].contains(last),
408 "overlap missing: '{last}' not carried into next chunk\n c0: {:?}\n c1: {:?}",
409 w[0],
410 w[1]
411 );
412 }
413 }
414}