@@ -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+
76196fn 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\n Just a few words here." . to_string ( ) ,
380+ snippet : "## Short\n Just 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\n Just a few words here." ) ;
389+ }
219390}
0 commit comments