use regex::Regex;
use std::sync::LazyLock;
static DIALOGUE_TURN_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^([A-Z][a-zA-Z0-9_\- ]{0,30})\s*:").unwrap());
static SECTION_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^(?:\[.*?\]|#{1,3}\s+\w|Session \d+|---+)").unwrap());
pub struct ChunkConfig {
pub chunk_size: usize,
pub overlap: usize,
pub min_chunk_size: usize,
}
impl Default for ChunkConfig {
fn default() -> Self {
Self {
chunk_size: 800,
overlap: 200,
min_chunk_size: 200,
}
}
}
#[derive(Debug, Clone)]
pub struct ChunkResult {
pub chunks: Vec<String>,
pub original_length: usize,
pub was_chunked: bool,
}
impl ChunkResult {
pub fn coverage_ratio(&self) -> f32 {
if self.chunks.is_empty() {
return 0.0;
}
1.0
}
}
#[inline]
fn floor_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
return s.len();
}
let mut i = index;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
#[inline]
fn ceil_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
return s.len();
}
let mut i = index;
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
pub fn chunk_text(text: &str, config: &ChunkConfig) -> ChunkResult {
let text = text.trim();
let original_length = text.len();
if original_length <= config.chunk_size {
return ChunkResult {
chunks: vec![text.to_string()],
original_length,
was_chunked: false,
};
}
let mut chunks = Vec::new();
let mut start = 0;
while start < original_length {
let mut end = floor_char_boundary(text, (start + config.chunk_size).min(original_length));
if end < original_length {
end = find_break_point(text, start, end, config.min_chunk_size);
end = floor_char_boundary(text, end);
}
start = ceil_char_boundary(text, start);
if start >= end {
break;
}
let chunk = text[start..end].trim();
if chunk.len() >= config.min_chunk_size || chunks.is_empty() {
chunks.push(chunk.to_string());
} else if let Some(last) = chunks.last_mut() {
last.push(' ');
last.push_str(chunk);
}
if end >= original_length {
break;
}
start = ceil_char_boundary(text, end.saturating_sub(config.overlap));
if start <= chunks.len().saturating_sub(1) * (config.chunk_size - config.overlap) {
start = ceil_char_boundary(text, end);
}
}
ChunkResult {
chunks,
original_length,
was_chunked: true,
}
}
fn find_break_point(text: &str, start: usize, ideal_end: usize, min_size: usize) -> usize {
let chunk = &text[start..ideal_end];
let sentence_boundaries: Vec<usize> = chunk
.char_indices()
.filter_map(|(byte_offset, c)| {
if (c == '.' || c == '!' || c == '?') && byte_offset >= min_size {
let after = byte_offset + c.len_utf8();
let next_char = chunk[after..].chars().next();
if next_char.is_none_or(|nc| nc.is_whitespace()) {
return Some(start + after);
}
}
None
})
.collect();
if let Some(&boundary) = sentence_boundaries.last() {
return boundary;
}
let word_boundaries: Vec<usize> = chunk
.char_indices()
.filter_map(|(i, c)| {
if c.is_whitespace() && i >= min_size {
Some(start + i)
} else {
None
}
})
.collect();
if let Some(&boundary) = word_boundaries.last() {
return boundary;
}
ideal_end
}
pub fn estimate_tokens(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let words = text.split_whitespace().count();
if words == 0 {
return text.chars().count().div_ceil(4);
}
let base_tokens = (words as f64 * 1.3).ceil() as usize;
let special_chars = text
.chars()
.filter(|c| c.is_ascii_punctuation() || *c == '\n')
.count();
let punct_tokens = special_chars / 3;
base_tokens + punct_tokens
}
pub struct SemanticChunkConfig {
pub target_size: usize,
pub max_size: usize,
pub min_size: usize,
pub preserve_dialogue_turns: bool,
pub split_on_paragraphs: bool,
}
impl Default for SemanticChunkConfig {
fn default() -> Self {
Self {
target_size: 800,
max_size: 1200,
min_size: 100,
preserve_dialogue_turns: true,
split_on_paragraphs: true,
}
}
}
#[derive(Debug, Clone)]
struct SemanticSegment {
text: String,
#[allow(dead_code)]
segment_type: SegmentType,
}
#[derive(Debug, Clone, PartialEq)]
enum SegmentType {
DialogueTurn,
Paragraph,
Section,
Text,
}
pub fn semantic_chunk_text(text: &str, config: &SemanticChunkConfig) -> ChunkResult {
let text = text.trim();
let original_length = text.len();
if original_length <= config.target_size {
return ChunkResult {
chunks: vec![text.to_string()],
original_length,
was_chunked: false,
};
}
let segments = split_into_segments(text, config);
let chunks = group_segments_into_chunks(segments, config);
ChunkResult {
chunks,
original_length,
was_chunked: true,
}
}
fn split_into_segments(text: &str, config: &SemanticChunkConfig) -> Vec<SemanticSegment> {
let mut segments = Vec::new();
let is_dialogue = config.preserve_dialogue_turns && DIALOGUE_TURN_PATTERN.is_match(text);
if is_dialogue {
let turn_starts: Vec<usize> = DIALOGUE_TURN_PATTERN
.find_iter(text)
.map(|m| m.start())
.collect();
if !turn_starts.is_empty() && turn_starts[0] > 0 {
let pre_text = text[..turn_starts[0]].trim();
if !pre_text.is_empty() {
segments.push(SemanticSegment {
text: pre_text.to_string(),
segment_type: SegmentType::Text,
});
}
}
for (i, &start) in turn_starts.iter().enumerate() {
let end = if i + 1 < turn_starts.len() {
turn_starts[i + 1]
} else {
text.len()
};
let turn_text = text[start..end].trim();
if !turn_text.is_empty() {
segments.push(SemanticSegment {
text: turn_text.to_string(),
segment_type: SegmentType::DialogueTurn,
});
}
}
} else if config.split_on_paragraphs {
let paragraph_pattern = Regex::new(r"\n\s*\n").unwrap();
let mut last_end = 0;
for mat in paragraph_pattern.find_iter(text) {
if mat.start() > last_end {
let para_text = text[last_end..mat.start()].trim();
if !para_text.is_empty() {
let seg_type = if SECTION_PATTERN.is_match(para_text) {
SegmentType::Section
} else {
SegmentType::Paragraph
};
segments.push(SemanticSegment {
text: para_text.to_string(),
segment_type: seg_type,
});
}
}
last_end = mat.end();
}
if last_end < text.len() {
let remaining = text[last_end..].trim();
if !remaining.is_empty() {
segments.push(SemanticSegment {
text: remaining.to_string(),
segment_type: SegmentType::Paragraph,
});
}
}
} else {
segments = split_by_sentences(text);
}
if segments.is_empty() {
segments.push(SemanticSegment {
text: text.to_string(),
segment_type: SegmentType::Text,
});
}
segments
}
fn split_by_sentences(text: &str) -> Vec<SemanticSegment> {
let sentence_pattern = Regex::new(r"[.!?]+\s+").unwrap();
let mut segments = Vec::new();
let mut last_end = 0;
for mat in sentence_pattern.find_iter(text) {
let sentence = text[last_end..mat.end()].trim();
if !sentence.is_empty() {
segments.push(SemanticSegment {
text: sentence.to_string(),
segment_type: SegmentType::Text,
});
}
last_end = mat.end();
}
if last_end < text.len() {
let remaining = text[last_end..].trim();
if !remaining.is_empty() {
segments.push(SemanticSegment {
text: remaining.to_string(),
segment_type: SegmentType::Text,
});
}
}
segments
}
fn group_segments_into_chunks(
segments: Vec<SemanticSegment>,
config: &SemanticChunkConfig,
) -> Vec<String> {
let mut chunks = Vec::new();
let mut current_chunk = String::new();
for segment in segments {
let segment_len = segment.text.len();
if segment_len > config.max_size {
if !current_chunk.is_empty() {
chunks.push(current_chunk.trim().to_string());
current_chunk = String::new();
}
let fixed_config = ChunkConfig {
chunk_size: config.target_size,
overlap: config.min_size / 2,
min_chunk_size: config.min_size,
};
let sub_chunks = chunk_text(&segment.text, &fixed_config);
chunks.extend(sub_chunks.chunks);
continue;
}
let new_len = current_chunk.len() + segment_len + 1;
if new_len > config.target_size && !current_chunk.is_empty() {
chunks.push(current_chunk.trim().to_string());
current_chunk = String::new();
}
if !current_chunk.is_empty() {
current_chunk.push('\n');
}
current_chunk.push_str(&segment.text);
}
if !current_chunk.is_empty() {
let trimmed = current_chunk.trim().to_string();
if trimmed.len() < config.min_size && !chunks.is_empty() {
let last = chunks.pop().unwrap_or_default();
chunks.push(format!("{last}\n{trimmed}"));
} else {
chunks.push(trimmed);
}
}
chunks
}
pub fn is_dialogue_format(text: &str) -> bool {
DIALOGUE_TURN_PATTERN.is_match(text)
}
pub fn auto_chunk_text(text: &str) -> ChunkResult {
if is_dialogue_format(text) {
semantic_chunk_text(text, &SemanticChunkConfig::default())
} else {
chunk_text(text, &ChunkConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_short_text_no_chunking() {
let config = ChunkConfig::default();
let result = chunk_text("This is a short text.", &config);
assert_eq!(result.chunks.len(), 1);
assert!(!result.was_chunked);
assert_eq!(result.chunks[0], "This is a short text.");
}
#[test]
fn test_long_text_chunking() {
let config = ChunkConfig {
chunk_size: 100,
overlap: 20,
min_chunk_size: 30,
};
let text = "This is sentence one. This is sentence two. This is sentence three. \
This is sentence four. This is sentence five. This is sentence six. \
This is sentence seven. This is sentence eight.";
let result = chunk_text(text, &config);
assert!(result.was_chunked);
assert!(result.chunks.len() > 1);
for chunk in &result.chunks {
assert!(
chunk.len() >= config.min_chunk_size,
"Chunk too small: '{}' (len={})",
chunk,
chunk.len()
);
}
let total_len: usize = result.chunks.iter().map(|c| c.len()).sum();
assert!(
total_len >= result.original_length,
"Total chunk length {} < original {}",
total_len,
result.original_length
);
}
#[test]
fn test_sentence_boundary_respected() {
let config = ChunkConfig {
chunk_size: 50,
overlap: 10,
min_chunk_size: 20,
};
let text = "First sentence here. Second sentence follows. Third sentence ends.";
let result = chunk_text(text, &config);
for chunk in &result.chunks {
let trimmed = chunk.trim();
if !trimmed.is_empty() && result.chunks.len() > 1 {
let last_char = trimmed.chars().last().unwrap();
assert!(
last_char == '.'
|| last_char == '!'
|| last_char == '?'
|| chunk == result.chunks.last().unwrap(),
"Chunk '{chunk}' doesn't end at sentence boundary"
);
}
}
}
#[test]
fn test_overlap_exists() {
let config = ChunkConfig {
chunk_size: 60,
overlap: 20,
min_chunk_size: 20,
};
let text = "AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH IIII JJJJ KKKK LLLL MMMM";
let result = chunk_text(text, &config);
if result.chunks.len() >= 2 {
for i in 0..result.chunks.len() - 1 {
let chunk1 = &result.chunks[i];
let chunk2 = &result.chunks[i + 1];
let words1: std::collections::HashSet<_> = chunk1.split_whitespace().collect();
let words2: std::collections::HashSet<_> = chunk2.split_whitespace().collect();
let common: Vec<_> = words1.intersection(&words2).collect();
assert!(
!common.is_empty() || chunk1.len() < config.overlap,
"No overlap between chunks {} and {}",
i,
i + 1
);
}
}
}
#[test]
fn test_token_estimation() {
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("test"), 2);
assert_eq!(estimate_tokens("hello world"), 3);
assert_eq!(estimate_tokens("Hello, world! How are you?"), 8);
let code = "fn main() { println!(\"hello\"); }";
let tokens = estimate_tokens(code);
assert!(tokens >= 5 && tokens <= 15, "Code tokens: {}", tokens);
assert_eq!(estimate_tokens("abcdefgh"), 2); }
#[test]
fn test_very_long_content() {
let config = ChunkConfig::default();
let long_text = "This is a test sentence. ".repeat(400);
let result = chunk_text(&long_text, &config);
assert!(result.was_chunked);
assert!(result.chunks.len() > 10); assert_eq!(result.coverage_ratio(), 1.0);
for chunk in &result.chunks {
assert!(
chunk.len() <= config.chunk_size + 100,
"Chunk too large: {} chars",
chunk.len()
);
}
}
#[test]
fn test_chunking_quality_unique_content_searchable() {
let config = ChunkConfig::default();
let beginning = "ALPHA_BEGINNING_MARKER is a unique identifier at the start.";
let middle_padding = "This is filler content to push things apart. ".repeat(30);
let middle = "BETA_MIDDLE_MARKER represents content in the center of the document.";
let end_padding = "More filler content for separation between sections. ".repeat(30);
let end = "GAMMA_END_MARKER signifies the conclusion of this memory content.";
let full_text = format!("{beginning} {middle_padding} {middle} {end_padding} {end}");
let result = chunk_text(&full_text, &config);
assert!(result.was_chunked, "Content should require chunking");
assert!(result.chunks.len() >= 3, "Should have multiple chunks");
let has_alpha = result.chunks.iter().any(|c| c.contains("ALPHA_BEGINNING"));
let has_beta = result.chunks.iter().any(|c| c.contains("BETA_MIDDLE"));
let has_gamma = result.chunks.iter().any(|c| c.contains("GAMMA_END"));
assert!(has_alpha, "ALPHA marker (beginning) not found in any chunk");
assert!(has_beta, "BETA marker (middle) not found in any chunk");
assert!(has_gamma, "GAMMA marker (end) not found in any chunk");
println!("Total chunks: {}", result.chunks.len());
println!("Original length: {} chars", result.original_length);
for (i, chunk) in result.chunks.iter().enumerate() {
let markers: Vec<&str> = vec![
if chunk.contains("ALPHA") { "ALPHA" } else { "" },
if chunk.contains("BETA") { "BETA" } else { "" },
if chunk.contains("GAMMA") { "GAMMA" } else { "" },
]
.into_iter()
.filter(|m| !m.is_empty())
.collect();
println!(
" Chunk {}: {} chars {}",
i,
chunk.len(),
if markers.is_empty() {
String::new()
} else {
format!("[contains: {}]", markers.join(", "))
}
);
}
}
#[test]
fn test_chunking_coverage_no_content_lost() {
let config = ChunkConfig {
chunk_size: 200,
overlap: 50,
min_chunk_size: 50,
};
let sentences: Vec<String> = (1..=20)
.map(|i| format!("Sentence number {i} contains unique information. "))
.collect();
let text = sentences.join("");
let result = chunk_text(&text, &config);
for i in 1..=20 {
let marker = format!("number {i}");
let found = result.chunks.iter().any(|c| c.contains(&marker));
assert!(
found,
"Sentence {i} not found in any chunk! Coverage gap detected."
);
}
}
}