use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use semtree_core::{Chunk, ChunkKind, Language};
use semtree_embed::Embedder;
use semtree_store::VectorStore;
use crate::{
ChunkRegistry, ContextWindow, FileManifest, HybridSearcher, Indexer, LexicalIndex, RagError,
SearchEngine, SearchFilters, SearchMode,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RebuildReason {
Missing,
Requested,
Incompatible {
was: String,
now: String,
},
}
impl std::fmt::Display for RebuildReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Missing => f.write_str("no existing index"),
Self::Requested => f.write_str("full rebuild requested"),
Self::Incompatible { was, now } => {
write!(f, "index was built with {was}, now running {now}")
}
}
}
}
#[derive(Debug, Clone)]
pub struct IndexReport {
pub chunks_indexed: usize,
pub rebuilt: Option<RebuildReason>,
}
impl IndexReport {
pub fn was_incremental(&self) -> bool {
self.rebuilt.is_none()
}
}
#[derive(Debug, Clone)]
pub struct IndexStats {
pub chunks: usize,
pub files: usize,
pub vectors: Option<usize>,
pub by_language: Vec<(Language, usize)>,
pub by_kind: Vec<(ChunkKind, usize)>,
pub embedder: String,
pub store: String,
}
impl IndexStats {
pub fn open(index_dir: &Path) -> Result<Self, RagError> {
let registry = ChunkRegistry::open(index_dir)?;
let manifest = FileManifest::load(index_dir);
Ok(Self::summarize(
®istry,
manifest.embedder().to_string(),
manifest.store().to_string(),
None,
))
}
fn summarize(
registry: &ChunkRegistry,
embedder: String,
store: String,
vectors: Option<usize>,
) -> Self {
let mut by_language: HashMap<Language, usize> = HashMap::new();
let mut by_kind: HashMap<ChunkKind, usize> = HashMap::new();
let mut files: HashSet<&Path> = HashSet::new();
for chunk in registry.iter() {
*by_language.entry(chunk.language).or_default() += 1;
*by_kind.entry(chunk.kind).or_default() += 1;
files.insert(chunk.path.as_path());
}
Self {
chunks: registry.len(),
files: files.len(),
vectors,
by_language: sorted_by_count(by_language),
by_kind: sorted_by_count(by_kind),
embedder,
store,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct SearchResult<'a> {
pub score: f32,
pub chunk: &'a Chunk,
}
pub struct IndexSession {
embedder: Arc<dyn Embedder>,
store: Arc<dyn VectorStore>,
registry: ChunkRegistry,
manifest: FileManifest,
searcher: HybridSearcher,
index_dir: PathBuf,
pending_rebuild: Option<RebuildReason>,
}
impl IndexSession {
pub fn open(
embedder: Arc<dyn Embedder>,
store: Arc<dyn VectorStore>,
index_dir: &Path,
) -> Result<Self, RagError> {
let embedder_fingerprint = embedder.fingerprint();
let store_fingerprint = store.metric().to_string();
let mut registry = ChunkRegistry::default();
let mut manifest = FileManifest::new(&embedder_fingerprint, &store_fingerprint);
let mut pending_rebuild = Some(RebuildReason::Missing);
if Self::is_present(index_dir) {
let existing = FileManifest::load(index_dir);
if existing.is_compatible_with(&embedder_fingerprint, &store_fingerprint) {
store.load(index_dir)?;
registry.load(index_dir)?;
manifest = existing;
pending_rebuild = None;
} else {
pending_rebuild = Some(RebuildReason::Incompatible {
was: format!("{}/{}", existing.embedder(), existing.store()),
now: format!("{embedder_fingerprint}/{store_fingerprint}"),
});
}
}
let searcher = Self::build_searcher(&embedder, &store, ®istry);
Ok(Self {
embedder,
store,
registry,
manifest,
searcher,
index_dir: index_dir.to_path_buf(),
pending_rebuild,
})
}
pub fn open_existing(
embedder: Arc<dyn Embedder>,
store: Arc<dyn VectorStore>,
index_dir: &Path,
) -> Result<Self, RagError> {
let session = Self::open(embedder, store, index_dir)?;
match &session.pending_rebuild {
None => Ok(session),
Some(RebuildReason::Missing) => Err(RagError::NoIndex(index_dir.to_path_buf())),
Some(reason) => Err(RagError::Filter(format!(
"index at {} is unusable: {reason}; re-index to rebuild it",
index_dir.display()
))),
}
}
fn is_present(index_dir: &Path) -> bool {
index_dir.join("manifest.json").exists() && index_dir.join("chunks.json").exists()
}
fn build_searcher(
embedder: &Arc<dyn Embedder>,
store: &Arc<dyn VectorStore>,
registry: &ChunkRegistry,
) -> HybridSearcher {
let engine = SearchEngine::new(embedder.clone(), store.clone());
HybridSearcher::new(engine, LexicalIndex::from_chunks(registry.iter()))
}
pub fn pending_rebuild(&self) -> Option<&RebuildReason> {
self.pending_rebuild.as_ref()
}
pub async fn index(
&mut self,
source_root: &Path,
full: bool,
on_progress: impl Fn(usize, usize),
) -> Result<IndexReport, RagError> {
let rebuilt = self
.pending_rebuild
.take()
.or_else(|| full.then_some(RebuildReason::Requested));
if rebuilt.is_some() {
self.store.clear().await?;
self.registry = ChunkRegistry::default();
self.manifest =
FileManifest::new(self.embedder.fingerprint(), self.store.metric().to_string());
}
let indexer = Indexer::new(self.embedder.clone(), self.store.clone());
let chunks_indexed = indexer
.index_dir(
source_root,
&mut self.registry,
Some(&mut self.manifest),
on_progress,
)
.await?;
self.searcher = Self::build_searcher(&self.embedder, &self.store, &self.registry);
Ok(IndexReport {
chunks_indexed,
rebuilt,
})
}
pub fn save(&self) -> Result<(), RagError> {
std::fs::create_dir_all(&self.index_dir)?;
self.store.save(&self.index_dir)?;
self.registry.save(&self.index_dir)?;
self.manifest.save(&self.index_dir)?;
Ok(())
}
pub async fn search(
&self,
query: &str,
top_k: usize,
mode: SearchMode,
filters: &SearchFilters,
) -> Result<Vec<SearchResult<'_>>, RagError> {
let hits = self
.searcher
.search(query, filters.fetch_size(top_k), mode)
.await?;
Ok(hits
.iter()
.filter_map(|hit| {
self.registry.get(&hit.id).map(|chunk| SearchResult {
score: hit.score,
chunk,
})
})
.filter(|result| filters.matches(result.chunk))
.take(top_k)
.collect())
}
pub async fn context(
&self,
query: &str,
top_k: usize,
mode: SearchMode,
) -> Result<ContextWindow, RagError> {
let hits = self.searcher.search(query, top_k, mode).await?;
Ok(ContextWindow::from_hits(query, &hits, &self.registry))
}
pub fn stats(&self) -> IndexStats {
IndexStats::summarize(
&self.registry,
self.embedder.fingerprint(),
self.store.metric().to_string(),
Some(self.store.len()),
)
}
pub fn index_dir(&self) -> &Path {
&self.index_dir
}
pub fn registry(&self) -> &ChunkRegistry {
&self.registry
}
}
fn sorted_by_count<K: Ord + Copy>(counts: HashMap<K, usize>) -> Vec<(K, usize)> {
let mut sorted: Vec<(K, usize)> = counts.into_iter().collect();
sorted
.sort_by(|(a_key, a_count), (b_key, b_count)| b_count.cmp(a_count).then(a_key.cmp(b_key)));
sorted
}