Skip to main content

semtree_rag/
session.rs

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/// Why an index is being rebuilt from scratch instead of updated in place.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum RebuildReason {
17    /// Nothing on disk yet.
18    Missing,
19    /// The caller asked for a full rebuild.
20    Requested,
21    /// The stored vectors came out of a different pipeline, so reusing them
22    /// would mix incompatible vectors or leave stale chunk ids behind.
23    Incompatible {
24        /// Embedder and store the index was built with.
25        was: String,
26        /// Embedder and store in use now.
27        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/// What an [`IndexSession::index`] pass did.
44#[derive(Debug, Clone)]
45pub struct IndexReport {
46    /// Chunks embedded and stored during this pass. Unchanged files contribute
47    /// nothing, so an incremental pass over an untouched tree reports zero.
48    pub chunks_indexed: usize,
49    /// Set when the pass rebuilt from scratch; `None` for an incremental update.
50    pub rebuilt: Option<RebuildReason>,
51}
52
53impl IndexReport {
54    pub fn was_incremental(&self) -> bool {
55        self.rebuilt.is_none()
56    }
57}
58
59/// A breakdown of what an index currently holds.
60#[derive(Debug, Clone)]
61pub struct IndexStats {
62    pub chunks: usize,
63    pub files: usize,
64    /// Vectors held by the store, when one is open. Should track `chunks`; a
65    /// gap means the store and the chunk metadata have drifted apart. `None`
66    /// after a metadata-only read, where the store was never loaded.
67    pub vectors: Option<usize>,
68    /// Chunk counts per language, most frequent first.
69    pub by_language: Vec<(Language, usize)>,
70    /// Chunk counts per kind, most frequent first.
71    pub by_kind: Vec<(ChunkKind, usize)>,
72    /// Fingerprint of the embedder that produced the vectors.
73    pub embedder: String,
74    /// Fingerprint of the store holding them, i.e. its distance metric.
75    pub store: String,
76}
77
78impl IndexStats {
79    /// Summarize the index at `index_dir` from its metadata alone.
80    ///
81    /// Reads the chunk registry and the manifest, and nothing else: no
82    /// embedding model is loaded and no vectors are touched, so inspecting an
83    /// index costs milliseconds. The trade-off is that `vectors` stays `None`,
84    /// because only the store knows how many it holds.
85    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            &registry,
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/// A ranked hit with the chunk it points at already resolved.
125#[derive(Debug, Clone, Copy)]
126pub struct SearchResult<'a> {
127    pub score: f32,
128    pub chunk: &'a Chunk,
129}
130
131/// An index on disk together with the backends that query and refresh it.
132///
133/// This is the whole lifecycle in one place - open, check the index still
134/// matches the pipeline that built it, update it incrementally, persist it,
135/// search it - so callers do not each reimplement it and drift apart. The
136/// [`semtree`] CLI and the MCP server are both thin shells over this type.
137///
138/// ```no_run
139/// use std::sync::Arc;
140/// use semtree_rag::{IndexSession, SearchFilters, SearchMode};
141/// use semtree_embed::fastembed::FastEmbedder;
142/// use semtree_store::usearch::UsearchStore;
143/// use semtree_embed::Embedder;
144///
145/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
146/// let embedder = Arc::new(FastEmbedder::new()?);
147/// let store = Arc::new(UsearchStore::new(embedder.dimension())?);
148///
149/// let mut session = IndexSession::open(embedder, store, std::path::Path::new(".semtree"))?;
150/// session.index(std::path::Path::new("."), false, |_, _| {}).await?;
151/// session.save()?;
152///
153/// let hits = session
154///     .search("where do we skip unchanged files", 5, SearchMode::Hybrid, &SearchFilters::default())
155///     .await?;
156/// # Ok(())
157/// # }
158/// ```
159///
160/// [`semtree`]: https://crates.io/crates/semtree
161pub 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    /// Open the index at `index_dir`, or start an empty session if there is
173    /// none yet.
174    ///
175    /// An index built by a different embedder, store, or chunker is *not*
176    /// loaded: it is left on disk and flagged for rebuild, so the next
177    /// [`index`](Self::index) call replaces it instead of mixing vectors that
178    /// cannot be compared. Query it before that and it looks empty, which is
179    /// the honest answer.
180    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, &registry);
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    /// Like [`open`](Self::open), but refuses to start without a usable index.
221    /// Use it on read-only paths, where an empty result would otherwise be
222    /// indistinguishable from a codebase that was never indexed.
223    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    /// An index counts as present only when both halves of it are on disk: the
240    /// manifest alone says nothing about the chunks it describes.
241    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    /// Why the next [`index`](Self::index) call will rebuild from scratch, if
255    /// it will. `None` means the index on disk is usable as-is.
256    pub fn pending_rebuild(&self) -> Option<&RebuildReason> {
257        self.pending_rebuild.as_ref()
258    }
259
260    /// Index `source_root`, skipping files whose content has not changed.
261    ///
262    /// Set `full` to force a rebuild; otherwise a rebuild still happens when
263    /// [`pending_rebuild`](Self::pending_rebuild) says the existing index
264    /// cannot be trusted. Progress is reported as `(files_done, files_total)`.
265    ///
266    /// Nothing is written to disk until [`save`](Self::save).
267    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        // Prefer the recorded reason over the caller's flag: "the embedder
274        // changed" is more useful to report back than "you asked for it".
275        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        // The lexical index is derived from the registry, so it goes stale the
298        // moment indexing touches a chunk.
299        self.searcher = Self::build_searcher(&self.embedder, &self.store, &self.registry);
300
301        Ok(IndexReport {
302            chunks_indexed,
303            rebuilt,
304        })
305    }
306
307    /// Persist the vectors, the chunk metadata and the manifest together. They
308    /// are only consistent as a set, so they are always written as one.
309    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    /// Search the index, returning at most `top_k` results that pass `filters`.
318    ///
319    /// Filters are metadata narrowing applied after ranking, so the ranker is
320    /// asked for extra candidates to compensate. Hits the registry cannot
321    /// resolve are dropped: without a chunk there is no path, name or code to
322    /// hand back.
323    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    /// Build a prompt-ready context window for `query` from the top `top_k`
349    /// chunks under `mode`.
350    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    /// What the index currently holds, including the live vector count.
361    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    /// Where this session reads and writes its index.
371    pub fn index_dir(&self) -> &Path {
372        &self.index_dir
373    }
374
375    /// The chunk metadata backing the index.
376    pub fn registry(&self) -> &ChunkRegistry {
377        &self.registry
378    }
379}
380
381/// Most frequent first, then by key so equal counts do not shuffle between runs.
382fn 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}