graph_model cognify knob as
CognifyConfig.graph_schema, consumed by the standalone cognify pipeline and
reachable from every entry point (see Entry points). To change
the summary shape instead, see
Custom Summarization Schema.
What it does
Replaces the defaultKnowledgeGraph extraction shape with the JSON Schema
produced by a custom Pydantic graph model. Mirrors Python’s graph_model. Rust
compiles that schema at runtime, requests structured output from the LLM, walks
the returned model, and persists its nodes and relationships through the normal
graph, provenance, and vector indexing stages.
CognifyConfig.graph_schema is a per-call option: set it with
CognifyConfig::with_graph_schema,
the cognee-cli --graph-schema / --graph-spec flags or the bindings’
graphSchema / graphSpec options (see Entry points). It has no env var. The graph_model
setting (cognee-cli config set graph_model, default KnowledgeGraph) is a
separate string key that nothing reads, so it does not select a schema.
Schema contract
Pass a Pydantic-compatible JSON Schema whose models declare their policy in ametadata property. Python produces one in either of two ways:
graph_model_to_graph_schema()applied to plainpydantic.BaseModelclasses that declare an explicitmetadata: dict = {...}field — the pattern Python’s own example uses.graph_spec_to_json_schema()applied to aGraphSchemaSpec. Rust ports this compiler too; see Spec DSL.
graph_model_to_graph_schema() applied to a DataPoint subclass drops every
field DataPoint declares, metadata included, so that schema carries no
policy: every node gets a random id, nothing is vector-indexed and nothing is
transparent. Python’s own round trip produces the same result, so Rust compiles
it the same way rather than rejecting it. An unprocessed
DataPoint.model_json_schema() is rejected, because it declares the reserved
DataPoint infrastructure fields: the keys a stored node’s base carries (id,
type, version, metadata, the timestamps, the source_* provenance
fields, …), which a custom property would overwrite. Python is laxer here:
its generated subclass may redeclare any DataPoint field. The two Python
DataPoint fields the Rust base does not carry, ontology_uri and the
removed valid_to, are ordinary custom fields, as in Python.
Local $defs and #/$defs/... references are supported, whether a field holds
one model, an optional model, a union of models, or a list of any of these;
null list items are skipped. Other shapes that reach a model — a union mixing
models with scalars, a dictionary of models, a nested list — are rejected rather
than stored as opaque properties. A $defs entry that is not a model (an
enum, a const or a non-object scalar type) is a value, as Python generates
an Enum or RootModel for it: a field that references one is a scalar
property, and an answer outside the enum is rejected. External references are
rejected so the schema is self-contained and deterministic. The metadata
property is consumed by the storage walk and removed from the schema sent to the
LLM; descriptive JSON Schema annotations remain available to guide it.
Policy lives in the metadata property’s default object:
identity_fieldsdetermine the stable UUID:uuid5(NAMESPACE_OID, "{Model}:{values}")over each identity value as Pythonstr()s it after Pydantic validation (anumberis a float, so3and"3"become3.0; adate-timeis a timezone-aware datetime, a Unix timestamp being read as UTC; adate,timeoruuidprints as Python’s2024-01-02,03:04:05and hyphenated lower-case text; only plain strings are lower-cased with spaces turned into_). An omitted optional identity field contributesNone.id,created_at,updated_ator a name that is neither a field nor a DataPoint base field make every node fall back to a random UUID, as in Python (a warning is logged).index_fieldsselect the scalar fields written to{Model}_{field}vector collections. Anullor blank value is not indexed, and a name that is not a scalar field of the model is skipped with a warning, as in Python.transparent: truemakes a structural container disappear during persistence so its DataPoint descendants take its place. A transparent root needs at least one reachable non-transparent descendant; otherwise the custom graph stores no nodes. Python’sGraphSchemaSpeccompiler never emitstransparent, so it only comes from a hand-written orBaseModelschema.
chunks_per_batch chunks) a node id
is claimed by its first occurrence: a later occurrence with the same id
contributes neither its properties nor its outgoing edges, though the edge
leading to it is kept. Edge sentences ("{source} {field} {target}.") label a
custom node with its first non-blank index field (trimmed
to 80 characters), else its name, else the model name.
The root model needs a title that is a valid class name: it starts with an
uppercase letter and holds only letters and ASCII digits (ResearchGraph, not
research_graph or Research Graph), and it may not be None, True,
False or DataPoint. A root given as a $ref still needs that top-level
title, equal to the $defs key the $ref points at.
Answers are validated as Pydantic validates the generated model in lax mode
(model_validate_json, the call instructor makes for a structured-output
answer), and a field holds the coerced value, not the raw JSON:
The same coercions apply inside a list and per variant of a union; a union
follows Pydantic’s smart mode, so the most exact match wins and the first
declared variant breaks a tie. Stored properties take the form Python’s storage
encoder writes: a
date-time, date or time is stored as isoformat() text
and a uuid as lower-case hyphenated text, so a date-time string is
normalised rather than kept verbatim (2024-03-01T00:00:00Z is stored as
2024-03-01T00:00:00+00:00).
Known gaps, where Pydantic accepts a value that is refused here:
- a value
serde_jsoncannot hold: numeric text that parses toinfornan("inf","NaN","1e400"), and an integer beyond thei64/u64range; nullfor a field that is not declared nullable: the generated Python model makes every property missing fromrequiredoptional, so it takesnull, while Rust reads the schema as written;- enum member names use Rust’s Unicode tables, so a few non-ASCII values (combining marks, numeric ideographs) can be named differently from Python;
- the
uuid1..uuid5formats and other string formats are plain strings.
crates/cognify/tests/fixtures/pydantic_lax/
holds the ground-truth table recorded from Pydantic 2.13 that the unit tests
replay.
Extraction prompt
Each chunk is extracted with the same system prompt the default path sends, as Python’sextract_content_graph does for any response model: the default graph
prompt with that chunk’s temporal normalization hints, or a
custom_extraction_prompt verbatim, with no hints. Only the response schema
differs.
Validation
CognifyConfig::validate() compiles the schema, so an invalid one
(ConfigError::InvalidGraphSchema) fails before any document is classified or
chunked. It also rejects graph_schema together with a graph_backend or the
cascade extraction strategy: only the single-pass LLM extractor honours a
custom schema, as Python raises for a custom graph_model with a non-LLM
extractor.
Example (programmatic)
Chunk attachment
By default (ChunkAttachment::Direct, or unset) each chunk links to the stored
root, or to the children that replace a transparent root, matching Python. Use
with_chunk_attachment(ChunkAttachment::All) to link it to every persisted
DataPoint reached by the transparent storage walk. Chunk attachment requires
graph_schema (validate() fails otherwise).
FromIdentity references (Rust-only keyword)
A reference field marked"x-cognee-from-identity": true is answered by the
LLM with the target’s identity string (a list of them for an array field)
instead of a nested object. Rust widens the string back into the target node:
"Acme" becomes {"name": "Acme"} for a target whose identity_fields is
["name"], so it gets the same id as the full Acme node, extracted alongside
it or already in the graph. This is the JSON form of Python’s
Annotated[Company, FromIdentity()] marker, which Python’s schema converter and
spec DSL cannot express: Python runs a schema with the keyword but ignores it
and asks for nested objects.
DynamicGraphError::InvalidReferenceType before any LLM call:
- Shape: the field holds a
$ref, a$refornull, or an array of$ref(the array itself optionallynull). A union of models, a scalar, a non-model$defsentry and an array of nullable items are rejected. - Identity: the target declares exactly one
identity_fieldsentry. - Constructible: every other required field of the target has a default.
- Marker value:
trueorfalse, on the field or anywhere inside it (such as on ananyOfvariant). The keyword is stripped from the schema sent to the LLM either way.
null, or an array of
strings) described as "The {identity field} of a {Target} already in the graph". An answered object is still accepted as the nested model. If the
identity field is not one of the target’s declared fields, the compile step
logs a warning and every reference becomes a node of its own.
A reference stores a node holding only the identity value and the target’s
defaults, so, as in Python:
- Within one storage batch the first occurrence of an id wins (see above), in field declaration order. Declare the field that lists targets in full before the fields that reference them.
- A reference stored in a later batch resets the stored node’s other fields to those defaults, because the Ladybug adapter overwrites the whole property blob when the node already exists.
Spec DSL
A custom graph model can also be written in the spec DSL described in Graph Model from JSON, the format the cognee UI graph-model editor produces. Rust’s port,crates/cognify/src/graph_spec.rs,
compiles a spec into the same JSON Schema Python’s graph_spec_to_json_schema
emits, key for key, so a spec runs through the same graph_schema path. The
format, its defaults and its validation rules are Python’s:
graph_schema_from_spec_value parses and compiles a spec given as an object or a
JSON string. graph_model_from_spec also builds the model, which catches a spec
that compiles but cannot build: the root entity’s name must be a class name (see
above), so a root named person or Org_unit fails.
Rust differs from Python in two details. A rule violation carries Python’s
ValueError message verbatim, but the entity-count and empty-enum limits, which
Python enforces through Pydantic, use Rust’s own wording. And an entity or field
name with a trailing newline is rejected, which Python’s
^[A-Za-z][A-Za-z0-9_]*$ lets through.
Entry points
graph_schema and chunk_attachment are per-call options, like Python’s
graph_model and chunk_attachment arguments. Neither has a persistent setting
or an environment variable.
- Rust:
CognifyConfig::with_graph_schema/with_chunk_attachment, as in the example above.cognee_cognify::parse_graph_schemareads a schema given as an object or a JSON string, andChunkAttachment::parse(orFromStr) reads"direct"/"all". A spec goes throughgraph_spec_to_json_schema(orgraph_schema_from_spec_valuefor an object or a JSON string), as above. - CLI:
cognee-cli cognify --graph-schema schema.json --chunk-attachment all, or--graph-spec spec.jsonfor a spec (not both).add-and-cognifytakes the same flags (see CLI). - Bindings (Python handle, TypeScript, Java, C): the cognify and
add-and-cognify options
graphSchema(an object or a JSON string; aliasgraphModel),graphSpec(a spec, compiled natively; not withgraphSchema) andchunkAttachment. Python also acceptsgraph_schema/graph_spec/chunk_attachmentand hascognee_py.graph_spec_to_json_schema; TypeScript types the spec asGraphSchemaSpec; Java hasCognifyOptions.graphSchema(...),.graphSpec(...)and.chunkAttachment(...)(see Bindings). - Python compat (
cognee_py.compat):cognify(graph_model=..., chunk_attachment=...)andadd_and_cognify(...), as upstream.graph_modeltakes a Pydantic model class, converted as upstream’sgraph_model_to_graph_schemadoes (through that function when upstreamcogneeis importable, otherwise withmodel_json_schema()minus the default field titles), or the schema as a dict or JSON string. Upstream’s ownKnowledgeGraphmeans the default pipeline. As upstream,chunk_attachmentwith a class that is not aDataPointsubclass raisesCogneeValidationError, checked when upstream’sDataPointis importable. Without upstreamcogneea class is accepted as its schema. A schema dict or string is always accepted, as upstream’s HTTP route takes it.graph_spec_to_json_schemais re-exported, as upstream’scognee.low_leveldoes, sograph_model=graph_spec_to_json_schema(spec)works;graph_spec=passes a spec straight through.
null and {} mean the default KnowledgeGraph, as Python’s HTTP route reads
an omitted or empty graph_model. The CLI and the bindings validate before any
side effect, so add-and-cognify adds nothing when the options are invalid. A
Rust caller runs CognifyConfig::validate itself; otherwise a bad schema fails
only at extraction, after the data is added and chunked. These are validation
errors (exit code 2 in the CLI, VALIDATION_ERROR in the
bindings):
- an attachment other than
"direct"/"all"(case-sensitive, as Python compares it); - an attachment without a schema;
- a schema that does not compile;
- a schema combined with a graph backend or the
cascadestrategy.
cognify() forwards
unknown keywords instead of raising.
Ownership and deletion
Custom nodes and field edges are recorded in the ownership ledger per data item before the graph and vector stores see them, as the default path records its entities. A node that two data items produce keeps both owners and is removed only with the last of them. Data, dataset andforget deletes and the
sweep after a failed run remove the item’s custom nodes, their
{Model}_{field} points and the EdgeType points of their field edges. The
store-wide wipe (DeleteScope::All) drops the custom collections too.
Pointers
CognifyConfig— schema and chunk-attachment configuration.crates/cognify/src/dynamic_graph.rs— runtime schema compiler, validation, identity, and storage walk.crates/cognify/src/graph_spec.rs— the spec DSL and its compiler.