Adopt an Existing Domain Graph
How to layer neo4j-agent-memory on top of a Neo4j graph that already
exists in production, so that library writes (entity extraction, MENTIONS
edges, relation writes) link to your existing nodes instead of creating
duplicates.
By default, the library MERGEs entities on (:Entity {name, type}). If
your existing graph has nodes labelled :Person, :Movie, :Client,
and so on — none of which carry :Entity — those merges will create
duplicates. The client.schema.adopt_existing_graph() helper attaches
the :Entity super-label, the library’s id/type/name properties,
and is idempotent.
Goal
Adopt an existing domain graph as long-term memory entities in one call:
await client.schema.adopt_existing_graph(
label_to_type={
"Person": "PERSON",
"Movie": "MOVIE",
"Genre": "GENRE",
},
name_property_per_label={"Movie": "title"},
)
Prerequisites
-
A running Neo4j 5.x with your existing domain graph already loaded.
-
neo4j-agent-memoryv0.2 or later. -
A
MemorySettingsconfigured for the database (see Configuration Reference).
If your domain types differ from the POLE+O default ontology, also configure SchemaModel.CUSTOM:
from neo4j_agent_memory import MemorySettings
from neo4j_agent_memory.config.settings import SchemaConfig, SchemaModel
settings = MemorySettings(
schema_config=SchemaConfig(
model=SchemaModel.CUSTOM,
entity_types=["PERSON", "MOVIE", "GENRE"],
strict_types=True,
),
# ...
)
Steps
1. Identify the labels and name properties to adopt
For each Neo4j label in your existing graph, decide:
-
The library entity type to assign (a string — POLE+O members like
PERSONif you’re keeping the default ontology, or your own type names if you’re usingSchemaModel.CUSTOM). -
The property to use as the
nameof the resulting:Entitynode. Defaults tonameper label, but you can override per label (movies often usetitle, people sometimes usefull_name, etc.).
2. Call adopt_existing_graph
async with MemoryClient(settings) as client:
report = await client.schema.adopt_existing_graph(
label_to_type={
"Person": "PERSON",
"Movie": "MOVIE",
"Genre": "GENRE",
},
name_property_per_label={"Movie": "title"},
)
print(f"Migrated {report.total_migrated} nodes "
f"({report.total_already_adopted} already adopted, "
f"{report.total_skipped} skipped).")
for label_report in report.by_label:
print(
f" {label_report.label} -> {label_report.type}: "
f"+{label_report.migrated_count} new, "
f"={label_report.already_adopted_count} already, "
f"~{label_report.skipped_count} skipped"
)
The helper:
-
Adds
:Entityto every matching node that doesn’t already carry it. -
Sets
typefrom the input map. -
Sets
namefrom the configured property (defaulting to existingname). -
Generates a deterministic
idof the form<label_lc>:<name>for nodes that don’t have one. Existingidproperties are preserved. -
Skips nodes whose configured name property is null.
Verification
After adoption, library writes that MERGE on (:Entity {name, type})
should link to your existing nodes:
# Add a message that names someone in the existing graph.
await client.short_term.add_message(
"demo", "user", "Have you seen Inception? Bob Singh directed it."
)
# Verify there's still exactly one node per name in the graph.
rows = await client.graph.execute_read(
"""
UNWIND ['Bob Singh', 'Inception'] AS target
MATCH (n {name: target})
WHERE n:Person OR n:Movie
RETURN target, count(n) AS count
"""
)
for row in rows:
print(f"{row['target']}: {row['count']} (expect 1)")
Edge cases
| Case | Behavior |
|---|---|
Node missing the configured name property |
Skipped. Reported in |
Node already carries |
No-op. Reported in |
Node has an |
Preserved. The helper only generates an |
Label or name property contains characters outside |
|
See Also
-
POLE+O Entity Model — the default ontology you’re either keeping or overriding with
SchemaModel.CUSTOM. -
examples/existing-graph/— a runnable end-to-end example using a small Movies-style domain.