Ontology.from_sources drafts one from a corpus and GraphRAG.suggest_schema_extensions proposes additions as new docs arrive.
The Invariant
GraphRAG SDK treats the ontology as a contract: every instance of a declared attribute’s owner entity type is queryable for that attribute. Values the LLM didn’t determine read asnull — Cypher treats absent and explicit-null identically under WHERE n.attr IS NULL, so callers get a consistent view regardless of which one underlies a given entity. There is no API that can declare schema your data doesn’t match.
This means one thing in practice: when you add an attribute, the SDK runs an LLM backfill across your existing chunks as part of the same call and only commits the schema change once the data is aligned. Adding an attribute is therefore expensive — pay attention to corpus size and use dry_run=True to preview before running for real.
The invariant covers attributes only. Declaring a new entity type or relation pattern is cheap (no data implication — “this is allowed” is not “this exists”). For those, there are opt-in discovery tools that re-scan the corpus.
The API at a Glance
15 methods onGraphRAG, grouped by what they touch.
Group 1 — Pure declarations (cheap, no LLM)
Return:Ontology.
Group 2 — Mechanical data migration (Cypher, no LLM)
Return:Ontology. Data migration runs first; the ontology graph is updated second. A crash between the two leaves the data graph ahead, and re-running the same call is idempotent.
Group 3 — Atomic attribute evolution (LLM, invariant-enforcing)
Return:EvolutionResult. The ontology graph write is the commit point — backfill runs first, schema change last.
NotImplementedError.
Group 4 — Opportunistic discovery (opt-in, not invariant-enforcing)
Return:BackfillResult. Use after add_entity / add_relation_pattern if you want to populate instances from the existing corpus.
Provenance:backfill_relation_patterndoes NOT writesource_chunk_idson the new edges.GraphStore.upsert_relationshipsonly unions provenance for:RELATESedges; writing it on arbitrary-typed edges would be silently overwritten by future MERGE writes. If you need chunk-level traceability on these backfilled edges, query the underlying co-mentioned chunks via the source/target entities’MENTIONED_INedges instead.
Why add_attribute is Atomic
Two reasons.
The invariant. If add_attribute were “declare cheap, fill later,” the schema would say “Person has role” while many Person nodes lacked the property. Querying MATCH (p:Person) WHERE p.role = "engineer" would silently miss data. The atomic shape guarantees consistency.
Honest commit ordering. The data graph is mutated first. The ontology graph is updated last, as the commit point. If anything fails during backfill, the schema stays at its pre-call state — readers see a consistent (old) view of the world. There is no window in which the schema promises an attribute that isn’t there.
Failure & Retry
If a chunk hard-fails (LLM error / parse error beyond retries), the call raisesOntologyEvolutionError and the ontology graph is not updated. The data graph may be partially mutated — some entities have the new property, some don’t. That’s safe because the schema doesn’t yet promise the property exists.
To recover: fix the underlying cause (rate limits, malformed chunks, etc.), then call add_attribute again with the same arguments. The call is idempotent:
- Already-processed chunks carry an
extracted_opsmarker; the LLM is not re-invoked for them. - Already-set entity values are not overwritten.
Type Changes
There is noretype_attribute. To change a type, drop the attribute and add it with the new type — the LLM re-derives values from the chunks.
toInteger coercion of "around thirty" would either drop the value or fabricate one; the LLM can re-extract 30 from the surrounding text.
Concurrency
Evolution calls are not safe to run concurrently withingest() or with each other on the same graph. Coordinate at the application level — treat evolution as a maintenance operation: pause new ingestion, run the evolution, resume.
The constraint has two sources:
add_attribute/drop_attribute— the extractor reads the persisted ontology to decide what to extract from new chunks. Whileadd_attributeis mid-flight, the ontology hasn’t been committed yet, and any concurrentingest()would produce entities without the new attribute. The invariant would silently break the moment the schema commits.- Mechanical Group 2 calls and
backfill_*— internally use a count-then-mutate pattern across two Cypher statements. Concurrent writes can change the count between the two queries.
ingest().
Cost Preview (dry_run=True)
add_attribute, backfill_entity, and backfill_relation_pattern accept a dry_run keyword. When True, the scope query runs but the LLM is not invoked and no writes happen. The returned result carries chunks_in_scope — the number of chunks this run would scan.
End-to-End Example
examples/09_ontology_evolution.py.
EvolutionResult Reference
Returned byadd_attribute. On a successful (non-dry-run) return, all counters are populated; hard failures raise OntologyEvolutionError instead of returning.
OntologyEvolutionError Reference
Raised byadd_attribute when one or more chunks hard-fail. The ontology graph is not updated.
The exception subclasses
RuntimeError so it propagates through await paths cleanly.
What’s Not in the API (and Why)
Relation-attribute mutation (
add_attribute("WORKS_AT", ...) etc.) raises NotImplementedError in v1. The workaround is delete_all() followed by a fresh ingest() with the updated ontology — that’s a heavy hammer, but it’s the only way to keep edge properties aligned without an edge-property migration primitive. A follow-up PR can lift this.