use nodedb_types::Namespace;
use crate::error::LiteError;
use crate::storage::engine::{StorageEngine, WriteOp};
use super::layout::{ROW_PREFIX, index_prefix, key, parse_key};
const F32_BYTES: usize = 4;
pub(crate) fn encode(vector: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(vector.len() * F32_BYTES);
for v in vector {
out.extend_from_slice(&v.to_le_bytes());
}
out
}
pub(crate) fn decode(bytes: &[u8]) -> Option<Vec<f32>> {
if bytes.is_empty() || !bytes.len().is_multiple_of(F32_BYTES) {
return None;
}
Some(
bytes
.as_chunks::<F32_BYTES>()
.0
.iter()
.map(|c| f32::from_le_bytes(*c))
.collect(),
)
}
pub(crate) fn put_op(collection: &str, doc_id: &str, vector: &[f32]) -> WriteOp {
WriteOp::Put {
ns: Namespace::Vector,
key: key(collection, doc_id),
value: encode(vector),
}
}
pub(crate) async fn remove<S: StorageEngine>(
storage: &S,
collection: &str,
doc_id: &str,
) -> Result<(), LiteError> {
storage
.delete(Namespace::Vector, &key(collection, doc_id))
.await
}
pub(crate) async fn list_collections<S: StorageEngine>(
storage: &S,
) -> Result<Vec<String>, LiteError> {
let rows = storage
.scan_prefix(Namespace::Vector, ROW_PREFIX.as_bytes())
.await?;
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut names: Vec<String> = Vec::new();
for (row_key, _) in &rows {
let Some((index_key, _)) = parse_key(row_key) else {
continue;
};
if seen.insert(index_key) {
names.push(index_key.to_owned());
}
}
Ok(names)
}
pub(crate) async fn load_collection<S: StorageEngine>(
storage: &S,
collection: &str,
) -> Result<Vec<(String, Vec<f32>)>, LiteError> {
let prefix = index_prefix(collection);
let rows = storage.scan_prefix(Namespace::Vector, &prefix).await?;
let mut out = Vec::with_capacity(rows.len());
for (row_key, value) in rows {
let Some(doc_id) = row_key
.get(prefix.len()..)
.and_then(|b| std::str::from_utf8(b).ok())
else {
tracing::warn!(
collection,
"durable vector row has a non-UTF-8 key; skipping"
);
continue;
};
let Some(vector) = decode(&value) else {
tracing::warn!(
collection,
doc_id,
bytes = value.len(),
"durable vector row is not a whole number of f32s; skipping"
);
continue;
};
out.push((doc_id.to_owned(), vector));
}
Ok(out)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn rebuild_index<S: StorageEngine>(
storage: &S,
collection: &str,
template: Option<(usize, crate::engine::vector::HnswParams)>,
) -> Result<
Option<(
crate::engine::vector::HnswIndex,
crate::engine::vector::IndexIdMap,
)>,
LiteError,
> {
use crate::engine::vector::{HnswIndex, HnswParams};
let rows = load_collection(storage, collection).await?;
if rows.is_empty() {
return Ok(None);
}
let (dim, params) = match template {
Some(t) => t,
None => (rows[0].1.len(), HnswParams::default()),
};
let mut index = HnswIndex::new(dim, params);
let mut id_map = crate::engine::vector::IndexIdMap::default();
for (doc_id, vector) in rows {
if vector.len() != dim {
tracing::warn!(
collection,
doc_id,
expected = dim,
found = vector.len(),
"durable vector has the wrong dimension for its collection; skipping"
);
continue;
}
let internal_id = index.len() as u32;
if let Err(e) = index.insert(vector) {
tracing::warn!(collection, doc_id, error = %e, "durable vector insert failed; skipping");
continue;
}
id_map.bind(&doc_id, internal_id);
}
Ok(Some((index, id_map)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_a_vector() {
let v = vec![1.0_f32, -2.5, 0.0, 3.25];
assert_eq!(decode(&encode(&v)).expect("decodes"), v);
}
#[test]
fn rejects_a_truncated_row() {
let mut bytes = encode(&[1.0_f32, 2.0]);
bytes.pop();
assert!(decode(&bytes).is_none());
assert!(decode(&[]).is_none());
}
}