fix(vector): Fix HNSW entry metadata and stale hits when an external ID is reused - #64
Merged
Conversation
Reinserting the external ID owned by the HNSW entry point orphaned the old slab slot but left entry_slot and entry_layer attached to it. The v2 writer serialized only the current slot for that ID while pairing it with the old slot's higher entry layer. On current main this produces a logically inconsistent but CRC-valid graph; a loader that validates the entry shape rejects it, while the current loader retains impossible entry metadata. Maintain the runtime invariant that the entry is a highest-layer live slot. When its identity is reused, direct insertion selects the highest remaining live node after the replacement becomes current. Search keeps orphan slots traversable for connectivity but excludes them from results, preventing a superseded vector from escaping under the current external ID. The writer independently derives both entry ID and layer from live topology as defense in depth. The deterministic reproduction inserts ID 7 at layer 3 and reuses it at layer 0. Before this change the runtime and reopened graph report layer 3, and the old vector can be returned as the current ID. The tests also prove fallback to a different live layer-2 entry and repair an explicitly stale writer input. The file version and layout remain unchanged; authoritative documents and the latest vector are not discarded. Verification: cargo test -p xerj-vector (38 passed); cargo test --release -p xerj-vector (38 passed); cargo clippy -p xerj-vector --all-targets -- -D warnings; cargo fmt --all --check; git diff --check.
xerj-org
added a commit
that referenced
this pull request
Jul 28, 2026
Merge PR #64: HNSW reused-ID entry fix, resolved against the storage-view refactor
Owner
|
Merged via #66: your branch went conflicting after the storage-view refactor (#65) landed, and this PR does not allow maintainer edits, so the conflict resolution was carried in a merge branch. Your head commit (7d93093) is a parent of the merge, all three regression tests are preserved, and the fix was ported onto the refactor as generic |
xerj-org
added a commit
that referenced
this pull request
Jul 28, 2026
The semantic-analytics and bounded-memory release. Headline: a knn or semantic query can carry aggs in the same _search request — the aggregations run over the retrieved top-k neighbour set, forced onto the exact brute-force path so bucket counts are exact — and a semantic hit can opt into a _passage field returning the winning chunk's text with byte-exact provenance, reconstructed from compact ingest-time offset metadata instead of a second stored copy. Underneath: the seven immutable-segment hydration caches now share one process-wide, cgroup-aware retained-payload budget with observable stats (explicitly a retention ceiling, not an RSS bound), and the storage crate gains a bounded, cancellable selective-hydration primitive — deliberately not wired to any engine route yet, measured on its committed fixture as ~59x lower peak RSS at ~4x wall time and claimed as nothing more. Two correctness holes closed: re-inserting a vector under the ID that owned the HNSW entry point could return the superseded vector and persist an inconsistent entry header (fix arrived from the probelabs fork, PR #64 via #66); and a flat 5,001-node Painless chain could exhaust the native stack before the depth guard fired — depth is now enforced exactly at parse time (500 accepted, 501 rejected with a bounded 400). One change from outside the org: transparent gzip request-body decompression on both routers, contributed by Vinz2168, which unblocks Filebeat and the other ES clients that compress by default. The entry states its own limits: significant_terms over a kNN slice returns empty (no background corpus yet), aggs-bearing kNN always runs brute-force and counts only the top-k slice, hybrid-with-aggs is a 400, the hydration budget bounds retained payload rather than RSS, and pre-fix HNSW graph files are only repaired on their next save. Docs: calltree.ai kNN+aggregations case study, pgvector-to-hybrid RAG retrieval example, daily.dev Postgres CDC case study with a push-streaming LSN-checkpointing consumer (and an in-place correction of the author's own RRF-syntax error), and the semantic-analytics use case wired across xerj.org and llms.txt.
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
This fixes a deterministic HNSW consistency bug triggered by inserting a new vector under an external ID that is already owned by the graph entry point.
The flat slab intentionally retains the superseded slot so existing edges can continue traversing it, while
slot_ofmakes only the replacement slot current. The entry metadata did not follow that ownership change. Reusing a layer-3 entry ID with a layer-0 vector could leave the old orphan slot asentry_slotwithentry_layer = 3.That stale state had two observable consequences:
No authoritative document or latest vector is discarded. The failure affects graph result correctness and persisted graph consistency; the engine can reconstruct derived HNSW state from its authoritative data.
Reproduction
The regression uses deterministic levels:
Before this change, the runtime and reopened graph retain entry layer 3, even though the only current serialized node for ID 7 has maximum layer 0. A query for
[1, 0]can also return the orphaned vector as ID 7.The focused reproduction can be run with:
Implementation
The change makes live-slot ownership explicit: a slot is current only when the reverse map resolves its external ID back to that exact slot.
When insertion reuses the entry identity, publication selects the highest-layer current slot after the replacement becomes live. This may be the replacement itself or a different remaining live node; it is not automatically the new lower-layer slot.
Search still traverses superseded slots so existing graph connectivity is preserved, but only current slots are admitted to the result set.
The persistence writer independently derives both entry ID and entry layer from live topology. This is defense in depth for an already inconsistent runtime graph and prevents an orphan slot's layer from being paired with a replacement slot's identity.
The on-disk format version and byte layout are unchanged.
Tests
Three deterministic regressions cover:
(orphan slot, layer 3)state directly and proving the writer emits a live, reloadable layer-0 entry header.Full scoped verification:
Scope and trade-offs
The contribution changes only
engine/crates/xerj-vector/src/hnsw.rs.Normal insertion retains its existing entry-selection path. Reusing the current entry identity performs one scan of the slab to find the highest live entry. Persistence already scans the complete graph, so deriving the live entry does not change its asymptotic cost. Result admission performs a reverse-map ownership check so orphan slots remain traversal-only. No end-to-end throughput improvement is claimed by this correctness fix.