use std::collections::HashMap;
use std::path::Path;
use std::sync::RwLock;
use async_trait::async_trait;
use semtree_embed::Embedding;
use usearch::{Index, IndexOptions, MetricKind, ScalarKind};
use crate::{Hit, Metric, StoreError, VectorStore};
const INITIAL_CAPACITY: usize = 1024;
pub struct UsearchStore {
dimensions: usize,
metric: Metric,
index: RwLock<Index>,
id_map: RwLock<HashMap<u64, String>>,
next_key: RwLock<u64>,
}
impl UsearchStore {
pub fn new(dimensions: usize) -> Result<Self, StoreError> {
Self::with_metric(dimensions, Metric::Cosine)
}
pub fn with_metric(dimensions: usize, metric: Metric) -> Result<Self, StoreError> {
let index = Self::make_index(dimensions, metric)?;
Ok(Self {
dimensions,
metric,
index: RwLock::new(index),
id_map: RwLock::new(HashMap::new()),
next_key: RwLock::new(0),
})
}
fn make_index(dimensions: usize, metric: Metric) -> Result<Index, StoreError> {
let options = IndexOptions {
dimensions,
metric: match metric {
Metric::Cosine => MetricKind::Cos,
Metric::Euclidean => MetricKind::L2sq,
Metric::DotProduct => MetricKind::IP,
},
quantization: ScalarKind::F32,
..Default::default()
};
let index = Index::new(&options).map_err(|e| StoreError::Init(e.to_string()))?;
index
.reserve(INITIAL_CAPACITY)
.map_err(|e| StoreError::Init(e.to_string()))?;
Ok(index)
}
fn reserve_one_more(&self) -> Result<(), StoreError> {
if has_room(&self.index.read().unwrap()) {
return Ok(());
}
#[allow(clippy::readonly_write_lock)]
let index = self.index.write().unwrap();
if has_room(&index) {
return Ok(());
}
let target = index.capacity().saturating_mul(2).max(INITIAL_CAPACITY);
index
.reserve(target)
.map_err(|e| StoreError::Insert(e.to_string()))
}
fn distance_to_score(&self, distance: f32) -> f32 {
match self.metric {
Metric::Cosine | Metric::DotProduct => 1.0 - distance,
Metric::Euclidean => 1.0 / (1.0 + distance),
}
}
}
#[async_trait]
impl VectorStore for UsearchStore {
async fn insert(&self, id: &str, embedding: &Embedding) -> Result<(), StoreError> {
self.reserve_one_more()?;
let key = {
let mut k = self.next_key.write().unwrap();
let current = *k;
*k += 1;
current
};
self.index
.read()
.unwrap()
.add(key, embedding)
.map_err(|e| StoreError::Insert(e.to_string()))?;
self.id_map.write().unwrap().insert(key, id.to_string());
Ok(())
}
async fn search(&self, query: &Embedding, top_k: usize) -> Result<Vec<Hit>, StoreError> {
let index = self.index.read().unwrap();
if index.size() == 0 {
return Ok(vec![]);
}
let results = index
.search(query, top_k)
.map_err(|e| StoreError::Search(e.to_string()))?;
drop(index);
let map = self.id_map.read().unwrap();
let hits = results
.keys
.iter()
.zip(results.distances.iter())
.filter_map(|(key, dist)| {
map.get(key).map(|id| Hit {
id: id.clone(),
score: self.distance_to_score(*dist),
})
})
.collect();
Ok(hits)
}
async fn delete(&self, id: &str) -> Result<(), StoreError> {
let map = self.id_map.read().unwrap();
if let Some((&key, _)) = map.iter().find(|(_, v)| v.as_str() == id) {
drop(map);
self.index
.read()
.unwrap()
.remove(key)
.map_err(|e| StoreError::Insert(e.to_string()))?;
self.id_map.write().unwrap().remove(&key);
}
Ok(())
}
async fn clear(&self) -> Result<(), StoreError> {
let index = Self::make_index(self.dimensions, self.metric)?;
*self.index.write().unwrap() = index;
self.id_map.write().unwrap().clear();
*self.next_key.write().unwrap() = 0;
Ok(())
}
fn save(&self, path: &Path) -> Result<(), StoreError> {
let index_path = path.join("index.usearch");
self.index
.read()
.unwrap()
.save(index_path.to_str().unwrap())
.map_err(|e| StoreError::Init(e.to_string()))?;
let meta = serde_json::json!({
"dimensions": self.dimensions,
"metric": self.metric,
"next_key": *self.next_key.read().unwrap(),
"id_map": self.id_map.read().unwrap().iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect::<HashMap<String, String>>(),
});
let meta_path = path.join("meta.json");
std::fs::write(meta_path, serde_json::to_string(&meta).unwrap())
.map_err(|e| StoreError::Init(e.to_string()))?;
Ok(())
}
fn load(&self, path: &Path) -> Result<(), StoreError> {
let meta_path = path.join("meta.json");
let raw =
std::fs::read_to_string(meta_path).map_err(|e| StoreError::Init(e.to_string()))?;
let meta: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| StoreError::Init(e.to_string()))?;
if let Some(saved) = meta.get("metric")
&& let Ok(saved) = serde_json::from_value::<Metric>(saved.clone())
&& saved != self.metric
{
return Err(StoreError::Init(format!(
"index metric mismatch: on disk {saved}, requested {}",
self.metric
)));
}
let next_key: u64 = meta["next_key"].as_u64().unwrap_or(0);
let id_map: HashMap<u64, String> = meta["id_map"]
.as_object()
.unwrap_or(&serde_json::Map::new())
.iter()
.filter_map(|(k, v)| Some((k.parse::<u64>().ok()?, v.as_str()?.to_string())))
.collect();
let index = Self::make_index(self.dimensions, self.metric)?;
let index_path = path.join("index.usearch");
index
.load(index_path.to_str().unwrap())
.map_err(|e| StoreError::Init(e.to_string()))?;
*self.index.write().unwrap() = index;
*self.next_key.write().unwrap() = next_key;
*self.id_map.write().unwrap() = id_map;
Ok(())
}
fn len(&self) -> usize {
self.index.read().unwrap().size()
}
fn metric(&self) -> Metric {
self.metric
}
}
fn has_room(index: &Index) -> bool {
index.size() < index.capacity()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn score_is_higher_for_nearer_vectors() {
let cos = UsearchStore::with_metric(4, Metric::Cosine).unwrap();
assert!(cos.distance_to_score(0.1) > cos.distance_to_score(0.9));
let l2 = UsearchStore::with_metric(4, Metric::Euclidean).unwrap();
assert!(l2.distance_to_score(0.1) > l2.distance_to_score(5.0));
assert!(l2.distance_to_score(0.0) <= 1.0);
}
#[test]
fn reload_rejects_metric_mismatch() {
let dir = std::env::temp_dir().join("semtree_store_metric_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let saved = UsearchStore::with_metric(4, Metric::Cosine).unwrap();
saved.save(&dir).unwrap();
let same = UsearchStore::with_metric(4, Metric::Cosine).unwrap();
assert!(same.load(&dir).is_ok());
let other = UsearchStore::with_metric(4, Metric::Euclidean).unwrap();
assert!(other.load(&dir).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn inserts_keep_working_past_the_initial_capacity() {
let store = UsearchStore::with_metric(4, Metric::Cosine).unwrap();
for i in 0..(INITIAL_CAPACITY + 10) {
store
.insert(&format!("id{i}"), &vec![i as f32, 0.0, 0.0, 1.0])
.await
.expect("insert grows the index instead of failing");
}
assert_eq!(store.len(), INITIAL_CAPACITY + 10);
}
#[tokio::test]
async fn a_reloaded_index_can_still_be_added_to() {
let dir = std::env::temp_dir().join("semtree_store_reload_insert_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = UsearchStore::with_metric(4, Metric::Cosine).unwrap();
store.insert("a", &vec![1.0, 0.0, 0.0, 0.0]).await.unwrap();
store.save(&dir).unwrap();
let reopened = UsearchStore::with_metric(4, Metric::Cosine).unwrap();
reopened.load(&dir).unwrap();
assert_eq!(reopened.len(), 1, "the saved vector came back");
reopened
.insert("b", &vec![0.0, 1.0, 0.0, 0.0])
.await
.unwrap();
assert_eq!(reopened.len(), 2, "and the new one was added on top");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn clear_empties_the_store() {
let store = UsearchStore::with_metric(4, Metric::Cosine).unwrap();
store.insert("a", &vec![1.0, 0.0, 0.0, 0.0]).await.unwrap();
assert_eq!(store.len(), 1);
store.clear().await.unwrap();
assert_eq!(store.len(), 0, "a full rebuild starts from nothing");
store.insert("b", &vec![0.0, 1.0, 0.0, 0.0]).await.unwrap();
assert_eq!(store.len(), 1, "and can be filled again");
}
}