GraphRAG vs Neo4j: a customer-360 knowledge graph that recommends the next action
This is a spoke of a 5-part GraphRAG cluster. Start with the hub, which teaches the core pattern — a knowledge base that answers multi-hop questions — then see the other spokes: fraud-ring detection, supply-chain impact analysis, and product recommendations. For the architecture argument in depth, read the pillar: GraphRAG vs Neo4j.
Objective
A support engineer opens an at-risk enterprise account and sees a fresh ticket: "double-charged on our latest invoice." The fastest useful answer isn't a knowledge-base article — it's what actually resolved a similar case before. But whether you drop everything for it depends on something the ticket text can't tell you: is this a $240k enterprise account or a free-tier trial?
Vector-only RAG can find tickets that read like this one, but it throws away the account's value and tier, because those are edges in a graph, not words in the ticket. Neo4j can hold the account graph, but it has no native embedding hop, so ticket-text similarity has to live in a separate vector database with an app-side merge on top. The classic build is a four-system stack: Neo4j for the customer/ticket/order graph, a vector DB (Pinecone / Weaviate / pgvector) for ticket embeddings, an orchestration layer (LangChain / LlamaIndex) to glue them, and an LLM API to grade how useful each past resolution is.
SynapCores collapses that into one query on one engine: tickets 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 each candidate inline.
This recipe builds a small customer-360 graph and answers "what should we tell
this at-risk account, and how much does it matter?" with a single Cypher
statement — then shows you exactly what the Neo4j stack version would take.
Step 1: Build the customer-360 graph
Customers PLACED orders and OPENED tickets. Each C360Ticket carries an
embedding of its text, a status ("open" / "resolved"), and — when resolved
— the resolution that fixed it. Embedding values here are 5-dimensional demo
vectors so the recipe is fast and deterministic; in production you generate them
with EMBED('the ticket text') (384-dim by default). Tickets about the same topic
share a vector direction, so [:SIMILAR_TO > 0.8] returns same-topic neighbors.
// Customers — tier + ARR are the structural signal vector search can't see
MERGE (c1:C360Customer {id: "CUST-77", name: "Northwind Logistics",
tier: "enterprise", arr: 240000, health: "at-risk"})
MERGE (c2:C360Customer {id: "CUST-12", name: "Acme Retail",
tier: "pro", arr: 24000, health: "healthy"})
MERGE (c3:C360Customer {id: "CUST-30", name: "Bluebird Studios",
tier: "free", arr: 0, health: "trial"})
// Tickets — topic A = billing, B = SSO/login, C = API rate limits.
// Same-topic vectors point the same direction (within +/-0.02).
MERGE (t1:C360Ticket {id: "TCK-1001", status: "open", resolution: "",
text: "We were double-charged on our latest enterprise invoice and need a refund.",
embedding: [0.80, 0.10, 0.05, -0.05, 0.20]}) // topic A (billing)
MERGE (t2:C360Ticket {id: "TCK-1002", status: "resolved",
text: "Invoice showed a duplicate charge for the monthly plan.",
resolution: "Issued a credit note for the duplicate line item and turned on invoice de-duplication.",
embedding: [0.79, 0.12, 0.06, -0.06, 0.21]}) // topic A (billing)
MERGE (t3:C360Ticket {id: "TCK-1003", status: "resolved",
text: "Billing overcharge after a mid-cycle plan upgrade.",
resolution: "Applied a proration credit for the mid-cycle upgrade and documented the billing cutover.",
embedding: [0.81, 0.09, 0.04, -0.04, 0.19]}) // topic A (billing)
MERGE (t4:C360Ticket {id: "TCK-2001", status: "resolved",
text: "SAML SSO login hit a redirect loop after our IdP change.",
resolution: "Updated the ACS URL in the SAML config to match the new IdP.",
embedding: [-0.30, 0.60, 0.20, 0.05, 0.10]}) // topic B (SSO)
MERGE (t5:C360Ticket {id: "TCK-2002", status: "open", resolution: "",
text: "Users can't log in via Okta SSO this morning.",
embedding: [-0.31, 0.61, 0.19, 0.06, 0.11]}) // topic B (SSO)
MERGE (t6:C360Ticket {id: "TCK-3001", status: "resolved",
text: "Hitting 429 rate-limit errors on the export API.",
resolution: "Raised the account's API rate-limit tier and added retry-after backoff guidance.",
embedding: [0.05, -0.40, 0.65, 0.10, -0.17]}) // topic C (API limits)
// Orders — structural account value
MERGE (o1:C360Order {id: "ORD-9001", item: "Annual enterprise license", amount: 240000})
MERGE (o2:C360Order {id: "ORD-9002", item: "Pro plan renewal", amount: 24000})
// Structure: customers OPENED tickets and PLACED orders
MERGE (c1)-[:OPENED]->(t1)
MERGE (c2)-[:OPENED]->(t2)
MERGE (c3)-[:OPENED]->(t3)
MERGE (c2)-[:OPENED]->(t4)
MERGE (c3)-[:OPENED]->(t5)
MERGE (c2)-[:OPENED]->(t6)
MERGE (c1)-[:PLACED]->(o1)
MERGE (c2)-[:PLACED]->(o2);
Step 2: The vector half — semantic recall alone
[:SIMILAR_TO > 0.8] walks from the at-risk account's open ticket to the tickets
whose text is near it. This is the "vector database" part — except it's a Cypher
edge, not a separate service.
// "Northwind's open ticket is about a double-charge.
// What past tickets are semantically related?"
MATCH (c:C360Customer {id: "CUST-77"})-[:OPENED]->(open:C360Ticket {status: "open"})
MATCH (open)-[:SIMILAR_TO > 0.8]->(similar:C360Ticket)
RETURN similar.id AS id, similar.status AS status, similar.text AS text;
Expected: TCK-1002 (duplicate-charge invoice) and TCK-1003 (mid-cycle overcharge) — the other two billing tickets. The SSO and API-limit tickets point in different directions, so they don't come back. The seed ticket (TCK-1001) is excluded from its own SIMILAR_TO set, so you get related tickets, not the one you started from.
Step 3: GraphRAG — semantic recall + graph structure + LLM ranking, one query
Now the payoff. Start from the at-risk account's open ticket, hop to its
semantically-similar resolved tickets, pull the account's tier and ARR off
the graph, and let llm_score rank how useful each past resolution is for the open
issue — all in a single statement.
// "For Northwind's open billing issue, which past resolution is most useful —
// and how much does this account matter?"
MATCH (c:C360Customer {id: "CUST-77"})-[:OPENED]->(open:C360Ticket {status: "open"})
MATCH (open)-[:SIMILAR_TO > 0.8]->(past:C360Ticket)
WHERE past.status = "resolved"
WITH c, open, past,
llm_score("Rate 0-1 how useful this past resolution is for resolving the open issue", past) AS usefulness
WHERE usefulness > 0.4
RETURN c.name AS account,
c.tier AS tier,
c.arr AS arr,
open.text AS open_issue,
past.resolution AS suggested_fix,
usefulness
ORDER BY usefulness DESC;
Expected: two rows for Northwind Logistics (tier enterprise, ARR 240000) —
the credit-note-plus-de-duplication fix (TCK-1002) and the proration-credit fix
(TCK-1003) — each with a high LLM usefulness score, most-useful first.
Vector-only RAG could surface the similar tickets, but it could not attach
"enterprise, $240k, at-risk" — that lives in the graph, and it's what tells the
support engineer this ticket jumps the queue.
What's happening
Three capabilities that normally live in three different systems run in one pass:
- Semantic recall —
[:SIMILAR_TO > 0.8]is the vector-DB hop, expressed as a Cypher edge. It finds the resolved billing tickets by meaning, not keywords. - Graph structure —
MATCH (c:C360Customer)-[:OPENED]->(open)and the returnedc.tier/c.arrare the account context. Similarity alone can't prioritize; the edges to the customer node do. - LLM ranking —
llm_score(...)grades each candidate resolution inside the query, so the most useful fix sorts to the top with no app-side LLM loop.
The WHERE past.status = "resolved" filter guarantees you only suggest fixes that
actually closed a case, and the seed ticket's exclusion from its own SIMILAR_TO set
means you never recommend the open ticket back to itself. One query, one store: the
ticket embeddings and the customer edges are the same rows in the same database.
What this takes on a Neo4j + vector-DB stack (for reference — not executed)
The same capability on the classic stack spans four systems. This block is a reference comparison, not runnable SynapCores code:
# 1) Neo4j holds the customer/ticket/order graph (Cypher) — but Neo4j has no
# native embedding hop, so SIMILAR_TO does not exist. You store the account
# context and structure only:
# (Neo4j Cypher)
MATCH (c:Customer {id:'CUST-77'})-[:OPENED]->(open:Ticket {status:'open'})
RETURN c.tier, c.arr, open.id
# 2) A separate vector DB (Pinecone/Weaviate/pgvector) holds the ticket
# embeddings. You query it out-of-band for semantic neighbors of the open
# ticket, then filter to resolved ones app-side:
index.query(vector=embed(open_text), top_k=10) # -> [ticket ids]
# 3) Application/orchestration code (LangChain) joins the two result sets by
# ticket id, because neither system can see the other's data — and re-attaches
# the account tier/ARR from the Neo4j rows:
rows = join(vector_ids, graph_rows_by_ticket_id)
# 4) A separate LLM API call scores each surviving resolution for usefulness:
for r in rows: r.score = openai.rank(prompt, r.resolution) # N more network calls
# Then keep Neo4j and the vector DB in sync forever with a CDC/ETL job so a new
# or edited ticket's text and its embedding never drift apart.
Four systems, three-to-N network round trips per ticket, and a synchronization job. In SynapCores it was one Cypher query against one store — the account graph and the ticket vectors are the same data. That is the whole argument of GraphRAG vs Neo4j.
Step 4: One more — combine structure and semantics freely
// "Resolved fixes semantically near Northwind's open billing ticket,
// but only ones that worked for a paying (non-free) account."
MATCH (c:C360Customer {id: "CUST-77"})-[:OPENED]->(open:C360Ticket {status: "open"})
MATCH (open)-[:SIMILAR_TO > 0.8]->(past:C360Ticket {status: "resolved"})
MATCH (owner:C360Customer)-[:OPENED]->(past)
WHERE owner.tier <> "free"
RETURN owner.name AS solved_for, owner.tier AS tier, past.resolution AS fix;
Cleanup (Optional)
MATCH (n:C360Customer) DETACH DELETE n;
MATCH (n:C360Ticket) DETACH DELETE n;
MATCH (n:C360Order) DETACH DELETE n;
Use it from your app or agent
- In production: replace the demo vectors with
EMBED('ticket text')when you MERGE tickets (384-dim), and addCREATE VECTOR INDEXfor large ticket volumes. 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. Wire it into your helpdesk so the "suggested fix" panel is a single request. - Why one engine: the customer edges and the ticket embeddings are the same rows
in the same store, so there is no vector-DB sync job and no app-side
set-intersection to re-attach tier/ARR. Add the LLM usefulness step with
llm_scorewithout leaving the query.
Key Concepts Learned
- GraphRAG = semantic recall + graph structure + LLM ranking. Vector-only RAG finds similar tickets but can't tell you the account is enterprise and at-risk.
[:SIMILAR_TO > t]is an inline vector hop — the vector DB collapses into a Cypher edge, with a tunable threshold for precision/recall.- Structural edges (
OPENED,PLACED,tier,arr) turn a similar-ticket lookup into a prioritized next action — the thing vector search alone can't do. llm_score(prompt, node)ranks candidate resolutions inside the query — no app-side LLM loop.- The Neo4j stack needs four systems and a sync job for the same result; here the graph and vectors are one dataset.
Next in this cluster: Fraud-ring detection · Supply-chain impact · Product recommendations · Hub: Knowledge base · Pillar: GraphRAG vs Neo4j