Every agent framework eventually hits the same wall: the model forgets. Context windows got bigger, then agents got longer-running, and the gap stayed. Vector stores helped — you can recall the chunk where the user mentioned their address — but they cannot answer "what is the user's address now, given they moved in March, and what was it in January?" That is a graph question, and specifically a temporal graph question.
This tutorial builds agent memory as a bi-temporal knowledge graph in Neo4j: episodes go in, facts get extracted, contradicted facts get invalidated instead of deleted, and retrieval blends semantic search with graph traversal and recency. It is the pattern behind temporal-memory libraries like Graphiti, rebuilt here in plain Cypher so you can own it, debug it, and put it in production.
What you will build
- A schema with three layers — episodic (what happened), semantic (what we believe), and entity (who and what).
- An ingestion path that turns a conversation turn into entities and facts with an LLM.
- Bi-temporal edges:
valid_from/valid_tofor when a fact was true in the world,created_at/expired_atfor when the system learned it. - Contradiction handling that invalidates rather than overwrites.
- A retrieval query that mixes vector similarity, graph expansion, and recency into one ranked context block.
Prerequisites
- Neo4j 2025.06 or newer (native
VECTORtype and vector indexes). The Docker setup works; Aura also works. - Python 3.10+,
pip install "neo4j>=6" openai - An OpenAI key (or any embedding + LLM provider — swap the two helper functions).
If you have not used vector indexes in Neo4j before, read Vector Search in Neo4j first; this post assumes you know what an HNSW index is.
Step 1 — the three-layer schema
The mistake most teams make is storing memory as one flat pile of embedded text. Separate the layers and each one can be queried on its own terms.
(:Episode) raw, immutable: a message, a tool result, a document
|
MENTIONS
v
(:Entity) --[:FACT {…temporal…}]--> (:Entity)
- Episode — append-only. Never edited, never deleted. It is your audit trail and your re-extraction source when the extraction prompt improves.
- Entity — deduplicated things: people, accounts, products, tickets.
- FACT — a typed, timestamped relationship between entities, always traceable back to the episodes that produced it.
Constraints and indexes first:
CREATE CONSTRAINT episode_id IF NOT EXISTS
FOR (e:Episode) REQUIRE e.id IS UNIQUE;
CREATE CONSTRAINT entity_key IF NOT EXISTS
FOR (n:Entity) REQUIRE (n.group_id, n.name) IS UNIQUE;
CREATE INDEX episode_time IF NOT EXISTS
FOR (e:Episode) ON (e.group_id, e.occurred_at);
CREATE VECTOR INDEX episode_embedding IF NOT EXISTS
FOR (e:Episode) ON (e.embedding)
OPTIONS { indexConfig: {
`vector.dimensions`: 1536,
`vector.similarity_function`: 'cosine',
`vector.quantization.enabled`: true
}};
CREATE FULLTEXT INDEX fact_text IF NOT EXISTS
FOR ()-[r:FACT]-() ON EACH [r.statement];
group_id is the tenant boundary — one per user, workspace, or agent session family. Every query filters on it. If tenants must be isolated at the database level rather than the property level, see Multi-Tenant Neo4j.
Step 2 — bi-temporal, and why you need both clocks
Two questions look the same and are not:
- "What did we believe on 1 May?" — system time.
- "What was true on 1 May?" — valid time.
An agent that confidently states a fact it learned yesterday, dated to last year, is failing the second question. Store four timestamps on every FACT:
| Property | Meaning |
|---|---|
valid_from | when the fact became true in the world |
valid_to | when it stopped being true (null = still true) |
created_at | when the system recorded it |
expired_at | when the system stopped believing it (null = still believed) |
Nothing is ever deleted. Invalidation is a write of valid_to and expired_at. That is what makes memory auditable: you can always show why the agent said what it said, and when it changed its mind.
Step 3 — ingest an episode
import os, json, uuid, datetime as dt
from neo4j import GraphDatabase
from openai import OpenAI
driver = GraphDatabase.driver(os.environ["NEO4J_URI"],
auth=(os.environ["NEO4J_USER"], os.environ["NEO4J_PASSWORD"]))
oai = OpenAI()
def embed(text: str):
return oai.embeddings.create(model="text-embedding-3-small", input=text).data[0].embedding
EXTRACT_PROMPT = """Extract factual statements from the message.
Return JSON: {"facts":[{"subject":str,"predicate":UPPER_SNAKE,"object":str,
"statement":str,"valid_from":ISO8601 or null}]}
Only facts stated or clearly implied. No speculation."""
def extract(text: str, now: str):
r = oai.chat.completions.create(
model="gpt-4o-mini", temperature=0,
response_format={"type": "json_object"},
messages=[{"role": "system", "content": EXTRACT_PROMPT},
{"role": "user", "content": f"now={now}\n\n{text}"}])
return json.loads(r.choices[0].message.content)["facts"]
The write is one transaction: episode, entities, facts.
INGEST = """
MERGE (ep:Episode {id: $id})
SET ep.group_id = $group_id, ep.body = $body, ep.role = $role,
ep.occurred_at = datetime($occurred_at),
ep.created_at = datetime($now),
ep.embedding = vector($embedding)
WITH ep
UNWIND $facts AS f
MERGE (s:Entity {group_id: $group_id, name: f.subject})
ON CREATE SET s.created_at = datetime($now)
MERGE (o:Entity {group_id: $group_id, name: f.object})
ON CREATE SET o.created_at = datetime($now)
MERGE (ep)-[:MENTIONS]->(s)
MERGE (ep)-[:MENTIONS]->(o)
CREATE (s)-[r:FACT {
uuid: randomUUID(), group_id: $group_id,
predicate: f.predicate, statement: f.statement,
valid_from: datetime(coalesce(f.valid_from, $occurred_at)),
valid_to: null,
created_at: datetime($now), expired_at: null,
episode_id: $id
}]->(o)
RETURN count(r) AS facts_written
"""
def ingest(group_id, body, role="user", occurred_at=None):
now = dt.datetime.now(dt.timezone.utc).isoformat()
occurred_at = occurred_at or now
facts = extract(body, now)
with driver.session() as s:
return s.execute_write(lambda tx: tx.run(
INGEST, id=str(uuid.uuid4()), group_id=group_id, body=body, role=role,
occurred_at=occurred_at, now=now, embedding=embed(body), facts=facts
).single()["facts_written"])
Note CREATE for the fact, not MERGE. Facts are versioned assertions; two independent observations of the same thing are two edges with different provenance, and that is correct.
Step 4 — invalidate contradictions
After writing new facts, look for older beliefs about the same subject and predicate and retire them. Single-valued predicates (LIVES_IN, WORKS_AT, PREFERS_LANGUAGE) are the ones that need this; multi-valued ones (ATTENDED) do not.
MATCH (s:Entity {group_id: $group_id})-[new:FACT {uuid: $new_uuid}]->(:Entity)
WHERE new.predicate IN $single_valued
MATCH (s)-[old:FACT]->(:Entity)
WHERE old.predicate = new.predicate
AND old.uuid <> new.uuid
AND old.expired_at IS NULL
AND old.valid_from <= new.valid_from
SET old.valid_to = coalesce(old.valid_to, new.valid_from),
old.expired_at = datetime($now),
old.expired_by = new.uuid
RETURN count(old) AS invalidated;
Two habits keep this honest. First, only invalidate on the same predicate — an LLM deciding "these two facts conflict" in free text is a source of silent memory corruption. Second, log every invalidation; the expired_by pointer gives you a chain you can walk when someone asks why the agent changed its answer.
Late-arriving information is handled by the old.valid_from <= new.valid_from guard: an episode about an older event does not retire a newer belief.
Step 5 — retrieve
Good agent memory retrieval is three signals merged, not one. Run vector search over episodes, expand to the facts about the entities those episodes mention, and rank by relevance and recency.
CALL db.index.vector.queryNodes('episode_embedding', 12, vector($q_embedding))
YIELD node AS ep, score
WHERE ep.group_id = $group_id
MATCH (ep)-[:MENTIONS]->(e:Entity)
MATCH (e)-[f:FACT]-(other:Entity)
WHERE f.expired_at IS NULL
AND f.valid_from <= datetime($as_of)
AND (f.valid_to IS NULL OR f.valid_to > datetime($as_of))
WITH DISTINCT f, e, other, max(score) AS relevance
WITH f, e, other, relevance,
duration.inSeconds(f.valid_from, datetime()).days AS age_days
RETURN e.name AS subject, f.predicate AS predicate, other.name AS object,
f.statement AS statement, f.valid_from AS since,
relevance * exp(-0.005 * age_days) AS rank
ORDER BY rank DESC
LIMIT 25;
Three things to notice:
as_ofis a parameter. Passdatetime()for "what is true now" and any past timestamp for "what was true then" — same query, no extra code. That single parameter is most of the value of bi-temporal modeling.- The exponential decay is a tunable prior, not a truth. Start at
0.005(≈ half-weight after four months) and tune against real questions. - Expired facts are filtered, not gone. Drop the
expired_at IS NULLclause and you have an audit view of the agent's belief history.
Feed the result to the model as compact lines — subject predicate object (since 2026-03-14) — rather than raw JSON. It costs fewer tokens and models follow it better.
Step 6 — keep the graph from rotting
Three maintenance jobs decide whether this is still useful in six months.
Entity resolution. "Acme", "Acme Corp", and "ACME Inc." must converge. Embed entity names, run a nightly similarity pass, and merge above a high threshold with human review in the middle band — the node similarity techniques from our GDS tutorial apply directly.
Episode retention. Episodes are append-only, not immortal. Archive raw bodies older than your retention window to object storage, keep the node and its embedding, and store a pointer.
Re-extraction. When the extraction prompt improves, replay episodes into a shadow group_id and diff the fact sets. This is only possible because episodes are immutable — which is the whole reason for the episodic layer.
Watch the cardinality of FACT edges per entity. A hub entity with a hundred thousand fact edges makes retrieval expansion expensive; if that happens, check the plan with PROFILE before adding hardware.
Where this fits
Temporal memory and GraphRAG are the same machinery pointed at different data: GraphRAG builds a graph from your documents, agent memory builds one from your interactions. Run both in one database and an agent can join what a customer told you last week against what the contract says. Expose them to your assistant through the Neo4j MCP servers, read-only, and you have a memory layer you can actually inspect.
The hard parts are not the Cypher. They are picking the predicate vocabulary, deciding which predicates are single-valued, setting the decay constant, and choosing where entity resolution needs a human. Those choices are why two teams building "the same" memory graph end up with wildly different quality.
If you are designing an agent memory or knowledge graph layer and want the schema decisions reviewed before they harden into six months of data, get in touch. Our senior Neo4j consultants do exactly this kind of modeling work, and an early review is far cheaper than a migration.