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

nordisk/nornir

.nornir/vector.md

Vector / semantic search

Internal reference for nornir's vector (semantic) search index — the "last index type" that completes the warehouse's projection set (plan-DAG, git/lineage, full-text, dependency, vector). This file lives in .nornir/ and is not a rendered artifact (only README.md / CHANGELOG.md are managed by docs render); it's a durable design note.

Source: src/vector/ (feature vector). The build is complete; plan.md §Search carries the one-paragraph shipped summary.

What it is

Semantic search over code + docs, alongside the existing Tantivy BM25 index. Text is embedded into 768-dim f32 vectors by jina-v2-base-code run from its ONNX export behind one Embedder interface — two backends, embed-tract (CPU, pure Rust) and embed-ort (ort/ONNX Runtime, GPU CUDA → CPU). Those vectors are stored and searched by an in-process index keyed by stable u64 ids that map back to warehouse rows: an exact-flat oracle for small corpora, with a vendored, rayon-free HNSW ANN index (znippy_zoomies::vann) taking over the live dense path once a snapshot grows past HNSW_MIN_ROWS (4096). Hybrid search merges the BM25 and dense rankings by Reciprocal-Rank-Fusion (RRF), and a Graph-RAG pass can expand hits along the call graph. (See Embedder below; the embedder pivoted Candle → ONNX — Candle couldn't load the code arch's QK-norm weights and isn't pure Rust.)

Laws it obeys (see plan.md §4 "Decided")

Exact oracle by default; a vendored HNSW ANN for scale

The flat VectorIndex is always the exact oracle — for small corpora it is the live path (sub-ms and 100 % recall, so a graph would be pure overhead), and it stays the ground truth every other path is measured against. Two of the approximate indexes once tried were removed and have not come back: turbovec (below) and a Ragnar-style int8 IVF (ragnars_emb) — IVF's nprobe cell-skipping trades recall for speed with nothing to fall back on.

What did land is what §"turbovec" below always allowed for: a pure-Rust, rayon-free HNSW ANN index, now vendored in znippy_zoomies::vann (the same crate as the SIMD kernels — ROOT-LAW: all parallelism via gatling, never rayon). It is not the old external CozoDB HNSW (a whole embedded DB, rejected 2026-07-02). It is opt-out and scale-gated: the live dense-kNN path builds it lazily (memoized per immutable snapshot) only when the corpus is ≥ HNSW_MIN_ROWS (4096) and NORNIR_VECTOR_HNSW isn't disabled — below the threshold, or with the env-toggle off, every query stays on the exact flat scan. So the flat oracle and the provably-exact int8 prefilter → f32 rerank (byte-identical to flat; see vector-cpu-simd.md) remain; HNSW is an added fast path at scale, A/B-able against the oracle via the toggle, not a replacement for it.

Why exact-flat, not turbovec (decided 2026-06-04)

The original plan named turbovec for ANN. It can't be used:

turbovec's value is TurboQuant 4-bit quantization — a lossy, in-RAM compression that trades precision for RAM/speed at huge scale. That directly conflicts with "max precision," and at nornir's scale (a few repos, ~tens of thousands of chunks) the RAM saving is irrelevant. So we use an exact brute-force cosine index: every query scores every stored vector → 100% recall, no quantization loss, no graph-traversal miss.

Compression vs storage, to be precise:

The VectorIndex API is deliberately engine-agnostic — which is exactly how the pure-Rust HNSW backend (znippy_zoomies::vann) slotted in behind the live path at scale without touching callers. A quantized backend could follow the same way.

Architecture

VectorIndex (src/vector/mod.rs)

Exact-flat cosine index. Vectors are L2-normalized on insert and the query is normalized per search, so the similarity score is a plain dot product (cosine) in [-1, 1], higher = closer.

method notes
new(dim) -> Result<Self> dim must be non-zero
add(&mut, vectors, ids) -> Result<()> row-major ids.len()*dim floats; ids unique; normalized on store
search(&self, query, k) -> Vec<(u64, f32)> exact top-k, descending; panics on dim mismatch
remove(&mut, id) -> bool O(1) swap-remove (fixes the moved row's id→index)
contains(id), len(), is_empty(), dim()
write(path) / load(path) .nvf binary format
active_simd() -> &'static str "avx512f" | "avx2+fma" | "scalar"

Internals: dim, ids: Vec<u64>, data: Vec<f32> (row-major normalized), pos: HashMap<u64, usize> (id → row).

SIMD — runtime-detected, per search

The per-vector dot product dispatches once per search() call (not per vector) to the best kernel the running CPU supports, via a function pointer:

select_dot_kernel():  avx512f ?  → dot_avx512   (16 f32 lanes, _mm512_fmadd_ps)
                      avx2+fma ?  → dot_avx2     (8  f32 lanes, _mm256_fmadd_ps)
                      else        → dot_scalar

Each SIMD fn is #[target_feature(enable = ...)] + #[cfg(target_arch = "x86_64")], with a scalar tail for the dim % lanes remainder. The binary builds and runs on any CPU; it just goes faster where the silicon allows.

dim = 768 (jina) splits cleanly: 48× 16-lane (AVX-512) or 96× 8-lane (AVX2).

Multicore — intra-query, not per-request

A single search fans its N-vector scan across cores (minimizes one query's latency — the right call for interactive agent/human search). Mechanism:

threads = thread_count(n)                  // hw cores, capped by n/1024; 1 if n small
std::thread::scope:
  partition rows [0..n) into `threads` contiguous ranges
  each thread: score its slice with the chosen SIMD kernel → local top-k
merge local top-ks → global top-k

No Arc, no new dependency — scoped threads borrow &self directly. The fn pointer is Send. Below 2 * MIN_ROWS_PER_THREAD (2048 rows) it runs single-threaded (spawning would be pure overhead).

The two parallelism layers compose: within a vector → SIMD lanes; across vectors → cores.

Persistence — .nvf format

Dependency-free little-endian binary written by write, read by load:

magic "NVF1" (4 bytes) | dim: u32 | n: u64 | ids: n × u64 | data: n*dim × f32

Data is stored already-normalized, so load does no recompute. load validates magic, dim, and exact length, and rejects duplicate ids.

Warehouse integration (LOCKED design — materialized embeddings)

Decision (2026-06-04): vectors are materialized in Iceberg so semantic search at any historical git_sha is a warehouse read — never a re-embed, never a git walk. This is the performance point for mega codebases: re-deriving from git at scale means random packfile reads + zlib inflate across hundreds of thousands of files (and a checkout/tree-walk for a historical sha, or a slow/​impossible clone when airgapped). The warehouse gives finished vectors via one partition-pruned columnar scan. It's also literally nornir's mission — "airgapped agent works a huge workspace by query — no full-repo scans, no code leaves the machine."

This is not a single .nvf blob per commit (that "dead blob" ages badly — re-stored whole each commit, no dedup). It is row-per-vector, three tables (src/warehouse/iceberg_schema.rs):

Time-travel / search at git_sha X: resolve the snapshot → SELECT manifest rows for snapshot_id → JOIN embeddings by content_hash → build a VectorIndex in memory (one transient u64 id per occurrence; the manifest row is the id → {file, span} map) → embed the query → search. Top-k excerpts are read lazily from git for the handful of hits only.

model_profile = a stable hash of (model_name, weights_sha, tokenizer_sha, pooling, normalize, dim, dtype) — not just the model name — so the same name can't silently mean different vectors. chunker_hash captures the chunker version/options. Both are filled by later slices.

Optional later: a .nvf blob (via VectorIndex::write) as a derived cache keyed by snapshot identity — fast repeated search at one sha, rebuilt from embeddings, never the source of truth.

One vector per function node (Mímir v2, src/vector/fnvec)

The two vector lenses (similar_code, semantic_clones) do not read line windows. They read ONE vector per function node of the code room:

node id (SCIP moniker) ──▶ content_hash ──▶ vector
       embedding_node_events        embeddings (content_hash, model_profile)

The embedders — one interface, two backends, a selectable model

store::Embedder is the interface (profile() + embed(texts)); both backends run the selected model's ONNX export (jina-embeddings-v2-base-code by default — 768-dim, 8192 ctx, QK-LayerNorm "code" arch) and share embed_support.rs (the selected model's identity, profile, mean-pool + L2-normalize), so their vectors — and thus the model_profile dedup key — are identical for a given model and interchangeable across backends in the warehouse.

Model registry + selection (plan item G3)

The model is no longer a hard-wired constant — it's chosen from a small registry (src/vector/embed_registry.rs). Each entry is { id, model_name, hf_repo, onnx_path, tokenizer_path, dim, max_tokens, cache_subdir }:

id model dim note
jina-v2-base-code (default) jinaai/jina-embeddings-v2-base-code 768 code arch; the only one fetched by default
minilm-l6-v2 sentence-transformers/all-MiniLM-L6-v2 384 registry data; a different dim through the index + key
bge-base-en-v1.5 BAAI/bge-base-en-v1.5 768 registry data; general-text

Selection is by $NORNIR_EMBED_MODEL (the entry id; default = the first registry entry). The same env var is read by build.rs, which fetches the selected model into its own cache_subdir — so the weights on disk and the model the embedder loads can never disagree. The CLI surfaces the active model (nornir vector index prints backend, model <name> (<dim>-dim); the helper vector::selected_model_desc()).

DIM is now dynamic: formerly embed_support::DIM = 768, now embed_support::dim() reads the selected registry entry. pool_and_normalize and prepare_tokens take dim / max_tokens arguments, and the index + warehouse dim column key off profile().dim — nothing assumes 768.

The registry file is std-only (no sibling-module use, no extern crates) on purpose: build.rs include!s it, so build and runtime share one source of truth. Adding a model = one EmbedModel entry (and, if you want it fetched by default, make it the first entry).

Re-embed awareness — models never mix

profile() puts the selected model's model_name and dim into ModelProfile, which hashes (with the weight/tokenizer SHAs, pooling, etc.) into the model_profile string stored in every vector table. Two different models therefore land under different model_profile keys and never collide: reconstruct/search resolve only the snapshot whose model_profile matches the query embedder, and the embeddings dedup filters on model_profile too.

Practical consequence — switching $NORNIR_EMBED_MODEL requires a fresh nornir vector index at the new model before semantic search returns hits: old vectors are a different dim + different semantics, so they're neither reused nor silently blended in. (A wrong-dim query against the wrong index would panic in VectorIndex::search — by design; here it instead resolves no snapshot for the mismatched profile.)

backend module feature runtime device
tract embed.rs embed-tract tract-onnx (pure Rust) CPU, multicore (scoped threads over the batch)
ort embed_ort.rs embed-ort ort (ONNX Runtime, C++) GPU (CUDA) → CPU fallback

vector::load_embedder() picks ort when embed-ort is on, else tract; a build with neither feature pulls no onnxruntime (the pure-copy default).

Why ONNX (not Candle): candle's built-in jina_bert is the English arch and cannot load the code weights (the code model has QK-LayerNorm attention.self.layer_norm_q/k + up_gated_layer/down_layer MLP). The ONNX export bakes the arch in, so both tract and ort run it directly. (Candle also isn't pure Rust — its tokenizers dep pulls the same onig/esaxx C/C++.)

Pipeline (both backends): tokenize → ONNX forward (input_ids + attention_mask, i64 [1, n]) → hidden [1, n, 768] → mean-pool → L2-normalize.

⚠ Accepted no-C exceptions (Rickard, 2026-06-04): tokenizers (onig C + esaxx C++) for both backends; ort additionally links the onnxruntime C++ lib (and the CUDA runtime at GPU time). tract is pure Rust. A nornir build with no embed feature has none of this. (The repo already compiles C via rusqlite "bundled".)

GPU "just works" (cuda.rs)

The ort CUDA provider is dlopened lazily and NEEDS libcudart/libcublas/ libcudnn/… glibc fixes the loader path at process start, so a runtime LD_LIBRARY_PATH is ignored — instead cuda::ensure() (called before the session builds) dlopens the CUDA libs with RTLD_GLOBAL in dependency order so the provider resolves against them. Discovery order: NORNIR_CUDA_LIBS (explicit colon dirs) → $VIRTUAL_ENV/$CONDA_PREFIX site-packages/nvidia/*/lib (pip nvidia-*-cu12 wheels) → NORNIR_CUDA_SCAN_ROOTS → known dirs (ollama bundles, /usr/local/cuda*/lib64). Best-effort: if cuDNN isn't found, ort just runs on CPU (no panic). OrtEmbedder::cuda_ready() reports whether the GPU EP engaged.

Under systemd (the nornir service): the unit runs with a minimal env — $VIRTUAL_ENV/$CONDA_PREFIX are unset — so GPU discovery falls to NORNIR_CUDA_LIBS and the hardcoded system dirs. cuDNN 9 must be among them or ort silently runs on CPU. The ollama cuda_v12 bundle ships cudart/cublas but not cuDNN, so for GPU on the server, set in /etc/nornir/nornir.env: NORNIR_CUDA_LIBS=/abs/nvidia/cudnn/lib:/abs/nvidia/cublas/lib (e.g. a pip nvidia-cudnn-cu12 wheel's lib). Without it the embedder still works, on CPU. Confirm with cuda_ready() (false ⇒ CPU fallback). Verified GPU on this box (RTX 4090, ~4.3 GB VRAM, 110× over tract-CPU — see Performance notes).

Convention dir + preflight (no env needed): /opt/nornir/cuda is a built-in search dir (candidate_dirs) — a matched CUDA-12 / cuDNN-9 .so set there makes the GPU work with no NORNIR_CUDA_LIBS, service or interactive. sudo nornir vector setup-cuda populates it for you by copying the libs from wherever discovery finds them on the box (pip nvidia-* wheels, ollama bundles, a toolkit); downloading from NVIDIA's redist CDN when no local source exists is a planned follow-up. Run nornir vector doctor to check: it prints driver / libcudart / cuDNN / loaded libs / dirs searched, runs a real test embed (dims, ms, cuda_ready), and tells you exactly what to fix. The one piece nornir can only detect, not install or embed, is the NVIDIA kernel driver (libcuda.so.1): it needs root + a module matching the running kernel + a reboot, so it's the operator's job (the doctor prints the distro install hint). The runtime libs (cudart/cublas/cuDNN) are NVIDIA redistributables and just need to live in a search dir.

A GPU on another box (vector::remote)

A server with no GPU borrows one: on the GPU box run nornir vector serve-embed --listen 0.0.0.0:7932 --token-file <file> (it loads the model on the CUDA EP strictly and refuses to serve the CPU unless --allow-cpu), and give the server NORNIR_EMBED_REMOTE=<host>:7932 plus NORNIR_EMBED_REMOTE_TOKEN_FILE=<file> (or NORNIR_EMBED_REMOTE_TOKEN). The server keeps everything that decides what is embedded (the span cut, the content hash, the delta, the ledger); the worker only turns texts into vectors, in the server's own model space (Embedder.Profile is checked; another profile is refused). The embeddings feed then runs unattended, and a sha it skipped for want of hardware is due again by itself. A text the device cannot hold alone (an 8k-token window) is embedded by the worker's CPU EP, same weights.

CPU ↔ GPU fallback (what's supported)

build device needs
embed-tract CPU (pure-Rust, multicore) nothing — no CUDA, no driver
embed-ort + GPU CUDA (NVIDIA) driver + cudart + cuDNN 9 discoverable
embed-ort, no/incomplete CUDA CPU (ort silently falls back) onnxruntime only
no embed feature — semantic search disabled (BM25 still works)

The fallback is automatic and silent — embed-ort requests the CUDA EP and ort drops to CPU if the libs/driver aren't there. nornir vector doctor / cuda_ready() are how you tell which actually happened. Same model + 768-dim vectors either way, so the warehouse model_profile dedup key is identical (CPU and GPU embeddings are interchangeable).

Weights

The selected model's model.onnx + tokenizer.json are fetched once by build.rs into a per-model dir ~/.cache/nornir/models/<cache_subdir>/… (override NORNIR_MODEL_CACHE to point at one model's files directly; NORNIR_SKIP_MODEL_FETCH=1 to reuse), and their dir + content-SHAs are passed to the crate via rustc-env. build.rs selects the model the same way the runtime does ($NORNIR_EMBED_MODEL), and re-runs on a change to that var. The default jina export is 611 MB f32. Nothing is committed; weights_sha/ tokenizer_sha feed ModelProfile.id() — so even the same model id re-keys if HF republishes different weights.

Feature flags

feature pulls in gives
vector nothing (pure std) flat VectorIndex + item-aware chunker + warehouse store
embed-tract tract-onnx (pure Rust) + tokenizers (⚠ onig C / esaxx C++) + weight fetch JinaEmbedder (CPU)
embed-ort ort (⚠ onnxruntime C++ + CUDA) + tokenizers + libloading + weight fetch OrtEmbedder (GPU/CPU)

Default CLI (all off) stays lean and C-free. The ANN index + store + chunker add no non-Rust deps; only the embed features cross the no-C line, by accepted exception.

Status

Performance notes (measured 2026-06-04, Threadripper 3975WX + RTX 4090)

examples/vector_timetravel.rs full-vectorizes real repos, then measures reconstruct (loading a vector "blob" for a git_sha from the warehouse).

Gotchas