Skip to main content
cognee-rust mirrors Python’s 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 default KnowledgeGraph 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 a metadata property. Python produces one in either of two ways:
  • graph_model_to_graph_schema() applied to plain pydantic.BaseModel classes that declare an explicit metadata: dict = {...} field — the pattern Python’s own example uses.
  • graph_spec_to_json_schema() applied to a GraphSchemaSpec. 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_fields determine the stable UUID: uuid5(NAMESPACE_OID, "{Model}:{values}") over each identity value as Python str()s it after Pydantic validation (a number is a float, so 3 and "3" become 3.0; a date-time is a timezone-aware datetime, a Unix timestamp being read as UTC; a date, time or uuid prints as Python’s 2024-01-02, 03:04:05 and hyphenated lower-case text; only plain strings are lower-cased with spaces turned into _). An omitted optional identity field contributes None. id, created_at, updated_at or 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_fields select the scalar fields written to {Model}_{field} vector collections. A null or 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: true makes 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’s GraphSchemaSpec compiler never emits transparent, so it only comes from a hand-written or BaseModel schema.
Within one storage batch (one data item’s 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_json cannot hold: numeric text that parses to inf or nan ("inf", "NaN", "1e400"), and an integer beyond the i64/u64 range;
  • null for a field that is not declared nullable: the generated Python model makes every property missing from required optional, so it takes null, 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..uuid5 formats and other string formats are plain strings.
The full contract (identity stringification, the union grading rules, date-time and timestamp parsing details) is in the upstream custom-graph-schema guide, and 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’s extract_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.
The marker is checked when the schema is compiled, so a violation fails with DynamicGraphError::InvalidReferenceType before any LLM call:
  • Shape: the field holds a $ref, a $ref or null, or an array of $ref (the array itself optionally null). A union of models, a scalar, a non-model $defs entry and an array of nullable items are rejected.
  • Identity: the target declares exactly one identity_fields entry.
  • Constructible: every other required field of the target has a default.
  • Marker value: true or false, on the field or anywhere inside it (such as on an anyOf variant). The keyword is stripped from the schema sent to the LLM either way.
In that schema the field becomes a string (or a string or 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_schema reads a schema given as an object or a JSON string, and ChunkAttachment::parse (or FromStr) reads "direct" / "all". A spec goes through graph_spec_to_json_schema (or graph_schema_from_spec_value for an object or a JSON string), as above.
  • CLI: cognee-cli cognify --graph-schema schema.json --chunk-attachment all, or --graph-spec spec.json for a spec (not both). add-and-cognify takes 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; alias graphModel), graphSpec (a spec, compiled natively; not with graphSchema) and chunkAttachment. Python also accepts graph_schema / graph_spec / chunk_attachment and has cognee_py.graph_spec_to_json_schema; TypeScript types the spec as GraphSchemaSpec; Java has CognifyOptions.graphSchema(...), .graphSpec(...) and .chunkAttachment(...) (see Bindings).
  • Python compat (cognee_py.compat): cognify(graph_model=..., chunk_attachment=...) and add_and_cognify(...), as upstream. graph_model takes a Pydantic model class, converted as upstream’s graph_model_to_graph_schema does (through that function when upstream cognee is importable, otherwise with model_json_schema() minus the default field titles), or the schema as a dict or JSON string. Upstream’s own KnowledgeGraph means the default pipeline. As upstream, chunk_attachment with a class that is not a DataPoint subclass raises CogneeValidationError, checked when upstream’s DataPoint is importable. Without upstream cognee a 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_schema is re-exported, as upstream’s cognee.low_level does, so graph_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 cascade strategy.
In the bindings a wrongly-typed value for any known cognify option is an error too, and an unknown option is logged as a warning. Python’s 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 and forget 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