+1 (415) 649-9454

Building a Recommendation Engine with Neo4j GDS: Node Similarity, FastRP, and kNN

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:

ParameterWhat it doesSensible start
degreeCutoffignores nodes with fewer than N relationships3 — customers with one purchase carry no signal
similarityCutoffdrops weak pairs0.1, then tune upward
topKkeeps only the N best matches per node10

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:

  1. Pick a cut-off date. Project only purchases before it.
  2. Run the pipeline and write recommendations.
  3. For each customer, check how many of their top 10 recommendations appear in their purchases after the cut-off. That is precision@10.
  4. 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_TO layer 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.id is backed by a constraint, and if SIMILAR_TO is capped at topK. 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 stats and 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 EXISTS filter 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.