AWS Builder Center

RingWatch: finding fraud rings with a graph database, and keeping the LLM honest

Built for Graph Hacks 2026 (WeMakeDevs x FalkorDB). Code: https://github.com/Manasa-L-Hegde/RingWatch

Final year -Information science and Engineering | Software Developer Trainee at CBA
The ring that no single record shows
Imagine seven customer accounts. Each one places a few orders, each order looks normal, and no two neighbouring accounts have anything obvious in common. But account 1 shares a phone with account 2, account 2 shares a different phone with account 3, and so on down a chain of seven accounts. Account 1 and account 7 never touch each other directly, yet they belong to the same fraud ring.
That is "Ring C" in my project, RingWatch. If you ask "who shares a device with account u193?" using a normal two-step join, you get exactly one answer: u194. If you ask the graph to follow shared devices and cards as far as they go, you get six accounts: u194 to u199. Same data, very different picture.
RingWatch is an investigation agent built on FalkorDB, an open source graph database. You give it a flagged order. It follows shared devices, cards and addresses through the graph, finds the ring, shows the exact path that links the accounts, gives a rule-based risk score, and remembers what the analyst decided so the next case is faster.
All data in the project is synthetic. Nothing here detects real fraud, and I say more about the limits at the end.

The graph model

The fraud graph has five node types and four edge types:
  • (:User)-[:PLACED]->(:Order)
  • (:Order)-[:SHIPPED_TO]->(:Address)
  • (:User)-[:USED_DEVICE]->(:Device)
  • (:User)-[:PAID_WITH]->(:Card)
I generated 1,292 nodes and 1,613 edges: 200 users, 600 orders, and the devices, cards and addresses around them. Most users are normal, with their own device and card. I planted three rings so I know the right answers:
  • Ring A: six users sharing two devices, joined through one bridging user.
  • Ring B: five users who share one card and ship to one address.
  • Ring C: seven users in a chain of devices, 12 hops from end to end.

Four graph queries that do the real work

1. Which accounts are linked to this order? One pattern finds other users who share a device or card with the buyer:
1
2
3
4
5
MATCH (o:Order {id:$oid})<-[:PLACED]-(u:User)-[r1:USED_DEVICE|PAID_WITH]->(x)
<-[r2:USED_DEVICE|PAID_WITH]-(v:User)
WHERE v <> u
RETURN DISTINCT u.id AS order_user, v.id AS linked_user,
labels(x)[0] AS via, x.id AS shared_entity
2. What is the whole ring? A variable-length pattern keeps walking between users and their shared devices and cards:
1
2
3
MATCH p = (u:User {id:$user_id})-[:USED_DEVICE|PAID_WITH*1..12]-(v:User)
WHERE v <> u AND length(p) <= $max_hops
RETURN DISTINCT v.id
The $max_hops parameter made a good demo. At 2 hops it returns one account, at 12 it returns all six. SQL can also do multi-hop work with recursive queries. What I liked in the graph version is that the depth is one number in one pattern, and the same pattern also gives me the path.
3. Why are these two accounts connected? I use FalkorDB's algo.SPpaths to get the shortest path between two accounts, and I show that path on screen as evidence. [PASTE THE EXACT algo.SPpaths CALL FROM tools.py HERE]
4. Which rings exist across the whole graph? Weakly connected components, run inside the database:
1
2
3
4
5
6
CALL algo.WCC({relationshipTypes:['USED_DEVICE','PAID_WITH']})
YIELD node, componentId
WHERE "User" IN labels(node)
WITH componentId, count(node) AS user_count, collect(node.id) AS users
WHERE user_count >= $min_users
RETURN componentId, user_count, users ORDER BY user_count DESC
The graph has 879 components, but only three have three or more users, and they are exactly my three planted rings (sizes 5, 6 and 7).

The model never writes a query

I did not want a language model writing Cypher, because a wrong query that looks right is the worst kind of bug. So RingWatch has a fixed catalogue of six tools (order facts, linked accounts, ring expansion, shortest path, ring detection, ring statistics). I wrote each query. The model only chooses which tool to call and fills in the arguments, and every ID is checked against the graph first. If an order does not exist, nothing else runs.
Every investigation ends in one of four outcomes:
  1. Path found: the graph linked the accounts, and the path is shown.
  2. Answered from memory: a confirmed earlier case matched.
  3. No links found: nothing shares a device or card. The app says clearly that this does not mean the account is safe, because the graph may simply not know about a link.
  4. Unknown entity: the ID is not in the graph, so zero tools run.
The model also writes one short explanation sentence. Before anyone sees it, a checker confirms that every user, device, card, order or address ID in the sentence was actually returned by a tool. If not, a plain template sentence built from the results is shown instead.
This caught a real problem during testing. In one run the model wrote that two accounts shared device dA2, but it had never seen dA2 in any tool result. The checker rejected the sentence and the template went out instead. A confident but unsupported sentence is exactly what I wanted to stop.

A verdict you can add up

The verdict is not the model's opinion. It is a rule-based score, and the reasons are listed so you can check the arithmetic:
  • Ring of 3 or more users: +35, and 6 or more: +10 more
  • Shared device +10, shared card +10, shared address (3 or more members) +5
  • 30% or more of the ring's orders flagged: +20
  • Average order of $3,000 or more: +10
  • Overlapping confirmed past case: +15, overlapping rejected case: -25
A score of 70 or more is FRAUD_RING, anything with links below that is SUSPICIOUS, and no links is NO_LINKS_FOUND. These thresholds are illustrative. I did not validate them on real fraud data.

Memory: one graph per analyst

Each analyst gets their own FalkorDB graph, named memory_<analyst>. Inside it, a Case node records the order, verdict and score, Entity nodes record the users, devices and cards involved, and Pattern nodes count how often a shape such as "shared device chain" was confirmed or rejected. When the analyst confirms or rejects a case, the status and pattern counts update.
Recall is an overlap query (simplified here):
1
2
3
4
MATCH (c:Case)-[:INVOLVES]->(e:Entity)
WHERE e.id IN $entity_ids
RETURN c.id AS case_id, c.status AS status, count(DISTINCT e) AS overlap
ORDER BY overlap DESC LIMIT 5
Because each analyst is a separate graph, isolation does not depend on a filter I might forget. In the demo, a new analyst opens the same order and sees no memory at all.
When a confirmed case shares three or more entities with the new order, the agent skips the model loop and runs only two cheap checks. The measured results:
  • Template mode: 5 tool calls in 165 ms cold, versus 2 calls in 88 ms with memory.
  • Gemini mode: 5 calls in about 14 seconds cold, versus 2 calls in about 107 ms with memory.
To be clear, the speedup comes from skipping the model, not from the graph being faster. And memory mode scores a little lower (80 instead of 85), because it only re-checks the users it can see. Memory makes the answer faster, not more confident.

What went wrong along the way

  • A browser test found that a pending, unconfirmed case was wrongly triggering "answered from memory." Fixed.
  • The risk score on the memory path showed 100 while its reasons added up to 80. I made the score equal the sum of its reasons.
  • An undirected shortestPath worked on my local Docker image but was rejected by FalkorDB Cloud. I switched to algo.SPpaths, with a variable-length fallback.
  • Building the Docker image on Python 3.12 caught a missing import that my local Python had hidden.
  • A new Free-tier cloud instance shows "Deploying" and "Unhealthy" for several minutes. It is normal, but I did not know that and deleted it once.

Limits

  • The data is synthetic and the rings are planted, so these results say nothing about real-world accuracy.
  • The score thresholds are illustrative and not tuned.
  • "No links found" is not a safety claim.
  • A real system would need real data, careful tuning, and human review of every decision.

Try it

  • Code: https://github.com/Manasa-L-Hegde/RingWatch
  • Demo video: [VIDEO_URL]
  • Live demo: [LIVE_URL] (the free host sleeps when idle, so the first load can take about a minute)
I used AI coding assistants while building this, as disclosed in the repository README.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article