Back to Blog

Fraud Detection on a Graph: Finding the Ring That Every Row Looks Innocent In

Five identical table rows on the left, each looking legitimate; on the right the same five accounts drawn as a graph, closed into a ring by a shared phone, device, address and email, with a transfer completing the loop

A fraud analyst pulls up five accounts. Each one has a plausible name, a valid address, a working phone number, a funding source, and a transaction history that looks like everyone else’s. Individually there is nothing to act on. Decline any one of them and you have declined a customer.

The fraud is not in any of the five records. It is that account A’s phone number is account B’s, account B’s device fingerprint is account C’s, account C’s shipping address is account D’s, account D’s email is account E’s, and E is the beneficiary on a transfer from A. In a table that is five unremarkable rows. In a graph it is a closed loop you can see at a glance.

Fraud lives in the connections, which is why fraud detection is one of the few workloads where a graph database is the better tool on the merits, not because graphs are in fashion.

The shape of the problem

Most of what a fraud team chases falls into three schemes, and each one is a pattern in the edges.

Fraud rings. A set of accounts linked through shared attributes or circular money movement. The money leaves and comes back. No single account looks wrong.

Synthetic identities. An identity assembled from real and fabricated details, built up over months of legitimate behavior before the bust-out. The giveaway is rarely a bad record. It is a cluster of records that are suspiciously similar to each other without being identical.

Layering and smurfing. Money broken into small amounts and moved through intermediaries so that the path from origin to destination is obscured. Each hop is a legitimate-looking transfer, and the scheme only shows when you look at the whole path.

Every one of those is a question about relationships at a depth you do not know in advance, and the depth is what makes it hard. If you knew the ring was exactly three accounts, you could write the join. You do not, so you cannot.

Modeling it

Keep the model boring. Accounts, devices, and identifiers are vertices; transfers and usage are edges.

CREATE VERTEX TYPE Account;
CREATE VERTEX TYPE Device;
CREATE VERTEX TYPE Identifier;   -- phone, email, address, tax id

CREATE EDGE TYPE TRANSFERRED;    -- Account -> Account, with amount and timestamp
CREATE EDGE TYPE USED;           -- Account -> Device
CREATE EDGE TYPE CLAIMS;         -- Account -> Identifier

The one modeling decision that matters: make shared attributes into vertices, not properties. If a phone number is a property on Account, then “find every account sharing a phone number” is a self-join over a string column. If it is an Identifier vertex that accounts point at, it is a two-hop traversal, and the shared identifier is a first-class thing you can attach a confidence score or a review status to.

Finding the ring

A ring is a path that returns to where it began. That is one query, and the depth is a parameter rather than a rewrite. Every query in this section comes in both Cypher and ArcadeDB SQL; use the tabs to switch, and use whichever your team already reads. Both run against the same data in the same engine.

MATCH path = (a:Account)-[:TRANSFERRED*2..5]->(a)
WHERE a.id = $accountId
RETURN path
MATCH {type: Account, as: a, where: (id = :accountId)}
      .out('TRANSFERRED'){while: ($depth < 5),
                          where: ($depth >= 2 AND @rid = $matched.a.@rid),
                          pathAlias: ring}
RETURN ring.id AS accounts

*2..5 in Cypher, and the $depth bounds in SQL, mean: follow TRANSFERRED edges between two and five hops, and only return paths that arrive back at the account they started from. Ask for six hops instead and you change one number. In a relational database each extra hop is another self-join, so a deeper ring means a different query.

The ring from the opening closes over a phone, a device, an address, an email and one transfer. In Cypher that is the same query with more edge types and no direction, since a shared identifier is one hop out and one hop back:

MATCH path = (a:Account)-[:TRANSFERRED|CLAIMS|USED*2..10]-(a)
WHERE a.id = $accountId
RETURN [n IN nodes(path) WHERE n:Account | n.id] AS accounts
SELECT @in.id AS beneficiary,
       shortestPath(@in, @out, 'BOTH', ['CLAIMS', 'USED'], {maxDepth: 10})
         [@type = 'Account'].id AS ring
FROM TRANSFERRED
WHERE @out.id = :accountId

Each shared identifier costs two hops, so the five-account ring above is nine edges long, and the bound is set with that in mind. The SQL version comes at it from the transfer: for each payment the account made, it looks for a path back from the beneficiary over shared identifiers and devices. An empty ring means the two ends are not otherwise connected.

To rank an account’s neighbors by how much they share with it, count the identifiers per neighbor:

MATCH (a:Account)-[:CLAIMS]->(i:Identifier)<-[:CLAIMS]-(b:Account)
WHERE a.id = $accountId AND a <> b
RETURN b.id AS account, count(i) AS sharedCount, collect(i.type) AS shared
ORDER BY sharedCount DESC
SELECT id AS account, count(*) AS sharedCount
FROM (SELECT expand(out('CLAIMS').in('CLAIMS')) FROM Account WHERE id = :accountId)
WHERE id <> :accountId
GROUP BY id
ORDER BY sharedCount DESC

Two accounts sharing one identifier is weak evidence. Two accounts sharing four is not. The count is the signal, and grouping it out of the traversal costs nothing.

The ring alone is not enough

Graph database marketing tends to skip this part, and it is why a single ring query is not a fraud system.

A ring is not proof. Five accounts sharing an address might be a family, roommates, or a small business. Five accounts sharing a device might be a library. The traversal finds the pattern; it does not tell you the pattern is fraud. Run it alone and you will generate false positives at a rate your review team will hate you for.

Confidence comes from combining the structural signal with signals of a different kind: text similarity, behavior, and timing. ArcadeDB keeps each of those in the same database as the graph.

Fuzzy identity matching, for synthetics

Synthetic identities are near-misses by construction. 123 Main St Apt 4 and 123 Main Street #4. Jonathon and Johnathon. Exact matching finds none of it.

A full-text index, which ArcadeDB scores with BM25, ranks the near-matches. The index is created once, in SQL:

CREATE PROPERTY Identifier.value STRING;
CREATE INDEX ON Identifier (value) FULL_TEXT;

Then query it from either language:

CALL db.index.fulltext.queryNodes('Identifier[value]', '"123 Main" Apt~')
YIELD node, score
RETURN node.value AS value, node.type AS type, score
ORDER BY score DESC
SELECT value, type, $score AS score
FROM Identifier
WHERE SEARCH_INDEX('Identifier[value]', '"123 Main" Apt~') = true
ORDER BY score DESC

One near-match proves nothing on its own. A cluster of applications sharing near-miss identifiers, all filed within a few days of each other, is a pattern exact matching cannot see at all.

Behavioral distance, for bust-outs

An account that has behaved normally for eight months and then changes character is the classic bust-out. If you already embed account behavior as a vector, and many teams do, the change shows up as distance: recently active accounts whose behavior now sits close to a known fraud profile.

CREATE INDEX ON Account (behavior) LSM_VECTOR METADATA {dimensions: 128, similarity: 'COSINE'};
CALL db.index.vector.queryNodes('Account[behavior]', 20, $knownFraudVector)
YIELD node, score
WHERE node.lastActivity > $since
RETURN node.id AS id, score
ORDER BY score DESC
SELECT id, distance
FROM (SELECT expand(vectorNeighbors('Account[behavior]', :knownFraudVector, 20)))
WHERE lastActivity > :since
ORDER BY distance

Cypher reports a similarity score, where higher is closer. SQL reports a distance, where lower is closer. Both rank the same neighbors.

This signal is independent of the graph one. An account can be structurally isolated and behaviorally anomalous, or structurally suspicious and behaviorally boring. The accounts that are both are the ones to look at first.

Timing, for velocity

Fraud has a tempo. Ten transfers in a minute, or a burst of account creations from one subnet, is a question of bucketing events by time:

MATCH (a:Account)-[t:TRANSFERRED]->()
WHERE t.ts > datetime($since)
WITH a.id AS account_id,
     datetime.truncate('minute', t.ts) AS minute,
     count(t) AS transfers,
     sum(t.amount) AS total
WHERE transfers > 10
RETURN minute, account_id, transfers, total
ORDER BY total DESC
SELECT FROM (
  SELECT ts.timeBucket('1m', ts) AS minute,
         account_id,
         count(*) AS transfers,
         sum(amount) AS total
  FROM TransferEvent
  WHERE ts > :since
  GROUP BY minute, account_id
)
WHERE transfers > 10
ORDER BY total DESC

The two versions read different data. Cypher works on the graph, so it buckets the timestamps already on the TRANSFERRED edges. SQL reads TransferEvent, a time-series type, where the storage engine can answer a bucketed aggregation in one pass. That is the better home for a high-volume event stream, and Cypher does not query time-series types.

Why one database rather than four

Every signal above is weak on its own and meaningful in combination, and that has consequences for where the data lives.

If the graph lives in one system, the embeddings in a vector store, the identifiers in a search cluster, and the events in a time-series database, then combining them means four queries, four network round trips, a join written in application code, and four copies of the truth that drift at four different rates. Worse, the drift is invisible: nothing errors when your vector store is nine minutes behind your graph. You just score an account against a behavior profile that is no longer current.

With all four models in one engine, the join happens in the query and every signal reads the same committed data. That is a narrower claim than “one database for everything”. It applies here because fraud scoring combines these signals on every query, so keeping them apart costs you on every query too.

Where a traversal is the wrong tool

A graph is not the answer to everything, and two limits are worth knowing before you start.

Unbounded traversals will hurt you. [:TRANSFERRED*] with no upper bound on a dense graph can walk a very large portion of it. Every query above bounds the depth deliberately. Bound yours too, and exclude hub vertices by hand. A bank’s own settlement account touches everything; the engine will not work that out for you.

Whole-graph analytics is a different workload. Community detection or PageRank across the entire account graph is not a transactional traversal, and running it as one will disappoint you. That is what the Graph OLAP engine is for. It reads the same data as the transactional engine, so there is no export step between finding a community offline and querying it online.

Getting started

The schema above is six statements and the Cypher ring query is three lines. If you want the longer version, with the multi-model investigation flow and the FAQ, it is on the fraud detection page; the Academy covers the Cypher and SQL used here in more depth.

The FTC recorded $12.5 billion in consumer fraud losses in 2024, a 25% increase year over year, and the FBI’s Internet Crime Complaint Center put its own figure at $16.6 billion for the same period. A lot of that money moved through accounts that looked fine one row at a time.