use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ChunkBoundary {
Paragraph,
Sentence,
Fixed,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub struct ChunkPolicy {
pub max_chunk_bytes: usize,
pub overlap_bytes: usize,
pub boundary: ChunkBoundary,
}
impl Default for ChunkPolicy {
fn default() -> Self {
Self {
max_chunk_bytes: 2_048,
overlap_bytes: 0,
boundary: ChunkBoundary::Paragraph,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextChunk {
pub text: String,
pub byte_range: core::ops::Range<usize>,
pub index: usize,
}
#[must_use]
pub fn chunk_text(text: &str, policy: &ChunkPolicy) -> Vec<TextChunk> {
if text.is_empty() {
return Vec::new();
}
let max = policy.max_chunk_bytes.max(1);
if text.len() <= max {
return vec![TextChunk {
text: text.to_owned(),
byte_range: 0..text.len(),
index: 0,
}];
}
let ranges = pack_units(text, &units(text, policy.boundary), max);
assemble(text, &ranges, policy.overlap_bytes)
}
struct Unit {
range: core::ops::Range<usize>,
atomic: bool,
}
fn units(text: &str, boundary: ChunkBoundary) -> Vec<Unit> {
let mut units = Vec::new();
for segment in fence_segments(text) {
match segment {
Segment::Fence(range) => units.push(Unit {
range,
atomic: true,
}),
Segment::Plain(range) => split_plain(text, range, boundary, &mut units),
}
}
units
}
enum Segment {
Fence(core::ops::Range<usize>),
Plain(core::ops::Range<usize>),
}
fn fence_segments(text: &str) -> Vec<Segment> {
let mut segments = Vec::new();
let mut cursor = 0_usize;
let mut fence_start: Option<usize> = None;
let mut line_start = 0_usize;
for line in text.split_inclusive('\n') {
let opens_or_closes = line.trim_start().starts_with("```");
let line_end = line_start + line.len();
match (fence_start, opens_or_closes) {
(None, true) => {
if line_start > cursor {
segments.push(Segment::Plain(cursor..line_start));
}
fence_start = Some(line_start);
}
(Some(start), true) => {
segments.push(Segment::Fence(start..line_end));
fence_start = None;
cursor = line_end;
}
_ => {}
}
line_start = line_end;
}
push_tail(&mut segments, fence_start, cursor, text.len());
segments
}
fn push_tail(segments: &mut Vec<Segment>, fence_start: Option<usize>, cursor: usize, end: usize) {
match fence_start {
Some(start) => segments.push(Segment::Fence(start..end)),
None if cursor < end => segments.push(Segment::Plain(cursor..end)),
None => {}
}
}
fn split_plain(
text: &str,
range: core::ops::Range<usize>,
boundary: ChunkBoundary,
out: &mut Vec<Unit>,
) {
let slice = &text[range.clone()];
let mut piece_start = 0_usize;
for cut in boundary_cuts(slice, boundary) {
out.push(Unit {
range: range.start + piece_start..range.start + cut,
atomic: false,
});
piece_start = cut;
}
if piece_start < slice.len() {
out.push(Unit {
range: range.start + piece_start..range.end,
atomic: false,
});
}
}
fn boundary_cuts(slice: &str, boundary: ChunkBoundary) -> Vec<usize> {
match boundary {
ChunkBoundary::Paragraph => paragraph_cuts(slice),
ChunkBoundary::Sentence => sentence_cuts(slice),
ChunkBoundary::Fixed => Vec::new(),
}
}
fn paragraph_cuts(slice: &str) -> Vec<usize> {
let mut cuts = Vec::new();
let bytes = slice.as_bytes();
let mut i = 0_usize;
while let Some(found) = find_from(bytes, i, b"\n\n") {
let mut end = found + 2;
while bytes.get(end) == Some(&b'\n') {
end += 1;
}
cuts.push(end);
i = end;
}
cuts
}
fn sentence_cuts(slice: &str) -> Vec<usize> {
let mut cuts = Vec::new();
let mut previous: Option<char> = None;
for (offset, ch) in slice.char_indices() {
let after_ender = matches!(previous, Some('.' | '!' | '?'));
if after_ender && ch.is_whitespace() {
cuts.push(offset + ch.len_utf8());
}
previous = Some(ch);
}
cuts
}
fn find_from(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
haystack
.get(from..)?
.windows(needle.len())
.position(|window| window == needle)
.map(|position| from + position)
}
fn pack_units(text: &str, units: &[Unit], max: usize) -> Vec<core::ops::Range<usize>> {
let mut chunks: Vec<core::ops::Range<usize>> = Vec::new();
let mut open: Option<core::ops::Range<usize>> = None;
for unit in units {
if unit.range.len() > max {
flush(&mut chunks, &mut open);
append_oversized(text, unit, max, &mut chunks);
} else {
open = Some(merge_or_flush(&mut chunks, open, unit.range.clone(), max));
}
}
flush(&mut chunks, &mut open);
chunks
}
fn merge_or_flush(
chunks: &mut Vec<core::ops::Range<usize>>,
open: Option<core::ops::Range<usize>>,
unit: core::ops::Range<usize>,
max: usize,
) -> core::ops::Range<usize> {
match open {
Some(range) if unit.end - range.start <= max => range.start..unit.end,
Some(range) => {
chunks.push(range);
unit
}
None => unit,
}
}
fn flush(chunks: &mut Vec<core::ops::Range<usize>>, open: &mut Option<core::ops::Range<usize>>) {
if let Some(range) = open.take() {
chunks.push(range);
}
}
fn append_oversized(
text: &str,
unit: &Unit,
max: usize,
chunks: &mut Vec<core::ops::Range<usize>>,
) {
if unit.atomic {
chunks.push(unit.range.clone());
return;
}
let mut start = unit.range.start;
while start < unit.range.end {
let floored = char_floor(text, (start + max).min(unit.range.end));
let end = if floored > start {
floored
} else {
char_ceil(text, start + 1).min(unit.range.end)
};
chunks.push(start..end);
start = end;
}
}
fn char_floor(text: &str, at: usize) -> usize {
let mut boundary = at.min(text.len());
while !text.is_char_boundary(boundary) {
boundary -= 1;
}
boundary
}
fn assemble(
text: &str,
ranges: &[core::ops::Range<usize>],
overlap_bytes: usize,
) -> Vec<TextChunk> {
ranges
.iter()
.enumerate()
.map(|(index, range)| {
let mut chunk_text = String::new();
if overlap_bytes > 0 && index > 0 {
let overlap_start = char_ceil(text, range.start.saturating_sub(overlap_bytes));
chunk_text.push_str(&text[overlap_start..range.start]);
}
chunk_text.push_str(&text[range.clone()]);
TextChunk {
text: chunk_text,
byte_range: range.clone(),
index,
}
})
.collect()
}
fn char_ceil(text: &str, at: usize) -> usize {
let mut boundary = at.min(text.len());
while !text.is_char_boundary(boundary) {
boundary += 1;
}
boundary
}
#[cfg(test)]
#[path = "chunk_tests.rs"]
mod tests;