-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathchunker.rs
More file actions
377 lines (332 loc) · 12.4 KB
/
Copy pathchunker.rs
File metadata and controls
377 lines (332 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
// Line-based markdown chunker — splits documents into semantic chunks.
//
// Splits on markdown headings and paragraph boundaries, respecting
// a max token limit per chunk. Preserves heading context.
use std::rc::Rc;
/// A single chunk of text with metadata.
#[derive(Debug, Clone)]
pub struct Chunk {
pub index: usize,
pub content: String,
pub heading: Option<Rc<str>>,
}
/// Split markdown text into chunks, each under `max_tokens` approximate tokens.
///
/// Strategy:
/// 1. Split on `## ` and `# ` headings (keeps heading with its content)
/// 2. If a section exceeds `max_tokens`, split on blank lines (paragraphs)
/// 3. If a paragraph still exceeds, split on line boundaries
///
/// Token estimation: ~4 chars per token (rough English average).
pub fn chunk_markdown(text: &str, max_tokens: usize) -> Vec<Chunk> {
if text.trim().is_empty() {
return Vec::new();
}
let max_chars = max_tokens * 4;
let sections = split_on_headings(text);
let mut chunks = Vec::with_capacity(sections.len());
for (heading, body) in sections {
let heading: Option<Rc<str>> = heading.map(Rc::from);
let full = if let Some(ref h) = heading {
format!("{h}\n{body}")
} else {
body.clone()
};
if full.len() <= max_chars {
chunks.push(Chunk {
index: chunks.len(),
content: full.trim().to_string(),
heading: heading.clone(),
});
} else {
// Split on paragraphs (blank lines)
let paragraphs = split_on_blank_lines(&body);
let mut current = heading
.as_deref()
.map_or_else(String::new, |h| format!("{h}\n"));
for para in paragraphs {
if current.len() + para.len() > max_chars && !current.trim().is_empty() {
chunks.push(Chunk {
index: chunks.len(),
content: current.trim().to_string(),
heading: heading.clone(),
});
current = heading
.as_deref()
.map_or_else(String::new, |h| format!("{h}\n"));
}
if para.len() > max_chars {
// Paragraph too big — split on lines
if !current.trim().is_empty() {
chunks.push(Chunk {
index: chunks.len(),
content: current.trim().to_string(),
heading: heading.clone(),
});
current = heading
.as_deref()
.map_or_else(String::new, |h| format!("{h}\n"));
}
for line_chunk in split_on_lines(¶, max_chars) {
chunks.push(Chunk {
index: chunks.len(),
content: line_chunk.trim().to_string(),
heading: heading.clone(),
});
}
} else {
current.push_str(¶);
current.push('\n');
}
}
if !current.trim().is_empty() {
chunks.push(Chunk {
index: chunks.len(),
content: current.trim().to_string(),
heading: heading.clone(),
});
}
}
}
// Filter out empty chunks
chunks.retain(|c| !c.content.is_empty());
// Re-index
for (i, chunk) in chunks.iter_mut().enumerate() {
chunk.index = i;
}
chunks
}
/// Split text into `(heading, body)` sections.
fn split_on_headings(text: &str) -> Vec<(Option<String>, String)> {
let mut sections = Vec::new();
let mut current_heading: Option<String> = None;
let mut current_body = String::new();
for line in text.lines() {
if line.starts_with("# ") || line.starts_with("## ") || line.starts_with("### ") {
if !current_body.trim().is_empty() || current_heading.is_some() {
sections.push((current_heading.take(), std::mem::take(&mut current_body)));
}
current_heading = Some(line.to_string());
} else {
current_body.push_str(line);
current_body.push('\n');
}
}
if !current_body.trim().is_empty() || current_heading.is_some() {
sections.push((current_heading, current_body));
}
sections
}
/// Split text on blank lines (paragraph boundaries)
fn split_on_blank_lines(text: &str) -> Vec<String> {
let mut paragraphs = Vec::new();
let mut current = String::new();
for line in text.lines() {
if line.trim().is_empty() {
if !current.trim().is_empty() {
paragraphs.push(std::mem::take(&mut current));
}
} else {
current.push_str(line);
current.push('\n');
}
}
if !current.trim().is_empty() {
paragraphs.push(current);
}
paragraphs
}
/// Split text on line boundaries to fit within `max_chars`
fn split_on_lines(text: &str, max_chars: usize) -> Vec<String> {
let mut chunks = Vec::with_capacity(text.len() / max_chars.max(1) + 1);
let mut current = String::new();
for line in text.lines() {
if current.len() + line.len() + 1 > max_chars && !current.is_empty() {
chunks.push(std::mem::take(&mut current));
}
current.push_str(line);
current.push('\n');
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_text() {
assert!(chunk_markdown("", 512).is_empty());
assert!(chunk_markdown(" ", 512).is_empty());
}
#[test]
fn single_short_paragraph() {
let chunks = chunk_markdown("Hello world", 512);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].content, "Hello world");
assert!(chunks[0].heading.is_none());
}
#[test]
fn heading_sections() {
let text = "# Title\nSome intro.\n\n## Section A\nContent A.\n\n## Section B\nContent B.";
let chunks = chunk_markdown(text, 512);
assert!(chunks.len() >= 3);
assert!(chunks[0].heading.is_none() || chunks[0].heading.as_deref() == Some("# Title"));
}
#[test]
fn respects_max_tokens() {
// Build multi-line text (one sentence per line) to exercise line-level splitting
let long_text: String = (0..200).fold(String::new(), |mut s, i| {
use std::fmt::Write;
let _ = writeln!(
s,
"This is sentence number {i} with some extra words to fill it up."
);
s
});
let chunks = chunk_markdown(&long_text, 50); // 50 tokens ≈ 200 chars
assert!(
chunks.len() > 1,
"Expected multiple chunks, got {}",
chunks.len()
);
for chunk in &chunks {
// Allow some slack (heading re-insertion etc.)
assert!(
chunk.content.len() <= 300,
"Chunk too long: {} chars",
chunk.content.len()
);
}
}
#[test]
fn preserves_heading_in_split_sections() {
let mut text = String::from("## Big Section\n");
for i in 0..100 {
use std::fmt::Write;
let _ = write!(text, "Line {i} with some content here.\n\n");
}
let chunks = chunk_markdown(&text, 50);
assert!(chunks.len() > 1);
// All chunks from this section should reference the heading
for chunk in &chunks {
if chunk.heading.is_some() {
assert_eq!(chunk.heading.as_deref(), Some("## Big Section"));
}
}
}
#[test]
fn indexes_are_sequential() {
let text = "# A\nContent A\n\n# B\nContent B\n\n# C\nContent C";
let chunks = chunk_markdown(text, 512);
for (i, chunk) in chunks.iter().enumerate() {
assert_eq!(chunk.index, i);
}
}
#[test]
fn chunk_count_reasonable() {
let text = "Hello world. This is a test document.";
let chunks = chunk_markdown(text, 512);
assert_eq!(chunks.len(), 1);
}
// ── Edge cases ───────────────────────────────────────────────
#[test]
fn headings_only_no_body() {
let text = "# Title\n## Section A\n## Section B\n### Subsection";
let chunks = chunk_markdown(text, 512);
// Should produce chunks for each heading (even with empty bodies)
assert!(!chunks.is_empty());
}
#[test]
fn deeply_nested_headings_ignored() {
// #### and deeper are NOT treated as heading splits
let text = "# Top\nIntro\n#### Deep heading\nDeep content";
let chunks = chunk_markdown(text, 512);
// "#### Deep heading" should stay with its parent section
assert!(!chunks.is_empty());
let all_content: String = chunks.iter().map(|c| c.content.clone()).collect();
assert!(all_content.contains("Deep heading"));
assert!(all_content.contains("Deep content"));
}
#[test]
fn very_long_single_line_no_newlines() {
// One giant line with no newlines — can't split on lines effectively
let text = "word ".repeat(5000);
let chunks = chunk_markdown(&text, 50);
// Should produce at least 1 chunk without panicking
assert!(!chunks.is_empty());
}
#[test]
fn only_newlines_and_whitespace() {
assert!(chunk_markdown("\n\n\n \n\n", 512).is_empty());
}
#[test]
fn max_tokens_zero() {
// max_tokens=0 → max_chars=0, should not panic or infinite loop
let chunks = chunk_markdown("Hello world", 0);
// Every chunk will exceed 0 chars, so it splits maximally
assert!(!chunks.is_empty());
}
#[test]
fn max_tokens_one() {
// max_tokens=1 → max_chars=4, very aggressive splitting
let text = "Line one\nLine two\nLine three";
let chunks = chunk_markdown(text, 1);
assert!(!chunks.is_empty());
}
#[test]
fn unicode_content() {
let text = "# 日本語\nこんにちは世界\n\n## Émojis\n🦀 Rust is great 🚀";
let chunks = chunk_markdown(text, 512);
assert!(!chunks.is_empty());
let all: String = chunks.iter().map(|c| c.content.clone()).collect();
assert!(all.contains("こんにちは"));
assert!(all.contains("🦀"));
}
#[test]
fn fts5_special_chars_in_content() {
let text = "Content with \"quotes\" and (parentheses) and * asterisks *";
let chunks = chunk_markdown(text, 512);
assert_eq!(chunks.len(), 1);
assert!(chunks[0].content.contains("\"quotes\""));
}
#[test]
fn multiple_blank_lines_between_paragraphs() {
let text = "Paragraph one.\n\n\n\n\nParagraph two.\n\n\n\nParagraph three.";
let chunks = chunk_markdown(text, 512);
assert_eq!(chunks.len(), 1); // All fits in one chunk
assert!(chunks[0].content.contains("Paragraph one"));
assert!(chunks[0].content.contains("Paragraph three"));
}
#[test]
fn heading_at_end_of_text() {
let text = "Some content\n# Trailing Heading";
let chunks = chunk_markdown(text, 512);
assert!(!chunks.is_empty());
}
#[test]
fn single_heading_no_content() {
let text = "# Just a heading";
let chunks = chunk_markdown(text, 512);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].heading.as_deref(), Some("# Just a heading"));
}
#[test]
fn no_content_loss() {
let text = "# A\nContent A line 1\nContent A line 2\n\n## B\nContent B\n\n## C\nContent C";
let chunks = chunk_markdown(text, 512);
let reassembled: String = chunks.iter().fold(String::new(), |mut s, c| {
use std::fmt::Write;
let _ = writeln!(s, "{}", c.content);
s
});
// All original content words should appear
for word in ["Content", "line", "1", "2"] {
assert!(
reassembled.contains(word),
"Missing word '{word}' in reassembled chunks"
);
}
}
}