Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

.nornir/vector-ragnar-reuse.md

ragnars_emb — Ragnar-style IVF over the int8 vector path (reuse map + bench)

⛔ DECISION 2026-07-02 — REJECTED for live search: exactness is required.

nornir vector search must return exact top-k. Both approximate ANN options are out:

  • Ragnar IVF (ragnars_emb / znippy-zoomies::vann) — recall < 1.0. Kept as a bench/experiment only; never wired into the live search path.
  • CozoDB HNSW (the G6 spike, src/index/cozo.rs + the cozo-index feature) — removed 2026-07-02. At nornir's real corpus size (~n=4000) exact brute-force was ~20× faster and exact, so HNSW's approximation bought nothing; the only remaining justification was Cozo's unified Datalog query, which does not outweigh a second embedded engine + the airgap-binary cost.

The exact oracle is always kept: flat brute-force f32 (VectorIndex), with an optional int8 prefilter → exact f32 rerank (fast and exact top-k). The material below is retained as the historical IVF design/bench record.

Update (post-decision): a vendored, rayon-free HNSW ANN (znippy_zoomies::vann) has since landed as a scale-gated live dense path — built only when a snapshot is ≥ HNSW_MIN_ROWS (4096) and NORNIR_VECTOR_HNSW isn't disabled, with the flat oracle retained (and A/B-able) beneath it. That reintroduces HNSW at scale specifically — it does not revive the rejected CozoDB engine or the IVF path; see vector.md.

Status (REMOVED — see decision above): the IVF prototype was removed from the tree. There is no src/vector/ragnars_emb.rs, no RagnarsEmbIndex type, and no nornir vector ragnar-bench CLI — grep confirms all three are absent. The int8 SIMD kernels and the ANN/bench_kernels sweep it was built on now live in the external znippy_zoomies::vann crate (see src/vector/mod.rs), not in nornir. The live search path is exact flat brute-force (VectorIndex) with the optional int8 prefilter → exact f32 rerank. Everything below is retained only as the historical IVF design/bench record; the module/type/CLI names it uses no longer exist.

This is the sub-linear companion to G2 (.nornir/vector-cpu-simd.md): G2 made the per-vector dot product fast (SIMD + int8 VNNI); ragnars_emb makes the search stop touching every vector. It reuses G2's kernels verbatim — it is a layout + traversal change, not a new numeric core.

The progression (flat-today → batched → IVF → placement)

1. Flat brute-force, today (VectorIndex, the baseline + ORACLE)

src/vector/mod.rs scores EVERY stored vector per query — O(N·dim), exact, 100 % recall. Perfect for small corpora and it is the exact oracle every approximate path is graded against. It does not scale: at 1 M × 384 every query streams ~370 MB of int8. ragnars_emb keeps VectorIndex unchanged as baseline/oracle.

2. Batched multi-query search (the throughput lever)

RagnarsEmbIndex::search_batch(queries, k, nprobe) processes M queries together and software-prefetches the next posting-list rows (_mm_prefetch, the same hint stree32::batch_stream uses) so the memory-bound int8 scans overlap instead of each query serially stalling on cache misses. It mirrors the pipelined overlap of knowledge::query's Ragnar batch_stream, applied to the cosine scan rather than an exact-key tree walk.

Correctness contract: search_batch is byte-identical to looping search_np — prefetch changes only when bytes arrive, never which bytes are scored, and the scoring (score_i8_batch) + top_k merge are the same calls in the same order. Asserted by search_batch_is_byte_identical_to_single and in every bench cell (batch== column).

3. IVF (inverted-file) — the structural, sub-linear win

Build clusters the corpus into nlist centroids with k-means++ (√N default nlist), assigns each vector to its nearest centroid, and stores rows in cluster (CSR) order so each posting list is one contiguous i8 slice. Search:

  1. score the query vs the nlist centroids (score_i8_batch, small),
  2. take the nprobe nearest centroids,
  3. scan ONLY those clusters' contiguous rows (score_i8_batch again),
  4. top_k merge.

That turns O(N·dim) into ≈ O((nlist + nprobe·N/nlist)·dim). nprobe is the recall/speed knob: recall rises with nprobe, measured against the flat oracle.

Reuse, verbatim. No dot product is reimplemented. Rows and centroids are quantized with super::quantize_i8 (the exact G2 quantizer, made pub(crate)); every cosine — k-means seeding distances, Lloyd assignment, the centroid pre-scan, and the posting scan — runs through super::score_i8_batch (AVX-512 VNNI → scalar). The merge reuses super::top_k. ragnars_emb adds structure, not arithmetic.

Determinism. k-means is seeded (a dependency-free SplitMix64 fed a u64) and runs a FIXED Lloyd-iteration count — no wall-clock convergence test, no rand. Two builds with the same seed + inputs are byte-identical (write/to_bytes round-trip proves it: seeded_build_is_deterministic). Only the bench reads the wall clock, and never inside an assertion.

4. Memory placement (resident → mmap → NUMA seam)

Where the Ragnar stree genuinely reappears (IVF? no — hybrid/term-dict yes)

The stree32 (Ragnar's sorted-key Eytzinger S+tree, already reused in knowledge::query for batched call-graph lookups) is an exact-key structure. Pure IVF does not want it: the centroid nearest-search is a tiny brute-force scan, and the CSR posting index is already O(1) — bolting an exact-key tree onto either would add nothing. Its real slot is a future hybrid / lexical layer: an exact term → posting dictionary (BM25-style keyword postings, or an exact id → row map) fused with the IVF ANN so one batched query mixes lexical + semantic recall. That is exactly the stree32::batch_stream shape (hash the term batch, route through the tree with prefetch, cross-check by string) — documented as the natural next slot, deliberately NOT forced onto pure IVF.

Tie to G6 (CozoDB / HNSW go/no-go)

G6 (.nornir/nordisk_plan.md) asks whether to adopt CozoDB's built-in HNSW as the ANN, or keep a hand-written Rust index. ragnars_emb is the "keep it in Rust" data point: IVF-over-our-own-int8-kernels, no new engine, no C/C++ backend, one airgapped binary. This bench (IVF crossover vs flat) is the apples-to-apples baseline the HNSW eval must beat to justify a second embedded engine + dep weight. IVF and HNSW are both approximate ANN with a recall/speed knob; if IVF hits the recall target at the corpus sizes nornir actually indexes, the "adopt Cozo HNSW" case rests on the unified Datalog semantic+structural query, not raw ANN speed.

API surface

Selection (flat vs ragnars_emb) is by corpus size or an explicit flag; the flat VectorIndex stays the exact oracle + small-corpus default.

Tests (inject / assert — real recall + values, seeded)

test asserts
ivf_recall_at_10_beats_oracle_threshold recall@10 ≥ 0.95 vs flat oracle at the chosen nprobe on a clustered set (real id-set intersection)
search_batch_is_byte_identical_to_single batched == single-query loop, ids AND f32 scores
seeded_build_is_deterministic same seed ⇒ byte-identical serialized index
write_load_roundtrips resident load reconstructs byte-for-byte + same answers
mmap_load_matches_resident (feat vector-mmap) mmap-backed answers == resident
bench_produces_real_numbers_and_measures_crossover end-to-end sweep, batch-identity per cell, crossover measured

Behind --features testmatrix the recall / batch-identity / crossover tests emit nornir_testmatrix::functional_status("nornir-ragnars-emb", "<check>", ok, detail) for ivf_recall, batch_matches_single, crossover_measured.

Bench results

nornir vector ragnar-bench on a deterministic clustered synthetic corpus (~k points per tight, well-separated cluster → a stable top-k SET, so recall is a real measurement, not an int8 near-tie coin-flip). Flat = exact f32 oracle/baseline; IVF = ragnars_emb at the ≥0.95-recall operating point.

Release build, dim 384, k 10, 200 queries/cell, AVX-512 VNNI box (nornir vector ragnar-bench --sizes 10000,100000,1000000 --dim 384):

N nlist k-means build s flat p50 µs flat q/s nprobe@0.95 IVF p50 µs IVF q/s recall@10 IVF speedup batch==
10 000 100 0.12 290.0 3 241 1 5.6 150 273 1.000 51.7× yes
100 000 317 3.08 2 229.7 446 1 11.8 77 452 1.000 189× yes
1 000 000 1 000 97.4 25 207.1 39 1 23.8 40 453 0.988 1 059× yes

At N=1 M the recall/speed knob is visible: nprobe 1→2→4 gives recall 0.988 → 0.995 → 1.000 (23.8 → 37.6 → 68.4 µs p50), so ≥0.99 costs one extra probe. Every cell's batched search_batch was byte-identical to the single-query loop.

Crossover. IVF (≥0.95 recall) beats flat p50 from N ≈ 150 upward (--sizes 20,50,100,150): N=100 → 0.91× (flat wins, IVF's fixed ~3.2 µs centroid-scan floor dominates and there are too few clusters to hit 0.95 recall); N=150 → 1.28×; N=200 → 1.5×; N=1 000 → 6.5×; and it widens to 3–4 orders of magnitude by 1 M. So the flat VectorIndex stays the right default only for tiny corpora (≲100 vectors); everywhere nornir actually indexes, IVF wins big.

k-means build cost is O(nlist·N·dim·iters) — the 97 s at 1 M (nlist 1000, 10 iters) is a one-time index build, amortized over every subsequent query, and is itself a candidate for the same fork-join parallelism the flat scan already uses (an honest follow-up seam).

Honest seams / caveats.