1use 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
32const DEFAULT_CONNECTIVITY: usize = 16;
35const DEFAULT_EXPANSION_ADD: usize = 128;
36const DEFAULT_EXPANSION_SEARCH: usize = 64;
37const INITIAL_CAPACITY: usize = 1024;
40
41#[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
72fn 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#[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
90pub 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: Mutex<usize>,
102}
103
104impl UsearchBackend {
105 pub fn new(dim: usize) -> oneai_core::Result<Self> {
107 Self::with_config(dim, UsearchBackendConfig::default())
108 }
109
110 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 pub fn open(path: &str, dim: usize) -> oneai_core::Result<Self> {
131 Self::open_with_config(path, dim, UsearchBackendConfig::default())
132 }
133
134 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 reserved: Mutex::new(sidecar.next_key.max(INITIAL_CAPACITY as u64) as usize),
161 })
162 }
163
164 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
191fn 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 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 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 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 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; };
290 let metadata = meta.get(id).cloned().unwrap_or_default();
291 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 Ok(())
323 }
324
325 fn dimension(&self) -> usize {
326 self.dim
327 }
328}
329
330impl UsearchBackend {
331 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 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}