GraphRAG vs Neo4j: product recommendations that blend co-purchase and semantics
This is a spoke of a 5-part GraphRAG cluster. It applies the core pattern to cross-sell recommendations; the hub teaches the pattern itself — GraphRAG vs Neo4j: a knowledge base that answers multi-hop questions. Sibling spokes: fraud-ring detection, supply-chain impact analysis, and customer-360 Q&A. For the architecture argument in depth, read the pillar: GraphRAG vs Neo4j.
Objective
"People who bought X also bought Y" is the workhorse of e-commerce cross-sell, and graph traversal does it beautifully — walk from a product to its buyers, then to everything they bought. But co-purchase recommendations have a fatal blind spot: cold start. A SKU you added this morning has zero purchase history, so the co-purchase query returns nothing. Your newest, highest-margin products get the worst recommendations at the exact moment you most want to sell them.
GraphRAG fixes this by adding a semantic channel. Give every product an
embedding of its name and description, and [:SIMILAR_TO > 0.8] finds products
that are conceptually alike even with no shared buyers. The textbook way to build
that blend is a four-system stack: Neo4j for co-purchase traversal, a vector
database (Pinecone / Weaviate / pgvector) for embeddings, an orchestration
layer (LangChain / LlamaIndex) to intersect and re-rank the two lists, and an
LLM API for a cross-sell rationale. Four services and a sync job.
SynapCores collapses that into one query on one engine: products carry their own
embedding property, [:SIMILAR_TO > 0.8] is a semantic hop inside Cypher (HNSW
under the hood), and llm_score(prompt, node) grades cross-sell fit inline. This
recipe builds a small product catalog with real buyers, recommends cross-sells for
a viewed product — including a brand-new SKU nobody has bought yet — then shows
you exactly what the Neo4j stack version would take.
Step 1: Build the recommendation graph
Products carry an embedding and are linked to the customers who bought them.
Embedding values here are 5-dimensional demo vectors so the recipe is fast and
deterministic; in production you generate them with EMBED('product name + description')
(384-dim by default). Products in the same category point the same direction, so
SIMILAR_TO groups them; different categories point elsewhere. Note SKU-102,
a brand-new pour-over kettle with no BOUGHT edges — that is our cold-start
test.
// Products — each with a semantic embedding of its name + description
// Cluster A (coffee gear): direction ~ [0.80, 0.10, 0.05, -0.05, 0.20]
MERGE (p1:RecProduct {sku: "SKU-100", name: "AeroPress Go Coffee Maker", price: 39.95,
embedding: [0.80, 0.10, 0.05, -0.05, 0.20]})
MERGE (p2:RecProduct {sku: "SKU-101", name: "Baratza Encore Burr Grinder", price: 149.00,
embedding: [0.81, 0.11, 0.04, -0.06, 0.21]})
MERGE (p3:RecProduct {sku: "SKU-102", name: "Fellow Stagg Gooseneck Pour-Over Kettle", price: 119.00,
embedding: [0.79, 0.09, 0.06, -0.04, 0.19]}) // BRAND NEW — no purchases
// Cluster B (yoga / fitness): direction ~ [-0.30, 0.60, 0.20, 0.05, 0.10]
MERGE (p4:RecProduct {sku: "SKU-200", name: "Manduka PRO Yoga Mat", price: 129.00,
embedding: [-0.30, 0.60, 0.20, 0.05, 0.10]})
MERGE (p5:RecProduct {sku: "SKU-201", name: "TheraBand Resistance Band Set", price: 24.99,
embedding: [-0.29, 0.61, 0.19, 0.06, 0.11]})
// Cluster C (kitchen cutlery): direction ~ [0.05, -0.40, 0.65, 0.10, -0.17]
MERGE (p6:RecProduct {sku: "SKU-300", name: "Wusthof Classic 8\" Chef's Knife", price: 169.95,
embedding: [0.05, -0.40, 0.65, 0.10, -0.17]})
// Customers
MERGE (c1:RecCustomer {id: "CUST-1", name: "Alice Nguyen"})
MERGE (c2:RecCustomer {id: "CUST-2", name: "Bob Martinez"})
MERGE (c3:RecCustomer {id: "CUST-3", name: "Carol Adeyemi"})
// Purchase history: customers BOUGHT products
MERGE (c1)-[:BOUGHT]->(p1) // Alice: AeroPress + grinder
MERGE (c1)-[:BOUGHT]->(p2)
MERGE (c2)-[:BOUGHT]->(p1) // Bob: AeroPress + grinder
MERGE (c2)-[:BOUGHT]->(p2)
MERGE (c3)-[:BOUGHT]->(p1) // Carol: AeroPress + chef's knife
MERGE (c3)-[:BOUGHT]->(p6);
Step 2: The vector half — cold-start recommendations
[:SIMILAR_TO > 0.8] walks from a viewed product to the products whose embeddings
are near it. This is the "vector database" part — except it's a Cypher edge, not a
separate service — and crucially it does not need any purchase history.
// "A shopper is viewing the AeroPress. What products are semantically similar?"
MATCH (seed:RecProduct {sku: "SKU-100"})-[:SIMILAR_TO > 0.8]->(rec:RecProduct)
RETURN rec.sku AS sku, rec.name AS name, rec.price AS price;
Expected: SKU-101 (Baratza grinder) and SKU-102 (Fellow Stagg kettle) — the
two other coffee-gear products. SKU-102 comes back even though nobody has bought
it — that is the cold-start win a co-purchase query cannot deliver. The yoga and
cutlery products point in different directions, so they stay below the 0.8 threshold
and don't appear. The seed (SKU-100) is excluded from its own SIMILAR_TO set, so
you get related products, not the one being viewed.
Step 3: GraphRAG — co-purchase + semantics + LLM rationale
Now the payoff, in two clean moves. First the structural channel (co-purchase),
then the semantic channel with an inline llm_score rationale — the GraphRAG
combine. Ship both; a real cross-sell rail blends them.
(a) Co-purchase (structural): walk from the seed to its buyers, then to everything they also bought, and rank by how often it co-occurs.
// "People who bought the AeroPress also bought..."
MATCH (seed:RecProduct {sku: "SKU-100"})<-[:BOUGHT]-(:RecCustomer)-[:BOUGHT]->(co:RecProduct)
WHERE co <> seed
RETURN co.sku AS sku, co.name AS name, count(*) AS bought_together
ORDER BY bought_together DESC;
Expected: SKU-101 (Baratza grinder) with bought_together = 2 (Alice and Bob),
then SKU-300 (Wusthof chef's knife) with bought_together = 1 (Carol). Strong
structural signal — but notice the brand-new kettle SKU-102 is absent: it has no
buyers, so co-purchase is blind to it.
(b) Semantic + llm_score rationale (the GraphRAG combine): hop to the seed's
semantic neighbors and let the LLM grade each as a cross-sell inline.
// "Score the semantically similar products as cross-sells for an AeroPress viewer."
MATCH (seed:RecProduct {sku: "SKU-100"})-[:SIMILAR_TO > 0.8]->(rec:RecProduct)
WITH rec, llm_score("rate 0-1 how good a cross-sell this is for someone viewing the seed product", rec) AS fit
WHERE fit > 0.4
RETURN rec.sku AS sku, rec.name AS name, rec.price AS price, fit
ORDER BY fit DESC;
Expected: both SKU-101 (grinder) and SKU-102 (kettle) surface with a high
fit (~0.8–0.9) — beans, grinder, kettle and press are one coffee ritual. The
new SKU-102 gets recommended on day one, ranked by an LLM rationale, with no
purchase history at all. Vector-only search could find the similar product, but it
could not fold in "and two customers who bought the seed also bought the grinder" —
that lives in the graph.
What's happening
Two recommendation channels, one query language, complementary blind spots:
- Co-purchase (structural) is precise once a product has sales — it captures
real, observed buying behavior ("also bought"). But it starts empty: a new SKU
with no
BOUGHTedges returns nothing, so your freshest inventory gets no cross-sell placement exactly when it needs the push. - Semantic similarity (vector) never has a cold-start gap — SKU-102 is recommendable the moment it's inserted with an embedding, before its first sale. On its own it can't see behavior, only meaning.
GraphRAG runs both and lets llm_score arbitrate — co-purchase carries proven
winners, similarity backfills cold-start and long-tail SKUs. In SynapCores that's the
same graph, the same query. On the classic stack it's two different databases whose
results an application has to reconcile by hand.
What this takes on a Neo4j + vector-DB stack (for reference — not executed)
Neo4j does the co-purchase traversal well. But it has no native embedding hop, so the cold-start half lives in a separate vector DB, and your app has to blend the two lists and call an LLM for the rationale. This block is a reference comparison, not runnable SynapCores code:
# 1) Neo4j holds co-purchase structure (Cypher) — but there is no SIMILAR_TO,
# so it can only recommend products that already have buyers:
# (Neo4j Cypher)
MATCH (seed:Product {sku:'SKU-100'})<-[:BOUGHT]-(:Customer)-[:BOUGHT]->(co:Product)
WHERE co <> seed
RETURN co.sku, count(*) AS bought_together ORDER BY bought_together DESC
# -> misses SKU-102 entirely (no purchase history = cold start)
# 2) A separate vector DB (Pinecone/Weaviate/pgvector) holds the embeddings.
# You query it out-of-band for semantic neighbors — the cold-start channel:
index.query(vector=embed("AeroPress Go Coffee Maker"), top_k=10) # -> [skus incl. SKU-102]
# 3) Application/orchestration code (LangChain) merges the co-purchase list and the
# similarity list, because neither system can see the other's data:
recs = blend(copurchase_skus, similar_skus) # de-dupe, weight, rank
# 4) A separate LLM API call generates the cross-sell rationale per candidate:
for sku in recs: fit = openai.rank(prompt, sku) # N more network calls
# Then keep Neo4j and the vector DB in sync forever with a CDC/ETL job on every
# new SKU and every new order.
Four systems, a blend step in application code, N LLM round trips, and a sync job. In SynapCores it was one Cypher query against one store — the purchase graph and the product embeddings are the same data. That is the whole argument of GraphRAG vs Neo4j.
Step 4: One more — cross-sell a brand-new SKU from its semantic twins' buyers
// "The new pour-over kettle has no sales yet. What has sold to buyers of its
// closest semantic matches? (cold-start cross-sell via similarity + behavior)"
MATCH (new:RecProduct {sku: "SKU-102"})-[:SIMILAR_TO > 0.8]->(twin:RecProduct)<-[:BOUGHT]-(cust:RecCustomer)
MATCH (cust)-[:BOUGHT]->(also:RecProduct)
WHERE also <> new
RETURN also.sku AS sku, also.name AS name, count(*) AS signal
ORDER BY signal DESC;
Cleanup (Optional)
MATCH (n:RecProduct) DETACH DELETE n;
MATCH (n:RecCustomer) DETACH DELETE n;
Use it from your app or agent
- In production: replace the demo vectors with
EMBED('product name + description')when you MERGE products (384-dim), and addCREATE VECTOR INDEXfor large catalogs. The Cypher queries above don't change. - REST/SDK: every query here is a
POST /v1/graph/matchwith{"sql": "<cypher>"}, orclient.graph.cypher(...)in the Python SDK — one call, one auth token. - Why one engine: the purchase edges and the product embeddings are the same rows
in the same store, so there is no vector-DB sync job and no app-side blend of two
result lists. Add an LLM rationale with
llm_scorewithout leaving the query.
Key Concepts Learned
- Co-purchase recommendations cold-start to empty. A new SKU with no buyers gets no structural recs — exactly when you most want to sell it.
[:SIMILAR_TO > t]is an inline vector hop that needs no purchase history, so it recommends brand-new SKUs on day one — the cold-start fix.- GraphRAG = structure + semantics + LLM ranking in one query. Co-purchase carries
proven winners; similarity backfills cold-start;
llm_scorearbitrates inline. - The Neo4j stack needs four systems, an app-side blend, and a sync job for the same result; here the purchase graph and the embeddings are one dataset.
More in this cluster: Knowledge-base hub · Fraud-ring detection · Supply-chain impact · Customer-360 Q&A · Pillar: GraphRAG vs Neo4j