Skip to content

Commit 05a55a5

Browse files
devwhodevsclaude
andcommitted
feat: token-aware sub-splitting for oversized chunks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad245fe commit 05a55a5

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

src/chunker.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,126 @@ fn flush_chunk(chunks: &mut Vec<Chunk>, heading: Option<String>, lines: &[&str])
7373
});
7474
}
7575

76+
/// Split oversized chunks into sub-chunks that fit within `max_tokens`.
77+
///
78+
/// - `token_count` counts tokens in a string (closure for testability).
79+
/// - Chunks under `max_tokens` pass through unchanged.
80+
/// - Over-sized chunks are split on sentence boundaries (`. ` or `\n`).
81+
/// - Each sub-chunk after the first includes `overlap_tokens` worth of trailing
82+
/// text from the previous sub-chunk.
83+
/// - Subsequent sub-chunks get ` (cont.)` appended to the parent heading.
84+
pub fn split_oversized_chunks(
85+
chunks: Vec<Chunk>,
86+
token_count: &dyn Fn(&str) -> usize,
87+
max_tokens: usize,
88+
overlap_tokens: usize,
89+
) -> Vec<Chunk> {
90+
let mut result = Vec::new();
91+
for chunk in chunks {
92+
if token_count(&chunk.text) <= max_tokens {
93+
result.push(chunk);
94+
continue;
95+
}
96+
// Split text into sentences on `. ` or `\n`
97+
let sentences = split_sentences(&chunk.text);
98+
let mut sub_chunks: Vec<String> = Vec::new();
99+
let mut current = String::new();
100+
101+
for sentence in &sentences {
102+
let candidate = if current.is_empty() {
103+
sentence.to_string()
104+
} else {
105+
format!("{current}{sentence}")
106+
};
107+
if !current.is_empty() && token_count(&candidate) > max_tokens {
108+
// Flush current sub-chunk
109+
sub_chunks.push(current.clone());
110+
// Build overlap prefix from the end of the previous sub-chunk
111+
let overlap = build_overlap(&current, token_count, overlap_tokens);
112+
current = format!("{overlap}{sentence}");
113+
} else {
114+
current = candidate;
115+
}
116+
}
117+
if !current.trim().is_empty() {
118+
sub_chunks.push(current);
119+
}
120+
121+
// Convert sub-chunks into Chunk structs
122+
for (i, sub_text) in sub_chunks.into_iter().enumerate() {
123+
let heading = if i == 0 {
124+
chunk.heading.clone()
125+
} else {
126+
chunk.heading.as_ref().map(|h| format!("{h} (cont.)"))
127+
};
128+
let full_text = match &heading {
129+
Some(h) => format!("{h}\n{}", sub_text.trim()),
130+
None => sub_text.trim().to_string(),
131+
};
132+
let snippet = make_snippet(&full_text);
133+
result.push(Chunk {
134+
heading,
135+
text: full_text,
136+
snippet,
137+
});
138+
}
139+
}
140+
result
141+
}
142+
143+
/// Split text into sentence-like segments, preserving delimiters.
144+
/// Splits on `. ` (sentence end) and `\n` (line break).
145+
fn split_sentences(text: &str) -> Vec<String> {
146+
let mut segments = Vec::new();
147+
let mut current = String::new();
148+
let chars: Vec<char> = text.chars().collect();
149+
let mut i = 0;
150+
while i < chars.len() {
151+
current.push(chars[i]);
152+
if chars[i] == '\n' {
153+
segments.push(current.clone());
154+
current.clear();
155+
} else if chars[i] == '.' && i + 1 < chars.len() && chars[i + 1] == ' ' {
156+
current.push(' ');
157+
i += 1; // consume the space
158+
segments.push(current.clone());
159+
current.clear();
160+
}
161+
i += 1;
162+
}
163+
if !current.is_empty() {
164+
segments.push(current);
165+
}
166+
segments
167+
}
168+
169+
/// Build an overlap string from the end of `text` that is approximately
170+
/// `overlap_tokens` tokens long, measured by `token_count`.
171+
fn build_overlap(text: &str, token_count: &dyn Fn(&str) -> usize, overlap_tokens: usize) -> String {
172+
if overlap_tokens == 0 {
173+
return String::new();
174+
}
175+
// Work backwards through words to build overlap
176+
let words: Vec<&str> = text.split_whitespace().collect();
177+
let mut overlap = String::new();
178+
for &word in words.iter().rev() {
179+
let candidate = if overlap.is_empty() {
180+
word.to_string()
181+
} else {
182+
format!("{word} {overlap}")
183+
};
184+
if token_count(&candidate) > overlap_tokens {
185+
break;
186+
}
187+
overlap = candidate;
188+
}
189+
if overlap.is_empty() {
190+
overlap
191+
} else {
192+
format!("{overlap} ")
193+
}
194+
}
195+
76196
fn make_snippet(text: &str) -> String {
77197
if text.len() > 200 {
78198
let truncated: String = text.chars().take(200).collect();
@@ -216,4 +336,55 @@ mod tests {
216336
let parsed = chunk_markdown(md);
217337
assert_eq!(parsed.tags, vec!["rust", "cli", "search"]);
218338
}
339+
340+
#[test]
341+
fn test_long_chunk_split() {
342+
// Generate ~600 words of text with sentence boundaries
343+
let sentences: Vec<String> = (0..60)
344+
.map(|i| format!("This is sentence number {} with several words to pad it out.", i))
345+
.collect();
346+
let long_text = sentences.join(" ");
347+
let word_count = long_text.split_whitespace().count();
348+
assert!(word_count > 512, "Test text must exceed 512 tokens (words); got {word_count}");
349+
350+
let chunk = Chunk {
351+
heading: Some("## Long Section".to_string()),
352+
text: format!("## Long Section\n{long_text}"),
353+
snippet: make_snippet(&format!("## Long Section\n{long_text}")),
354+
};
355+
356+
let token_fn = |s: &str| s.split_whitespace().count();
357+
let result = split_oversized_chunks(vec![chunk], &token_fn, 512, 50);
358+
359+
assert!(result.len() >= 2, "Expected at least 2 sub-chunks, got {}", result.len());
360+
// First chunk keeps original heading
361+
assert_eq!(result[0].heading.as_deref(), Some("## Long Section"));
362+
// Subsequent chunks get (cont.)
363+
assert_eq!(result[1].heading.as_deref(), Some("## Long Section (cont.)"));
364+
// All sub-chunks should be within token limit
365+
for c in &result {
366+
let tokens = token_fn(&c.text);
367+
assert!(tokens <= 512, "Sub-chunk has {tokens} tokens, exceeds 512");
368+
}
369+
// Snippets should be regenerated
370+
for c in &result {
371+
assert!(!c.snippet.is_empty());
372+
}
373+
}
374+
375+
#[test]
376+
fn test_short_chunk_no_split() {
377+
let chunk = Chunk {
378+
heading: Some("## Short".to_string()),
379+
text: "## Short\nJust a few words here.".to_string(),
380+
snippet: "## Short\nJust a few words here.".to_string(),
381+
};
382+
383+
let token_fn = |s: &str| s.split_whitespace().count();
384+
let result = split_oversized_chunks(vec![chunk], &token_fn, 512, 50);
385+
386+
assert_eq!(result.len(), 1);
387+
assert_eq!(result[0].heading.as_deref(), Some("## Short"));
388+
assert_eq!(result[0].text, "## Short\nJust a few words here.");
389+
}
219390
}

0 commit comments

Comments
 (0)