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")
- 100% Rust, no C deps, no shellout, no daemon. CPU-only, in-process.
- Airgapped + self-contained. Model weights (
onnx/model.onnx+tokenizer.json) are fetched once bybuild.rsinto a content-addressed cache (~/.cache/nornir/models/…,NORNIR_MODEL_CACHEoverride); the build needs network once, the runtime never does. Weights are not committed or bundled into the binary. No Ollama, no daemon. (Since #9 the default build ships BOTH backends — pure-RusttractCPU ANDortGPU via load-dynamic — and picks at runtime: NVIDIA CUDA → AMD ROCm → tract CPU. The binary still links no onnxruntime; the GPU runtime isdlopened only when present. See.nornir/embedder-selection.mdand the §4 C-exception note.) - Max precision first. The embedder is 768-dim (vs a cheaper 384-dim) specifically for better code semantics —
plan.mdaccepts the 2× storage. The index honours that by keeping full f32 and doing exact search.
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:
- Every published turbovec version (0.1–0.8) carries an unconditional
[target.'cfg(target_os = "linux")'.dependencies.ndarray] features = ["blas"](and the same for macOS). That pullscblas-sys→ links system-lopenblas, a C library. No downstream feature flag disables it. - That fails to link here and violates the no-C-deps law. Installing openblas or vendoring a de-BLAS'd turbovec was rejected.
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:
- Quantization (turbovec) shrinks the in-RAM working set and searches the codes directly. We don't need it yet.
- Iceberg/parquet zstd shrinks bytes at rest but expands back to full f32 in RAM on load — and compresses raw
f32embeddings poorly (~1.1–1.5×). - The real time-travel saving is dedup, not compression: key chunk vectors by
content_hashso unchanged chunks are stored once across snapshots. Engine-agnostic; see "Warehouse" below.
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.
- AVX-512 needs stable AVX-512 intrinsics (rustc ≥ 1.89; we're on 1.95).
- On the Zen 2 dev box (Threadripper 3975WX):
active_simd()=avx2+fma. - On a Zen 4 box (Ryzen 7900):
avx512f.
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):
embeddings— content-addressed, deduped vector store. One row per distinct(content_hash, model_profile):dim,vector(native Arrowlist<float32>— a contiguous f32 buffer that maps straight to/fromVectorIndex'sVec<f32>, fast columnar read/write, no byte coding, and queryable by DuckDB/Polars/DataFusion),normalized,vector_sha256. A chunk surviving 500 commits is stored once. Adding 10 changed files appends a few rows. Storage grows with distinct content, notcorpus × commits.embedding_snapshots— identity of one index build:snapshot_id,workspace,repo,git_sha,branch,model_profile,chunker_hash,fileset_hash,complete,row_count,ts. Makes re-index idempotent.embedding_manifest— per-occurrence rows for a snapshot:snapshot_id,ordinal,content_hash,model_profile,file,start_line,end_line. No vector, no source text — code stays in git (identity = git_sha); only hashes + spans live here.
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)
- Chunk = the source text of one node of kind
fn(a free function) ormethod(a method, an associated fn, a trait's own method), cut by the span the rustc driver recorded for it at that commit: the linesstart_line..=end_line, the indentation they share removed. No parser of ours finds a function boundary (tests/no_second_parser.rs). Not chunks:ffideclarations, closures (their text is in the function that holds them), and a function a macro wrote (the item or itsimpl/traitblock has an EXPANDS edge: the driver positions it at the invocation), and a function in a file a build script generated into the target directory (prost / bindgen output: the driver records it by absolute path, it is not source of the repo). Those are counted. A text over 120 lines or 8 000 bytes is embedded in pieces of at most that size and its vector is their length-weighted mean, normalized — still one vector, keyed by the hash of the whole text. (Measured on znippy-zoomies, tract CPU: giant functions embedded whole made ONE forward hold 9.5 GB for more than twenty minutes.) - Key.
content_hash= SHA-256 of that text. The vector is a row ofembeddingslike every other: a body that occurs in ten places is embedded once. The link node →content_hashisembedding_node_events, an append-only change log in the same catalog (repo · seq · sha · op · moniker · content_hash · chunker): the state of a repo at consumer ordinal N is the fold of its events withseq ≤ N.embedding_node_runsis the ledger: one row per run (scope, model space, backend, counts, status, reason). - The feed embeds deltas. The
embeddingsfeed's function half (fnvec::room::embed_member) cuts every function node of the member at the consumed commit, compares the hashes with the open links, embeds the texts that have no vector, and writes events only for the nodes whose text changed. A function that merely moved keeps its link. The model is loaded only when a text needs a vector. Then the line-window half runs as before. - The dependency ring (
fnvec::ring) is not embedded in a consume. When the ring's manifest moved, the feed — once its own run is over — starts ONE background job (job kindvector_embed, targetring): unit =crate@version, source = the directory of the crate's manifest ascargo metadata --offlinein the consumer's clone names it, priced as a whole by the embed budget before the first crate. On a CPU box it refuses with the measured ETA. Its vectors go toembeddings_ring(the schema ofembeddings, a table of its own: every reader ofembeddingsscans the model's rows).nornir workspace embed <ws> --ringruns it on request. One embedding run at a time per process (fnvec::embed_turn): the feed holds the turn for its run, the ring job takes it per crate. - Measured (znippy-zoomies @ 66a2891, dwarf, tract CPU, 10 workers under a 4 GB cap, box loaded): 4 628
fn+methodnodes → 3 546 chunks (711 written by a macro, 371 in build-script output), 3 377 distinct bodies, 3 473 pieces, 0.77 function/s, 72.7 min;embeddings16 MB,embedding_node_events0.2 MB. The next commit (two functions changed): 2 vectors, 0.8 s. Its ring: 143 464 function-like nodes, 90 185 with a chunk, in 408 crates. - Line windows stay the chunk of free-text search (
vector search,Funnel.Retrieve) and of every file that is not Rust (chunk.rs, the three-table snapshot design below). Nothing of that changed.
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
-
VectorIndex— exact-flat, SIMD (AVX-512/AVX2/scalar), multicore,.nvfpersistence. 10 unit tests; full lib suite green. -
chunk.rs— line-window chunker for every file (thesynitem-aware.rschunking went out with Mímir v2; per-function chunks arefnvec, above).chunk_filedispatches;chunk_textis the line splitter. Dedup-ready via SHA-256content_hash;index_repochunks files in parallel (scoped threads). 10 unit tests. -
Warehouse materialization (3-table design).
- Slice 1 — Iceberg schemas (
embeddings,embedding_snapshots,embedding_manifest) + registration inensure_layout. - Slice 2 —
src/vector/store.rs:ModelProfile/chunker_hash, batched dedupindex_snapshot(embeds only missing content),reconstruct+search,Embeddertrait +index_repo. Vector column = Arrowlist<float32>. 5 roundtrip tests on a live warehouse. - Slice 3 — optional
.nvfderived cache keyed by snapshot identity.
- Slice 1 — Iceberg schemas (
-
Embedders (one interface, two backends).
embed.rs(embed-tract) — tract-onnx, CPU, pure Rust. Multicore:embed()fans the batch across cores viastd::thread::scope.embed_ort.rs(embed-ort) — ort/ONNX Runtime, GPU (CUDA) → CPU.cuda.rsauto-discovers +dlopens CUDA libs so the GPU EP just works.embed_support.rs— shared profile + pooling so both backends produce identical vectors.load_embedder()picks the best available.- 3 ignored integration tests pass with the real model (tract load+embed, tract end-to-end, ort load+embed).
-
CLI + MCP surface.
nornir vector index <repo>— vectorize a repo at HEAD (only changed/new chunks re-embed).nornir vector search <q> --repo R [--sha PREFIX] --mode lexical|semantic|hybrid [--limit N].nornir vector stats.nornir vector doctor— GPU/CUDA preflight (driver/cudart/cuDNN), a real test embed, and advice. Gated onvector; semantic/hybrid need an embedder feature and error helpfully otherwise.--shaaccepts a prefix (time-travel).- MCP tool
vector_search { query, repo, sha?, limit? }— embeds the query and searches the warehouse on aspawn_blockingthread (the store drives its own runtime). Present in anymcpbuild; returns a rebuild hint unless built withembed-tract/embed-ort. Verified end-to-end over stdio. - Hybrid = semantic ∪ BM25 fused by reciprocal-rank (RRF, k=60) on file path.
-
HNSW ANN fast path + Graph-RAG. The live dense-kNN path (
store.rs) routes through a vendored, rayon-free HNSW index (znippy_zoomies::vann,Reconstructed::ann) once a snapshot is≥ HNSW_MIN_ROWS(4096) andNORNIR_VECTOR_HNSWisn't disabled; below that, or with the toggle off, it stays on the exact flat oracle. The graph is built once per immutable snapshot and memoized. A Graph-RAG expansion can widen retrieval along the call graph. RRF fusion lives insrc/knowledge/fusion.rs(DEFAULT_RRF_K = 60, the Cormack et al. 2009 default) with a red-when-broken recall guard.
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).
- Load a vector blob (the headline): 6.9 ms (holger, 121 vec) / 14.7 ms (znippy, 430 vec) — best of 5. A pure columnar read + in-memory index build, independent of the embedder and of GPU, and the same path used for git-SHA time-travel. Loading a never-pushed/dirty SHA costs the same.
- Semantic query: ~7–15 ms end-to-end at that scale.
- Incremental / dedup: a never-pushed one-line change re-embeds only the touched chunk and still loads in the same ~7/15 ms.
-
Embedding throughput (same f32 model, per chunk / full holger):
- tract CPU (pure Rust): ~1900 ms / 147 s
- ort CPU: ~337 ms / 41 s (~5.6×)
- ort CUDA (RTX 4090): 13 ms / 1.6 s (~110×) — GPU verified engaged (util spikes, VRAM → ~4.3 GB) via
cuda.rspreload.
Gotchas
searchpanics on a wrong-dim query — that's a programmer error, surfaced loudly (mirrors turbovec's old behaviour). Embed with the same model/dim the index was built for.- Scores are cosine in
[-1, 1]. Don't assume[0, 1]. - Exact-flat is O(N·dim) per query. Great to ~100k vectors; past that, swap a pure-Rust HNSW behind the same
VectorIndexAPI (cargo benchwill tell you when). Do not reach for turbovec — it C-links openblas. - AVX-512 only fires on CPUs that have it; never assume it's active. Use
active_simd()to check.