1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use semtree_core::{Chunk, ChunkKind, Language};
6use semtree_embed::Embedder;
7use semtree_store::VectorStore;
8
9use crate::{
10 ChunkRegistry, ContextWindow, FileManifest, HybridSearcher, Indexer, LexicalIndex, RagError,
11 SearchEngine, SearchFilters, SearchMode,
12};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum RebuildReason {
17 Missing,
19 Requested,
21 Incompatible {
24 was: String,
26 now: String,
28 },
29}
30
31impl std::fmt::Display for RebuildReason {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 Self::Missing => f.write_str("no existing index"),
35 Self::Requested => f.write_str("full rebuild requested"),
36 Self::Incompatible { was, now } => {
37 write!(f, "index was built with {was}, now running {now}")
38 }
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct IndexReport {
46 pub chunks_indexed: usize,
49 pub rebuilt: Option<RebuildReason>,
51}
52
53impl IndexReport {
54 pub fn was_incremental(&self) -> bool {
55 self.rebuilt.is_none()
56 }
57}
58
59#[derive(Debug, Clone)]
61pub struct IndexStats {
62 pub chunks: usize,
63 pub files: usize,
64 pub vectors: Option<usize>,
68 pub by_language: Vec<(Language, usize)>,
70 pub by_kind: Vec<(ChunkKind, usize)>,
72 pub embedder: String,
74 pub store: String,
76}
77
78impl IndexStats {
79 pub fn open(index_dir: &Path) -> Result<Self, RagError> {
86 let registry = ChunkRegistry::open(index_dir)?;
87 let manifest = FileManifest::load(index_dir);
88 Ok(Self::summarize(
89 ®istry,
90 manifest.embedder().to_string(),
91 manifest.store().to_string(),
92 None,
93 ))
94 }
95
96 fn summarize(
97 registry: &ChunkRegistry,
98 embedder: String,
99 store: String,
100 vectors: Option<usize>,
101 ) -> Self {
102 let mut by_language: HashMap<Language, usize> = HashMap::new();
103 let mut by_kind: HashMap<ChunkKind, usize> = HashMap::new();
104 let mut files: HashSet<&Path> = HashSet::new();
105
106 for chunk in registry.iter() {
107 *by_language.entry(chunk.language).or_default() += 1;
108 *by_kind.entry(chunk.kind).or_default() += 1;
109 files.insert(chunk.path.as_path());
110 }
111
112 Self {
113 chunks: registry.len(),
114 files: files.len(),
115 vectors,
116 by_language: sorted_by_count(by_language),
117 by_kind: sorted_by_count(by_kind),
118 embedder,
119 store,
120 }
121 }
122}
123
124#[derive(Debug, Clone, Copy)]
126pub struct SearchResult<'a> {
127 pub score: f32,
128 pub chunk: &'a Chunk,
129}
130
131pub struct IndexSession {
162 embedder: Arc<dyn Embedder>,
163 store: Arc<dyn VectorStore>,
164 registry: ChunkRegistry,
165 manifest: FileManifest,
166 searcher: HybridSearcher,
167 index_dir: PathBuf,
168 pending_rebuild: Option<RebuildReason>,
169}
170
171impl IndexSession {
172 pub fn open(
181 embedder: Arc<dyn Embedder>,
182 store: Arc<dyn VectorStore>,
183 index_dir: &Path,
184 ) -> Result<Self, RagError> {
185 let embedder_fingerprint = embedder.fingerprint();
186 let store_fingerprint = store.metric().to_string();
187
188 let mut registry = ChunkRegistry::default();
189 let mut manifest = FileManifest::new(&embedder_fingerprint, &store_fingerprint);
190 let mut pending_rebuild = Some(RebuildReason::Missing);
191
192 if Self::is_present(index_dir) {
193 let existing = FileManifest::load(index_dir);
194 if existing.is_compatible_with(&embedder_fingerprint, &store_fingerprint) {
195 store.load(index_dir)?;
196 registry.load(index_dir)?;
197 manifest = existing;
198 pending_rebuild = None;
199 } else {
200 pending_rebuild = Some(RebuildReason::Incompatible {
201 was: format!("{}/{}", existing.embedder(), existing.store()),
202 now: format!("{embedder_fingerprint}/{store_fingerprint}"),
203 });
204 }
205 }
206
207 let searcher = Self::build_searcher(&embedder, &store, ®istry);
208
209 Ok(Self {
210 embedder,
211 store,
212 registry,
213 manifest,
214 searcher,
215 index_dir: index_dir.to_path_buf(),
216 pending_rebuild,
217 })
218 }
219
220 pub fn open_existing(
224 embedder: Arc<dyn Embedder>,
225 store: Arc<dyn VectorStore>,
226 index_dir: &Path,
227 ) -> Result<Self, RagError> {
228 let session = Self::open(embedder, store, index_dir)?;
229 match &session.pending_rebuild {
230 None => Ok(session),
231 Some(RebuildReason::Missing) => Err(RagError::NoIndex(index_dir.to_path_buf())),
232 Some(reason) => Err(RagError::Filter(format!(
233 "index at {} is unusable: {reason}; re-index to rebuild it",
234 index_dir.display()
235 ))),
236 }
237 }
238
239 fn is_present(index_dir: &Path) -> bool {
242 index_dir.join("manifest.json").exists() && index_dir.join("chunks.json").exists()
243 }
244
245 fn build_searcher(
246 embedder: &Arc<dyn Embedder>,
247 store: &Arc<dyn VectorStore>,
248 registry: &ChunkRegistry,
249 ) -> HybridSearcher {
250 let engine = SearchEngine::new(embedder.clone(), store.clone());
251 HybridSearcher::new(engine, LexicalIndex::from_chunks(registry.iter()))
252 }
253
254 pub fn pending_rebuild(&self) -> Option<&RebuildReason> {
257 self.pending_rebuild.as_ref()
258 }
259
260 pub async fn index(
268 &mut self,
269 source_root: &Path,
270 full: bool,
271 on_progress: impl Fn(usize, usize),
272 ) -> Result<IndexReport, RagError> {
273 let rebuilt = self
276 .pending_rebuild
277 .take()
278 .or_else(|| full.then_some(RebuildReason::Requested));
279
280 if rebuilt.is_some() {
281 self.store.clear().await?;
282 self.registry = ChunkRegistry::default();
283 self.manifest =
284 FileManifest::new(self.embedder.fingerprint(), self.store.metric().to_string());
285 }
286
287 let indexer = Indexer::new(self.embedder.clone(), self.store.clone());
288 let chunks_indexed = indexer
289 .index_dir(
290 source_root,
291 &mut self.registry,
292 Some(&mut self.manifest),
293 on_progress,
294 )
295 .await?;
296
297 self.searcher = Self::build_searcher(&self.embedder, &self.store, &self.registry);
300
301 Ok(IndexReport {
302 chunks_indexed,
303 rebuilt,
304 })
305 }
306
307 pub fn save(&self) -> Result<(), RagError> {
310 std::fs::create_dir_all(&self.index_dir)?;
311 self.store.save(&self.index_dir)?;
312 self.registry.save(&self.index_dir)?;
313 self.manifest.save(&self.index_dir)?;
314 Ok(())
315 }
316
317 pub async fn search(
324 &self,
325 query: &str,
326 top_k: usize,
327 mode: SearchMode,
328 filters: &SearchFilters,
329 ) -> Result<Vec<SearchResult<'_>>, RagError> {
330 let hits = self
331 .searcher
332 .search(query, filters.fetch_size(top_k), mode)
333 .await?;
334
335 Ok(hits
336 .iter()
337 .filter_map(|hit| {
338 self.registry.get(&hit.id).map(|chunk| SearchResult {
339 score: hit.score,
340 chunk,
341 })
342 })
343 .filter(|result| filters.matches(result.chunk))
344 .take(top_k)
345 .collect())
346 }
347
348 pub async fn context(
351 &self,
352 query: &str,
353 top_k: usize,
354 mode: SearchMode,
355 ) -> Result<ContextWindow, RagError> {
356 let hits = self.searcher.search(query, top_k, mode).await?;
357 Ok(ContextWindow::from_hits(query, &hits, &self.registry))
358 }
359
360 pub fn stats(&self) -> IndexStats {
362 IndexStats::summarize(
363 &self.registry,
364 self.embedder.fingerprint(),
365 self.store.metric().to_string(),
366 Some(self.store.len()),
367 )
368 }
369
370 pub fn index_dir(&self) -> &Path {
372 &self.index_dir
373 }
374
375 pub fn registry(&self) -> &ChunkRegistry {
377 &self.registry
378 }
379}
380
381fn sorted_by_count<K: Ord + Copy>(counts: HashMap<K, usize>) -> Vec<(K, usize)> {
383 let mut sorted: Vec<(K, usize)> = counts.into_iter().collect();
384 sorted
385 .sort_by(|(a_key, a_count), (b_key, b_count)| b_count.cmp(a_count).then(a_key.cmp(b_key)));
386 sorted
387}