Skip to main content

mentedb_index/
manager.rs

1//! Composite index manager that owns and coordinates all index types.
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use mentedb_core::MemoryNode;
7use mentedb_core::error::MenteResult;
8use mentedb_core::types::{MemoryId, Timestamp};
9
10use crate::bitmap::BitmapIndex;
11use crate::bm25::Bm25Index;
12use crate::hnsw::{HnswConfig, HnswIndex};
13use crate::salience::SalienceIndex;
14use crate::temporal::TemporalIndex;
15
16/// Configuration for the composite index manager.
17#[derive(Default)]
18pub struct IndexManagerConfig {
19    /// HNSW configuration parameters.
20    pub hnsw: HnswConfig,
21}
22
23/// Owns all index types and provides unified indexing and hybrid search.
24pub struct IndexManager {
25    /// Vector similarity index.
26    pub hnsw: HnswIndex,
27    /// BM25 full-text index for keyword search.
28    pub bm25: Bm25Index,
29    /// Tag and attribute bitmap index.
30    pub bitmap: BitmapIndex,
31    /// Timestamp range index.
32    pub temporal: TemporalIndex,
33    /// Importance score index.
34    pub salience: SalienceIndex,
35}
36
37impl IndexManager {
38    /// Create a new index manager with the given configuration.
39    pub fn new(config: IndexManagerConfig) -> Self {
40        Self {
41            hnsw: HnswIndex::new(config.hnsw),
42            bm25: Bm25Index::new(),
43            bitmap: BitmapIndex::new(),
44            temporal: TemporalIndex::new(),
45            salience: SalienceIndex::new(),
46        }
47    }
48
49    /// Save all indexes to the given directory (bincode format).
50    pub fn save(&self, dir: &Path) -> MenteResult<()> {
51        std::fs::create_dir_all(dir)?;
52        self.hnsw.save(&dir.join("hnsw.bin"))?;
53        self.bm25.save(&dir.join("bm25.bin"))?;
54        self.bitmap.save(&dir.join("bitmap.bin"))?;
55        self.temporal.save(&dir.join("temporal.bin"))?;
56        self.salience.save(&dir.join("salience.bin"))?;
57        Ok(())
58    }
59
60    /// Load all indexes from the given directory. Tries `.bin` first, falls back to `.json`.
61    pub fn load(dir: &Path) -> MenteResult<Self> {
62        let hnsw_path = Self::resolve_path(dir, "hnsw");
63        let hnsw = HnswIndex::load(&hnsw_path, HnswConfig::default().ef_search)?;
64
65        let bm25_bin = dir.join("bm25.bin");
66        let bm25_json = dir.join("bm25.json");
67        let bm25 = if bm25_bin.exists() {
68            Bm25Index::load(&bm25_bin)?
69        } else if bm25_json.exists() {
70            Bm25Index::load(&bm25_json)?
71        } else {
72            Bm25Index::new()
73        };
74
75        let bitmap = BitmapIndex::load(&Self::resolve_path(dir, "bitmap"))?;
76        let temporal = TemporalIndex::load(&Self::resolve_path(dir, "temporal"))?;
77        let salience = SalienceIndex::load(&Self::resolve_path(dir, "salience"))?;
78        Ok(Self {
79            hnsw,
80            bm25,
81            bitmap,
82            temporal,
83            salience,
84        })
85    }
86
87    /// Resolve index file path: prefer `.bin`, fall back to `.json`.
88    fn resolve_path(dir: &Path, name: &str) -> std::path::PathBuf {
89        let bin = dir.join(format!("{name}.bin"));
90        if bin.exists() {
91            bin
92        } else {
93            dir.join(format!("{name}.json"))
94        }
95    }
96
97    /// Index a memory node across all indexes.
98    pub fn index_memory(&self, node: &MemoryNode) {
99        // Vector index
100        if !node.embedding.is_empty() {
101            let _ = self.hnsw.insert(node.id, &node.embedding);
102        }
103
104        // BM25 full-text index, over the context-prefixed text so contextual
105        // retrieval also matches the caller's situating blurb.
106        let indexed = node.indexed_text();
107        if !indexed.is_empty() {
108            self.bm25.insert(node.id, &indexed);
109        }
110
111        // Tag bitmap index. Clear this id's existing tags first, so a re-index
112        // after an edit (e.g. un-pinning, which removes scope:always and re-stores
113        // the node) reflects the node's CURRENT tags exactly, not the union across
114        // every stored version. Without this, a removed tag lingers in the index
115        // and over-counts on query_tag. Harmless no-op on a first insert.
116        self.bitmap.remove_all(node.id);
117        for tag in &node.tags {
118            self.bitmap.add_tag(node.id, tag);
119        }
120
121        // Temporal index
122        self.temporal.insert(node.id, node.created_at);
123
124        // Salience index
125        self.salience.insert(node.id, node.salience);
126    }
127
128    /// True when the memory's vector is indexed and live.
129    pub fn contains_vector(&self, id: MemoryId) -> bool {
130        self.hnsw.contains(id)
131    }
132
133    /// All live vector indexed memory ids.
134    pub fn vector_ids(&self) -> Vec<MemoryId> {
135        self.hnsw.ids()
136    }
137
138    /// Tombstone a vector whose backing page no longer exists; used by open
139    /// time reconciliation when a snapshot is newer than the last forget.
140    pub fn remove_vector_only(&self, id: MemoryId) {
141        let _ = self.hnsw.remove(id);
142        self.bm25.remove(id);
143    }
144
145    /// Remove a memory from all indexes.
146    pub fn remove_memory(&self, id: MemoryId, node: &MemoryNode) {
147        let _ = self.hnsw.remove(id);
148        self.bm25.remove(id);
149        self.bitmap.remove_all(id);
150        self.temporal.remove(id, node.created_at);
151        self.salience.remove(id, node.salience);
152    }
153
154    /// Hybrid search combining vector similarity, BM25 keyword matching,
155    /// tag filtering, time range, and salience.
156    ///
157    /// Strategy:
158    /// 1. Vector search (HNSW) for top candidates
159    /// 2. BM25 keyword search for top candidates
160    /// 3. Merge via Reciprocal Rank Fusion (RRF)
161    /// 4. Filter by tags and time range
162    /// 5. Boost by salience and recency
163    /// 6. Return top k results
164    pub fn hybrid_search(
165        &self,
166        query_embedding: &[f32],
167        tags: Option<&[&str]>,
168        time_range: Option<(Timestamp, Timestamp)>,
169        k: usize,
170    ) -> Vec<(MemoryId, f32)> {
171        self.hybrid_search_with_query(query_embedding, None, tags, time_range, k)
172    }
173
174    /// Hybrid search with an optional text query for BM25 matching.
175    ///
176    /// When `query_text` is provided, BM25 results are merged with vector
177    /// results via RRF. When None, behaves like vector-only search.
178    pub fn hybrid_search_with_query(
179        &self,
180        query_embedding: &[f32],
181        query_text: Option<&str>,
182        tags: Option<&[&str]>,
183        time_range: Option<(Timestamp, Timestamp)>,
184        k: usize,
185    ) -> Vec<(MemoryId, f32)> {
186        self.hybrid_search_with_query_mode(query_embedding, query_text, tags, false, time_range, k)
187    }
188
189    /// Hybrid search with configurable tag mode (AND vs OR).
190    pub fn hybrid_search_with_query_mode(
191        &self,
192        query_embedding: &[f32],
193        query_text: Option<&str>,
194        tags: Option<&[&str]>,
195        tags_or: bool,
196        time_range: Option<(Timestamp, Timestamp)>,
197        k: usize,
198    ) -> Vec<(MemoryId, f32)> {
199        if k == 0 {
200            return Vec::new();
201        }
202
203        // Build tag filter set (if tags are specified)
204        let tag_filter: Option<HashSet<MemoryId>> = tags.map(|t| {
205            if t.is_empty() {
206                HashSet::new()
207            } else if tags_or {
208                self.bitmap.query_tags_or(t).into_iter().collect()
209            } else {
210                self.bitmap.query_tags_and(t).into_iter().collect()
211            }
212        });
213
214        // Build time-range filter set
215        let time_filter: Option<HashSet<MemoryId>> =
216            time_range.map(|(start, end)| self.temporal.range(start, end).into_iter().collect());
217
218        // Combine filters into a single candidate set
219        let candidate_set: Option<HashSet<MemoryId>> = match (&tag_filter, &time_filter) {
220            (Some(tf), Some(trf)) => Some(tf.intersection(trf).copied().collect()),
221            (Some(tf), None) => Some(tf.clone()),
222            (None, Some(trf)) => Some(trf.clone()),
223            (None, None) => None,
224        };
225
226        // Pre-filtered path: when we have a candidate set and it's reasonably sized,
227        // do brute-force search directly over the candidates instead of global search + post-filter.
228        // This is critical for OR-tag queries with many tags where global top-k misses most matches.
229        let use_prefilter = candidate_set.as_ref().is_some_and(|cs| {
230            let cs_len = cs.len();
231            // Use pre-filter when candidate set is non-trivial but manageable for brute-force
232            // (up to 500K is fine — brute-force cosine on 384-dim vectors is fast)
233            cs_len > 0 && cs_len <= 500_000
234        });
235
236        let fetch_k = k * 4;
237        let rrf_k: f32 = 60.0;
238
239        let (vector_candidates, bm25_candidates) = if use_prefilter {
240            let cs = candidate_set.as_ref().unwrap();
241            let vc = self.hnsw.search_filtered(query_embedding, cs, fetch_k);
242            let bc = match query_text {
243                Some(qt) if !self.bm25.is_empty() => self.bm25.search_filtered(qt, fetch_k, cs),
244                _ => Vec::new(),
245            };
246            (vc, bc)
247        } else {
248            let vc = self.hnsw.search(query_embedding, fetch_k);
249            let bc = match query_text {
250                Some(qt) if !self.bm25.is_empty() => self.bm25.search(qt, fetch_k),
251                _ => Vec::new(),
252            };
253            (vc, bc)
254        };
255
256        if vector_candidates.is_empty() && bm25_candidates.is_empty() {
257            return Vec::new();
258        }
259
260        // Merge via RRF
261        let mut rrf_scores: HashMap<MemoryId, f32> = HashMap::new();
262
263        for (rank, (id, _)) in vector_candidates.iter().enumerate() {
264            *rrf_scores.entry(*id).or_insert(0.0) += 1.0 / (rrf_k + rank as f32);
265        }
266        for (rank, (id, _)) in bm25_candidates.iter().enumerate() {
267            *rrf_scores.entry(*id).or_insert(0.0) += 1.0 / (rrf_k + rank as f32);
268        }
269
270        // Post-filter only needed when NOT using pre-filter path
271        let mut scored: Vec<(MemoryId, f32)> = rrf_scores
272            .into_iter()
273            .filter(|(id, _)| {
274                if !use_prefilter {
275                    if let Some(ref tf) = tag_filter
276                        && !tf.contains(id)
277                    {
278                        return false;
279                    }
280                    if let Some(ref trf) = time_filter
281                        && !trf.contains(id)
282                    {
283                        return false;
284                    }
285                }
286                true
287            })
288            .map(|(id, rrf_score)| {
289                let salience = self.salience.get_salience(id).unwrap_or(0.5);
290                let recency = 0.5f32;
291
292                let combined = rrf_score * 0.7 + salience * 0.05 + recency * 0.02;
293                (id, combined)
294            })
295            .collect();
296
297        scored.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
298        scored.truncate(k);
299        scored
300    }
301}
302
303impl Default for IndexManager {
304    fn default() -> Self {
305        Self::new(IndexManagerConfig::default())
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use mentedb_core::memory::MemoryType;
313    use mentedb_core::types::AgentId;
314
315    fn make_node(
316        embedding: Vec<f32>,
317        tags: Vec<String>,
318        salience: f32,
319        created_at: u64,
320    ) -> MemoryNode {
321        let mut node = MemoryNode::new(
322            AgentId::new(),
323            MemoryType::Episodic,
324            "test".into(),
325            embedding,
326        );
327        node.tags = tags;
328        node.salience = salience;
329        node.created_at = created_at;
330        node
331    }
332
333    #[test]
334    fn test_index_and_search() {
335        let mgr = IndexManager::default();
336        let node = make_node(vec![1.0, 0.0, 0.0, 0.0], vec!["test".into()], 0.8, 1000);
337        mgr.index_memory(&node);
338
339        let results = mgr.hybrid_search(&[1.0, 0.0, 0.0, 0.0], None, None, 1);
340        assert_eq!(results.len(), 1);
341        assert_eq!(results[0].0, node.id);
342    }
343
344    #[test]
345    fn test_tag_filter() {
346        let mgr = IndexManager::default();
347        let a = make_node(vec![1.0, 0.0, 0.0, 0.0], vec!["alpha".into()], 0.8, 1000);
348        let b = make_node(vec![0.9, 0.1, 0.0, 0.0], vec!["beta".into()], 0.8, 1000);
349        mgr.index_memory(&a);
350        mgr.index_memory(&b);
351
352        let results = mgr.hybrid_search(&[1.0, 0.0, 0.0, 0.0], Some(&["alpha"]), None, 10);
353        assert_eq!(results.len(), 1);
354        assert_eq!(results[0].0, a.id);
355    }
356
357    #[test]
358    fn test_time_filter() {
359        let mgr = IndexManager::default();
360        let a = make_node(vec![1.0, 0.0, 0.0, 0.0], vec![], 0.8, 100);
361        let b = make_node(vec![0.9, 0.1, 0.0, 0.0], vec![], 0.8, 500);
362        mgr.index_memory(&a);
363        mgr.index_memory(&b);
364
365        let results = mgr.hybrid_search(&[1.0, 0.0, 0.0, 0.0], None, Some((400, 600)), 10);
366        assert_eq!(results.len(), 1);
367        assert_eq!(results[0].0, b.id);
368    }
369
370    #[test]
371    fn test_remove_memory() {
372        let mgr = IndexManager::default();
373        let node = make_node(vec![1.0, 0.0, 0.0, 0.0], vec!["t".into()], 0.5, 100);
374        let id = node.id;
375        mgr.index_memory(&node);
376        mgr.remove_memory(id, &node);
377
378        let results = mgr.hybrid_search(&[1.0, 0.0, 0.0, 0.0], None, None, 10);
379        assert!(results.is_empty());
380    }
381
382    #[test]
383    fn test_empty_search() {
384        let mgr = IndexManager::default();
385        let results = mgr.hybrid_search(&[1.0, 0.0], None, None, 5);
386        assert!(results.is_empty());
387    }
388}