DEV Community

Manoj Kumar Gogula
Manoj Kumar Gogula

Posted on

Five Cypher patterns I keep reaching for when building agent memory

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

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_at property 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.