feat(agent): expose the HNSW parameters in MemoryConfig

Graph degree and the build- and query-time candidate list sizes were
constants, so a deployment had no way to trade recall against memory or
query speed. They are now `MemoryConfig::hnsw_m`,
`hnsw_ef_construction` and `hnsw_ef_search`, persisted with the store
and defaulting to exactly the previous behaviour (16, 64, and a query
list that scales with `k`).

Two things the straightforward version would have got wrong:

`clawhdf5-ann` asserts a graph degree of at least 2, so a configured 0 —
from a file, or from a caller reading 0 as "use the default" — aborted
the process inside the index builder. The store clamps instead, and a
test covers it: removing the clamp makes that test panic rather than
fail.

`ef_search` and the candidate pool handed to score fusion were the same
number. Tying the pool to the new setting would mean lowering `ef` for
speed also narrows what fusion sees, quietly degrading hybrid results
through a knob that looks like it only costs time. They are now
independent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-20 17:53:11 -07:00
co-authored by Claude Opus 5
parent 16c9ee0554
commit e5e087f9ab
7 changed files with 141 additions and 9 deletions
+48 -8
View File
@@ -65,12 +65,6 @@ use cache::MemoryCache;
use clawhdf5_ann::{DistanceMetric, HnswIndex, Storage};
use ephemeral::{EphemeralConfig, EphemeralStore};
/// HNSW construction parameters used for the agent's vector index. Cosine is the
/// agent's similarity metric, so the index is built with cosine distance.
#[cfg(feature = "hnsw")]
const HNSW_M: usize = 16;
#[cfg(feature = "hnsw")]
const HNSW_EF_CONSTRUCTION: usize = 64;
// EphemeralEntry and EphemeralStats are part of the crate public API via
// the `ephemeral` module; they are not needed directly in lib.rs internals.
#[allow(unused_imports)]
@@ -150,6 +144,23 @@ pub struct MemoryConfig {
///
/// Has no effect without the `hnsw` feature.
pub quantized_index: bool,
/// HNSW graph degree. Higher means a denser graph: better recall, more
/// memory and slower builds. Clamped to at least 2 when the index is
/// built, since a graph with fewer connections is not one.
///
/// Has no effect without the `hnsw` feature.
pub hnsw_m: usize,
/// Candidate list size while building the HNSW graph. Higher means a
/// better graph and a slower build; it does not affect query cost.
///
/// Has no effect without the `hnsw` feature.
pub hnsw_ef_construction: usize,
/// Candidate list size for a query, trading throughput for recall. `0`
/// keeps the default, which scales with the requested `k`
/// (`max(k * 8, 64)`) so that fusion still sees a useful pool.
///
/// Has no effect without the `hnsw` feature.
pub hnsw_ef_search: usize,
}
impl MemoryConfig {
@@ -172,6 +183,9 @@ impl MemoryConfig {
wal_enabled: true,
wal_max_entries: 500,
quantized_index: false,
hnsw_m: 16,
hnsw_ef_construction: 64,
hnsw_ef_search: 0,
}
}
}
@@ -826,6 +840,32 @@ impl HDF5Memory {
// the index length drifts from the cache length (covering any mutation path
// that doesn't call a hook, e.g. consolidation pushes).
/// Graph degree for the index, never below the 2 the builder requires:
/// a config value of 0 or 1 would otherwise panic inside `clawhdf5-ann`.
#[cfg(feature = "hnsw")]
fn hnsw_m(&self) -> usize {
self.config.hnsw_m.max(2)
}
/// Build-time candidate list size, never below the graph degree — a
/// smaller one cannot fill a node's connections.
#[cfg(feature = "hnsw")]
fn hnsw_ef_construction(&self) -> usize {
self.config.hnsw_ef_construction.max(self.hnsw_m())
}
/// Query-time candidate list size for a `k`-result search. `0` means the
/// default, which scales with `k`.
#[cfg(feature = "hnsw")]
pub(crate) fn hnsw_ef_search(&self, k: usize) -> usize {
let default = (k * 8).max(64);
if self.config.hnsw_ef_search == 0 {
default
} else {
self.config.hnsw_ef_search.max(k)
}
}
/// How the index should store its copy of the vectors, per the config.
#[cfg(feature = "hnsw")]
fn index_storage(&self) -> Storage {
@@ -856,8 +896,8 @@ impl HDF5Memory {
let rows: Vec<Vec<f32>> = self.cache.embeddings.iter().map(<[f32]>::to_vec).collect();
let mut index = HnswIndex::build_with(
&rows,
HNSW_M,
HNSW_EF_CONSTRUCTION,
self.hnsw_m(),
self.hnsw_ef_construction(),
DistanceMetric::Cosine,
self.index_storage(),
);