use std::collections::HashMap;
use async_trait::async_trait;
use oneai_core::traits::{Filter, Metadata, VectorBackend, VectorHit};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use usearch::{Index, IndexOptions, MetricKind, ScalarKind};
const DEFAULT_CONNECTIVITY: usize = 16;
const DEFAULT_EXPANSION_ADD: usize = 128;
const DEFAULT_EXPANSION_SEARCH: usize = 64;
const INITIAL_CAPACITY: usize = 1024;
#[derive(Debug, Clone, Copy)]
pub struct UsearchBackendConfig {
pub connectivity: usize,
pub expansion_add: usize,
pub expansion_search: usize,
}
impl Default for UsearchBackendConfig {
fn default() -> Self {
Self {
connectivity: DEFAULT_CONNECTIVITY,
expansion_add: DEFAULT_EXPANSION_ADD,
expansion_search: DEFAULT_EXPANSION_SEARCH,
}
}
}
fn build_index(dim: usize, cfg: UsearchBackendConfig) -> oneai_core::Result<Index> {
let opts = IndexOptions {
dimensions: dim,
metric: MetricKind::Cos,
quantization: ScalarKind::F32,
connectivity: cfg.connectivity,
expansion_add: cfg.expansion_add,
expansion_search: cfg.expansion_search,
multi: false,
};
Index::new(&opts).map_err(|e| oneai_core::OneAIError::Rag(format!("usearch new: {e}")))
}
fn reserve_initial(index: &Index, cap: usize) -> oneai_core::Result<()> {
index
.reserve(cap)
.map_err(|e| oneai_core::OneAIError::Rag(format!("usearch reserve {cap}: {e}")))
}
#[derive(Serialize, Deserialize)]
struct Sidecar {
dim: usize,
key_to_id: Vec<(u64, String)>,
id_to_key: Vec<(String, u64)>,
meta: Vec<(String, HashMap<String, String>)>,
next_key: u64,
}
pub struct UsearchBackend {
index: Mutex<Index>,
dim: usize,
cfg: UsearchBackendConfig,
key_to_id: Mutex<HashMap<u64, String>>,
id_to_key: Mutex<HashMap<String, u64>>,
meta: Mutex<HashMap<String, Metadata>>,
next_key: Mutex<u64>,
reserved: Mutex<usize>,
}
impl UsearchBackend {
pub fn new(dim: usize) -> oneai_core::Result<Self> {
Self::with_config(dim, UsearchBackendConfig::default())
}
pub fn with_config(dim: usize, cfg: UsearchBackendConfig) -> oneai_core::Result<Self> {
let index = build_index(dim, cfg)?;
reserve_initial(&index, INITIAL_CAPACITY)?;
Ok(Self {
index: Mutex::new(index),
dim,
cfg,
key_to_id: Mutex::new(HashMap::new()),
id_to_key: Mutex::new(HashMap::new()),
meta: Mutex::new(HashMap::new()),
next_key: Mutex::new(1),
reserved: Mutex::new(INITIAL_CAPACITY),
})
}
pub fn open(path: &str, dim: usize) -> oneai_core::Result<Self> {
Self::open_with_config(path, dim, UsearchBackendConfig::default())
}
pub fn open_with_config(
path: &str,
dim: usize,
cfg: UsearchBackendConfig,
) -> oneai_core::Result<Self> {
let index = build_index(dim, cfg)?;
index
.load(path)
.map_err(|e| oneai_core::OneAIError::Rag(format!("usearch load {path}: {e}")))?;
let sidecar_path = sidecar_path(path);
let sidecar_str = std::fs::read_to_string(&sidecar_path).map_err(|e| {
oneai_core::OneAIError::Rag(format!("read sidecar {}: {e}", sidecar_path))
})?;
let sidecar: Sidecar = serde_json::from_str(&sidecar_str)
.map_err(|e| oneai_core::OneAIError::Rag(format!("parse sidecar: {e}")))?;
Ok(Self {
index: Mutex::new(index),
dim: sidecar.dim,
cfg,
key_to_id: Mutex::new(sidecar.key_to_id.into_iter().collect()),
id_to_key: Mutex::new(sidecar.id_to_key.into_iter().collect()),
meta: Mutex::new(sidecar.meta.into_iter().collect()),
next_key: Mutex::new(sidecar.next_key),
reserved: Mutex::new(sidecar.next_key.max(INITIAL_CAPACITY as u64) as usize),
})
}
pub async fn save(&self, path: &str) -> oneai_core::Result<()> {
let index = self.index.lock().await;
index
.save(path)
.map_err(|e| oneai_core::OneAIError::Rag(format!("usearch save {path}: {e}")))?;
drop(index);
let key_to_id = self.key_to_id.lock().await;
let id_to_key = self.id_to_key.lock().await;
let meta = self.meta.lock().await;
let next_key = self.next_key.lock().await;
let sidecar = Sidecar {
dim: self.dim,
key_to_id: key_to_id.iter().map(|(k, v)| (*k, v.clone())).collect(),
id_to_key: id_to_key.iter().map(|(k, v)| (k.clone(), *v)).collect(),
meta: meta.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
next_key: *next_key,
};
let sidecar_str = serde_json::to_string(&sidecar)
.map_err(|e| oneai_core::OneAIError::Rag(format!("serialize sidecar: {e}")))?;
let p = sidecar_path(path);
std::fs::write(&p, sidecar_str)
.map_err(|e| oneai_core::OneAIError::Rag(format!("write sidecar {p}: {e}")))
}
}
fn sidecar_path(path: &str) -> String {
format!("{path}.meta.json")
}
#[async_trait]
impl VectorBackend for UsearchBackend {
async fn upsert(
&self,
id: &str,
embedding: &[f32],
metadata: Metadata,
) -> oneai_core::Result<()> {
if embedding.len() != self.dim {
return Err(oneai_core::OneAIError::Rag(format!(
"UsearchBackend: dim mismatch (got {}, expected {})",
embedding.len(),
self.dim
)));
}
let index = self.index.lock().await;
let mut key_to_id = self.key_to_id.lock().await;
let mut id_to_key = self.id_to_key.lock().await;
let mut meta = self.meta.lock().await;
let mut next_key = self.next_key.lock().await;
let mut reserved = self.reserved.lock().await;
if let Some(old_key) = id_to_key.get(id).copied() {
key_to_id.remove(&old_key);
}
let key = *next_key;
*next_key += 1;
if (key as usize) > *reserved {
let new_cap = (*reserved * 2).max(key as usize + INITIAL_CAPACITY);
index.reserve(new_cap).map_err(|e| {
oneai_core::OneAIError::Rag(format!("usearch reserve {new_cap}: {e}"))
})?;
*reserved = new_cap;
}
index
.add(key, embedding)
.map_err(|e| oneai_core::OneAIError::Rag(format!("usearch add: {e}")))?;
id_to_key.insert(id.to_string(), key);
key_to_id.insert(key, id.to_string());
meta.insert(id.to_string(), metadata);
Ok(())
}
async fn search(
&self,
query: &[f32],
top_k: usize,
filter: Option<&Filter>,
) -> oneai_core::Result<Vec<VectorHit>> {
if query.len() != self.dim {
return Err(oneai_core::OneAIError::Rag(format!(
"UsearchBackend: query dim mismatch (got {}, expected {})",
query.len(),
self.dim
)));
}
if top_k == 0 {
return Ok(Vec::new());
}
let index = self.index.lock().await;
let key_to_id = self.key_to_id.lock().await;
let meta = self.meta.lock().await;
let fetch_k = match filter {
Some(_) => (top_k * 4).max(top_k),
None => top_k,
};
let matches = if let Some(f) = filter {
index
.filtered_search(query, fetch_k, |key: u64| -> bool {
key_to_id
.get(&key)
.is_some_and(|id| meta.get(id).is_some_and(|m| f.matches(m)))
})
.map_err(|e| oneai_core::OneAIError::Rag(format!("usearch filtered_search: {e}")))?
} else {
index
.search(query, fetch_k)
.map_err(|e| oneai_core::OneAIError::Rag(format!("usearch search: {e}")))?
};
let mut hits: Vec<VectorHit> = Vec::new();
for (key, dist) in matches.keys.iter().zip(matches.distances.iter()) {
let Some(id) = key_to_id.get(key) else {
continue; };
let metadata = meta.get(id).cloned().unwrap_or_default();
let score = (1.0 - dist).clamp(-1.0, 1.0);
hits.push(VectorHit {
id: id.clone(),
score,
metadata,
});
if hits.len() >= top_k {
break;
}
}
hits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
hits.truncate(top_k);
Ok(hits)
}
async fn delete(&self, id: &str) -> oneai_core::Result<()> {
let mut key_to_id = self.key_to_id.lock().await;
let mut id_to_key = self.id_to_key.lock().await;
let mut meta = self.meta.lock().await;
if let Some(key) = id_to_key.remove(id) {
key_to_id.remove(&key);
meta.remove(id);
}
Ok(())
}
fn dimension(&self) -> usize {
self.dim
}
}
impl UsearchBackend {
pub fn config(&self) -> UsearchBackendConfig {
self.cfg
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn hnsw_upsert_search_filter_delete() {
let be = UsearchBackend::new(4).unwrap();
be.upsert(
"a",
&[0.1, 0.2, 0.3, 0.4],
Metadata::from([("k".into(), "x".into())]),
)
.await
.unwrap();
be.upsert(
"b",
&[0.11, 0.21, 0.31, 0.41],
Metadata::from([("k".into(), "y".into())]),
)
.await
.unwrap();
be.upsert(
"c",
&[0.9, 0.8, 0.7, 0.6],
Metadata::from([("k".into(), "x".into())]),
)
.await
.unwrap();
let hits = be.search(&[0.1, 0.2, 0.3, 0.4], 3, None).await.unwrap();
assert_eq!(hits[0].id, "a");
assert_eq!(hits[1].id, "b");
assert!(hits[0].score > 0.99);
let f = Filter::new().with_eq("k", "x");
let hits = be.search(&[0.1, 0.2, 0.3, 0.4], 5, Some(&f)).await.unwrap();
assert_eq!(hits.len(), 2);
assert!(hits.iter().all(|h| h.metadata["k"] == "x"));
}
#[tokio::test]
async fn upsert_replaces_and_stale_skipped() {
let be = UsearchBackend::new(4).unwrap();
be.upsert("a", &[0.1, 0.2, 0.3, 0.4], Metadata::new())
.await
.unwrap();
be.upsert("a", &[0.9, 0.8, 0.7, 0.6], Metadata::new())
.await
.unwrap();
let hits = be.search(&[0.9, 0.8, 0.7, 0.6], 10, None).await.unwrap();
assert_eq!(hits.iter().filter(|h| h.id == "a").count(), 1);
assert_eq!(hits[0].id, "a");
}
#[tokio::test]
async fn save_load_roundtrip() {
let dir = std::env::temp_dir().join("oneai-vector-usearch-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("idx.usearch");
let path_str = path.to_string_lossy().to_string();
{
let be = UsearchBackend::new(4).unwrap();
be.upsert(
"a",
&[0.1, 0.2, 0.3, 0.4],
Metadata::from([("k".into(), "x".into())]),
)
.await
.unwrap();
be.upsert(
"c",
&[0.9, 0.8, 0.7, 0.6],
Metadata::from([("k".into(), "x".into())]),
)
.await
.unwrap();
be.save(&path_str).await.unwrap();
}
let be = UsearchBackend::open(&path_str, 4).unwrap();
let hits = be.search(&[0.1, 0.2, 0.3, 0.4], 2, None).await.unwrap();
assert_eq!(hits[0].id, "a");
assert_eq!(hits[0].metadata["k"], "x");
let _ = std::fs::remove_dir_all(&dir);
}
}