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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions BENCHMARK.md
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,135 @@ indexing); Moon still ingests 5.8–10.4× faster. (2) recall@10 dips 0.9992 →
the index now serves from 3 segments instead of one (multi-segment beam truncation), a
~0.001 recall cost for the 2.3–4.6× faster time-to-green; still within 0.002 of Qdrant.

### 10.10 2026-07-08 ANN-benchmarks 4-way: Moon vs RediSearch vs Qdrant vs turbovec (GCE ARM, 1.18M/1M vectors)

First campaign on the **standard ANN-benchmarks datasets with bundled ground truth**
(previous sections used synthetic/MiniLM corpora ≤50K). Full recall/QPS trade-off curves
via the new runtime `FT.CONFIG SET <idx> EF_RUNTIME` (commit `0afac2c4`) — one ingest,
one settled index, N query points.

**Environment:** GCE t2a-standard-8 (ARM Ampere, 8 vCPU, us-central1-a), Moon
`0afac2c4` (feat/vector-fastscan, `-C target-cpu=native`, monoio, `--shards 8
--appendonly no --disk-offload disable --max-unflushed-immutable-segments 0`);
RediSearch = `redis/redis-stack-server:latest` (Docker, HNSW M=16 EF_CONSTRUCTION=200);
Qdrant = `qdrant/qdrant:latest` (Docker, default HNSW, wait-for-optimizer green);
turbovec = github.com/RyanCodrai/turbovec (in-process Rust/Python TurboQuant flat index).
Harness: redis-py / qdrant-client / native API, 1000 test queries, recall@10 vs bundled
ground truth, qps1/qps8 = 1/8 client threads (no pipelining). Moon "settled" = ingest →
green → `VACUUM VECTOR` merge loop to 1 segment/shard (8 total).

#### glove-200-angular — 1,183,514 × 200d, COSINE, k=10

| system | config | recall@10 | qps (1 client) | qps (8 clients) |
|--------|--------|:---------:|:----:|:----:|
| **Moon** (SQ8, settled) | ef=16 | 0.648 | **1448** | **2907** |
| | ef=64 | 0.827 | **888** | **1899** |
| | ef=128 | 0.887 | **612** | **1309** |
| | ef=256 | 0.933 | **400** | **876** |
| RediSearch | ef=16 | 0.496 | 2328 | 3618 |
| | ef=64 | 0.694 | 1202 | 1704 |
| | ef=128 | 0.771 | 816 | 1074 |
| | ef=256 | 0.836 | 524 | 602 |
| Qdrant | ef=16 | 0.570 | 410 | 574 |
| | ef=64 | 0.768 | 335 | 544 |
| | ef=256 | 0.887 | 214 | 426 |
| turbovec | 4-bit | 0.875 | 63 | 62 |
| | 2-bit | 0.625 | 105 | — |

**Iso-recall verdicts (the only fair read):**

- **vs RediSearch @ ~0.83 recall:** Moon 888/1899 vs 524/602 — **1.7× / 3.2× win**.
(RediSearch's 2328-qps ef=16 row is at 0.50 recall — not a comparable operating point.)
- **vs Qdrant @ ~0.887 recall:** Moon 612/1309 vs 214/426 — **2.9× / 3.1× win**.
- **vs turbovec @ ~0.875 recall:** Moon ~700+/1500+ vs 63/62 — **>10× win**; the flat
scan is O(N) and collapses at 1.18M vectors.

**Time-to-ready** (same dataset): Moon ingest 165 s → **green (HNSW-tier, 0.85+ recall
available) at 277 s**; RediSearch 1498 s; Qdrant ~390 s. Moon's *fully-settled* state
(merge to 1 segment/shard) took a further ~61 min — see trade-offs below.

#### glove subset 100K (turbovec's native scale), COSINE

k=64 (turbovec's README config):

| system | config | recall@64 | qps1 | qps8 |
|--------|--------|:---------:|:----:|:----:|
| **Moon** (SQ8, settled) | ef=64 | 0.886 | 647 | **1318** |
| | ef=128 | 0.952 | 524 | 1103 |
| | ef=512 | 0.998 | 272 | 639 |
| turbovec | 4-bit | 0.895 | **760** | 781 |
| | 2-bit | 0.663 | 1191 | 1243 |

k=10:

| system | config | recall@10 | qps1 | qps8 |
|--------|--------|:---------:|:----:|:----:|
| **Moon** (SQ8, settled) | ef=16 | 0.765 | 1787 | 3211 |
| | ef=64 | **0.930** | **1076** | **2286** |
| | ef=256 | 0.995 | 527 | 1139 |
| turbovec | 4-bit | 0.877 | 787 | 802 |

At k=10 Moon wins both axes (higher recall AND higher qps1, 2.9× qps8). At k=64 the
in-process flat scan edges Moon on single-client qps (no network hop, cost amortized
over large k) while Moon wins 8-client throughput 1.7× and is the only one that can
reach >0.9 recall. turbovec gets no concurrency scaling (760 → 781).

#### gist-960-euclidean — 1,000,000 × 960d, L2, k=10

| system | config | recall@10 | qps1 | qps8 |
|--------|--------|:---------:|:----:|:----:|
| **Moon** (SQ8, settled) | ef=16 | 0.816 | **995** | **2077** |
| | ef=64 | 0.962 | **528** | **1085** |
| | ef=256 | **0.994** | **216** | **472** |
| Qdrant | ef=16 | 0.624 | 264 | 390 |
| | ef=64 | 0.861 | 215 | 371 |
| | ef=256 | 0.965 | 134 | 314 |
| RediSearch | ef=16 | 0.486 | 1242 | 1827 |
| | ef=64 | 0.757 | 591 | 798 |
| | ef=256 | 0.927 | 246 | 270 |

(turbovec omitted: IP/COSINE-only library, and its O(N) flat scan is non-viable at 1M.)

**Iso-recall verdicts — Moon wins every band:**

- **@ ~0.86:** Moon (ef≈24, interp.) ~**4× / ~5×** Qdrant's 215/371.
- **@ ~0.96:** Moon 528/1085 vs Qdrant 134/314 — **3.9× / 3.5×**.
- **@ ~0.93:** Moon *at higher recall* (0.962 @ 528/1085) vs RediSearch 0.927 @
246/270 — **2.1× / 4.0×**.
- Moon is the only system measured past 0.99 recall (0.994 @ 216/472 — still faster
than both competitors' ~0.93–0.965 operating points).

**Time-to-ready** (gist): Moon ingest 396 s → green 437 s; RediSearch 2871 s;
Qdrant ~620 s. Moon settle (merge to 1 segment/shard) took a further ~97 min — the
merge-parallelism gap noted below.

⚠ **Moon TQ4 on gist recorded recall 0.002–0.003 — a bug, not noise.** TQ's ADC ranked
L2 by `sphere_dist·‖a‖²`, which is only rank-valid on the unit sphere; gist is
unnormalized L2 and the estimator collapses. **Fixed in this branch** (`14a07025`): all
TQ scoring paths reconstruct `‖a−q‖² = (‖a‖−‖q‖)² + ‖a‖‖q‖·d̂²`, and `FT.CREATE` L2
indexes now **default to SQ8** (explicit TQ*+L2 warns). GCE re-verification with
explicit TQ4 post-fix (same instance/harness, unsettled index so recall-only):
ef=16 **0.784**, ef=64 **0.942**, ef=256 **0.986** — a ~350× recovery, within ~0.03 of
SQ8 (4-bit direction precision at 960d). The main table above is the SQ8 run.

#### Findings that changed Moon along the way

1. **`EF_RUNTIME` was FT.CREATE-frozen** → recall/QPS curves needed one full index
rebuild per point. Now runtime-tunable via `FT.CONFIG SET` (also fixed FT.CONFIG SET
being silently local-shard-only under monoio at shards>1).
2. **MA1 write-stall guard fires on TOTAL immutable segments (>20), not unflushed
ones** — a 1M+ bulk load sits above the threshold while merges lag, throttling
ingest to ~190 vec/s (24× slowdown) on idle hardware. Workaround
`--max-unflushed-immutable-segments 0` during bulk loads; semantics fix pending.
3. **Unmerged segments multiply query cost**: per-query work ≈ shards × segments × ef.
Pre-settle (57 segments) the same index ran 4–5× slower at the same ef. `VACUUM
VECTOR` merges to 1 segment/shard but is local-shard-only over the wire and took
~56 min for 1.18M × 200d (and ~97 min for 1M × 960d) at only ~1.2 cores —
GraphUnion merge parallelism is the next optimization target.
4. **Multi-segment search unions independent beams** → higher recall ceiling at equal
ef (unsettled ef=256: 0.9865 vs settled 0.933). The settled index needs ef 512+ to
reclaim the >0.95 band — at far higher qps than the unsettled equivalent.

---

## 11. Graph Engine
Expand Down
102 changes: 102 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added — CLI/moon.conf defaults for vector + graph tuning knobs (PR #TBD)

- **`--vector-ef-runtime` / `--vector-rerank-mult` / `--vector-exact-beam`**
set the server-wide starting values every NEW vector index is created with
(FT.CREATE), so recall-sensitive fleets configure once instead of issuing
FT.CONFIG per index. Per-index `FT.CONFIG SET` always overrides. Same
ranges as FT.CONFIG (ef 10-4096 or 0=auto, mult 1-64), validated loudly at
startup. All three work as `moon.conf` keys (`vector-exact-beam yes|no`).
- **`--graph-result-cache-entries` / `--graph-result-cache-bytes`** size the
per-graph Cypher result cache (previously hardcoded 256 entries / 4 MiB).
- Startup wiring is first-write-wins process state, installed by both the
binary entry and `run_embedded`; unit tests and library embedders that
never install defaults keep the exact pre-flag behavior.

### Added — Recall knobs: FT.CONFIG RERANK_MULT + EXACT_BEAM (PR #TBD)

- **`FT.CONFIG SET <idx> RERANK_MULT <n>`** (1-64, default 4) deepens the HQ-1
exact-rerank stage: the top `n·k` beam candidates are re-scored with true f16
sidecar distances before top-k truncation, recovering true neighbors the
quantized ADC ranking dropped below the default 4·k cut. Cost ~`n·k·dim` f16
decodes per segment.
- **`FT.CONFIG SET <idx> EXACT_BEAM ON|OFF`** (default OFF) navigates the HNSW
beam itself with exact f16 distances instead of quantized ADC estimates —
beam candidate *selection* becomes exact, so recall is graph-limited
(Qdrant-parity at equal ef) rather than quantization-limited. QPS cost grows
with dimension. Segments without an exact-rerank sidecar (pre-HQ-1 disk
reloads) silently keep the quantized beam.
- Both knobs are per-index, apply to the next FT.SEARCH (all search paths:
sync, yielding, worker-pool fan-out, FT.RECOMMEND, hybrid), broadcast to all
shards, and persist across restarts via a new v4 index-meta sidecar format
(v1-v3 sidecars load with defaults).

### Added — Runtime-tunable EF_RUNTIME via FT.CONFIG (PR #TBD)

- **`FT.CONFIG SET <idx> EF_RUNTIME <n>`** adjusts the HNSW search beam width at
runtime without rebuilding the index (RediSearch parity). Range matches
FT.CREATE (10-4096); `0` restores the auto heuristic. Applies to the next
FT.SEARCH immediately and persists via the index meta sidecar. `FT.CONFIG GET
EF_RUNTIME` added alongside.
- **Fixed — FT.CONFIG SET was local-shard only under monoio**: at shards>1 the
setting silently applied to 1/N index partitions (AUTOCOMPACT,
COMPACTION_WEIGHT, MERGE_RECALL_TOLERANCE were equally affected). SET now
broadcasts to all shards like FT.CREATE; GET stays local.

### Fixed — TQ ADC estimator is now metric-faithful on raw L2 (PR #TBD)

- **TQ quantization ranked L2 queries by `sphere_dist·‖a‖²`** — the unit-sphere
distance between normalized directions scaled by the document norm. On
unnormalized L2 data this makes every small-norm vector look near regardless
of direction (gist-960: recall 0.002). All TQ scoring paths (HNSW beam +
budgeted variant, mutable brute-force + FastScan pre-filter bound, flat
scan, multibit brute-force, A2 decoded-L2) now reconstruct the true metric:
`‖a−q‖² = (‖a‖−‖q‖)² + ‖a‖·‖q‖·d̂²_sphere`. COSINE/IP scoring is unchanged.
Unit tests pin recall on varying-norm data at both the mutable-scan and
HNSW-beam levels (7+/10 and 6+/10 vs exact f32 ground truth; 4/10 and worse
before the fix). GCE re-verification on gist-960 with explicit TQ4:
recall@10 0.784/0.942/0.986 at ef 16/64/256 vs 0.002-0.003 pre-fix (~350x),
within ~0.03 of SQ8.

### Changed — FT.CREATE L2 indexes default to SQ8 quantization (PR #TBD)

- **`FT.CREATE ... DISTANCE_METRIC L2` without an explicit `QUANTIZATION` now
defaults to SQ8** instead of TQ4. TQ's norm-scaled ADC estimator assumes
unit-sphere metrics (COSINE/IP) and collapses on unnormalized L2 data —
recall 0.002 measured on gist-960-euclidean (1M × 960d). SQ8 is
metric-faithful on raw L2. COSINE/IP defaults are unchanged (TQ4). An
explicit `QUANTIZATION TQ*` + L2 is still honored but logs a warning.
- Benchmarks: `BENCHMARK.md` §10.10 — ANN-benchmarks 4-way campaign (Moon vs
RediSearch vs Qdrant vs turbovec on glove-200-angular 1.18M / gist-960 1M /
glove-100K). Tuning guide gains bulk-load (`--max-unflushed-immutable-segments
0`), settle (`VACUUM VECTOR`), and runtime-`EF_RUNTIME` recipes.

### Added — FastScan SIMD vector scan: NEON TBL kernel + live-path integration (PR #TBD)

- **NEON TBL FastScan kernel** (`src/vector/distance/fastscan.rs`): register-resident
4-bit LUT accumulation via `vqtbl1q_u8` for aarch64 — the primary platform previously
fell back to the scalar kernel. 32 candidate distances per block at ~2 ns/candidate
(128d), 17-21× faster than the per-candidate scalar ADC path.
- **AVX2 kernel overflow fix**: nibble-pair distances were added as u8 before widening,
silently wrapping for LUT pairs summing past 255 and corrupting rankings. All kernels
now widen to u16 before adding and accumulate with saturating adds (scalar/NEON/AVX2
bit-identical across the full u8 LUT range; parity tests no longer mask LUTs to 0x7F).
- **Adaptive quantized LUT builder** (`build_quantized_lut`): FAISS-style per-coordinate
bias + global scale chosen so entries fit u8 AND the worst-case accumulated sum fits
u16 (no kernel saturation for in-range data), with f32 reconstruction (`acc/scale +
bias`). Replaces the legacy `precompute_lut`, which hardcoded the v1 1/sqrt(768)
`CENTROIDS` table (encode/search codebook asymmetry — the same recall bug class fixed
by codebook v2) and a fixed `LUT_SCALE` that could overflow both u8 entries and the
u16 accumulator.
- **Mutable-segment FastScan pre-filter** (`src/vector/segment/mutable.rs`): the MVCC
brute-force scan now maintains a FAISS-interleaved shadow of the TQ4 codes (32-vector
blocks, +padded_dim/2 B/vector) and screens candidates with the SIMD kernel; only
candidates whose sound lower bound (`approx − 0.5·padded_dim/scale`) beats the current
heap worst get the exact f32 ADC rescore. Results are bit-identical to the plain scan
(the bound is a true lower bound; saturation only under-estimates). Measured: top-10
over 20K×128d 1.25 ms → 96 µs (~13×), 5K×768d 1.66 ms → 423 µs (3.9×) on Apple
Silicon. Engages above 64 entries (per-query LUT build costs ~2-8 µs); SQ8/A2
collections and TQ-prod scoring keep the existing paths.
- New criterion bench `benches/fastscan_bench.rs` (block kernels, LUT build, end-to-end
mutable scan A/B); shadow-layout + FastScan-vs-plain equality tests (full scan,
chunked scan, MVCC visibility + bitmap-filter paths).

## [0.6.0] — 2026-07-08

**Release highlights** (full detail in the sections below; "PR #TBD" entries
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ harness = false
name = "sq8_adc_bench"
harness = false

[[bench]]
name = "fastscan_bench"
harness = false

[[bench]]
name = "hnsw_bench"
harness = false
Expand Down
Loading
Loading