Building Graph-Based Recommendation Systems with Neo4j
Why Graphs for Recommendations?
Most recommendation systems start with a matrix: users as rows, items as columns, and interaction signals (purchases, views, ratings) as cell values. Matrix factorisation and nearest-neighbour search on this matrix power the recommenders that drive Amazon, Netflix, and Spotify. But the matrix model has a blind spot: it treats every interaction as an isolated event between two entities, when in reality every purchase happens in a web of context — the time of day, the device used, the promotional campaign active, the other items in the basket, the user's location.
Graph databases do not require you to flatten this context into a feature vector. You model the relationships as they are: a user CHECKED_OUT a basket that CONTAINS items, during a SESSION that started on a MOBILE device, while a PROMOTION was active. This richer structure enables recommendation strategies that are impossible — or prohibitively expensive — in the matrix model.
This article walks through four recommendation strategies you can implement with Neo4j, from simple collaborative filtering to hybrid production architectures, with Cypher code you can adapt to your domain.
Data Model: The Foundation
Every recommendation strategy depends on your graph schema. Here is a minimal but production-proven model:
// Users and their profiles
(:User {id: "u1", name: "Alice", region: "EU", joined: date("2024-03-01")})
// Items with attributes
(:Item {id: "i42", title: "Wireless Headphones", category: "Electronics", price: 89.99})
// Interactions — the critical signal
(:User)-[:PURCHASED {timestamp: datetime(), quantity: 1, channel: "web"}]->(:Item)
(:User)-[:VIEWED {timestamp: datetime(), duration_seconds: 45}]->(:Item)
(:User)-[:RATED {score: 5, timestamp: datetime()}]->(:Item)
Promoting interactions to typed, property-bearing relationships — rather than a single generic INTERACTED_WITH edge — is the key design decision. It lets you weight signals differently: a purchase is worth more than a view, a five-star rating counts more than a click.
// Create interaction with channel context
MATCH (u:User {id: "u1"})
MATCH (i:Item {id: "i42"})
CREATE (u)-[:PURCHASED {
timestamp: datetime("2026-06-15T14:30:00Z"),
quantity: 1,
channel: "web"
}]->(i)
Strategy 1: Collaborative Filtering via Graph Traversal
The classic "users who bought X also bought Y" pattern is a two-hop traversal:
MATCH (target:Item {id: "i42"})<-[:PURCHASED]-(user:User)-[:PURCHASED]->(recommendation:Item)
WHERE recommendation.id <> "i42"
WITH recommendation, count(DISTINCT user) AS co_purchase_count
RETURN recommendation.title AS item,
recommendation.category AS category,
co_purchase_count AS also_bought
ORDER BY co_purchase_count DESC
LIMIT 10
This query finds every user who purchased the target item, then collects the other items those users purchased. The count(DISTINCT user) is the co-purchase strength — the number of shared customers between two items.
Weighted Collaborative Filtering
Real systems weight interactions. A purchase in the last week is more predictive than one from last year. You can incorporate temporal decay directly in Cypher:
MATCH (target:Item {id: "i42"})<-[p1:PURCHASED]-(user:User)-[p2:PURCHASED]->(rec:Item)
WHERE rec.id <> "i42"
WITH rec,
sum(
(1.0 / (1.0 + duration.inDays(p1.timestamp, datetime()).days / 30.0)) *
(1.0 / (1.0 + duration.inDays(p2.timestamp, datetime()).days / 30.0))
) AS weighted_score
RETURN rec.title, weighted_score
ORDER BY weighted_score DESC
LIMIT 10
The weight decays as a sigmoid-like function of recency: interactions within the last 30 days contribute near-full weight; interactions older than a year contribute negligible weight.
| Weighting Strategy | Decay Function | Best For |
|---|---|---|
| None (binary) | 1 if purchased | Stable catalog, infrequent purchases |
| Recency-weighted | 1 / (1 + days/30) | Fashion, news, seasonal goods |
| Frequency-weighted | log(1 + count) | Repeat purchases (groceries, consumables) |
| Revenue-weighted | amount / avg(amount) | High-value item discovery |
Strategy 2: Content-Based Filtering with Category Traversal
When a user has few interactions (the cold-start problem), collaborative filtering produces sparse results. Content-based filtering fills the gap by recommending items similar to what the user already likes, based on item attributes.
// Find items in categories the user has purchased from
MATCH (u:User {id: "u1"})-[p:PURCHASED]->(purchased:Item)
WITH u, collect(DISTINCT purchased.category) AS categories,
avg(p.price) AS price_range_upper
// Recommend items in same categories, unpurchased
MATCH (rec:Item)
WHERE rec.category IN categories
AND NOT EXISTS {
MATCH (u)-[:PURCHASED]->(rec)
}
AND rec.price <= price_range_upper * 1.5
RETURN rec.title, rec.category, rec.price
ORDER BY rec.price ASC
LIMIT 10
Attribute Propagation for Deeper Similarity
For richer content similarity, model item attributes as connected nodes:
// Items sharing tags with the user's purchased items
MATCH (u:User {id: "u1"})-[:PURCHASED]->(:Item)-[:TAGGED_AS]->(tag:Tag)
MATCH (rec:Item)-[:TAGGED_AS]->(tag)
WHERE NOT EXISTS { MATCH (u)-[:PURCHASED]->(rec) }
WITH rec, count(DISTINCT tag) AS shared_tags
ORDER BY shared_tags DESC
LIMIT 10
RETURN rec.title, shared_tags
This tag-propagation approach handles nuanced similarity that category matching misses. Two items in "Electronics" might be very different (a laptop vs a USB cable), but items sharing five tags (wireless, audio, bluetooth, black, sony) are almost certainly similar.
Strategy 3: Hybrid Recommendation with Weighted Scoring
Production recommenders blend multiple signals. A hybrid score combines collaborative filtering, content similarity, and recency into a single ranking:
MATCH (u:User {id: "u1"})
MATCH (rec:Item)
// Collaborative signal: co-purchase strength
OPTIONAL MATCH (rec)<-[:PURCHASED]-(:User)-[:PURCHASED]->(other:Item)
WHERE EXISTS { MATCH (u)-[:PURCHASED]->(other) }
WITH u, rec, count(DISTINCT other) AS collab_score
// Content signal: shared categories
OPTIONAL MATCH (u)-[:PURCHASED]->(p:Item)
WHERE p.category = rec.category
WITH u, rec, collab_score, count(DISTINCT p) AS category_score
// Recency signal: how new is the item
WITH rec,
collab_score * 0.5 AS collab_weight,
category_score * 0.3 AS content_weight,
(1.0 / (1.0 + duration.inDays(rec.release_date, datetime()).days / 90.0)) * 0.2 AS recency_weight
RETURN rec.title,
collab_weight + content_weight + recency_weight AS hybrid_score
ORDER BY hybrid_score DESC
LIMIT 15
The weights (0.5, 0.3, 0.2) are hyperparameters. In practice you tune these with A/B testing or Bayesian optimisation against a business metric — conversion rate, watch time, or purchase completion.
| Signal | Weight | Data Source | Update Frequency |
|---|---|---|---|
| Collaborative filtering | 0.5 | PURCHASED relationships | Hourly |
| Content similarity | 0.3 | Category + tag overlap | Daily |
| Recency | 0.2 | Item release date | Continuous |
| Personalisation | 0.4 | User's own purchase history | Real-time |
Note: weights in a production system typically sum to more than 1.0 when normalised across model ensembles — the table above shows relative proportions within a single catalogue pass.
Strategy 4: Real-Time Personalisation with Graph Projections
For sub-second recommendation at request time, pre-compute graph projections using the Neo4j GDS library and write similarity scores back as relationships:
// Create a co-purchase graph projection
CALL gds.graph.project(
'co-purchase',
['User', 'Item'],
{
PURCHASED: { orientation: 'UNDIRECTED' }
}
);
// Run Node Similarity to find item-item affinities
CALL gds.nodeSimilarity.write('co-purchase', {
writeRelationshipType: 'SIMILAR_TO',
writeProperty: 'score',
topK: 20
});
Once written, a recommendation query becomes a single-hop lookup:
MATCH (target:Item {id: "i42"})-[s:SIMILAR_TO]->(rec:Item)
RETURN rec.title, s.score
ORDER BY s.score DESC
LIMIT 10
This query returns in single-digit milliseconds on graphs with millions of nodes. The trade-off is staleness — you need to refresh the projection and re-run similarity on a schedule.
Cold Start: The Graph Advantage
Cold start — recommending to new users or new items — is the hardest problem in recommendation. Graphs offer two strategies that matrices cannot:
User cold start: Use the user's registration attributes to find similar existing users. A new user who registered via a referral link, selected "developer tools" as their interest, and joined from an EU IP address can be mapped to a cohort of existing users with the same profile. Their recommendations become the cohort's popular items:
MATCH (new:User {id: "u_new"})
MATCH (existing:User)
WHERE existing.region = new.region
AND existing.joined < new.joined
MATCH (existing)-[:PURCHASED]->(item:Item)
WHERE NOT EXISTS { MATCH (new)-[:PURCHASED]->(item) }
RETURN item.title, count(DISTINCT existing) AS cohort_popularity
ORDER BY cohort_popularity DESC
LIMIT 10
Item cold start: Leverage the item's metadata graph — its category, tags, brand, and supplier — to find structurally similar items, even before any user interacts with it. The graph context of the item (what shelf it would sit on, what ecosystem it belongs to) is available at ingestion time.
Production Architecture
A graph-powered recommendation system in production spans three tiers:
| Layer | Technology | Responsibility |
|---|---|---|
| Online | Neo4j (indexed lookups) | Serve recommendations via single-hop traversals or pre-written SIMILAR_TO edges |
| Nearline | Neo4j + GDS (streaming) | Refresh item similarity, community detection, and PageRank scores every hour |
| Offline | GDS + external ML | Train embeddings, matrix factorisation, or graph neural network models on full graph snapshots |
The pattern mirrors the fraud detection architecture covered in Fraud Detection with Graph Databases: fast Cypher queries for real-time serving, GDS algorithms for nearline batch scoring, and external ML for the deepest offline analysis.
For personalisation at scale, consider writing user-specific recommendation lists as relationships:
CALL gds.pageRank.stream('user-item-graph', {
sourceNodes: [userNode],
maxIterations: 20
})
YIELD nodeId, score
MATCH (rec:Item) WHERE elementId(rec) = nodeId
RETURN rec.title, score
ORDER BY score DESC
LIMIT 10
This personalises PageRank by using the user as the source node — items reachable via short paths from the user receive higher scores, capturing both collaborative and content signals in a single graph algorithm.
Choosing Your Strategy
| Strategy | Cold Start | Diversity | Latency | Implementation Effort |
|---|---|---|---|---|
| Collaborative traversal | Poor | Low | Fast | Low |
| Content-based | Good | Medium | Fast | Medium |
| Hybrid weighted | Medium | High | Medium | High |
| GDS similarity projection | Medium | Low | Fastest (pre-computed) | Medium |
| PageRank personalisation | Poor | High | Medium | High |
There is no universal best. Start with collaborative traversal — it is three lines of Cypher and will outperform a random baseline by a wide margin. Add content-based signals for cold start coverage. Graduate to hybrid scoring and GDS projections as your catalogue and user base grow. The graph model does not require you to choose upfront — you add edges, not migrate schemas.
Further Reading
- Fraud Detection with Graph Databases — Production graph patterns for real-time scoring, using the same architectural tiers
- Community Detection Algorithms — Louvain, Leiden, and Label Propagation for user segment discovery
- Graph Algorithms with Neo4j GDS — PageRank, Node Similarity, and centrality for recommendation features
- Graph Data Modeling with Neo4j — Schema design patterns that make recommendation queries efficient
- Huang, Y. et al. Graph-Based Recommendation System. In: Proceedings of the 16th ACM Conference on Recommender Systems (RecSys 2022). — Comprehensive survey of graph techniques in recommendation.
- Neo4j. Building a Recommendation Engine with Neo4j. GraphAcademy. https://neo4j.com/graphacademy/training-recommendations/