Layer
neo4j-agent-memoryon top of the production graph you already have — no duplicate nodes, no migration script, idempotent.
This is the headline example for the adopt an existing graph workflow. By default the library MERGEs entities on (:Entity {name, type}). If your existing graph has nodes labelled :Person, :Movie, :Client, etc. — none of which carry :Entity — those merges create duplicates. client.schema.adopt_existing_graph(...) attaches the :Entity super-label and the library's required id/type/name properties to your existing nodes so library writes link to them instead.
⚠️ Neo4j Labs ProjectThis example is part of
neo4j-agent-memory, a Neo4j Labs project. It is actively maintained but not officially supported. APIs may change. Community support is available via the Neo4j Community Forum.
Need a different LLM or embedding model? As of
neo4j-agent-memoryv0.3 you can swap providers via a single string —MemorySettings(llm="anthropic/claude-opus-5", embedding="BAAI/bge-small-en-v1.5"). See Bring Your Own Model.
client.schema.adopt_existing_graph(label_to_type=..., name_property_per_label=..., dry_run=...)— one call to attach the library's super-label and properties to nodes from a pre-existing schema. Idempotent; re-runnable safely;--dry-runfirst.SchemaModel.CUSTOM— configureMemorySettingsso library writes target your domain types (MOVIE,GENRE, …) instead of the default POLE+O ontology. The only runnable example in the repo using a non-POLE+O domain.- Deterministic mention linking —
add_message(extraction_mode="explicit", explicit_mentions=[EntityRef(...)])links a message to exactly the adopted nodes it talks about: no NER model, no API key, and the only path that currently links to adopted nodes at all (see "Known gaps"). - Retrieval over the adopted graph — backfill embeddings, then
long_term.search_entities(),long_term.add_relationship()/get_related_entities(), and aclient.query.cypher()traversal of the graph's ownACTED_IN/DIRECTED/IN_GENREedges. - Adoption-aware
name_property_per_label— tells the library that:Movienodes usetitlerather thannamefor their display name. - No-LLM, no-API-key path — runs with
llm=Noneand a local sentence-transformers embedder.
| File | Purpose |
|---|---|
memory_settings.py |
build_settings() plus the adoption mapping (LABEL_TO_TYPE, NAME_PROPERTY_PER_LABEL) and demo constants shared by every script. |
seed_domain_graph.cypher |
Three :Person, three :Movie, two :Genre nodes with relationships. Pre-library style — no :Entity labels, no library properties. Stand-in for "your existing production graph". MERGE-only. |
seed.py |
Runs the seed file through the library's Neo4j client (no host cypher-shell needed). --reset deletes only the seed labels, and only with EXISTING_GRAPH_ALLOW_RESET=1. |
adopt.py |
Calls client.schema.adopt_existing_graph(...). --dry-run reports the projection without writing. |
memory_io.py |
Writes messages with explicit mentions and asserts that exactly one node exists per name across all labels, with MENTIONS edges on the adopted nodes. |
retrieve.py |
Embedding backfill, search_entities, a library relation write, and a domain-relationship traversal. |
run.sh |
seed → dry-run → adopt → write/verify → retrieve. Run this for the full demo. |
- Bolt only.
client.schema.adopt_existing_graph()raisesNotSupportedErroron the hosted NAMS backend, where schema is server-managed. Seeexplanation/backends.adoc. - Neo4j 5.26 LTS or 2026.x. Start the repo's test container with
make neo4j-startfrom the repo root (it listens onbolt://localhost:7687withneo4j/test-password— the defaults used here), or exportNEO4J_URI,NEO4J_USERNAME,NEO4J_PASSWORD. neo4j-agent-memoryinstalled with thesentence-transformersextra so the local embedder works:uv sync --extra sentence-transformers(oruv sync --all-extras).
Where to run the seed.
seed.pyis MERGE-only and never deletes anything, but it does write:Person/:Movie/:Genrenodes — run it against a scratch database.adopt.py,memory_io.pyandretrieve.pyare the parts you can point at a graph you care about (start withadopt.py --dry-run).
From the repo root:
bash examples/existing-graph/run.shOr step by step:
uv run python examples/existing-graph/seed.py
uv run python examples/existing-graph/adopt.py --dry-run
uv run python examples/existing-graph/adopt.py
uv run python examples/existing-graph/memory_io.py
uv run python examples/existing-graph/retrieve.pyYou should see:
==> 2/4 Projecting adoption (dry run), then adopting...
Would adopt 8 nodes (0 already adopted, 0 skipped).
Person → PERSON: +3 new, =0 already, ~0 skipped
Movie → MOVIE: +3 new, =0 already, ~0 skipped
Genre → GENRE: +2 new, =0 already, ~0 skipped
(projection only — re-run without --dry-run to apply)
Adopted 8 nodes (0 already adopted, 0 skipped).
…
Nodes per demo name (1 means library writes hit the adopted node):
Arrival total=1 ['Entity:Movie']
Bob Singh total=1 ['Entity:Person']
Carol Reyes total=1 ['Entity:Person']
Inception total=1 ['Entity:Movie']
MENTIONS edges produced by add_message():
Arrival MOVIE Entity:Movie
…
2. search_entities('science fiction film', entity_types=['MOVIE'])
Inception MOVIE similarity=0.720
The Matrix MOVIE similarity=0.684
Arrival MOVIE similarity=0.666
Re-run bash examples/existing-graph/run.sh to confirm idempotency: the second run reports already adopted for every node, still finds exactly one node per name, and embeds nothing new.
adopt_existing_graph(label_to_type, *, name_property_per_label=None, dry_run=False):
- For each input label, attaches the
:Entitysuper-label. - Sets
typefrom the mapping,namefrom the configured property (defaulting toname), and keeps any existingidproperty — falling back to a deterministic<label_lc>:<name>id so re-runs produce the same value. - Skips nodes that lack the configured name property and reports them in
report.by_label[i].skipped_count. - Returns an
AdoptionReportwith per-label counts. Withdry_run=Truenothing is written.
After adoption, library writes that MERGE on (:Entity {name, type}) — explicit mentions, relation writes, add_entity, preference targeting — land on your existing domain nodes instead of creating duplicates. (Automatic NER extraction is the exception; see "Known gaps" below.)
These are library limitations the example works around rather than hides:
- Automatic extraction cannot link to an adopted node. Two silent failure modes, both reproduced on Neo4j 5.26 with v0.5.0: (a) the extractors map their labels through POLE+O, so a
MOVIEmention is typedOBJECT, MERGEs a second:Entity:Objectnode and links the mention to the duplicate; (b) give the extractor alabel_mappingthat preservesMOVIEand the MERGE does hit the adopted node, but noMENTIONSedge appears at all, becauseShortTermMemory._extract_and_link_entitieslinks by the id it generated instead of the id the MERGE returned (the adopted node kept its own). This is why the example links mentions explicitly withEntityRef— that path links by the id MERGE returns, so no extraction extras are needed either. schema_config.strict_typesis declarative. It governslong_term.add_entity()validation, never extractor output — andMemoryClientdoes not currently forwardschema_configtoLongTermMemory, so the flag does not reject out-of-schema types today.- Adopted ids must be UUID-shaped for the read helpers. Nodes adopted with a generated
<label_lc>:<name>id (here: the:Genrenodes, which have noidof their own) raiseValueError: badly formed hexadecimal UUID stringif they come back fromsearch_entities(), which hydratesEntity.idas aUUID.retrieve.pyskips embedding those nodes; they stay reachable throughclient.query.cypher(). - Adoption writes no embedding. Semantic search only finds adopted nodes after a backfill — see step 1 of
retrieve.py.
- How-to guide:
docs/modules/ROOT/pages/how-to/adopt-existing-graph.adoc— full design, name-property gotchas, how to bring your own ontology. - Reference:
docs/modules/ROOT/pages/reference/schema-objects.adoc— declarative constraints and indexes the library expects.
Verified against neo4j-agent-memory v0.5.0 with Neo4j 5.26.19 on 2026-09-10.