[ISSUE-844] fix(ai): invalidate an embedding vector when its text changes - #858
Open
E2ern1ty wants to merge 1 commit into
Open
[ISSUE-844] fix(ai): invalidate an embedding vector when its text changes#858E2ern1ty wants to merge 1 commit into
E2ern1ty wants to merge 1 commit into
Conversation
…nges EmbeddingIndexStore keyed each persisted vector by the entity key, which is a prefix, the id and the label, with nothing derived from the value that was embedded. initStore treated the presence of that key as "this entity is indexed", so an entity that kept its id and changed its value silently kept the vector of the value it no longer had. Retrieval for the old value still reached the entity while its current value was unreachable, and since the subgraph handed on is verbalized from the current graph, a query about the old value came back with a context showing the new one. A record now carries a fingerprint of the text it was produced from, a new contentHash field on EmbeddingResult holding SHA-256 over the entity's chunk list. On rebuild a record is loaded only when that fingerprint matches what the entity would be embedded from now; a mismatch counts as not indexed, so the entity is embedded again. Both paths derive the chunk list through one method, so the text hashed on the way in is the text hashed on the way out, which takes verbalize(GraphEntity) to be a function of the entity alone. The number of records dropped is logged, since the complaint in the issue is not only that the vector was stale but that nothing said so. Existing index files. A record without a fingerprint cannot be shown to match the current value, and trusting it would keep exactly the staleness this check is for, so it is not loaded and its entity is embedded once. That is one of the two treatments the issue put up for decision, taken because the other one, valid until the entity is next touched, never comes due: nothing touches such a record. The file is still only appended to, and the second half of the issue is scoped as the issue itself suggests. A superseded record stays in the file and is rejected on each later read, so the file grows by one record set per distinct value an entity has held, and a value that comes back is served from the record already there without a request. The records of deleted entities also stay. Pruning that history wants a rewrite rather than an append, which the issue puts down as separate work, better done as compaction. The harm named there does not wait for it: an id taken over by another entity no longer adopts the vector left behind, because that vector's fingerprint does not match the new text. EmbeddingIndexInvalidationTest drives the store against a local embeddings endpoint answering with one hot vectors, one dimension per distinct text, so a stored vector says which text produced it. Six cases: a changed value on a vertex and on an edge, recall through GraphMemoryServer going the right way on both the old and the current value, an id taken over by another entity, a value that comes back, and a record without a fingerprint. Each fails against the behaviour it pins. The unchanged run asserts the file is byte identical, which is what would catch a fingerprint computed differently on the two paths. GraphMemoryTest no longer loads an embedding store from a checked in index file, and that file is removed. It could not be carried over: its records line up with the text the current code would embed for only 14 of its 163 entities, the rest disagreeing on chunk count, so there was nothing honest to stamp them with and no way to embed them again offline. It also could not have affected a single assertion, since every query there embeds to double[0] through MockChatRobot and EmbeddingVector.match returns 0.0 on a length mismatch, below the 0.5 threshold. Its assertions come from the keyword path and are unchanged; the store's load path is now covered deliberately by the new test. Not addressed here. The fingerprint covers the text only, so changing the embedding model or its dimension leaves every record loaded, which is the same class of staleness; which properties of a model identify it is a separate decision. And a record set short of a chunk still matches on every record it has, so an entity can be held with part of its vectors, as it could before, since a fingerprint covers all of an entity's chunks at once and carries no ordinal.
E2ern1ty
force-pushed
the
fix/embedding-index-invalidate-on-value-change
branch
from
August 26, 2026 13:30
f8511d1 to
61bd968
Compare
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.
What changes were proposed in this pull request?
Fixes #844.
EmbeddingIndexStorekeyed each persisted vector by the entity key, a prefix plus the id and the label, with nothing derived from the value that was embedded.initStoreread the presence of that key as "this entity is indexed", so an entity that kept its id and changed its value silently kept the vector of the value it no longer had. Retrieval for the old value still reached the entity while its current value was unreachable, and because the subgraph handed on is verbalized from the current graph, a query about the old value came back with a context showing the new one.A record now says which text produced it.
EmbeddingResultgains acontentHashfield, SHA-256 over the entity's chunk list. On rebuild a record is loaded only when that fingerprint matches what the entity would be embedded from now; a mismatch counts as not indexed, so the entity is embedded again. Both paths derive the chunk list through one method, so the text hashed on the way in is the text hashed on the way out. The number of records dropped is logged, since the complaint in the issue is not only that the vector was stale but that nothing said so.Existing index files are treated as the first of the two options the issue put up for decision. A record without a fingerprint cannot be shown to match the current value, and trusting it would keep exactly the staleness the check is for, so it is not loaded and its entity is embedded once. The other option, valid until the entity is next touched, never comes due, because nothing touches such a record.
That is the whole of the production change: +106 lines across three files, +63 of them in
EmbeddingIndexStore.What is deliberately left out
The file is still only appended to, which is where the issue puts the line: "Pruning orphan records wants a rewrite of the file rather than an append, which is a separate piece of work from invalidation and may be better done as compaction."
So a superseded record stays in the file and is rejected on each later read, and the records of deleted entities stay too. Two things make that liveable:
What remains for compaction is only that the file size does not track the graph.
I had a rewrite-on-load in the first revision of this PR and took it out. It cost a public
upgradeIndexFile, a temp-file-and-fsync write path, and the bookkeeping to keep a graph that scanned short from taking the whole file with it — about 220 lines to prune history the issue had already scoped out.One thing to look at: a test fixture is removed
GraphMemoryTestno longer loads an embedding store fromsrc/test/resources/index/LDBCEmbeddingIndexStore, and that 4.6MB file is deleted. Two findings led there, both checkable before the deletion:double[0]throughMockChatRobot, andEmbeddingVector.matchreturns0.0on a length mismatch, below the0.5threshold. Everything asserted there comes from the keyword path, and those 22 assertions pass unchanged. Runtime for that test drops from 27.6s to 0.6s, the 27s having been model retries against a null URL.If you would rather keep the file, I can drop only the wiring, though it would then be unreferenced. The
**/resources/index/**rat exclusion in the root pom is now unused; I left it alone as it is a generic pattern, and can remove it on request.Two known limits, neither introduced here
indexBatch. This is unchanged from before: a partial set was skipped as indexed then too.The verify path also takes
verbalize(GraphEntity)to be a function of the entity alone, noted in the javadoc where it is relied on. The one implementation in tree is.How was this PR tested?
EmbeddingIndexInvalidationTestdrives the store against a local/v1/embeddingsendpoint answering with one hot vectors, one dimension per distinct text, so a stored vector says which text produced it: cosine 1 against its own text and 0 against any other. No key and no network needed. Six cases:GraphMemoryServerEach case fails against the behaviour it pins, checked by disabling the fingerprint comparison: three of the six fail outright, the other three by the branch they exercise. The byte-identical assertion on the unchanged run is what would catch a fingerprint computed differently on the two paths.
mvn -pl geaflow-ai clean test -Pjdk8: 12 tests, 0 failures, checkstyle and RAT clean. Nothing ingeaflow-ai/src/mainconstructs this store today, so the added per-entity hashing on load has no production caller in tree.