use anyhow::Result;
use ragrig::{
DocumentParsers, EmbedderSpec, build_parsers, build_parsers_without_vision,
chunker::{Chunker, available_chunkers},
scan_document_files,
store::{BruteForceStore, StoredChunk, VectorStore},
types::{ChunkConfig, ChunkMeta, DocumentId, PipelineFilter, PipelineProvenance},
};
use std::path::PathBuf;
#[derive(serde::Serialize)]
struct ChunkRecord {
text: String,
section: Option<String>,
section_path: Vec<String>,
token_span: Option<(usize, usize)>,
}
#[derive(serde::Serialize)]
struct DocReport {
file: String,
parser: String,
chunks: Vec<ChunkRecord>,
}
#[derive(serde::Serialize)]
struct ChunkerReport {
chunker: String,
chunk_size: usize,
chunk_overlap: usize,
documents: Vec<DocReport>,
}
#[derive(serde::Serialize)]
struct Summary {
chunker: String,
chunks: usize,
avg_chars_per_chunk: f64,
max_chars: usize,
}
#[tokio::main]
async fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let folder: PathBuf = args
.next()
.ok_or_else(|| {
anyhow::anyhow!("usage: chunk_compare <folder> [out_dir] [query] [--no-vision]")
})?
.into();
let out_dir: PathBuf = args
.next()
.map(Into::into)
.unwrap_or_else(|| PathBuf::from("./chunk_compare_out"));
let query = args
.next()
.unwrap_or_else(|| "What are the main findings?".to_string());
let no_vision = args.any(|a| a == "--no-vision");
std::fs::create_dir_all(&out_dir)?;
let config = ChunkConfig::default();
let parsers = if no_vision {
DocumentParsers::new(build_parsers_without_vision())
} else {
DocumentParsers::new(build_parsers())
};
let mut parsed: Vec<(String, String, String)> = Vec::new(); for (doc, name) in scan_document_files(&folder) {
match parsers.parse_with_name(doc.path()) {
Ok((markdown, parser)) => {
println!("parsed {name:>40} with {parser}");
parsed.push((name, parser.to_string(), markdown));
}
Err(e) => println!("skipping {name}: {e}"),
}
}
if parsed.is_empty() {
anyhow::bail!("no parseable documents found in {}", folder.display());
}
struct Collected {
chunker: Box<dyn Chunker>,
entries: Vec<(String, String, ragrig::ChunkedText)>,
chunks: usize,
}
let mut collected: Vec<Collected> = Vec::new();
let mut summaries: Vec<Summary> = Vec::new();
for chunker in available_chunkers() {
println!("chunking with '{}'...", chunker.name());
let mut documents: Vec<DocReport> = Vec::new();
let mut entries: Vec<(String, String, ragrig::ChunkedText)> = Vec::new();
for (file, parser, markdown) in &parsed {
let chunks = chunker.chunk(markdown, &config).await?;
documents.push(DocReport {
file: file.clone(),
parser: parser.clone(),
chunks: chunks
.iter()
.map(|c| ChunkRecord {
text: c.text.clone(),
section: c.section.clone(),
section_path: c.section_path.clone(),
token_span: c.token_span,
})
.collect(),
});
entries.extend(
chunks
.into_iter()
.map(|c| (file.clone(), parser.clone(), c)),
);
}
let total_chunks = entries.len();
let total_chars: usize = entries.iter().map(|(_, _, c)| c.text.len()).sum();
let max_chars = entries
.iter()
.map(|(_, _, c)| c.text.len())
.max()
.unwrap_or(0);
summaries.push(Summary {
chunker: chunker.name().to_string(),
chunks: total_chunks,
avg_chars_per_chunk: if total_chunks == 0 {
0.0
} else {
total_chars as f64 / total_chunks as f64
},
max_chars,
});
let report = ChunkerReport {
chunker: chunker.name().to_string(),
chunk_size: config.size,
chunk_overlap: config.overlap,
documents,
};
let path = out_dir.join(format!("chunks_{}.json", chunker.name()));
std::fs::write(&path, serde_json::to_string_pretty(&report)?)?;
println!(
" -> {} chunks, avg {:.0} chars | wrote {}",
total_chunks,
summaries.last().unwrap().avg_chars_per_chunk,
path.display()
);
collected.push(Collected {
chunker,
entries,
chunks: total_chunks,
});
}
std::fs::write(
out_dir.join("summary.json"),
serde_json::to_string_pretty(&summaries)?,
)?;
let model =
std::env::var("RAGRIG_EMBED_MODEL").unwrap_or_else(|_| "nomic-embed-text".to_string());
println!("vectorizing with Ollama model '{model}'...");
let embedder = EmbedderSpec::ollama(&model).build()?;
let embedder_id = embedder.metadata().id();
let store = BruteForceStore::open_or_create(&out_dir)?;
for c in &collected {
let texts: Vec<String> = c
.entries
.iter()
.map(|(_, _, chunk)| chunk.text.clone())
.collect();
if texts.is_empty() {
continue;
}
let mut embedded: Vec<ragrig::Embedding> = Vec::with_capacity(texts.len());
for (i, batch) in texts.chunks(100).enumerate() {
embedded.extend(embedder.embed(batch.to_vec()).await?);
let done = (i * 100 + batch.len()).min(texts.len());
println!(
" embedded {done}/{} chunks for '{}'",
texts.len(),
c.chunker.name()
);
}
let stored: Vec<StoredChunk> = embedded
.into_iter()
.zip(&c.entries)
.map(|(embedding, (file, parser, chunk))| StoredChunk {
text: embedding.text,
document: DocumentId::from(file.clone()),
vector: embedding.vector,
meta: ChunkMeta {
section: chunk.section.clone(),
..ChunkMeta::default()
},
provenance: PipelineProvenance::new(parser, c.chunker.name(), &embedder_id),
})
.collect();
store.insert(stored).await?;
println!(
" indexed {} chunks from pipeline '{}'",
c.chunks,
c.chunker.name()
);
}
store.flush()?;
let embedded_query = embedder.embed(vec![query.clone()]).await?;
let Some(query_embedding) = embedded_query.first() else {
anyhow::bail!("embedder returned no query vector");
};
println!("\nQuery: \"{query}\"");
for c in &collected {
let filter = PipelineFilter::for_chunker(c.chunker.name());
let hits = store
.search_filtered(&query_embedding.vector, &query, 3, 0.0, &filter)
.await?;
println!(
"\n── pipeline '{}' ({} chunks) ──",
c.chunker.name(),
c.chunks
);
if hits.is_empty() {
println!(" (no hits)");
continue;
}
for (i, hit) in hits.iter().enumerate() {
let snippet: String = hit.chunk.text.chars().take(90).collect();
println!(
" {}. [{:.4}] {} — {snippet}...",
i + 1,
hit.score,
hit.chunk.document
);
}
}
println!(
"\nSummary written to {}",
out_dir.join("summary.json").display()
);
println!(
"Store written to {}",
out_dir.join(".ragrig_store").display()
);
Ok(())
}