Adopt an Existing Domain Graph
|
Available on NAMS: No. The APIs on this page are bolt-only — calling them against the hosted NAMS backend raises |
How to layer neo4j-agent-memory on top of a Neo4j graph that already
exists in production, so that library writes (MENTIONS edges, relation
writes, entity upserts) 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})
land on your existing nodes. Name the entities explicitly so the write is
deterministic — automatic NER extraction does not link to adopted nodes
(see Limitations):
from neo4j_agent_memory.schema.models import EntityRef
# Add a message that names people and movies in the existing graph.
await client.short_term.add_message(
"demo",
"user",
"Have you seen Inception? Bob Singh directed it.",
extraction_mode="explicit",
explicit_mentions=[
EntityRef(name="Inception", type="MOVIE"),
EntityRef(name="Bob Singh", type="PERSON"),
],
)
# Count every node the library could have created, across *all* labels —
# a duplicate shows up as a second label set such as ["Entity", "Object"].
# `client.query.cypher` is the portable read accessor (it works on NAMS
# too); `client.graph` remains for write Cypher on bolt.
rows = await client.query.cypher(
"""
UNWIND ['Bob Singh', 'Inception'] AS target
MATCH (n) WHERE n.name = target
RETURN target, count(n) AS total, collect(DISTINCT labels(n)) AS label_sets
ORDER BY target
"""
)
for row in rows:
print(f"{row['target']}: {row['total']} (expect 1) {row['label_sets']}")
# Adoption writes no embedding, so backfill before semantic search.
await client.long_term.add_entity(
"Inception", "MOVIE", resolve=False, deduplicate=False
)
movies = await client.long_term.search_entities(
"science fiction film", entity_types=["MOVIE"]
)
Limitations
Verified against v0.5.0 on bolt:
-
Automatic extraction does not link to adopted nodes. The NER extractors map their labels through POLE+O, so a
MOVIEmention arrives typedOBJECTand MERGEs a second:Entity:Objectnode; give the extractor alabel_mappingthat preservesMOVIEand the MERGE finds the adopted node but noMENTIONSedge is written, because the link step looks the entity up by the id it generated rather than the id the MERGE returned. Useextraction_mode="explicit"withEntityRefinstead. -
SchemaConfig.strict_typesis not forwarded.MemoryClientdoes not passschema_configtoLongTermMemory, so out-of-schema types are not rejected today. -
Adopted ids must be UUID-shaped for the read helpers. Nodes adopted without a pre-existing
idget a deterministic<label_lc>:<name>id; helpers that hydrateEntity.idas aUUID(such assearch_entities()) raiseValueErroron those rows. Read them withclient.query.cypher.
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.