All notable changes to this project are documented here. The format follows Keep a Changelog, and this project follows Semantic Versioning. The 0.x series is unstable: minor bumps may break the public API.
- Added an
updatable_storeexample covering add, checkpoint, delete, reopen, and search through the optionalstorefeature. - Added
diskann::DiskANNSearcher::search_with_diagnostics, which reports per-query graph reads, vector reads, logical bytes read, and visited-node counts for the file-backed search path. - Added a
store_reopen_diagnosticsexample that prints first snapshot-search cost with persisted HNSW sidecars present versus after deleting sidecars and forcing source-segment rebuilds. - Added
store::SnapshotIndex, a read-only checkpoint view that opens the segstore manifest and queries persisted HNSW sidecars before falling back to one source-vector segment when a sidecar is missing or stale.
examples/dual_branch_demo.rsnow reports standard-HNSW recall against metric-matched brute-force ground truth and labels Dual-Branch recall changes as deltas rather than assumed improvements.store::UpdatableIndexnow keys its in-memory per-segment HNSW cache by segstore's stable segment ids instead ofArcpointers, and prunes stale cache entries when compaction/reclaim changes the segment set.- Store writer searches now build the temporary writer-buffer HNSW from the buffer slice instead of cloning buffered vectors first.
- The
storefeature now requiressegstore = "0.5"anddurability = "0.7.1"for manifest-only snapshot reads and checked sidecar envelopes.
- Corrected the RaBitQ example command and README quantization feature note to match the current feature split.
- Store sidecar envelopes now include the
segstoresegment id, so a copied or misnamed HNSW sidecar is rejected and rebuilt instead of being accepted for a different segment. store::UpdatableIndex::{compact, compact_tiers, reclaim}now persist sidecars for newly merged segments immediately after segstore checkpoints them, instead of waiting for the next search to rebuild and write the sidecar lazily.
HNSWIndex::to_postcardandHNSWIndex::from_postcardare now available with thepersistencefeature, so consumers can persist graph sidecars without enabling the fullstorefeature.- The
storefeature persists per-segment HNSW sidecars and loads them on restart when the build recipe and live id set still match.
- The
storefeature now requiressegstore = "0.4"for persisted per-segment index sidecars.
- Stale, corrupt, or recipe-mismatched sidecars are rejected and rebuilt instead of being used for search.
store::UpdatableIndex::extend(vectors): bulk ingest that syncs the write-ahead log once per batch instead of once per vector (each vector is dimension-checked and L2-normalized before any is ingested). ~3.4x faster than a loop ofaddon a real filesystem (benchingest_fs: 12.6ms vs 3.7ms / 4000 vectors).
- The
storefeature now requiressegstore = "0.3"; the internalmerge_segmentstakes&[&Segment](segstore 0.3's by-reference signature).
- The
storebench opts out ofclippy::unwrap_used(benches legitimately unwrap on setup), fixing a pre-existing CI failure.
- A
deletenow invalidates only the cached HNSW of the segment that holds the id, not the whole cache, so one delete no longer forces every segment to rebuild on the next query.
store::UpdatableIndex::compact_tiers(): one round of size-tiered compaction (merge similarly-sized segments), keeping segment count bounded without a fullcompact().
store::UpdatableIndex::reclaim(min_live_ratio)andspace_amplification()(via the newStore::live_len): cheap tombstone reclamation, merging only the delete-heavy segments instead of a full compaction.
store::UpdatableIndexnow targets segstore 0.2 and caches the per-segment HNSW by the segment's stableArcidentity, so a sealed add rebuilds only the one new segment instead of the whole corpus. Measured: a single add after 5k vectors took the next search from a full ~193ms rebuild to ~5ms (40x -> 1x). Deletes still invalidate the cache (a tombstone changes the live filter); they are far rarer than adds.
store::UpdatableIndexcaches the per-segment HNSW indexes and rebuilds them only on mutation (add/delete/compact), instead of rebuilding every segment's graph on every query.store::UpdatableIndex::addnow returns an error for a vector whose dimension does not match the index, rather than silently dropping it from every rebuild.- Restored the
cdylibcrate-type so maturin builds thepyvicinityextension (it reported "No Cargo targets to build" without it).
storedocs no longer claim an "exact" cross-segment merge. HNSW search is itself approximate, so the merged result is approximate.
- Optional
storefeature:store::UpdatableIndex, an updatable, durable multi-segment ANN index backed bysegstore. A per-segment HNSW is searched and merged (exact top-k); vectors are L2-normalized on ingest. Incremental add/delete plus write-ahead log, checkpoint, compaction, and crash recovery. Opt-in; the default build does not depend on segstore. clifeature: avicinitybinary that builds an HNSW index from a JSONL file of dense vectors and runs k-NN queries.
- Corrected
HNSWBuilder::m_maxandHNSWIndex::newparameter docs:m_maxcaps neighbors on the base layer (the paper'sM_max0, default2 * m). The previous docs claimed it applied to non-zero layers and defaulted tom; construction has always usedm_maxfor layer 0 andmabove it. - Corrected
HNSWIndex::search_with_distancedocs: the closure'sinternal_idindexes the BFS-reordered vector storage afterbuild(), not insertion order. Parallel arrays must be built fromraw_vectors()afterbuild().
- Documented contracts on
HNSWIndex::build(BFS reorder of internal IDs, idempotency, RNG determinism viaHNSWParams::seed),search(error variants,k > nreturns fewer results,ef < ksilently clamped tok), andraw_vectors(post-build reorder warning, previously only on the legacyvectors_rawalias). - Documented the segment persistence compatibility contract on the
persistencemodule and in the README: 0.6.x legacy fallback, future-version rejection, corrupt/truncated input returns errors rather than panicking. - Tests pinning future-format-version rejection, garbage-byte JSON load,
k > nandk = 0search results, exact error variants for dimension mismatch / add-after-build / empty build, andbuild()idempotency.
HNSWIndex::searchandbatch_search_mqonow also normalize the query whenauto_normalize=truefor cosine and angular metrics. Previously the flag normalized inserts only, so search queries silently produced meaningless distances under cosine (the dot-only fast path assumes both sides are unit-norm). The Python binding already did this inprep_query; the Rust API now matches.- README HNSW snippet ran but raised
InvalidParameteron cosine because the constant[0.1; 128]isn't unit-norm. Now usesauto_normalize(true)on the builder so the literal copy-paste works. - README IVF-PQ snippet trained 256 clusters on 2 vectors and silently
returned distance=0 for every result. Reframed as API-shape with a note
about training-data scale plus a pointer to
examples/ivf_pq_demo.rs.
pyvicinityPython bindings on PyPI (abi3 wheel, CPython 3.9+). Ships hand-written.pyistubs +py.typed(PEP 561), exposesHNSWIndex+DistanceMetric. Verified in CI bymypy.stubtest,mypy --strict,ruff, and 25 pytests.examples/python/: text similarity (sentence-transformers), recall@k sweep, and ann-benchmarks/VIBE drop-in wrapper.- Sentinels
pyvicinity.MISSING_LABEL(= -1) andpyvicinity.MISSING_DISTANCE(= +inf) for masking short rows inbatch_searchresults, matching faiss's convention. auto_normalizeconstructor flag normalizes vectors on both insert and query (closes the hnswlib #592 class of correctness bug). Rejected at construction withL2/InnerProductsince those metrics don't have spherical semantics.- CI gate on
publish-pypiworkflow: aborts unless the source SHA'sci.ymlrun issuccess. - musllinux x86_64 + aarch64 wheel matrix in publish-pypi.
- All GitHub Actions in
publish-pypi.ymlpinned to specific minor versions instead of mutable major tags.
- Renamed
esgmodule torange_filtered. The 0.7.xesgname implied fidelity to the partition-aware structure of Yang et al. 2025 (arXiv:2504.04018); the shipped implementation is the paper's strawman baseline (HNSW + attribute-range post-filter), so the rename reflects what the module actually does. A paper-fidelity ESG variant is planned as a separate module.EsgIndex->RangeFilteredIndexEsgParams->RangeFilteredParams- feature
esg->range_filtered - module path
vicinity::esg::*->vicinity::range_filtered::* RangeFilteredParams::num_checkpointsremoved (was deprecated in 0.7.3; the partition-aware path that consumed it was never implemented).
vicinity::spectralmodule visibility downgraded frompubtopub(crate). The Marchenko-Pastur helpers were reachable only viaADSamplingState::new_auto, which has no production consumer. Thermt-spectralfeature and thermtdep remain available for the internalnew_autopath; the public wrappersmp_lambda_plusandcount_mp_outliersare no longer accessible from outside the crate.
-vicinity = { version = "0.7", features = ["esg"] }
+vicinity = { version = "0.8", features = ["range_filtered"] }
-use vicinity::esg::{EsgIndex, EsgParams};
+use vicinity::range_filtered::{RangeFilteredIndex, RangeFilteredParams};
let params = EsgParams {
- num_checkpoints: 16, // remove
hnsw_m: 16,
hnsw_ef_construction: 200,
ef_search: 100,
};FreshGraphIndex::insertno longer rebuilds the per-nodeinbound_countarray from scratch on every call. The count is maintained as a struct field acrossadd_slice/insert/build/compactand updated per neighbor-list mutation. Per-insert cost wasO(max_degree²) + O(n × max_degree)and is nowO(max_degree²)- flat innonce every node is at cap. Measured on Apple Silicon at dim=32, max_degree=16: ~120 µs/op at n=10K..50K vs an extrapolated ~920 µs/op pre-fix at n=50K (~7x speedup at scale). Both reachability invariants (I1 entry-point liveness, I2 in-edge survival under reverse-edge prune) survive the refactor.HNSWIndex::save_to_fileis now atomic and durable. Writes to a sibling temp file, fsyncs the temp, atomically renames into place, then fsyncs the parent directory so the rename survives a crash on filesystems that journal data and metadata independently (XFS, some ext4 configs, overlayfs). A crash mid-write leaves the prior file intact rather than truncated. P-HNSW (MDPI 2025) flagged partial-write corruption as a recurring failure mode; this routine is the in-tree counterpart todurability::storage::FsDirectory::atomic_write(which is gated behind thepersistencefeature). Cost: ~13 ms/op (durability syncs) vs ~0.5 ms/op for the prior non-atomic path; trade-off is correctness, dominated by typical save cadence.AcornConfigdocstring now names the selectivity regimes where 2-hop expansion is load-bearing (2-20%), where it's overhead (>20%), and where it hits a recall floor (<2%, fall back toselectivity_searchwithmatching_ids). Doc-only change;selectivity_search's Low branch already routes correctly.
acorn_search_with_statsreturns anAcornStats { two_hop_invocations, two_hop_nodes_examined }struct alongside results, so regression tests can assert "the 2-hop branch fired N>=1 times" rather than relying on a noisier black-box recall delta. The existingacorn_searchbecomes a thin wrapper that discards stats; signature unchanged.AcornStatsis#[non_exhaustive]so additional counters can be added without bumping major. The wrapper has no measurable overhead in release builds (Rust inlines the discard-stats indirection; measured 803 ns/op for both entry points).tests/hnsw_integration_tests.rs::test_acorn_two_hop_branch_fires_at_sparse_selectivityasserts the 2-hop branch fires at least once at ~2.5% selectivity. Verified to catch the gate-disabled regression by togglingenable_two_hop=falseand confirming the assertion fires withtwo_hop_invocations=0.src/hnsw/graph.rs::tests::test_delete_with_repair_preserves_self_search_reachabilityasserts every surviving doc_id is self-search-reachable after deleting 60% of nodes viadelete_with_repair. Existing tests stop at 20-30% deletion and check result count or top-k recall; this guards the failure mode where many cumulative repair calls leave individual live nodes orphaned.src/fresh_graph/mod.rs::tests::build_post_state_every_non_entry_node_has_inbound_edgeis a static post-build connectivity guard: every non-entry node has at least one inbound edge. Adapted from qdrant'stest_graph_connectivityshape, parameterized over five seeds. Catches build-time orphans the dynamic delete-reinsert cycle test wouldn't surface.src/hnsw/graph.rs::tests::test_hnsw_save_to_file_uses_rename_not_truncateis a Unix-only inode-based gate guard: truncate-overwrite preserves the inode, rename-overwrite replaces it. Verified to catch the regression by revertingsave_to_fileto non-atomic and confirming this test fails (left==right) while the happy-path roundtrip tests still pass.src/hnsw/graph.rs::tests::test_hnsw_build_save_reload_search_equalityend-to-end build -> save_to_file -> drop -> load_from_file -> search- equality round-trip via the path-based API on a fresh live index. Plugs the gap between the in-memory roundtrip (already covered) and the v0 fixture decode tests (which pin a static byte sequence but don't exercise the writer).- Three
#[ignore]'d perf probes (orphan_protection_scaling_probeextended to n=50K;save_to_file_overhead_probe;acorn_stats_overhead_probe) for one-shot validation of the changes above. Same shape as the existing scaling probe; not run in CI.
- No upstream dependency bumps. The session also added a
sync_parent_of_pathhelper todurability(0.6.4) for the upstream concern that motivatedsave_to_file's atomic refactor; vicinity's ownsave_to_fileinlines the equivalent pattern rather than taking the helper as a dep, sincesave_to_fileisserde-gated anddurabilityispersistence-gated.
- Real v0.6.2-written segment fixture under
tests/fixtures/v0_segment_dim8/(~2.6 KB total) and a regression test (real_v0_fixture_loads_and_searches_correctly) that loads it via the v1 reader and asserts the search ordering matches the original v0.6.2 run. Guards the legacy v0 decode path against silent regression and documents that 0.6.x → 0.7.x persistence migration works on real data. The 0.7.0 test set only proved no-panic on random bytes; it did not prove correctness on a real legacy file. tests/edge_cases.rs::search_returns_external_doc_ids_at_high_offsetpins the external-doc_idcontract atu32::MAX - 200(catches a regression where the search path returns an internal slot index).tests/edge_cases.rs::zero_query_does_not_panic_with_auto_normalizepins the documented zero-query path: search returns finite distances or an explicit error, never a panic on the0 / ||q||normalization step.src/hnsw/graph.rs::tests::test_delete_with_repair_recall_floor_against_ground_truthis a recall-floor companion to the existingtest_delete_with_repair_maintains_recall. The original asserts result count and absence-of-deleted-ids; this adds a recall-vs-brute-force ground-truth assertion at >= 0.7 after deleting 20% of nodes.tests/hnsw_integration_tests.rs::test_acorn_low_selectivity_returns_valid_resultsguards ACORN at ~2.5% predicate selectivity. An earlier draft comparedenable_two_hoptrue vs false and asserted a positive recall gap; in measurement the gap flipped sign at sparse selectivity (2-hop adds candidates that displace better ones in a tight beam). Reframed as a sparse-predicate regression guard with a 0.5 recall floor.src/fresh_graph/mod.rs::tests::delete_reinsert_cycles_preserve_reachabilityguards two FreshGraph invariants that together close the delete-reinsert unreachability failure mode of arxiv:2407.07871: (I1)entry_pointalways references a non-tombstoned internal id, and (I2)insert's reverse-edge prune never evicts a node whose only remaining in-edge would be the one through the neighbor being pruned at. Runs 200 cycles on a 60-vector graph and asserts every livedoc_idis self-search-reachable. The assertion message names the broken invariant when it fires.src/fresh_graph/mod.rs::tests::orphan_protection_scaling_probe(#[ignore]'d, run with--release --nocapture) measures per-insert cost of the orphan-protection rebuild asngrows. Sample on Apple Silicon, dim=32: 12 µs/op below max-degree saturation, 300+ µs/op once every node hits cap. Validates the rebuild-per-call cost is acceptable at FreshGraph's target n; informs whether incremental inbound-count maintenance is worth designing.
DistanceMetric::InnerProductdoc comment now states that the caller is responsible for L2-normalization. Un-normalized inner-product ranking is dominated by magnitude, which is rarely the intended retrieval behavior (Milvus discussion #32479).FreshGraph::buildandFreshGraph::insertnow share a singleadd_reverse_edge_protectedhelper. Previously the reverse-edge prune was duplicated between the two paths and onlyinserthad orphan protection. The build path now applies the same I2 invariant symmetrically: the initial RNG-prune pass cannot evict a node whose only remaining in-edge would be the one being considered.
- FreshGraph delete-reinsert unreachability. Two paired changes:
delete()now repromotesentry_pointwhen it tombstones the medoid. Previously the entry-point field was left pointing at the deleted internal index, so subsequent searches and inserts rooted their beam at a dead anchor. Repromotion prefers a live neighbor of the stale entry (preserves graph locality) and falls back to a linear scan over live nodes only if the entire neighborhood is tombstoned.insert()'s reverse-edge prune now computes a per-insert inbound-degree count, marks any candidate whose count is<= 1as orphan-protected, and runs RNG-prune only over the unprotected remainder. Without this, repeated cycles can evict a long-existing in-edge from every list that referenced it, leaving its target unreachable even though the node was never deleted.- The two changes are paired because the entry-point fix alone leaves the same unreachable set on the regression test (5 of 60 live ids after 200 cycles). The orphan-protection change is the load-bearing one; the entry-point fix is shipped because rooting search at a tombstoned anchor is incorrect on its own merits.
- HNSW segment binary persistence now starts with a magic + version header
(
HNSW_SEGMENT_MAGIC = b"VCNHNSW\x01"+FORMAT_VERSION: u32 = 1). Mismatched magic returnsPersistenceError::Formatinstead of silently decoding garbage; unsupported version numbers return a descriptive error. Files written by 0.6.x lack the magic — the load path falls back to a legacy v0 decoder, so existing persisted indices round-trip transparently. tests/persistence_robustness.rs::segment_binarymodule:loading_corrupt_magic_returns_format_error(sanity), andproptest_one_byte_corruption_never_panics(random byte flip in the metadata header always producesResult::Err, never a panic).
docs/GUIDE.mdoutlier-detection example referenced a non-existentLidEstimate.categoryfield. Switched toLidStats::from_estimates(&est).categorize(lid), which is whatexamples/lid_outlier_detection.rsalready does.- Three rendered intra-doc-link errors in
src/lemur/model.rs([hidden_dim]was parsed as a link target by rustdoc). All shape annotations now use backticked code so they render literally. docs/datasets.mdreferenced a non-existenthdf5Cargo feature. Updated to point atscripts/download_ann_benchmarks.py(the actual conversion path).lib.rsrecommendation table claimed DiskANN persistence was mmap-based;src/diskann/disk_io.rs:95documents it as planned. Now reads "file-based save/load; mmap planned".- README and benchmark-results.md called the GloVe-25 dataset "cosine"; the ann-benchmarks dataset and the rendered plot are angular distance.
- README's NSG row claimed a 50K hard cap; the limit is empirical, not enforced. Reworded to "build slows above ~50K vectors".
docs/GUIDE.mdquickstart now usesHNSWIndex::builderto match the README and the bulk of the examples. The directHNSWIndex::new(dim, m, m_max)constructor is still exposed.- README's Supported Algorithms table now surfaces LSH (cross-polytope) and
LsmIndex(LSM-tree streaming HNSW), both previously implemented but undocumented. KD-Tree,Ball Tree, andRP-Forestrows are flagged "(experimental)" persrc/classic/mod.rs.
- CI tightened to
-D warningsfor clippy across the per-feature matrix. - Fixed six pre-existing clippy issues in production code (uncovered by the stricter CI).
- Test code is now allowed to use
unwrap/expect/needless_updatevia scoped#[cfg_attr(test, allow(...))]inlib.rs.
publish-pypiGitHub Actions workflow with OIDC trusted publishing and a full wheel matrix.
- Python package renamed
vicinity→pyvicinityfor PyPI registration.
- IVF-RaBitQ cross-cluster ranking via the qntz typed-edge API (corrected a cross-cluster comparability bug introduced when residuals are evaluated against different cluster centroids).
- Vamana parallel build with batched rayon and deferred pruning. Measured 9.5x speedup on SIFT-128 (35 min → 3.7 min) and 7.1x on GIST-960; default build batch is 4096.
- DiskANN parallel build (same batched-rayon pattern as Vamana).
- NSW parallel build.
- Batched distance computation in HNSW beam search (Faiss pattern). +10% QPS
at
ef=100on SIFT-128. - SymphonyQG-VR (vertex-relative) variant with per-parent residual encoding, ported to qntz's type-safe edge API.
- Module docs across the crate were updated to drop stale version references and machine-specific numbers from public doc comments.
- README opening trimmed to undersell tone (one-line description; no feature list in the tagline).
- SymphonyQG-VR cross-space bias: recall jumped from 55-86% to 99.9% at ef=400 on the standard benchmark after correcting the cross-parent distance comparison.
- SymphonyQG-VR cross-parent distance comparability (precondition for the bias fix above).
- Per-parent VR residual (kept the simpler global-rotation variant after the per-parent variant showed no recall benefit).
Highlights from the 0.5 line: generation-counter visited-tracking in HNSW
search (replacing per-search HashSet allocation), devirtualized distance
dispatch, SymphonyQG search variant, and KD-Tree pruning improvements. See
git log v0.4.0..v0.5.0 for the full commit list.
Highlights: ADSampling integration, SQ4 (4-bit scalar quantization) module,
and dependency bumps for innr, clump, sbits, rankops. See
git log v0.3.6..v0.4.0 for details.
The 0.3 line covered the initial public-API stabilization, the
MetadataValue enum and Range filter (breaking, in 0.3.5), and a series of
CI-greening commits. Earlier than 0.3 the project was pre-public; consult
git log v0.3.0.. for full history.