use crate::types::ChunkConfig;
use anyhow::Result;
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ChunkedText {
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub section: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub section_path: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_span: Option<(usize, usize)>,
}
impl ChunkedText {
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
section: None,
section_path: Vec::new(),
token_span: None,
}
}
}
#[async_trait::async_trait]
pub trait Chunker: Send + Sync + std::fmt::Debug + dyn_clone::DynClone {
fn name(&self) -> &'static str;
async fn chunk(&self, text: &str, config: &ChunkConfig) -> Result<Vec<ChunkedText>>;
}
dyn_clone::clone_trait_object!(Chunker);
#[derive(Debug, Clone, Copy, Default)]
pub struct MarkdownChunker;
#[async_trait::async_trait]
impl Chunker for MarkdownChunker {
fn name(&self) -> &'static str {
"markdown"
}
async fn chunk(&self, text: &str, config: &ChunkConfig) -> Result<Vec<ChunkedText>> {
Ok(crate::parsers::markdown_chunk_with_sections(text, config)
.into_iter()
.map(|(text, section)| ChunkedText {
text,
section_path: section.iter().cloned().collect(),
section,
token_span: None,
})
.collect())
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TokenChunker;
#[async_trait::async_trait]
impl Chunker for TokenChunker {
fn name(&self) -> &'static str {
"token"
}
async fn chunk(&self, text: &str, config: &ChunkConfig) -> Result<Vec<ChunkedText>> {
Ok(crate::parsers::chunk_text(text, config)
.into_iter()
.map(ChunkedText::plain)
.collect())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChunkedrsMode {
#[default]
Recursive,
Markdown,
Code,
Html,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ChunkedrsChunker {
mode: ChunkedrsMode,
}
impl ChunkedrsChunker {
pub fn new(mode: ChunkedrsMode) -> Self {
Self { mode }
}
pub fn recursive() -> Self {
Self::new(ChunkedrsMode::Recursive)
}
pub fn markdown() -> Self {
Self::new(ChunkedrsMode::Markdown)
}
pub fn code() -> Self {
Self::new(ChunkedrsMode::Code)
}
pub fn html() -> Self {
Self::new(ChunkedrsMode::Html)
}
pub fn mode(&self) -> ChunkedrsMode {
self.mode
}
}
#[async_trait::async_trait]
impl Chunker for ChunkedrsChunker {
fn name(&self) -> &'static str {
match self.mode {
ChunkedrsMode::Recursive => "chunkedrs-recursive",
ChunkedrsMode::Markdown => "chunkedrs-markdown",
ChunkedrsMode::Code => "chunkedrs-code",
ChunkedrsMode::Html => "chunkedrs-html",
}
}
async fn chunk(&self, text: &str, config: &ChunkConfig) -> Result<Vec<ChunkedText>> {
let builder = chunkedrs::chunk(text)
.max_tokens(config.size)
.overlap(config.overlap);
let chunks = match self.mode {
ChunkedrsMode::Recursive => builder,
ChunkedrsMode::Markdown => builder.markdown(),
ChunkedrsMode::Code => builder.code(),
ChunkedrsMode::Html => builder.html(),
}
.split();
Ok(chunks
.into_iter()
.filter(|c| !c.content.trim().is_empty())
.map(|c| {
let section = c.section().map(str::to_string);
let section_path = c.section_path;
let token_span = Some((c.start_token, c.end_token));
ChunkedText {
text: c.content,
section,
section_path,
token_span,
}
})
.collect())
}
}
#[cfg(feature = "chunker-textsplitter")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextSplitterMode {
#[default]
Text,
Markdown,
}
#[cfg(feature = "chunker-textsplitter")]
#[derive(Debug, Clone, Copy, Default)]
pub struct TextSplitterChunker {
mode: TextSplitterMode,
}
#[cfg(feature = "chunker-textsplitter")]
impl TextSplitterChunker {
pub fn new(mode: TextSplitterMode) -> Self {
Self { mode }
}
pub fn text() -> Self {
Self::new(TextSplitterMode::Text)
}
pub fn markdown() -> Self {
Self::new(TextSplitterMode::Markdown)
}
pub fn mode(&self) -> TextSplitterMode {
self.mode
}
}
#[cfg(feature = "chunker-textsplitter")]
#[async_trait::async_trait]
impl Chunker for TextSplitterChunker {
fn name(&self) -> &'static str {
match self.mode {
TextSplitterMode::Text => "textsplitter",
TextSplitterMode::Markdown => "textsplitter-markdown",
}
}
async fn chunk(&self, text: &str, config: &ChunkConfig) -> Result<Vec<ChunkedText>> {
let max_chars = config.size.saturating_mul(4);
let chunks: Vec<String> = match self.mode {
TextSplitterMode::Text => text_splitter::TextSplitter::new(max_chars)
.chunks(text)
.map(str::to_string)
.collect(),
TextSplitterMode::Markdown => text_splitter::MarkdownSplitter::new(max_chars)
.chunks(text)
.map(str::to_string)
.collect(),
};
Ok(chunks.into_iter().map(ChunkedText::plain).collect())
}
}
#[cfg(feature = "chunker-cognigraph")]
#[derive(Debug, Clone, Copy, Default)]
pub struct CognigraphChunker;
#[cfg(feature = "chunker-cognigraph")]
#[async_trait::async_trait]
impl Chunker for CognigraphChunker {
fn name(&self) -> &'static str {
"cognigraph-core"
}
async fn chunk(&self, text: &str, config: &ChunkConfig) -> Result<Vec<ChunkedText>> {
let target_bytes = config.size.saturating_mul(4);
let chunks = cognigraph_chunker::core::chunk(text.as_bytes())
.size(target_bytes)
.collect::<Vec<&[u8]>>();
Ok(chunks
.into_iter()
.map(|bytes| {
ChunkedText::plain(String::from_utf8_lossy(bytes).trim().to_string())
})
.filter(|c| !c.text.is_empty())
.collect())
}
}
#[allow(unused_mut)] pub fn available_chunkers() -> Vec<Box<dyn Chunker>> {
let mut chunkers: Vec<Box<dyn Chunker>> = vec![
Box::new(MarkdownChunker),
Box::new(TokenChunker),
Box::new(ChunkedrsChunker::recursive()),
Box::new(ChunkedrsChunker::markdown()),
Box::new(ChunkedrsChunker::code()),
Box::new(ChunkedrsChunker::html()),
];
#[cfg(feature = "chunker-textsplitter")]
{
chunkers.push(Box::new(TextSplitterChunker::text()));
chunkers.push(Box::new(TextSplitterChunker::markdown()));
}
#[cfg(feature = "chunker-cognigraph")]
{
chunkers.push(Box::new(CognigraphChunker));
}
chunkers
}
#[cfg(test)]
mod tests {
use super::*;
fn long_md() -> String {
"# Intro\n\nIntroductory paragraph with some words.\n\n## Method\n\nThe method section describes the procedure in detail. ".repeat(40)
}
fn chunk_sync(
chunker: &dyn Chunker,
text: &str,
config: &ChunkConfig,
) -> Result<Vec<ChunkedText>> {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(chunker.chunk(text, config))
}
#[test]
fn markdown_chunker_keeps_sections() {
let md = "# H1\n\nshort body\n\n## H2\n\nanother body\n";
let chunks = chunk_sync(&MarkdownChunker, md, &ChunkConfig::default()).unwrap();
assert!(chunks.len() >= 2);
assert!(chunks.iter().any(|c| c.section.as_deref() == Some("# H1")));
assert!(chunks.iter().any(|c| c.section.as_deref() == Some("## H2")));
for c in &chunks {
assert_eq!(c.section_path.len(), usize::from(c.section.is_some()));
}
}
#[test]
fn token_chunker_respects_size() {
let chunks = chunk_sync(&TokenChunker, &long_md(), &ChunkConfig::new(64, 8).unwrap())
.unwrap();
assert!(chunks.len() > 1);
assert!(chunks.iter().all(|c| !c.text.is_empty() && c.text.len() <= 64 * 16));
}
#[test]
fn chunkedrs_markdown_tracks_ancestry_and_token_spans() {
let md = "# Guide\n\nIntro.\n\n## Install\n\nRun cargo add.\n";
let chunks = chunk_sync(
&ChunkedrsChunker::markdown(),
md,
&ChunkConfig::new(512, 0).unwrap(),
)
.unwrap();
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].section.as_deref(), Some("# Guide"));
assert_eq!(chunks[0].section_path, vec!["# Guide"]);
assert_eq!(chunks[1].section_path, vec!["# Guide", "## Install"]);
for c in &chunks {
let (start, end) = c.token_span.unwrap();
assert!(start < end, "token span must be non-empty");
}
}
#[test]
fn chunkedrs_code_and_html_modes_split() {
let code = "fn a() {\n one();\n}\n\nfn b() {\n two();\n}\n";
let chunks = chunk_sync(&ChunkedrsChunker::code(), code, &ChunkConfig::new(8, 0).unwrap())
.unwrap();
assert!(!chunks.is_empty());
let html = "<h1>Title</h1><p>First para.</p><p>Second para.</p>";
let chunks = chunk_sync(&ChunkedrsChunker::html(), html, &ChunkConfig::new(8, 0).unwrap())
.unwrap();
assert!(chunks.len() >= 2);
}
#[test]
fn chunkers_work_through_the_dyn_trait() {
let chunkers: Vec<Box<dyn Chunker>> = available_chunkers();
assert!(!chunkers.is_empty());
for chunker in chunkers {
assert!(!chunker.name().is_empty());
let chunks = chunk_sync(&*chunker, &long_md(), &ChunkConfig::default()).unwrap();
assert!(
!chunks.is_empty(),
"chunker {} produced no chunks",
chunker.name()
);
}
}
}