Search options (source filters, re-ranking, confidence); float16 default #5

Open
osobh wants to merge 3 commits from feat/search-options into main
6 changed files with 109 additions and 25 deletions
Showing only changes of commit 5c8323cb1e - Show all commits
+17 -6
View File
@@ -11,12 +11,18 @@
each checkpoint, so it becomes readable at its next checkpoint on this each checkpoint, so it becomes readable at its next checkpoint on this
version; other files with `f32` or empty datasets need rewriting. Details in version; other files with `f32` or empty datasets need rewriting. Details in
`docs/known-issues.md`. `docs/known-issues.md`.
- **`MemoryConfig::float16` now does what it says.** It was persisted and - **New stores store embeddings as half precision by default.**
otherwise ignored; embeddings were always stored as `f32`. A store created `MemoryConfig::float16` was persisted and otherwise ignored; it now writes
with it on now writes half-precision embeddings (48% smaller files) and `float16` embeddings (48% smaller files at 100K) and rounds each embedding
rounds embeddings to half precision as they are saved. A store that already to half precision as it is saved — and it defaults to `true` for new
had `float16 = true` rounds its embeddings when next opened and writes them stores. On the full LongMemEval haystack with real MiniLM embeddings every
as `float16` at its next checkpoint. Off by default. retrieval metric matched `f32`. **Existing stores are unaffected**: every
agent store has recorded `float16 = false`, and keeps it (a v2.5.0 fixture
guards this). A store that already had `float16 = true` rounds its
embeddings when next opened and writes them as `float16` at its next
checkpoint. Opt out with `float16 = false` or `create --f32`; the CLI's
`--float16` is still accepted and now a no-op. Values beyond ±65504 are
refused, so keep `f32` for unnormalised vectors.
- **Breaking:** `MemoryError` gained `InvalidEntry`, returned when a - **Breaking:** `MemoryError` gained `InvalidEntry`, returned when a
`float16` store is given an embedding value beyond ±65504. Exhaustive `float16` store is given an embedding value beyond ±65504. Exhaustive
matches need the new arm. matches need the new arm.
@@ -123,6 +129,11 @@
now an error. now an error.
### Defaults ### Defaults
- `clawhdf5-agent`: `MemoryConfig::float16` defaults to `true` for new stores,
measured rather than assumed: identical LongMemEval retrieval on real
embeddings, 48% smaller files and faster checkpoints and opens at 100K.
`clawhdf5-cli create --f32` opts out; like `--f32-index`, it only ever
switches the default off.
- `clawhdf5-agent`: `MemoryConfig::quantized_index` defaults to `true` for new - `clawhdf5-agent`: `MemoryConfig::quantized_index` defaults to `true` for new
stores. The reason it had been off — that int8 search was slower on ARM — stores. The reason it had been off — that int8 search was slower on ARM —
did not survive measurement (see Corrections). Stores that predate the did not survive measurement (see Corrections). Stores that predate the
+6 -4
View File
@@ -87,10 +87,12 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
`export` do). An unreadable WAL (torn header, bad magic) is quarantined to `export` do). An unreadable WAL (torn header, bad magic) is quarantined to
`<store>.h5.wal.corrupt-<ts>` rather than blocking `open()`; a WAL with an `<store>.h5.wal.corrupt-<ts>` rather than blocking `open()`; a WAL with an
unknown *newer* version still fails and is left untouched. unknown *newer* version still fails and is left untouched.
- `MemoryConfig::float16` (off by default, persisted; CLI `create --float16`) - `MemoryConfig::float16` (**on by default** for new stores, persisted;
writes `/memory/embeddings` as IEEE half precision (48% smaller file at existing stores keep their recorded `false` — guarded by the v2.5.0
100K, same recall). `MemoryCache::half_precision` rounds each embedding as fixture in `tests/float16_store.rs`; CLI opt-out is `create --f32`) writes
it enters the cache (push, update, WAL replay, and on load of a store still `/memory/embeddings` as IEEE half precision (48% smaller file at 100K;
LongMemEval with real MiniLM embeddings identical to f32).
`MemoryCache::half_precision` rounds each embedding as it enters the cache (push, update, WAL replay, and on load of a store still
`f32` on disk), so memory and file agree bit for bit; the conversions live `f32` on disk), so memory and file agree bit for bit; the conversions live
in `clawhdf5_format::float16` and must stay the single implementation. in `clawhdf5_format::float16` and must stay the single implementation.
Values beyond ±65504 are `MemoryError::InvalidEntry`. Interop: every file Values beyond ±65504 are `MemoryError::InvalidEntry`. Interop: every file
+12 -8
View File
@@ -89,7 +89,8 @@ breaking change, are in [CHANGELOG.md](CHANGELOG.md).
fix themselves at their next checkpoint. See fix themselves at their next checkpoint. See
[docs/known-issues.md](docs/known-issues.md). [docs/known-issues.md](docs/known-issues.md).
- `MemoryConfig::float16` now stores half-precision embeddings (it was - `MemoryConfig::float16` now stores half-precision embeddings (it was
ignored): 48% smaller files at the same recall. ignored), and is on by default for new stores: 48% smaller files, and
identical LongMemEval retrieval on real embeddings.
- `HDF5Memory::search` with `SearchOptions`: filter by source channel (exact - `HDF5Memory::search` with `SearchOptions`: filter by source channel (exact
filtered top-k, never slower than unfiltered), and opt-in re-ranking and filtered top-k, never slower than unfiltered), and opt-in re-ranking and
confidence rejection, which used to be OpenClaw-only. confidence rejection, which used to be OpenClaw-only.
@@ -256,8 +257,9 @@ retrieval recall reported as QA accuracy typically overstates by 20–30 points.
| 10K | 17.0 MB | 1.7 KB | 2.7 MB (6.2x) | | 10K | 17.0 MB | 1.7 KB | 2.7 MB (6.2x) |
| 100K | 169.8 MB | 1.7 KB | 26.9 MB (6.2x) | | 100K | 169.8 MB | 1.7 KB | 26.9 MB (6.2x) |
With `MemoryConfig::float16` the embeddings take half the space: an agent These figures are `f32` embeddings. New agent stores default to
store of 100K × 384 records is 80.8 MiB instead of 154.0. `MemoryConfig::float16`, which halves them: 100K × 384 records take 80.8 MiB
instead of 154.0.
**In memory** — a store reopened from disk, 384-dim `f32`, measured with a **In memory** — a store reopened from disk, 384-dim `f32`, measured with a
counting allocator ([BENCHMARKS.md § Memory footprint](BENCHMARKS.md#memory-footprint)): counting allocator ([BENCHMARKS.md § Memory footprint](BENCHMARKS.md#memory-footprint)):
@@ -610,12 +612,14 @@ setting existed keep their `f32` index; opt out for new stores with
`quantized_index = false` or `clawhdf5-cli create --f32-index`. See `quantized_index = false` or `clawhdf5-cli create --f32-index`. See
[BENCHMARKS.md § Quantising the index copy](BENCHMARKS.md#quantising-the-index-copy-quantized_index). [BENCHMARKS.md § Quantising the index copy](BENCHMARKS.md#quantising-the-index-copy-quantized_index).
`MemoryConfig::float16` (off by default; CLI `create --float16`) stores the `MemoryConfig::float16` (**on by default** for new stores) stores the
embeddings on disk as IEEE half precision (numpy `float16`): at 100K × 384 the embeddings on disk as IEEE half precision (numpy `float16`): at 100K × 384 the
file drops from 154 to 81 MiB, checkpoints and opens get faster, and vector file drops from 154 to 81 MiB, checkpoints and opens get faster, and on the
recall and search latency do not change. Embeddings are rounded as they are full LongMemEval haystack with real MiniLM embeddings every retrieval metric
saved, so the store searches the same before and after a reopen; values must matches `f32`. Embeddings are rounded as they are saved, so the store searches
lie within ±65504. See the same before and after a reopen; values must lie within ±65504. Existing
stores keep their setting. Opt out with `float16 = false` or
`clawhdf5-cli create --f32` — e.g. for unnormalised vectors. See
[BENCHMARKS.md § float16 embedding storage](BENCHMARKS.md#float16-embedding-storage-memoryconfigfloat16). [BENCHMARKS.md § float16 embedding storage](BENCHMARKS.md#float16-embedding-storage-memoryconfigfloat16).
### `clawhdf5-format` ### `clawhdf5-format`
+8 -1
View File
@@ -136,6 +136,13 @@ pub struct MemoryConfig {
/// so search results are the same before and after a reopen. Values must /// so search results are the same before and after a reopen. Values must
/// lie within ±65504; a save outside that is `MemoryError::InvalidEntry`. /// lie within ±65504; a save outside that is `MemoryError::InvalidEntry`.
/// Fixed when the store is created (persisted in `/meta`). /// Fixed when the store is created (persisted in `/meta`).
///
/// **On by default for new stores**: on the full LongMemEval haystack with
/// real MiniLM embeddings every retrieval metric matched `f32`, and at
/// 100K records the file is 48% smaller (`BENCHMARKS.md`). Existing
/// stores keep the setting they were created with. Set it to `false` for
/// full-precision embeddings, e.g. for unnormalised vectors that may
/// exceed the half-precision range.
pub float16: bool, pub float16: bool,
pub compression: bool, pub compression: bool,
pub compression_level: u32, pub compression_level: u32,
@@ -191,7 +198,7 @@ impl MemoryConfig {
embedding_dim, embedding_dim,
chunk_size: 512, chunk_size: 512,
overlap: 50, overlap: 50,
float16: false, float16: true,
compression: false, compression: false,
compression_level: 0, compression_level: 0,
compact_threshold: 0.3, compact_threshold: 0.3,
@@ -206,3 +206,55 @@ fn wal_replay_rounds_like_a_live_save() {
assert_eq!(recovered.count(), 30); assert_eq!(recovered.count(), 30);
assert_eq!(search_bits(&mut recovered, 77), live); assert_eq!(search_bits(&mut recovered, 77), live);
} }
#[test]
fn new_stores_default_to_float16() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("default.h5");
let mut m = HDF5Memory::create(MemoryConfig::new(path.clone(), "agent", DIM)).unwrap();
assert!(m.config().float16);
m.save_batch((0..10).map(entry).collect()).unwrap();
drop(m);
assert_eq!(embeddings_dtype_and_values(&path).0, "Other(\"float16\")");
assert!(HDF5Memory::open(&path).unwrap().config().float16);
}
#[test]
fn an_existing_f32_store_stays_f32() {
// Written by the v2.5.0 CLI, with `float16 = 0` in /meta (every agent
// store has recorded it). Flipping the default for new stores must not
// reach back and round an existing store's embeddings.
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 before = embeddings_dtype_and_values(&path);
assert_eq!(before.0, "F32");
let mut m = HDF5Memory::open(&path).unwrap();
assert!(!m.config().float16, "an old store must reopen as f32");
let dim = m.config().embedding_dim;
let odd: Vec<f32> = (0..dim).map(|i| 0.1 + i as f32 * 1e-4).collect();
m.save_batch(vec![MemoryEntry {
chunk: "added after the upgrade".into(),
embedding: odd.clone(),
source_channel: "test".into(),
timestamp: 1.0,
session_id: "s".into(),
tags: String::new(),
}])
.unwrap();
drop(m);
// Checkpointed: still f32, the old rows untouched and the new one exact.
let (dtype, values) = embeddings_dtype_and_values(&path);
assert_eq!(dtype, "F32");
assert_eq!(&values[..before.1.len()], before.1.as_slice());
assert_eq!(&values[before.1.len()..], odd.as_slice());
}
+14 -6
View File
@@ -36,10 +36,13 @@ enum Commands {
/// Accepted for compatibility; int8 is now the default /// Accepted for compatibility; int8 is now the default
#[arg(long, hide = true, conflicts_with = "f32_index")] #[arg(long, hide = true, conflicts_with = "f32_index")]
quantized_index: bool, quantized_index: bool,
/// Store embeddings on disk as IEEE half precision (float16): half /// Store embeddings as full-precision f32 instead of the default
/// the bytes, about three significant digits; values must lie within /// half precision (float16: half the bytes, about three significant
/// ±65504 /// digits, values within ±65504)
#[arg(long)] #[arg(long)]
f32: bool,
/// Accepted for compatibility; float16 is now the default
#[arg(long, hide = true, conflicts_with = "f32")]
float16: bool, float16: bool,
}, },
/// Save a memory entry (reads JSON from stdin or --json) /// Save a memory entry (reads JSON from stdin or --json)
@@ -107,11 +110,16 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
wal, wal,
f32_index, f32_index,
quantized_index: _, quantized_index: _,
float16, f32,
float16: _,
} => { } => {
let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim); let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim);
config.wal_enabled = wal; config.wal_enabled = wal;
config.float16 = float16; // As with --f32-index: only ever switch the library default off.
if f32 {
config.float16 = false;
}
let config_float16 = config.float16;
// Only ever switch *off* the library default: assigning the flag // Only ever switch *off* the library default: assigning the flag
// outright would force every CLI-created store back to f32 unless // outright would force every CLI-created store back to f32 unless
// the caller knew to ask for int8. // the caller knew to ask for int8.
@@ -127,7 +135,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
"embedding_dim": dim, "embedding_dim": dim,
"wal_enabled": wal, "wal_enabled": wal,
"quantized_index": config_quantized, "quantized_index": config_quantized,
"float16": float16, "float16": config_float16,
"count": mem.count(), "count": mem.count(),
}); });
println!("{}", serde_json::to_string_pretty(&j)?); println!("{}", serde_json::to_string_pretty(&j)?);