Skip to content

perf(vector): SIMD SQ8 ADC + exact rerank + churn reliability fixes (deep-review bundles 1-5)#214

Merged
pilotspacex-byte merged 15 commits into
mainfrom
perf/vector-search-optimization
Jul 6, 2026
Merged

perf(vector): SIMD SQ8 ADC + exact rerank + churn reliability fixes (deep-review bundles 1-5)#214
pilotspacex-byte merged 15 commits into
mainfrom
perf/vector-search-optimization

Conversation

@pilotspacex-byte

@pilotspacex-byte pilotspacex-byte commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Vector-engine optimization + reliability branch from the 2026-07-05 deep review (tmp/VECTOR-DEEP-REVIEW.md), validated end-to-end on GCE (both arches, vs v0.5.1 baseline). 8 commits, each independently reviewable:

Commit What
2b9b3792 Hot-path quick wins (QP-1 COW key map, VEC-3, QP-4, XC-5, HQ-4, VEC-5, HQ-6)
846910f6 Correctness bundle: VEC-1 update dedup, XC-SHARD-1 cluster-wide FT.INFO, XC-3 yield-path FilterStrategy, VEC-4 MERGE_RECALL_TOLERANCE knob, VEC-7 KEEP_RAW fail-loud
eb6c1ee8 MEMORY DOCTOR: per-shard KV memory published even when maxmemory unlimited
30ce53e7 HQ-2: SIMD-dispatched SQ8 ADC kernels (NEON / AVX2+FMA / AVX-512F + scalar fallback), math independently re-derived + verified
7df3346f HQ-1: exact rerank stage — f16 sidecar of original vectors per immutable segment; top 4·k beam candidates re-scored with true metric distances
3e27f023 Fix: DEL/UNLINK unindexes vectors on every dispatch path (was SPSC Execute + tokio sharded only — deleted keys matched KNN forever at shards=1)
1261ab83 Fix: update-churn mass-deletion at compact/merge install (dead-window-entry ≠ key-deleted; origin-gated merge tombstone replay; monotonic LSNs in test API)
098cf3e2 Long-run validation harness (scripts/vector-validate.py + scripts/gcloud-vector-soak.sh)

Reliability: 3 defects found by the new churn soak, fixed red→green

  1. DEL resurrection (pre-existing): conn-local paths never called mark_deleted_for_key — 20% of KNN results were deleted keys after 1 min of churn. Wire-level tests in tests/vector_del_unindex.rs.
  2. Compact-install update loss (regression from VEC-1, caught before merge): keys updated-then-compacted vanished — 32% of live keys/minute. A/B vs v0.5.1 + commit bisect + mechanism test test_bg_compact_update_before_freeze_survives_install.
  3. Merge-install tombstone replay loss: source segments' lifetime tombstone sets replayed key_hash-wide killed current copies from sibling segments. test_bg_merge_update_across_segments_survives.

Invariant established: an entry tombstone is not a key deletion — reconcile kills must be scoped (live-sibling check / origin gid), never key_hash-wide.

GCE validation (branch vs v0.5.1, c4a ARM Axion + c3 x86 Sapphire Rapids)

Recall/QPS @20k random-Gaussian 384d unit vectors (single index, COMPACT_THRESHOLD 4096):

Machine Config base R@10 branch R@10 base QPS branch QPS base p50 branch p50
c4a ARM SQ8 0.769 0.771 430 919 (2.1×) 2.33 ms 1.09 ms
c4a ARM TQ4 0.682 0.730 (+4.8pts) 389 475 (+22%) 2.53 ms 2.12 ms
c3 x86 SQ8 0.770 0.772 294 1218 (4.1×) 3.39 ms 0.82 ms
c3 x86 TQ4 0.682 0.730 (+4.8pts) 308 359 (+17%) 3.25 ms 2.79 ms

(Absolute R@10≈0.77 is the documented random-Gaussian-384d concentration artifact — real MiniLM embeddings score 0.96+; identical for both binaries.)

Soak (20 min, appendonly yes, 60/25/10/5 search/update/insert/delete wire churn): 311k ops (ARM) / 377k ops (x86), 0 resurrections across all samples, 0/1000 self-recall lost-probe on both arches, RSS proportional to live growth (20k→36–39k live).

Durability (kill -9 mid-churn, everysec + 5 s margin): 0/5000 settled keys missing, post-recovery recall 0.986, double-crash restart clean — both arches.

Full report: tmp/VECTOR-SOAK-VALIDATION.md; raw verdicts tmp/vec-soak-{c4a,c3}-standard-8.json (both "pass": true).

Local CI parity

Release suite 3996 passed; tokio feature suite 3462 passed; clippy clean on default AND runtime-tokio,jemalloc; cargo fmt --check clean. Memory cost of the rerank sidecar: +2·dim B/vector (opt-out knob is a noted follow-up).

vs RediSearch

The 2026-06 four-feature baseline had Moon winning vector insert 6–20× but trailing RediSearch on search QPS/recall — HQ-1/HQ-2 are the two levers from that review. A fresh head-to-head bench on this branch is running; results will be posted as a comment.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an exact rerank option for more accurate SQ8/TQ4 results after compaction and merges (with sidecar persistence).
    • Added per-index MERGE_RECALL_TOLERANCE.
    • Added intra-query vector search worker pooling via --ft-search-workers (including 0 to disable).
    • Introduced bounded bulk compaction to reduce post-FT.COMPACT query latency/QPS impact.
  • Bug Fixes
    • Improved DEL/UNLINK unindexing consistency across dispatch paths (including transactions/pipelines).
    • Fixed vector update/tombstone handling to prevent duplicates and lost updates across compaction/merges.
    • Improved FT.INFO cluster-wide aggregation; preserved exact-rerank sidecar behavior and rejected unsupported FT.CREATE ... MERGE_MODE KEEP_RAW.
    • Improved memory reporting under unlimited maxmemory.
  • Tests / Tooling
    • Added long-run vector reliability validation plus new SQ8 ADC correctness benchmarks/tests.

Seven verified findings from the 2026-07-05 vector deep review
(tmp/VECTOR-DEEP-REVIEW.md), all mechanical and behavior-preserving:

- QP-1: Arc-wrap VectorIndex.key_hash_to_key. The previous owned HashMap
  (one entry per indexed vector) was deep-cloned on EVERY dense-KNN query
  snapshot and SESSION/RANGE/hybrid path just to resolve k result keys.
  Capture is now an O(1) Arc bump; writers copy-on-write via Arc::make_mut
  (clones at most once per in-flight snapshot, only under concurrent
  read+write).
- VEC-3: delete dead per-insert work. handle_vector_insert(_field) and
  VectorStore::insert_vector heap-allocated an SQ8 quantization
  (vec![0i8; dim]) and computed a norm that append()/append_transactional()
  declared as unused `_vector_sq`/`_norm` params and re-derived internally.
  Params removed from the signatures; all callers swept.
- QP-4: hoist brute-force query prep out of the yield-chunk loop. The
  chunked mutable-segment scan re-allocated its top-k heap and re-derived
  the FWHT query rotation (or SQ8 normalize) every 1024-entry chunk. New
  BruteForceQuery ctx (prepare_brute_force_query + brute_force_scan_mvcc_chunk)
  prepares once per query and shares one heap across chunks; the sync
  full-scan API delegates to the same code, so results stay identical.
- XC-5: stripe VECTOR_SEARCH_TOTAL / VECTOR_TOTAL_VECTORS. Global
  fetch_add counters on every FT.SEARCH/insert ping-pong one cache line
  across shard threads; now 16 padded stripes with a thread-local slot,
  summed only on INFO reads.
- HQ-4: implement aarch64 prefetch_node. The 2-hop dual prefetch was a
  documented no-op on ARM (Moon's primary target); now stable inline-asm
  PRFM PLDL1KEEP mirroring the x86_64 hint pattern (2 neighbor lines +
  3 code lines). SAFETY: PRFM never faults; addresses built with
  wrapping_add so pointer::add's in-bounds contract is not invoked.
- VEC-5: fix latent code_len for SQ8 at compaction.rs:554 (missing
  is_sq8 branch present at the other two sites; was dim+4 instead of dim,
  masked only by a codebook-None early-continue).
- HQ-6: skip the per-row normalize+FWHT sub-centroid sign prep for SQ8
  during compaction — both inner branches unconditionally `continue` for
  SQ8, so the O(n·d log d) rotation work was computed and discarded.

Validation: cargo fmt; clippy clean (default + runtime-tokio,jemalloc);
854 vector lib tests + 281 vector_search command tests + 41 integration
tests (edge cases, stress, segment merge, warm e2e, memory audit,
insert bench) all pass in release.

Refs: tmp/VECTOR-DEEP-REVIEW.md (Bundle 1)
author: Tin Dang
Five verified defects from tmp/VECTOR-DEEP-REVIEW.md, each TDD'd
red -> green:

- VEC-1 update duplication: HSET on an already-indexed key appended a
  second copy instead of replacing. Insert path now tombstones the old
  copy first: O(1) fast path via key_hash_to_global_id +
  mark_deleted_if_key when the old copy lives in the mutable segment,
  scan fallback (mark_deleted_by_key_hash across mutable + immutable
  segments) otherwise. Field-segment inserts use the scan path (no gid
  map there). Red tests reproduced 2-vs-1 and 6-vs-5 duplicate counts;
  green after fix (tests/vector_update_tombstone.rs).

- XC-SHARD-1 FT.INFO under-report: at shards=N, FT.INFO answered from
  the local shard only (~1/N of num_docs). Added scatter_ft_info
  (coordinator SPSC scatter modeled on scatter_invalidate_range) +
  merge_ft_info_responses (additive top-level counters, per-field merge
  keyed by field_name, error propagation), wired in both the sharded
  and monoio handlers. Unit tests in ft_info.rs.

- XC-3 filtered-search strategy on yielding path: the cooperative-yield
  search ignored FilterStrategy and always graph-filtered. Snapshot now
  carries the selectivity-picked strategy; HnswPostFilter searches
  unfiltered with fetch_k = 3k / ef >= 3k and post-filters by bitmap,
  matching the non-yielding path.

- VEC-4 merge recall gate: background GraphUnion tolerance (0.70) is
  now a per-index knob, FT.CONFIG SET/GET MERGE_RECALL_TOLERANCE
  (validated 0.0..=1.0, default unchanged). Raising the default was
  rejected: merge-abort has no backoff, so a tighter default would
  spin futile merge retries on small indexes.

- VEC-7 fail-loud stub: FT.CREATE ... MERGE_MODE KEEP_RAW silently fell
  back to graph-union semantics; now rejected with an explicit error
  until the raw-vector sidecar exists. The separate KEEP_RAW ON boolean
  flag is untouched (round-trip test still passes).

Docs: CLAUDE.md vector section corrected — immutable segments DO merge
(GraphUnion default, threshold 16 / 20% dead, recall-gated); the
forbidden thing is decode+re-encode. FT.INFO cross-shard invariant and
the new MERGE_RECALL_TOLERANCE knob documented.

Refs: tmp/VECTOR-DEEP-REVIEW.md (VEC-1, XC-SHARD-1, XC-3, VEC-4, VEC-7)
author: Tin Dang
MEMORY DOCTOR's "DashTable + entries" line (and the Prometheus KV
memory gauge) read ShardDatabases::memory_per_shard, but the publish on
the 100ms eviction tick was gated on `rt.maxmemory > 0` since the GAP-1
elastic-budget wiring (7329b18, 2026-06-10). Under the default
unlimited maxmemory the atomics stayed at 0 forever, so MEMORY DOCTOR
attributed 100% of RSS to allocator overhead regardless of dataset
size.

The publish is an O(1) accumulator read per database (Database::
estimated_memory returns the used_memory field) plus one Relaxed store,
so it now runs unconditionally on the tick. Elastic-budget recompute
still requires a finite cap and stays gated.

Caught by tests/memory_doctor_response.rs, which asserts DashTable > 0
after loading 1000 keys. The test predates the regression (2026-04-28)
but self-skips in CI (needs a prebuilt release binary + redis-cli), so
the breakage was invisible for three weeks; red -> green locally with
this fix.

author: Tin Dang
SQ8 asymmetric distance previously ran a scalar per-element loop
(query[i] - (min + code[i]*scale))^2 for every beam-search candidate.
Decompose it algebraically so the per-candidate pass is pure
widen+FMA reductions that vectorize cleanly:

  d = SUM(q_i - min - s*c_i)^2
    = (SUMq^2 - 2*min*SUMq + n*min^2)          <- per-QUERY, once
      - 2s*(SUM(q*c) - min*SUMc) + s^2*SUMc^2  <- per-candidate stats

Per-candidate work is exactly three running sums (SUM(q*c), SUMc,
SUMc^2) computed together in one pass: sq8_candidate_stats_scalar is
the portable reference; NEON / AVX2+FMA / AVX-512F kernels widen 16-32
u8 codes per iteration (u8->f32 exact for 0..=255) and are dispatched
through a new DistanceTable::sq8_stats fn pointer resolved ONCE per
query. sq8_l2_from_stats / sq8_ip_from_stats do the O(1) combine.

Wired into: HNSW beam search (both dist closures in
hnsw_search_filtered) and both mutable-segment brute-force paths; the
chunked MVCC scan carries (SUMq, SUMq^2) in BruteForceQuery so they
are computed once per query across all yield chunks, over the PREPARED
(normalized) query buffer the kernel actually sees.

Criterion (aarch64 NEON, dev host; per-candidate distance):
  128d:  39.8ns -> 14.5ns  (2.74x)
  384d: 100.3ns -> 54.4ns  (1.84x)
  768d: 190.7ns -> 120.5ns (1.58x)

Known tradeoff (measured): the decomposition WITHOUT SIMD is ~1.65x
slower than the naive loop (three sums vs one), so the scalar-fallback
tier (pre-AVX2 x86_64, ~2012-era) regresses; all supported targets
(aarch64 NEON baseline, x86_64 AVX2/AVX-512) are wins. An SSE2
sq8_stats kernel is a possible follow-up if pre-AVX2 ever matters.

Verification (math re-derived and reviewed line-by-line, kernels
audited for lane alignment/bounds/tail handling): parity tests pin
scalar-vs-SIMD and stats-vs-naive at dims 1..768 including SIMD-width
tails and negative-min / extreme-scale quantization params; dispatched
end-to-end test reproduces naive ADC to 1e-3 relative. Full release
suite + tokio feature set + clippy both feature sets green.

Implemented with a senior-rust-engineer subagent (worktree); math,
SIMD lane layout, and integration independently verified and the
mutable-segment wiring ported onto the BruteForceQuery chunked-scan
structure by hand.

Refs: tmp/VECTOR-DEEP-REVIEW.md (HQ-2)
author: Tin Dang
Compacted segments ranked candidates purely by quantized ADC distance:
SQ8 got ZERO refinement and TQ4 only estimator-level refinement — the
main recall gap vs engines that keep full-precision vectors
(deep-review HQ-1; RediSearch comparison 0.86 vs 0.96 R@10 at MiniLM
384d).

Design:
- New src/vector/f16.rs: hand-rolled IEEE 754 binary16 conversion
  (round-to-nearest-even, subnormals, overflow->inf, NaN — each pinned
  by unit tests) — no new dependency.
- MutableSegment now retains an f16 copy of every inserted vector in
  BOTH build modes (unlike raw_f32, which is Exact-only) at 2*dim
  bytes/vector; freeze() passes it through FrozenSegment.
- compact() hands the immutable segment its sidecar by BFS permutation
  alone (no re-encode); GraphUnion merge propagates sidecars
  all-or-nothing (a partial sidecar would silently mix exact and ADC
  distances within one segment).
- ImmutableSegment::search/search_filtered re-score the top 4*k
  ADC-ranked beam candidates from the sidecar before top-k truncation
  (beam results arrive nearest-first, so the prefix is the oversample
  set). Conventions match the quantized paths for merge consistency:
  L2 -> true squared L2; Cosine/InnerProduct -> normalized-pair
  squared L2 (2 - 2*<q,x>/|x|). The TQ_prod estimator rerank is
  skipped when a sidecar is present.
- Persistence: raw_f16.bin (u16 LE) per segment directory; a missing
  or size-mismatched file means "no sidecar" — pre-sidecar segment
  dirs load unchanged and degrade to ADC distances (fail-soft), old
  readers ignore the extra file.
- resident_bytes()/MEMORY DOCTOR account the sidecar.

Memory cost: +2*dim B/vector in mutable and compacted segments
(384d = +768 B/vector). Opt-out knob deferred as follow-up.

TDD: tests/vector_exact_rerank.rs written red-first — L2/Cosine/TQ4
distance-exactness (SQ8 ADC error measured ~1.7-2.7e-3 rel, TQ4 up to
18.6%; post-fix within f16 tolerance), recall@10 guard, persistence
roundtrip, legacy-dir fallback, merge preservation. 7/7 green; full
release suite + tokio feature set + clippy both feature sets green.

Refs: tmp/VECTOR-DEEP-REVIEW.md (HQ-1)
author: Tin Dang
The Bundle-5 soak diagnostic (scripts/vector-validate.py) caught deleted
keys resurfacing in FT.SEARCH: after one minute of mixed churn at
--shards 1, 40/200 KNN results were DELETED keys and live-set recall
collapsed 0.985 -> 0.735.

Root cause is the three-dispatch-paths gap: the vector auto-delete hook
(VectorStore::mark_deleted_for_key) existed ONLY on the cross-shard SPSC
Execute arm and the tokio sharded handler. Missing on:

- handler_monoio conn-local writes (the default runtime's ONLY path at
  shards=1 — every DEL was invisible to the index)
- handler_single direct + MULTI/EXEC paths
- shared.rs MULTI batch path
- SPSC PipelineBatch + PipelineBatchSlotted arms

All six sites now call a single parity helper, auto_delete_vectors
(spsc_handler.rs, next to auto_index_hset_public): every path that runs
HSET auto-index runs the DEL/UNLINK tombstone. Arms with neither hook
(ExecuteSlotted, MultiExecute*) can't resurrect — keys they touch were
never auto-indexed — and are left as-is.

TDD: tests/vector_del_unindex.rs written red-first, wire-level (spawns
real servers; store-level tests cannot catch dispatch wiring): DEL,
UNLINK, MULTI/EXEC DEL at shards=1, pipelined DEL at shards=4 — all 4
resurrected before the fix, green after. Soak re-run: resurrections 40
-> 0, live-set recall recovered.

Refs: Bundle-5 reliability validation (vector deep review follow-on)
author: Tin Dang
The Bundle-5 churn soak (60% search / 25% update / 10% insert / 5%
delete over the wire) lost 32% of live keys from the vector index in
one minute: self-recall probes missed 1054/3262 keys whose hashes still
existed in KV, and live-set recall collapsed 0.985 -> 0.685. A/B on the
identical op sequence against v0.5.1 (LOST=0) proved a branch
regression; commit-bisect pinned first-bad to the VEC-1 update path
(846910f). Two install-time reconcile sites treated "this ENTRY is
dead" as "this KEY is deleted":

1. Compact install (snap_and_reconcile): any dead entry in the frozen
   window triggered a key_hash-WIDE tombstone on the new immutable.
   VEC-1 tombstones the old copy in place on update, so a key updated
   before the freeze had dead-old + live-new in the SAME window — and
   the install deleted the new copy out of the immutable it had just
   been compacted into. Fix: a dead window entry only proves deletion
   when the key has no live window sibling (DEL/UNLINK kills all
   copies, so real deletes still fire; post-freeze updates land in the
   tail and stay covered by the tail_keys arm).

2. Merge install (poll_install_merge): replayed each source's LIFETIME
   interior tombstone set key_hash-wide onto the merged output. An
   update interior-tombstones the old copy's home segment (and, via the
   VEC-1 fallback, every immutable) while the new copy lives elsewhere
   — the replay killed the current copy merged in from a sibling
   segment. Fix: origin-gate the replay (new
   mark_deleted_by_key_hash_install_from) — a source's tombstone only
   kills merged entries whose global_id came from that source; real
   DEL/UNLINK tombstones are in every source's set and still apply.

3. VectorStore::insert_vector (test/convenience API) allocated
   insert_lsn = mutable.len()+1, which restarts after every compaction;
   merge dedup (keep highest insert_lsn) then kept a STALE copy over
   the current one. Now allocates from the same monotonic
   txn_manager LSN allocator as the wire path.

TDD red-first: test_bg_compact_update_before_freeze_survives_install
(count=0 red -> 1 green) and test_bg_merge_update_across_segments_
survives (0 red -> 1 green). End-to-end churn repro (24k wire ops,
default GraphUnion merge): LOST 1054 -> 0, live-set recall 0.685 ->
0.98, and with MERGE_MODE NONE: LOST 684 -> 0. All 562 vector lib
tests green.

Refs: Bundle-5 reliability validation (scripts/vector-validate.py)
author: Tin Dang
Bundle-5 deliverable: validate reliability/stability/durability of the
vector engine under long-run churn, on real cloud hardware, comparing
this branch against the v0.5.1 baseline.

- scripts/vector-validate.py — on-target driver (raw RESP client, no
  redis-cli: FT.SEARCH PARAMS blobs are binary). Three phases:
    recall      per binary x SQ8/TQ4: 20k MiniLM-like 384d unit
                vectors, R@10 vs numpy brute force, QPS + p50/p99.
    soak        appendonly yes, 60/25/10/5% search/update/insert/delete
                churn; samples live-set recall, RSS, num_docs, and
                deleted-key resurrection every 60s; hard-fails on
                resurrection, recall < 0.85, or RSS runaway.
    durability  settled-write survival across kill -9 (everysec + 5s
                margin), post-recovery recall, double-crash restart.
  Servers are spawned/killed by the driver (SIGKILL only — SIGTERM +
  SO_REUSEPORT hang gotcha). O(1) id-pool sampling in the churn loop.

- scripts/gcloud-vector-soak.sh — GCE orchestration (c4a-standard-8 ARM
  + c3-standard-8 x86): ships the unpushed branch via git bundle,
  builds moon-base (v0.5.1) + moon-branch with ELF asserts, installs
  python3-numpy, runs the driver, fetches results JSON, teardown trap.
  --self-test gate runs with no GCloud cost.

This harness caught all three defects fixed in the two preceding
commits before any cloud spend (DEL/UNLINK unindex gap, compact-install
update loss, merge-install tombstone replay loss).

Refs: Bundle-5 (vector deep review follow-on)
author: Tin Dang
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@TinDang97, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3853dcce-75e6-46e6-8378-cace3e3ebd40

📥 Commits

Reviewing files that changed from the base of the PR and between 414fd1f and fbd828f.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • src/command/connection.rs
  • src/config.rs
  • src/main.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/shared.rs
  • src/shard/coordinator.rs
  • src/vector/distance/avx2.rs
  • src/vector/distance/avx512.rs
  • src/vector/distance/neon.rs
  • src/vector/f16.rs
  • src/vector/hnsw/graph.rs
  • src/vector/segment/immutable.rs
  • tests/vector_exact_rerank.rs
📝 Walkthrough

Walkthrough

This PR adds SQ8 stats-based distance kernels, an f16 exact-rerank sidecar, cluster-wide FT.INFO aggregation, DEL/UNLINK delete parity, striped vector metrics, bounded compaction, and standalone validation/benchmark scripts.

Changes

Vector search correctness and performance

Layer / File(s) Summary
Docs, config, and startup
CHANGELOG.md, CLAUDE.md, src/config.rs, src/main.rs, src/vector/mod.rs, tests/*integration.rs
Updates the vector-search notes, adds the FT.SEARCH worker flag, initializes the search pool at startup, exposes new vector submodules, and sets the worker flag in test server configs.
SQ8 stats kernels and benchmark
Cargo.toml, benches/sq8_adc_bench.rs, src/vector/turbo_quant/sq8.rs, src/vector/distance/{avx2,avx512,neon,mod}.rs, src/vector/hnsw/search.rs, src/vector/segment/mutable.rs
Adds stats-based SQ8 helpers, SIMD implementations for AVX2/AVX-512/NEON, dispatch wiring, HNSW and brute-force call-site updates, and a benchmark target comparing naive and stats-based ADC paths.
Exact rerank sidecar
src/vector/f16.rs, src/vector/persistence/{recovery,segment_io}.rs, src/vector/segment/{mutable,immutable,compaction}.rs, tests/vector_exact_rerank.rs, tests/moonstore_warm_e2e.rs, tests/vector_edge_cases.rs, tests/vector_insert_bench.rs, tests/vector_memory_audit.rs, tests/vector_segment_merge.rs, tests/vector_stress.rs, src/vector/segment/holder.rs
Adds f16 conversion helpers, stores and persists raw f16 sidecar data through mutable, frozen, immutable, compaction, merge, and recovery paths, and uses it for exact reranking during search.
Search pool and yielding path
src/vector/search_pool.rs, src/vector/segment/holder.rs, src/command/vector_search/{execute,hybrid,tests}.rs
Adds the intra-query worker pool, captures filter strategy in search snapshots, changes search result key-hash ownership to Arc, and routes yielding FT.SEARCH through pooled or serial segment execution with cooperative mutable scanning.
Bounded compaction and merge policy
src/vector/store.rs, src/vector/segment/compaction.rs, tests/vector_stress.rs
Updates vector-store compaction to freeze bounded prefixes, preserves merge recall tolerance per index, and carries the exact-rerank sidecar through compaction and graph-union merges only when all entries have it.
Update tombstones and DEL/UNLINK parity
src/vector/store.rs, src/vector/segment/{mutable,immutable}.rs, src/shard/spsc_handler.rs, tests/vector_update_tombstone.rs, tests/vector_del_unindex.rs
Fixes HSET update tombstoning in mutable and immutable segments, adds install-from tombstone replay, and wires DEL/UNLINK auto-delete helpers into every dispatch path with new regression tests.
Cluster FT.INFO and recall config
src/command/vector_search/{ft_info,ft_config,ft_create}.rs, src/shard/coordinator.rs, src/server/conn/*, tests/vector_segment_merge.rs
Adds FT.INFO scatter and merge across shards, exposes the merge helper publicly, adds the per-index MERGE_RECALL_TOLERANCE config, and rejects FT.CREATE MERGE_MODE KEEP_RAW.
Striped metrics and memory publication
src/vector/metrics.rs, src/command/connection.rs, src/shard/persistence_tick.rs
Replaces global vector counters with striped counters, updates INFO output and tests to use the new aggregators, and always publishes shard memory usage during eviction ticks.

Long-run vector reliability test harness

Layer / File(s) Summary
Validation and benchmark scripts
scripts/vector-validate.py, scripts/gcloud-vector-soak.sh, scripts/bench-vector-vs-redisearch.py
Adds the long-run vector validator, GCE orchestration script, and a benchmark script that compares Moon and RediSearch vector search behavior.

Estimated code review effort: 5 (Critical) | ~150 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the summary well, but it omits the required Checklist, Performance Impact, and Notes sections from the template. Add the missing Checklist, Performance Impact, and Notes sections, and include the required test/benchmark status for each checklist item.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change set: SIMD SQ8 ADC, exact rerank, and reliability fixes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/vector-search-optimization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

SIMD SQ8 ADC, exact rerank, and vector churn reliability fixes

✨ Enhancement 🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• **HQ-2 (SIMD SQ8 ADC):** Algebraically decomposes SQ8 L2 into query constants + candidate stats;
 adds NEON/AVX2/AVX-512 kernels.
• **HQ-1 (exact rerank):** Adds f16 raw-vector sidecar; reranks top 4·k candidates with true metric
 distances.
• **Churn reliability:** Fix DEL/UNLINK unindexing on all paths; fix compact/merge install tombstone
 scoping.
• **Hot-path wins:** Arc-COW key map snapshots and chunked brute-force query prep/heap hoisting.
• **Cluster correctness:** FT.INFO now scatter-gathers across shards and merges additive stats.
• **Validation:** Adds soak/benchmark harness + targeted regression tests.
Diagram

graph TD
    subgraph QueryPath["Query Path"]
        FTS["FT.SEARCH dispatch"] --> SNAP["SearchSnapshot\n(Arc key map,\nFilterStrategy)"] --> BFQ["BruteForceQuery\n(prep once)"] --> MUT[("Mutable scan")]
        SNAP --> HNSW["HNSW beam\n(SQ8 SIMD)"] --> IMM[("Immutable\nHNSW+rerank")]
        IMM --> SID["f16 sidecar\n(exact rerank)"]
    end

    subgraph SIMD["SIMD ADC tier"]
        DT["DistanceTable\nsq8_stats"] --> AVX512["AVX-512F"]
        DT --> AVX2["AVX2+FMA"]
        DT --> NEON["NEON"]
        DT --> SCAL["Scalar"]
    end

    HNSW --> DT
    BFQ --> DT

    subgraph Lifecycle["Segment lifecycle"]
        MUT2[("Mutable\nraw_f16") ] --> COMPACT["compact()\npermute BFS"] --> IMM2[("Immutable\nraw_f16")]
        IMM2 --> MERGE["merge\n(origin-gated)"] --> IMM2 --> DISK[("raw_f16.bin")]
    end

    subgraph Reliability["Reliability"]
        DEL["DEL/UNLINK"] --> AUTODEL["auto_delete_vectors"]
        INSTALL["compact/merge\ninstall"] --> SCOPE["live-sibling / origin\nscoping"]
    end

    subgraph Legend
      direction LR
      _db[(Database/segment)] ~~~ _mod["Module/logic"] ~~~ _ext{{"SIMD tier"}}
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use `half` crate for f16 conversion
  • ➕ Battle-tested conversion edge cases
  • ➕ Potential platform intrinsics integration
  • ➖ Adds a new dependency on a latency-sensitive path
  • ➖ This PR needs only two functions and already tests IEEE-754 corner cases exhaustively
2. Store f32 (not f16) raw-vector sidecar
  • ➕ True exact rerank distances
  • ➕ No decode cost
  • ➖ ~2× larger sidecar vs f16 (4·dim B/vector vs 2·dim B/vector)
  • ➖ Larger working set reduces cache locality and increases RSS during rerank

Recommendation: Keep the PR approach: the SQ8 ADC stats decomposition is the right SIMD-enabling factoring, and the f16 sidecar is a balanced memory/recall trade-off. The only meaningful alternative is delegating f16 conversion to a crate, but the current hand-rolled implementation avoids a new dependency and is well-tested; f32 sidecar is likely too costly for steady-state RSS.

Files changed (49) +4374 / -308

Enhancement (15) +1791 / -84
ft_config.rsAdd MERGE_RECALL_TOLERANCE FT.CONFIG knob +30/-0

Add MERGE_RECALL_TOLERANCE FT.CONFIG knob

• Implements 'FT.CONFIG GET/SET <idx> MERGE_RECALL_TOLERANCE' with parsing and range validation, storing per-index merge recall tolerance.

src/command/vector_search/ft_config.rs

ft_info.rsMerge per-shard FT.INFO responses for cluster-wide stats +227/-0

Merge per-shard FT.INFO responses for cluster-wide stats

• Adds 'merge_ft_info_responses' that sums additive top-level and per-field counters across shard responses, with unit tests.

src/command/vector_search/ft_info.rs

mod.rsExport FT.INFO merge helper +1/-1

Export FT.INFO merge helper

• Exposes the FT.INFO merge functionality for coordinator scatter-gather usage.

src/command/vector_search/mod.rs

coordinator.rsAdd scatter_ft_info helper to fan out FT.INFO and merge responses +61/-0

Add scatter_ft_info helper to fan out FT.INFO and merge responses

• Implements scatter to all shards via SPSC messaging, collects replies, executes locally without await under shard borrow, then merges via 'merge_ft_info_responses'.

src/shard/coordinator.rs

avx2.rsAdd AVX2+FMA SQ8 ADC stats kernel +146/-0

Add AVX2+FMA SQ8 ADC stats kernel

• Implements 'sq8_stats' widening u8→f32 and reducing dot/sum/sumsq with FMA, plus parity tests vs scalar reference including tail handling.

src/vector/distance/avx2.rs

avx512.rsAdd AVX-512F SQ8 ADC stats kernel +128/-0

Add AVX-512F SQ8 ADC stats kernel

• Implements 'sq8_stats' with 32-wide loop (2×16) and scalar tail, plus parity tests vs scalar reference including tail handling.

src/vector/distance/avx512.rs

mod.rsExtend DistanceTable with sq8_stats and SIMD dispatch +120/-0

Extend DistanceTable with sq8_stats and SIMD dispatch

• Adds 'sq8_stats' fn pointer to 'DistanceTable', wires dispatch at init (AVX-512 > AVX2+FMA > NEON > scalar), and adds smoke + parity tests.

src/vector/distance/mod.rs

neon.rsAdd NEON SQ8 ADC stats kernel +139/-0

Add NEON SQ8 ADC stats kernel

• Implements 'sq8_stats' with u8→u16→u32→f32 widening and vfma reductions, plus parity tests vs scalar reference including empty and tail cases.

src/vector/distance/neon.rs

f16.rsNew minimal f16 encode/decode utilities for rerank sidecar +185/-0

New minimal f16 encode/decode utilities for rerank sidecar

• Adds IEEE-754 binary16 conversions, slice encoding, and squared-L2 computation against f16 vectors with unit tests for corner cases.

src/vector/f16.rs

search.rsUse SIMD-dispatched SQ8 ADC stats path in HNSW search +38/-4

Use SIMD-dispatched SQ8 ADC stats path in HNSW search

• Resolves 'sq8_stats' once per query and uses 'sq8_l2_from_stats' for per-candidate SQ8 distances instead of naive ADC loops.

src/vector/hnsw/search.rs

mod.rsExpose new f16 module +1/-0

Expose new f16 module

• Adds module export for the new f16 utilities so segments can build and use the rerank sidecar.

src/vector/mod.rs

segment_io.rsPersist/load raw_f16.bin sidecar for exact rerank +40/-2

Persist/load raw_f16.bin sidecar for exact rerank

• Writes optional 'raw_f16.bin' when present; reads it back if size matches expectation and otherwise degrades gracefully with a warning.

src/vector/persistence/segment_io.rs

immutable.rsHQ-1 exact rerank via optional f16 sidecar in immutable segments +146/-5

HQ-1 exact rerank via optional f16 sidecar in immutable segments

• Adds 'raw_f16' optional sidecar and 'rerank_exact' (top 4·k candidates reranked with true distances) and integrates it into search paths.

src/vector/segment/immutable.rs

mutable.rsStore f16 originals, add BruteForceQuery, and use SIMD SQ8 ADC in scans +225/-72

Store f16 originals, add BruteForceQuery, and use SIMD SQ8 ADC in scans

• Captures f16 originals on insert, introduces 'BruteForceQuery' to hoist query prep/top-k heap across yield chunks, and uses SIMD-dispatched SQ8 stats + O(1) combine in brute-force distance computations.

src/vector/segment/mutable.rs

sq8.rsHQ-2 SQ8 stats decomposition: per-query stats, per-candidate stats, O(1) combine +304/-0

HQ-2 SQ8 stats decomposition: per-query stats, per-candidate stats, O(1) combine

• Adds 'sq8_query_stats', 'sq8_candidate_stats_scalar', 'sq8_l2_from_stats', and 'sq8_ip_from_stats' with extensive tests proving parity with naive ADC and guarding cancellation/edge cases.

src/vector/turbo_quant/sq8.rs

Bug fix (13) +599 / -143
ft_create.rsReject KEEP_RAW merge mode fail-loud (unimplemented) +9/-0

Reject KEEP_RAW merge mode fail-loud (unimplemented)

• Errors out when 'MERGE_MODE KEEP_RAW' is requested to avoid promising recall guarantees not yet supported.

src/command/vector_search/ft_create.rs

dispatch.rsFix yield-path filter strategy selection at snapshot capture +10/-4

Fix yield-path filter strategy selection at snapshot capture

• Computes selectivity-based 'FilterStrategy' at capture time so yielding KNN uses the same post-filter/ACORN strategy as sync search.

src/command/vector_search/ft_search/dispatch.rs

ft.rsScatter-gather FT.INFO across shards in monoio handler +11/-3

Scatter-gather FT.INFO across shards in monoio handler

• Routes 'FT.INFO' through 'scatter_ft_info' to return cluster-wide additive stats instead of single-shard counts.

src/server/conn/handler_monoio/ft.rs

mod.rsEnsure DEL/UNLINK auto-unindexes vectors in monoio path +14/-0

Ensure DEL/UNLINK auto-unindexes vectors in monoio path

• Adds DEL/UNLINK post-dispatch hooks calling 'auto_delete_vectors' for wire-parity with HSET auto-indexing.

src/server/conn/handler_monoio/mod.rs

ft.rsScatter-gather FT.INFO in sharded handler +16/-0

Scatter-gather FT.INFO in sharded handler

• Intercepts 'FT.INFO' and uses 'scatter_ft_info' to merge per-shard counters; other FT commands continue broadcasting.

src/server/conn/handler_sharded/ft.rs

handler_single.rsEnsure DEL/UNLINK auto-unindexes vectors in single-conn path +25/-0

Ensure DEL/UNLINK auto-unindexes vectors in single-conn path

• Adds DEL/UNLINK auto-delete hooks in both direct dispatch and transactional execution paths.

src/server/conn/handler_single.rs

shared.rsEnsure DEL/UNLINK auto-unindexes vectors in sharded TXN executor +9/-0

Ensure DEL/UNLINK auto-unindexes vectors in sharded TXN executor

• Adds DEL/UNLINK post-dispatch hook calling 'auto_delete_vectors' during 'execute_transaction_sharded'.

src/server/conn/shared.rs

persistence_tick.rsPersistence tick adjustments for reliability semantics +13/-9

Persistence tick adjustments for reliability semantics

• Tweaks persistence tick behavior to align with updated LSN/tombstone semantics and test expectations.

src/shard/persistence_tick.rs

spsc_handler.rsDEL/UNLINK auto-delete vectors + VEC-1 update tombstoning + QP-1 key map COW +76/-21

DEL/UNLINK auto-delete vectors + VEC-1 update tombstoning + QP-1 key map COW

• Adds 'auto_delete_vectors' and calls it after successful DEL/UNLINK on shared dispatch paths. Implements VEC-1 update behavior: tombstone previous version before append (non-txn). Switches 'key_hash_to_key' mutation to 'Arc::make_mut' and removes dead SQ8 pre-quantization/norm computation from inserts.

src/shard/spsc_handler.rs

metrics.rsPublish per-shard KV memory even with unlimited maxmemory +62/-10

Publish per-shard KV memory even with unlimited maxmemory

• Ensures memory metrics remain available when 'maxmemory' is not set, enabling the MEMORY DOCTOR output under unlimited configs.

src/vector/metrics.rs

compaction.rsPropagate f16 rerank sidecar through compaction/merge; fix SQ8 code_len bug +84/-17

Propagate f16 rerank sidecar through compaction/merge; fix SQ8 code_len bug

• Builds BFS-ordered f16 buffers during compaction and merge (all-or-nothing sidecar). Fixes SQ8 'code_len' selection to avoid latent slice/OOB risks.

src/vector/segment/compaction.rs

holder.rsQP-4 brute-force query hoist + XC-3 filter strategy for yield path +95/-55

QP-4 brute-force query hoist + XC-3 filter strategy for yield path

• Adds 'filter_strategy' to snapshots, hoists brute-force prep/heap across yield chunks, and restores HnswPostFilter oversample+post-filter behavior for high-selectivity filters.

src/vector/segment/holder.rs

store.rsArc-COW key map, churn-safe install/merge tombstones, and merge recall tolerance knob +175/-24

Arc-COW key map, churn-safe install/merge tombstones, and merge recall tolerance knob

• Wraps 'key_hash_to_key' in Arc for O(1) snapshot capture. Fixes compact-install reconcile to avoid key_hash-wide deletes when a live sibling exists. Gates merge tombstone replay by origin global_ids to avoid killing updated copies in sibling segments. Adds 'merge_recall_tolerance' used by vacuum merges and FT.CONFIG.

src/vector/store.rs

Refactor (5) +41 / -13
connection.rsMinor connection command handling adjustments +2/-2

Minor connection command handling adjustments

• Small connection-layer tweaks to align vector command dispatch behavior with updated handlers.

src/command/connection.rs

execute.rsMinor ft_search execute path alignment +1/-1

Minor ft_search execute path alignment

• Small change to keep yield and sync paths consistent after snapshot/strategy fixes.

src/command/vector_search/ft_search/execute.rs

hybrid.rsHybrid search path adjustments for updated snapshot fields +7/-1

Hybrid search path adjustments for updated snapshot fields

• Updates hybrid vector/text search wiring to accommodate snapshot/filter strategy changes.

src/command/vector_search/hybrid.rs

graph.rsMinor HNSW graph adjustments to support rerank/merge invariants +26/-2

Minor HNSW graph adjustments to support rerank/merge invariants

• Small HNSW graph changes to maintain stable mapping and performance in support of the new rerank and merge logic.

src/vector/hnsw/graph.rs

recovery.rsUpdate WAL replay to new append signatures (remove unused SQ8 params) +5/-7

Update WAL replay to new append signatures (remove unused SQ8 params)

• Adjusts vector WAL replay to call 'append'/'append_transactional' without precomputed SQ8/norm args, matching VEC-3 signature cleanup.

src/vector/persistence/recovery.rs

Tests (11) +1082 / -64
sq8_adc_bench.rsNew Criterion benchmark for naive vs stats vs SIMD SQ8 ADC +89/-0

New Criterion benchmark for naive vs stats vs SIMD SQ8 ADC

• Benchmarks three SQ8 L2 ADC variants at dims 128/384/768, including SIMD-dispatched stats kernels via 'distance::table().sq8_stats'.

benches/sq8_adc_bench.rs

tests.rsUpdate vector-search command tests for new semantics +16/-24

Update vector-search command tests for new semantics

• Adapts existing tests to updated filtering/dispatch/tombstone behavior and removes now-obsolete expectations.

src/command/vector_search/tests.rs

moonstore_warm_e2e.rsAdjust warm E2E tests for updated vector behavior +4/-12

Adjust warm E2E tests for updated vector behavior

• Updates warm-store end-to-end tests to reflect modified append signatures and new deletion/update semantics.

tests/moonstore_warm_e2e.rs

vector_del_unindex.rsNew regression suite for DEL/UNLINK unindexing across dispatch paths +381/-0

New regression suite for DEL/UNLINK unindexing across dispatch paths

• Adds wire-level tests ensuring DEL/UNLINK consistently tombstones vectors so deleted keys do not appear in KNN results regardless of dispatch path.

tests/vector_del_unindex.rs

vector_edge_cases.rsUpdate vector edge-case tests for new signatures/semantics +4/-8

Update vector edge-case tests for new signatures/semantics

• Adjusts edge-case tests to align with VEC-3 signature changes and updated churn behavior.

tests/vector_edge_cases.rs

vector_exact_rerank.rsNew regression suite for exact rerank sidecar correctness +402/-0

New regression suite for exact rerank sidecar correctness

• Verifies f16 sidecar construction/persistence and that reranked results improve recall and return true metric distances to f16 tolerance.

tests/vector_exact_rerank.rs

vector_insert_bench.rsAdjust insert benchmark tests for new append path +4/-8

Adjust insert benchmark tests for new append path

• Updates insert benchmark test scaffolding for removed SQ8 pre-quantization params and related API changes.

tests/vector_insert_bench.rs

vector_memory_audit.rsMinor memory audit expectation update +1/-1

Minor memory audit expectation update

• Aligns memory audit test output with updated memory metrics behavior.

tests/vector_memory_audit.rs

vector_segment_merge.rsAdd/adjust tests for merge update/tombstone correctness +49/-7

Add/adjust tests for merge update/tombstone correctness

• Extends merge tests to cover update-across-segments survival under origin-gated tombstone replay.

tests/vector_segment_merge.rs

vector_stress.rsUpdate stress tests for new update/delete semantics +2/-4

Update stress tests for new update/delete semantics

• Adjusts vector stress tests to match updated deletion/unindexing and append signature changes.

tests/vector_stress.rs

vector_update_tombstone.rsNew regression suite for update tombstone invariants +130/-0

New regression suite for update tombstone invariants

• Adds tests establishing the invariant that entry tombstones are not key deletions and that updates survive compaction/merge installs.

tests/vector_update_tombstone.rs

Documentation (2) +119 / -4
CHANGELOG.mdAdd detailed changelog for vector perf + reliability bundles +114/-0

Add detailed changelog for vector perf + reliability bundles

• Documents HQ-1/HQ-2 performance work, churn reliability fixes, FT.INFO scatter-gather, and new validation/benchmark tooling.

CHANGELOG.md

CLAUDE.mdUpdate developer notes for vector engine deep-review branch +5/-4

Update developer notes for vector engine deep-review branch

• Refreshes local guidance to reflect the new vector optimization/reliability workflow and findings.

CLAUDE.md

Other (3) +742 / -0
Cargo.tomlRegister SQ8 ADC Criterion benchmark +4/-0

Register SQ8 ADC Criterion benchmark

• Adds a Criterion bench target for 'benches/sq8_adc_bench.rs'.

Cargo.toml

gcloud-vector-soak.shNew GCE orchestration script for multi-arch soak testing +200/-0

New GCE orchestration script for multi-arch soak testing

• Automates provisioning/build/run/collect for long-run vector soak validation on ARM and x86 GCE instances.

scripts/gcloud-vector-soak.sh

vector-validate.pyNew end-to-end vector validation harness +538/-0

New end-to-end vector validation harness

• Adds a comprehensive validator covering recall/QPS, churn soak, durability via crash/restart, and JSON verdict output.

scripts/vector-validate.py

@qodo-code-review

qodo-code-review Bot commented Jul 5, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (2) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 40 rules

Grey Divider


Action required

1. Exact rerank tests bare unwrap 📘 Rule violation ✧ Quality
Description
New .unwrap() usages were introduced across multiple test areas (including
tests/vector_exact_rerank.rs, src/vector/store.rs, SQ8 stats tests, and merge_tests) without
the required adjacent allow+justification comment pair, and a new crate-level
#![allow(clippy::unwrap_used)] was added without an immediate justification comment. This violates
the unwrap annotation/audit requirements and includes a partial_cmp(...).unwrap() panic risk if
NaNs are encountered.
Code

tests/vector_exact_rerank.rs[R234-239]

+        let mut truth: Vec<(f32, u32)> = vecs
+            .iter()
+            .enumerate()
+            .map(|(i, v)| (l2_sq(&q, v), i as u32))
+            .collect();
+        truth.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
Relevance

⭐⭐⭐ High

Unwrap annotation+justification is enforced and accepted in past reviews (PR71); missing annotations
rejected elsewhere (PR211).

PR-#71
PR-#211

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 302083 requires every .unwrap() to be explicitly covered by an attached
#[allow(clippy::unwrap_used)] along with a directly preceding justification comment, but the cited
new .unwrap() occurrences in the referenced test files lack that paired annotation. Additionally,
the comparator in tests/vector_exact_rerank.rs uses partial_cmp(...).unwrap(), which is a
documented panic path when NaN values are present, and the new crate-level
#![allow(clippy::unwrap_used)] suppression in tests/vector_del_unindex.rs is broad and lacks an
adjacent justification comment, conflicting with the requirement (including Rule 209935) that new
lint suppressions be justified and narrowly scoped.

Rule 302083: Annotate safe unwrap calls with allow and justification
Rule 209935: Rust code must be clippy-clean under all supported feature profiles
tests/vector_exact_rerank.rs[234-239]
tests/vector_exact_rerank.rs[271-273]
tests/vector_exact_rerank.rs[319-321]
src/vector/store.rs[2894-2911]
src/vector/store.rs[3151-3184]
src/vector/turbo_quant/sq8.rs[581-600]
src/command/vector_search/ft_info.rs[487-490]
tests/vector_del_unindex.rs[18-19]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Several new `.unwrap()` calls were added in test code without the required adjacent `allow(clippy::unwrap_used)` plus directly-preceding justification comment, and a new crate-level `#![allow(clippy::unwrap_used)]` was introduced without an immediate justification and is overly broad. Additionally, the `partial_cmp(...).unwrap()` used in a sort comparator can panic if NaNs appear.

## Issue Context
- Compliance Rule 302083 requires that each unwrap be either avoided or explicitly annotated with an attached `#[allow(clippy::unwrap_used)]` and a justification comment immediately above that allow.
- The requirement applies in both application and test code.
- Where possible, prefer avoiding unwraps via `expect` (with a message), returning `Result` from tests and using `?`, or using safer comparisons (e.g., `f32::total_cmp`) rather than `partial_cmp(...).unwrap()`.
- Rule 209935 additionally requires new lint suppressions to be justified and narrowly scoped; avoid crate-level suppressions unless absolutely necessary and explicitly justified.

## Fix Focus Areas
- tests/vector_exact_rerank.rs[234-239]
- tests/vector_exact_rerank.rs[271-273]
- tests/vector_exact_rerank.rs[319-321]
- src/vector/store.rs[2894-2911]
- src/vector/store.rs[3151-3184]
- src/vector/turbo_quant/sq8.rs[581-600]
- src/command/vector_search/ft_info.rs[487-490]
- tests/vector_del_unindex.rs[18-19]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Bad f16 subnormal decode ✓ Resolved 🐞 Bug ≡ Correctness
Description
f16_to_f32 computes the exponent for f16 subnormals with an off-by-one (127 - 15 - lead), so
every subnormal half decodes to half its correct value. This breaks the function’s “exact” contract
and can skew HQ-1 exact-rerank distances for small-magnitude vector components.
Code

src/vector/f16.rs[R66-82]

+/// Convert IEEE 754 binary16 bits to f32 (exact — every f16 is representable).
+#[inline]
+pub fn f16_to_f32(bits: u16) -> f32 {
+    let sign = ((bits & 0x8000) as u32) << 16;
+    let exp = ((bits >> 10) & 0x1F) as u32;
+    let mant = (bits & 0x03FF) as u32;
+
+    let out = if exp == 0 {
+        if mant == 0 {
+            sign // Signed zero.
+        } else {
+            // Subnormal: normalize by shifting the mantissa up.
+            let lead = mant.leading_zeros() - 21; // Zeros above bit 9.
+            let exp32 = 127 - 15 - lead;
+            let mant32 = (mant << (lead + 1)) & 0x03FF;
+            sign | (exp32 << 23) | (mant32 << 13)
+        }
Relevance

⭐⭐ Medium

No prior similar f16 subnormal decode fixes found in history.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The subnormal decode path subtracts 15 (f16 bias) but also effectively undercounts the exponent by
1; for mant=1, the computed exp32 yields 2^-25 rather than the correct 2^-24. The tests
already define the smallest subnormal bit pattern, demonstrating the intended value range for
roundtrips.

src/vector/f16.rs[66-90]
src/vector/f16.rs[128-139]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`src/vector/f16.rs::f16_to_f32` decodes f16 subnormals with the wrong exponent bias, producing values that are 2× too small (e.g., `0x0001` becomes `2^-25` instead of `2^-24`). This violates the comment that the conversion is exact and affects exact-rerank distance computations when vectors include subnormal f16 values.

## Issue Context
The test suite already asserts the smallest subnormal encoding (`f32_to_f16(5.96046e-8) == 0x0001`), but there is no corresponding assertion that `f16_to_f32(0x0001)` matches `5.96046e-8`.

## Fix Focus Areas
- src/vector/f16.rs[73-90]
- src/vector/f16.rs[128-139]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. SIMD stats uses get_unchecked ✓ Resolved 📘 Rule violation ≡ Correctness
Description
New SIMD SQ8 stats implementations use get_unchecked for scalar tails, which is a forbidden unsafe
construct per UNSAFE_POLICY.md unless explicitly justified and benchmarked. This increases the
unsoundness audit surface without showing the required policy justification adjacent to the unsafe
usage.
Code

src/vector/distance/avx2.rs[R366-372]

+    // Scalar tail
+    while i < n {
+        let cf = *codes.get_unchecked(i) as f32;
+        let qv = *query.get_unchecked(i);
+        dot_sum += qv * cf;
+        sum_c_sum += cf;
+        sumsq_c_sum += cf * cf;
Relevance

⭐⭐ Medium

No clear historical enforcement about forbidding get_unchecked; only mixed unsafe-policy discussions
(PR71).

PR-#71

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 297369 requires new unsafe usage to comply with UNSAFE_POLICY.md and prohibits forbidden
constructs. The diff adds unchecked indexing (get_unchecked) in three SIMD implementations without
adjacent policy-required justification/benchmarking evidence.

Rule 297369: Enforce unsafe code usage against UNSAFE_POLICY.md
src/vector/distance/avx2.rs[366-372]
src/vector/distance/avx512.rs[308-315]
src/vector/distance/neon.rs[347-354]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new SIMD SQ8 stats functions use `codes.get_unchecked(i)` / `query.get_unchecked(i)` in the scalar tail.

## Issue Context
`UNSAFE_POLICY.md` lists `get_unchecked` as forbidden without explicit design review/benchmark justification. The tail loop is unlikely to be performance-critical enough to justify unchecked indexing.

## Fix Focus Areas
- src/vector/distance/avx2.rs[366-372]
- src/vector/distance/avx512.rs[308-315]
- src/vector/distance/neon.rs[347-354]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Unclamped cosine rerank distance ✓ Resolved 🐞 Bug ≡ Correctness
Description
For Cosine/InnerProduct, rerank_exact computes 2 - 2*(dot/||x||) without clamping, so f16
rounding can push the cosine term slightly outside [-1,1] and produce negative or >4 squared-L2
distances. This violates the engine’s unit-sphere distance convention and can perturb ordering near
ties.
Code

src/vector/segment/immutable.rs[R208-218]

+                // One pass: ⟨q̂,x⟩ and ‖x‖² from the f16-decoded vector.
+                let mut dot = 0.0f32;
+                let mut xsq = 0.0f32;
+                for (q, &h) in q_ref.iter().zip(vec_f16.iter()) {
+                    let x = crate::vector::f16::f16_to_f32(h);
+                    dot += q * x;
+                    xsq += x * x;
+                }
+                if xsq > 0.0 {
+                    result.distance = 2.0 - 2.0 * (dot / xsq.sqrt());
+                }
Relevance

⭐⭐ Medium

No historical evidence found for clamping cosine rerank distance outputs.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rerank code uses an unclamped cosine-derived formula, while the SQ8 encoding path explicitly
establishes that Cosine/InnerProduct are handled as unit-sphere metrics whose ranking distance is
squared-L2 (non-negative). Without clamping, rerank_exact can violate that invariant.

src/vector/segment/immutable.rs[199-223]
src/vector/segment/mutable.rs[195-202]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
In `ImmutableSegment::rerank_exact`, the unit-sphere metric path computes `2.0 - 2.0 * (dot / xsq.sqrt())` with no clamping. Due to f16 decode + float arithmetic, `dot/||x||` can be slightly > 1 or < -1, yielding a negative (or >4) squared-L2 distance.

## Issue Context
The rest of the engine treats Cosine/InnerProduct as unit-sphere metrics and ranks them via squared-L2 on normalized vectors, which is always in [0, 4].

## Fix Focus Areas
- src/vector/segment/immutable.rs[199-223]
- src/vector/segment/mutable.rs[195-202]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

5. Rerank before tombstone filter ✓ Resolved 🐞 Bug ≡ Correctness
Description
ImmutableSegment::search and search_filtered call rerank_exact() before removing tombstoned
candidates, so deleted entries can consume the 4·k rerank budget. After retain(is_live_bfs), final
results may include non-reranked entries whose approximate ADC distances decide ordering and
returned distance values.
Code

src/vector/segment/immutable.rs[R320-326]

+        // HQ-1: exact rerank of the full beam (ef candidates) from the f16
+        // sidecar — replaces quantized estimates with true metric distances
+        // before top-k truncation. No-op without a sidecar.
+        self.rerank_exact(&mut candidates, query, k);
        // Filter deleted entries before truncating so that k live results are
        // returned even when some candidates are tombstoned.
        candidates.retain(|c| {
Relevance

⭐ Low

Team previously rejected tombstone/liveness search-path changes (immutable segment liveness
suggestion rejected in PR165).

PR-#165

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The current control flow reranks and sorts, then filters deletions, then truncates. Since
rerank_exact is explicitly bounded to the top 4·k prefix, filtering after rerank can change which
candidates survive into the final truncated top-k set, leaving some survivors with their pre-rerank
(quantized) distances.

src/vector/segment/immutable.rs[280-333]
src/vector/segment/immutable.rs[357-374]
src/vector/segment/immutable.rs[169-223]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Exact rerank is applied before tombstone filtering. Because `rerank_exact` only recomputes distances for the first `min(4*k, candidates.len())` items, tombstoned entries near the front can displace live entries from the reranked prefix. After filtering, the final top-k can contain candidates that were never reranked, defeating the “exact distances” promise and potentially changing result order.

## Issue Context
This matters most under churn-heavy workloads where tombstones are common among top approximate candidates.

## Fix Focus Areas
- src/vector/segment/immutable.rs[280-333]
- src/vector/segment/immutable.rs[357-374]
- src/vector/segment/immutable.rs[169-223]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. find_moon_binary() falls back to target 📘 Rule violation ▣ Testability
Description
tests/vector_del_unindex.rs spawns the server by guessing target/{release,debug}/moon when
MOON_BIN is unset. This violates the requirement that integration tests must set MOON_BIN
explicitly, and can cause CI/local runs to execute the wrong binary.
Code

tests/vector_del_unindex.rs[R31-55]

+fn find_moon_binary() -> std::path::PathBuf {
+    if let Ok(bin) = std::env::var("MOON_BIN") {
+        let p = std::path::PathBuf::from(bin);
+        if p.exists() {
+            return p;
+        }
+    }
+    let manifest = env!("CARGO_MANIFEST_DIR");
+    let release = std::path::PathBuf::from(format!("{manifest}/target/release/moon"));
+    if release.exists() {
+        return release;
+    }
+    let debug = std::path::PathBuf::from(format!("{manifest}/target/debug/moon"));
+    if debug.exists() {
+        return debug;
+    }
+    panic!("No moon binary found. Build first or set MOON_BIN=/path/to/moon.");
+}
+
+fn free_port() -> u16 {
+    let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind :0");
+    let p = l.local_addr().expect("local_addr").port();
+    drop(l);
+    p
+}
Relevance

⭐ Low

Repo has merged tests that directly run target/release binaries without MOON_BIN; indicates fallback
tolerated (PR65).

PR-#65

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires integration tests to explicitly set MOON_BIN and forbids fallback helpers.
The new helper find_moon_binary() explicitly falls back to target/release/moon and
target/debug/moon when MOON_BIN is unset/missing.

Rule 992389: Integration tests must set MOON_BIN explicitly for server binaries
tests/vector_del_unindex.rs[31-55]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The integration test resolves the server binary via fallback paths under `target/` when `MOON_BIN` is not set. Compliance requires tests to fail fast unless `MOON_BIN` is explicitly provided.

## Issue Context
Relying on fallback paths can silently run stale or incorrect binaries (debug vs release, different workspace, etc.), undermining test reliability.

## Fix Focus Areas
- tests/vector_del_unindex.rs[31-55]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +234 to +239
let mut truth: Vec<(f32, u32)> = vecs
.iter()
.enumerate()
.map(|(i, v)| (l2_sq(&q, v), i as u32))
.collect();
truth.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Exact rerank tests bare unwrap 📘 Rule violation ✧ Quality

New .unwrap() usages were introduced across multiple test areas (including
tests/vector_exact_rerank.rs, src/vector/store.rs, SQ8 stats tests, and merge_tests) without
the required adjacent allow+justification comment pair, and a new crate-level
#![allow(clippy::unwrap_used)] was added without an immediate justification comment. This violates
the unwrap annotation/audit requirements and includes a partial_cmp(...).unwrap() panic risk if
NaNs are encountered.
Agent Prompt
## Issue description
Several new `.unwrap()` calls were added in test code without the required adjacent `allow(clippy::unwrap_used)` plus directly-preceding justification comment, and a new crate-level `#![allow(clippy::unwrap_used)]` was introduced without an immediate justification and is overly broad. Additionally, the `partial_cmp(...).unwrap()` used in a sort comparator can panic if NaNs appear.

## Issue Context
- Compliance Rule 302083 requires that each unwrap be either avoided or explicitly annotated with an attached `#[allow(clippy::unwrap_used)]` and a justification comment immediately above that allow.
- The requirement applies in both application and test code.
- Where possible, prefer avoiding unwraps via `expect` (with a message), returning `Result` from tests and using `?`, or using safer comparisons (e.g., `f32::total_cmp`) rather than `partial_cmp(...).unwrap()`.
- Rule 209935 additionally requires new lint suppressions to be justified and narrowly scoped; avoid crate-level suppressions unless absolutely necessary and explicitly justified.

## Fix Focus Areas
- tests/vector_exact_rerank.rs[234-239]
- tests/vector_exact_rerank.rs[271-273]
- tests/vector_exact_rerank.rs[319-321]
- src/vector/store.rs[2894-2911]
- src/vector/store.rs[3151-3184]
- src/vector/turbo_quant/sq8.rs[581-600]
- src/command/vector_search/ft_info.rs[487-490]
- tests/vector_del_unindex.rs[18-19]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/vector/distance/avx2.rs Outdated
Comment thread src/vector/f16.rs
Comment thread src/vector/segment/immutable.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/vector/store.rs (1)

177-240: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

store.rs far exceeds the 1500-line file limit.

This file spans well over 3400 lines (test module alone runs to ~3438), and this PR adds further fields/logic/tests to it rather than splitting it into submodules.

As per coding guidelines, "No single .rs file should exceed 1500 lines; split into submodules when approaching the limit."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vector/store.rs` around lines 177 - 240, The VectorIndex/store.rs file is
already far beyond the allowed single-file size, so this change should be moved
out of the monolithic store.rs module. Split the new VectorIndex fields and
related logic into focused submodules (for example around VectorIndex,
compaction, or key-hash tracking), and update the main store.rs to just
re-export or wire them together. Use the VectorIndex struct and its associated
methods as the anchor points for extracting the code.

Source: Coding guidelines

🧹 Nitpick comments (5)
src/command/vector_search/ft_config.rs (1)

134-158: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a unit test for the new FT.CONFIG parameter.

No test exercising FT.CONFIG SET/GET <idx> MERGE_RECALL_TOLERANCE is included in this bundle (unlike the MERGE_MODE KEEP_RAW rejection, which has test_ft_create_merge_mode_keep_raw_rejected).

As per coding guidelines, "Every new command needs at least one unit test and one consistency test entry."

Also applies to: 199-203

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/command/vector_search/ft_config.rs` around lines 134 - 158, Add coverage
for the new FT.CONFIG MERGE_RECALL_TOLERANCE parameter by creating a unit test
that exercises FT.CONFIG SET and GET on an index and verifies the value is
accepted and persisted through the existing FT.CONFIG handling in ft_config.rs.
Follow the pattern of test_ft_create_merge_mode_keep_raw_rejected for locating
the right test module, and add a corresponding consistency test entry as
required by the coding guidelines so this new command behavior is covered
end-to-end.

Source: Coding guidelines

src/command/vector_search/ft_info.rs (2)

263-267: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Non-Array remote responses are silently dropped from the aggregate instead of failing loud.

The doc comment states errors are propagated fail-loud, but a remote response that is neither Frame::Error nor Frame::Array is silently skipped (continue) rather than surfaced. This would silently under-count num_docs/other additive stats for that shard with no indication to the caller. Compare with scatter_invalidate_range in src/shard/coordinator.rs, which explicitly returns Frame::Error(...) for Ok(other) unexpected response shapes.

In practice this is unreachable today since ft_info/ft_info_text_only only ever return Frame::Array or Frame::Error, so this is a defensive gap rather than an active bug.

🛡️ Optional defensive fix
     for remote in remotes {
         let r_items: &[Frame] = match remote {
             Frame::Array(a) => a,
-            _ => continue,
+            other => {
+                let _ = other;
+                return Frame::Error(Bytes::from_static(
+                    b"ERR FT.INFO: unexpected response from remote shard",
+                ));
+            }
         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/command/vector_search/ft_info.rs` around lines 263 - 267, Non-`Array`
remote responses in the ft_info aggregate are being skipped instead of surfaced,
which breaks the fail-loud behavior described by the doc comment. Update the
aggregation logic in the ft_info path so the match on each remote response does
not `continue` on unexpected shapes; instead, return a `Frame::Error` for any
response that is neither `Frame::Array` nor the expected error form. Use the
existing ft_info aggregation code and mirror the defensive pattern used by
`scatter_invalidate_range` in `coordinator` to keep unexpected remote replies
visible to the caller.

209-316: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Derived stats (bytes_per_posting) go stale after additive merge.

bytes_per_posting is precomputed locally as total_postings / num_docs_val (see ft_info_text_only, src/command/vector_search/ft_info.rs:370-378). After merge_ft_info_responses sums num_docs and total_inverted_index_size across shards, bytes_per_posting is left at the local shard's pre-merge ratio, producing an internally inconsistent FT.INFO report (e.g. summed totals imply one ratio, bytes_per_posting reports another). avg_doc_len has the same staleness issue but can't be trivially recomputed from the currently-additive fields alone (would need an additional additive "total doc length" field to properly weight the average).

Consider recomputing bytes_per_posting from the merged totals after the loop.

♻️ Suggested recompute after the merge loop
     }

+    // Recompute derived stat now that num_docs/total_inverted_index_size are merged.
+    if let (Some(ti), Some(ndi), Some(bpi)) = (
+        value_idx(&items, b"total_inverted_index_size"),
+        value_idx(&items, b"num_docs"),
+        value_idx(&items, b"bytes_per_posting"),
+    ) {
+        if let (Some(total), Some(docs)) = (int_at(&items, ti), int_at(&items, ndi)) {
+            items[bpi] = Frame::Integer(if docs > 0 { total / docs } else { 0 });
+        }
+    }
+
     Frame::Array(items.into())
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/command/vector_search/ft_info.rs` around lines 209 - 316,
`merge_ft_info_responses` leaves the derived `bytes_per_posting` field stale
after it adds shard totals, so the merged FT.INFO output becomes internally
inconsistent. After the additive merge loop in `merge_ft_info_responses`,
recompute `bytes_per_posting` from the merged `total_inverted_index_size` and
`num_docs`/posting totals for the top-level FT info frame, using the same logic
as `ft_info_text_only` to keep the report consistent. Keep `avg_doc_len`
unchanged unless you introduce an additional additive source field for it.
tests/vector_update_tombstone.rs (1)

1-131: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good regression coverage for the default field; additional-field path is untested.

Both tests exercise handle_vector_insert (default field, field_idx == 0) via ft_create_args/hset, which only ever indexes a single "vec" field. The parallel VEC-1 fix in handle_vector_insert_field (src/shard/spsc_handler.rs, additional-field tombstoning) has no equivalent test here — an update to a non-default vector field after compaction would not be caught by this suite.

Consider adding a multi-field variant (schema with two VECTOR fields) exercising the hset_update_after_compaction_... scenario against the additional field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vector_update_tombstone.rs` around lines 1 - 131, The current
regression only covers the default vector field path through
handle_vector_insert via ft_create_args and hset, so it misses the
additional-field tombstoning logic in handle_vector_insert_field. Add a new
multi-field test variant (schema with two VECTOR fields) that updates a
non-default vector field after compaction, mirroring
hset_update_after_compaction_tombstones_immutable_copy, and assert the updated
key appears exactly once so stale copies in the secondary field are caught.
src/command/vector_search/tests.rs (1)

684-686: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Leftover sq quantization is now dead work. After the append signature change these blocks still build sq via quantize_f32_to_sq(v, &mut sq) but never pass it anywhere, so the computation is discarded. Consider dropping the sq/quantize_f32_to_sq lines at these call sites to avoid confusion during future edits.

♻️ Example cleanup (repeat at each listed site)
-        let mut sq = vec![0i8; dim];
-        quantize_f32_to_sq(v, &mut sq);
         snap.mutable.append(i as u64, v, i as u64);

Also applies to: 2420-2422, 2434-2436, 2524-2526, 2810-2812, 3152-3154, 3350-3352

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/command/vector_search/tests.rs` around lines 684 - 686, Remove the
leftover SQ quantization work at each vector_search test call site: in the
blocks around append on snap.mutable, the local sq buffer and
quantize_f32_to_sq(v, &mut sq) are no longer used after the append signature
change. Update the affected test cases to call append directly and drop the dead
sq-related lines in vector_search/tests.rs wherever this pattern appears,
including the other listed append sites.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/vector-validate.py`:
- Around line 225-238: The rss_mb() helper currently mis-scales RSS values
because its fallback assumes every non-MB/GB unit is KB. Update the unit
handling in rss_mb() so the RSS parsing branch explicitly supports B, KB, MB,
GB, and TB using the correct conversion to megabytes, while keeping the existing
MEMORY DOCTOR parsing logic and rss_mb() return behavior intact.

In `@src/command/vector_search/ft_config.rs`:
- Around line 134-158: The MERGE_RECALL_TOLERANCE branch in ft_config.rs only
updates idx.merge_recall_tolerance in memory, so the value is lost on restart.
Persist this setting alongside the existing index metadata used by the vector
sidecar, and update the save/load path so the field is serialized and restored
with compaction_weight. Make the changes in the metadata handling and the code
paths that read/write the index state so MERGE_RECALL_TOLERANCE survives
restarts.

In `@src/shard/spsc_handler.rs`:
- Around line 2150-2162: DEL/UNLINK vector tombstones are applied without
rollback tracking, so they bypass cross-store transaction undo. Update the
DEL/UNLINK path that calls auto_delete_vectors in spsc_handler to also record a
matching vector intent in the active txn state, consistent with how
abort_cross_store_txn replays txn.vector_intents. Use the same transaction
plumbing already used by the HSET auto-index flow so TXN.ABORT can restore
vector state when kv_undo restores the key.

In `@src/vector/f16.rs`:
- Around line 76-82: The subnormal path in f16_to_f32 uses an off-by-one shift
constant, causing every nonzero subnormal to decode at half the correct
magnitude. Update the leading-zero adjustment in the subnormal branch of
f16_to_f32 (the lead calculation) so the exponent is normalized correctly, then
expand the roundtrip tests in the f16 module to cover a few subnormal inputs
like the smallest and largest subnormals.

---

Outside diff comments:
In `@src/vector/store.rs`:
- Around line 177-240: The VectorIndex/store.rs file is already far beyond the
allowed single-file size, so this change should be moved out of the monolithic
store.rs module. Split the new VectorIndex fields and related logic into focused
submodules (for example around VectorIndex, compaction, or key-hash tracking),
and update the main store.rs to just re-export or wire them together. Use the
VectorIndex struct and its associated methods as the anchor points for
extracting the code.

---

Nitpick comments:
In `@src/command/vector_search/ft_config.rs`:
- Around line 134-158: Add coverage for the new FT.CONFIG MERGE_RECALL_TOLERANCE
parameter by creating a unit test that exercises FT.CONFIG SET and GET on an
index and verifies the value is accepted and persisted through the existing
FT.CONFIG handling in ft_config.rs. Follow the pattern of
test_ft_create_merge_mode_keep_raw_rejected for locating the right test module,
and add a corresponding consistency test entry as required by the coding
guidelines so this new command behavior is covered end-to-end.

In `@src/command/vector_search/ft_info.rs`:
- Around line 263-267: Non-`Array` remote responses in the ft_info aggregate are
being skipped instead of surfaced, which breaks the fail-loud behavior described
by the doc comment. Update the aggregation logic in the ft_info path so the
match on each remote response does not `continue` on unexpected shapes; instead,
return a `Frame::Error` for any response that is neither `Frame::Array` nor the
expected error form. Use the existing ft_info aggregation code and mirror the
defensive pattern used by `scatter_invalidate_range` in `coordinator` to keep
unexpected remote replies visible to the caller.
- Around line 209-316: `merge_ft_info_responses` leaves the derived
`bytes_per_posting` field stale after it adds shard totals, so the merged
FT.INFO output becomes internally inconsistent. After the additive merge loop in
`merge_ft_info_responses`, recompute `bytes_per_posting` from the merged
`total_inverted_index_size` and `num_docs`/posting totals for the top-level FT
info frame, using the same logic as `ft_info_text_only` to keep the report
consistent. Keep `avg_doc_len` unchanged unless you introduce an additional
additive source field for it.

In `@src/command/vector_search/tests.rs`:
- Around line 684-686: Remove the leftover SQ quantization work at each
vector_search test call site: in the blocks around append on snap.mutable, the
local sq buffer and quantize_f32_to_sq(v, &mut sq) are no longer used after the
append signature change. Update the affected test cases to call append directly
and drop the dead sq-related lines in vector_search/tests.rs wherever this
pattern appears, including the other listed append sites.

In `@tests/vector_update_tombstone.rs`:
- Around line 1-131: The current regression only covers the default vector field
path through handle_vector_insert via ft_create_args and hset, so it misses the
additional-field tombstoning logic in handle_vector_insert_field. Add a new
multi-field test variant (schema with two VECTOR fields) that updates a
non-default vector field after compaction, mirroring
hset_update_after_compaction_tombstones_immutable_copy, and assert the updated
key appears exactly once so stale copies in the secondary field are caught.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8f07e894-3037-4ef4-877a-14d5207e2462

📥 Commits

Reviewing files that changed from the base of the PR and between eadc8b8 and 098cf3e.

📒 Files selected for processing (49)
  • CHANGELOG.md
  • CLAUDE.md
  • Cargo.toml
  • benches/sq8_adc_bench.rs
  • scripts/gcloud-vector-soak.sh
  • scripts/vector-validate.py
  • src/command/connection.rs
  • src/command/vector_search/ft_config.rs
  • src/command/vector_search/ft_create.rs
  • src/command/vector_search/ft_info.rs
  • src/command/vector_search/ft_search/dispatch.rs
  • src/command/vector_search/ft_search/execute.rs
  • src/command/vector_search/hybrid.rs
  • src/command/vector_search/mod.rs
  • src/command/vector_search/tests.rs
  • src/server/conn/handler_monoio/ft.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/handler_sharded/ft.rs
  • src/server/conn/handler_single.rs
  • src/server/conn/shared.rs
  • src/shard/coordinator.rs
  • src/shard/persistence_tick.rs
  • src/shard/spsc_handler.rs
  • src/vector/distance/avx2.rs
  • src/vector/distance/avx512.rs
  • src/vector/distance/mod.rs
  • src/vector/distance/neon.rs
  • src/vector/f16.rs
  • src/vector/hnsw/graph.rs
  • src/vector/hnsw/search.rs
  • src/vector/metrics.rs
  • src/vector/mod.rs
  • src/vector/persistence/recovery.rs
  • src/vector/persistence/segment_io.rs
  • src/vector/segment/compaction.rs
  • src/vector/segment/holder.rs
  • src/vector/segment/immutable.rs
  • src/vector/segment/mutable.rs
  • src/vector/store.rs
  • src/vector/turbo_quant/sq8.rs
  • tests/moonstore_warm_e2e.rs
  • tests/vector_del_unindex.rs
  • tests/vector_edge_cases.rs
  • tests/vector_exact_rerank.rs
  • tests/vector_insert_bench.rs
  • tests/vector_memory_audit.rs
  • tests/vector_segment_merge.rs
  • tests/vector_stress.rs
  • tests/vector_update_tombstone.rs

Comment on lines +225 to +238
def rss_mb(c):
r = c.cmd("MEMORY", "DOCTOR")
if isinstance(r, (bytes, str)):
text = r.decode() if isinstance(r, bytes) else r
for line in text.splitlines():
if "RSS:" in line:
parts = line.split()
try:
val = float(parts[-2])
unit = parts[-1]
return val * (1024 if unit == "GB" else 1) if unit in ("MB", "GB") else val / 1024
except (ValueError, IndexError):
pass
return -1.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the string/format rss_mb() depends on actually exists in the server output path.
rg -nP -C3 '\bRSS\b' --type=rust src/command src/ | rg -i 'memory|doctor|rss'
rg -nP -C3 'MEMORY|DOCTOR' --type=rust src/command | head -50

Repository: pilotspace/moon

Length of output: 11939


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the MEMORY DOCTOR formatter and the rss_mb parser.
rg -n "humanize_bytes|rss_mb|MEMORY DOCTOR|RSS:" src scripts/vector-validate.py

# Show the relevant implementation slices with line numbers.
sed -n '1,220p' src/command/server_admin.rs | cat -n
printf '\n---\n'
sed -n '220,280p' scripts/vector-validate.py | cat -n

Repository: pilotspace/moon

Length of output: 15979


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact unit formatting used for RSS output.
sed -n '520,560p' src/command/server_admin.rs | cat -n

Repository: pilotspace/moon

Length of output: 1733


Handle B and TB RSS outputs rss_mb() treats every non-MB/GB unit as val / 1024, which is only correct for KB; B and TB are mis-scaled and can skew the soak’s RSS growth check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/vector-validate.py` around lines 225 - 238, The rss_mb() helper
currently mis-scales RSS values because its fallback assumes every non-MB/GB
unit is KB. Update the unit handling in rss_mb() so the RSS parsing branch
explicitly supports B, KB, MB, GB, and TB using the correct conversion to
megabytes, while keeping the existing MEMORY DOCTOR parsing logic and rss_mb()
return behavior intact.

Comment on lines +134 to +158
} else if param.eq_ignore_ascii_case(b"MERGE_RECALL_TOLERANCE") {
// VEC-4: recall gate for UNATTENDED (background/vacuum) GraphUnion
// merges. Default 0.70 catches only catastrophic collapse; operators
// running recall-sensitive workloads can tighten toward the manual
// FT.COMPACT gate (0.90). Raising it can make auto-merge abort
// repeatedly on small indexes (segments stay > threshold) — informed
// trade-off, hence a knob rather than a new default.
let parsed: f32 = match std::str::from_utf8(value)
.ok()
.and_then(|s| s.parse::<f32>().ok())
{
Some(v) => v,
None => {
return Frame::Error(Bytes::from_static(
b"ERR MERGE_RECALL_TOLERANCE must be a number",
));
}
};
if !(0.0..=1.0).contains(&parsed) {
return Frame::Error(Bytes::from_static(
b"ERR MERGE_RECALL_TOLERANCE must be between 0.0 and 1.0",
));
}
idx.merge_recall_tolerance = parsed;
Frame::SimpleString(Bytes::from_static(b"OK"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether merge_recall_tolerance is persisted/restored anywhere else.
rg -n "merge_recall_tolerance" -C3
rg -n "collect_index_metas_with_weights|save_index_metadata_v3" -C5

Repository: pilotspace/moon

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant config handler and persistence code.
git ls-files | rg '^src/command/vector_search/ft_config\.rs$|^src/vector/store\.rs$|^src/command/vector_search/' || true

echo '--- ft_config.rs outline ---'
ast-grep outline src/command/vector_search/ft_config.rs --view expanded || true

echo '--- store.rs outline ---'
ast-grep outline src/vector/store.rs --view expanded || true

echo '--- search for sidecar persistence and merge recall tolerance ---'
rg -n "merge_recall_tolerance|compaction_weight|save_index_meta_sidecar|collect_index_metas_with_weights|save_index_metadata_v3" src -C 3 || true

Repository: pilotspace/moon

Length of output: 28849


MERGE_RECALL_TOLERANCE needs persistence across restarts. This branch updates idx.merge_recall_tolerance, but the vector sidecar currently only stores compaction_weight, so the value resets to 0.70 after a restart. Persist it with the rest of the index metadata and extend the load/save format accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/command/vector_search/ft_config.rs` around lines 134 - 158, The
MERGE_RECALL_TOLERANCE branch in ft_config.rs only updates
idx.merge_recall_tolerance in memory, so the value is lost on restart. Persist
this setting alongside the existing index metadata used by the vector sidecar,
and update the save/load path so the field is serialized and restored with
compaction_weight. Make the changes in the metadata handling and the code paths
that read/write the index state so MERGE_RECALL_TOLERANCE survives restarts.

Comment thread src/shard/spsc_handler.rs
Comment on lines +2150 to +2162
/// Tombstone auto-indexed vectors for every key argument of a successful
/// DEL/UNLINK. Wire-parity requirement: every dispatch path that runs
/// `auto_index_hset*` on HSET must run this on DEL/UNLINK, or deleted keys
/// keep matching FT.SEARCH forever (resurrection + live-set recall collapse;
/// found by the Bundle-5 soak diagnostic at shards=1).
pub fn auto_delete_vectors(vector_store: &mut VectorStore, args: &[crate::protocol::Frame]) {
for arg in args {
if let Some(key) = crate::server::connection::extract_bytes(arg) {
vector_store.mark_deleted_for_key(key.as_ref());
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether TXN.ABORT has any compensation path for vector deletes,
# analogous to record_vector's undo path for HSET inserts.
rg -n 'mark_deleted_for_key|record_vector|VectorIntent' src/transaction/abort.rs -A3 -B3
rg -n 'fn mark_deleted_for_key' -A 25 src/vector/store.rs

Repository: pilotspace/moon

Length of output: 1653


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect transaction abort logic for any vector-delete compensation or undo hooks.
rg -n 'auto_delete_vectors|mark_deleted_for_key|VectorIntent|kv_undo|undo' src/transaction/abort.rs src -A4 -B4

# Inspect the helper and its nearby call sites / txn gating.
rg -n 'fn auto_delete_vectors|auto_delete_vectors\(' src/shard/spsc_handler.rs src -A4 -B4

# Find all direct references to delete-vector helper across handlers.
rg -n 'auto_delete_vectors' src -g '*.rs'

Repository: pilotspace/moon

Length of output: 50374


🏁

Length of output: 25


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show any vector-related undo/abort logic and the helper's wiring points.
sed -n '1,260p' src/transaction/abort.rs | nl -ba | sed -n '1,260p' | rg -n 'vector|Vector|kv_undo|undo|abort|tombstone|delete|record_vector'
printf '\n--- auto_delete_vectors call sites ---\n'
rg -n 'auto_delete_vectors' src -g '*.rs' -A2 -B2
printf '\n--- txn_id gating around vector insert/delete helpers ---\n'
rg -n 'txn_id\s*==\s*0|txn_id' src/shard/spsc_handler.rs -A2 -B2 | rg -n 'handle_vector_insert|handle_vector_insert_field|auto_delete_vectors|mark_deleted_for_key|txn_id'

Repository: pilotspace/moon

Length of output: 189


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect txn abort handling and all current uses of the delete helper.
if [ -f src/transaction/abort.rs ]; then
  printf '%s\n' '--- src/transaction/abort.rs ---'
  rg -n 'vector|Vector|kv_undo|undo|abort|tombstone|delete|record_vector' src/transaction/abort.rs -A3 -B3 || true
fi

printf '\n%s\n' '--- auto_delete_vectors call sites ---'
rg -n 'auto_delete_vectors' src -g '*.rs' -A2 -B2 || true

printf '\n%s\n' '--- txn_id gating around vector insert/delete helpers ---'
rg -n 'txn_id\s*==\s*0|handle_vector_insert|handle_vector_insert_field|auto_delete_vectors|mark_deleted_for_key' src/shard/spsc_handler.rs -A2 -B2 || true

Repository: pilotspace/moon

Length of output: 22094


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the active cross-txn DEL/UNLINK and HSET branches in the sharded handler.
sed -n '1260,1420p' src/server/conn/handler_sharded/mod.rs | cat -n

printf '\n--- monoio delete wiring ---\n'
sed -n '1288,1325p' src/server/conn/handler_monoio/mod.rs | cat -n

printf '\n--- shared post-dispatch auto-delete path ---\n'
sed -n '230,255p' src/server/conn/shared.rs | cat -n

printf '\n--- vector intent field definitions ---\n'
rg -n 'struct CrossStoreTxn|vector_intents|VectorIntent' src/transaction src/server/conn -A4 -B4

Repository: pilotspace/moon

Length of output: 22356


🏁

Length of output: 25


DEL/UNLINK need vector rollback plumbing.

abort_cross_store_txn only replays txn.vector_intents, but the new auto_delete_vectors(...) calls never record a matching intent. If DEL/UNLINK runs inside an active cross-store TXN, the vector tombstone becomes visible immediately and won’t be undone by TXN.ABORT even if kv_undo restores the key.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shard/spsc_handler.rs` around lines 2150 - 2162, DEL/UNLINK vector
tombstones are applied without rollback tracking, so they bypass cross-store
transaction undo. Update the DEL/UNLINK path that calls auto_delete_vectors in
spsc_handler to also record a matching vector intent in the active txn state,
consistent with how abort_cross_store_txn replays txn.vector_intents. Use the
same transaction plumbing already used by the HSET auto-index flow so TXN.ABORT
can restore vector state when kv_undo restores the key.

Comment thread src/vector/f16.rs
Comment on lines +76 to +82
} else {
// Subnormal: normalize by shifting the mantissa up.
let lead = mant.leading_zeros() - 21; // Zeros above bit 9.
let exp32 = 127 - 15 - lead;
let mant32 = (mant << (lead + 1)) & 0x03FF;
sign | (exp32 << 23) | (mant32 << 13)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and nearby tests.
git ls-files src/vector/f16.rs
wc -l src/vector/f16.rs
sed -n '1,220p' src/vector/f16.rs

# Find tests or references for subnormal handling.
rg -n "known_values_roundtrip_exact|relative_error_bounded|f16_to_f32|subnormal|0x0001|0x0200|0x03FF" src -S

Repository: pilotspace/moon

Length of output: 7864


🏁 Script executed:

python3 - <<'PY'
import struct, math

def current(bits):
    sign = ((bits & 0x8000) << 16) & 0xffffffff
    exp = (bits >> 10) & 0x1f
    mant = bits & 0x3ff
    if exp == 0:
        if mant == 0:
            out = sign
        else:
            lead = mant.bit_length()  # 1..10
            lz = 32 - lead
            lead = lz - 21
            exp32 = 127 - 15 - lead
            mant32 = (mant << (lead + 1)) & 0x03FF
            out = sign | (exp32 << 23) | (mant32 << 13)
    elif exp == 0x1f:
        out = sign | 0x7f800000 | (mant << 13)
    else:
        out = sign | ((exp + 127 - 15) << 23) | (mant << 13)
    return struct.unpack('<f', struct.pack('<I', out & 0xffffffff))[0]

def exact(bits):
    sign = -1.0 if (bits & 0x8000) else 1.0
    exp = (bits >> 10) & 0x1f
    mant = bits & 0x3ff
    if exp == 0:
        if mant == 0:
            return math.copysign(0.0, sign)
        return sign * mant * 2.0**-24
    if exp == 0x1f:
        return math.copysign(math.inf if mant == 0 else math.nan, sign)
    return sign * (1.0 + mant / 1024.0) * 2.0**(exp - 15)

bad = []
for bits in range(0x0001, 0x0400):
    c = current(bits)
    e = exact(bits)
    if c != e:
        bad.append((bits, c, e))
        if len(bad) >= 10:
            break

print("mismatches:", len(bad))
for b, c, e in bad:
    print(hex(b), c, e)

for b in [0x0001, 0x0200, 0x03FF]:
    print(hex(b), current(b), exact(b))
PY

Repository: pilotspace/moon

Length of output: 796


Subnormal f16_to_f32 is off by one exponent (all subnormals decode at half magnitude).

lead = mant.leading_zeros() - 21 is one too small in the subnormal branch, so every nonzero subnormal comes back 2× too small. For example, 0x0001 decodes to 2^-25 instead of 2^-24, and 0x0200 decodes to 2^-16 instead of 2^-15. Change the constant to 22, and add a few subnormal cases to the roundtrip test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vector/f16.rs` around lines 76 - 82, The subnormal path in f16_to_f32
uses an off-by-one shift constant, causing every nonzero subnormal to decode at
half the correct magnitude. Update the leading-zero adjustment in the subnormal
branch of f16_to_f32 (the lead calculation) so the exponent is normalized
correctly, then expand the roundtrip tests in the f16 module to cover a few
subnormal inputs like the smallest and largest subnormals.

Source: Coding guidelines

Adds scripts/bench-vector-vs-redisearch.py: because Moon speaks the
RediSearch FT.* wire dialect, one driver benches both engines with
identical protocol, data, and ground truth (numpy brute-force cosine):

- insert throughput (pipelined HSET, vectors auto-indexed)
- FT.SEARCH KNN-10 QPS + p50/p99 (single connection, 10s tight loop)
- R@10 on two 384d unit-vector datasets:
  * gaussian — iid random (concentration-of-distances hard case)
  * clustered — 200-center mixture, sigma 0.04 => members at ~0.8
    cosine to their center, in-distribution queries (real-embedding-
    like neighborhoods; larger sigmas degenerate to the random regime)
- RediSearch EF_RUNTIME sweep (default/64/128/256/512) so search QPS
  is compared at MATCHED recall, not mismatched engine defaults

RediSearch runs FLOAT32 HNSW via --loadmodule redisearch.so
(REDISEARCH_SO env); Moon runs SQ8 + TQ4 pre- and post-FT.COMPACT.
--only moon|redisearch supports single-engine smoke runs.

Measured on GCE c3-standard-8 (results posted to PR #214): Moon wins
insert 24-38x (86k vs 2.3-3.5k vec/s) and hard-dataset recall at
matched latency; RediSearch keeps ~3.8x search QPS at matched
recall=1.0 on well-clustered data.

refs: PR #214 follow-up bench promised in the PR body
author: Tin Dang
@pilotspacex-byte

Copy link
Copy Markdown
Contributor Author

Head-to-head: Moon (this branch) vs RediSearch 7.4 — results

As promised in the PR body. Setup: GCE c3-standard-8 (x86 Sapphire Rapids — RediSearch's AVX-optimized home turf), Ubuntu 24.04, RediSearch 7.4.0-v7 (redisearch.so on redis-stack redis-server, FLOAT32 HNSW), Moon = this branch (SQ8 / TQ4, post-FT.COMPACT). 20k × 384d unit vectors, K=10, single connection, R@10 vs exact numpy cosine ground truth. Driver: scripts/bench-vector-vs-redisearch.py (a9429667); both engines are driven over the identical FT.* wire protocol. Numbers replicated across 2–3 independent runs (variation < 3%).

Two datasets: clustered (200-center mixture, members at ~0.8 cosine to center, in-distribution queries — real-embedding-like) and gaussian (iid random — the concentration-of-distances hard case). RediSearch gets an EF_RUNTIME sweep so QPS is compared at matched recall, not mismatched defaults.

Insert / indexing throughput

Engine vec/s (gaussian) vec/s (clustered)
RediSearch FLOAT32 HNSW 2,286 3,530
Moon SQ8 86,285 86,408
Moon TQ4 44,705 44,772

Moon 24–38× faster. (Moon ingests via mutable segment + background HNSW build; RediSearch builds the graph synchronously on write.)

Search — clustered (realistic embeddings-like)

Config R@10 QPS p50
RediSearch ef-default(10) 0.904 12,720 0.081 ms
RediSearch ef-64 1.000 6,899 0.143 ms
RediSearch ef-512 1.000 1,254 0.794 ms
Moon SQ8 1.000 1,794 0.565 ms
Moon TQ4 1.000 573 1.758 ms

At matched recall (1.0): RediSearch wins search QPS ~3.8× (6,899 vs 1,794). Note the June-2026 baseline had this gap at 16× with a recall deficit (0.86 vs 0.96) — HQ-1/HQ-2 closed it to 3.8× at recall parity, and TQ4's exact-rerank now reaches 1.0 recall on realistic data.

Search — gaussian (hard / high-entropy)

Config R@10 QPS p50
RediSearch ef-default(10) 0.062 11,216 0.090 ms
RediSearch ef-64 0.283 4,086 0.240 ms
RediSearch ef-128 0.444 2,530 0.363 ms
RediSearch ef-256 0.637 1,588 0.665 ms
RediSearch ef-512 0.838 958 1.044 ms
Moon SQ8 0.772 1,261 0.799 ms
Moon TQ4 0.730 362 2.765 ms

Interpolating RediSearch to Moon's 0.772 recall (~ef-384) gives ≈1,100–1,200 QPS: parity, slight Moon edge, with lower p50. Where the data is hard, quantized-beam + exact rerank holds recall that a pure fp32 HNSW beam only reaches at large EF.

Verdict ("does this beat Redis?")

  • Insert/indexing: yes, decisively — 24–38×.
  • Hard/high-entropy vectors: yes — better recall-per-latency; parity at matched recall.
  • Well-clustered data, raw search QPS at matched recall: not yet — RediSearch ~3.8× ahead (was 16× + recall deficit before this branch).
  • Payload memory: Moon SQ8 stores 1,152 B/vec (codes + f16 rerank sidecar) vs 1,536 B/vec fp32 (~25% less); TQ4 960 B/vec (~37% less).

Raw JSONs: tmp/vs-redisearch-c3-run3.json (gaussian + EF sweep), tmp/vs-redisearch-c3-run4.json (corrected clustered + EF sweep).

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/bench-vector-vs-redisearch.py`:
- Around line 10-12: The module docstring in bench-vector-vs-redisearch.py is
out of sync with the actual clustered dataset default. Update the header
description for the clustered dataset to match gen_clustered and its default
sigma value, and make sure the rationale in the docstring stays consistent with
the implementation details in gen_clustered. Keep the wording aligned with the
symbols gen_clustered and the module docstring so future readers see the correct
dataset construction.
- Around line 171-198: The fallback in knn_ids currently retries FT.SEARCH
without both NOCONTENT and EF_RUNTIME, which can silently change ef-* benchmark
behavior. Update the retry path in knn_ids so it only removes NOCONTENT while
preserving EF_RUNTIME when present, or otherwise explicitly report that
EF_RUNTIME is unsupported. Keep the logic that parses the FT.SEARCH response and
only adjust the query construction used in the retry branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ade2f1f1-3adb-421a-8646-63a96d31a1b8

📥 Commits

Reviewing files that changed from the base of the PR and between 098cf3e and a942966.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • scripts/bench-vector-vs-redisearch.py
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md

Comment on lines +10 to +12
Datasets (384d unit vectors):
gaussian — iid random Gaussian, normalized (harsh: concentration of distances)
clustered — 200-center Gaussian mixture, sigma 0.25 (closer to real embeddings)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Docstring sigma value is stale.

The module docstring says clustered data uses sigma 0.25, but gen_clustered's actual default is sigma=0.04 (matching the inline rationale at Lines 152-155). Future readers relying on the header docs would misunderstand the actual dataset construction.

📝 Proposed fix
-  clustered  — 200-center Gaussian mixture, sigma 0.25 (closer to real embeddings)
+  clustered  — 200-center Gaussian mixture, sigma 0.04 (closer to real embeddings)

Also applies to: 149-163

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/bench-vector-vs-redisearch.py` around lines 10 - 12, The module
docstring in bench-vector-vs-redisearch.py is out of sync with the actual
clustered dataset default. Update the header description for the clustered
dataset to match gen_clustered and its default sigma value, and make sure the
rationale in the docstring stays consistent with the implementation details in
gen_clustered. Keep the wording aligned with the symbols gen_clustered and the
module docstring so future readers see the correct dataset construction.

Comment on lines +171 to +198
def knn_ids(c, idx, q, k=K, ef_runtime=None):
query = f"*=>[KNN {k} @vec $B" + (f" EF_RUNTIME {ef_runtime}" if ef_runtime else "") + "]"
r = c.cmd(
"FT.SEARCH", idx, query, "PARAMS", "2", "B", q.tobytes(),
"NOCONTENT", "DIALECT", "2",
)
if isinstance(r, Exception):
# Engines differ on NOCONTENT / EF_RUNTIME support — retry plain.
r = c.cmd(
"FT.SEARCH", idx, f"*=>[KNN {k} @vec $B]",
"PARAMS", "2", "B", q.tobytes(), "DIALECT", "2",
)
if isinstance(r, Exception):
raise RuntimeError(f"FT.SEARCH failed: {r}")
keys = [r[i] for i in range(1, len(r), 2)]
else:
# NOCONTENT reply: [total, key1, key2, ...]; with-content: keys at odd idx.
body = r[1:]
if body and isinstance(body[0], bytes) and len(body) >= 2 and isinstance(body[1], list):
keys = body[0::2]
else:
keys = body
out = []
for kk in keys:
s = kk.decode() if isinstance(kk, bytes) else str(kk)
if ":" in s:
out.append(int(s.split(":", 1)[1]))
return out

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and inspect the relevant section with line numbers.
wc -l scripts/bench-vector-vs-redisearch.py
sed -n '140,230p' scripts/bench-vector-vs-redisearch.py

# Search for related FT.SEARCH usage and any notes about EF_RUNTIME/NOCONTENT.
rg -n "FT\.SEARCH|EF_RUNTIME|NOCONTENT|DIALECT" scripts/bench-vector-vs-redisearch.py README* docs* . 2>/dev/null || true

Repository: pilotspace/moon

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect Redis/RediSearch docs in the repo if present.
fd -a -t f -g '*redisearch*' -g '*search*' .

Repository: pilotspace/moon

Length of output: 265


🌐 Web query:

Redis FT.SEARCH syntax NOCONTENT PARAMS DIALECT order EF_RUNTIME RediSearch command argument validation

💡 Result:

The FT.SEARCH command in Redis Search (RediSearch) follows a specific syntax structure [1][2]. Parameters such as NOCONTENT, PARAMS, and DIALECT modify the command behavior, with DIALECT being particularly important for query parsing and feature availability [1][2][3]. Command Syntax Structure: FT.SEARCH index query [NOCONTENT] [TIMEOUT timeout] [PARAMS nargs name value...] [RETURN num field [AS alias]...] [LIMIT offset num] [DIALECT version] Key Arguments and Their Roles: 1. NOCONTENT: This optional flag instructs the command to return only the document IDs, excluding the actual field content [1][2]. It is useful for reducing network traffic [1][4]. 2. PARAMS: Allows passing external parameters into the query string, which is essential for vector search and security (to avoid injection) [1][4]. The argument 'nargs' must specify the count of key-value pairs multiplied by two [1][2]. 3. DIALECT: Specifies the query syntax version [3]. RediSearch supports multiple dialects (e.g., 1, 2, 3, 4), and newer dialects are often required for advanced query features, specific parsing improvements, and performance optimizations [3][5][6]. Dialect 2 or higher is typically recommended for modern search functionality [3]. 4. EF_RUNTIME: While often discussed in the context of vector search, this parameter can be passed via the PARAMS argument to tune the search runtime for K-Nearest Neighbors (KNN) operations, controlling the size of the dynamic candidate list [7]. Validation and Parsing: RediSearch has evolved its query parsing and validation logic in recent versions [8][5]. Recent updates, including changes in Redis 8, have focused on stricter validation, such as ensuring numeric range boundaries (min <= max) are correctly formed and dropping support for legacy features like the old FILTER and GEOFILTER options in favor of modern query syntax [8][5]. When using higher dialect versions, the engine applies more rigorous validation on field types and query predicates [5]. For optimal performance and to utilize the latest features, it is recommended to explicitly set the DIALECT and ensure queries adhere to current syntax standards as defined in the official Redis Search documentation [3][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the call sites and how benchmark output uses the returned IDs.
rg -n "knn_ids\(|ef_runtime|recall|sweep|json|print" scripts/bench-vector-vs-redisearch.py

# Read the main loop / reporting region.
sed -n '1,260p' scripts/bench-vector-vs-redisearch.py

Repository: pilotspace/moon

Length of output: 10059


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '109,120p' docs/vector-search-guide.md
sed -n '300,320p' scripts/bench-vector-vs-redisearch.py

Repository: pilotspace/moon

Length of output: 1642


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find any documented vector-search examples that include NOCONTENT.
rg -n "FT\.SEARCH.*NOCONTENT|NOCONTENT.*PARAMS|NOCONTENT.*DIALECT|RETURN 0" docs scripts README.md

# Show the vector-search guide section around FT.SEARCH examples if it mentions NOCONTENT.
sed -n '1,140p' docs/vector-search-guide.md

Repository: pilotspace/moon

Length of output: 7920


Preserve EF_RUNTIME on retry
The fallback drops EF_RUNTIME, so any NOCONTENT-related error can make every ef-* run report default-runtime results with no warning. Retry without NOCONTENT only, or surface the EF_RUNTIME incompatibility.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/bench-vector-vs-redisearch.py` around lines 171 - 198, The fallback
in knn_ids currently retries FT.SEARCH without both NOCONTENT and EF_RUNTIME,
which can silently change ef-* benchmark behavior. Update the retry path in
knn_ids so it only removes NOCONTENT while preserving EF_RUNTIME when present,
or otherwise explicitly report that EF_RUNTIME is unsupported. Keep the logic
that parses the FT.SEARCH response and only adjust the query construction used
in the retry branch.

@20k

20k commented Jul 5, 2026

Copy link
Copy Markdown

Please don't ping me with your AI slop thanks 👍

…tion

Closes the single-connection search-QPS gap vs RediSearch by attacking
per-query LATENCY (single-conn QPS = 1/latency): the per-segment HNSW
searches of ONE KNN query now fan out across a worker pool instead of
running serially on the shard task.

1. src/vector/search_pool.rs — SearchWorkerPool: std::thread workers +
   flume channels (mirrors BackgroundCompactor). Jobs carry
   Arc<ImmutableSegment>/Arc<WarmSearchSegment> references (O(1) refcount
   bumps); each worker owns a cached SearchScratch (amortized zero alloc
   per job); per-job catch_unwind containment (empty result + warn on a
   panicking segment search — never a hang; a dropped reply surfaces as
   RecvError on the caller). Global pool via OnceLock; uninitialized =
   disabled so embedded/unit-test uses see the exact serial path.

2. SegmentHolder::search_mvcc_yielding_with_pool — submits all
   immutable/warm segment searches BEFORE the mutable MVCC chunk scan
   (workers overlap with it), then awaits replies via recv_async (parks
   the task, never the shard event loop). ACORN-filtered traversal ships
   the bitmap Arc to workers; HnswPostFilter mode applies the bitmap at
   collection, mirroring the serial branch. Cold/IVF tiers stay serial
   (rare). Serial path preserved verbatim for pool=None / <2 segments.
   Identity guaranteed: SearchResult's total order (distance, then id)
   makes accumulation order immaterial — enforced by pooled-vs-serial
   identity tests + an 8-thread concurrency stress test.

3. Bounded bulk compaction (the enabler): a bulk-loaded mutable used to
   compact into ONE giant graph, leaving the pool nothing to fan out
   over. With a pool active and COMPACT_THRESHOLD > 0, each build now
   freezes at most max(threshold, len/8) entries
   (MutableSegment::freeze_prefix — prefix window, absolute
   vector_offsets stay valid, tail survives via clone_suffix exactly
   like a mid-build append; applies to both the force_compact drain
   loop and begin_background_compact). Pool-less deployments keep
   single-segment builds — multi-segment SERIAL search is strictly
   slower, so no configuration regresses. Red/green:
   test_force_compact_bulk_bounded_segments,
   test_bg_compact_bulk_bounded_segments.

4. --ft-search-workers N config: default auto = vCPUs − shards capped at
   8 (0 on shards==cores KV deployments — zero interference); 0 =
   explicit off. Parse test added.

Measured (macOS dev box, 20k×384d clustered SQ8, single conn,
post-FT.COMPACT, R@10 = 1.0 in all rows):
  - status quo (1 segment, serial):        p50 2.06 ms,  473 QPS
  - bounded 5 segments, workers=0:         p50 9.58 ms,  105 QPS
  - bounded 5 segments, workers=auto:      p50 0.46 ms, 2321 QPS (4.9×)
GCE vs-RediSearch re-run posted to PR #214.

Trade-off note: each segment is searched at the full resolved ef, so a
pooled multi-segment query does ~Nseg× the CPU work of a single-graph
query for the latency win — the same per-query cost profile as
RediSearch's MT query workers. Per-segment ef reduction is a possible
follow-up (recall-gated).

CI parity: fmt, clippy (default + tokio,jemalloc), full release +
tokio suites green.

author: Tin Dang

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/vector/search_pool.rs (1)

92-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

.expect in library code should be annotated per guidelines.

SearchWorkerPool::new lives in library code (src/vector/...), where the guideline forbids bare unwrap()/expect(). A thread-spawn failure here is a reasonable panic, but it must carry an explicit allow + justification (as main.rs does for its own spawns).

♻️ Suggested annotation
                 let rx = job_rx.clone();
+                // ALLOW: spawning the fixed startup worker set; failure here is
+                // an unrecoverable process-init error.
+                #[allow(clippy::expect_used)]
                 std::thread::Builder::new()
                     .name(format!("moon-vec-search-{i}"))
                     .spawn(move || worker_loop(&rx))
                     .expect("failed to spawn vector search worker thread")

As per coding guidelines: "Do not use unwrap() or expect() in library code outside tests ... If an unwrap() is truly safe, annotate it with #[allow(clippy::unwrap_used)] and add a one-line justification comment."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vector/search_pool.rs` around lines 92 - 112, SearchWorkerPool::new
currently uses a bare expect() when spawning worker threads, which violates the
library-code guideline. Update the worker spawn in SearchWorkerPool::new to add
the required #[allow(clippy::unwrap_used)] annotation with a one-line
justification comment explaining why the panic is acceptable here, matching the
pattern used in main.rs. Keep the change localized around the
thread::Builder::spawn call and worker_loop path so the intent is clear to
future readers.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/vector/search_pool.rs`:
- Around line 92-112: SearchWorkerPool::new currently uses a bare expect() when
spawning worker threads, which violates the library-code guideline. Update the
worker spawn in SearchWorkerPool::new to add the required
#[allow(clippy::unwrap_used)] annotation with a one-line justification comment
explaining why the panic is acceptable here, matching the pattern used in
main.rs. Keep the change localized around the thread::Builder::spawn call and
worker_loop path so the intent is clear to future readers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7fbc3fe5-508c-4fcf-aaee-170ae2ffae2c

📥 Commits

Reviewing files that changed from the base of the PR and between a942966 and 414fd1f.

📒 Files selected for processing (12)
  • CHANGELOG.md
  • scripts/bench-vector-vs-redisearch.py
  • src/config.rs
  • src/main.rs
  • src/vector/mod.rs
  • src/vector/search_pool.rs
  • src/vector/segment/holder.rs
  • src/vector/segment/mutable.rs
  • src/vector/store.rs
  • tests/mq_integration.rs
  • tests/txn_kv_wiring.rs
  • tests/workspace_integration.rs
✅ Files skipped from review due to trivial changes (4)
  • tests/txn_kv_wiring.rs
  • tests/mq_integration.rs
  • src/vector/mod.rs
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • scripts/bench-vector-vs-redisearch.py
  • src/vector/segment/mutable.rs

GCE c3-standard-8 head-to-head (run 5) showed the auto default (vCPUs −
shards) REGRESSING clustered SQ8 at the default resolved ef: 1,732 QPS
(pool-off control, byte-identical to pre-change) → 1,165 QPS pooled.
Probe matrix on the same instance isolated the cause: it is the ef work
multiplier (each of the 5 segments pays the full ef=300 — workers=3 vs
workers=7 within 3%, so NOT SMT oversubscription), which on a 4-physical-
core box exceeds the parallel win. On a 10-physical-core macOS box the
same default is a 4.9x QPS win (473 -> 2,321, R@10=1.0).

A knob that regresses at defaults on common cloud shapes must not be a
silent tax — same posture as --io-busy-poll-us: measured opt-in with
documented per-platform guidance (good size: physical cores − shards,
cap 8; auto_workers() stays as the reference sizing).

Consequence: with the pool off by default, bounded bulk compaction (gated
on pool presence) is also off by default — FT.COMPACT default behavior is
byte-identical to v0.5.1, so this feature is zero-risk at defaults.

Probe bonus finding (documented in CHANGELOG): the existing per-index
EF_RUNTIME knob measured fairly closes most of the vs-RediSearch
clustered gap serially — ef 64: 4,493 QPS at R@10=1.0 vs RediSearch
5,864 (was 3.8x at ef-default-vs-ef300, now ~1.3x). Full tables in
PR #214.

author: Tin Dang
@pilotspacex-byte

Copy link
Copy Markdown
Contributor Author

GCE re-run: intra-query worker pool + fair EF-swept head-to-head (run 5 + probe matrix)

TL;DR — the 3.8× clustered search-QPS gap is now ~1.3× at matched recall 1.0, and most of it came from measuring fairly (sweeping Moon's existing per-index EF_RUNTIME knob the same way we sweep RediSearch's), not from new machinery. The worker pool (414fd1fd) is a real 4.9× on physical-core-rich boxes and a recall win on hard data, but it regresses on SMT-constrained cloud shapes — so it ships default-off, opt-in (3bcaef84), same posture as --io-busy-poll-us. FT.COMPACT default behavior is byte-identical to v0.5.1 (bulk-split is pool-gated).

Setup: GCE c3-standard-8 (8 vCPU = 4 physical cores, x86), redis-stack 7.4.0-v7 (RediSearch), 20k×384d cosine, single connection, R@10 vs exact ground truth, p50/p99 over 200 queries + 8 s QPS window. Two datasets: iid gaussian and a 200-center clustered mixture (σ=0.04). Moon = this branch, SQ8, post-FT.COMPACT rows.

What shipped

  • 414fd1fdFT.SEARCH intra-query worker pool (src/vector/search_pool.rs): the per-segment HNSW searches of ONE KNN query fan out across searcher threads while the shard task scans the mutable segment; replies awaited via flume::recv_async (shard event loop never blocks). Plus bounded bulk compaction (pool-gated): bulk loads now freeze into ≤8 bounded segments instead of one giant graph, so the pool has something to fan over. Pooled results are byte-identical to serial (identity + 8-thread stress tests); worker panics contained per-job.
  • 3bcaef84 — default flipped to --ft-search-workers 0 (opt-in) based on the numbers below.

RediSearch EF_RUNTIME sweep (clustered, the hard dataset)

EF_RUNTIME R@10 p50 QPS
default (10) 0.904 0.082 ms 11,799
64 1.000 0.150 ms 5,864
128 1.000 0.308 ms 4,012
512 1.000 0.769 ms 1,238

Matched-recall anchor: RediSearch 1.0 @ 5,864 QPS (run-to-run variance vs run 4's 6,899 is ±15%).

Moon clustered SQ8: pooled defaults vs control vs probe matrix

Run 5 at Moon's default resolved ef (300 for k=10/384d):

Config Segments R@10 p50 QPS
pool-off control (= pre-change behavior, gate ✅) 1 1.000 0.584 ms 1,732
pooled auto (workers=7) 5 0.9925 0.718 ms 1,165 ⚠

The pooled regression prompted a 4-config probe on the same instance (all R@10 = 1.000):

Probe Segments ef Workers p50 QPS
A 5 300 3 0.826 ms 1,198
B 1 64 0 (serial) 0.205 ms 4,493
C 5 64 7 0.367 ms 2,919
D 5 64 3 0.501 ms 2,396

Readings:

  • A ≈ pooled-default (1,198 vs 1,165) → the regression is NOT SMT oversubscription; it's the ef work multiplier: each segment pays the full resolved ef, so a 5-segment pooled query does ~5× the CPU of one graph — more than 4 physical cores can parallelize away.
  • B is the fair matched-recall comparison: Moon EF_RUNTIME 64, single graph, serial → 4,493 QPS @ R@10 = 1.0 vs RediSearch 5,864 → gap 1.3× (was 3.8× when comparing Moon's ef-300 default against RediSearch's swept knee).
  • C shows pool+ef64 is the best multi-segment config (2,919) — relevant because organic (non-bulk) workloads are multi-segment anyway.

Gaussian (iid) — Moon dominates the recall-QPS frontier

RediSearch never exceeds R@10 0.838 even at ef 512 (927 QPS; 0.062 at default ef). Moon SQ8: serial 0.772 @ 1,218 QPS; pooled 5-segment 0.9855 @ 730 QPS — a recall level RediSearch doesn't reach at any swept setting. (The +0.21 recall from pooling is the multi-segment oversampling effect: 5 graphs × full ef ≫ one graph's candidate set.)

Inserts (unchanged headline)

Moon 64.5–80.6k vec/s vs RediSearch 2.2–3.5k → 24–38× faster ingest, both datasets.

Why default-off

On this 4-physical-core box the auto default regressed clustered SQ8 1,732 → 1,165 and TQ4 561 → 277; on a 10-physical-core macOS box the same default is 473 → 2,321 QPS (4.9×) at R@10 = 1.0. A knob that regresses at defaults on common cloud shapes must be a measured opt-in, not a silent tax (the --io-busy-poll-us precedent). Guidance shipped in --help/CHANGELOG: opt in with roughly physical cores − shards (cap 8).

Follow-ups

  1. Per-segment ef reduction (recall-gated) — kill the Nseg× multiplier (e.g. ef/√Nseg per segment); would let the pool win even on SMT boxes and stack with C-style configs.
  2. Consider resolving a lower default ef at query time for high-COMPACT_THRESHOLD single-segment indexes — probe B says ef 300 pays 3.5× latency for recall that ef 64 already saturates on clusterable data (index-time EF_RUNTIME already exposes this per-index today).

Bench driver: scripts/bench-vector-vs-redisearch.py (EF sweep both engines, --moon-args passthrough, fixed-center clustered gen). Raw JSONs: tmp/vs-redisearch-c3{,-pooloff}.json. Instance torn down.

…lock

scripts/audit-unsafe.sh scans only the 3 lines above `unsafe {` for the
SAFETY: tag; the PRFM prefetch block's 5-line SAFETY comment pushed the
tag to offset 5, failing the CI safety audit (Lint job) despite the
block being fully documented. Reorder the comment so the SAFETY:
sentence is adjacent to the block; no code change.

author: Tin Dang
…r, cosine clamp, safe SIMD tails

Addresses the three real defects from the Qodo review of PR #214
(comment 4885258727):

- f16_to_f32 subnormal decode was off by one in BOTH the exponent and
  the normalization shift: power-of-two mantissas decoded to half their
  value, multi-bit mantissas also lost their top fraction bit. Fixed
  (exp32 = 113 - lead, shift = lead) and pinned by two exhaustive
  tests: all 1023 subnormals decode to exactly mant * 2^-24, and every
  finite f16 bit pattern roundtrips f16 -> f32 -> f16 bit-identically.

- rerank_exact ran BEFORE the tombstone liveness filter at both search
  call sites, so dead candidates consumed the 4*k exact-rerank budget
  and surviving live results could keep quantized ADC estimates mixed
  into the post-rerank ordering. The filter now runs first; regression
  test deletes the 30 nearest of 64 vectors (k=5, budget 20) and
  asserts exact distances + true live top-k (RED-verified against the
  old ordering).

- Cosine rerank now clamps cos to [-1, 1] before computing 2 - 2*cos:
  f16 rounding could push the ratio slightly outside the legal range,
  producing distances below 0 / above 4.

- SQ8 stats kernels (avx2/avx512/neon) scalar tails use safe zip
  iteration instead of get_unchecked, per UNSAFE_POLICY: the tail is at
  most one sub-vector-width pass, so bounds checks cost nothing and the
  unsafe surface stays confined to the intrinsics.

Refs: PR #214 review
author: Tin Dang
@pilotspacex-byte pilotspacex-byte merged commit 35223ee into main Jul 6, 2026
11 checks passed
TinDang97 added a commit that referenced this pull request Jul 6, 2026
A G-segment index searched EVERY graph segment at the full resolved ef,
doing ~G× the CPU work of one ef-wide beam over a single graph. This
was measured as the root cause of the worker pool's regression on
SMT-constrained boxes (GCE c3 probe matrix, PR #214: workers 3 vs 7
within 3% => the ef work multiplier, not SMT oversubscription).

per_segment_ef(ef, G, quota) = clamp: max(ceil(ef/sqrt(G)), quota, 24),
capped at ef. Rationale: segments partition the corpus, so each returns
its LOCAL nearest — a per-segment beam of ef/sqrt(G) keeps the merged
candidate mass at ~ef*sqrt(G), still a strict oversample of the
single-graph baseline. Floors: the merge's per-segment quota (fetch_k /
oversample_k — HNSW must be able to return that many) and 24 (beam
degenerates below).

Applied at ALL multi-segment graph search sites — sync search_filtered
(all four strategy branches), sync search_mvcc, and the yielding path's
shared graph_ef (worker-pool jobs, submit-failure inline fallback, and
serial loops all read it) — so pooled == serial byte-identity holds
(identity + stress tests green, unchanged).

Recall gate (macOS, same data/seeds as the sidecar A/B earlier today,
20k×384d SQ8 post-compact, 5 segments, resolved ef 300 → 135/segment):
  clustered 5seg: R@10 1.0000 -> 1.0000 (p50 0.699 -> 0.687 ms)
  gaussian  5seg: R@10 0.9915 -> 0.9915 (p50 0.808 -> 0.789 ms)
  1seg controls byte-unchanged (1.0000 / 0.7710).
On a 10-core dev box the wall-clock delta is small (workers already
absorb the excess in parallel); the win is the ~2× CPU-work cut, which
is exactly what the 4-physical-core cloud shapes lacked. GCE before/
after on the recorded probe configs (1,198 / 2,919 / 2,396 QPS) lands
with the branch validation run.

Unit tests pin the helper's split/floor/cap/monotonicity behavior.

author: Tin Dang
TinDang97 added a commit that referenced this pull request Jul 6, 2026
Apply the same UNSAFE_POLICY cleanup that PR #214 review applied to the
f32 SQ8 stats kernels: the int8 symmetric ADC tails (avx2/avx512/neon)
now iterate `qi8[i..n].iter().zip(codes[i..n])` instead of
`get_unchecked` — the tail is at most one sub-vector-width pass, so
bounds checks cost nothing and the unsafe surface stays confined to the
SIMD intrinsics.

Refs: PR #214 review follow-through
author: Tin Dang
pilotspacex-byte added a commit that referenced this pull request Jul 6, 2026
…ef, int8 ADC, keyspace parity, full index durability (#215)

* perf(vector): SIMD f16 exact-rerank kernels (NEON integer-rescale + F16C)

Profile evidence (perf, ef64 matched-recall config): rerank_exact was
17.2% of every FT.SEARCH query — 4k candidates × dim scalar software
f16_to_f32 decodes (branchy bit-twiddling) + scalar FMAs per query per
segment. This was the single largest addressable slice of the 55µs/query
gap vs RediSearch at matched recall.

Two new fused decode+distance kernels in the DistanceTable (dispatch via
the existing OnceLock table, scalar fallback always available):

- f16_l2 / f16_dot_normsq, aarch64 NEON: baseline-NEON integer-rescale
  decode — (bits&0x7FFF)<<13 reinterpreted as f32 then multiplied by
  2^112 re-biases the exponent exactly (power of two; every finite f16
  is exact in f32; subnormals normalize through the same multiply);
  exp==0x1F lanes (Inf/NaN) rebuilt via bit-select. No ARMv8.2 FP16
  intrinsics required — runs on every AArch64 CPU.
- x86_64: F16C vcvtph2ps + FMA, 16 halves/iteration. Runtime-detected
  as f16c && fma && avx2 (AVX2 is NOT implied by F16C+FMA — AMD
  Piledriver has both without AVX2; the hsum helper needs AVX2).
- rerank_exact (immutable.rs) now calls the dispatched kernels for both
  the L2 and the fused cosine/IP path (the cosine loop was previously
  inline scalar).

Semantics pinned by tests: SIMD == scalar on normals, f16 subnormals,
zeros, odd tails (13 lengths), and Inf/NaN propagation — per-arch direct
tests plus dispatched-table tests.

Measured (OrbStack VM aarch64, 20k×384d clustered SQ8, 1 segment,
single conn): ef64 7,795 → 8,468 QPS (+8.6%), rerank self-time 17.2% →
~9%; ef300 2,280 → 2,353. x86 F16C expected better (1-instr decode);
GCE numbers with the full stack in the PR.

Negative result (recorded in CHANGELOG): skipping rerank entirely for
SQ8 was hypothesized (SQ8 ADC error is small) and A/B-REFUTED — R@10
drops 0.010 clustered / 0.017 gaussian-5seg. Recall is Moon's edge over
RediSearch, so the pass stays unconditional and the kernels make it
cheap instead. The A/B gate did its job.

New unsafe: SIMD intrinsics blocks in distance/neon.rs + distance/avx2.rs
following the module's existing target_feature + SAFETY-comment pattern
(user-approved SIMD kernel work).

author: Tin Dang

* perf(vector): per-query fixed-cost cleanup — scratch TLS, Arc treemap, ryu

Three per-query fixed costs on the FT.SEARCH wire path, found in the
same profile as the f16 rerank work:

1. QP-3 thread-cached SearchScratch: capture_dense_knn_snapshot built a
   fresh SearchScratch EVERY query — BinaryHeaps, visited BitVec, and
   the 32-65KB ADC LUT buffer reallocated (then re-grown mid-search)
   per query. New take_thread_scratch/recycle_thread_scratch TLS pair
   in hnsw::search (one slot; exact padded_dim match — query_rotated
   .len() ACTS as the search's padded dim, so larger-is-fine is wrong);
   the yielding search recycles at its single exit. Concurrent
   same-thread searches just miss the cache and allocate as before.

2. QP-2 amortized committed-treemap capture: all 7 search-capture sites
   cloned the MVCC committed RoaringTreemap per query.
   TransactionManager::committed_snapshot() now returns a cached
   Arc<RoaringTreemap>, refreshed lazily on first capture after a
   commit/prune (dirty flag set at the ONLY two mutation points:
   commit() insert + prune remove_range). Read-heavy: one Arc bump per
   search. Write-heavy: at most one clone per search — never worse
   than before. SearchSnapshot.committed is now the Arc; MvccContext
   still borrows &RoaringTreemap via deref. Unit test pins snapshot
   equivalence + Arc-identity amortization + owned-view isolation.

3. RESP score formatting: __vec_score built f32 strings via Display
   (grisu::format_shortest_opt, 1.4% self-time in the ef64 profile).
   ryu (itoa's float sibling, new dep) replaces it in the three
   response.rs sites; {:.6} fixed-precision sites elsewhere unchanged.

Measured (OrbStack VM aarch64, 20k×384d clustered SQ8, 1 segment,
single conn, ef64): 8,468 -> 8,810 QPS (+4.0%); cumulative with the
SIMD f16 kernels +13% from the 7,795 baseline. ef300 unchanged (fixed
costs amortize into the longer search).

author: Tin Dang

* perf(vector): EF-SPLIT — divide the beam across graph segments

A G-segment index searched EVERY graph segment at the full resolved ef,
doing ~G× the CPU work of one ef-wide beam over a single graph. This
was measured as the root cause of the worker pool's regression on
SMT-constrained boxes (GCE c3 probe matrix, PR #214: workers 3 vs 7
within 3% => the ef work multiplier, not SMT oversubscription).

per_segment_ef(ef, G, quota) = clamp: max(ceil(ef/sqrt(G)), quota, 24),
capped at ef. Rationale: segments partition the corpus, so each returns
its LOCAL nearest — a per-segment beam of ef/sqrt(G) keeps the merged
candidate mass at ~ef*sqrt(G), still a strict oversample of the
single-graph baseline. Floors: the merge's per-segment quota (fetch_k /
oversample_k — HNSW must be able to return that many) and 24 (beam
degenerates below).

Applied at ALL multi-segment graph search sites — sync search_filtered
(all four strategy branches), sync search_mvcc, and the yielding path's
shared graph_ef (worker-pool jobs, submit-failure inline fallback, and
serial loops all read it) — so pooled == serial byte-identity holds
(identity + stress tests green, unchanged).

Recall gate (macOS, same data/seeds as the sidecar A/B earlier today,
20k×384d SQ8 post-compact, 5 segments, resolved ef 300 → 135/segment):
  clustered 5seg: R@10 1.0000 -> 1.0000 (p50 0.699 -> 0.687 ms)
  gaussian  5seg: R@10 0.9915 -> 0.9915 (p50 0.808 -> 0.789 ms)
  1seg controls byte-unchanged (1.0000 / 0.7710).
On a 10-core dev box the wall-clock delta is small (workers already
absorb the excess in parallel); the win is the ~2× CPU-work cut, which
is exactly what the 4-physical-core cloud shapes lacked. GCE before/
after on the recorded probe configs (1,198 / 2,919 / 2,396 QPS) lands
with the branch validation run.

Unit tests pin the helper's split/floor/cap/monotonicity behavior.

author: Tin Dang

* perf(vector): AE-1 saturation-gated per-segment adaptive ef, retire blanket EF-SPLIT

Compact/GraphUnion builds now run a one-time self-probe against the exact
f16-sidecar ground truth (estimate_suggested_ef): 16 leave-self-out sample
queries, brute-force GT via the SIMD f16 kernels, R@10 measured across an
ef ladder [24..256]. A segment whose curve is FULLY SATURATED from the
minimum rung (first rung >= 0.995 and within epsilon 0.005 of the ladder
top) is certified trivially easy and searches at min-ef (24); every other
segment keeps the FULL resolved ef. The suggestion only applies when ef
was heuristic-defaulted (never over a user-pinned EF_RUNTIME), is floored
by the merge quota, and is in-memory only — segments reloaded from disk
carry None and use the full beam.

This SUPERSEDES the blanket ef/sqrt(G) split (EF-SPLIT, previous commit):
re-validation on a confirmed-fresh binary showed the split silently cost
gaussian 5-seg R@10 0.9915 -> 0.9295 (the original "recall preserved"
A/B had exercised a stale binary — identical recall AND latency to the
pre-split build was the tell). A second design letting the probe pick a
mid-ladder knee was also rejected (0.9915 -> 0.939): self-sampled queries
are optimistically biased on unstructured data — the whole measured curve
shifts up — so the probe's only trustworthy verdict is "saturated at
min-ef". per_segment_ef and EF_SPLIT_MIN are removed; a policy note in
holder.rs records the negative result. A compact-time tracing::debug
logs each segment's ladder and decision for field diagnosis.

Recall/latency A/B (20k x 384d SQ8 cosine, COMPACT_THRESHOLD 4096, same
seeds/harness end-to-end, OrbStack-host macOS dev box):

  clustered 5seg  R@10 1.0000  p50 0.79 -> 0.26 ms (~3x)
  clustered 1seg  R@10 1.0000 -> 0.9960 (within probe epsilon 0.005)
                  p50 1.78 -> 1.46 ms
  gaussian  5seg  R@10 0.9915  p50 ~0.79 ms  (probe self-rejects on all
                  5 segments -> full beam; byte-identical to pre-split)
  gaussian  1seg  R@10 0.7710 (unchanged control)

Tests: vector lib suite 887 passed; test_compact_measures_suggested_ef
covers the probe attach path; clippy clean on default and
runtime-tokio,jemalloc feature sets; fmt clean. CHANGELOG rewrites the
now-inaccurate EF-SPLIT entry into this combined AE-1 entry.

author: Tin Dang

* fix(vector): FLUSHALL/FLUSHDB ghost vectors + HDEL stale-vector tombstone (R3/R4)

Persistence-review findings R3 + R4 (compaction/AOF/WAL data-flow audit):

R3 - FLUSHALL/FLUSHDB never touched the FT indexes: flushed hashes stayed
searchable as ghost vectors/documents until the next restart. Both
commands now clear every vector + text index's CONTENTS while KEEPING
the FT.CREATE definitions, matching restart semantics (definitions come
from the sidecar; contents re-derive from the now-empty keyspace):
- VectorStore::clear_all_contents(): per index, tombstone warm segments
  (as drop_index) and rebuild a fresh empty index from the same IndexMeta;
  pending_segments discarded so a post-flush FT.CREATE cannot resurrect
  pre-flush contents via the recovery-claim path in create_index.
- TextStore::clear_all_contents(): rebuild each TextIndex from its schema
  via new_with_schema (postings, term dicts, doc maps, TAG/NUMERIC reset).

R4 - HDEL of an indexed vector field left the vector searchable (only
whole-key DEL/UNLINK tombstoned; the sharded handler even documented the
gap as intended). auto_hdel_vectors tombstones the key in exactly the
indexes whose vector field was removed, via the new per-index
VectorStore::mark_deleted_for_key_in_index (sibling indexes keyed on
other fields keep their entries; mark_deleted_for_key refactored onto
the shared tombstone_key_in_index body). After a successful HDEL the
named fields are definitively absent, so the tombstone always agrees
with the hash state. Documented follow-ups: multi-vector-field indexes
tombstone the whole doc; TEXT/TAG/NUMERIC field removal not re-indexed.

Wire-parity: both hooks added on ALL dispatch paths - handler_single
(inline + MULTI/EXEC arm), handler_monoio conn-local, handler_sharded
local write path, SPSC execute (both arms), shared batch path.

TDD: tests/vector_flush_hdel_tombstone.rs (3 tests, red -> green;
tokio+graph harness mirroring ft_search_as_of_filter.rs). Vector lib
suite 887 passed, text 229 passed; fmt + clippy clean on default and
runtime-tokio,jemalloc feature sets.

author: Tin Dang

* fix(vector): make exact-rerank sidecar drop loud + FT.INFO coverage (R5)

Persistence-review finding R5: GraphUnion merge propagates the HQ-1
exact-rerank f16 sidecar all-or-nothing (compaction.rs all_have_raw) --
one sidecar-less source segment silently drops exact rerank for the
ENTIRE merged segment, degrading recall with no signal. Rebuilding the
sidecar from SQ8 decode was rejected (re-quantization error would
contaminate "exact" rerank distances).

- merge_immutable now emits tracing::warn with sources /
  sources_with_sidecar counts when the drop discards at least one real
  sidecar (all-sources-bare merges stay quiet: nothing is lost).
- FT.INFO gains additive top-level counters graph_segments and
  segments_with_exact_rerank (summed across shards by
  merge_ft_info_responses), so coverage < total flags ADC-only segments
  in steady state. Unit test sums_exact_rerank_coverage_counters.

author: Tin Dang

* docs(vector): document AE-1 adaptive ef, FLUSHALL/HDEL parity, FT.INFO coverage counters

Bring the doc surface in line with the recent vector-search commits:

- CLAUDE.md (Vector Search section): HQ-1 rerank bullet now names the
  runtime-detected SIMD f16 kernels (NEON integer-rescale / F16C+FMA,
  +8.6% QPS alone, ~+13% with the per-query fixed-cost cleanup) and the
  now-loud sidecar-drop warn + FT.INFO coverage counters; new bullets
  for AE-1 saturation-gated per-segment ef (incl. the REJECTED blanket
  EF-SPLIT with its measured 0.9915 -> 0.9295 gaussian regression) and
  FLUSHALL/FLUSHDB/HDEL keyspace parity with documented follow-ups.
- docs/vector-search-guide.md: FT.INFO graph_segments /
  segments_with_exact_rerank; new FLUSHALL/FLUSHDB/HDEL section
  (contents cleared, definitions survive, keyspace-global FLUSHDB
  note, restart-rebuild durability model as it stands today); AE-1
  self-probe as compaction step 7; Search Path corrected (stale "no
  separate rerank needed" replaced with exact-rerank + AE-1 ef steps).
- docs/commands.md: FLUSHALL/HDEL one-liner in the vector tip + known
  HDEL limitations bullet linking the new guide anchor.

Covers commits 213b7b75, eace11d3, 5e8d3f47, e71bea58, f332f481.
No source changes; in-progress durability design intentionally NOT
documented as shipped.

author: Tin Dang

* feat(vector): durability write path - persist segments + manifest/keymap snapshots (B1+B2)

Phases B1+B2 of tmp/VECTOR-DURABILITY-DESIGN.md (persistence-audit R1/R2
remediation; R6 folded in). Recovery behavior unchanged in this commit -
the loader/dedup-rescan lands next (B3).

B1 - formats:
- SegmentMeta.suggested_ef: Option<u32> (serde default; old dirs load) so
  the AE-1 compact-time estimate survives disk round-trips (R6).
- write_immutable_segment_staged: staging-<id> dir -> per-file fsync +
  dir fsync -> atomic rename -> root-dir fsync. A crash mid-write leaves
  only an orphan staging dir, never a half segment under a live name.
- New src/vector/persistence/manifest.rs: IndexManifest JSON (atomic
  tmp+fsync+rename+dir-fsync write, tolerant load) carrying collection_id
  (the QJL rotation seed - REQUIRED to search reloaded codes),
  next_segment_id / next_global_id floors, live segment_ids and
  keymap_epoch; binary keymap (MKM1, xxh64 integrity, corrupt -> None +
  warn) mapping key_hash -> (global_id, vec_checksum, key); orphan-sweep
  helpers. 19 unit tests (round-trip, corruption tolerance, atomicity,
  sweep correctness).

B2 - write path:
- VectorIndex.key_hash_to_vec_checksum: Arc<HashMap<u64,u64>> (xxh64 of
  the HSET vector blob; the B3 rescan uses it to skip re-encoding
  unchanged keys). key_hash_to_global_id Arc-wrapped for O(1) capture.
  Maintained at auto-index insert, pruned in the shared tombstone body.
- Compaction/merge persist the built segment (staged writer) when the
  store has a persist_dir - background worker writes off the shard
  thread; segment ids allocated ON the shard thread at submit.
  ImmutableSegment.disk_segment_id links installed segments to their
  on-disk ids for manifest assembly.
- persist_hook_after_install (poll_install compact/merge + force paths):
  O(1) Arc capture on the shard thread; a dedicated single-worker
  SnapshotPool writes keymap-<epoch> then manifest (in that order - a
  manifest never references an unwritten keymap), then GCs superseded
  segment dirs/keymap epochs. Per-index seq + watermark Mutex held
  across the whole write+GC section: stale jobs drop, never reorder
  (BackgroundCompactor is multi-worker with NO per-index ordering -
  the guard is load-bearing).
- drop_index / clear_all_contents delete the idx-<hex> persist dir
  (async best-effort); create_index floor-reads any surviving manifest
  so a drop/recreate generation never collides with old files.

Crash contract: the manifest may UNDERSTATE what is on disk (crash
between segment write and manifest commit -> orphan, swept at B3
recovery) but never overstates - understating costs rescan work, never
correctness.

Known accepted gaps (B3/B4 scope, documented in code): multi-field
indexes checksum only the default field (missing checksum -> B3 treats
as changed -> safe re-encode); drop+recreate same-name gets a fresh
watermark Arc (old-generation in-flight jobs tolerated via id floors).

Verification: vector lib 914 passed (+27), persistence 467 (+19), text
229; fmt + clippy -D warnings clean on default and
runtime-tokio,jemalloc feature sets.

author: Tin Dang

* fix(text): remove clone_on_copy in TextStore::clear_all_contents

BM25Config is Copy; the .clone() in the R3 flush-parity rebuild
(eace11d3) trips clippy::clone_on_copy under -D warnings. Masked at
commit time by clippy's crate-level cache; surfaced by the B3 agent's
clean-tree run.

author: Tin Dang

* feat(vector): startup recovery from persisted segments + dedup rescan (B3)

Phase B3 of tmp/VECTOR-DURABILITY-DESIGN.md - closes the persistence
audit's R1 (discarded-recovery landmine) and R2 (restart cost scales
with total vectors). With B1+B2 (2918f3c9) this completes the vector
durability line: restart now loads compacted segments from disk and
re-encodes only keys that changed.

New src/vector/persistence/recover_v2.rs (RecoveryState; wired through
the event_loop's existing index-restore loop, rescan loop, and a new
finalize call - event_loop diff is wiring only):
- create_index: reads the index's manifest; when present, recreates the
  index PINNING the recovered collection_id (QJL rotation seed - loaded
  codes are unsearchable under any other seed) via new
  VectorStore::create_index_with_collection_id, seeds
  next_collection_id / next_segment_id / global-id allocator from the
  manifest floors, loads each segment dir (disk_segment_id +
  suggested_ef restored, AE-1 estimator NOT re-run), installs the
  SegmentList, and loads the keymap into the three key-hash maps.
- 4-level degradation ladder on segment-load failure: cid-matched load;
  cid mismatch -> drop that segment's keys via its own MVCC headers
  (new segment_io::read_mvcc_headers_only); headers-only parse -> same
  precise drop; nothing readable -> abandon ALL recovered state for the
  index (partial keymap + partial segments must never mix into stale
  search results). Crash contract: degrade to rebuild, never to wrong.
- reconcile_key (dedup rescan): per matching key, xxh64 of the live
  vector blob vs the recovered checksum; unchanged across EVERY
  matching recovered index -> re-run auto_index_hset with the default
  vector field STRIPPED from args (existing metadata-only branch
  rebuilds PayloadIndex; TEXT/TAG/NUMERIC fields remain -> text
  re-derived; zero HNSW/TQ work). Any disagreement, unknown key,
  checksum 0 (documented multi-field gap) or fresh index -> tombstone
  stale copies then full re-encode. Conservative by design.
- finish: deletion probe (keymap keys never observed by the rescan ->
  tombstoned), orphan sweep (staging/segment/keymap files unreferenced
  by the manifest; idx-* dirs with no sidecar index), and a per-index
  acceptance log: loaded segments / verified-unchanged / re-indexed /
  tombstoned.

R1 removal: recovery.rs (624-line WAL-replay path) DELETED;
Shard::recover_vectors + both call sites, pending_segments /
attach_recovered / register_warm_segments / register_cold_segments
removed (serena-verified unreferenced); the _discarded_vector_store
comment now states the real contract; VectorWalRecord doc-headered as
test-only. tests/crash_recovery_vacuum.rs trimmed of the two tests
that exercised the deleted path.

Known documented gap: the collection_id pin's merge-compatibility
invariant is not exercisable by a search-only test (segments rotate by
their own disk-reconstructed metadata); a full compact+merge cycle
test lands with B4.

Tests: recover_v2 unit tests incl. round-trip asserting
verified_unchanged == n (proves the metadata-only branch fired, not
just result equality) + corrupt-segment fallback + finish-tombstone
probe. Full lib suite 3707 passed (default) / 3050 (tokio); fmt +
clippy -D warnings clean both feature sets; integration smokes green
(vector_flush_hdel_tombstone, ft_search_as_of_filter,
crash_recovery_vacuum).

author: Tin Dang

* test(vector): B4 kill-9 crash-recovery suite + merge recall gate self-exclusion fix

B4 closes the durability line (B1-B3, tmp/VECTOR-DURABILITY-DESIGN.md) with
five end-to-end kill-9 crash-recovery scenarios against real server
processes in tests/crash_recovery_vector_durability.rs:

  S1 unchanged-keys dedup fast path (rescan skips re-encoding)
  S2 updates+deletes reconcile across a crash (checksum + deletion probe)
  S3 orphan staging/segment/keymap files swept on boot
  S4 collection_id pin survives a post-recovery compact + GraphUnion merge
  S5 no-persist-dir regression guard (--appendonly no => no idx-* dirs)

All waits are bounded condition polls (port accept / manifest / log line),
never fixed sleeps. Servers spawn with --disk-free-min-pct 0 so the suite
is immune to the dev volume hovering at Moon's 5% diskfull write-pause.

S4 exposed a REAL pre-existing bug, fixed here: verify_merge_recall
(src/vector/segment/compaction.rs) compared merged-graph HNSW results
INCLUDING the query point (every sampled query is a database point,
distance 0 -> always rank 1) against a brute-force ground truth that
EXCLUDED it, structurally capping measurable recall at (k-1)/k = 0.90 --
exactly the manual FT.COMPACT/VACUUM VECTOR gate, so ANY manual merge of
an index with >= MIN_RECALL_SAMPLE_N (50) vectors was rejected with
"merge recall 0.9000 < tolerance 0.9000" regardless of graph quality.
(Smoking gun: the reported recall was byte-identical, 0.8999959826...,
across completely different datasets -- the f32 accumulation of Q x 0.9/Q.)
The background 0.70 gate masked this. Fix: request k+1 from the merged
graph and drop the self-point before comparison.

Fallout fixes in existing tests:
- tests/vector_segment_merge.rs: test_recall_gate_rejects_impossible_tolerance
  previously "passed" only because of the capped-at-0.9 bug (tolerance 1.0
  could never be met). Honest recall on a clean merge is exactly 1.0 (the
  graph is verified in the decoded-centroid space it was built in), so the
  rejection/rollback path is now pinned with an unattainable tolerance > 1.0.
- tests/vector_segment_merge.rs: merge_immutable call-site updated for the
  B1 persist parameter (missed in 2918f3c9; the suite had not been compiled
  since).

Verified: crash_recovery_vector_durability 3x consecutive green (5/5),
vector_segment_merge 13/13, vector_exact_rerank 7/7, crash matrix suites
green serially, lib compact/merge unit tests green, fmt + clippy clean on
default and runtime-tokio,jemalloc feature sets.

author: Tin Dang

* perf(vector): int8 symmetric ADC kernels for SQ8 search

Replace the f32-widening per-candidate distance computation in SQ8's
ADC (52-61% of FT.SEARCH query time per the prior vector deep review)
with integer dot-product SIMD kernels. The query is quantized once per
search to int8 symmetric (no zero point, qmax=127); per-candidate
dot/sum/sumsq statistics against the persisted u8 SQ8 codes are then
computed with exact-integer SIMD, reconstructed to f32, and fed into
the existing (unchanged) sq8_l2_from_stats combine step.

Kernel tiers (all gated behind sq8_i8_stats: Option<Sq8I8StatsFn> in
DistanceTable, resolved once per query/chunk, None falls back to the
pre-existing f32 sq8_stats path):

- NEON (aarch64, baseline): vmull_s8 offset-trick widening multiply
  (c' = c ^ 0x80) + vpadalq accumulate. vdotq_s32/SDOT is gated behind
  the unstable stdarch_neon_dotprod feature on rustc 1.94 and was
  avoided entirely in favor of this portable shape.
- AVX2 (x86_64): cvtepi8_epi16/cvtepu8_epi16 widen to i16 (exact) +
  mullo_epi16 (exact, |q*c| <= 127*255 < i16::MAX) + madd_epi16
  accumulate. Deliberately NOT VPMADDUBSW (_mm256_maddubs_epi16) as
  originally designed -- that path requires qmax=63 to avoid signed
  saturation, and empirical recall A/B testing showed qmax=63 breaches
  the 0.01 R@10 gate (0.0165-0.0267 measured) while qmax=127 stays
  within bounds (0.003-0.0045). qmax=127 is used uniformly across all
  three tiers instead of the design doc's suggested 127/63 split.
- AVX512-VNNI (x86_64, feature-gated): true _mm512_dpbusd_epi32
  (VPDPBUSD) for dot_qc and sum_c (codes as the unsigned operand,
  quantized query/ones as the signed operand); sumsq_c can't use
  VPDPBUSD directly (codes can't be the signed operand) so it reuses
  the AVX2-style i16-widen + madd_epi16 shape.

Recall safety: the existing exact-rerank sidecar (HQ-1) is untouched.
Added an explicit recall A/B test (top-10 overlap >= 0.98 AND
|R@10(f32) - R@10(int8)| <= 0.01) across L2 (dim 128, dim 384) and
cosine-normalized (dim 128), N=5000 vectors / 200 queries, seeded LCG
data, pure brute-force (no ANN gate). Extreme-value + tail-handling
tests pin each SIMD kernel against an exact i64 scalar oracle.

MOON_SQ8_INT8_ADC=0 forces the old f32 kernels (OnceLock-cached env
read); documented in CHANGELOG, intentionally not added to CLAUDE.md's
env var list per design-doc scope.

Local measurement (NEON, this dev machine only -- not a production
number): ~1.5x/2.0x/2.15x speedup at dim 128/384/768 via the new
criterion bench variant (benches/sq8_adc_bench.rs). AVX2 and AVX512-
VNNI kernels are compile-verified cross-target from aarch64
(x86_64-apple-darwin, including --features simd-avx512) but not
runtime-executed this session -- they run under x86 CI/GCE.

No changes to the persisted SQ8 on-disk format, tq_adc.rs/TQ4, or any
existing f32 kernel path.

Files: src/vector/turbo_quant/sq8.rs, src/vector/distance/{mod,neon,
avx2,avx512}.rs, src/vector/hnsw/search.rs, src/vector/segment/
mutable.rs, benches/sq8_adc_bench.rs, CHANGELOG.md.

author: Tin Dang

* refactor(vector): safe indexing in int8 ADC scalar tails

Apply the same UNSAFE_POLICY cleanup that PR #214 review applied to the
f32 SQ8 stats kernels: the int8 symmetric ADC tails (avx2/avx512/neon)
now iterate `qi8[i..n].iter().zip(codes[i..n])` instead of
`get_unchecked` — the tail is at most one sub-vector-width pass, so
bounds checks cost nothing and the unsafe surface stays confined to the
SIMD intrinsics.

Refs: PR #214 review follow-through
author: Tin Dang

---------

Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants