perf(vector/hnsw): segment-local u32 ordinal graph + LVS1 v2 format (#853)#854
Merged
Conversation
…853) Address the HNSW search hot loop's per-candidate hash probes by keying the read-side graph with segment-local u32 ordinals (the rank of a doc id in the ascending unique record id sequence): - reader-side OrdinalHnswGraph: adjacency holds u32 ordinals, neighbour expansion is a direct Vec index (was an AHashMap probe per hop), and the quantized distance lookup becomes an array access (was a SipHash HashMap probe per candidate) - visited bitmap is sized by node_count instead of max_doc_id + 1, so a long-lived store whose global id allocator has advanced far past the segment's node count no longer pays a proportionally inflated allocation + memset per query - int8 SQ prefetch computes record addresses directly (base + pos * stride); frontier candidates shrink from 16 to 8 bytes - LVS1 header version 2 (HNSW only): the graph block stores ordinals and drops the per-node doc id (~2x smaller on disk); v1 segments still load via rank translation and upgrade on the next rewrite; Flat/IVF keep stamping v1 - writer build internals stay doc_id-keyed; write() emits v2 with guards that refuse to serialize a corrupt block - multi-field legacy segments get an explicit ordinal -> position table (u32::MAX = absent), preserving the #676 field-routing semantics Store-level benches: Deletion Search del0pct -61%, del10pct -56%; new sparse-id case (ids from 50M, churn-shaped) -86%; multi-field -7%. Adds the first legacy-format fixture tests (v1 read, v1->v2 upgrade, corruption cases, empty graph, multi-field routing).
This was referenced Jul 12, 2026
Closed
Merged
mosuka
added a commit
that referenced
this pull request
Jul 12, 2026
…segment dictionary (#859) (#862) Final piece of the Issue #633 field-name work: readers keep one shared Arc<[Arc<str>]> dictionary per segment and (doc_id, u16) pairs per record instead of one heap String per record per retained structure. - FieldInterner (format.rs): v3 segments use the header dictionary (fixed, ids validated); v1/v2 segments synthesize it at load in first-appearance order, so legacy files get the same interned representation with no version branching afterwards - readers: vector_ids becomes Vec<(u64, u16)>; IVF cluster_to_vectors likewise (its eager path used to retain 3 String copies per record); per-field caches and pool build inputs keep their String shapes (transient clones only, nothing per-record retained) - VectorStorage::get/contains take (doc_id, &str) and the OnDemand arm resolves the name against the segment dictionary by linear scan — the per-candidate field_name.to_string() on the mmap-default f32 paths is gone structurally; OnDemand offsets are keyed (u64, u16) - IVF searcher: probe results stay (u64, u16), the field filter resolves once per search and compares u16 per candidate, and names rehydrate only for emitted top-k hits - public trait signatures unchanged: vector_ids(), iterators, and the other boundary methods rehydrate from the dictionary at return time RAM accounting (structural): a retained per-record String costs ~24B header + heap + allocator overhead, x2 retained copies (HNSW/Flat) or x3 (IVF); the interned pair costs 16B inline with zero per-record heap. Eager gate bench neutral (del0 33.4us / del10 29.3us, inside the post-#854 envelope).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements #853: the HNSW read side moves from external u64 doc ids to segment-local u32 ordinals (rank in the ascending unique record doc-id sequence), removing every per-candidate hash probe from the search hot loop, and introduces the LVS1 header version 2 graph block that encodes those ordinals on disk.
Why this works where #709 (width-only narrowing) failed: the win is not the width — it is that ordinals turn the per-candidate SipHash
HashMap<u64,u32>probe (quantized distance lookup), the per-hop AHashMap probe (get_neighbors), and the doc_id-keyed prefetch probe into plain array indexing, and let the per-query visited bitmap be sized by the segment's node count instead of the global doc-id space.Design
OrdinalHnswGraph(validated at load); version-dispatched graph parse — v1 = legacy parse + rank translation (warn-and-drop for corrupt danglers), v2 = direct HashMap-free build with hard validation.ord_to_posis identity for single-field segments (every current producer); legacy multi-field segments get an explicit table withu32::MAX= absent, preserving the perf(vector/search):VectorSearchParams.fields(field selector) ignored — no per-field routing #676 field-routing semantics.BitVec(node_count)visited set, probe-freedistance_ord, direct-address int8 prefetch.ResultCandidate, rerank, and the perf(vector/search): cardinality-driven mode selection for filtered HNSW (follow-up of #645) #738 brute-force mode stay u64; the perf(vector/search): no filter-aware HNSW graph traversal —allowed_idsis post-filter only #645 pristine/bookkeeping codegen split is preserved. Pass-1 prefetch loops are gated (prefetch_enabled) so no-prefetch storages skip them entirely — a small real regression caught and fixed during A/B.write()emits v2 with guards that refuse to serialize a corrupt block (node_count < u32::MAX, graph/record set equality);load()reads both versions, so compaction/merge upgrade v1 segments in place..delmap-style version dispatch precedent followed; the v2 graph block is ~2x smaller on disk.Measured (A = main ×2 runs, B = this branch, medians, worst-pairing)
Deletion Search/top10/del0pctDeletion Search/top10/del10pctHNSW Graph Search sparse_ids/top10/10000(new bench; ids from 50M — churned-store shape)HNSW Multi-field Search(non-identityord_to_posguard)B-side same-code spread on the deletion cases: ±1.7%. The sparse case is the visited-bitmap structural fix (the #647 premise): v1 zeroed ~6.3MB per query there, ordinals ~1.25KB.
Honest caveat: the mmap/OnDemand f32 benches (historical ±6–19% noise class) are mixed at 50k (+17% plain vs −0.2%/+20% on the same-traversal rerank case across two runs of the same/lighter code) — that class cannot resolve ±20% on this host and is flagged for post-merge observation. Dense 1k/5k, ef_16/ef_128 and multi-field all improved. Full A/B tables and the Phase 0a/0b gate record are in #853.
Testing
--all-targets --features embeddings-all+ wasm target);cargo checkclean for wasm32 and--features pq-fastscan.persistence.md(en/ja) documents the versioned segment headers; markdownlint clean.Closes #853
Refs #686