Skip to main content

oneai_vector/
usearch_backend.rs

1//! `VectorBackend` backed by [`usearch`] HNSW ANN.
2//!
3//! Verified API surface (P0 spike): `Index::new(&IndexOptions{dimensions,
4//! metric, quantization, connectivity, expansion_add, expansion_search,
5//! multi})`, `add(key: u64, &[f32])`, `search(&[f32], k) -> Matches{keys,
6//! distances}`, `save(path)` / `load(path)` (`load` is an `&self` method —
7//! construct with `Index::new` first), and `filtered_search(&[f32], k, |key:
8//! u64| bool)` which does **pre-filter** search — exactly what metadata
9//! filtering needs.
10//!
11//! `usearch` stores only `(u64 key, Vec<f32>)`, so the string id + metadata
12//! live in side maps. Upsert assigns a fresh key per write; stale vectors from
13//! a previous write of the same id stay in the HNSW graph (cheap removal isn't
14//! generally supported) but are filtered out at search time via the `key→id`
15//! map (only the *current* key per id is mapped, so stale keys map to `None`
16//! and are skipped). For the desktop/server scale `usearch` targets this is a
17//! fine trade-off; a periodic rebuild reclaims graph space.
18//!
19//! Persistence: [`UsearchBackend::save`] writes both the usearch index and a
20//! `{path}.meta.json` sidecar (id↔key + metadata); [`UsearchBackend::open`]
21//! loads both. `mmap`-backed read-only views (`Index::view`) are left to a
22//! future enhancement — `load` (full read) is the default here.
23
24use std::collections::HashMap;
25
26use async_trait::async_trait;
27use oneai_core::traits::{Filter, Metadata, VectorBackend, VectorHit};
28use serde::{Deserialize, Serialize};
29use tokio::sync::Mutex;
30use usearch::{Index, IndexOptions, MetricKind, ScalarKind};
31
32/// Default HNSW parameters (matching the spike): connectivity 16,
33/// expansion_add 128, expansion_search 64. Tunable via [`UsearchBackendConfig`].
34const DEFAULT_CONNECTIVITY: usize = 16;
35const DEFAULT_EXPANSION_ADD: usize = 128;
36const DEFAULT_EXPANSION_SEARCH: usize = 64;
37/// Initial reserved capacity (usearch requires `reserve()` before `add()`).
38/// Grown geometrically as the index fills.
39const INITIAL_CAPACITY: usize = 1024;
40
41/// Configuration for [`UsearchBackend`].
42#[derive(Debug, Clone, Copy)]
43pub struct UsearchBackendConfig {
44    pub connectivity: usize,
45    pub expansion_add: usize,
46    pub expansion_search: usize,
47}
48
49impl Default for UsearchBackendConfig {
50    fn default() -> Self {
51        Self {
52            connectivity: DEFAULT_CONNECTIVITY,
53            expansion_add: DEFAULT_EXPANSION_ADD,
54            expansion_search: DEFAULT_EXPANSION_SEARCH,
55        }
56    }
57}
58
59fn build_index(dim: usize, cfg: UsearchBackendConfig) -> oneai_core::Result<Index> {
60    let opts = IndexOptions {
61        dimensions: dim,
62        metric: MetricKind::Cos,
63        quantization: ScalarKind::F32,
64        connectivity: cfg.connectivity,
65        expansion_add: cfg.expansion_add,
66        expansion_search: cfg.expansion_search,
67        multi: false,
68    };
69    Index::new(&opts).map_err(|e| oneai_core::OneAIError::Rag(format!("usearch new: {e}")))
70}
71
72/// Reserve `cap` slots on a freshly-built index. Called once at construction
73/// (and again from `upsert` to grow when capacity is exhausted).
74fn reserve_initial(index: &Index, cap: usize) -> oneai_core::Result<()> {
75    index
76        .reserve(cap)
77        .map_err(|e| oneai_core::OneAIError::Rag(format!("usearch reserve {cap}: {e}")))
78}
79
80/// Sidecar persisted alongside the usearch index file.
81#[derive(Serialize, Deserialize)]
82struct Sidecar {
83    dim: usize,
84    key_to_id: Vec<(u64, String)>,
85    id_to_key: Vec<(String, u64)>,
86    meta: Vec<(String, HashMap<String, String>)>,
87    next_key: u64,
88}
89
90/// HNSW vector backend.
91pub struct UsearchBackend {
92    index: Mutex<Index>,
93    dim: usize,
94    cfg: UsearchBackendConfig,
95    key_to_id: Mutex<HashMap<u64, String>>,
96    id_to_key: Mutex<HashMap<String, u64>>,
97    meta: Mutex<HashMap<String, Metadata>>,
98    next_key: Mutex<u64>,
99    /// Reserved HNSW capacity (usearch requires `reserve()` before `add()`;
100    /// we grow it geometrically as `next_key` approaches it).
101    reserved: Mutex<usize>,
102}
103
104impl UsearchBackend {
105    /// Create an in-memory HNSW index with default parameters.
106    pub fn new(dim: usize) -> oneai_core::Result<Self> {
107        Self::with_config(dim, UsearchBackendConfig::default())
108    }
109
110    /// Create with custom HNSW parameters.
111    pub fn with_config(dim: usize, cfg: UsearchBackendConfig) -> oneai_core::Result<Self> {
112        let index = build_index(dim, cfg)?;
113        reserve_initial(&index, INITIAL_CAPACITY)?;
114        Ok(Self {
115            index: Mutex::new(index),
116            dim,
117            cfg,
118            key_to_id: Mutex::new(HashMap::new()),
119            id_to_key: Mutex::new(HashMap::new()),
120            meta: Mutex::new(HashMap::new()),
121            next_key: Mutex::new(1),
122            reserved: Mutex::new(INITIAL_CAPACITY),
123        })
124    }
125
126    /// Load a persisted backend: reads the usearch index at `path` and the
127    /// `{path}.meta.json` sidecar. The HNSW parameters are assumed to match
128    /// [`UsearchBackendConfig::default`]; for custom params use
129    /// [`Self::open_with_config`].
130    pub fn open(path: &str, dim: usize) -> oneai_core::Result<Self> {
131        Self::open_with_config(path, dim, UsearchBackendConfig::default())
132    }
133
134    /// Load with explicit HNSW parameters.
135    pub fn open_with_config(
136        path: &str,
137        dim: usize,
138        cfg: UsearchBackendConfig,
139    ) -> oneai_core::Result<Self> {
140        let index = build_index(dim, cfg)?;
141        index
142            .load(path)
143            .map_err(|e| oneai_core::OneAIError::Rag(format!("usearch load {path}: {e}")))?;
144        let sidecar_path = sidecar_path(path);
145        let sidecar_str = std::fs::read_to_string(&sidecar_path).map_err(|e| {
146            oneai_core::OneAIError::Rag(format!("read sidecar {}: {e}", sidecar_path))
147        })?;
148        let sidecar: Sidecar = serde_json::from_str(&sidecar_str)
149            .map_err(|e| oneai_core::OneAIError::Rag(format!("parse sidecar: {e}")))?;
150        Ok(Self {
151            index: Mutex::new(index),
152            dim: sidecar.dim,
153            cfg,
154            key_to_id: Mutex::new(sidecar.key_to_id.into_iter().collect()),
155            id_to_key: Mutex::new(sidecar.id_to_key.into_iter().collect()),
156            meta: Mutex::new(sidecar.meta.into_iter().collect()),
157            next_key: Mutex::new(sidecar.next_key),
158            // Loaded index already has capacity for the on-disk vectors; reserve
159            // headroom for further inserts.
160            reserved: Mutex::new(sidecar.next_key.max(INITIAL_CAPACITY as u64) as usize),
161        })
162    }
163
164    /// Persist the index + sidecar. Writes `{path}.meta.json` next to `path`.
165    pub async fn save(&self, path: &str) -> oneai_core::Result<()> {
166        let index = self.index.lock().await;
167        index
168            .save(path)
169            .map_err(|e| oneai_core::OneAIError::Rag(format!("usearch save {path}: {e}")))?;
170        drop(index);
171
172        let key_to_id = self.key_to_id.lock().await;
173        let id_to_key = self.id_to_key.lock().await;
174        let meta = self.meta.lock().await;
175        let next_key = self.next_key.lock().await;
176        let sidecar = Sidecar {
177            dim: self.dim,
178            key_to_id: key_to_id.iter().map(|(k, v)| (*k, v.clone())).collect(),
179            id_to_key: id_to_key.iter().map(|(k, v)| (k.clone(), *v)).collect(),
180            meta: meta.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
181            next_key: *next_key,
182        };
183        let sidecar_str = serde_json::to_string(&sidecar)
184            .map_err(|e| oneai_core::OneAIError::Rag(format!("serialize sidecar: {e}")))?;
185        let p = sidecar_path(path);
186        std::fs::write(&p, sidecar_str)
187            .map_err(|e| oneai_core::OneAIError::Rag(format!("write sidecar {p}: {e}")))
188    }
189}
190
191/// Path of the metadata sidecar written next to the usearch index file.
192/// Given `path = "/tmp/idx.usearch"` → `"/tmp/idx.usearch.meta.json"`.
193fn sidecar_path(path: &str) -> String {
194    format!("{path}.meta.json")
195}
196
197#[async_trait]
198impl VectorBackend for UsearchBackend {
199    async fn upsert(
200        &self,
201        id: &str,
202        embedding: &[f32],
203        metadata: Metadata,
204    ) -> oneai_core::Result<()> {
205        if embedding.len() != self.dim {
206            return Err(oneai_core::OneAIError::Rag(format!(
207                "UsearchBackend: dim mismatch (got {}, expected {})",
208                embedding.len(),
209                self.dim
210            )));
211        }
212        let index = self.index.lock().await;
213        let mut key_to_id = self.key_to_id.lock().await;
214        let mut id_to_key = self.id_to_key.lock().await;
215        let mut meta = self.meta.lock().await;
216        let mut next_key = self.next_key.lock().await;
217        let mut reserved = self.reserved.lock().await;
218        // Retire the old key mapping (stale vector stays in the graph but is
219        // skipped at search via key_to_id miss).
220        if let Some(old_key) = id_to_key.get(id).copied() {
221            key_to_id.remove(&old_key);
222        }
223        let key = *next_key;
224        *next_key += 1;
225        // Grow reserved capacity geometrically when exhausted (usearch errors
226        // "Reserve capacity ahead of insertions!" otherwise).
227        if (key as usize) > *reserved {
228            let new_cap = (*reserved * 2).max(key as usize + INITIAL_CAPACITY);
229            index.reserve(new_cap).map_err(|e| {
230                oneai_core::OneAIError::Rag(format!("usearch reserve {new_cap}: {e}"))
231            })?;
232            *reserved = new_cap;
233        }
234        index
235            .add(key, embedding)
236            .map_err(|e| oneai_core::OneAIError::Rag(format!("usearch add: {e}")))?;
237        id_to_key.insert(id.to_string(), key);
238        key_to_id.insert(key, id.to_string());
239        meta.insert(id.to_string(), metadata);
240        Ok(())
241    }
242
243    async fn search(
244        &self,
245        query: &[f32],
246        top_k: usize,
247        filter: Option<&Filter>,
248    ) -> oneai_core::Result<Vec<VectorHit>> {
249        if query.len() != self.dim {
250            return Err(oneai_core::OneAIError::Rag(format!(
251                "UsearchBackend: query dim mismatch (got {}, expected {})",
252                query.len(),
253                self.dim
254            )));
255        }
256        if top_k == 0 {
257            return Ok(Vec::new());
258        }
259        let index = self.index.lock().await;
260        let key_to_id = self.key_to_id.lock().await;
261        let meta = self.meta.lock().await;
262
263        // Over-fetch to compensate for stale keys & filtered-out rows.
264        let fetch_k = match filter {
265            Some(_) => (top_k * 4).max(top_k),
266            None => top_k,
267        };
268
269        let matches = if let Some(f) = filter {
270            // Pre-filter: the closure decides which keys participate in the
271            // HNSW traversal (spike-verified `filtered_search`).
272            index
273                .filtered_search(query, fetch_k, |key: u64| -> bool {
274                    key_to_id
275                        .get(&key)
276                        .is_some_and(|id| meta.get(id).is_some_and(|m| f.matches(m)))
277                })
278                .map_err(|e| oneai_core::OneAIError::Rag(format!("usearch filtered_search: {e}")))?
279        } else {
280            index
281                .search(query, fetch_k)
282                .map_err(|e| oneai_core::OneAIError::Rag(format!("usearch search: {e}")))?
283        };
284
285        let mut hits: Vec<VectorHit> = Vec::new();
286        for (key, dist) in matches.keys.iter().zip(matches.distances.iter()) {
287            let Some(id) = key_to_id.get(key) else {
288                continue; // stale key from a prior upsert
289            };
290            let metadata = meta.get(id).cloned().unwrap_or_default();
291            // Cosine distance in [0,2]; similarity = 1 - dist.
292            let score = (1.0 - dist).clamp(-1.0, 1.0);
293            hits.push(VectorHit {
294                id: id.clone(),
295                score,
296                metadata,
297            });
298            if hits.len() >= top_k {
299                break;
300            }
301        }
302        hits.sort_by(|a, b| {
303            b.score
304                .partial_cmp(&a.score)
305                .unwrap_or(std::cmp::Ordering::Equal)
306        });
307        hits.truncate(top_k);
308        Ok(hits)
309    }
310
311    async fn delete(&self, id: &str) -> oneai_core::Result<()> {
312        let mut key_to_id = self.key_to_id.lock().await;
313        let mut id_to_key = self.id_to_key.lock().await;
314        let mut meta = self.meta.lock().await;
315        if let Some(key) = id_to_key.remove(id) {
316            key_to_id.remove(&key);
317            meta.remove(id);
318        }
319        // The vector remains in the HNSW graph (no cheap remove); it will be
320        // skipped at search because key_to_id no longer maps it. Reclaim via
321        // periodic rebuild.
322        Ok(())
323    }
324
325    fn dimension(&self) -> usize {
326        self.dim
327    }
328}
329
330impl UsearchBackend {
331    /// Expose the active config (for diagnostics).
332    pub fn config(&self) -> UsearchBackendConfig {
333        self.cfg
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[tokio::test]
342    async fn hnsw_upsert_search_filter_delete() {
343        let be = UsearchBackend::new(4).unwrap();
344        be.upsert(
345            "a",
346            &[0.1, 0.2, 0.3, 0.4],
347            Metadata::from([("k".into(), "x".into())]),
348        )
349        .await
350        .unwrap();
351        be.upsert(
352            "b",
353            &[0.11, 0.21, 0.31, 0.41],
354            Metadata::from([("k".into(), "y".into())]),
355        )
356        .await
357        .unwrap();
358        be.upsert(
359            "c",
360            &[0.9, 0.8, 0.7, 0.6],
361            Metadata::from([("k".into(), "x".into())]),
362        )
363        .await
364        .unwrap();
365
366        let hits = be.search(&[0.1, 0.2, 0.3, 0.4], 3, None).await.unwrap();
367        assert_eq!(hits[0].id, "a");
368        assert_eq!(hits[1].id, "b");
369        assert!(hits[0].score > 0.99);
370
371        let f = Filter::new().with_eq("k", "x");
372        let hits = be.search(&[0.1, 0.2, 0.3, 0.4], 5, Some(&f)).await.unwrap();
373        assert_eq!(hits.len(), 2);
374        assert!(hits.iter().all(|h| h.metadata["k"] == "x"));
375    }
376
377    #[tokio::test]
378    async fn upsert_replaces_and_stale_skipped() {
379        let be = UsearchBackend::new(4).unwrap();
380        be.upsert("a", &[0.1, 0.2, 0.3, 0.4], Metadata::new())
381            .await
382            .unwrap();
383        be.upsert("a", &[0.9, 0.8, 0.7, 0.6], Metadata::new())
384            .await
385            .unwrap();
386        let hits = be.search(&[0.9, 0.8, 0.7, 0.6], 10, None).await.unwrap();
387        // Only one 'a' despite the stale graph vector.
388        assert_eq!(hits.iter().filter(|h| h.id == "a").count(), 1);
389        assert_eq!(hits[0].id, "a");
390    }
391
392    #[tokio::test]
393    async fn save_load_roundtrip() {
394        let dir = std::env::temp_dir().join("oneai-vector-usearch-test");
395        let _ = std::fs::remove_dir_all(&dir);
396        std::fs::create_dir_all(&dir).unwrap();
397        let path = dir.join("idx.usearch");
398        let path_str = path.to_string_lossy().to_string();
399
400        {
401            let be = UsearchBackend::new(4).unwrap();
402            be.upsert(
403                "a",
404                &[0.1, 0.2, 0.3, 0.4],
405                Metadata::from([("k".into(), "x".into())]),
406            )
407            .await
408            .unwrap();
409            be.upsert(
410                "c",
411                &[0.9, 0.8, 0.7, 0.6],
412                Metadata::from([("k".into(), "x".into())]),
413            )
414            .await
415            .unwrap();
416            be.save(&path_str).await.unwrap();
417        }
418
419        let be = UsearchBackend::open(&path_str, 4).unwrap();
420        let hits = be.search(&[0.1, 0.2, 0.3, 0.4], 2, None).await.unwrap();
421        assert_eq!(hits[0].id, "a");
422        assert_eq!(hits[0].metadata["k"], "x");
423        let _ = std::fs::remove_dir_all(&dir);
424    }
425}