Skip to content

feat(structured): tables declared on the ontology, one node for a row and its prose mention - #328

Merged
galshubeli merged 54 commits into
mainfrom
feat/structured-ingestion
Sep 12, 2026
Merged

galshubeli merged 54 commits into
mainfrom
feat/structured-ingestion

Conversation

@galshubeli

@galshubeli galshubeli commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Closes FalkorDB/research#82 · design in #290 · supersedes #74

What

GraphRAG-SDK reads prose (text, PDF, Markdown). This adds tables — CSV/TSV/PSV out of the box, any other format through a RecordLoaderStrategy — as a first-class source, without an LLM call per row, and so that a row and the passage that names the same thing land on one node.

rag = GraphRAG(..., ontology=Ontology(tables=[
    TableMapping(source="employees.csv", label="Person", key="employee_id", name="full_name",
                 properties={"age": Column("age", "INTEGER"), "title": "job_title"},
                 links=[Link("WORKS_AT", to="Organization", by="org_id")]),
]))
await rag.ingest("employees.csv")     # rows → typed Person nodes + RELATES{rel_type:"WORKS_AT"}
await rag.ingest("acme_report.pdf")   # "Alice Smith" in the prose → the same Person node
await rag.finalize()

Docs: docs/structured-ingestion.mdx (guide), docs/api-reference.mdx § Structured Ingestion. Example: examples/11_structured_ingestion.py. CHANGELOG Unreleased has the full list.

API surface

  • graphrag_sdk.core.tables: TableMapping, Column, Link, MappingError. Mappings live on Ontology.tables and are looked up by the source's basename — ingest()/update() take no mapping=.
  • ingest(path) routes .csv/.tsv/.psv/.tab to the record path (loader=TextLoader() opts out); returns StructuredIngestionResult (records, rows_skipped, entities_created/updated/deleted, references_ambiguous, …). record_loader= accepts any RecordLoaderStrategy (Parquet, a database cursor, in-memory rows); strict_mapping= refuses unmapped columns. ingest(path, document_id="employees.csv") re-syncs a table from a renamed export.
  • update(path) re-syncs a table to its new export (rows added, changed, removed); delete_document(table) and new drop_table() take the table's columns back off every node while a document's own facts stay.
  • A table with no TableMapping is not refused: when an LLM is configured the model proposes one from the header, a sample and the existing labels (deterministic natural_mapping otherwise). The proposal is stored derived=True, reported by finalize().proposed_mappings until a declaration replaces it, and every key/type it picks is checked against the data. finalize() also reports probable_duplicates it declined to merge and why, and property_conflicts with how many entities two tables actually disagree on.

Design (method A, "shared node")

  • Identity. A row's node id is compute_entity_id(name, label) — the same function prose extraction uses — so the two meet at write time, no merge pass. The key derives the id only when the row has no name column or the names are ambiguous.
  • Ownership. Every column a table writes is signed with the table's namespace (employees__age), and the table's key is kept as entity_key; is_stub marks placeholders raised by a Link to a row not yet seen. Prose never writes signed props; a table never writes unsigned ones except name/description. That is what lets re-sync, redeclaration and drop_table remove exactly what the table said and nothing else.
  • Provenance. Each row is a Chunk{kind:"record"} on the table's Document, with MENTIONED_IN, so update()/delete_document() orphan cleanup, chunk retrieval and Cypher work unchanged. Edges are RELATES{rel_type} — no new edge convention for retrieval to learn.
  • Re-sync. Rows no longer in the export lose their columns and key; one still pointed at by another row becomes a placeholder. A redeclaration diffs everything the table signs (columns, own key, each link's key) against the previous declaration, on nodes and in the ontology.
  • Dedup survivor rank now: keyed node → real over placeholder → degree → description length → name length → id. Exact phase groups by canonical name form.

Method B (a separate Record node per row linked to the entity) was implemented and compared on the same corpus; A won on connectivity and retrieval hops. The comparison lives on branch feat/structured-ingestion-separate-nodes for reference.

Behaviour changes to know about

  • finalize() runs LLMVerifiedResolution by default (resolve=False restores the old exact-only pass).
  • A .csv is no longer read as one prose chunk.
  • ingest() of a table returns StructuredIngestionResult, not an IngestionResult subclass.
  • Range indexes on id (and entity_key) are created per label before writes — prose ingest gets the O(n) write path too.

Fixed along the way (prose path too)

Merge dropped the duplicate's properties; edge identity ignored rel_type (one edge per pair); unbound MERGE in the remap could fork the survivor; source_chunk_ids was last-writer; digit-only name differences (GPT-3/GPT-4) were merged; reopening a graph logged an ERROR per label for already-existing indexes; text-to-Cypher rows lost their column names; retrieval held a stale ontology.

Tests

  • Unit: PYTHONPATH=src pytest tests/ — 1498 passed, 210 skipped.
  • Integration (FalkorDB, RUN_INTEGRATION=1): the structured, graph-store, resolver, identity, loader and finalize-report suites — 580 passed. test_ontology_evolve_integration::test_rename_entity_relabels_data_nodes fails on main as well.
  • ruff check src && ruff format --check src; node scripts/check_mdx.mjs; mypy src/graphrag_sdk — 137 errors vs 141 on main.

Review guide

Start with core/tables.py (the declaration), then ingestion/structured_pipeline.py (rows → GraphData), then api/main.py — _ingest_structured, _run_post_cutover_cleanup, _retract_table, _retract_what_the_declaration_dropped. Storage: graph_store.py resolve_by_entity_key / reconcile_keyed_identity / release_entity_keys / rows_outside_document; deduplicator.py _survivor_rank and the keyed guards in _deduplicate_fuzzy.

Summary by CodeRabbit

  • New Features

    • Added deterministic ingestion for CSV and other tabular sources with typed columns, links, and table mappings.
    • Added table re-sync/removal, custom record loaders, mapping proposals, detailed ingestion results, and optional Cypher retrieval.
    • Added cross-source entity resolution and expanded finalization diagnostics.
  • Bug Fixes

    • Improved deduplication, provenance preservation, relationship handling, validation, and protection of structured properties from prose overwrites.
  • Documentation

    • Added structured-ingestion documentation, examples, and API guidance.

Gal Shubeli and others added 30 commits August 19, 2026 14:59
Every data edge is written with `type == "RELATES"` and its semantic type in a
`rel_type` property. Four places decided whether two edges were the same edge
using only `(start, end)` or `(start, "RELATES", end)`, so they read as "one
edge per entity pair" and silently discarded every fact after the first.

- deduplicator: the survivor is now bound by its own MATCH before the MERGE.
  Written inline, `s` is unbound and MERGE creates the entire pattern, forking
  the survivor into a second type-label-less node that takes the remapped edges
  while the real survivor is left with none. Reproducible from three CREATEs.
- deduplicator: the RELATES MERGE is keyed on rel_type. Without it a survivor's
  WORKS_AT and a duplicate's FOUNDED collapse onto one edge, destroying a fact
  and leaving its source_chunk_ids attached to the other.
- graph_store: same key on the write path, batch and per-item fallback both.
- resolution: the dedup key includes properties["rel_type"]. exact_match had an
  inline copy of remap_relationships that had drifted, so both carried the same
  bug; it now calls the shared helper.

coalesce(rel_type, '') keeps the merge key non-null: FalkorDB rejects a MERGE
keyed on a null property, which would abort the whole remap for an untyped edge.

Tests: 6 new, each verified to fail without the fix. Two guard against
overshoot, since a finer key must still dedupe identical facts.
…e deleting it

The remap migrates edges only, so DETACH DELETE took the duplicate's properties
with it. That silently lost whatever only the duplicate knew: the `description`
entity vector search embeds, and every typed value a structured source supplied.

Observed end to end: a graph built from CSVs, then one document ingested. The
merge picked the prose node as survivor and every structured property was gone
afterwards, with no error. age, title, hq_country, employee_count all deleted.

Properties are copied with keep_existing semantics, so a value already on the
survivor always wins and a merge can never overwrite what the survivor knew.
`id` and `embedding` are never carried. Both merge paths call it, exact and
fuzzy.
…euse it

Moves _build_lexical_graph and _write_mentions out of IngestionPipeline into a
base that depends on nothing but self.graph_store. Verbatim move, no behaviour
change; IngestionPipeline now inherits them.

A structured pipeline cannot subclass IngestionPipeline: its __init__ requires a
chunker and an LLM extractor, and a structured source has neither, so
subclassing means passing None and relying on which methods happen to be
called. Sharing a base instead keeps both paths writing byte-identical Document,
Chunk and MENTIONED_IN shapes, which is what lets one retrieval path serve both.

The full suite passes unchanged, which is the point of this commit.

Also fixes the mock in test_deduplicate_entities_merges_duplicates, which
enumerated exactly six query_raw results. The property carry added in the
previous commit makes a seventh call, so the mock ran dry and the delete failed
silently. The list is now explicit about which call each entry answers.
Text-to-Cypher rows reached the answering LLM as bare values with the
column names discarded. For a row of entity names that is survivable; for
an aggregate it is not. `RETURN avg(p.age) AS average_age` arrived as the
single token "39.5", which the final model cannot attribute to anything,
so it answered "the context does not provide enough information" while the
query it had just run returned the correct number.

Two changes, both in the path between execution and the prompt:

- Each value is labelled with its returned column name, so the aggregate
  reads `average_age: 39.5`. Nodes and maps are left alone: they already
  read as described things, and prefixing them would only add noise.
  A driver that omits the header degrades to unlabelled rows rather than
  failing the retrieval, since a header is never essential.

- The section names the question the query was generated from. A labelled
  scalar is still unscoped on its own ("39.5 for whom?"), and saying which
  question the rows answer is what makes it usable.

Measured on the same question and graph: "not enough information" before,
"39.5" after. Only reachable with `enable_cypher=True`, which is off by
default, so no existing retrieval path changes.
The survivor of a merge was chosen by longest description alone. When one
of the duplicates has an id derived from a declared key, that rule picks
the wrong node, and the damage only appears on the next ingest.

A node written from a mapping is keyed on a column the source declared, so
every later ingest of that table recomputes the same id. A node extracted
from prose is keyed on whatever surface form the model produced, and prose
usually wins on description length. So the prose node survives, the keyed
id stops existing, and re-ingesting a corrected export recreates it as a
second node: measured as two `E-1` people, one titled "Engineer" and one
"engineer", each holding half the facts and neither complete.

The rank is now, in order: derived from a declared key, then real over
placeholder, then longest description. `is_stub` is the marker because only
a mapped source writes it, so `None` means the node came from extraction.
The same rule replaces array order in the semantic phase, where the
survivor was simply whichever id appeared first.

For a corpus with no structured sources every candidate ranks (0, 1, len),
which is the previous ordering exactly, so prose-only graphs are unaffected.
Ingest a CSV through the extraction pipeline and it loses the two things a
table has that a document does not: a stable key per row and a known type
per column. Every cell arrives as a described entity, so `age` is the
string "34" if it survives at all, and nothing can be averaged, filtered
numerically, or joined on a key. Measured on a real run before this: the
`age` property did not exist on any node, and all eight entities carried
the identical key set an extracted entity always has.

This adds the other route. The caller declares what the columns mean and
the graph is written deterministically, with no model in the loop:

    ORGS = Table("Organization", key="org_id", name="org_name",
                 employee_count=Column("employee_count", "INTEGER"))
    await rag.ingest("orgs.csv", mapping=ORGS)

A record is a chunk. That is what makes the two halves the same shape: a
row is retrievable and traceable to its source exactly as a paragraph is,
while the typed projection lives on the entity where aggregation reads it.
Records are deliberately not chained with NEXT_CHUNK, because rows have no
reading order and cypher_generation tells the model that edge means "the
next sequential Chunk".

How a row and a sentence become one node: a source holding both a key and
a name publishes the id an extractor would independently compute for the
same thing, in `alias_ids`. `finalize()` then folds them with the ordinary
resolver and carries the typed columns onto the survivor. The bridge is
exact string equality on computed ids, never similarity, so it cannot merge
two different people. Variant names (`A. Smith`) still do not bridge; that
is a floor, not an oversight.

Three things the mapping layer refuses, each because the failure is silent
and remote from its cause:

- `name` cannot be declared as a property. The extraction path merges
  ontology-declared attributes over the system properties it just built, so
  a declared `name` invites the extractor to answer it with a null for
  every prose mention. Symptom: CSVs ingest fine, then documents produce
  nameless nodes that resolve into nothing. Found by measuring it.
- A record stream is a factory, not an iterator. The write path walks the
  records twice; a one-shot iterator is empty the second time and ingests
  zero rows without raising.
- A mapping is validated against the source's real header before anything
  is written, so a mapping that does not fit leaves the graph untouched.

A foreign key is written as a reference: ON CREATE only, carrying its key
so it is joinable, flagged `is_stub` until the source that owns the entity
arrives. Order between sources does not matter.

Ontology registration is additive. The store refuses to modify an existing
label, which is right for extraction but wrong here, since `employees.csv`
may declare `Organization` by key alone and `orgs.csv` later add
`employee_count` to it. New labels go through `register()`; existing ones
are extended through the evolution primitives, which still raise on a type
contradiction.

The declared ontology is what makes the columns queryable: text-to-Cypher
can see that `age` is an INTEGER instead of guessing everything is a
described entity. On a mixed graph, all three classes of question are now
answerable through one `completion()` call, including the one that needs
both halves ("who works at the company that reported the revenue miss, and
how old are they") which no single source can answer.

Includes `examples/11_structured_ingestion.py`, a docs page, 71 unit tests
and 15 integration tests against a live FalkorDB.
Two problems with one cause: the record loader invented its own convention
for a Document id.

It defaulted to the bare file name while the text path derives
`os.path.normpath(source)`. So `ingest(path, mapping=M)` filed the source
under "orgs.csv" and `update(path, mapping=M)` would look for
"/tmp/x/orgs.csv" and not find it — and its own docstring claimed the name
was "the stable handle update() and delete_document() use", which was
exactly backwards. It also collided: two same-named exports in different
directories became one Document, silently interleaving their rows.

Also adds `RecordMapping.fingerprint`, a digest of the declaration itself,
for the update path to fold into its content hash. Identical rows under a
changed mapping produce a different graph, so without it, adding a column
to a declaration would look like unchanged data and be skipped.

And makes the types say what construction already guarantees: `alias` is
non-optional once set (`handle`), and `properties` holds Columns after
normalisation (`typed_properties`). Both were mypy errors under the repo's
strict config, and the second is now read once per source instead of once
per record — for a large table that is millions of redundant dict builds.
A row deleted from a source stayed in the graph forever. Re-ingesting an
export rewrote the rows that changed and added the rows that appeared, but
a departed row had nothing left to rewrite it, so it persisted with an
orphaned record chunk still attached to its Document. Measured on a live
graph: re-ingested a two-row CSV with one row removed, and the removed
organization was still there with the document still reporting two chunks.
`update()` was no help — it rejected `mapping` outright with a TypeError,
so the SDK's whole incremental-update feature did not apply to tables.

`update(..., mapping=...)` now drives the existing crash-safe state
machine. Only the content-producing phase differs: the records are written
under the pending Document and everything after that — the cleanup-state
write, the commit marker, the cutover, the scoped orphan sweep — is the
same code the prose path runs. Phases 3b through 6 moved into
`_finish_update` so there is one copy rather than two; the ordering in
there *is* the crash-safety contract, and a second copy would drift
without anything failing until a crash landed in the gap.

Deletion then falls out rather than being implemented: a departed row has
no new chunk, so the cutover deletes its old one and Phase 6 removes the
entity — unless another source still mentions it, because the sweep is
scoped to this document's candidates. Writing under the pending id is
load-bearing: record chunk ids derive from the effective document id, so
pointing them at the canonical id would MERGE them onto the very chunks
the cutover is about to delete.

Ingesting a source that is already present now routes through that same
path. A table is a snapshot, not an addition, so "ingest this export
again" means "this is its current state" — which is also the call people
reach for first, and leaving it as an in-place write both kept departed
rows and doubled a source's chunks (measured: two chunks for one row,
because the post-cutover ids are pending-derived).

Also fixes the sync wrappers, which is why this class of bug survived:
`ingest_sync` had no `mapping` parameter at all despite a docstring saying
to keep it in step, and neither `ingest` overload mentioned it, so a type
checker rejected the exact call the docs teach. There was already a
tripwire test for wrapper drift covering update / delete_document /
apply_changes — `ingest` was simply not in its list. It is now.
A mapping declares its columns into the ontology so generated Cypher can
see their types. That also puts them in front of the extractor, which then
answers them from prose. Measured: a job title arrived as "engineer" from a
memo and overwrote the "Engineer" the HR export spelled, because the merge
keeps the survivor's value and prose had written it first.

This is the same shape as the earlier `name` bug, where declaring an
attribute the SDK owns let the extractor null it out. That one was fixed by
not declaring `name`. These columns have to stay declared — that is the
whole point of declaring them — so instead they now have an owner.
Extraction may not write a property a mapping declared. Only the value is
discarded: the entity, its name, its description and every unclaimed
property are exactly as extracted, and what was dropped is logged, because
silently discarding a value looks identical to never extracting one.

Ownership is persisted as `Property.structured` in the ontology graph, so
it survives a reload rather than holding only in whichever process ran the
ingest, and it is sticky: a later declaration that omits the flag cannot
hand the column back to the extractor.

This implements the recommended answer to an open question (does the table
or the document win?) rather than a settled one. It is deliberately one
guard in one place, `_drop_structured_properties`, so reversing it or
softening it to fill-only-when-absent is a local change.

Found while testing it: rebuilding GraphData by listing fields dropped
`mentions`, which silently cost every extracted entity its MENTIONED_IN
edges — a merged node that no longer remembered the document it came from,
visible only as missing provenance two steps later. Uses `model_copy` now,
so a field added in future cannot go the same way.
Declaring that a column is an INTEGER exists so that a question can be
answered by querying instead of by finding a passage that states the
answer. Two things stood between that and anyone using it.

The option was not on the client. The documented way to get an aggregate
was to hand-build a MultiPathRetrieval out of `rag._graph_store` and
`rag._vector_store` — our own example and docs page instructed people to
reach into internals, which is both a bad advertisement and a promise we
never made. `GraphRAG(enable_cypher=True)` now does it. Still off by
default: it costs an extra LLM call per question and wants an ontology with
real property types, which is exactly what a structured source provides.

And the strategy was holding an ontology from before the data existed. It
was constructed in `__init__` and refreshed only on the ontology's first
load, so every later change was invisible to it — including every
structured ingest, which is precisely when typed columns get declared.
Measured: after ingesting a CSV the facade knew
`Organization.employee_count` while the strategy's copy of Organization had
no properties at all. Enabling Cypher alone would have generated queries
blind to the columns the mapping existed to declare.

The working ontology is now a property whose setter republishes to the
strategy, so all seven assignment sites stay in step rather than the one
that happened to remember. Strategies adopt it through a new
`set_ontology` hook on the base class, a no-op by default so a strategy
that ignores the ontology need not implement it.

Adds `GraphRAG.query()` while removing the last reason to reach past the
facade: there was no public way to read your own graph, which is why the
examples verified their own work through private attributes. It runs the
Cypher it is given, with parameters, and returns rows.

The example and docs page now use both, and neither touches an underscore.
A review pass over the whole path, probing what the tests had not: hostile
names, non-unique keys, wrong-path updates, and size. Four real problems,
two of them silent data loss.

**An update could re-interpret a table as prose.** `update(path)` without
the mapping loaded the CSV as text, replaced its record chunks with one
text chunk, and deleted every entity with them. Measured: two
organizations before the call, none after, nothing raised. The same call
arrives from `apply_changes(modified=[...])`, so a scheduled sync would
have emptied a table on its first run. A Document already recorded how it
was written; it is now read back and the mismatch refused in both
directions, with apply_changes surfacing it per entry.

**Rows sharing a key disappeared.** A chunk's id came from the row's key,
so two rows keyed K1 produced one chunk holding the first row's cells while
the ingest reported two records. Each row now keeps its own chunk, and both
passes over the source take their chunk ids from one shared walk so they
cannot drift. A key that is not unique is logged with the column and the
repeated values, because it usually means the wrong column was declared.
Ids for unique keys are byte-identical to before, so nothing moves in
graphs that already exist.

**Writing n nodes cost O(n^2).** Nothing indexed `id`, and a MERGE can only
use an index on the label in its own pattern — indexing `__Entity__.id`
does not help, because that label arrives in a later SET. Measured on a
structured ingest: 4k rows 15.2s, and 50k projected to hours. With a range
index per written label, created before the write rather than after: 4k in
1.9s, 50k in 38.5s, memory flat because records stream. Prose gains too,
where the cost was hidden by documents contributing few nodes each. Labels
come from mappings and ontologies, so they are indexed on first write
rather than declared up front.

**A declaration that could not be written failed deep in the driver.** A
property named with a backtick surfaced as `DatabaseError: Invalid input at
end of input`, from a query the caller never wrote. Property names and
relationship types must now be identifiers, since generated Cypher writes
them bare; the escape for an awkward column is to name the property and
point it at the column. Labels stay permissive — `Legal Entity` and
`Ünïcode` are fine — but are refused when the sanitiser would silently
rewrite them into something the graph holds under a different name.

Injection itself held: a hostile label was sanitised, a hostile key value
was slugged into an id, and the graph stayed intact in both cases.

Two casting fixes while there: `FLOAT` rejects `nan` and `inf`, which are
valid literals that turn an `avg()` over the whole column into NaN with
nothing to point at; `LIST` is parsed as a CSV row so a quoted element may
contain a comma instead of silently becoming two.

Docs carry the measured throughput table, the naming rules, the
repeated-key behaviour and the update guard. Three graph-store tests
asserted a raw query count and now assert the write they care about, so
they are not tripwires for an unrelated index.
Found by running an outside team's demo of this feature. It reported
`entities_deduplicated: 0` and its own instrumentation said no question ever
saw context from both halves, while the graph looked fully populated.

The cause is that ingest order silently decided whether a table and a
document could ever join. Resolution matches on name *and* label, which is
what stops "Apple" the company merging with "Apple" the fruit. But an
extractor can only label an entity with a label the ontology already has.
Read a document before any mapping is declared and "Carbon Farming" is
filed under a guessed `Concept`; the CSV then declares it a
`MitigationPractice`; the labels differ, so resolution correctly refuses to
merge them. Two nodes, same name, no error, and the feature's whole premise
quietly absent. Measured on their files with only the order changed: prose
first merged 0, tables first merged 5.

The fix is not to document the ordering. A mapping *declares* that a name is
a `MitigationPractice`; the extractor only *guessed* `Concept`, from a list
that did not yet contain the real label. Those are the same thing described
by two sources, one of which knows. So exactly one cross-label merge is now
allowed: when a name exists under one label a mapping declared and one or
more labels nothing declared, the declared label survives and absorbs the
rest, keeping the document's description and mentions. This is the rule that
already governs declared columns, applied to the label.

The guard that matters is untouched. Two *declared* labels sharing a name
are a real modelling conflict, not a guess to correct, and are left alone.
A name under only undeclared labels is the Apple case and is left alone too.
Both are now reported as `FinalizeResult.unmerged_name_collisions` rather
than showing up as a bare `deduplicated=0` with no explanation.

On their demo, unchanged and in its original order: 0 merges before, 4
after, and no silent collisions left.

Also adds `GraphRAG(mappings=[...])` and `declare_mapping()`, which register
a mapping's labels and column types without writing data. That was my first
attempt at a fix and it is the wrong shape for one — it needs the caller to
know — but it remains worth having: the extractor then has the real labels
while it reads, and gets more of them right first time. Same corpus, 9
merges instead of 4. Quality, not correctness.
Declaring a table took two shapes. `Table(...)` for one record, one entity;
`RecordMapping(nodes=[NodeMapping(...)], edges=[EdgeMapping(...)])` once a
record produced more than one. The second was strictly more capable and the
first was sugar over it, which sounds harmless until a table turns out to
have a foreign key in it. Then the sugar has to be thrown away and
re-expressed in a different shape: the worst kind of seam, because the
moment you learn something new about your data you pay for having started
simple. It also made the surface twice as large to explain, which it
demonstrably was.

`Table` is now the only form and it grows by argument:

    ORGS = Table("Organization", key="org_id", name="org_name",
                 employee_count=Column("employee_count", "INTEGER"))

    EMPLOYEES = Table("Person", key="employee_id", name="full_name",
                      age=Column("age", "INTEGER"),
                      links=[Link("WORKS_AT", to="Organization", by="org_id")])

`Link` says the thing worth saying about a table: `org_id` is not text, it is
an edge. It carries the relationship, the label it points at and the column
holding the key, plus optionally the target's denormalised name and
properties for the edge itself. The target is still written ON CREATE only,
so a pointer can never overwrite the source that owns the entity.

`Table` is also a class now rather than a function wearing a capital letter,
so the linter suppression goes with it. `RecordMapping` stays as its base and
the internal representation the pipeline consumes; `NodeMapping`,
`EdgeMapping` and `RecordMapping` are no longer exported, because nothing a
caller writes needs them. Public signatures say `Table`.

Two links to the same label get distinct handles automatically, which is the
case that used to require hand-written aliases. A link on the record's own
key is refused, since it would join a record to itself.

Doing this now because the branch is unreleased with one known consumer, and
their three mappings already use `Table`, so nothing of theirs changes.
Review pass over what changed since the last audit: the Table/Link
refactor and the declared-label adoption. One real defect.

Three links to the same label failed. Handles are derived, and the
disambiguator was the relationship type, which collides the moment two links
share one — `PARTY_TO` twice to `Organization` produced two nodes wanting the
handle `Organization__PARTY_TO`. It surfaced as "duplicate node aliases",
naming a concept the caller never wrote and cannot set. The disambiguator is
now the column, which is the thing that actually differs between two links.
Two links genuinely naming the same target by the same column are refused
with that said plainly.

The adoption pass deletes nodes, so it got its own battery: edges moving in
both directions with their direction intact, provenance moving so the
survivor still remembers its document, property carry-over with the declared
value winning, two declared labels left alone, two guessed labels left alone,
a guessed node with no declared twin left alone, and finalize twice being a
no-op. All correct; tests added for the ones a unit test can reach and an
integration test for edge movement, which needs edges written directly
because the scripted extraction fixture emits `"relationships": []` and a
prose-driven version of it would pass while proving nothing.

Re-ran the earlier adversarial battery on the unified API: hostile labels,
property names and relationship types are all refused at declaration; a
hostile key value is still slugged into an id with the graph intact; rows
sharing a key still keep a chunk each; updating a table as prose is still
refused; a deleted row still leaves. Scale is unchanged by the refactor:
50,000 rows in 38.6s against 38.5s before, memory flat.

One thing left as-is and worth naming: when two *guessed* labels share a name
and both carry a description, which description lands on the survivor depends
on fetch order, since neither is authoritative. Same non-determinism the
existing same-label dedup already has, and both values are equally valid.
A mapping written by hand chooses a label by hand, and a label chosen by
hand can invent `Person` on a graph whose entities are all `Employee`. Both
then exist, one real person is held as two nodes, and the facts split
between them — resolution matches on name *and* label and will not join
across the two, correctly, because nothing says they mean the same thing.

`propose_mapping(source)` cannot do that, because it must choose a label the
ontology already has. One model call, at authoring time, and it decides as
little as possible. Everything a reading of the data settles is measured:
the key column is the one unique and complete across the sample; a column's
type is whatever every value actually parses as, widening on a single
failure because a declared type is enforced at ingest; and a foreign key is
found by computing the entity id its value would have and looking it up,
which proves a reference instead of guessing from a column name. Labels are
offered with how many entities each already holds.

That leaves the model the part no measurement answers: which existing
concept the rows describe, and what to call the relationships. A label
outside the ontology is rejected and fed back through the same
`extract_with_retry` contract ontology discovery already uses, so this is
the repo's existing pattern applied one layer down rather than a new one.

The result is a reviewable `MappingProposal` carrying the evidence for every
choice, `as_code()` for committing, and `requested_new_label` when nothing
fitted. Nothing is written and nothing is applied. Committing the code is
the intended path: a proposal regenerated per run puts a model back in the
ingest path, and a declared mapping exists so that none runs there.

Writing the mapping yourself is unchanged and needs no model.

Two silent routes into a bad graph are closed alongside it.

A `.csv` with no mapping took the text path: the whole file became one chunk
with its commas intact, an extractor pulled out whatever it noticed, and no
column kept its type. Measured on a two-row export — one entity written,
`age` absent entirely, nothing raised. It now raises and names both ways
out, because the graph looks populated afterwards and a log line would not
save anyone. Passing a loader explicitly stays the escape, and it is a real
case: a table of support tickets or survey answers is prose that happens to
live in columns. `TextLoader` is exported so that escape is reachable, which
it was not when the message first recommended it.

And a hand-written mapping introducing a label beside one already in use now
logs which labels hold entities. Only a warning: declaring a new type is
ordinary on a fresh graph, and refusing would make that tiresome to no
purpose. What the author was missing is the list, not permission.

Two defects found while building it. The first attempt chose `Person` over
an `Employee` holding data, because `Person` is a built-in default and so
legally in the ontology — constraining to the ontology is not enough while
the pool holds both, which is what the entity counts fixed. The second was
found by mypy: a column named `links`, `key` or `name` collides with
`Table`'s own parameters and raised `TypeError: got multiple values for
keyword argument`. Such a column is left unmapped and reported.

Known limitation, documented rather than worked around: the choice is per
file, not per column. A mapping never extracts from text, so a source like
`ticket_id, customer, body` either keeps its typed identity and loses what
`body` mentions, or gets the entities from `body` and loses the identity.
Reading that a table and a document end up on one node is not the same as
watching it happen. This walks through it against a live graph and prints a
query at every step to paste into the FalkorDB browser, so the claim is
checkable rather than asserted.

Generates its own data: two PDFs and three CSVs about a fictional energy
company. The PDFs are written by a small PDF writer included in the notebook,
because no PDF library is installed by default and a demo that needs one
installed first is a demo people do not run. Nothing binary is committed and
a re-run starts from an empty graph.

The names are the substance of the example. `Northwind Energy`, `Maya
Ellison` and `Kestrel Grid` appear identically in the prose and in the
tables, because the join is exact string equality on the name — a table whose
display names are phrases the documents never use joins to nothing, and that
is the one design decision worth being explicit about in a demo.

Covers the parts that are easy to claim and hard to show: a proposal fitted
to the ontology that already exists, a hand-written mapping with a foreign
key, two links to the same label from one row, the merge at `finalize()`,
all three classes of question, a re-sync that removes a row deleted from the
export, and both refusals.

Verified by extracting the code cells and running them end to end: exit 0,
six entities reachable from both a PDF and a CSV, `Northwind Energy` from
five sources, and all four questions answered correctly, including the two
that need both halves.

Two things the run itself taught, now in the notebook. The helper rendered
nodes as `<Node object at 0x...>`, which is useless in a notebook even though
returning whole nodes is what draws the picture in the browser; it renders
them as `Label(name)` now. And the model's half of a proposal genuinely
varies between runs — one run made `country` a property, another linked it to
`Location` — so the notebook says so and points at `as_code()` as the way to
freeze one answer, which is also what keeps a load free of a model.
Validating the notebook step by step turned up a flaw in the notebook
itself: it ended with two `Northwind Energy` nodes, which is precisely the
split the whole walkthrough is about.

Not an SDK fault. Step 9 demonstrates the explicit-loader escape by reading a
ticket export as prose, that ticket mentions Northwind Energy, and
`finalize()` had already run back in step 6. So a fresh entity was created
for a company the graph already held, and nothing merged it.

Which makes it worth showing rather than hiding, because it is the easiest
call in the SDK to forget: `finalize()` belongs after the *last* ingest, and
it is cheap to repeat. The notebook now says so and runs it, going from two
Northwinds to one, with the ticket ending up on the same node as the two PDFs
and the three CSVs.

Verified by a 33-check validation of a run from an empty graph: files
generated and their text extractable, join names verbatim in the PDF, the
proposal reusing a label already in use and introducing no new type,
employee_count arriving as an int rather than a string, WORKS_AT written,
both sides of a contract reaching two different organizations, entities
spanning a PDF and a CSV, the arithmetic in the table-only answer, the
departed row deleted and the promoted row keeping its identity, an unchanged
re-load being a no-op, both refusals, and no duplicate names left in the
graph. 33/33, exit 0, about a minute end to end.
The notebook's questions answered correctly, and I had been treating that as
proof the halves were joined. Checking what the generated Cypher actually
does says otherwise: asked who works at the company that reported the
shortfall, it produced `WHERE o.name CONTAINS 'shortfall'` and returned zero
rows. The answer came from retrieval putting the prose chunk and the record
chunks in the model's context separately, which works just as well on a
graph where nothing merged at all.

Which is the same weakness worth criticising in anyone's demo of this: an
answer can look hybrid while the graph is two disconnected islands, and the
answers alone cannot tell you which you have.

So the notebook now ends with the version that can only work joined. Every
clause of its WHERE comes from a different source — the document from a PDF,
`org_id` from a column only the CSV supplied, `WORKS_AT` from an edge only
the CSV created — and it walks from a sentence about a shortfall to two ages
in an HR export. On a disconnected graph it returns nothing, which is what
makes it worth running. Section 7 now says up front what it does and does not
prove, and points here.

Also states the test to apply to any demo of this: ask whether the question
could still be answered if the halves were never joined. If it could, the
demo is showing retrieval.

Verified: 33/33 checks from an empty graph, exit 0, and both closing queries
return what they claim — Maya Ellison 34 and Tomas Reyes 47 through the
traversal, and both companies reached through prose answering with the CSV's
headcount and revenue.
The notebook opened with 98 lines of hand-rolled PDF writer and inline CSV
string literals before the reader saw a single SDK call. The point of the
example is how tables and documents join, and none of that plumbing was
about it.

The seven files now live in examples/data/hybrid_walkthrough/ as real files
on disk, with a README naming the shared spellings that make the join work.
Step 0 is a directory listing. A reader can open the CSVs, edit them, or
point the notebook at their own.

The changed export is a committed employees_v2.csv rather than a cell that
rewrote a tracked file, and both loads pass the same document_id, so the
re-sync is explicit instead of a side effect of the filename.

Also: *.pdf was globally ignored, so the two fixtures needed a narrow
exception; and the WORKS_AT step claimed four edges where the graph holds
six before finalize() — the board review asserts two of them in prose.

Verified by executing the notebook itself with nbclient from an empty
graph, three times: 0 error cells, entities_deleted=1, no_op=True on the
unchanged reload, and the section 10 traversal still returns Maya Ellison
and Tomas Reyes.
The narrative kept getting interrupted by code that was not about the SDK.
Cell 7 was 36 lines of `_render` / `look` / `counts` — printing nodes as
Label(name), wrapping Cypher, truncating row lists. Real work, but the same
kind of noise as the PDF writer that came out in the previous commit.

It now lives in examples/walkthrough.py, and the notebook imports three
names. Every code cell left in the notebook calls the SDK.

Also:
  - dropped `await rag.__aenter__()`. It is `return self` — a no-op that
    read like required setup.
  - the browser URL and graph name are printed once at connect instead of
    on every one of the ten `look()` calls.
  - `glob("*.[cp][sd][vf]")` was a puzzle; it is a suffix check now.
  - edges printed as <falkordb.edge.Edge object at 0x...>; they render as
    -[WORKS_AT]-> so the rel_type is visible, which is the point of them.
  - `textwrap` was imported at connect and used once, fifteen cells later.
  - merged an orphan one-line markdown cell into the one above it.

19 code cells / 353 lines -> 17 / 234, with nothing removed from what the
notebook demonstrates. Executed with nbclient from an empty graph after
each change: 0 error cells, entities_deleted=1, no_op=True, and section 10
still walks from the board review to Maya Ellison and Tomas Reyes.
Each of these produced a graph that looked correct and reported success.
Found by reviewing the feature against real exports rather than the
fixtures it was built with, and every one is now a regression test.

finalize() deleted whole tables. Step 1's legacy NULL-name cleanup was
unscoped, so a fact export declared without a name column — a reading
keyed on reading_id with a sensor code and a value — lost every row
during the call the docs tell you to make. Measured two Reading nodes
before and none after, reported only as a legacy-stub count. Now scoped
to nodes no mapping wrote.

key="id" cost every row its provenance. The key column was written
verbatim, so `id` overwrote the node's graph id and MENTIONED_IN edges
were never created: no provenance and no way to join to prose, on a load
that reported success. Reserved and awkward headers now route through
safe_property_name, so key="id" stores under col_id and the graph keeps
its own. The same rule fixes a header containing a space, which used to
reach the driver inside a parameter map and surface as a raw
DatabaseError from a query the caller never wrote.

Every number in a European export was out by a factor of ten. INTEGER
and FLOAT stripped all commas, so 880,5 became 8805.0. They now decide
the cases that are decidable — both separators present means the
rightmost is the decimal point; a lone comma not followed by three
digits is a decimal comma — and refuse the genuinely ambiguous 1,234 for
FLOAT while reading it as grouping for INTEGER, where no fractional part
is on offer.

A blank key cell dropped its row and said nothing. records: 3 for a
four-row file is true about what was written and false about what the
file said. rows_skipped and rows_in_source now appear on the result when
something was skipped, with a warning naming the column and the lines.

One bad cell left a broken graph. The chunk pass committed the Document,
a chunk per row and the content hash before the mapping pass ran, so a
cast failure left a document of orphan chunks — and because the document
record then existed, the retry compared hashes instead of writing. Both
passes now complete before anything is written, which is what makes the
documented "leaves the graph untouched" true.

Two people called John Smith became one. Dedup groups by display name,
so it merged them and deleted one, reporting the loss as a successful
dedup. A declared key is an assertion of identity and now outranks a
shared name.

Also adds the report that was missing underneath all of it: near-miss
names that probably denote one thing and did not merge are surfaced in
FinalizeResult.probable_duplicates. Rules, not embeddings — measured,
"same" scored 0.601-0.980 and "different" 0.706-0.930 over realistic
pairs, so no threshold exists; the rules get 10/10 recall with no false
positives. Reported and never merged: a merge in the graph does not hold,
because the next read of that document recreates the node it deleted.

43 regression tests. 1387 passed, 1 pre-existing failure unrelated to
this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ows and prose

Move the table declaration onto the ontology — Ontology(tables=[TableMapping(...)])
— so a mapping is registered before anything is extracted and the graph is
independent of the order sources arrive in. A row and a mention now derive the
same entity id from name and label, so a CSV row and a PDF mention of the same
person land on one node at finalize(). Every property a table writes is signed
with its source, so two tables disagreeing keep both values and prose can never
overwrite a declared column.

Also: an undeclared table is read as-is rather than refused, with a proposed
mapping reported; re-syncing a source is the same ingest() call; the data-safety
guards on the structured path; and the finalize() report (property_conflicts,
probable_duplicates, proposed_mappings, unresolved_references,
stale_signed_properties, mapping_changed, ...).

Replaces the walkthrough script and notebook 12 with
examples/13_documents_and_tables.ipynb.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…by default

A table's "Priya Raman" and a note's "Ms. Raman" arrive in different ingest()
calls, so the resolver that decides such pairs within one document never met
them, and the graph-wide deduplicator only merged spellings. finalize() now
runs a resolver over every entity at once — by default an LLMVerifiedResolution
over the instance's llm and embedder, asking from cosine 0.6 because names
differ more across sources than within one document. finalize(resolver=...)
substitutes your own; finalize(resolve=False) reports instead of asking. The
resolver decides identity; the merge keeps the table's node and every value it
signed, and never folds two rows of one table into each other.

A NO is remembered as a DISTINCT_FROM edge and listed in rejected_duplicates,
so the same pair is not asked twice; name-rule near-misses are handed to the
resolver whatever they scored; two rows of one table are declared distinct up
front. The hand-off is four optional ctx.metadata keys on ResolutionStrategy.

Also in this change:
- a property a table owns is not offered to the extractor
- mapping lookup by basename, so a source moved on disk keeps its declaration;
  the table's Document id is its basename too
- propose_mapping() through the SDK's LLM with structured output; drop_table()
- MockEmbedder uses a stable, zero-centred digest: hash() salted per process and
  all-positive components made unrelated names score as duplicates on some seeds

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When the resolver judged `Austria` and `Republik Österreich` one country
the survivor was chosen by description length, so the hub the tables
pointed at (five rows, four mentions) was renamed after the one-mention
citation and `c.name CONTAINS 'Austria'` came back empty. Degree
(RELATES either way + MENTIONED_IN) now ranks ahead of description; a
row or placeholder still outranks any prose node.

Found running three papers and three tables from
FalkorDB-POCs/GraphRAG-SDK-Docs-Example; PDFs-first went 24/25, the
same order now 25/25.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Found by the wave-2 eval (12 tables, 6 PDFs, one JSON, dirty files).

- References to one (label, key) within a batch derived different ids when one
  column named the target and another did not: two Paper placeholders for one
  arXiv id. Collapse them before the graph lookup — the row wins, then the
  named reference — and remap edges. Also lands a self-reference (manager_id)
  on its own file's rows.
- Strip surrounding whitespace from keys before storing entity_key; the id
  derivation already ignored it, so `inst-10 ` derived the INST-10 id but could
  never be found by key.
- LLMVerifiedResolution leaves apart any pair whose names differ only in digits
  (P-011 / P-021, GPT-3 / GPT-4): they embed above the hard threshold and the
  model is inconsistent on them.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The ontology fragment a mapping contributes describes a label it did not
create only by what the table did ("Declared by a structured source, keyed
on exp_id", "Referenced by key from a structured source"), and the store's
coalesce took that text over the description the user declared. Measured
on twelve tables: every declared description was gone after the first
load, and propose_mapping() asked to place an undeclared grants table chose
Experiment three times out of three, because Experiment now read "keyed on
exp_id" instead of "a field experiment measuring methane flux". The
extractor and text-to-Cypher read the same descriptions.

_register_structured_ontology keeps an existing label's description; a
label nothing has described still takes the table's note. The proposal
prompt now lists each label's description next to its properties. With
both, the same table is placed under a new Funding label three of three.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…es, JSON

examples/14_research_group_knowledge_base: a reproducible corpus that fits
the design about 80%. Twelve CSVs written by different people (keys that
disagree, a bare junction table, an undeclared file, 'austria'/'Austria ',
a ';'-joined FK cell), six arXiv PDFs (two connected only by prose, one
that must stay an island), two notes and a JSON scheduler dump.

  download_pdfs.py  six pinned arXiv ids, 15 MB, gitignored
  ontology.py       14 entity types, 11 TableMappings, each trait commented
  ingest.py         prose -> tables -> JSON -> finalize(resolve=True)
  verify.py         32 expectations recomputed from the CSVs, checked in Cypher
  ask.py            18 aggregate / hybrid / lookup questions with sources used
  dirty_files.py    six single-fault loads on <graph>_dirty

README records what to expect (tables 6 s for 1,785 rows, PDFs 12-20 min,
finalize 4 min; 6/6 aggregates exact) and the limitations the corpus
exposes. CHANGELOG, docs and the examples table point at it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A foreign key now keys a node a document created first: upsert_reference_nodes
adds its signed key ON MATCH, entity_key and is_stub via coalesce, and never
the name. Node-level source_chunk_ids is a union on every write and on a
resolver merge, and a deleted chunk's id is stripped by the post-cutover
cleanup. drop_table() removes the signed key column from graph and ontology on
every label the table wrote and releases entity_key/is_stub unless another
table still keys the node.

The description overwrite by a second document is left as is: with no
per-chunk attribution an append could not be undone by update(), so it is the
ownership question, not an operator bug.

Unit tests pin the Cypher shapes; integration tests pin the behaviour against
FalkorDB.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@galshubeli

Copy link
Copy Markdown
Collaborator Author

Merged origin/main twice (#309, #318) — branch is mergeable again at 9a55954.

Conflict notes, since two of them were real overlaps:

  • _build_lexical_graph / _write_mentions (Chunking: idempotent re-ingest and a measured 384-token default #309): main's shortfall reporting is ported into the shared LexicalGraphWriter both pipelines extend; _reported_short moved beside it. update() takes force= alongside record_loader=/strict_mapping=, and the complete-writes gate reads result_metadata inside our shared _finish_update().
  • Per-label id index (Extraction: share one GLiNER model, drop the inference lock, index ids, report failed chunks #318): both branches added one. Kept ours (id + entity_key + a table's signed key, seeded from db.indexes()), routed main's call sites to it including the __Entity__ index for relationship MATCHes, and adopted main's rule of memoising a label only once its index exists. Main's TestGraphStoreIdIndex passes unchanged; test_upsert_rel_error_continues now fails R1's write by type rather than by position (it lined up by accident before).

CI: test (3.10/3.11/3.12) is red on tests/test_facade.py::test_complete_writes_record_content_hash_on_cutover — main fails the same test since #318 (GLiNER's deberta tokenizer fails to load on the runner: Error parsing line b'\x0e' in …/spm.model, so extraction reports a shortfall). Not from this branch; passes locally (1580 passed / 214 skipped; structured + e2e integration 181 passed against live FalkorDB). Lint, Integration, CodeQL green.

Copilot AI left a comment

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.

🟡 Changes recommended

VectorStore’s new _id_indices_ensured cache can incorrectly short-circuit id-range index creation after broader index cache resets, leading to missing pre-MERGE indexes in subsequent runs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 57/58 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread graphrag_sdk/src/graphrag_sdk/storage/vector_store.py
@Naseem77

Naseem77 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

@galshubeli

[High] Failed structured writes are certified as complete, so retrying the same table skips the missing data (newly found).

StructuredIngestionPipeline.run() passes the final content_hash into _build_lexical_graph() before writing the entity nodes, references, and relationship edges. It also ignores the lexical writer's shortfall return and the relationship store's written count. Consequently, a failed or partial table load can leave a Document whose hash falsely certifies the entire source as complete. The next public ingest() routes through update() and returns no_op=True for the unchanged CSV rather than repairing it. structured_pipeline.py:600-613

Reproduction: Against isolated FalkorDB, ingest a one-row employee CSV with a WORKS_AT link and inject database-write failures through the batch and individual fallback attempts. In separate runs, fail the Person write, the RELATES write, or the PART_OF write. Restore the connection behavior and ingest the identical CSV again. All three retries return no_op=True; the corresponding node/edge count remains 0 instead of 1. The Person-write case raises DatabaseError on the first call, so even retrying an explicitly failed ingest incorrectly succeeds as a no-op. The edge-write cases log failures but return a success-shaped ingestion result.

Fix: Record the structured Document's hash only after all required writes complete. Propagate reported shortfalls through the structured result and update/cutover metadata so an incomplete write cannot receive a completion hash, and keep an ordinary retry eligible to repair it. Add failure-and-retry coverage for entity, provenance, and relation writes; the prose pipeline's complete-write gate does not currently protect this structured path.

galshubeli and others added 2 commits September 9, 2026 17:57
Blockers
- rename_entity() follows through to the stored TableMapping, its links
  and link columns; drop_entity() refuses a label a table maps rows to or
  links to, naming the table and drop_table(). Before, a rename left the
  stored ontology in a shape the validator refuses on every later load.
- Ontology initialisation and registration are serialised (asyncio.Lock
  per event loop); the store collapses a duplicated :TableMapping node,
  reads such a graph once per source meanwhile, keys MappedLink by
  (type, to, by) and MappedLinkColumn by link target, and skips a
  malformed stored mapping with a warning instead of raising from load().
- A second proposed mapping no longer retires a prior proposal with the
  same signature; it falls through to the signature-collision check.
- CI integration job runs the whole suite under RUN_INTEGRATION=1.

Should-fix
- A label a Link points at has one key column: refused at registration
  (whichever declaration completes the shape) and at proposal time, where
  the model is told why. Two tables keying one label differently with no
  link to it remain the supported multi-source shape.
- find_near_misses is blocked on comparable tokens rather than pairwise;
  same result set, 5k entities under one label in under a second.
- A merge carries the longer description.
- Link order is sorted before handle derivation, so an unchanged export
  no longer re-syncs by the order the graph returned its links.

Tests: 20 regression tests (18 verified failing on the pre-fix source);
test_rename_entity_relabels_data_nodes reworked to assert data, since
FalkorDB keeps a label in db.labels() after its last node is relabelled.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 9, 2026 14:59
Comment thread graphrag_sdk/tests/test_structured_guards.py Fixed
Comment thread graphrag_sdk/src/graphrag_sdk/ingestion/mapping_proposal.py Fixed

Copilot AI left a comment

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.

🟡 Changes recommended

RetrievalStrategy.set_ontology() currently has no no-op body, which is a Python syntax error that will break imports.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 58/60 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread graphrag_sdk/src/graphrag_sdk/retrieval/strategies/base.py
…ites

The structured pipeline wrote content_hash with the Document, before the
rows, references and edges. A Person write that raised, or a RELATES
batch the store logged and skipped, left a Document whose hash certified
the table complete; the retry of the identical file routed through
update(), matched the hash and returned no_op=True against the missing
data (Naseem77 on #328).

The hash is now the last write, through the LexicalGraphWriter's shared
_mark_content_hash, and is withheld when the lexical or data edge write
came up short — the same gate IngestionPipeline applies. The shortfall is
reported as StructuredIngestionResult.incomplete_writes (and in as_dict,
so the re-sync cutover in _finish_update already honours it), and an
ordinary retry repairs the table. _build_lexical_graph loses its
content_hash parameter: nothing writes the hash up front any more.

Tests: entity write raising, PART_OF and RELATES coming up short, and a
short re-sync — each followed by a retry of the identical file that must
not be a no-op. All four fail on the previous source.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 9, 2026 15:09
@galshubeli

Copy link
Copy Markdown
Collaborator Author

@Naseem77 — two commits since your last pass, plus a merge of main (#320).

2be64ed — your High finding: failed structured writes were certified complete

Confirmed and fixed exactly as you described. content_hash is now the last write, through a shared LexicalGraphWriter._mark_content_hash (the prose pipeline's own copy moved there), and it is withheld when the lexical or data edge write comes up short — the same gate IngestionPipeline applies. The shortfall is reported as StructuredIngestionResult.incomplete_writes (also in as_dict(), so _finish_update's cutover gate picks it up for re-syncs with no further change). _build_lexical_graph loses its content_hash parameter; nothing writes the hash up front any more.

Tests (TestAnIncompleteLoadIsNotCertified, four cases): Person write raising; PART_OF short; RELATES short; a re-sync coming up short. Each is followed by a retry of the identical file that must not be a no_op, and then a second retry that must be. All four fail on the previous source — the entity case fails on exactly your symptom (hash present after DatabaseError).

39dedf4 — second-audit round

Blockers

  1. rename_entity / drop_entity on a label a table owns — rename now follows through to TableMapping.label, MappedLink.to, MappedLinkColumn.link_to; the next export re-syncs its rows under the new label (verified: reconcile_keyed_identity finds them by signed key). drop_entity refuses while a table maps rows to or links to the label, naming the table and drop_table() — the mapping would re-register the label on the next load, so the drop could only be undone.
  2. Concurrent first touch — asyncio.Lock (per event loop) around initialisation and registration; the store collapses a duplicated :TableMapping node on the next registration and reads such a graph once per source meanwhile; MappedLink keyed by (type, to, by) (two links by one column to two labels no longer collapse on reload); malformed stored mapping → warning + skip, not a raise out of load().
  3. Second proposal retiring the first — _retire_superseded_proposals skips when the incoming is itself derived, so HR.csv after hr.csv falls to the signature check and is refused. Not case-folding _mapping_for: the refusal is the right outcome.
  4. CI — the integration job runs the whole suite under RUN_INTEGRATION=1. test_rename_entity_relabels_data_nodes reworked to assert data (FalkorDB keeps a label in db.labels() after its last node is relabelled).

Should-fix

  • Two key columns on one label — implemented narrower than the auditor's "refuse at declaration": refused only when a Link targets the two-way-keyed label, which is the sole place unsigned entity_key is read. Two tables both describing Person under their own keys, unified by name, is the multi-source case this feature exists for (your round-2 tests cover it), and a same-column-name rule would have pushed users to rename columns to fake one id space. Enforced in OntologyStore (whichever declaration completes the shape) and at proposal time (the model is told why). test_a_foreign_key_two_rows_answer_to_is_given_to_neither reworked to the same-column shape, where references_ambiguous is still the right guard.
  • find_near_misses O(N²) — blocked on comparable tokens (why_same accepts on three grounds; each implies a shared block key, so the result set is identical — asserted against the brute-force scan). 5k entities under one label: 0.16 s.
  • Merge drops the richer description — the longer one is carried (entity embeddings are name-only, so this is safe).
  • Link order — sorted before handle derivation; an unchanged export no longer re-syncs by the order the graph returned its links.

Suite: 1933 passed with RUN_INTEGRATION=1; ruff clean; mypy unchanged at 137.

Left as follow-ups (real, but each is its own change): re-sync mutating live entities before the pending Document commits; loader short rows / unbalanced quotes; compute_entity_id lowercasing the label; DISTINCT_FROM counted as connectivity in expand_sibling_entities; canonical_key's corporate-suffix stripping (a default to decide knowingly); .csv taking the structured path is a behaviour change for 1.4.0 users → minor bump + release note.

Copilot AI left a comment

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.

🔵 Needs a closer look

It introduces a large new ingestion mode and modifies core storage/resolution/retrieval behaviors across many modules, warranting careful human validation of edge cases and backward compatibility.

Review details
  • Files reviewed: 58/60 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

The integration job now runs the whole suite, which surfaced the same
runner problem main fixed in dda742e for test_facade: the default GLiNER
extractor cannot load its deberta tokenizer on the CI runner, the chunk
counts as an incomplete write, content_hash is withheld and update() is
no longer a no-op. The test is about the hash short-circuit, not NER —
give it an empty step-1 extractor and keep the scripted step-2 LLM.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 9, 2026 15:20

Copilot AI left a comment

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.

🔵 Needs a closer look

The PR introduces a large new ingestion mode and changes multiple core behaviors (ontology model, ingestion, resolution, retrieval, indexing), so it warrants a final human review despite no specific defects found in this pass.

Review details
  • Files reviewed: 59/61 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

… pattern nodes

Two review threads on #328 that were real:

- delete_all() reset _indices_ensured but not _id_indices_ensured, so after
  a graph drop the per-label id range indexes were never recreated in the
  same process and the next ingest paid the unindexed MERGE cost.
- add_relation_pattern_node() copied type/description but not `structured`
  to the new pattern's Property node, and load() kept the first row it saw,
  so a declared relation property could read back unstructured depending on
  row order. Copy the flag and OR it across nodes on load.

Also the two code-quality nits: an unused first `renamed` assignment in a
test, and the implicit string concatenation inside a list literal in the
proposal prompt.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 9, 2026 15:34

Copilot AI left a comment

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.

🔵 Needs a closer look

It introduces a broad new ingestion mode and substantial cross-cutting changes across ingestion, storage, resolution, retrieval, docs, and CI that warrant careful human validation.

Review details
  • Files reviewed: 60/62 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Reconciles the finalize-time deduplicator fixes from main (#323) with the
structured-ingestion merge rules:

- One `_survivor_rank`: declared key, real over placeholder, long form
  over acronym, degree, description, name length, id.
- One merge path, `_absorb`: remap (MATCH-first, rel_type-keyed), read
  both nodes, join descriptions with " | ", record aliases, union
  source_chunk_ids, carry every property the survivor lacks, delete the
  duplicate in the same statement, detect a vanished survivor.
- Grouping stays on `canonical_key`, now folding accents, abbreviation
  dots and a leading English article too; acronym groups are unioned on
  top of it.
- `properties_to_carry` joins descriptions instead of keeping the longer,
  so the dedup and the table-row reconcile in GraphStore agree.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 12, 2026 10:02
@galshubeli
galshubeli merged commit bd2c54c into main Sep 12, 2026
15 checks passed
@galshubeli
galshubeli deleted the feat/structured-ingestion branch September 12, 2026 10:09

Copilot AI left a comment

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.

🟡 Changes recommended

One critical and six moderate findings remain unresolved, including transactional identity reconciliation, custom-loader dispatch, and table-owned edge cleanup.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (10)

CHANGELOG.md:17

  • The release note says table identity comes from the declared key, but this feature's central shared-node behavior derives a named row's node id from name and uses the key as entity_key for links and re-sync. This misleading summary conflicts with the immediately following explanation and the public API contract.
    docs/api-reference.mdx:796
  • This promises that passing any RecordLoaderStrategy enables another format, but the current dispatch only routes four filename suffixes to structured ingestion; a .parquet/database-backed source with this argument is sent through prose (and update() rejects it). The implementation and this public API reference need to agree on explicit custom-loader dispatch.
    docs/api-reference.mdx:743
  • This says key derives the node id, but the implementation derives a named row's id from name (via compute_entity_id) so it can share the node with prose; key is stored as entity_key and used to find rows, with id derivation only when no usable name exists. As written, consumers may expect a corrected display name to keep the old id and misunderstand the prose-join behavior.
    docs/api-reference.mdx:701
  • This API overview says identity comes from the key, but named table rows use the name-derived node id so they share ids with prose; the key is the entity_key used for links and re-sync. Please align this summary with the detailed TableMapping.key and name descriptions below.
    docs/structured-ingestion.mdx:553
  • A row's node id does not generally come from its declared key: when name is present it is name-derived, and the key is the lookup/ownership value used during re-sync. This sentence documents the wrong update behavior and obscures why a rename can move the node while preserving its keyed row identity.
    docs/structured-ingestion.mdx:35
  • This overview repeats the incorrect identity rule: a named row's node id is derived from name to match prose, while the declared key is retained as entity_key for links and re-sync (and is only the id fallback without a name). The current wording conflicts with the guide's own mapping section and can mislead users about why prose and rows share nodes.
    docs/structured-ingestion.mdx:884
  • This note documents that a custom loader is still restricted to the four delimited suffixes, which contradicts the feature contract that other formats (for example Parquet) work through RecordLoaderStrategy. It also conflicts with the custom-source example/API text above; once dispatch is corrected, this limitation must be removed or rewritten.
    graphrag_sdk/src/graphrag_sdk/core/tables.py:301
  • datetime is a subclass of date, so a Python datetime supplied by a custom record loader takes this branch and is stored as 2026-01-01T12:00:00 rather than the date-only value produced by the string branch below. A column declared as DATE therefore has different semantics depending on loader representation and can sort/compare incorrectly. Strip the time component before serializing datetime inputs.
    graphrag_sdk/src/graphrag_sdk/ingestion/resolution_strategies/base.py:329
  • When two relationships become the same (start, type, rel_type, end) after entity resolution, this branch keeps only the first relationship object. Its source_chunk_ids are therefore the only provenance retained; deleting that chunk later can remove the edge even though the discarded relationship's chunk still supports it. Merge the duplicate relationship's provenance (and preserve the applicable properties) instead of silently dropping it.
    graphrag_sdk/src/graphrag_sdk/ingestion/structured_pipeline.py:459
  • This pipeline docstring says identity comes from the declared key, contradicting reconcile_keyed_identity() below and the shared-node contract: named rows use the name-derived id, while the key is stored and used for row/link resolution. Please correct the description so maintainers do not preserve the wrong identity invariant.
  • Files reviewed: 60/63 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines +600 to +603
for (label, signed_key), rows in keyed.items():
result.identity_moved.update(
await self.graph_store.reconcile_keyed_identity(label, signed_key, rows)
)
Comment on lines +875 to +879
for prop, column in edge_columns[(edge.type, edge.source, edge.target)].items():
value = column.cast(record.get(column.name))
if value is not None:
# Signed, exactly as node properties are. Two tables
# declaring one edge property used to overwrite each
galshubeli added a commit that referenced this pull request Sep 12, 2026
main moved after #323 and #328 landed. Only CHANGELOG.md conflicted:
#328 added a large ### Fixed block to [Unreleased] at the same position
as this branch's ### Removed entry. Resolved keep-both, leaving
[Unreleased] as Added / Changed / Fixed / Removed.

Verified on the merged tree: 1807 passed, 227 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

4 participants