Before You Start
- This is a low-level infrastructure exercise, not a replacement for
cognee.remember()or Cognee pipelines — direct graph writes like this skip vector indexing, relational records, dataset provenance, and normal access-control behavior. - This guide defines a custom
Person(DataPoint)model — see DataPoints for the concept, or Custom Data Models and Custom Graph Model to go deeper.
Code in Action
What Just Happened
Step 1: Configure an Isolated Graph and Get the Engine
graph_file_path points Ladybug at a throwaway directory so this exercise never touches your default Cognee graph. graph_database_subprocess_enabled=False keeps Ladybug running inside your Python process instead of as a separate background process — simpler for a short script like this. get_graph_engine() is async and returns whatever adapter the configuration selected — here, a Ladybug adapter — typed as GraphDBInterface. Everything from this point on is written against that interface, not against Ladybug specifically.
Step 2: Define a Minimal Node Model
Person inherits from DataPoint, Cognee’s base model for graph nodes. Declaring "identity_fields": ["name"] gives each Person a deterministic id based on its name, so Person(name="Alice") always resolves to the same node id instead of a random one.
Step 3: Create Nodes and an Edge
add_nodes(nodes) takes a list of DataPoint instances and writes them as one bulk operation. The Ladybug, Postgres, and Turso adapters split a large list into several idempotent bulk statements rather than sending one statement per call, so a single write cannot outgrow the backend’s per-statement limits; the chunk size is an internal, per-adapter detail and is not configurable. add_edge(source_id, target_id, relationship_name, edge_properties) creates one directed edge — here, Alice -[knows]-> Bob with a small property dictionary. Both take plain string ids, so DataPoint.id (a UUID) is converted with str(...) before being passed in.
Step 4: Read Data Back
get_node(node_id) returns a single node’s properties as a dictionary, or None if it does not exist. get_neighbors(node_id) returns the properties of every node connected to the given node — here, Bob’s only neighbor is Alice. has_edge(source_id, target_id, relationship_name) checks whether a specific directed, labeled edge exists, returning a plain bool.
Step 5: Clean Up
delete_nodes(node_ids) removes the listed nodes and any edges attached to them. Running it in a finally block ensures the example nodes are removed even if an earlier step raises, leaving the isolated graph empty again.
One Interface, Many Databases
Cognee can store its graph in several different backends — Ladybug (the default local, file-based engine), Postgres, Neo4j, and others (see Graph Stores for how to configure each one). Application code that queries or writes to the graph should not need a different code path for each one. Cognee solves this with three pieces:- Configuration picks which provider is active (e.g.
"ladybug"or"postgres_demo"). get_graph_engine()is a factory function. It reads the configuration and returns an object implementing the interface — you never construct an adapter yourself.GraphDBInterfaceis an abstract base class that declares the methods every adapter must provide (add_node,get_node,has_edge, and so on).- Concrete adapters (
LadybugAdapter,PostgresDemoAdapter, …) implement that interface against a specific database.
get_graph_engine() works unchanged no matter which provider is configured.
Seed selection: get_top_degree_node_ids
GraphDBInterface.get_top_degree_node_ids(top_k) — the method that picks seed nodes for the default graph visualization — is declared on the interface but, unlike most of its methods, is not abstract: it ships with a working inherited implementation, so an adapter that predates it keeps loading. Its contract:
- It returns node ids only (
list[str]), never node objects or degree counts. - It is approximate by contract: an adapter may sample instead of counting exactly, and the order of near-equal nodes may vary between calls. Do not treat the result as a ranking — only as “up to
top_knodes worth starting from”. - Isolated nodes are valid seeds, so a graph with nodes but no edges still yields a non-empty result.
top_kmust be ≥ 1; a non-positive value raisesValueErrorbefore the graph is read.
get_top_degree_node_ids — a third-party adapter from cognee-community, for instance — still works: it inherits the default, which reads the whole graph and counts degree in Python. That is O(graph) in memory and can exhaust a worker on a multi-million-node graph, so the fallback logs a warning once per adapter type per process:
Entity typing: get_entity_type_names
GraphDBInterface.get_entity_type_names(entity_ids) follows the same optional-override pattern: declared on the interface, not abstract, so an adapter written before it keeps loading. It is the one-hop is_a lookup that lets a bounded visualization read label entities whose EntityType node falls outside the window. Its contract:
- It takes a
list[str]of entity ids and returnsdict[str, str], keyed by entity id, whose values are thenameof theEntityTypeeach entity points at throughis_a. - An entity with no
is_aedge — or one whose type node has no name — is simply absent from the result; it is not a key with an empty value. - An empty input list returns
{}without touching the store. - It reads one hop outward from the given entities only, never from a type node outward.
is_a edge and the target’s name. Every other adapter — Neptune, and community adapters — inherits the default, which goes through get_neighborhood(node_ids=entity_ids, depth=1, edge_types=["is_a"]) and therefore hydrates the entities with all their properties and returns every edge among the returned nodes. That is heavier than the native queries but bounded by the entities asked about, so unlike the get_top_degree_node_ids fallback it logs no warning. If you override it, read only the is_a edges and the target names rather than hydrating whole neighborhoods.
The caller also tolerates an adapter that raises: a failed lookup is logged once and skipped for the rest of that read instead of aborting it.
Counting: get_graph_counts
GraphDBInterface.get_graph_counts() follows the same optional-override pattern: declared on the interface, not abstract, so an adapter written before it keeps loading. Its contract:
- It takes no arguments and returns a
(num_nodes, num_edges)tuple of twoints — the counts and nothing else. No mean degree, no connected components, no density. For those, callget_graph_metrics(include_optional=...), which is unchanged. - On an empty graph, the Ladybug, Neo4j, Postgres, and Turso overrides return
(0, 0)rather than raising.
count(n) and count(r)), Postgres and Turso with SELECT count(*) over their node and edge tables, Neptune with a single model-independent aggregation. The Neo4j override exists precisely because the alternative does not scale — collecting the nodes or edges to take len() of them builds the whole graph inside one transaction and exhausts Neo4j’s transaction memory pool on a large graph.
An adapter that does not override it still works: it inherits the default, which calls get_graph_metrics(include_optional=False) and reads the two counts out of the result. That is correct but needlessly expensive, because computing those metrics also walks the connected components. The fallback logs a warning once per adapter type per process:
GET /api/v1/datasets/graph-summary and GET /api/v1/visualize/brains-summary) counts each dataset’s graph once per cognify run, caches the result against that run id, and never asks for the rest of the metrics.
Write failures: Neptune’s bulk fallback
The interface says nothing about how an adapter performs a bulk write, and Neptune’s answer is a fallback: when the bulk query behindadd_nodes, delete_nodes or add_edges fails (for add_edges, one query per relationship type), it retries that query’s items one at a time. What that fallback does with a per-item failure is visible through the interface, so it is worth knowing:
- Each of the three methods collects the items that failed individually and, if the list is non-empty, raises one
RuntimeErrornaming the counts —Failed to add 3 of 12 nodes after bulk fallback. The first underlying error is chained withraise ... from, so it is available as__cause__. - The error is raised once, after every item has been attempted — not at the first failure — so the items that can be written are, and stay written.
has_edgeraisesRuntimeError("Failed to check edge existence: ...")when the check itself errors, rather than returningFalse. Treat aboolfrom it as an answer about the graph, not as “no error occurred”.
"neptune" and "neptune_analytics", which share these methods. Other adapters are unaffected — this is adapter-specific behavior, not an interface-wide contract — and cognee wraps none of these calls in a retry, so a caller that wants one supplies it.
Comparing Adapters
LadybugAdapter and PostgresDemoAdapter both implement add_nodes, add_edge, get_node, get_neighbors, and has_edge from GraphDBInterface — but the two implementations look nothing alike internally. Ladybug builds parameterized Cypher-style statements against an embedded Kuzu database; Postgres issues SQL against relational tables that model nodes and edges. Neither difference is visible to code written against the interface, which is the point: swapping graph_database_provider from "ladybug" to "postgres_demo" (with matching connection settings) does not require changing any of the code above.
DataPoints
Learn more about DataPoint, the base model Person builds on
BaseRetriever Guide
See the same abstract-contract pattern applied to retrievers