FeOx ANN
HNSW approximate nearest neighbor search for Rust. Deterministic builds, SIMD distance kernels, zero dependencies.
Documentation | FeOxDB | Issues
Part of the FeOx family. For a persistent vector store with metadata filtering, see feox-vector, built on FeOxDB and this index.
Features
- SIMD Distance Kernels: NEON on aarch64, AVX2+FMA on x86_64 with runtime detection, scalar fallback elsewhere. Four-accumulator fused multiply-add loops, written as intrinsics so LLVM can inline them into the search loops.
- Deterministic Builds: Node levels come from a stable hash of the record id. The same records in the same order produce the same graph, every time. See Why Deterministic Builds.
- Parallel Bulk Load:
bulk_load()runs candidate searches on worker threads against a frozen graph and applies the results in fixed order. No locks, no shared mutable state, and the output does not depend on the thread count: 1 thread and 16 threads produce identical graphs. - Checksummed Persistence:
save_to()/load_from()write a compact binary snapshot (FXA1) with a CRC32 integrity check and fsync on save. Corrupted or truncated snapshots are rejected.save_in_background()snapshots without blocking. - Concurrent Queries: Queries take
&selfand use thread-local scratch buffers. Zero allocation per query, no coordination between readers. - Diversity-Heuristic Neighbor Selection: Links are selected with the HNSW paper's diversity heuristic, then backfilled to the degree limit. This yields far higher recall per edge than plain top-M selection.
- Zero Dependencies:
thiserroronly. - Cosine Similarity: Vectors are normalized on insert and scored with the SIMD dot product.
- Filtered Search: Pass any
Fn(&str) -> boolclosure (orAnnFilterimpl) to restrict results at query time. - Soft Deletes and Upserts: Deletes tombstone nodes without a rebuild. Upserts replace visible records in place.
- Multi-Entry-Point Search: Keeps a capped set of entry points and starts from the best one for each query. Improves recall on clustered data.
Quick Start
[]
= "0.1"
use ;
Filtered search takes any closure over the record id:
let filter = ;
let matches = index.query?;
Parallel bulk loading:
let threads = available_parallelism.map.unwrap_or;
let mut index = new?;
index.bulk_load?;
Persistence:
index.save_to?;
let restored = load_from?;
let handle = index.save_in_background;
Tuning
AnnConfig::for_dimensions(d) gives sensible defaults (max_neighbors: 24, ef_construction: 160, ef_search: 64). The usual HNSW trade-offs apply:
| Parameter | Effect |
|---|---|
max_neighbors / max_base_neighbors |
Graph degree. Raising it improves recall and costs memory and insert speed. |
ef_construction |
Candidate breadth during insert. Raising it improves graph quality and slows builds. |
ef_search |
Candidate breadth during query. Raising it improves recall and adds latency. Can be set per query. |
Why Deterministic Builds
Classic HNSW assigns each node a level by sampling from a random number generator. Two builds of the same data produce two different graphs that answer some queries differently. The index becomes state with its own identity: you build it once, back it up, and distribute the artifact, because a rebuild will not reproduce it.
FeOx ANN derives the level from an FNV-1a hash of the record id, two bits per level, which follows the same geometric distribution in expectation. The graph is a pure function of the records and their insertion order. In production this means:
- Replicas agree. Servers that rebuild the index independently from the same records serve identical results. No artifact distribution, no answer drift between nodes behind a load balancer.
- Deploys are diffable. If retrieval changes after a rollout, the cause is the code change, never build randomness.
- Bugs reproduce. Rebuild from the source records and you get the exact graph that misbehaved, on any machine.
- Recall is testable. CI can assert an exact recall number. Tuning changes compare against a noise-free baseline.
Sequential builds of other HNSW libraries can be made reproducible with a fixed seed. Parallel builds cannot: per-node locking makes the graph depend on thread scheduling, with or without a seed. FeOx ANN keeps determinism at full parallel build speed.
Benchmarks
The criterion benches build a 10,000 x 128d index from a seeded generator and measure top-10 query latency at ef_search values of 32, 64, 128, and 256, plus a filtered variant and 1-thread vs N-thread builds. Deterministic inputs make the numbers comparable across machines and commits.
Reference numbers on Apple Silicon, single-threaded queries, 10k x 128d uniform random vectors. Random vectors are a worst case for ANN; real embeddings reach higher recall at the same ef:
| Operation | Result |
|---|---|
| Bulk load, 1 thread | ~1.9 s |
| Bulk load, 16 threads | ~1.2 s |
| Query top-10, ef=32 | ~23 us |
| Query top-10, ef=64 | ~41 us |
| Query top-10, ef=128 | ~76 us |
| Query top-10, ef=256 | ~141 us, recall@10 0.95 |
Comparison
A reproducible head-to-head against usearch (SIMD C++ with Rust bindings) and instant-distance (pure Rust) lives in comparison/:
SIFT1M: 1,000,000 x 128d SIFT descriptors, cosine similarity over normalized vectors, ground truth computed exactly, recall@10 over 500 queries. All engines configured with graph degree 24 and build breadth 160. Queries single-threaded, engines run one at a time. Apple Silicon.
| Engine | ef=64 | ef=128 | ef=256 |
|---|---|---|---|
| feox-ann | 0.951 at 202 us | 0.984 at 328 us | 0.998 at 585 us |
| usearch | 0.969 at 303 us | 0.992 at 536 us | 0.999 at 967 us |
| instant-distance | 0.989 at 519 us | 0.996 at 963 us | 0.999 at 1848 us |
Read the table as a recall/latency frontier. At 0.99 recall feox-ann answers in roughly 60% of usearch's time on this dataset, and at each latency point it reaches equal or higher recall than both. Build times differ in parallelism model and are listed for context only: feox-ann 237 s deterministic parallel on 16 threads, instant-distance 116 s with rayon, usearch 510 s through a single-threaded add loop. One dataset on one machine; run the harness on your own data before deciding.
Deletes and Rebuilds
delete() marks nodes as tombstones. They stop appearing in results immediately and remain in the graph as routing waypoints until you rebuild. For collections with heavy churn, rebuild periodically from the source of truth (feox-vector automates this with dirty tracking and background rebuilds). Snapshots preserve tombstones, so a saved and restored index behaves identically to the original.
Limitations
- In-memory graph: the index (vectors plus neighbor lists) lives in RAM. Snapshots persist it to disk, but querying requires loading it back. At roughly 4 bytes per dimension plus about 250 bytes of graph per record, 1M x 128d costs about 750 MB. Use a disk-native engine for datasets that exceed memory.
- Cosine similarity only: vectors are normalized on insert. No L2, no inner product on unnormalized vectors, no quantization. f32 only.
- Tombstones until rebuild: deletes hide records immediately but leave graph nodes in place. Heavy churn degrades the graph until you rebuild from source.
- Determinism is per architecture: NEON and AVX accumulate in different orders, so scores can differ in the last bit across CPU families. Same machine, same build: identical graphs and results.
License
Licensed under the Apache License, Version 2.0. See LICENSE.
Contributing
Contributions are welcome! See CONTRIBUTING.md.