GraphRAG vs Neo4j: fraud-ring detection across accounts, devices, and behavior
This is a spoke of a 5-part GraphRAG cluster. For the core pattern, start at the hub: GraphRAG vs Neo4j: a knowledge base that answers multi-hop questions. The other spokes apply the same pattern to supply-chain impact analysis, customer-360 Q&A, and product recommendations. For the architecture argument in depth, read the pillar: GraphRAG vs Neo4j.
Objective
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.
Step 3: GraphRAG — shared infrastructure + behavior + LLM scoring, one query
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_TO0.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 addCREATE VECTOR INDEXfor 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/matchwith{"sql": "<cypher>"}, orclient.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_scorewithout 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.
Back to the hub: GraphRAG knowledge base · Supply-chain impact · Customer-360 Q&A · Product recommendations · Pillar: GraphRAG vs Neo4j