Recommendation Engines with Neo4j Knowledge Graphs: From Cypher to Production
Why Graphs for Recommendations?
Recommendation engines have traditionally been dominated by matrix factorisation (Singular Value Decomposition, Alternating Least Squares) and, more recently, deep learning models (two-tower networks, Graph Neural Networks). But these approaches share a fundamental limitation: they operate on feature vectors, not on the relational structure of the domain.
A knowledge graph preserves that structure. Consider an e-commerce catalogue: a user purchases a laptop, and a traditional recommender finds "users who bought X also bought Y." A graph-based recommender can traverse: user → purchased → laptop → compatible_with → docking_station → reviewed_by → users_who_rated_highly → other_purchases. Three hops, each adding a different signal — purchase history, product compatibility, and community rating — combined into a single traversal.
This article walks through four recommendation strategies you can implement on Neo4j today, from straightforward Cypher queries to production-grade GDS pipelines.
The Product Graph Data Model
All the examples in this article use a common graph schema. Whether you are building for e-commerce, content streaming, or professional networking, the pattern is similar:
| Node Label | Properties | Purpose |
|---|---|---|
:User | userId, name, location, joinedAt | The entity receiving recommendations |
:Product | productId, title, category, price, tags | The item being recommended |
:Category | name, parentCategory | Product taxonomy hierarchy |
:Purchase | timestamp, quantity, amount | Intermediate node for transaction (reification) |
:Review | rating, text, createdAt | User feedback on a product |
:Session | sessionId, startedAt | Anonymous browsing session |
Relationships connect them as follows:
(:User)-[:MADE]->(:Purchase)-[:INCLUDES]->(:Product)
(:User)-[:WROTE]->(:Review)-[:ABOUT]->(:Product)
(:Product)-[:BELONGS_TO]->(:Category)
(:Category)-[:PARENT_OF]->(:Category)
(:Session)-[:VIEWED]->(:Product)
(:User)-[:HAS_SESSION]->(:Session)
This model captures not just what happened but the full context — who, what, when, and how entities relate to each other.
Strategy 1: Collaborative Filtering with Cypher
Collaborative filtering (CF) is the oldest and most reliable recommendation technique. The insight is simple: users who have similar purchase or rating histories will tend to like similar items. In a graph, "similarity" is measured by shared paths, not by vector distance.
User-User Collaborative Filtering
Find users who purchased the same products as the target user, then recommend products those similar users bought:
// User-user collaborative filtering
MATCH (target:User {userId: "alice"})
MATCH (target)-[:MADE]->(:Purchase)-[:INCLUDES]->(product:Product)
MATCH (product)<-[:INCLUDES]-(:Purchase)<-[:MADE]-(peer:User)
WHERE peer <> target
MATCH (peer)-[:MADE]->(:Purchase)-[:INCLUDES]->(recommendation:Product)
WHERE NOT EXISTS {
MATCH (target)-[:MADE]->(:Purchase)-[:INCLUDES]->(recommendation)
}
RETURN recommendation.title AS product,
count(DISTINCT peer) AS shared_customers,
round(avg(
CASE WHEN EXISTS {
MATCH (peer)-[:WROTE]->(:Review)-[:ABOUT]->(recommendation)
// Extract as signal for scoring
}
THEN 1.0 ELSE 0.5 END
), 2) AS avg_peer_affinity
ORDER BY shared_customers DESC, avg_peer_affinity DESC
LIMIT 10
This query:
- Finds what Alice bought.
- Finds peers who bought overlapping products (the Jaccard-style intersection).
- Finds what those peers bought that Alice has not.
- Ranks by number of shared customers and peer review signals.
The beauty of the graph formulation is that it naturally handles sparsity. A traditional matrix with 100,000 users and 50,000 products has 5 billion cells, of which 99.9% are empty. The graph formulation only touches the non-empty cells — the purchases that actually exist.
Item-Item Collaborative Filtering
Item-item CF reverses the traversal: find products that are frequently purchased together:
// Item-item collaborative filtering
MATCH (target:Product {productId: "laptop-pro-16"})
MATCH (target)<-[:INCLUDES]-(:Purchase)<-[:MADE]-(:User)
MATCH (:User)-[:MADE]->(:Purchase)-[:INCLUDES]->(co_occurring:Product)
WHERE co_occurring <> target
RETURN co_occurring.title AS also_bought,
count(*) AS co_occurrence_count,
round(count(*) * 1.0 / (
MATCH (target)<-[:INCLUDES]-(:Purchase)
RETURN count(*)
), 4) AS lift
ORDER BY co_occurrence_count DESC
LIMIT 10
The lift metric normalises co-occurrence by the base popularity of the target product, preventing bestsellers from dominating recommendations. A lift of 3.0 means users who bought the laptop are three times more likely to buy this item than the average user.
| Approach | Strengths | Weaknesses |
|---|---|---|
| User-User CF | Captures diverse tastes, serendipitous discovery | Cold start for new users; expensive at scale |
| Item-Item CF | Stable recommendations, pre-computable | No serendipity; favours popular items |
| Graph formulation | Naturally sparse; includes indirect connections | Variable-length path queries can be expensive |
Strategy 2: Content-Based Filtering via Graph Traversal
Collaborative filtering needs other users' behaviour. Content-based filtering uses the attributes of items the user already likes. A graph makes this traversal elegant because attributes are nodes, not strings.
// Content-based recommendations via category and tag hierarchy
MATCH (target:User {userId: "alice"})
MATCH (target)-[:MADE]->(:Purchase)-[:INCLUDES]->(bought:Product)
MATCH (bought)-[:BELONGS_TO]->(cat:Category)
// Walk up the category tree to find broader interests
MATCH (cat)-[:PARENT_OF*0..2]->(subcategory:Category)
// Find products in those categories with high ratings
MATCH (candidate:Product)-[:BELONGS_TO]->(subcategory)
WHERE NOT EXISTS {
MATCH (target)-[:MADE]->(:Purchase)-[:INCLUDES]->(candidate)
}
// Factor in review quality
OPTIONAL MATCH (candidate)<-[:ABOUT]-(review:Review)
RETURN candidate.title AS product,
subcategory.name AS category,
count(DISTINCT review) AS review_count,
coalesce(round(avg(review.rating), 1), 0.0) AS avg_rating,
avg(review.rating) * log(count(DISTINCT review) + 1) AS score
ORDER BY score DESC
LIMIT 10
This query traverses: user → purchased → product → category → parent_category (up to 2 levels), then finds products in those subcategories the user has not purchased, ranked by a Bayesian-adjusted average rating.
The advantage over a SQL-based content filter is that the graph captures multi-hop category affinity. If Alice buys a laptop (in "Computers"), the query also finds products in "Computer Accessories" and "Peripherals" — subcategories she might not have browsed yet.
Strategy 3: Hybrid Recommendations with GDS
Pure collaborative filtering fails on cold-start items (no purchase history). Pure content-based filtering cannot capture taste similarity. A hybrid approach combines both.
The Neo4j Graph Data Science (GDS) library provides the Node Similarity algorithm, which computes pairwise Jaccard similarity between nodes based on shared neighbours. This is ideal for hybrid recommendations.
from neo4j import GraphDatabase
from neo4j_gds import GraphDataScience
gds = GraphDataScience("bolt://localhost:7687", auth=("neo4j", "password"))
# Step 1: Project a graph where Users connect to Products via Purchase
G = gds.graph.project(
"recommendation-graph",
["User", "Product"],
{"MADE": {"orientation": "REVERSE"},
"INCLUDES": {"orientation": "NATURAL"}}
)
# Step 2: Compute user-product Node Similarity
# This creates a bipartite projection where similar users share products
result = gds.nodeSimilarity.write(
G,
writeRelationshipType="SIMILAR_TO",
writeProperty="similarity",
topK=20,
similarityCutoff=0.1
)
print(f"Computed {result['relationshipsWritten']} similarity relationships")
# Step 3: Recommend via the pre-computed similarity graph
query = """
MATCH (target:User {userId: 'alice'})-[:SIMILAR_TO]->(similar:User)
MATCH (similar)-[:MADE]->(:Purchase)-[:INCLUDES]->(product:Product)
WHERE NOT EXISTS {
MATCH (target)-[:MADE]->(:Purchase)-[:INCLUDES]->(product)
}
RETURN product.title AS product,
count(DISTINCT similar) AS similar_users,
sum(similar.similarity) AS total_similarity_score
ORDER BY total_similarity_score DESC
LIMIT 10
"""
The topK=20 parameter limits each user to 20 similar peers, bounding the downstream query. The similarityCutoff prevents noisy near-zero matches from polluting recommendations.
GDS Pipeline for Batch Recommendations
In production, you should schedule the similarity computation and write results back to the graph periodically:
from neo4j_gds import Pipeline
pipeline = Pipeline("hybrid-recommendation")
pipeline.addNodeProperty("degree", mutateProperty="user_degree",
contextNodeLabels=["User"])
pipeline.addNodeProperty("degree", mutateProperty="product_degree",
contextNodeLabels=["Product"])
pipeline.addRelationshipProperty("l2", mutateProperty="rating_weighted",
contextRelationshipTypes=["INCLUDES"])
pipeline.addNodeSimilarity(
writeRelationshipType="SIMILAR_TO",
writeProperty="similarity_score",
topK=20
)
result = pipeline.run(
G,
writeBatchSize=10000
)
print(f"Pipeline completed: {result['pipeline']}")
Strategy 4: Real-Time Session-Based Recommendations
Not all recommendations need historical data. Session-based recommenders work on anonymous browsing behaviour — what the user is looking at right now.
// Real-time session-based recommendations
MATCH (session:Session {sessionId: "sess_abc123"})
MATCH (session)-[:VIEWED]->(viewed:Product)
// Find products viewed in the same session or by others viewing the same products
MATCH (viewed)<-[:VIEWED]-(other_session:Session)
MATCH (other_session)-[:VIEWED]->(candidate:Product)
WHERE candidate <> viewed
AND NOT EXISTS {
MATCH (session)-[:VIEWED]->(candidate)
}
RETURN candidate.title AS product,
count(DISTINCT other_session) ALSO_VIEWED,
avg(
CASE WHEN other_session = session THEN 2.0 ELSE 1.0 END
) AS relevance_weight
ORDER BY relevance_weight * also_viewed DESC
LIMIT 5
This is the "Customers Also Viewed" widget at graph speed. The relevance_weight biases toward items viewed in the same session (strong signal) while still including items from other sessions that viewed the same products (weak but useful signal).
| Strategy | Latency | Data Required | Cold Start | Best For |
|---|---|---|---|---|
| User-User CF | Seconds (live) | Purchase history | Poor | Established users |
| Item-Item CF | Milliseconds (pre-computed) | Co-occurrence | Good for items | "Frequently bought together" |
| Content-based | Milliseconds (indexed) | Product attributes | Excellent | New products, new users |
| GDS Node Similarity | Batch (pre-computed) | Full purchase graph | Poor | Large-scale hybrid |
| Session-based | Milliseconds (live) | Current session only | Excellent | Anonymous browsing |
Production Architecture
A production recommendation engine on Neo4j typically has three tiers:
┌─────────────────────────────────────────────────────┐
│ Orchestration │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ Batch Layer │ │ Near-Realtime│ │ Real-Time │ │
│ │ GDS jobs │ │ CDC ingest │ │ Cypher API │ │
│ │ Similarity │ │ Purchase │ │ Session │ │
│ │ Pre-compute │ │ updates │ │ queries │ │
│ └──────┬───────┘ └──────┬───────┘ └─────┬──────┘ │
│ └─────────────────┼─────────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Neo4j DB │ │
│ │ (graph + │ │
│ │ cypher) │ │
│ └──────┬───────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ API Layer │ │
│ │ (REST/gRPC) │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────┘
Batch layer: GDS Node Similarity, PageRank (for popularity-weighted recommendations), and community detection run on a schedule (daily or hourly) and write results back to the graph as relationships and properties.
Near-realtime layer: Purchase events from Kafka or a CDC stream update the graph incrementally. New purchases create MADE → Purchase → INCLUDES chains and trigger targeted recomputation for affected users.
Real-time layer: Session-based queries and simple Cypher lookups run on each page load, returning recommendations in under 200ms.
Measuring Recommendation Quality
Graph-based recommenders introduce evaluation challenges that matrix-based recommenders do not face. The diversity of traversal paths means precision@k alone is insufficient.
| Metric | What It Measures | Graph Advantage |
|---|---|---|
| Precision@k | Fraction of relevant recommendations | Graph paths encode richer relevance signals |
| Recall@k | Coverage of relevant items | Graph traversal finds items matrix methods miss |
| Diversity | Number of distinct categories recommended | Graph's category hierarchy enables explicit diversity constraints |
| Serendipity | Unexpected but relevant recommendations | Long-tail traversal paths surface non-obvious items |
| Coverage | Fraction of catalogue recommended | Graph structure prevents popularity bias |
To enforce diversity, add a category constraint to any recommendation query:
MATCH (target:User {userId: "alice"})
// ... recommendation logic ...
WITH collect(DISTINCT candidate) AS candidates
// Ensure at most 2 recommendations from any category
UNWIND candidates AS product
MATCH (product)-[:BELONGS_TO]->(cat:Category)
WITH cat, collect(product) AS category_products
UNWIND category_products[0..2] AS diverse_product
RETURN diverse_product.title AS product,
cat.name AS category
This constraint prevents the classic "10 recommendations, all of them phone cases" problem.
Putting It All Together
The recommendation strategy you choose depends on your data maturity:
- Day one — no purchase history: Session-based recommendations with content-based fallback. Only needs the product catalogue and browsing data.
- Day 30 — some purchase data: Add item-item CF for "frequently bought together" widgets. Pre-compute co-occurrence counts.
- Day 90 — enough user behaviour: Introduce user-user CF and GDS Node Similarity. Run as a nightly batch job.
- Day 365 — mature platform: Full hybrid pipeline with GDS, session-based real-time, and diversity constraints.
Each stage builds on the same graph schema. You never need to export data to a separate recommendation engine — the knowledge graph is the recommendation engine. The relationships you model for your domain (purchases, categories, reviews) are the same relationships your recommender traverses. That coherence is the graph's killer advantage.
For more on the foundational patterns used here, see Graph Data Modeling Patterns (the intermediate node pattern for purchases) and Graph Algorithms in Production with Neo4j GDS (Node Similarity and PageRank in GDS pipelines).