Skip to content

fix(vector): Fix HNSW entry metadata and stale hits when an external ID is reused - #64

Merged
xerj-org merged 1 commit into
xerj-org:mainfrom
probelabs:fix/hnsw-reused-id-entry-layer
Jul 28, 2026
Merged

fix(vector): Fix HNSW entry metadata and stale hits when an external ID is reused#64
xerj-org merged 1 commit into
xerj-org:mainfrom
probelabs:fix/hnsw-reused-id-entry-layer

Conversation

@buger

@buger buger commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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_of makes 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 as entry_slot with entry_layer = 3.

That stale state had two observable consequences:

  • Search could return the superseded vector under the current external ID.
  • Persistence wrote the current slot once, but paired its external ID with the orphan slot's old layer. The resulting file was CRC-valid while its entry metadata described a layer the serialized entry node did not have.

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:

ID 7, vector [1, 0], layer 3
ID 7, vector [0, 1], layer 0

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:

cd engine
cargo test -p xerj-vector reused_entry_external_id_with_lower_layer_saves_a_reloadable_graph -- --nocapture

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:

  • Reusing a layer-3 entry ID at layer 0, excluding the superseded vector from results, saving, and reopening with the latest vector and entry layer 0.
  • Selecting a different live layer-2 node as the entry instead of blindly choosing the layer-0 replacement, with search checks before and after reopen.
  • Recreating the old stale (orphan slot, layer 3) state directly and proving the writer emits a live, reloadable layer-0 entry header.

Full scoped verification:

cargo test -p xerj-vector
38 passed, 0 failed

cargo test --release -p xerj-vector
38 passed, 0 failed

cargo clippy -p xerj-vector --all-targets -- -D warnings
passed

cargo fmt --all --check
passed

git diff --check
passed

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.

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
@xerj-org xerj-org closed this in #66 Jul 28, 2026
@xerj-org
xerj-org merged commit d4d2233 into xerj-org:main Jul 28, 2026
4 checks passed
@xerj-org

Copy link
Copy Markdown
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 GraphStorageRead helpers. Thanks for the fix!

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants