"Which vector index is best" is not a question. It is a category error.

The ANN-Benchmarks paper has a figure that should end the conversation permanently. On GLOVE (Figure 10), the minimum build time to reach recall 0.9 for 10-nearest-neighbors ranged from 2.2 seconds for FAISS-IVF to 17,000 seconds for HNSW(NMSlib). A spread of about 7,700x, at identical recall.

And then read the rest of the same figure, which is the real lesson: FAISS's own HNSW implementation hit that recall in 1,700 seconds. So most of that 7,700x is implementation, not algorithm. Even the number I just used to make my point needs an operating point before it means anything.

So a claim like "HNSW is 10x faster than IVF" is unfalsifiable without stating recall@k, k, dataset, dimensionality, N, thread count, batch size, memory budget, build time, and whether the cache was warm. Five axes, all independently tunable, all coupled. Pick an operating point or you are not making a claim.

I have been building a storage engine in Rust with vector indexing as a first-class citizen, which means I have had to read past the marketing and into the papers and the source. What follows is what actually determines behavior, and the implementation details that only show up when you write one yourself.

Why we are here at all

The result everyone should cite and almost nobody does is Weber, Schek and Blott, VLDB 1998:

"there is no organization of HDVS based on clustering or partitioning which does not degenerate to a sequential scan if dimensionality exceeds a certain threshold."

Under their stated assumptions, partitioning index structures fail to beat a sequential scan above roughly 10 dimensions, and complexity converges to O(N) because every data block eventually gets touched. That is not folklore about kd-trees being bad at d=768. It is an analytical result, derived under assumptions of uniformity and independence, which is precisely why real clustered embeddings escape it.

The companion result is distance concentration, from Beyer, Goldstein, Ramakrishnan and Shaft (ICDT 1999, circulated as a 1998 tech report): under conditions that hold for many high-dimensional distributions, the ratio of the farthest point's distance to the nearest point's distance converges to 1. Everything is approximately equidistant. The query becomes unstable.

So why does approximate search work at all?

Because real embeddings are not uniform in R^d. They live on a manifold with low intrinsic dimensionality. This is not a hand-wave: hnswlib's own parameter guidance is written in terms of intrinsic dimensionality rather than ambient dimension.

"Higher M work better on datasets with high intrinsic dimensionality and/or high recall, while low M work better for datasets with low intrinsic dimensionality and/or low recalls."

That is the whole field in one sentence. Your parameters should follow the structure of your embeddings, not their nominal width.

Your recall target is probably too high

Two things about recall that are worth internalizing before you tune anything.

First, the definition. ANN-Benchmarks uses a distance-based recall rather than ID-set overlap, specifically to handle ties:

recall(π, π*) = |{ p ∈ π : dist(p,q) ≤ dist(p_k*, q) }| / k

Most hand-rolled recall implementations do naive ID-set intersection, which under-reports recall on any dataset with duplicate or near-duplicate vectors. If your measured recall looks worse than it should, check this before you tune ef_search.

Second, a June 2026 paper argues the metric itself is misleading. "ANN Search: Recall What Matters" (arXiv 2606.04522) points out that in high dimensions an algorithm can return points that are geometrically as close as the true neighbors but with different IDs, so recall@k overstates the true cost of approximation. They propose a distance-ratio metric instead, and report that Label Precision@100 barely moves as recall drops to 0.4 in classification experiments, and that hitting a given distance-quality target costs substantially less compute than hitting the equivalent recall target.

If you are paying for recall 0.99 because it sounded safe, measure whether your downstream task can tell the difference from 0.9. Usually it cannot, and the compute difference is large.

IVF: still the right answer more often than people admit

The structure is two levels. Train k-means over a sample to get nlist coarse centroids. Assign each vector to its nearest centroid and append it to that centroid's inverted list, stored contiguously per cluster. At query time, find the nprobe nearest centroids and scan only those lists.

The contiguity is the entire point. Scanning a list is a linear memory sweep, not pointer chasing.

The cost model falls out of the structure. With balanced lists you scan about N × nprobe / nlist vectors, plus nlist distance computations to pick the centroids. Setting nlist ≈ √N balances the two terms, which is why Faiss recommends somewhere in the range of 4√N to 16√N, and you need 30xK to 256xK training vectors for K centroids, so training 65,536 centroids wants somewhere between 2M and 17M vectors.

The failure mode is boundaries. A query near a Voronoi cell boundary has its true neighbors spread across several cells, and with nprobe=1 you systematically miss them. Recall is therefore not uniform across queries; it is much worse for boundary queries, and boundary queries become a larger fraction of all queries as dimensionality grows. Mitigations in increasing cost: raise nprobe, probe in order of distance to the cell boundary rather than to the centroid, encode residuals from the centroid rather than raw vectors, or replicate each point into its two nearest cells.

Here is the detail that reframes the whole HNSW-versus-IVF debate. Above about a million vectors, the coarse quantizer becomes the bottleneck: you are doing 65,536 centroid comparisons per query just to pick lists. Faiss's answer is to put an HNSW graph over the centroids, which is why its recommended factory strings at scale read IVF65536_HNSW32.

IVF and graph indexes are not competitors. Production IVF contains a graph index.

The memory argument is where IVF genuinely wins. At d=768, one million vectors:

ConfigurationBytes/vectorTotalvs raw
Flat f323,0723.07 GB1x
HNSW, M=163,2003.20 GB0.96x
IVF-Flat3,0803.08 GB1.00x
IVF-SQ8776776 MB4.0x
IVF-PQ, M=64, 8 bits7272 MB42.7x

Those rows exclude the coarse centroids and the PQ codebook, and the HNSW row uses Faiss's layer-0-only formula, so it understates HNSW's links by roughly 18%. With that said: HNSW's graph overhead is trivial at d=768, about 4%. But HNSW does not compress the vectors at all. IVF-PQ is 42 times smaller. That is a memory argument, not a speed argument, and it is the honest one.

Worth noting the inverse: at d=128 with M=16, the graph is 20% of the index, and at M=64 it is 50%. "HNSW's memory overhead is negligible" is dimension-dependent and false for small d with large M.

IVF also wins on build time (on GLOVE, FAISS-IVF built in about 2 seconds against 1,700 for FAISS's own HNSW at equal recall), on deletes (an inverted list is a Vec, removal is a swap-remove, there is no connectivity to repair), on query-time tunability (nprobe needs no rebuild, while M is baked into the graph), on disk friendliness (contiguous lists are sequential reads), and on tail latency (nprobe bounds the work per query, while graph search work is data-dependent).

Where it loses is high recall on hard data. Which brings us to the good part.

HNSW: two ideas, one of which everyone gets wrong

Cite version 4 of Malkov and Yashunin, arXiv 1603.09320. Earlier versions differ.

The hierarchy, derived

Layer assignment is one line in Algorithm 1:

l ← ⌊ -ln(unif(0,1)) · mL ⌋      with     mL = 1 / ln(M)

Work out why that is the right formula and the whole structure becomes obvious. -ln(U) is Exponential(1). Scaling by mL gives Exponential with rate 1/mL = ln M. So:

P(l ≥ ℓ) = exp(-ℓ / mL) = exp(-ℓ · ln M) = M^(-ℓ)

Each layer up holds 1/M the population of the layer below. Expected maximum layer is about log_M(N). That is the entire hierarchy in one expression.

Concretely, M=16 and N=10M: layer 0 has 10M nodes, layer 1 has 625K, layer 2 has 39K, layer 3 has 2.4K, layer 4 has 153, layer 5 has 10. About six layers, meaning five layers of coarse descent, each a greedy walk, before any real work happens on layer 0.

And note the asymmetry in the search procedure: upper layers are traversed with ef = 1, pure greedy, handing off a single entry point between layers. Only layer 0 runs with your ef_search. The upper layers exist purely to teleport the entry point near the query. A single bad greedy step up there is unrecoverable.

The neighbor selection heuristic, which is the actual algorithm

Algorithm 3 is the naive version: return the M nearest candidates. Algorithm 4 is the one that matters: examine candidates nearest-first and add candidate e only if e is closer to the base element than to any already-selected candidate.

Here is the mental model. Naive top-M produces redundant edges. If your M nearest neighbors are all in the same tight cluster in the same direction, you have spent every edge on one direction and have no way to escape. The heuristic is an angular diversity filter. It approximates the Relative Neighborhood Graph, which is provably connected because it contains the Euclidean minimum spanning tree. Note that the guarantee does not transfer: HNSW approximates the RNG over a bounded candidate set, so it inherits no connectivity proof, which is exactly why unreachable points are possible at all.

Picture two clusters separated by a gap. Under naive top-M, no element in cluster A has an edge to cluster B, and your graph has two components. Under the heuristic, the nearest B-element survives pruning precisely because nothing already selected is closer to it than the base point is.

The paper's own framing is that the heuristic prevents search from getting stuck at cluster boundaries and maintains global connectivity by preferentially including elements from different clusters.

This is the single most important non-obvious idea in HNSW, and it is the thing most reimplementations get wrong. If you write your own and recall is mysteriously capped on clustered data, this is why.

Memory, three ways

The paper's link-storage formula is (Mmax0 + mL · Mmax) · bytes_per_link, giving 60 to 450 bytes per object for M in 6 to 48 with 4-byte links. At M=16 with Mmax0=32 that works out to about 151 bytes per element. hnswlib's rule of thumb is roughly M × 8-10 bytes, so 128 to 160 at M=16. Faiss's formula gives 128, because it accounts only for layer 0 and ignores upper layers.

The discrepancy is instructive: the upper layers add about 18% on top of layer-0 link memory. That is the price of O(log N).

The weaknesses are structural, not incidental

Faiss's wiki is blunt: HNSW does not support removing vectors from the index. hnswlib implements soft delete only, a DELETE_MARK bit in the link-list header.

The reason, stated perfectly in the IP-DiskANN paper:

"Since the graph is singly-linked, deletions are hard because there is no fast way to find in-neighbors of a deleted vertex."

That one sentence explains the delete problem across every graph index. To remove node v you must find every u with v in its neighbor list. That is a full scan unless you maintain in-edges, which doubles memory and doubles write amplification.

And repair is lossy. The standard fix connects v's in-neighbors to v's out-neighbors, but those new edges were never vetted by the pruning heuristic, so graph quality degrades monotonically with every delete.

It gets worse. A 2024 paper (arXiv 2407.07871) formalizes unreachable points: nodes that possess outgoing edges but lack incoming edges in all layers. They are permanently unfindable. Recall silently drops and nothing errors. The cause is HNSW's replaced-update strategy: if a deleted node was the sole source of incoming edges for a neighbor, that neighbor becomes unreachable after repair. At around 90% recall the authors measured replaced_update's insertion throughput running 5 to 10 times slower than query throughput on GIST and ImageNet.

That is the sharpest citable evidence that HNSW updates are structurally broken rather than merely slow. Plan your architecture around it.

DiskANN: the same rule with a knob, plus one beautiful trick

Vamana, the graph in DiskANN (NeurIPS 2019), builds differently: start from a random R-regular directed graph, process points in random order, run a greedy search to collect the full visited set, prune it, add back-edges, re-prune anything over degree R. Two passes, first with α = 1 and then with a user-chosen α ≥ 1.

The pruning rule, verbatim:

"for p' ∈ V do if α · d(p*, p') ≤ d(p, p') then remove p' from V"

Set α = 1 and the pruning rule is HNSW's neighbor selection heuristic. The paper describes α=1 as giving semantics similar to HNSW and NSG; the candidate sets still differ, since Vamana prunes over every visited node rather than the final candidate list. α is a relaxation knob on that shared rule.

With α > 1 the pruning is less aggressive: you keep an edge unless the already-selected candidate is closer by a factor of α. That retains more long-range edges, and the paper's justification is that it ensures distance decreases by a factor of α at each search step, reducing graph diameter. Fewer hops per query.

On SSD, hops are round trips. Diameter is latency. That is the whole design.

Other differences worth having in your head: Vamana's candidate set is all visited nodes rather than just the final L candidates, it starts from a random graph rather than an empty one, it does two passes, and it is flat. No hierarchy, no upper-layer memory, a single fixed medoid as entry point.

The 4 KB sector trick

In DRAM, DiskANN keeps PQ-compressed vectors at roughly 32 bytes per point. On SSD, for each point it stores contiguously: the full-precision vector, then the IDs of up to R neighbors, then padding to a fixed record size.

Fixed-size records mean, verbatim:

"computing the offset within the disk of the data corresponding to any point i is a simple calculation, and does not require storing the offsets in memory"

No offset table. Node ID times record size equals byte offset.

Now the economics. Reading a 4 KB aligned sector costs the same as reading 512 bytes. A degree-128 neighborhood is about 512 bytes of IDs. So the neighborhood and the full-precision vector fit in one sector, and:

The full coordinates sit in the same sector as the neighbor list, so retrieving a node's neighborhood brings its exact vector along with no extra reads to the SSD.

Full-precision reranking is free. You pay zero additional I/O to rescore against exact vectors, because the vector was already in the sector you had to read anyway.

That is the most elegant idea in the paper, and it is the one most relevant to anyone building a disk-backed database, because you already have the full vectors in your storage layer.

The operating characteristics are worth knowing too: target fewer than 10 disk round trips per query, ideally 5 or fewer; run the SSD at 30 to 40% load factor to avoid queue backlog; expect threads to spend 40 to 50% of query time in I/O.

The number that settles the memory-constrained case

On SIFT1B, at equal memory footprint, the reported 1-recall@1: IVFOADC+G+P at 16 bytes plateaued at 37.04%, at 32 bytes at 62.74%. DiskANN saturates at 100%, and still delivers above 95% in under 3.5 ms while sustaining over 5,000 QPS on 16 threads.

That is a 37-point gap at saturation, and 32 points at the sub-3.5ms operating point. If you are memory-constrained and need high recall, the graph wins and it is not close.

Also: for 98% 5-recall@5 with beam width 4, Vamana needs 2 to 3 times fewer hops than HNSW and NSG, and keeps improving with higher max degree while HNSW and NSG stagnate.

FreshDiskANN later added batch deletion with periodic consolidation, reporting a 5 to 10 times reduction in the cost of maintaining freshness while retaining over 95% 5-recall@5 with thousands of concurrent inserts, deletes, and searches per second. IP-DiskANN (2025) went further and processes each insertion and deletion in place, avoiding batch consolidation entirely.

Quantization is a decomposition, not a compression trick

Product quantization splits a d-dimensional vector into M sub-vectors, quantizes each independently with 2^b centroids, and stores M·b bits. The effective codebook size is (2^b)^M, which is the entire point: 2^64 distinct codes from eight small k-means runs.

Asymmetric distance computation is where the speed comes from. Build M lookup tables of 2^b entries per query, at cost O(2^b · d), once. Then each database vector costs M lookups and M-1 adds. At M=8 that is eight table lookups against 768 multiply-adds for exact f32. The codebook for d=768 at b=8 is about 786 KB, which is trivially cacheable, and that is why ADC is fast. The "asymmetric" in the name just means you only quantize one side; keeping the query in full precision is strictly lower error, and it is what everyone does.

RaBitQ (SIGMOD 2024) is the more interesting recent development because it comes with a bound. Center at the dataset centroid, project to the unit sphere, use the 2^D vertices of a hypercube with coordinates ±1/√D as the codebook, rotated by a random orthogonal matrix. The estimator is unbiased, and the error is O(1/√D) with high probability, which the paper argues is sharp and asymptotically optimal for D bits.

The comparison that matters: RaBitQ uses D bits, one per dimension. The PQ and OPQ configurations it benchmarks against use 2D bits by default. RaBitQ gets better accuracy at half the code length, with a guarantee, where PQ has none. On one dataset the paper reports PQ incurring more than 50% average relative error and never exceeding 60% recall even with reranking, a catastrophic case RaBitQ fixes.

Binary quantization is a high-dimension-only technique, and the vendor data proves it. Qdrant's published recall by embedding dimension: 0.9966 at 3072 dims, then 0.9847, 0.9826 and 0.98 at 1536, then 0.9563 and 0.9445 at 768, all with 3x or 4x oversampling. Their benchmark article says you can expect poorer results below 1024 dimensions because there is not enough information maintained in the binary vector. That monotonic degradation is a clean, defensible claim, and it should stop you from binarizing a 384-dimension model.

The universal pattern across every system I looked at is the same three steps: search with compressed vectors retrieving k × oversampling candidates, rescore those against full-precision vectors, return top k. Faiss spells it RFlat in the factory string. Qdrant calls it rescore and turns it on by default for binary. Elastic's BBQ does it. DiskANN gets it free.

The design lesson for a database is the decomposition itself. The index becomes a cheap, aggressively lossy candidate generator that lives in memory, plus an exact verifier that lives on disk. Correctness lives in the verifier, which means the generator can be as lossy as you like. And that is precisely the argument for putting a vector index inside a database rather than beside one: you already have the full vectors in your storage layer, so the verifier is free.

Matryoshka embeddings compose with all of this multiplicatively, since they compress the dimension axis rather than the precision axis. Truncate 3072 to 256 dimensions, then binary-quantize, and you have a two-stage funnel with the same shape.

Now the Rust part

Do not reach for Rc and RefCell

The consensus answer is arena or index-based adjacency, and conveniently it is also the fastest answer. Rc<RefCell<Node>> fails here for five separate reasons: 16 bytes of refcount overhead per node plus a borrow flag, pointer chasing that destroys locality, runtime panics instead of compile errors, cycles that leak (and a proximity graph is nothing but cycles), and no Send or Sync, which takes parallel build off the table entirely.

Look at what hnswlib actually does for layer 0:

size_links_level0_     = maxM0_ * sizeof(tableint) + sizeof(linklistsizeint);
size_data_per_element_ = size_links_level0_ + data_size_ + sizeof(labeltype);
char *data_level0_memory_;

Layer 0 is one flat byte array with the neighbor list, the vector data, and the external label packed contiguously per element at a fixed stride. Node ID to byte offset is a multiply. No pointer chasing, no per-node allocation. And touching a node's neighbor list brings its vector into cache for free, which is the same idea as DiskANN's 4 KB sector applied to cache lines.

Upper layers use a separate jagged allocation, which is fine because they are 1/M the size and traversed with ef=1.

In Rust that maps to something like:

struct Graph {
    // neighbors of node i live in adj[i*R .. i*R + degree[i] as usize]
    adj:     Vec<u32>,
    degree:  Vec<u16>,
    vectors: Vec<f32>,   // node i occupies i*d .. (i+1)*d
}

It emphatically does not map to Vec<Vec<u32>>.

Use u32 node IDs. That caps you at 4.29 billion nodes and halves adjacency memory against usize. USearch went further with a custom 40-bit integer, reporting 37.5% better space efficiency than 8-byte integers while still addressing over a trillion entries. For most projects u32 is the right call, and it is worth writing down as an explicit design constraint rather than discovering it later.

SIMD: the situation is worse than you think

std::simd is still nightly-only. Portable SIMD remains behind #![feature(portable_simd)], tracking issue rust-lang/rust#86656, with an open meta-issue for critical blockers before stabilization. Verified this week.

So you are on std::arch intrinsics with runtime dispatch, and the important detail is where you put the check:

pub struct Distances { dot: unsafe fn(&[f32], &[f32]) -> f32 }
 
impl Distances {
    pub fn detect() -> Self {
        #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
        {
            if is_x86_feature_detected!("avx2") {
                return Self { dot: dot_avx2 };
            }
        }
        Self { dot: dot_scalar }
    }
}

is_x86_feature_detected! caches its result in an atomic, but it is still a branch. Detect once, store a function pointer in the index struct, and hoist it out of the inner loop. A per-distance-call feature check will cost you more than the SIMD gains on short vectors.

Now the fact that surprises people most: the compiler will not auto-vectorize your dot product. LLVM can vectorize the multiplies, but float addition is not associative, so it cannot legally reassociate the accumulation into a tree reduction without fast-math flags that remain nightly-only in Rust. Your naive iter().zip().map().sum() compiles to a scalar dependency chain.

The fix is to make the reassociation explicit in the source:

fn dot(a: &[f32], b: &[f32]) -> f32 {
    let mut acc = [0f32; 8];
    let mut ai = a.chunks_exact(8);
    let mut bi = b.chunks_exact(8);
    for (x, y) in ai.by_ref().zip(bi.by_ref()) {
        for k in 0..8 {
            acc[k] += x[k] * y[k];
        }
    }
    let tail: f32 = ai.remainder().iter()
        .zip(bi.remainder())
        .map(|(x, y)| x * y)
        .sum();
    acc.iter().sum::<f32>() + tail
}

Eight independent accumulators, reduced at the end. Now LLVM has nothing to prove.

Also worth remembering that vectorization is complexity-limited and can change between compiler versions, so an auto-vectorized kernel can silently regress on a toolchain upgrade. Benchmark your distance kernel in CI, not once.

One more simplification available to you. On unit-normalized vectors, L2 ranking, cosine ranking, and dot-product ranking are the same ranking, since ‖a-b‖² = 2 - 2⟨a,b⟩ for unit vectors. Normalize once at ingest and implement only dot product. One kernel, one SIMD path to maintain. On un-normalized vectors these are three different rankings, and maximum inner product search is genuinely harder than metric search because the triangle inequality does not hold and the pruning arguments break.

For reference on what is achievable: SimSIMD (now renaming itself to NumKong) reports a 4.7x speedup over NumPy on f32 dot products at 2048 dimensions, with much larger gains on bf16, f16, and i8 mainly because the baseline libraries have no kernel for those at all. Note the honest part of their table, the error column: bf16 at 1.8% error is fine for candidate generation and not fine for final ranking. Which is the same candidate-generator-versus-verifier split from earlier.

Memory mapping, and a safety claim you should not make

memmap2 is the de facto crate, and its documentation says the thing people skip:

"All file-backed memory map constructors are marked unsafe because of the potential for Undefined Behavior (UB) using the map if the underlying file is subsequently modified, in or out of process."

This is unfixable in general. Any process with write access to your index file can cause UB in your address space, and the documented mitigations (file permissions, locks, unlinked files) are platform-specific and partial.

The honest engineering positions are: treat index files as immutable once sealed and only ever mmap sealed files, validate on load with a checksum, and document the risk. What you should not do is claim your mmap-based index is "safe Rust."

That immutability constraint is not a compromise, incidentally. It is the same architecture Qdrant and Lance both landed on.

Allocation discipline and the query path

The goal is zero allocations in the query path, and it is achievable. Never allocate a visited set per query: hnswlib uses a pool of arrays with a generation stamp, so resetting is an O(1) counter increment rather than a memset. Reuse candidate heap allocations by clearing rather than dropping. Neighbor lists are fixed-capacity in the flat arena, never a Vec per node.

This is the difference between your p50 and your p99.

For build parallelism, use rayon. The HNSW paper reports linear scaling to about 40 threads on 10M SIFT, so it genuinely works, and build time is where the leverage is. Insertion is read-mostly against the existing graph plus a small write to a handful of neighbor lists, which suits fine-grained per-node locks (hnswlib uses one mutex per node) or a sharded lock array indexed by node_id % SHARDS to bound the memory.

Meilisearch's arroy is a good Rust case study here. They use atomics for lock-free concurrent ID allocation, arguing it scales where their mutex-based design would have every thread waiting on one lock, and roaring bitmaps for deleted-item tracking cheap enough to share across every thread without copying. They publish no benchmark for the lock-free-versus-mutex claim, but the reasoning holds. They also got transactionality and crash safety essentially free by building on LMDB instead of inventing persistence, which for a database project is the obvious move and worth stealing.

Deletes: stop trying to repair the graph

Every production system marks and defers. hnswlib sets a delete bit. Faiss does not support deletion at all. Qdrant soft-deletes and ignores. Weaviate uses tombstones cleaned periodically. Lance uses deletion vectors that mark rows without rewriting fragments. pgvector marks and zeroes.

The cost of tombstones is that query work is proportional to physical size rather than logical size. A collection at 50% tombstones does roughly twice the distance computations. Qdrant's vacuum optimizer triggers at a deleted_threshold of 0.2 with a minimum of 1,000 vectors in the segment, which is a reasonable default to copy.

But the architecture that actually solves this is not a better repair algorithm. It is immutable searchable segments plus a small mutable head plus background merge. Qdrant's optimizer keeps the segment being optimized readable through a proxy while changes go to copy-on-write segments with retrieval priority. That sidesteps concurrent graph mutation entirely, and it composes with the mmap immutability requirement above, and it gives you a natural place to hang crash consistency.

If you are writing an index inside a database, you already have a WAL, a segment abstraction, and a compaction scheduler. Use them. Do not invent a second, worse persistence layer inside the index.

Benchmark honestly, or do not publish a number

The sins are consistent and easy to spot: benchmarking single-threaded and comparing against a multi-threaded baseline, measuring on a warm cache, reporting recall measured at a different ef than the latency, and omitting build time entirely.

A credible report states dataset and dimensionality, N, the exact index parameters at build and at query, thread count, batch size, whether the cache was warm, the recall definition used, peak build memory, build wall-clock, and the full recall-versus-QPS curve rather than a single point. If you cannot produce that, you have an anecdote.

The same skepticism applies to reading other people's numbers, including the ones in this post. Every figure here comes from a paper or a repository, and every one of them was measured at an operating point that may not be yours.

One last data point

Microsoft rewrote DiskANN in Rust. The repository is now 99.9% Rust, the legacy C++ branch is documented as not actively developed, and the current release is on crates.io. The central abstraction is a DataProvider trait, with reference implementations for in-memory, disk, a Redis-like KV store, and a B-tree, so the index composes into whatever storage engine you already have.

Meanwhile the most-downloaded pure-Rust HNSW crate, instant-distance, has not shipped a release since June 2023, and hnsw has not shipped since 2021. hnsw_rs is the exception and is actively maintained.

Draw your own conclusion about the state of the ecosystem. Mine was that the abstractions are converging on something a database can host natively, that the interesting work is in the storage integration rather than the graph algorithm, and that there is still room to build.


Related: where retrieval actually breaks, measured, and FerroDB, the Rust storage engine this post came out of.

Sources