Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ a low-latency, memory-efficient, distributed cache written in Rust. designed to
- **key commands** — DEL, EXISTS, EXPIRE, TTL, PEXPIRE, PTTL, PERSIST, TYPE, SCAN, KEYS, RENAME
- **server commands** — PING, ECHO, INFO, DBSIZE, FLUSHDB, BGSAVE, BGREWRITEAOF, AUTH, QUIT
- **pub/sub** — SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, plus PUBSUB introspection
- **vector similarity search** — HNSW-backed approximate nearest neighbor search with cosine, L2, and inner product metrics (compile with `--features vector`)
- **protobuf storage** — schema-validated protobuf values with field-level access (compile with `--features protobuf`)
- **authentication** — `--requirepass` for redis-compatible AUTH (legacy and username/password forms)
- **tls support** — redis-compatible TLS on a separate port, with optional mTLS for client certificates
Expand Down Expand Up @@ -144,6 +145,46 @@ cargo build --release --features protobuf

field-level operations decode/mutate/re-encode on the server, so clients don't need protobuf libraries for simple reads and writes. nested paths use dot notation (e.g., `address.city`). complex types (repeated, map, nested messages) require `PROTO.GET`/`PROTO.SET` for full replacement.

## vector similarity search

ember supports HNSW-backed approximate nearest neighbor search for building recommendation systems, semantic search, and RAG pipelines. compile with `--features vector` to enable.

```bash
# build with vector support
cargo build --release --features vector
```

**commands**:

| command | description |
|---------|-------------|
| `VADD key element f32 [f32 ...] [METRIC COSINE\|L2\|IP] [QUANT F32\|F16\|Q8] [M n] [EF n]` | add a vector to the set |
| `VSIM key f32 [f32 ...] COUNT k [EF n] [WITHSCORES]` | k nearest neighbors |
| `VREM key element` | remove a vector |
| `VGET key element` | retrieve stored vector values |
| `VCARD key` | number of vectors in the set |
| `VDIM key` | dimensionality of the vector set |
| `VINFO key` | metadata: dim, count, metric, quantization, M, ef |

index configuration (METRIC, QUANT, M, EF) is set on the first VADD and locked after that. dimension is inferred from the first vector's length. each key owns its own independent HNSW index.

```bash
# store some embeddings
VADD docs doc1 0.1 0.2 0.3 METRIC COSINE
VADD docs doc2 0.9 0.1 0.0
VADD docs doc3 0.0 0.8 0.2

# find 2 nearest neighbors
VSIM docs 0.1 0.3 0.2 COUNT 2 WITHSCORES
# => 1) "doc1" 2) "0.05" 3) "doc3" 4) "0.12"

VCARD docs # => (integer) 3
VDIM docs # => (integer) 3
VINFO docs # => metric, quantization, dim, count, M, ef
```

distance metrics: **COSINE** (default), **L2** (squared euclidean), **IP** (inner product). quantization: **F32** (default), **F16** (half precision), **Q8** (8-bit integer). lower precision uses less memory at a small accuracy cost.

## configuration

| flag | default | description |
Expand Down Expand Up @@ -257,7 +298,7 @@ contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
| 4 | clustering (raft, gossip, slots, migration) | ✅ complete |
| 5 | developer experience (observability, CLI, clients) | 🚧 in progress |

**current**: 94 commands, 989 tests, ~21k lines of code (excluding tests)
**current**: 101 commands, 796+ tests, ~22k lines of code (excluding tests)

## security

Expand Down
10 changes: 5 additions & 5 deletions crates/ember-core/src/keyspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1926,17 +1926,17 @@ impl Keyspace {
self.entries.insert(key.to_owned(), Entry::new(value, None));
}

let entry = self
.entries
.get_mut(key)
.expect("just inserted or verified");
let entry = match self.entries.get_mut(key) {
Some(e) => e,
None => return Err(VectorWriteError::IndexError("entry missing".into())),
};
let old_entry_size = memory::entry_size(key, &entry.value);

let added = match entry.value {
Value::Vector(ref mut vs) => vs
.add(element.clone(), &vector)
.map_err(|e| VectorWriteError::IndexError(e.to_string()))?,
_ => unreachable!(),
_ => return Err(VectorWriteError::WrongType),
};
entry.touch();

Expand Down
148 changes: 134 additions & 14 deletions crates/ember-core/src/types/vector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ pub enum VectorError {
#[error("dimension mismatch: index has {expected}, got {got}")]
DimensionMismatch { expected: usize, got: usize },

#[error("vector contains non-finite value (NaN or infinity)")]
NonFinite,

#[error("usearch error: {0}")]
Index(String),
}
Expand Down Expand Up @@ -227,7 +230,7 @@ impl VectorSet {
/// Adds or replaces a vector for the given element name.
///
/// Returns `true` if a new element was added, `false` if an existing
/// element was updated.
/// element was updated. Rejects vectors containing NaN or infinity.
pub fn add(&mut self, element: String, vector: &[f32]) -> Result<bool, VectorError> {
if vector.len() != self.dim {
return Err(VectorError::DimensionMismatch {
Expand All @@ -236,6 +239,10 @@ impl VectorSet {
});
}

if vector.iter().any(|v| !v.is_finite()) {
return Err(VectorError::NonFinite);
}

// ensure capacity (double when full, amortized O(1))
if self.index.size() >= self.index.capacity() {
let new_cap = (self.index.capacity() * 2).max(64);
Expand All @@ -253,7 +260,7 @@ impl VectorSet {
false
} else {
let key = self.next_key;
self.next_key += 1;
self.next_key = self.next_key.wrapping_add(1);
self.index
.add(key, vector)
.map_err(|e| VectorError::Index(e.to_string()))?;
Expand All @@ -280,10 +287,13 @@ impl VectorSet {
}
}

/// Default search beam width. Used when the caller doesn't specify one.
const DEFAULT_EF_SEARCH: usize = 64;

/// Searches for the k nearest neighbors of the given query vector.
///
/// `ef_search` controls the search beam width (higher = more accurate,
/// slower). Pass 0 to use usearch's default.
/// slower). Pass 0 to use the default (64).
pub fn search(
&self,
query: &[f32],
Expand All @@ -297,14 +307,22 @@ impl VectorSet {
});
}

if query.iter().any(|v| !v.is_finite()) {
return Err(VectorError::NonFinite);
}

if self.elements.is_empty() {
return Ok(Vec::new());
}

// temporarily adjust search expansion if requested
if ef_search > 0 {
self.index.change_expansion_search(ef_search);
}
// always set expansion_search explicitly so a previous call's value
// doesn't leak into this search
let ef = if ef_search > 0 {
ef_search
} else {
Self::DEFAULT_EF_SEARCH
};
self.index.change_expansion_search(ef);

let matches = self
.index
Expand Down Expand Up @@ -427,26 +445,53 @@ impl fmt::Debug for VectorSet {
}
}

impl Clone for VectorSet {
fn clone(&self) -> Self {
// rebuild the index from scratch — usearch Index doesn't implement Clone
impl VectorSet {
/// Fallible clone — rebuilds the HNSW index from scratch.
///
/// Usearch's `Index` doesn't implement Clone, so we create a new index
/// with the same config and re-insert every vector. Returns an error
/// if the index can't be created or a vector fails to insert.
pub fn try_clone(&self) -> Result<Self, VectorError> {
let mut new = Self::new(
self.dim,
self.metric,
self.quantization,
self.connectivity,
self.expansion_add,
)
.expect("clone: failed to create index with same config");
)?;

for (name, &key) in &self.elements {
let mut buffer = vec![0.0f32; self.dim];
if self.index.get(key, &mut buffer).is_ok() {
let _ = new.add(name.clone(), &buffer);
new.add(name.clone(), &buffer)?;
}
}

new
Ok(new)
}
}

impl Clone for VectorSet {
fn clone(&self) -> Self {
// try_clone rebuilds the index from scratch. if it fails (e.g. OOM
// inside usearch), return an empty set with the same config rather
// than panicking. data loss is preferable to a server crash.
self.try_clone().unwrap_or_else(|_| {
Self::new(
self.dim,
self.metric,
self.quantization,
self.connectivity,
self.expansion_add,
)
.unwrap_or_else(|_| {
// if we can't even create an empty index with the same
// config, fall back to the smallest possible valid set.
// this only happens under extreme memory pressure.
Self::new(self.dim, self.metric, self.quantization, 2, 2)
.expect("failed to allocate even a minimal index — system is out of memory")
})
})
}
}

Expand Down Expand Up @@ -639,4 +684,79 @@ mod tests {
let vec = vs.get("a").unwrap();
assert!((vec[0] - 1.0).abs() < 0.01);
}

#[test]
fn reject_nan_in_add() {
let mut vs = make_set(3);
let err = vs.add("bad".into(), &[1.0, f32::NAN, 0.0]).unwrap_err();
assert!(matches!(err, VectorError::NonFinite));
assert_eq!(vs.len(), 0);
}

#[test]
fn reject_infinity_in_add() {
let mut vs = make_set(3);
let err = vs
.add("bad".into(), &[f32::INFINITY, 0.0, 0.0])
.unwrap_err();
assert!(matches!(err, VectorError::NonFinite));
}

#[test]
fn reject_nan_in_search() {
let mut vs = make_set(3);
vs.add("a".into(), &[1.0, 0.0, 0.0]).unwrap();
let err = vs.search(&[f32::NAN, 0.0, 0.0], 1, 0).unwrap_err();
assert!(matches!(err, VectorError::NonFinite));
}

#[test]
fn search_ef_does_not_leak_between_calls() {
let mut vs = make_set(3);
vs.add("a".into(), &[1.0, 0.0, 0.0]).unwrap();
vs.add("b".into(), &[0.0, 1.0, 0.0]).unwrap();

// first search with high ef
let r1 = vs.search(&[0.9, 0.1, 0.0], 1, 200).unwrap();
assert_eq!(r1.len(), 1);

// second search with default ef should still work correctly
let r2 = vs.search(&[0.9, 0.1, 0.0], 1, 0).unwrap();
assert_eq!(r2.len(), 1);
assert_eq!(r2[0].element, r1[0].element);
}

#[test]
fn try_clone_returns_ok() {
let mut vs = make_set(3);
vs.add("a".into(), &[1.0, 2.0, 3.0]).unwrap();

let cloned = vs.try_clone().unwrap();
assert_eq!(cloned.len(), 1);
assert_eq!(cloned.get("a").unwrap(), vec![1.0, 2.0, 3.0]);
}

#[test]
fn u8_round_trip_metric() {
for metric in [
DistanceMetric::Cosine,
DistanceMetric::L2,
DistanceMetric::InnerProduct,
] {
let byte: u8 = metric.into();
assert_eq!(DistanceMetric::from_u8(byte), metric);
}
}

#[test]
fn u8_round_trip_quantization() {
for quant in [
QuantizationType::F32,
QuantizationType::F16,
QuantizationType::I8,
] {
let byte: u8 = quant.into();
assert_eq!(QuantizationType::from_u8(byte), quant);
}
}
}
46 changes: 46 additions & 0 deletions crates/ember-persistence/src/aof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1531,6 +1531,52 @@ mod tests {
assert_eq!(rec, decoded);
}

#[cfg(feature = "vector")]
#[test]
fn record_round_trip_vadd() {
let rec = AofRecord::VAdd {
key: "embeddings".into(),
element: "doc1".into(),
vector: vec![0.1, 0.2, 0.3],
metric: 0, // cosine
quantization: 0, // f32
connectivity: 16,
expansion_add: 64,
};
let bytes = rec.to_bytes().unwrap();
let decoded = AofRecord::from_bytes(&bytes).unwrap();
assert_eq!(rec, decoded);
}

#[cfg(feature = "vector")]
#[test]
fn record_round_trip_vadd_high_dim() {
let rec = AofRecord::VAdd {
key: "vecs".into(),
element: "e".into(),
vector: vec![0.0; 1536], // typical embedding dimension
metric: 1, // l2
quantization: 1, // f16
connectivity: 32,
expansion_add: 128,
};
let bytes = rec.to_bytes().unwrap();
let decoded = AofRecord::from_bytes(&bytes).unwrap();
assert_eq!(rec, decoded);
}

#[cfg(feature = "vector")]
#[test]
fn record_round_trip_vrem() {
let rec = AofRecord::VRem {
key: "embeddings".into(),
element: "doc1".into(),
};
let bytes = rec.to_bytes().unwrap();
let decoded = AofRecord::from_bytes(&bytes).unwrap();
assert_eq!(rec, decoded);
}

#[cfg(feature = "encryption")]
mod encrypted {
use super::*;
Expand Down
Loading
Loading