use crate::documents::build_text_to_source;
use crate::documents::build_text_to_source_with_stats;
use crate::documents::FileIndexResult;
use crate::documents::compute_file_hash;
use crate::embed::Embedder;
use crate::parsers::{DocumentParsers, self};
use crate::store::{ScoredChunk, VectorStore, embed_and_insert};
use crate::types::{ChunkConfig, DocumentType, FileHashEntry, IndexManifest, SourceFile};
use anyhow::{Result, anyhow};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
fn chrono_now_iso() -> String {
use std::time::SystemTime;
let dur = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = dur.as_secs();
let days_since_epoch = secs / 86400;
let time_of_day = secs % 86400;
let (y, m, d) = civil_from_days(days_since_epoch as i64);
let h = time_of_day / 3600;
let min = (time_of_day % 3600) / 60;
let s = time_of_day % 60;
format!("{y:04}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
pub fn get_embeddings_file_path(folder: &Path) -> PathBuf {
folder.join(".ragrig_embeddings.json")
}
pub fn scan_document_files(folder: &Path) -> Vec<(DocumentType, String)> {
WalkDir::new(folder)
.into_iter()
.filter_map(|e| e.ok())
.filter_map(|entry| {
let path = entry.path().to_path_buf();
if !path.is_file() {
return None;
}
let ext = path.extension()?.to_str()?;
let doc_type = DocumentType::from_extension(ext, path.clone())?;
let name = doc_type.file_name().to_string();
Some((doc_type, name))
})
.collect()
}
pub async fn embed_documents(
embedder: &dyn Embedder,
parsers: &DocumentParsers,
config: &ChunkConfig,
document_files: Vec<(DocumentType, String)>,
store: &dyn VectorStore,
) -> Result<()> {
log::info!("Parsing {} documents...", document_files.len());
store.validate_embedder(&embedder.metadata())?;
let (all_texts, text_to_source) = build_text_to_source(&document_files, parsers, config)?;
if all_texts.is_empty() {
return Err(anyhow::anyhow!(crate::RagrigError::NoDocumentsFound {
folder: "(provided document list)".into(),
}));
}
log::info!(
"Generating embeddings for {} total text chunks...",
all_texts.len()
);
let embedded = embedder.embed(all_texts).await?;
embed_and_insert(store, embedded, &text_to_source).await?;
store.flush()?;
Ok(())
}
pub async fn collect_documents(
embedder: &dyn Embedder,
parsers: &DocumentParsers,
folder: &Path,
config: &ChunkConfig,
store: &dyn VectorStore,
) -> Result<()> {
let _stats = collect_documents_with_stats(embedder, parsers, folder, config, store).await?;
Ok(())
}
pub async fn collect_documents_with_stats(
embedder: &dyn Embedder,
parsers: &DocumentParsers,
folder: &Path,
config: &ChunkConfig,
store: &dyn VectorStore,
) -> Result<Vec<FileIndexResult>> {
log::info!("Scanning folder recursively: {:?}", folder);
let document_files = scan_document_files(folder);
log::info!(
"Found {} document files (PDF + EPUB).",
document_files.len()
);
let (all_texts, text_to_source, stats) =
build_text_to_source_with_stats(&document_files, parsers, config)?;
if all_texts.is_empty() {
return Err(anyhow::anyhow!(crate::RagrigError::NoDocumentsFound {
folder: folder.to_string_lossy().into_owned(),
}));
}
log::info!(
"Generating embeddings for {} total text chunks...",
all_texts.len()
);
let batch_size = 50usize;
let mut embedded: Vec<(String, Vec<f32>)> = Vec::with_capacity(all_texts.len());
for (batch_i, batch) in all_texts.chunks(batch_size).enumerate() {
let done = (batch_i * batch_size).min(all_texts.len());
log::info!(
" [embedded {}/{} chunks]",
done + batch.len(),
all_texts.len()
);
let batch_embedded = embedder.embed(batch.to_vec()).await?;
embedded.extend(batch_embedded);
}
log::info!(" [embedded {}/{} chunks] done.", all_texts.len(), all_texts.len());
let count = embedded.len();
embed_and_insert(store, embedded, &text_to_source).await?;
let mut file_hashes: Vec<FileHashEntry> = Vec::new();
for (doc_type, file_name) in &document_files {
if let Ok(hash) = compute_file_hash(doc_type.path()) {
file_hashes.push(FileHashEntry {
file_name: SourceFile::from(file_name.clone()),
hash,
});
}
}
let manifest = IndexManifest {
created: chrono_now_iso(),
chunk_size: config.size,
chunk_overlap: config.overlap,
embedding_model: embedder.model_name().to_string(),
embedding_dimensions: embedder.dimension(),
document_count: document_files.len(),
total_chunks: count,
file_hashes,
};
store.record_manifest(manifest)?;
store.flush()?;
log::info!("Collection complete: {} chunks stored.", count);
Ok(stats)
}
pub async fn index_folder(
folder: &Path,
embedder: &dyn Embedder,
) -> Result<Box<dyn VectorStore>> {
let parsers = DocumentParsers::new(parsers::build_parsers());
let config = ChunkConfig::default();
let store = crate::store::open_store(folder).await?;
collect_documents(embedder, &parsers, folder, &config, &*store).await?;
Ok(store)
}
pub async fn search_similar(
embedder: &dyn Embedder,
top_k: usize,
similarity_threshold: f64,
store: &dyn VectorStore,
query: &str,
) -> Result<Vec<ScoredChunk>> {
store.validate_embedder(&embedder.metadata())?;
let embedded = embedder.embed(vec![query.to_string()]).await?;
let query_vec: Vec<f32> = embedded
.first()
.map(|(_, v)| v.clone())
.ok_or_else(|| anyhow!("Failed to get query embedding"))?;
store
.search(&query_vec, query, top_k, similarity_threshold)
.await
}
pub async fn remove_deleted_embeddings(
store: &dyn VectorStore,
current_files: &[(DocumentType, String)],
) -> Result<()> {
let current_file_names: HashSet<SourceFile> = current_files
.iter()
.map(|(doc_type, _)| SourceFile::from(doc_type.file_name().to_string()))
.collect();
let stored_sources = store.sources();
for name in &stored_sources {
if !current_file_names.contains(name) {
log::info!("Removing chunks for deleted file: {}", name);
store.delete_by_source(&name.0).await?;
}
}
store.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scan_picks_up_pdf_and_ignores_txt() {
let dir = std::env::temp_dir().join(format!("ragrig-scan-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.pdf"), b"%PDF-1.4 fake").unwrap();
std::fs::write(dir.join("c.txt"), b"ignored").unwrap();
let docs = scan_document_files(&dir);
let names: Vec<&str> = docs.iter().map(|(dt, _)| dt.file_name()).collect();
assert!(names.contains(&"a.pdf"));
assert!(!names.contains(&"c.txt"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scan_ignores_directories() {
let dir = std::env::temp_dir().join(format!("ragrig-scan-dir-{}", std::process::id()));
std::fs::create_dir_all(dir.join("subdir")).unwrap();
let docs = scan_document_files(&dir);
assert!(docs.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}