GraphRAG vs Neo4j: fraud-ring detection across accounts, devices, and behavior
Catch organized fraud rings in one Cypher query — shared devices and addresses (graph structure) plus behavioral similarity (vector hop) plus LLM risk scoring — and see what the same detection costs on a Neo4j + vector-DB stack.
All recipes· graph· 12 minutesintermediate✓ Certified v1.14.0-cecypher
Instance: localhost:8080
Opens your running SynapCores (GraphRAG vs Neo4j: fraud-ring detection across accounts, devices, and behavior will be staged for a preview — nothing runs until you click Run). No instance yet? Install free in ~30s.
Single-account fraud rules — velocity limits, chargeback ratios, blocklists — are
good at catching the lone bad actor and useless against an organized ring.
A ring is several accounts that look ordinary one at a time but betray themselves
in two dimensions at once: they share infrastructure (the same device
fingerprint or shipping address) and they behave alike (near-identical
transaction patterns). Neither signal alone is enough — plenty of honest people
share a household device, and plenty of unrelated accounts happen to spend
similarly. The fraud is in the intersection.
That intersection is exactly what a vector-only RAG stack cannot express. Vector
search finds accounts that behave like a known-bad seed, but it can't enforce
"and they also touched the same device." The graph knows the shared-device edge,
but a plain graph engine has no notion of behavioral similarity. GraphRAG is
the combination: semantic recall plus graph structure plus an LLM judgment,
in one pass.
The textbook build is a four-system stack: Neo4j for the account/device/address
graph, a vector database (Pinecone / Weaviate / pgvector) for behavioral
embeddings, an orchestration layer to intersect their results, and an LLM API
to score ring risk. SynapCores collapses that into one query on one engine:
accounts carry their own behavioral embedding, [:SIMILAR_TO > 0.8] is a
semantic hop inside Cypher, and llm_score(prompt, node) grades ring risk
inline. This recipe builds a small fintech graph, flags a ring from a known-fraud
seed, and then shows exactly what the Neo4j-stack version would take.
Step 1: Build the fraud graph
Accounts carry a behavioral embedding and a coarse risk flag, and they link to
the devices they logged in from and the addresses they ship to. The embedding
values here are 5-dimensional demo vectors so the recipe is fast and
deterministic; in production you generate them with EMBED('the account's transaction-pattern summary') (384-dim by default). Accounts in the same ring
point the same direction in embedding space; unrelated legit accounts point
elsewhere.
// Ring cluster (behaviorally alike) — ACC-1001 is the known-fraud seed
MERGE (a1:FrAccount {id: "ACC-1001", name: "Dylan Reyes", risk: "confirmed_fraud",
embedding: [0.80, 0.10, 0.05, -0.05, 0.20]})
MERGE (a2:FrAccount {id: "ACC-1002", name: "Priya Nkemdirim", risk: "unreviewed",
embedding: [0.79, 0.11, 0.04, -0.06, 0.21]})
MERGE (a3:FrAccount {id: "ACC-1003", name: "Marcus Osei", risk: "unreviewed",
embedding: [0.81, 0.09, 0.06, -0.04, 0.19]})
MERGE (a4:FrAccount {id: "ACC-1004", name: "Lena Fournier", risk: "unreviewed",
embedding: [0.80, 0.12, 0.05, -0.05, 0.18]})
// Legit accounts, different behavior clusters — should NOT come back as ring
MERGE (b1:FrAccount {id: "ACC-2001", name: "Sofia Alvarez", risk: "clean",
embedding: [-0.30, 0.60, 0.20, 0.05, 0.10]})
MERGE (b2:FrAccount {id: "ACC-2002", name: "Tom Becker", risk: "clean",
embedding: [-0.31, 0.58, 0.22, 0.06, 0.09]})
MERGE (c1:FrAccount {id: "ACC-3001", name: "Wei Zhang", risk: "clean",
embedding: [0.05, -0.40, 0.65, 0.10, -0.17]})
MERGE (c2:FrAccount {id: "ACC-3002", name: "Amara Diallo", risk: "clean",
embedding: [0.06, -0.39, 0.64, 0.11, -0.18]})
// Shared infrastructure
MERGE (d9:FrDevice {id: "DEV-9F2A", fingerprint: "chrome-linux-x11-sameua"})
MERGE (d3:FrDevice {id: "DEV-3B1C", fingerprint: "ios-safari-legit"})
MERGE (d7:FrDevice {id: "DEV-7E44", fingerprint: "android-legit"})
MERGE (addrX:FrAddress {id: "ADDR-X", line: "44 Canal St, Unit 6, Newark NJ"})
MERGE (addrY:FrAddress {id: "ADDR-Y", line: "902 Oak Ave, Austin TX"})
// The ring shares ONE device and ONE drop address (structural signal)
MERGE (a1)-[:USED_DEVICE]->(d9)
MERGE (a2)-[:USED_DEVICE]->(d9)
MERGE (a3)-[:USED_DEVICE]->(d9)
MERGE (a1)-[:SHIPS_TO]->(addrX)
MERGE (a2)-[:SHIPS_TO]->(addrX)
MERGE (a3)-[:SHIPS_TO]->(addrX)
// ACC-1004 behaves like the ring but used its OWN device (no shared structure)
MERGE (a4)-[:USED_DEVICE]->(d7)
MERGE (a4)-[:SHIPS_TO]->(addrY)
// Legit accounts, ordinary independent infrastructure
MERGE (b1)-[:USED_DEVICE]->(d3)
MERGE (c1)-[:USED_DEVICE]->(d7);
Step 2: The vector half — behaviorally similar accounts
[:SIMILAR_TO > 0.8] walks from the known-fraud seed to accounts whose behavioral
embeddings are near it. This is the "vector database" part — except it's a Cypher
edge, not a separate service.
// "Which accounts behave like our confirmed-fraud seed ACC-1001?"
MATCH (seed:FrAccount {id: "ACC-1001"})-[:SIMILAR_TO > 0.8]->(similar:FrAccount)
RETURN similar.id AS id, similar.name AS name, similar.risk AS risk;
Expected: ACC-1002, ACC-1003, and ACC-1004 — all three share the seed's
behavioral direction. The seed is excluded from its own neighbor set. The
clean accounts in the other clusters (ACC-2001/2002, ACC-3001/3002) are
behaviorally far, so they do not come back. Note that ACC-1004 behaves like the
ring but, as we'll see, never touched the shared device — behavior alone would
over-flag it.
Now the payoff. A real ring member must satisfy both signals: it shares the
seed's device (structural edge) and it behaves like the seed (SIMILAR_TO).
Then llm_score grades each survivor for organized-ring risk — all in a single
statement.
// "Find accounts that BOTH share a device with the fraud seed AND behave like it,
// then score each for organized-ring risk."
MATCH (seed:FrAccount {id: "ACC-1001"})-[:USED_DEVICE]->(:FrDevice)<-[:USED_DEVICE]-(other:FrAccount)
MATCH (seed)-[:SIMILAR_TO > 0.8]->(other)
WITH DISTINCT other,
llm_score("Rate 0-1 how likely this account is part of an organized fraud ring, given it shares a device and shipping address with a confirmed-fraud account and mirrors its transaction behavior", other) AS ring_risk
WHERE ring_risk > 0.4
RETURN other.id AS id, other.name AS name, ring_risk
ORDER BY ring_risk DESC;
Expected: ACC-1002 (Priya Nkemdirim) and ACC-1003 (Marcus Osei) come back
with high ring_risk scores (~0.8–0.9). Both share device DEV-9F2A and drop
address ADDR-X with the seed and sit in the seed's behavioral cluster.
Crucially, ACC-1004 is filtered out: it behaves like the ring (it passed
Step 2) but it never used the shared device, so the structural half of the join
excludes it — no false positive. The seed itself is excluded from its own
SIMILAR_TO neighbor set, so you get the ring around the seed, not the seed.
What's happening
This one query is doing what the four-system stack does across four services:
Structural recall (the graph): the shared-device self-join
(:FrDevice)<-[:USED_DEVICE]-(other) finds accounts wired to the same
infrastructure as the seed.
Semantic recall (the vector hop):[:SIMILAR_TO > 0.8] keeps only the
device-sharers that also behave like the seed — the `MATCH (seed)-[:SIMILAR_TO
0.8]->(other)` line intersects the two signals in-engine, with no app-side set
math.
LLM judgment (inline):llm_score(...) turns the surviving pair into a
ranked risk list an analyst can action, without a separate model call per row in
application code.
Shared device or similar behavior each over-flags on its own; their
intersection is the ring. That intersection is a single Cypher statement here
because the edges and the embeddings are the same data.
What this takes on a Neo4j + vector-DB stack (for reference — not executed)
The same detection on the classic stack spans four systems. This block is a
reference comparison, not runnable SynapCores code:
# 1) Neo4j holds the account/device/address graph (Cypher). It can find the
# shared-device ring structurally — but Neo4j has NO embedding hop, so there
# is no SIMILAR_TO and no behavioral filter here:
# (Neo4j Cypher)
MATCH (seed:FrAccount {id:'ACC-1001'})-[:USED_DEVICE]->(:FrDevice)<-[:USED_DEVICE]-(other:FrAccount)
RETURN other.id # -> [device-sharing account ids]
# 2) A separate vector DB (Pinecone/Weaviate/pgvector) holds the behavioral
# embeddings. You query it out-of-band for behavioral neighbors of the seed:
index.query(vector=embed("ACC-1001 transaction pattern"), top_k=25) # -> [similar ids]
# 3) Application/orchestration code intersects the two ID sets by hand, because
# neither system can see the other's data — this is the ring:
ring_ids = set(device_share_ids) & set(behavior_ids)
# 4) A separate LLM API call scores each surviving account for ring risk:
for a in ring_ids: risk = openai.rank(prompt, a) # N more network calls
# Then keep Neo4j and the vector DB in sync forever with a CDC/ETL job so a new
# login or a re-embedded behavior profile doesn't silently break the join.
Four systems, three-to-N network round trips per investigation, and a
synchronization job standing between "an account changed" and "the ring query is
correct." In SynapCores it was one Cypher query against one store — the graph
edges and the behavioral vectors are the same rows. That is the whole argument of
GraphRAG vs Neo4j.
Step 4: One more — widen the net to shared drop addresses
// "Accounts that ship to the seed's drop address AND behave like it —
// the address-based view of the same ring."
MATCH (seed:FrAccount {id: "ACC-1001"})-[:SHIPS_TO]->(:FrAddress)<-[:SHIPS_TO]-(other:FrAccount)
MATCH (seed)-[:SIMILAR_TO > 0.8]->(other)
RETURN other.id AS id, other.name AS name, other.risk AS current_flag;
Expected: ACC-1002 and ACC-1003 again — they ship to ADDR-X and behave
like the seed. This confirms the ring from a second structural angle, which is
how analysts corroborate a signal before freezing accounts.
Cleanup (Optional)
MATCH (n:FrAccount) DETACH DELETE n;
MATCH (n:FrDevice) DETACH DELETE n;
MATCH (n:FrAddress) DETACH DELETE n;
Use it from your app or agent
In production: replace the demo vectors with EMBED('the account's transaction-pattern summary') when you MERGE accounts (384-dim), and add
CREATE VECTOR INDEX for large populations. The Cypher queries above don't
change. Re-embed an account when its behavior profile shifts and the ring query
stays correct — no external vector store to resync.
REST/SDK: every query here is a POST /v1/graph/match with {"sql": "<cypher>"},
or client.graph.cypher(...) in the Python SDK — one call, one auth token. Wire
Step 3 to a real-time login/checkout hook and you have an inline ring check.
Why one engine: the shared-device edges and the behavioral embeddings are the
same rows in the same store, so there is no vector-DB sync job and no app-side
set-intersection to keep the structural and behavioral halves consistent. Add the
LLM risk score with llm_score without leaving the query.
Key Concepts Learned
A fraud ring lives in the intersection of shared infrastructure (graph) and
shared behavior (vectors) — either signal alone over-flags, as ACC-1004
demonstrates.
[:SIMILAR_TO > t] is an inline vector hop — the behavioral vector DB
collapses into a Cypher edge, with a tunable threshold for precision/recall.
Intersecting structure and semantics is a single join here (MATCH ... USED_DEVICE ... + MATCH (seed)-[:SIMILAR_TO > 0.8]->(other)), not app-side set
math across two databases.
llm_score(prompt, node) ranks ring risk inside the query — no per-row LLM loop
in application code.
The Neo4j stack needs four systems and a sync job for the same result; here the
graph and the vectors are one dataset.