Graph Algorithms¶
Code review, 4:02 PM. A PR adds CALL gds.wcc.stream() directly inside the risk API's request handler, "so fraud rings are always fresh." The reviewer's first question: what happens to the serving cluster the first time this runs against a 40-million-edge graph in the middle of the afternoon?
A. Nothing — WCC is O(log n) with the right index. B. The one request just takes longer; other traffic is unaffected. C. The in-memory graph projection pins RAM and CPU, and every other Bolt session queued behind it stalls. D. It fails fast with an out-of-memory error and the request moves on.
Pick one before reading on. Storing User → Device → IP → Transaction → Merchant lets you walk. Finding a ring, a bridge account, or similar merchants is a different class of work — global (or large-subgraph) algorithms. In fraud, three matter first: Weakly Connected Components (WCC), PageRank, node similarity. The Staff skill is when they run — not calling GDS on the payment path.
Start with the situation¶
Nightly fraud job:
- WCC on 90-day
USES+ selectedMADEedges → component ids → candidate rings. - PageRank (or betweenness on a small ring) → who to investigate first inside a component of 500 accounts.
- Node similarity (Jaccard on shared devices/merchants) → link-score for accounts that never share a single hop in a naive MATCH, and recs-style “merchants like this merchant.”
Dashboards still use ClickHouse. The risk API still uses bounded MATCH. Algorithms fill the batch intelligence slot.
Why the obvious approach breaks¶
- Algorithms are O(edges) or worse and need a projected graph in RAM (GDS) or a distributed Pregel (Spark).
- They do not belong in a Bolt query that holds the serving page cache.
- WCC on a graph with a supernode IP collapses the world into one component.
- PageRank without a window ranks “Amazon” and CGNAT, not mules.
- Similarity is quadratic in naive form; you need knn / sampling / filters.
The hard part is pipeline design: what edges go into the projection, when it runs, where results land.
Build the mental picture¶
| Algorithm | Picture | Fraud question |
|---|---|---|
| BFS / shortest path | Layers from a seed | How close is this account to a bad IP? Online OK if bounded |
| WCC | Paint connected blobs | What are the rings? Batch |
| PageRank | Importance flows on edges | Who is central in this blob? Batch / on a subgraph |
| Node similarity | Shared-neighbour Jaccard | Who looks like whom? Batch; recs too |
| Louvain | Dense communities | Finer than WCC when the blob is huge |
BFS is an algorithm but in Neo4j it is just MATCH *1..3 — operational. WCC is not a MATCH.
flowchart LR
subgraph online [Online - OLTP graph]
M["Bounded MATCH / BFS"]
end
subgraph offline [Offline - GDS / Spark]
W["WCC"]
P["PageRank"]
S["Node similarity"]
end
subgraph serve [Serving results]
KV["KV / Postgres flags"]
CH["ClickHouse"]
end
M --> API[Risk API]
W --> KV
W --> CH
P --> CH
S --> Rec[Recs / link scores] Under the hood¶
Neo4j GDS: gds.graph.project copies a subset of nodes/edges into an in-memory compressed graph. Algorithms run on that. Mutating algorithms write properties back (componentId, score). The projection is the real schema: if you include [:FROM] to NAT IPs, WCC is one blob.
Spark GraphX / Pregel: same algorithms when the graph does not fit GDS RAM. Worse interactivity, better scale.
When vs OLAP: ClickHouse aggregates tables. It does not compute components unless you precompute edges and run a specialised job. Do not fake WCC with GROUP BY device_id. That finds stars, not multi-hop rings.
Put it to work¶
Project the fraud graph (90-day, no supernodes)¶
CALL gds.graph.project(
'fraud90',
{
User: { label: 'User' },
Device: { label: 'Device', properties: 'flag' },
IP: { label: 'IP', properties: 'flag' },
Merchant: { label: 'Merchant' }
},
{
USES: { type: 'USES', orientation: 'UNDIRECTED' },
AT: { type: 'AT', orientation: 'UNDIRECTED' }
}
);
Better: cypher projection that drops supernodes and old edges:
MATCH (u:User)-[r:USES]->(x)
WHERE r.last_seen > datetime() - duration('P90D')
AND coalesce(x.flag,'') <> 'supernode'
AND (x:Device OR x:IP)
WITH gds.graph.project(
'fraud90',
u,
x,
{},
{ relationshipType: 'USES' }
) AS g
RETURN g;
(Exact project APIs evolve; the idea is filter then project, never project then pray.)
WCC — rings¶
CALL gds.wcc.stream('fraud90')
YIELD nodeId, componentId
WITH gds.util.asNode(nodeId) AS n, componentId
WHERE n:User
RETURN componentId, count(*) AS users
ORDER BY users DESC
LIMIT 50;
Write back for analysts:
Then export:
Load into ClickHouse: ring_id, user_id, computed_at. Dashboards join rates; they do not run WCC.
When to run: nightly or on-demand after an incident. Never per payment.
When not: if 80% of users share one NAT IP still in the projection — you will get one component. Fix the model first.
PageRank — who matters in the blob¶
Global PageRank on users+merchants+IPs ranks popular merchants. Useful as a sanity check, noisy as a fraud score.
Do this instead: project one WCC (or the 2-hop ego of a seed) and PageRank there.
CALL gds.pageRank.stream('fraud90')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS n, score
WHERE n:User
RETURN n.user_id, score
ORDER BY score DESC
LIMIT 20;
Interpretation: high score = connected to other well-connected nodes (mule clusters, not necessarily the boss). Combine with betweenness on small graphs to find bridges:
CALL gds.betweenness.stream('ring_42')
YIELD nodeId, score
RETURN gds.util.asNode(nodeId).user_id AS user_id, score
ORDER BY score DESC LIMIT 10;
Betweenness on the full 100M-edge graph is the job you postpone.
When: after WCC, on components above a size threshold (e.g. 20–5,000 users). Not on the live serving graph at noon.
Node similarity — collusion and recs¶
Jaccard: users similar if they share devices/IPs/merchants.
CALL gds.nodeSimilarity.stream('fraud90', { topK: 10, similarityCutoff: 0.2 })
YIELD node1, node2, similarity
WITH gds.util.asNode(node1) AS a, gds.util.asNode(node2) AS b, similarity
WHERE a:User AND b:User
RETURN a.user_id, b.user_id, similarity
ORDER BY similarity DESC
LIMIT 100;
Fraud: pairs with high similarity and disjoint KYC. Recs: run on (:User)-[:BOUGHT]->(:Product) or merchant graph; write ALSO_BOUGHT.
When: batch. Not nodeSimilarity inside the 200 ms risk budget.
BFS — the online one¶
MATCH path = (start:User {user_id: $uid})-[:USES*1..3]-(reachable)
WHERE coalesce(reachable.flag,'') <> 'supernode'
RETURN DISTINCT reachable, length(path);
This is algorithmic BFS implemented as traversal. Keep it in Neo4j serving. Do not use GDS BFS for the payment API unless you measured it on a dedicated graph and even then you probably wanted MATCH.
Louvain — optional¶
When WCC blobs are huge (marketplace), Louvain splits dense communities. Run offline; modularity is sensitive to resolution. Do not lead with it in fraud v1 — WCC + filters first.
Mapping to business questions¶
| Question | Algorithm | Online or batch |
|---|---|---|
| Connected to this seed in ≤3 hops? | BFS / MATCH | Online, bounded |
| Shortest path A–B? | shortestPath, cap length | Investigation |
| What are the rings? | WCC | Batch |
| Who is central in ring 42? | PageRank / betweenness on subgraph | Batch |
| Who looks like this mule? | Node similarity | Batch |
| Customer segments from behaviour | Louvain / embeddings | Batch |
| Fraud $ by category | Not a graph algorithm — OLAP | Dashboard |
| Recs carousel | Similarity precomputed | Online read of results |
Where teams get caught¶
WCC + supernode = one ring
Filter NAT IPs and mega-merchants before project.
PageRank as fraud score
Popular ≠ criminal. Use as a ranker inside a suspicious component.
GDS on the serving instance
Steal RAM, wreck p99. Separate projection host or window.
Writing component ids without a version
Nightly WCC renumbers. Join on computed_at. Do not treat wcc_id=7 as stable forever.
Similarity on raw Transaction nodes
Too sparse or too unique. Similarity on User/Device/Merchant with aggregated edges.
How it fails¶
- Job runs 14 hours, overlaps the next nightly, cluster out of RAM.
- Analysts treat a 2-million-user component as a ring (it is the US NAT blob).
- Risk API blocked on GDS write locks.
- Spark GraphX job on all history without a window; shuffle death.
- Recs team runs similarity every request against live graph.
How to investigate¶
- Size the projection: node/edge counts, max degree. If max degree is 10^6, stop.
- WCC size histogram: if the largest component is 40% of users, the projection is wrong.
- Spot-check a component in Browser: is it a shared office NAT or a real mule set?
- Runtime vs
gds.graph.listmemory. If you cannot fit, subgraph or Spark. - Compare to OLAP: component’s fraud rate in ClickHouse. A huge component with baseline rate is not a ring.
Scale: 10× / 100× / 1000×¶
10×. GDS on a replica, 90-day USES, nightly WCC + PageRank on top-N components.
100×. Filter harder (degree caps, drop top 0.01% degree nodes into a separate “infrastructure” list). Similarity only on candidates (users already in WCC > N or on a watchlist), not all-pairs. Export edges to Spark if RAM fails.
1000×. Algorithms live next to the lake (Iceberg edge list + Spark / dedicated graph processor). Neo4j holds a sample or recent graph for investigation. Online systems read precomputed ring_id, pagerank_in_ring, similar_users[10] from KV. Recs are a two-tower model; GDS similarity is a candidate generator at most.
Trade-offs¶
GDS: excellent iteration for mid-size graphs, dangerous on the serving box. Spark: scale, slow iteration. OLAP: cannot replace WCC. Embeddings/GNNs: more recall for recs and sophisticated fraud, more ML ops — not a substitute for WCC v1.
Alternatives¶
| Tool | Role |
|---|---|
| Neo4j GDS | Mid-scale, Cypher-adjacent |
| Spark GraphX / GraphFrames | Lake-scale WCC/PageRank |
| NetworkX | Notebook, not production scale |
| DGL / PyG | GNN research/prod ML |
| ClickHouse | Metrics on outputs, not components |
| Pregel-on-Flink | Niche streaming graphs |
Apply¶
If someone wants “real-time connected components” on each payment, say no: incremental WCC exists in papers and some products; you still need a bounded online check (MATCH/KV) plus a periodic algorithm. Ship WCC nightly first. Put results where analysts already query (ClickHouse) and where the API can GET user→ring_risk.
Check your understanding¶
Place the jobs on a clock
Payment p99 80 ms. 40M users, 200M USES edges / 90 days. Analysts want rings, top accounts per ring, similar merchants for recs, and a dashboard of fraud rate by ring.
- What runs in the risk API process?
- What is the nightly GDS (or Spark) pipeline, in order?
- Why PageRank before WCC is the wrong order.
- Where does ClickHouse come in, and what does it not compute?
- A NAT IP with 8M users was left in the projection. What do you see, and how do you fix it?
Answer
-
Bounded MATCH (2-hop USES) + read of precomputed flags (
ring_risk, watchlist). No WCC/PageRank/similarity. -
Filter supernodes → project 90-day graph → WCC → size histogram → PageRank/betweenness per large-but-not-huge component → node similarity on candidates → write properties/export to KV + ClickHouse. Recs similarity can be a separate product graph.
-
Global PageRank without components ranks popular infrastructure. You need blobs first, then rank inside blobs.
-
ClickHouse: fraud amount, rates, time series by
ring_id. It does not compute WCC. -
One giant component (~everyone). Fix: flag/remove that IP (and degree outliers) before project; rerun. Do not “Louvain the blob” as the first fix — remove the lie in the edge set.