Skip to content

[FEATURE] Stop a build when two different documents claim one source id - #559

Open
noel-improv wants to merge 2 commits into
awslabs:mainfrom
noel-improv:feat/fail-on-source-id-collision
Open

noel-improv wants to merge 2 commits into
awslabs:mainfrom
noel-improv:feat/fail-on-source-id-collision

Conversation

@noel-improv

@noel-improv noel-improv commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Description

Stop a build when two different documents claim one source id. Widening the id in #541 makes a collision rare, but a graph still merges the second document into the first one's __Source__ node when one happens, and a graph written at the legacy width keeps the collisions it already has. This makes a collision an error that names both documents instead of a silent merge.

A document is identified by the hash llama_index computes over its text and metadata. Every chunk already carries it on its SOURCE relationship, and it survives the S3 and file round trip, so no node gains a new field.

Changes

  • SourceNodeBuilder copies the document hash from the SOURCE relationship onto the source node.
  • SourceGraphBuilder records it as __Source__.documentHash on create and keeps the first one on match (coalesce), so the first document to claim an id owns it.
  • New indexing/source_id_collision.py: SourceIdCollisionGuard raises SourceIdCollisionError when one source id is seen with two hashes, whether inside one document (a storage prefix that merged two documents), across documents in one run, or against the hash already recorded in the graph.
  • LexicalGraphIndex.build() and extract_and_build() run the guard after the width guard, before the build pipeline.

Problem

MERGE on the source id attaches a second document to the first one's node, and the S3 prefix reads back as one document holding both sets of chunks. Nothing logs or raises, so a collision looks like a document with more chunks.

Related issue (if any): #533

Testing

  • Unit tests added/updated
  • Integration tests added (as appropriate)
  • Existing tests pass (pytest)
  • Tested manually (describe below)

Unit tests use the colliding pair from test_source_id_collision.py and cover: two documents on one id raise naming both, re-ingesting the same document passes, chunks in one merged prefix that disagree raise, a chunk with no hash is not checked, a different document already in the graph raises, a source written before the hash existed is accepted, the graph lookup is batched and binds ids as a parameter, the source write keeps the first hash, and build() refuses a colliding document. Full unit suite: 2,306 passed.

Each detection branch and the coalesce were reverted one at a time; each change failed a test.

tests/integration/indexing/test_source_id_collision_live.py runs against a real graph and skips unless NEO4J_TEST_URI or NEPTUNE_GRAPH_TEST_ID is set. Both stores pass, which covers what a mocked store cannot: the coalesce on match, and the guard's lookup through node_id(), which is a property on Neo4j and ~id on Neptune.

Live test Neo4j 5 Neptune Analytics
The first write records the document hash pass pass
A second, different document does not overwrite it pass pass
A source written without a hash takes the first one offered pass pass
A different document already in the graph raises pass pass
The same document again passes pass pass
A source without a hash passes pass pass

Each run uses its own tenant, so every node it writes carries a tenant-suffixed label and is deleted afterwards.

Checklist

  • Code follows existing style and conventions
  • License headers present on new files
  • Documentation updated (if applicable)
  • No breaking changes (or clearly documented)

Detection is on by default and stops the build. Two behaviour notes for the release:

  • A graph written before this change carries no document hash, so its existing collisions are not reported until both documents are ingested again, after which the second one raises. That is the correct outcome, but on a graph that already merged documents it will look like a new failure.
  • The graph lookup runs once per build batch of documents, and the guard yields documents in groups of that size.

Not in this PR

  • extract() on its own cannot detect a collision against the graph, since it does not write to one. It is caught when the extracted documents are built.
  • The default chunk uploader still logs a failed chunk and continues. That is unrelated to identity and is left as it is.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

…one source id

MERGE on the source id attaches a second document to the first one's node
and nothing reports it, so a collision reads back as one document holding
both sets of chunks. Widening the id makes this rare; it does not make it
detectable.

A document is identified by the hash llama_index computes over its text and
metadata, which every chunk already carries on its SOURCE relationship, so no
node gains a field. The source write records that hash on the __Source__ node
on create and keeps the first one on match. Before the build pipeline, a
guard raises when one source id is seen with two hashes: inside one document
(a storage prefix that merged two documents), across documents in one run, or
against the hash recorded in the graph. The error names both documents. The
graph lookup runs once per build batch of documents, bound as a parameter.

Re-ingesting a document is unchanged. A source written before the hash was
recorded carries none and is accepted; the next write records it, so a graph
written at the legacy width reports its existing collisions the first time
both documents are ingested again.

Refs awslabs#533
The source write's coalesce on match and the guard's id lookup through
node_id() need a store to prove them. Runs against Neo4j or Neptune Analytics
and skips unless NEO4J_TEST_URI or NEPTUNE_GRAPH_TEST_ID is set. Each run uses
its own tenant and deletes what it wrote.
@noel-improv noel-improv self-assigned this Sep 15, 2026
@noel-improv
noel-improv marked this pull request as ready for review September 16, 2026 17:43
document_hash = hashes.pop()
metadata = sources[0][2]
earlier = self._seen.get(source_id)
if earlier and earlier[0] != document_hash:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The document hash discriminates more finely than the source id, so this raises on documents the id generator considers identical. TextNode.hash is sha256(str(text) + str(metadata)) — insertion-order sensitive — while create_source_id hashes _get_properties_str, which sorts. Re-ingesting one document with its metadata keys built in a different order ({'a':'1','b':'2'} vs {'b':'2','a':'1'}, same text) yields one source id and two hashes, and the build stops with "Two different documents share source id …". Same divergence hits _check_graph, so run 2 is rejected against run 1's graph. Derive the identity from the same normalized metadata string the source id uses rather than RelatedNodeInfo.hash



def _name(metadata:Optional[Dict]) -> str:
return str((metadata or {}).get('file_path') or 'a document')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

_name reads only file_path, so for a Document built in code, or a reader that uses url/file_name/s3_key, the error reads "a document (hash …) and a document (hash …)" — naming neither, which is the thing the PR is for. Fall back through a few metadata keys and include the ref node's doc_id

# key of the same name can't override the merge key.
properties = {**clean_metadata, 'sourceId': source_id}
if document_hash:
properties[DOCUMENT_HASH_PROPERTY] = document_hash

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A metadata key literally named documentHash defeats the first-writer-wins guarantee: on_match already holds source.\documentHash = params.documentHashfromclean_metadata, SET clauses run left to right, so by the time the appended coalesceevaluates,source.documentHashis already the incoming hash. ThesourceIdcase two lines below has a comment guarding exactly this; this one has no equivalent. ExcludedocumentHashfromclean_metadata, or put the coalesce first.


def __init__(self, graph_store:GraphStore, tenant_id:TenantId, lookup_batch_size:Optional[int]=None):
self.graph_store = graph_store
self.tenant_id = tenant_id

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

tenant_id is stored and never read — the lookup is already tenant-scoped by MultiTenantGraphStore._rewrite_query matching the backticked Source label. SourceIdWidthGuard needs it for record_graph_source_id_width; this guard doesn't. Drop the parameter.

hash already recorded on the graph's __Source__ node. A source written before
the hash was recorded carries none and is accepted; the build records it.

Graph lookups run once per ``lookup_batch_size`` documents, so documents are

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"documents are yielded in groups of that size" only holds for a generator input. build() and extract_and_build() hand the width guard a list, which comes back a list, so call materialises everything and every graph lookup runs before the first document reaches the build pipeline — lookup_batch_size bounds the IN $sourceIds list, nothing else. Worth saying so in the docstring.

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