Cypher is easy to read and surprisingly easy to write badly. The syntax is so close to plain English that your first few queries work, and then one of them quietly returns 40,000 rows because you matched two unrelated patterns in the same clause.
These are the five patterns I keep coming back to when the graph is backing an agent's memory. Nothing exotic — just the ones that saved me the most time. All of them run on any Bolt/Cypher endpoint.
1. MERGE for writes you'll run more than once
Agents re-observe the same facts constantly. The same entity gets extracted from three different documents, the same relationship gets inferred twice. If you CREATE, you get duplicates; if you check-then-insert, you get a race.
MERGE matches or creates in one atomic step:
MERGE (p:Person {id: $person_id})
ON CREATE SET p.name = $name, p.first_seen = timestamp()
ON MATCH SET p.last_seen = timestamp()
The important and frequently-missed detail: MERGE matches on the entire pattern you give it. MERGE (p:Person {id: $id, name: $name}) will create a second node when the name changes, because the pattern no longer matches. Merge on the identifying property only, then SET the rest. And put a uniqueness constraint on that property before you rely on any of this — without one, concurrent merges can still both create.
2. Bounded variable-length paths
"What does this depend on, transitively?" is one line:
MATCH path = (s:Service {name: $name})-[:DEPENDS_ON*1..4]->(dep)
RETURN DISTINCT dep.name AS name, min(length(path)) AS distance
ORDER BY distance
Note the upper bound. Writing * or *1.. unbounded on a well-connected graph is how you hang a query — in a social-ish graph almost everything is reachable within six hops, so an unbounded traversal degrades into "scan the graph." Pick a real depth. If you don't know it, start at 3 and measure.
DISTINCT matters too: multiple paths reach the same node, and without it you'll return the same dependency once per route.
3. OPTIONAL MATCH instead of a second round trip
The reflex from SQL is a LEFT JOIN; the reflex from ORMs is a second query. Cypher lets you attach the optional part inline, so a node with no attachments still comes back:
MATCH (d:Document {id: $doc_id})
OPTIONAL MATCH (d)<-[:AUTHORED]-(author:Person)
OPTIONAL MATCH (d)-[:CITES]->(cited:Document)
RETURN d.title AS title,
collect(DISTINCT author.name) AS authors,
collect(DISTINCT cited.title) AS citations
A plain MATCH here would drop the document entirely if it happened to have no citations, which is the kind of bug that only shows up on the one record your demo uses.
4. The neighbourhood fetch
This is the query that actually feeds the model. Resolve the entities in the question, walk out a fixed radius, return the subgraph — and nothing else:
MATCH (seed) WHERE seed.id IN $seed_ids
MATCH path = (seed)-[*1..2]-(neighbour)
WITH collect(DISTINCT neighbour) AS nodes,
collect(DISTINCT relationships(path)) AS rels
RETURN nodes, rels
The discipline here is the radius, not the syntax. Two hops is usually plenty; three often doubles the context for very little extra signal. Whatever you pick, cap the result size explicitly — one unexpectedly popular hub node can drag half the graph into your prompt, and you'd rather truncate deliberately than discover it in a token bill.
5. Provenance as an edge property
If an agent is going to act on a fact, you need to know where the fact came from. Put it on the relationship when you write it:
MATCH (a:Person {id: $a}), (b:Company {id: $b})
MERGE (a)-[r:WORKS_AT]->(b)
ON CREATE SET r.source = $source_doc,
r.method = $extraction_method,
r.confidence = $confidence,
r.asserted_at = timestamp()
Then a retrieval query can return the path and its justification together:
MATCH path = (a:Person {id: $a})-[:WORKS_AT]->(c:Company)
RETURN c.name AS company,
[rel IN relationships(path) | rel.source] AS sources
No separate audit table, no reconstructing the reasoning by hand. "Why do you believe this?" becomes a column.
The mistake behind most slow queries
Worth stating plainly, because it caught me more than once: two unconnected patterns in the same MATCH produce a cartesian product.
-- accidental: every Person paired with every Company
MATCH (p:Person), (c:Company)
RETURN p, c
That's every Person times every Company. Either connect the patterns with a relationship, or split them across WITH boundaries. Most engines will warn you; read the warning.
And always run EXPLAIN or PROFILE before you blame the database. PROFILE shows rows per operator, and the culprit is usually an unindexed label scan sitting at the bottom.
Trying these somewhere
All of the above is standard Cypher, so it runs against any Bolt endpoint. I've been using CognoDB for prototypes because the official Neo4j drivers connect unchanged (Bolt 5.0–5.4, one-line URI change) and the free tier comes up in about a minute with no card — which is the difference between trying a graph idea on a Tuesday evening and not trying it.
Full disclosure: I work on CognoDB. The patterns aren't specific to it, and I'd rather you learn Cypher on whatever endpoint is closest to hand.
What did I miss?
I'm genuinely curious about the ones I haven't internalised yet — especially around modelling time-varying relationships and pruning stale agent memory, which I still don't have a clean pattern for. If you've solved either, I'd like to read it.
More in this series under #cognodb.
Top comments (1)
The provenance-as-edge-property pattern is the one I keep fighting for in my own setups. Once a fact can answer "where did this come from" without a join against an audit table, debugging an agent's wrong answer goes from archaeology to a single query.
On your open question about stale memory: I haven't found a clean in-graph pattern either. What worked for me is boring — an
asserted_atproperty plus a scheduled job that deletes edges below a confidence/time floor, run off-peak so it never races the agents. Curious whether anyone has tried soft-deletion (a TOMBSTONE flag on the edge, filtered in retrieval, compacted later) without the graph degrading into a scan-the-edges problem.