DEV Community

Cover image for Disarming the "Join Bomb": Re-Engineering Collaborative Filtering on Neo4j
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Disarming the "Join Bomb": Re-Engineering Collaborative Filtering on Neo4j

If you are building a recommendation engine in a graph database, there is one critical juncture where your seemingly innocent query suddenly grinds to a halt. In relational SQL, we call it the N+1 problem or Cartesian Explosions. In Neo4j, it's an unoptimized biderectional traversal in a highly dense graph—what I like to call the "Join Bomb".

To explore the mechanics of this performance bottleneck and how to eliminate it, I built a local Neo4j Performance Lab—a Streamlit application that pits a "Naive" Cypher query against an "Optimized" APOC-driven query on a massive synthetic dataset.

The Architecture

Before jumping into the queries, let's look at what we're working with:

The Architecture

We generate a graph consisting of Users, Products, and Categories. To demonstrate the problem accurately, we seed 1,000 Users and 5,000 Products but forcefully generate 100,000+ BOUGHT relationships. This high density is designed to trap our unoptimized queries in exponentially growing traversal paths.

The Problem: The Naive Traversal

In collaborative filtering, the standard question is: "What products in Category X should we recommend based on what similar users bought?"

The intuitive, naive way to write this in Cypher is a direct traversal:

MATCH (target:User {id: $user_id})-[:BOUGHT]->(item:Product)<-[:BOUGHT]-(peer:User)
MATCH (peer)-[r:BOUGHT]->(reco:Product)-[:BELONGS_TO]->(c:Category)
WHERE c.name = $category AND reco.price < $max_price AND reco <> item
RETURN reco.name, count(*) as frequency
ORDER BY frequency DESC
LIMIT 10
Enter fullscreen mode Exit fullscreen mode

Why does this fail at scale?

Neo4j processes matching patterns left-to-right. In a massive graph:

  1. It expands from the User to their items (10s of records).
  2. It expands backwards from those items to everyone who bought them (10,000s of paths).
  3. It expands forwards from every peer to everything they bought (Millions of paths).
  4. Only after traversing millions of edges does it evaluate the WHERE clause to filter out the wrong categories and prices.

This results in a NodeByLabelScan or massive Expand(All) operators that inflate your total Database Hits astronomically.

The Solution: Indexing and APOC Intersections

To solve this we must invert the traversal and minimize path expansions by using APOC Collections and early index filtering.

// Step 1: O(1) collection of what our target user owns
MATCH (u:User {id: $user_id})-[:BOUGHT]->(p:Product)
WITH u, collect(p.id) as user_products

// Step 2: Use an explicit NodeIndexSeek to start small
MATCH (c:Category {name: $category}) USING INDEX c:Category(name)
MATCH (reco:Product)-[:BELONGS_TO]->(c)

// Step 3: Fast Relationship Filtering earlier in the pipeline
MATCH (peer:User)-[r2:BOUGHT]->(reco)
WHERE r2.price_at_purchase < $max_price

// Step 4: Intersect natively using APOC without expanding the graph geometry
MATCH (peer)-[:BOUGHT]->(peer_p:Product)
WITH user_products, peer, reco, collect(peer_p.id) as peer_products
WITH user_products, peer, reco, peer_products, apoc.coll.intersection(user_products, peer_products) as shared_items
WHERE size(shared_items) > 0 AND NOT reco.id IN user_products

RETURN reco.name, count(peer) as score
ORDER BY score DESC LIMIT 10
Enter fullscreen mode Exit fullscreen mode

The Performance Delta

When measured in the Streamlit lab, the performance metrics shift drastically:

  • Naive Query: ~4,500+ DB hits, >120ms total execution time.
  • Optimized Query: DB hits plummet, execution time drops massively.

Instead of scanning all users, we perform a NodeIndexSeek on the exact category. We apply the price filter strictly on the relationship property price_at_purchase before expanding any further.

Most importantly, we avoid the bidirectional Join Bomb. Instead of matching paths back to shared products, we use apoc.coll.intersection(). Calculating overlap in local, in-memory arrays circumvents traversing thousands of node-relationships recursively in the query planner.

Enter Local AI Explainability

Because debugging query metadata is notoriously dry, I hooked the lab up to Ollama running llama3.2 locally. By extracting the tree from Neo4j's .profile data, the Streamlit app asks the local LLM to explain why the execution was fast or slow. The LLM accurately identifies NodeByLabelScan vs Filter operator placements, transforming the app into a fantastic interview or presentation tool.

If you are dealing with graph scale, stop writing naive traversals! Build pipelines that respect the planner.

Example Output

Code is available on my Github: https://github.com/harishkotra/realtime-recommendation-engine

Top comments (0)