`MemoryConfig::quantized_index` now defaults to `true`. It holds a quarter of the index memory and, with the exact re-score, is faster at equal recall on every configuration measured: 1.63x the queries per second on x86-64 (AVX2) and 1.18x on a Raspberry Pi 5 (NEON SDOT), with builds 1.8x and 2.3x faster. The one argument for keeping it off — that int8 search was slower on ARM — did not survive being measured. Existing stores do not change. A store written with v2.6.0 or later keeps its persisted setting. One written before the setting existed has no stored value, and it loads as `false` rather than as the new default, so reopening it never changes how its index is held. That case is guarded by a real store written with the v2.5.0 CLI, committed as `tests/fixtures/store_v2_5_0.h5` (6.8 KB): the test asserts it reopens with an f32 index and still searches, and it fails if the load default is changed to `true`. The CLI needed more than a new default. `create --quantized-index` assigned its value straight into the config, so under the new default every CLI-created store would have been forced back to f32 unless the caller knew to ask for int8. It is replaced by `--f32-index`, which only ever switches the default off; `--quantized-index` is still accepted, hidden, as a no-op, and the two conflict. The whole agent suite passes under the new default, including the brute-force recall oracle, now running on int8 plus re-score without being asked to. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
347 lines
12 KiB
Rust
347 lines
12 KiB
Rust
//! Integration tests for the optional HNSW-accelerated vector search path.
|
|
//!
|
|
//! These only run when the crate is built with `--features hnsw`. They drive the
|
|
//! real `HDF5Memory` API (save / save_batch / delete / hybrid_search) and check
|
|
//! the approximate results against a brute-force cosine oracle, plus confirm that
|
|
//! deletions are honoured end-to-end.
|
|
#![cfg(feature = "hnsw")]
|
|
|
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
|
use tempfile::TempDir;
|
|
|
|
/// Deterministic splitmix64 so tests are reproducible without an RNG crate.
|
|
fn splitmix64(state: &mut u64) -> u64 {
|
|
*state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
|
let mut z = *state;
|
|
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
|
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
|
z ^ (z >> 31)
|
|
}
|
|
|
|
fn make_vector(seed: &mut u64, dim: usize) -> Vec<f32> {
|
|
(0..dim)
|
|
.map(|_| (splitmix64(seed) >> 40) as f32 / 16_777_216.0 - 0.5)
|
|
.collect()
|
|
}
|
|
|
|
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
|
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
|
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
if na == 0.0 || nb == 0.0 {
|
|
0.0
|
|
} else {
|
|
dot / (na * nb)
|
|
}
|
|
}
|
|
|
|
fn entry(chunk: &str, embedding: Vec<f32>, tags: &str) -> MemoryEntry {
|
|
MemoryEntry {
|
|
chunk: chunk.to_string(),
|
|
embedding,
|
|
source_channel: "test".to_string(),
|
|
timestamp: 0.0,
|
|
session_id: "s".to_string(),
|
|
tags: tags.to_string(),
|
|
}
|
|
}
|
|
|
|
fn new_memory(dir: &TempDir, dim: usize) -> HDF5Memory {
|
|
let config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", dim);
|
|
HDF5Memory::create(config).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn hnsw_matches_bruteforce_oracle() {
|
|
let dir = TempDir::new().unwrap();
|
|
let dim = 16;
|
|
let n = 250;
|
|
let mut mem = new_memory(&dir, dim);
|
|
|
|
let mut seed = 0xC0FF_EE12_3456_789A;
|
|
let vectors: Vec<Vec<f32>> = (0..n).map(|_| make_vector(&mut seed, dim)).collect();
|
|
for (i, v) in vectors.iter().enumerate() {
|
|
mem.save(entry(&format!("chunk {i}"), v.clone(), &format!("k{i}")))
|
|
.unwrap();
|
|
}
|
|
|
|
// Vector-only query: keyword weight 0 isolates the HNSW vector stage.
|
|
let query = make_vector(&mut seed, dim);
|
|
let k = 10;
|
|
let results = mem.hybrid_search(&query, "", 1.0, 0.0, k);
|
|
assert_eq!(results.len(), k, "should return k results");
|
|
|
|
// Brute-force cosine top-k oracle.
|
|
let mut oracle: Vec<(usize, f32)> = vectors
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, v)| (i, cosine(&query, v)))
|
|
.collect();
|
|
oracle.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
|
let oracle_ids: std::collections::HashSet<usize> =
|
|
oracle.iter().take(k).map(|(i, _)| *i).collect();
|
|
let hnsw_ids: std::collections::HashSet<usize> = results.iter().map(|r| r.index).collect();
|
|
|
|
let overlap = oracle_ids.intersection(&hnsw_ids).count();
|
|
assert!(
|
|
overlap >= 7,
|
|
"HNSW recall too low vs brute force: {overlap}/{k} (hnsw={hnsw_ids:?})"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deleted_entry_excluded_from_search() {
|
|
let dir = TempDir::new().unwrap();
|
|
let dim = 8;
|
|
let mut mem = new_memory(&dir, dim);
|
|
|
|
let mut seed = 42;
|
|
let vectors: Vec<Vec<f32>> = (0..60).map(|_| make_vector(&mut seed, dim)).collect();
|
|
for (i, v) in vectors.iter().enumerate() {
|
|
mem.save(entry(&format!("c{i}"), v.clone(), &format!("t{i}")))
|
|
.unwrap();
|
|
}
|
|
|
|
// Query exactly equal to vector 5 — it must be the top hit.
|
|
let query = vectors[5].clone();
|
|
let top = mem.hybrid_search(&query, "", 1.0, 0.0, 1);
|
|
assert_eq!(top[0].index, 5, "exact match should rank first");
|
|
|
|
mem.delete(5).unwrap();
|
|
|
|
let after = mem.hybrid_search(&query, "", 1.0, 0.0, 5);
|
|
assert!(
|
|
after.iter().all(|r| r.index != 5),
|
|
"deleted entry must not appear in results"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn incremental_inserts_after_search_are_found() {
|
|
let dir = TempDir::new().unwrap();
|
|
let dim = 8;
|
|
let mut mem = new_memory(&dir, dim);
|
|
|
|
let mut seed = 7;
|
|
// First batch, then a search to force the index to build.
|
|
for i in 0..40 {
|
|
let v = make_vector(&mut seed, dim);
|
|
mem.save(entry(&format!("a{i}"), v, &format!("a{i}")))
|
|
.unwrap();
|
|
}
|
|
let _ = mem.hybrid_search(&make_vector(&mut seed, dim), "", 1.0, 0.0, 5);
|
|
|
|
// Now insert a distinctive vector incrementally and confirm we can find it.
|
|
let needle = vec![10.0f32; dim];
|
|
let idx = mem.save(entry("needle", needle.clone(), "needle")).unwrap();
|
|
let hits = mem.hybrid_search(&needle, "", 1.0, 0.0, 1);
|
|
assert_eq!(
|
|
hits[0].index, idx,
|
|
"incrementally inserted vector must be found"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn save_batch_then_search_is_consistent() {
|
|
let dir = TempDir::new().unwrap();
|
|
let dim = 8;
|
|
let mut mem = new_memory(&dir, dim);
|
|
|
|
let mut seed = 99;
|
|
let vectors: Vec<Vec<f32>> = (0..50).map(|_| make_vector(&mut seed, dim)).collect();
|
|
let entries: Vec<MemoryEntry> = vectors
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, v)| entry(&format!("b{i}"), v.clone(), &format!("b{i}")))
|
|
.collect();
|
|
mem.save_batch(entries).unwrap();
|
|
|
|
// Exact-match queries should resolve to themselves after a batch insert.
|
|
for probe in [0usize, 17, 49] {
|
|
let hits = mem.hybrid_search(&vectors[probe], "", 1.0, 0.0, 1);
|
|
assert_eq!(
|
|
hits[0].index, probe,
|
|
"batch-inserted vector {probe} not found"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn quantized_index_matches_the_f32_index_after_re_scoring() {
|
|
// A quantised index holds approximate vectors, but the store still has the
|
|
// exact ones, so the query path re-scores the candidate pool before
|
|
// fusion. The results a caller sees should therefore be the same.
|
|
let dim = 64;
|
|
let n = 400;
|
|
let mut seed = 0x5EED_1234_5678_9ABC;
|
|
let vectors: Vec<Vec<f32>> = (0..n).map(|_| make_vector(&mut seed, dim)).collect();
|
|
let queries: Vec<Vec<f32>> = (0..20).map(|_| make_vector(&mut seed, dim)).collect();
|
|
|
|
let build = |dir: &TempDir, quantized: bool| {
|
|
let mut config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", dim);
|
|
config.quantized_index = quantized;
|
|
let mut mem = HDF5Memory::create(config).unwrap();
|
|
for (i, v) in vectors.iter().enumerate() {
|
|
mem.save(entry(&format!("chunk {i}"), v.clone(), &format!("k{i}")))
|
|
.unwrap();
|
|
}
|
|
mem
|
|
};
|
|
|
|
let exact_dir = TempDir::new().unwrap();
|
|
let quant_dir = TempDir::new().unwrap();
|
|
let mut exact = build(&exact_dir, false);
|
|
let mut quantized = build(&quant_dir, true);
|
|
|
|
let k = 10;
|
|
let mut agree = 0;
|
|
for q in &queries {
|
|
let want: Vec<usize> = exact
|
|
.hybrid_search(q, "", 1.0, 0.0, k)
|
|
.iter()
|
|
.map(|r| r.index)
|
|
.collect();
|
|
agree += quantized
|
|
.hybrid_search(q, "", 1.0, 0.0, k)
|
|
.iter()
|
|
.filter(|r| want.contains(&r.index))
|
|
.count();
|
|
}
|
|
let overlap = agree as f64 / (k * queries.len()) as f64;
|
|
assert!(
|
|
overlap >= 0.95,
|
|
"quantised store should match the f32 one: {overlap}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn quantized_index_setting_survives_a_reopen() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("mem.h5");
|
|
let mut config = MemoryConfig::new(path.clone(), "agent", 8);
|
|
config.quantized_index = true;
|
|
let mut mem = HDF5Memory::create(config).unwrap();
|
|
let mut seed = 7;
|
|
for i in 0..30 {
|
|
mem.save(entry(&format!("c{i}"), make_vector(&mut seed, 8), "t"))
|
|
.unwrap();
|
|
}
|
|
mem.flush_wal().unwrap();
|
|
drop(mem);
|
|
|
|
// Reopening must not silently quadruple the index's memory, so the flag
|
|
// is part of the stored config rather than a per-session choice.
|
|
let reopened = HDF5Memory::open(&path).unwrap();
|
|
assert!(reopened.config().quantized_index);
|
|
}
|
|
|
|
#[test]
|
|
fn hnsw_parameters_are_configurable_and_persisted() {
|
|
// The graph degree and both candidate-list sizes used to be constants, so
|
|
// a deployment could not trade recall against memory or speed at all.
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("mem.h5");
|
|
let mut config = MemoryConfig::new(path.clone(), "agent", 16);
|
|
config.hnsw_m = 8;
|
|
config.hnsw_ef_construction = 32;
|
|
config.hnsw_ef_search = 128;
|
|
let mut mem = HDF5Memory::create(config).unwrap();
|
|
|
|
let mut seed = 99;
|
|
let vectors: Vec<Vec<f32>> = (0..300).map(|_| make_vector(&mut seed, 16)).collect();
|
|
for (i, v) in vectors.iter().enumerate() {
|
|
mem.save(entry(&format!("c{i}"), v.clone(), "t")).unwrap();
|
|
}
|
|
// Still correct with a smaller graph: an exact match must rank first.
|
|
let top = mem.hybrid_search(&vectors[42], "", 1.0, 0.0, 1);
|
|
assert_eq!(top[0].index, 42);
|
|
|
|
mem.flush_wal().unwrap();
|
|
drop(mem);
|
|
let reopened = HDF5Memory::open(&path).unwrap();
|
|
assert_eq!(reopened.config().hnsw_m, 8);
|
|
assert_eq!(reopened.config().hnsw_ef_construction, 32);
|
|
assert_eq!(reopened.config().hnsw_ef_search, 128);
|
|
}
|
|
|
|
#[test]
|
|
fn degenerate_hnsw_parameters_do_not_panic() {
|
|
// `clawhdf5-ann` asserts m >= 2, so a zero from a config file — or from a
|
|
// caller who assumed 0 meant "default" — would abort the process inside
|
|
// the index builder. The store clamps instead.
|
|
let dir = TempDir::new().unwrap();
|
|
let mut config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", 8);
|
|
config.hnsw_m = 0;
|
|
config.hnsw_ef_construction = 0;
|
|
config.hnsw_ef_search = 1;
|
|
let mut mem = HDF5Memory::create(config).unwrap();
|
|
|
|
let mut seed = 5;
|
|
let vectors: Vec<Vec<f32>> = (0..50).map(|_| make_vector(&mut seed, 8)).collect();
|
|
for (i, v) in vectors.iter().enumerate() {
|
|
mem.save(entry(&format!("c{i}"), v.clone(), "t")).unwrap();
|
|
}
|
|
let results = mem.hybrid_search(&vectors[7], "", 1.0, 0.0, 5);
|
|
assert_eq!(results[0].index, 7, "exact match should still rank first");
|
|
}
|
|
|
|
#[test]
|
|
fn new_stores_default_to_the_quantized_index() {
|
|
// int8 is the default because it is smaller and, with an exact re-score,
|
|
// faster at equal recall on every platform measured (see BENCHMARKS.md).
|
|
let dir = TempDir::new().unwrap();
|
|
let config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", 8);
|
|
assert!(config.quantized_index);
|
|
|
|
let path = config.path.clone();
|
|
let mut mem = HDF5Memory::create(config).unwrap();
|
|
let mut seed = 3;
|
|
let vectors: Vec<Vec<f32>> = (0..40).map(|_| make_vector(&mut seed, 8)).collect();
|
|
for (i, v) in vectors.iter().enumerate() {
|
|
mem.save(entry(&format!("c{i}"), v.clone(), "t")).unwrap();
|
|
}
|
|
assert_eq!(
|
|
mem.hybrid_search(&vectors[11], "", 1.0, 0.0, 1)[0].index,
|
|
11
|
|
);
|
|
mem.flush_wal().unwrap();
|
|
drop(mem);
|
|
assert!(HDF5Memory::open(&path).unwrap().config().quantized_index);
|
|
}
|
|
|
|
#[test]
|
|
fn a_store_written_before_the_setting_existed_stays_f32() {
|
|
// `store_v2_5_0.h5` was written by the v2.5.0 CLI, before
|
|
// `quantized_index` or the HNSW parameters were persisted, so it carries
|
|
// none of them. Flipping the default for new stores must not reach back
|
|
// and change how an existing store's index is held.
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("legacy.h5");
|
|
std::fs::copy(
|
|
concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/store_v2_5_0.h5"
|
|
),
|
|
&path,
|
|
)
|
|
.unwrap();
|
|
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
assert!(
|
|
!bytes.windows(15).any(|w| w == b"quantized_index"),
|
|
"the fixture must predate the setting, or it tests nothing"
|
|
);
|
|
|
|
let mut mem = HDF5Memory::open(&path).unwrap();
|
|
assert!(
|
|
!mem.config().quantized_index,
|
|
"an old store must reopen with an f32 index"
|
|
);
|
|
assert_eq!(mem.config().hnsw_m, 16);
|
|
assert_eq!(mem.config().hnsw_ef_construction, 64);
|
|
assert_eq!(mem.count(), 6);
|
|
// And it still searches: entry 3's own embedding finds it first.
|
|
let hit = mem.hybrid_search(&[3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], "", 1.0, 0.0, 1);
|
|
assert_eq!(hit[0].index, 3);
|
|
}
|