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.
50    pub fn save(&self, dir: &Path) -> MenteResult<()> {
51        std::fs::create_dir_all(dir)?;
52        self.hnsw.save(&dir.join("hnsw.json"))?;
53        self.bm25.save(&dir.join("bm25.json"))?;
54        self.bitmap.save(&dir.join("bitmap.json"))?;
55        self.temporal.save(&dir.join("temporal.json"))?;
56        self.salience.save(&dir.join("salience.json"))?;
57        Ok(())
58    }
59
60    /// Load all indexes from the given directory.
61    pub fn load(dir: &Path) -> MenteResult<Self> {
62        let hnsw = HnswIndex::load(&dir.join("hnsw.json"), HnswConfig::default().ef_search)?;
63        let bm25_path = dir.join("bm25.json");
64        let bm25 = if bm25_path.exists() {
65            Bm25Index::load(&bm25_path)?
66        } else {
67            Bm25Index::new()
68        };
69        let bitmap = BitmapIndex::load(&dir.join("bitmap.json"))?;
70        let temporal = TemporalIndex::load(&dir.join("temporal.json"))?;
71        let salience = SalienceIndex::load(&dir.join("salience.json"))?;
72        Ok(Self {
73            hnsw,
74            bm25,
75            bitmap,
76            temporal,
77            salience,
78        })
79    }
80
81    /// Index a memory node across all indexes.
82    pub fn index_memory(&self, node: &MemoryNode) {
83        // Vector index
84        if !node.embedding.is_empty() {
85            let _ = self.hnsw.insert(node.id, &node.embedding);
86        }
87
88        // BM25 full-text index
89        if !node.content.is_empty() {
90            self.bm25.insert(node.id, &node.content);
91        }
92
93        // Tag bitmap index
94        for tag in &node.tags {
95            self.bitmap.add_tag(node.id, tag);
96        }
97
98        // Temporal index
99        self.temporal.insert(node.id, node.created_at);
100
101        // Salience index
102        self.salience.insert(node.id, node.salience);
103    }
104
105    /// Remove a memory from all indexes.
106    pub fn remove_memory(&self, id: MemoryId, node: &MemoryNode) {
107        let _ = self.hnsw.remove(id);
108        self.bm25.remove(id);
109        self.bitmap.remove_all(id);
110        self.temporal.remove(id, node.created_at);
111        self.salience.remove(id, node.salience);
112    }
113
114    /// Hybrid search combining vector similarity, BM25 keyword matching,
115    /// tag filtering, time range, and salience.
116    ///
117    /// Strategy:
118    /// 1. Vector search (HNSW) for top candidates
119    /// 2. BM25 keyword search for top candidates
120    /// 3. Merge via Reciprocal Rank Fusion (RRF)
121    /// 4. Filter by tags and time range
122    /// 5. Boost by salience and recency
123    /// 6. Return top k results
124    pub fn hybrid_search(
125        &self,
126        query_embedding: &[f32],
127        tags: Option<&[&str]>,
128        time_range: Option<(Timestamp, Timestamp)>,
129        k: usize,
130    ) -> Vec<(MemoryId, f32)> {
131        self.hybrid_search_with_query(query_embedding, None, tags, time_range, k)
132    }
133
134    /// Hybrid search with an optional text query for BM25 matching.
135    ///
136    /// When `query_text` is provided, BM25 results are merged with vector
137    /// results via RRF. When None, behaves like vector-only search.
138    pub fn hybrid_search_with_query(
139        &self,
140        query_embedding: &[f32],
141        query_text: Option<&str>,
142        tags: Option<&[&str]>,
143        time_range: Option<(Timestamp, Timestamp)>,
144        k: usize,
145    ) -> Vec<(MemoryId, f32)> {
146        if k == 0 {
147            return Vec::new();
148        }
149
150        let fetch_k = k * 4;
151        let rrf_k: f32 = 60.0;
152
153        // Step 1: Vector search candidates
154        let vector_candidates = self.hnsw.search(query_embedding, fetch_k);
155
156        // Step 2: BM25 search candidates (if query text provided and index has docs)
157        let bm25_candidates = match query_text {
158            Some(qt) if !self.bm25.is_empty() => self.bm25.search(qt, fetch_k),
159            _ => Vec::new(),
160        };
161
162        if vector_candidates.is_empty() && bm25_candidates.is_empty() {
163            return Vec::new();
164        }
165
166        // Step 3: Merge via RRF
167        let mut rrf_scores: HashMap<MemoryId, f32> = HashMap::new();
168
169        for (rank, (id, _)) in vector_candidates.iter().enumerate() {
170            *rrf_scores.entry(*id).or_insert(0.0) += 1.0 / (rrf_k + rank as f32);
171        }
172        for (rank, (id, _)) in bm25_candidates.iter().enumerate() {
173            *rrf_scores.entry(*id).or_insert(0.0) += 1.0 / (rrf_k + rank as f32);
174        }
175
176        // Build set of tag-filtered ids (if tags are specified)
177        let tag_filter: Option<HashSet<MemoryId>> = tags.map(|t| {
178            if t.is_empty() {
179                HashSet::new()
180            } else {
181                self.bitmap.query_tags_and(t).into_iter().collect()
182            }
183        });
184
185        // Build set of time-range-filtered ids (if time range is specified)
186        let time_filter: Option<HashSet<MemoryId>> =
187            time_range.map(|(start, end)| self.temporal.range(start, end).into_iter().collect());
188
189        // Step 4: Filter and boost with salience/recency
190        let max_ts = rrf_scores
191            .keys()
192            .filter_map(|id| self.temporal.get_timestamp(*id))
193            .max()
194            .unwrap_or(1) as f64;
195
196        let mut scored: Vec<(MemoryId, f32)> = rrf_scores
197            .into_iter()
198            .filter(|(id, _)| {
199                if let Some(ref tf) = tag_filter
200                    && !tf.contains(id)
201                {
202                    return false;
203                }
204                if let Some(ref trf) = time_filter
205                    && !trf.contains(id)
206                {
207                    return false;
208                }
209                true
210            })
211            .map(|(id, rrf_score)| {
212                let salience = self.salience.get_salience(id).unwrap_or(0.5);
213                let ts = self.temporal.get_timestamp(id).unwrap_or(0) as f64;
214                let recency = if max_ts > 0.0 {
215                    (ts / max_ts) as f32
216                } else {
217                    0.0
218                };
219
220                // RRF is the primary signal, salience and recency are light boosts
221                let combined = rrf_score * 0.7 + salience * 0.05 + recency * 0.02;
222                (id, combined)
223            })
224            .collect();
225
226        // Sort descending by combined score
227        scored.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
228        scored.truncate(k);
229        scored
230    }
231}
232
233impl Default for IndexManager {
234    fn default() -> Self {
235        Self::new(IndexManagerConfig::default())
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use mentedb_core::memory::MemoryType;
243    use mentedb_core::types::AgentId;
244
245    fn make_node(
246        embedding: Vec<f32>,
247        tags: Vec<String>,
248        salience: f32,
249        created_at: u64,
250    ) -> MemoryNode {
251        let mut node = MemoryNode::new(
252            AgentId::new(),
253            MemoryType::Episodic,
254            "test".into(),
255            embedding,
256        );
257        node.tags = tags;
258        node.salience = salience;
259        node.created_at = created_at;
260        node
261    }
262
263    #[test]
264    fn test_index_and_search() {
265        let mgr = IndexManager::default();
266        let node = make_node(vec![1.0, 0.0, 0.0, 0.0], vec!["test".into()], 0.8, 1000);
267        mgr.index_memory(&node);
268
269        let results = mgr.hybrid_search(&[1.0, 0.0, 0.0, 0.0], None, None, 1);
270        assert_eq!(results.len(), 1);
271        assert_eq!(results[0].0, node.id);
272    }
273
274    #[test]
275    fn test_tag_filter() {
276        let mgr = IndexManager::default();
277        let a = make_node(vec![1.0, 0.0, 0.0, 0.0], vec!["alpha".into()], 0.8, 1000);
278        let b = make_node(vec![0.9, 0.1, 0.0, 0.0], vec!["beta".into()], 0.8, 1000);
279        mgr.index_memory(&a);
280        mgr.index_memory(&b);
281
282        let results = mgr.hybrid_search(&[1.0, 0.0, 0.0, 0.0], Some(&["alpha"]), None, 10);
283        assert_eq!(results.len(), 1);
284        assert_eq!(results[0].0, a.id);
285    }
286
287    #[test]
288    fn test_time_filter() {
289        let mgr = IndexManager::default();
290        let a = make_node(vec![1.0, 0.0, 0.0, 0.0], vec![], 0.8, 100);
291        let b = make_node(vec![0.9, 0.1, 0.0, 0.0], vec![], 0.8, 500);
292        mgr.index_memory(&a);
293        mgr.index_memory(&b);
294
295        let results = mgr.hybrid_search(&[1.0, 0.0, 0.0, 0.0], None, Some((400, 600)), 10);
296        assert_eq!(results.len(), 1);
297        assert_eq!(results[0].0, b.id);
298    }
299
300    #[test]
301    fn test_remove_memory() {
302        let mgr = IndexManager::default();
303        let node = make_node(vec![1.0, 0.0, 0.0, 0.0], vec!["t".into()], 0.5, 100);
304        let id = node.id;
305        mgr.index_memory(&node);
306        mgr.remove_memory(id, &node);
307
308        let results = mgr.hybrid_search(&[1.0, 0.0, 0.0, 0.0], None, None, 10);
309        assert!(results.is_empty());
310    }
311
312    #[test]
313    fn test_empty_search() {
314        let mgr = IndexManager::default();
315        let results = mgr.hybrid_search(&[1.0, 0.0], None, None, 5);
316        assert!(results.is_empty());
317    }
318}