Every graph project reaches the point where someone asks for "people who bought this also bought that". You can write that as a two-hop Cypher query in about a minute — and it will be fine until the day a handful of blockbuster products connect to half the customer base and the query melts. Neo4j Graph Data Science (GDS) exists for exactly this transition: move the heavy similarity computation into an in-memory graph projection, compute relationships once, write them back, and let your application serve recommendations with a single-hop lookup.
This tutorial builds a working recommendation pipeline end to end: project the graph, compute similarity two different ways (exact Jaccard and approximate embeddings + kNN), write the results back, serve them, and measure whether they are any good. Our fraud detection post used GDS to produce features for a model; here GDS is the model.
Prerequisites
- Neo4j 2025.x or 2026.x with the Graph Data Science plugin installed (Aura Professional and above include it; the Docker setup can mount it via
NEO4J_PLUGINS='["graph-data-science"]') - A user with the
gds.*procedures allowed - Enough heap to hold the projection — we size that in step 2
Check what you have:
RETURN gds.version() AS gds, gds.license() AS license;
Step 1 — the data model
We use the smallest useful retail model. Two labels, one relationship, one property that matters:
CREATE CONSTRAINT customer_id IF NOT EXISTS
FOR (c:Customer) REQUIRE c.id IS UNIQUE;
CREATE CONSTRAINT product_id IF NOT EXISTS
FOR (p:Product) REQUIRE p.id IS UNIQUE;
// (:Customer)-[:PURCHASED {quantity, at}]->(:Product)
If you are loading a sample, any bipartite purchase/rating/watch dataset works — the pipeline below does not care whether the edge means "bought", "watched" or "clicked", only that it connects two node types.
One modelling note before you start: recommendations live on the relationships, not on properties. If your current schema stores purchases as a JSON array on the customer node, fix that first. GDS can only traverse what is a real relationship.
Step 2 — project the graph (and size it first)
GDS runs on an in-memory projection, not on the stored graph. Since GDS 2.4 the recommended way to build one is the Cypher projection via gds.graph.project used as a function inside a query — the old "native projection" syntax with node/relationship maps is deprecated.
Estimate memory before you allocate it:
MATCH (c:Customer)-[r:PURCHASED]->(p:Product)
RETURN gds.graph.project.estimate(
count(DISTINCT c) + count(DISTINCT p),
count(r),
{ readConcurrency: 4 }
) AS estimate;
If the estimate is close to your available heap, stop and add heap — a projection that spills will take the database down with it. Then project:
MATCH (c:Customer)-[:PURCHASED]->(p:Product)
WITH gds.graph.project(
'purchases',
c,
p,
{
sourceNodeLabels: labels(c),
targetNodeLabels: labels(p),
relationshipType: 'PURCHASED'
},
{ undirectedRelationshipTypes: ['PURCHASED'] }
) AS g
RETURN g.graphName AS graph, g.nodeCount AS nodes, g.relationshipCount AS rels;
Two details that people get wrong here:
- Undirected. Similarity algorithms need to walk customer → product → customer. If you project the relationship as directed only, Node Similarity will return nothing and you will spend an afternoon wondering why.
- The projection is a snapshot. It does not update when the database changes. Recompute it on a schedule (nightly is typical for retail) or in response to a CDC stream if you need it fresher.
Step 3 — exact similarity with Node Similarity
gds.nodeSimilarity computes Jaccard similarity between nodes that share neighbours. Always run stats first — it costs one pass and tells you whether your thresholds are sane before you write anything:
CALL gds.nodeSimilarity.stats('purchases', {
similarityCutoff: 0.1,
degreeCutoff: 3,
topK: 10
})
YIELD nodesCompared, similarityPairs, similarityDistribution
RETURN nodesCompared, similarityPairs, similarityDistribution.mean AS meanScore;
The three knobs, in order of impact:
| Parameter | What it does | Sensible start |
|---|---|---|
degreeCutoff | ignores nodes with fewer than N relationships | 3 — customers with one purchase carry no signal |
similarityCutoff | drops weak pairs | 0.1, then tune upward |
topK | keeps only the N best matches per node | 10 |
topK is the one that protects you. Without it, a dense catalogue produces a quadratic blow-up of similarity pairs and the write phase takes hours.
When the stats look reasonable, mutate rather than write — that keeps the result in the projection so later steps can use it, and you decide once at the end what gets persisted:
CALL gds.nodeSimilarity.mutate('purchases', {
similarityCutoff: 0.1,
degreeCutoff: 3,
topK: 10,
mutateRelationshipType: 'SIMILAR_TO',
mutateProperty: 'score'
})
YIELD relationshipsWritten;
Then persist just that layer back to the database:
CALL gds.graph.relationshipProperties.write(
'purchases', 'SIMILAR_TO', ['score']
);
Step 4 — when exact similarity is too slow: FastRP + kNN
Node Similarity compares neighbour sets, so cost grows with the density of the graph. Past a few million relationships the usual answer is to embed the nodes and do an approximate nearest-neighbour search instead.
CALL gds.fastRP.mutate('purchases', {
embeddingDimension: 256,
iterationWeights: [0.0, 1.0, 1.0],
randomSeed: 42,
mutateProperty: 'embedding'
});
CALL gds.knn.mutate('purchases', {
nodeProperties: { embedding: 'COSINE' },
topK: 10,
sampleRate: 0.5,
deltaThreshold: 0.001,
mutateRelationshipType: 'SIMILAR_EMB',
mutateProperty: 'score'
})
YIELD relationshipsWritten, similarityDistribution;
randomSeed matters more than it looks: without it, two runs produce different embeddings and your recommendations shuffle for no reason a stakeholder can understand. Note that a seed only guarantees reproducibility at concurrency: 1.
FastRP is fast and cheap, but it is structural — it knows nothing about price, category or recency. Blend those in at serving time (step 5) rather than trying to force them into the embedding.
Step 5 — serve the recommendations
The whole point of the pipeline is that the online query is now trivial: one hop to similar customers, one hop to their products, minus what the customer already has.
MATCH (me:Customer {id: $customerId})-[s:SIMILAR_TO]->(peer:Customer)
MATCH (peer)-[:PURCHASED]->(p:Product)
WHERE NOT EXISTS { (me)-[:PURCHASED]->(p) }
AND p.inStock = true
RETURN p.id AS product,
p.name AS name,
sum(s.score) AS score,
count(DISTINCT peer) AS support
ORDER BY score DESC, support DESC
LIMIT 10;
sum(s.score) is a deliberate choice: a product recommended by three moderately similar peers usually beats one recommended by a single very similar peer. support gives your front end something to threshold on so you never show a recommendation backed by one person.
Business rules — stock, region, margin, age rating — belong in this query, not in the algorithm. That separation is what lets merchandising change the rules without a recompute.
Step 6 — measure it before you ship it
Skipping evaluation is the most common failure we see in GDS engagements. A minimal hold-out test:
- Pick a cut-off date. Project only purchases before it.
- Run the pipeline and write recommendations.
- For each customer, check how many of their top 10 recommendations appear in their purchases after the cut-off. That is precision@10.
- Compare against a "most popular items" baseline.
If you cannot beat popularity, the graph is not adding value yet — usually because degreeCutoff is too low, the interaction data is too sparse, or the edge you chose (clicks?) is too noisy. Fix the input, not the algorithm.
Also track coverage: the share of the catalogue that ever appears in anyone's top 10. A recommender with 95% precision that only ever suggests forty products is a merchandising problem, not a win.
Step 7 — run it in production without hurting the database
- Drop the projection when you are done.
CALL gds.graph.drop('purchases')— projections hold heap until the database restarts otherwise. - Isolate the workload. GDS competes with transactional queries for heap and CPU. Run it on a dedicated instance, a read replica sized for analytics, or a serverless GDS Session / Aura Graph Analytics so the OLTP cluster never feels it.
- Make the job idempotent. Delete the previous
SIMILAR_TOlayer before writing the new one, in batches (CALL { ... } IN TRANSACTIONS OF 10000 ROWS), or you will silently accumulate stale scores. - Index the serving path. The recommendation query above is only fast if
Customer.idis backed by a constraint, and ifSIMILAR_TOis capped attopK. Confirm with a PROFILE before launch. - Alert on shape, not just success. Relationship count written, mean similarity score, and coverage per run. A silent collapse to near-zero similarity pairs after an upstream data change is the failure that reaches customers first.
Common pitfalls
- Projecting the purchase relationship as directed — no similarity results at all.
- No
degreeCutoff, so one-purchase customers dominate the pair count. - Writing before running
statsand discovering the write phase produces 400 million relationships. - Treating FastRP embeddings as stable across runs without
randomSeed. - Recommending items the customer already owns, because the
NOT EXISTSfilter was left for "later".
A graph-based recommender is one of the fastest ways to prove Neo4j's value inside an organisation: the data is usually already there, the pipeline is a day's work, and the result is measurable. Getting it to survive contact with production traffic — sizing, isolation, scheduling, and evaluation — is where teams stall. If you are building one, or you have a GDS job that has quietly become the slowest thing in your estate, get in touch and we will walk through it with you.