Skip to main content

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}