Skip to content

[ISSUE-844] fix(ai): invalidate an embedding vector when its text changes - #858

Open
E2ern1ty wants to merge 2 commits into
apache:masterfrom
E2ern1ty:fix/embedding-index-invalidate-on-value-change
Open

[ISSUE-844] fix(ai): invalidate an embedding vector when its text changes#858
E2ern1ty wants to merge 2 commits into
apache:masterfrom
E2ern1ty:fix/embedding-index-invalidate-on-value-change

Conversation

@E2ern1ty

@E2ern1ty E2ern1ty commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Fixes #844.

EmbeddingIndexStore keyed each persisted vector by the entity key, a prefix plus the id and the label, with nothing derived from the value that was embedded. initStore read 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. EmbeddingResult gains a contentHash field, 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.

The production change is +149 lines across three files, of which about 45 come from the review response below rather than from the fix itself.

Measured against a real embedding model

The same harness was run on master (3f73eb5) and on this branch, both driving the store at BAAI/bge-m3, 1024 dimensions, through a local proxy that counts requests and records what is sent. The two values used are unrelated: cosine(embed(old), embed(new)) = 0.2923, so a hit on one is not a hit on both.

A. The value of an entity changes, id and label unchanged.

master this PR
requests on the second run 0 1, carrying the current value
cosine(vector held, embed(old value, now gone)) 1.0000 0.2923
cosine(vector held, embed(current value)) 0.2923 1.0000
query = embed(old value) reaches the entity yes, and the context shows the new value no
query = embed(current value) reaches the entity no yes, context shows the current value

The master column reproduces the issue exactly, including the part a fact-checking layer cannot see from the outside: the entity is retrieved because of the old value and the reader is then shown the new one. The issue reported 1.0000 and 0.2357 for those two cosines against its own pair of values; here the same measurement gives 1.0000 and 0.2923.

B. An entity is removed and another takes its id over, which is the harm the issue names for records that are never pruned. Three runs: indexed, gone, id reused with a different value.

master this PR
requests on the third run 0 1
cosine(vector held, embed(value of the entity that is gone)) 1.0000 0.2923
cosine(vector held, embed(its own value)) 0.2923 1.0000

So on master the new entity silently adopts the vector left behind. It no longer does, and that does not wait for compaction.

C. A value the entity has held before comes back. On this branch the third run issues 0 requests and the file stays at 2 lines: the record already there matches again. This is what bounds the growth of an append-only file to one record set per distinct value.

D. A record with no fingerprint, holding a vector that happens to be correct. On master it is kept and nothing is sent. On this branch the first run sends 1 request and the second sends 0 — the cost of the format change is paid once, per entity, and never again.

Nothing about the credentials used is in the diff; the harness read them from the environment and is not part of this PR.

Review response, second commit

Two things were raised about indexStoreMap, both about the map rather than the fingerprint. Addressed in 923347f, though not quite as suggested; the reasoning is in the review threads.

  • Keyed by ModelUtils.getGraphEntityKey instead of by the entity. Lookups across instances already worked, since GraphVertex.equals answers on the id and the label, and what a key retained was the Vertex and its values rather than the graph. The reason to change it is narrower: with the entity as the key, what counts as the same indexed entity is decided by GraphVertex.equals, and this is a fix about an entity's identity not covering its value. Someone extending that equals to cover the values would silently stop getEntityIndex finding records and stop the scan skipping what is indexed. The file has always been keyed by the entity key; now the map is too.
  • Published once, rather than made concurrent. initStore built into the field and mutated it throughout, so a reader on another thread saw an index half way through being built. A ConcurrentHashMap makes each operation safe but leaves that reader looking at a partial index, which on a recall path is worse than an error. So initStore builds into a local map and assigns it in one go, the field is volatile and starts empty, and a reader sees either the index that was there before or the finished one — and no longer needs a build to have happened to avoid an NPE. This does not make concurrent initStore calls safe against each other; they still race on the file, as before.

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. Measurements C and B above are why that is liveable: growth is bounded by the number of distinct values an entity has held rather than by the number of changes, and the dangerous half of the pruning problem is already closed by the fingerprint. 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

GraphMemoryTest no longer loads an embedding store from src/test/resources/index/LDBCEmbeddingIndexStore, and that 4.6MB file is deleted. Two findings led there, both checkable before the deletion:

  • It could not be carried over. Its records line up with the text the current code would embed for 14 of its 163 entities; the other 149 disagree on chunk count, in a pattern consistent with the file having been written when the values were joined before splitting rather than split per value. So there was no honest fingerprint to stamp on it, and no way to embed it again offline.
  • It could not have affected a single assertion. Every query in that test embeds to double[0] through MockChatRobot, and EmbeddingVector.match returns 0.0 on a length mismatch, below the 0.5 threshold. 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

  • The fingerprint covers the text only, so changing the embedding model or its dimension leaves every record loaded, and on a dimension change recall would quietly go to zero. Same class of staleness, but which properties of a model identify it is a separate decision. Happy to open a follow-up.
  • 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 — a fingerprint covers all of an entity's chunks at once and carries no ordinal. Reachable through the blank-response skip in 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?

  • Tests have Added for the changes
  • Production environment verified

Verified against a real hosted embedding model as set out above, though not in a production deployment, hence the second box.

EmbeddingIndexInvalidationTest covers the same ground offline, driving the store against a local /v1/embeddings endpoint that answers 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. Eight cases:

case what it pins
changed value on a vertex embedded again, holds the current vector and not the old one, and the record just written is then accepted with the file left byte for byte
changed value on an edge the same for edges
recall through GraphMemoryServer the old value reaches nothing, the current value reaches the vertex and reads as that value
id taken over by another entity the vector left behind is not adopted
value that comes back served from the record already there, nothing appended
record without a fingerprint not taken on trust, and the cost is paid once
read through an entity object from another graph the index is found through either, and a store with no index yet holds nothing rather than failing
read while a build is in flight the index that was there before, never a part of the one being built

Each case fails against the behaviour it pins, checked by disabling the fingerprint comparison and, for the last one, by assigning the index before the build instead of after. 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: 14 tests, 0 failures, checkstyle and RAT clean. The one case with threads in it was run repeatedly to check it settles. Nothing in geaflow-ai/src/main constructs this store today, so the added per-entity hashing on load has no production caller in tree.

…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
E2ern1ty force-pushed the fix/embedding-index-invalidate-on-value-change branch from f8511d1 to 61bd968 Compare August 26, 2026 13:30
unversionedRecords++;
} else if (embedding.contentHash.equals(
currentFingerprint(key, entity, key2Fingerprint))) {
this.indexStoreMap.computeIfAbsent(entity, k -> new ArrayList<>()).add(embedding);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've observed a phenomenon: the code uses a Map<GraphEntity, List<EmbeddingResult>>(indexStoreMap) and populates it with entity instances in the initStore; however, subsequent locations can access the index using other GraphAccessor/GraphEntity instances (e.g., the GraphEntity instance generated when getEntityIndex is called externally might not be the same object reference). If the GraphEntity lacks appropriate equals/hashCode or object instance adjacency, it can lead to not finding loaded records (a logical error); furthermore, using a GraphEntity object as a long-term key maintains a strong reference to the graph, causing memory leaks.

Relevant code:

// Snippet from EmbeddingIndexStore.initStore (already exists in the PR)

GraphEntity entity = key2EntityMap.get(key);

if (entity != null) {

this.indexStoreMap.computeIfAbsent(entity, k -> new ArrayList<>()).add(embedding);

}

Suggested solution (prioritize stable string keys instead of objects): Change the indexStoreMap to a Map<String, List> with the entity key (ModelUtils.getGraphEntityKey(entity)) as the key, and replace the key with this string in all reads. This avoids memory issues caused by referencing entity objects and ensures consistency across Accessors/instances.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 923347f, keyed by ModelUtils.getGraphEntityKey as you suggest, though I want to correct the two reasons given, because they point at the wrong risk and I would rather the record be accurate.

Lookups across instances already worked. GraphVertex.equals/hashCode delegate to Vertex, which is Objects.equals(id) && Objects.equals(label), and GraphEdge to Edge, which is srcId, dstId and label. So an instance handed out by a different GraphAccessor for the same entity is equal and hashes alike, and getEntityIndex found the records. I could not construct a case on master where it did not.

What a key retained was not the graph. GraphVertex holds a Vertex, and Vertex holds id, label and values — no reference to MemoryGraph, GraphAccessor or anything above it. So the retention was of the Vertex objects and their value strings, which is real but bounded by the entity count and small beside the vectors in the same map, at 1024 doubles each. Not a leak in the sense of holding a graph alive.

The reason I think the change is right is narrower, and closer to what this PR is about. With the entity as the key, what counts as the same indexed entity is decided by GraphVertex.equals, which answers on the id and the label. This PR exists because an entity's identity did not cover its value. Extending Vertex.equals to cover the values is a reasonable thing for someone to want next, and the moment they did, getEntityIndex would stop finding records and the scan in initStore would stop skipping what is already indexed — silently, both of them. Meanwhile the file has always been keyed by the entity key. Two notions of identity for one thing, one of them defined in a class this PR invites people to change. Keying the map by the same string as the file collapses them into one and takes the index out of reach of that change.

The call sites moved with it: the scan loop and the in-batch dedup set now hold keys, indexBatch writes under the key it already computes for res.input, and getEntityIndex looks up getGraphEntityKey(entity). That last one builds a small string per lookup where before it hashed two fields, which on the query path is one allocation per vertex per query, against a 1024-dimension cosine for each of them.

There is a test for the scenario you describe: it indexes through one graph and reads through an entity object from another, asserting first that the two objects really are different. It holds on master as well — it is there to keep it holding.

String key = embedding.input;
GraphEntity entity = key2EntityMap.get(key);
if (entity != null) {
this.indexStoreMap.computeIfAbsent(entity, k -> new ArrayList<>()).add(embedding);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the indexStoreMap is read and written by different threads during runtime (e.g., index initialization on the startup thread, querying on the worker thread), concurrent read/write safety must be guaranteed. Replace the map with a ConcurrentHashMap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 923347f, but not with a ConcurrentHashMap, because I do not think that fixes the case you describe.

Take it as stated: initialization on the startup thread, a query on a worker thread. initStore assigned the map to the field up front and then mutated it for the whole of its run — the file read, then batch after batch of embedding. A worker reading in that window sees whatever has been filled in so far. With a ConcurrentHashMap it still does: every individual get is safe, no exception is thrown, and the answer is a partial index. For a recall path that is worse than an error, because it comes back as a plausible result rather than a failure. The same goes for a second initStore on a live store, which under either map empties the index for readers before filling it again.

So the map is built aside and assigned to the field in one go, at the end of initStore. The field is volatile, and starts as Collections.emptyMap(). A reader now sees either the index that was there before or the finished one, and a reader that arrives before any build has happened gets an empty list rather than the NullPointerException it would have got on master. getEntityIndex reads the field once per call, so it works against one map throughout.

Two notes on the trade-off, since it is not free:

  • During a rebuild both maps are held, so peak memory is roughly doubled for that window. I think consistency is worth it here, and there is no caller in tree that rebuilds a live store — nothing in geaflow-ai/src/main constructs this store at all today.
  • This does not make initStore safe to call concurrently with itself. Two builds at once still race on the index file, which is appended to. That was true before and I have not tried to fix it here; if you would like it enforced, the smallest thing would be to reject a concurrent call outright rather than to make it work.

There is a test: it holds the embeddings endpoint open with a latch, runs a second initStore on the same store on another thread, and asserts that a reader in that window gets the vector from before the build. It fails if the field is assigned before the build instead of after — with the assignment moved back, the reader gets zero vectors, which is the partial index a ConcurrentHashMap would also have shown.

…ublish it once

Review of apache#858 raised two things about indexStoreMap. Both are about the map
rather than the fingerprint, and both are addressed, though not quite as
suggested.

Keyed by a string, not by the entity. The map is now
Map<String, List<EmbeddingResult>> under ModelUtils.getGraphEntityKey, the same
string the index file is keyed by, so one notion of which entity a vector
belongs to serves both memory and disk. Lookups across instances already worked,
since GraphVertex.equals answers on the id and the label and GraphEdge on the
two ids and the label, and what a key retained was the Vertex and its values
rather than the graph. The reason to change it is narrower and more to the point
of this fix: with the entity as the key, what counts as the same indexed entity
is decided by GraphVertex.equals, and this is a fix about an entity's identity
not covering its value. Someone extending that equals to cover the values, which
is a reasonable thing to want, would silently stop getEntityIndex finding
records and stop the scan skipping what is indexed. Keying by the entity key
takes the index out of the reach of that change.

Published once, rather than made concurrent. initStore built into the field and
mutated it throughout, so a reader on another thread saw an index half way
through being built, and a second initStore emptied it for readers before
filling it again. A ConcurrentHashMap makes each operation safe but leaves that
reader looking at a partial index, which is the wrong answer rather than a
crash. So initStore now builds into a local map and assigns it to the field in
one go, the field is volatile and starts empty, and a reader is shown either the
index that was there before or the finished one, and no longer has to have
waited for a first build to avoid failing.

Two cases added. One reads through an entity object from a different graph than
the one that was indexed, which is the scenario raised; it holds on master too
and is there to keep it holding. The other holds the endpoint open, reads while
a second build is in flight, and requires the vector from before the build; it
fails if the field is assigned before the build rather than after.
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.

geaflow-ai: embedding vectors are never invalidated when an entity's value changes

2 participants