use anyhow::Result;
use ragrig::{
ChunkConfig, Corpus, DocumentParsers, EmbedderSpec, FolderCorpus, MarkdownChunker, MultiCorpus,
PipelineFilter, UrlCorpus,
parsers::build_parsers,
store::open_store,
vector::{ingest_corpus, search_similar},
};
use std::path::Path;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() < 4 {
anyhow::bail!(
"usage: multi_corpus <store-dir> <query> <dir...> <url...>\n\
example: multi_corpus ./store \"quantum entanglement\" \
./docs/papers ./docs/books https://arxiv.org/pdf/2106.09685.pdf"
);
}
let store_dir = &args[0];
let query = &args[1];
let mut multi = MultiCorpus::new("demo-corpus");
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()?;
let url_corpus = UrlCorpus::new("urls", http_client)
.with_max_download_bytes(Some(ragrig::DEFAULT_MAX_DOWNLOAD_BYTES));
let mut dir_count = 0usize;
let mut url_count = 0usize;
for arg in &args[2..] {
if arg.starts_with("http://") || arg.starts_with("https://") {
url_corpus.add_url(arg.clone());
url_count += 1;
} else {
dir_count += 1;
multi.push(Box::new(FolderCorpus::named(
format!("dir{dir_count}"),
arg,
)));
}
}
if dir_count == 0 {
anyhow::bail!("no directory arguments given — pass at least one directory");
}
if url_corpus.is_empty() {
anyhow::bail!("no URL arguments given — pass at least one http(s):// URL");
}
multi.push(Box::new(url_corpus));
let mut corpus_names: Vec<String> = (1..=dir_count).map(|i| format!("dir{i}")).collect();
corpus_names.push("urls".into());
println!(
"Corpora: {} ({} directories + 1 URL list with {} URLs)",
multi.name(),
dir_count,
url_count
);
let parsers = DocumentParsers::new(build_parsers());
let embedder = EmbedderSpec::Ollama {
model: "nomic-embed-text:latest".into(),
request_timeout_secs: Some(300),
}
.build()?;
let chunker = MarkdownChunker;
let config = ChunkConfig::default();
let store = open_store(Path::new(store_dir)).await?;
println!("Indexing all corpora into {}...", store_dir);
let stats = ingest_corpus(&multi, &parsers, &chunker, &*embedder, &config, &*store).await?;
println!("Indexed {} documents:", stats.len());
for s in &stats {
println!(
" [{:<5}] {} → {} chunks{}",
s.corpus,
s.file_name,
s.chunks,
if s.ok { "" } else { " FAILED" }
);
}
println!("\nStored per-corpus document identity:");
for doc in store.stored_documents() {
println!(
" corpus={:<5} file={} (sha256: {})",
doc.corpus,
doc.document,
doc.document_id.as_deref().unwrap_or("—")
);
}
println!("\nCorpus-wide top hits for '{query}':");
let hits = search_similar(&*embedder, 3, 0.0, &*store, query).await?;
for h in &hits {
println!(" [{}] {:.80}", h.chunk.document, h.chunk.text);
}
if hits.is_empty() {
println!(" (no hits)");
}
let embedded = embedder.embed(vec![query.clone()]).await?;
let query_vec = embedded
.first()
.map(|e| e.vector.clone())
.unwrap_or_default();
for name in corpus_names {
let filter = PipelineFilter::for_corpus(&name);
let hits = store
.search_filtered(&query_vec, query, 3, 0.0, &filter)
.await?;
println!(
"\nFrom corpus '{name}' ({} chunks stored):",
store.count_matching(&filter).await
);
for h in &hits {
println!(" {:.80}", h.chunk.text);
}
if hits.is_empty() {
println!(" (no hits)");
}
}
Ok(())
}