2025-10-22

Finanzkriminalität aufdecken mit DuckDB und Graph-Abfragen

Daniël ten Wolde

Dem Geld zu folgen ist schwerer, als es aussieht. Raffinierte Kriminelle verwischen ihre Spuren mit langen, komplexen Transaktionsketten, um den Ursprung illegaler Mittel zu verschleiern. Diese Netzwerke zu entwirren ist ein klassisches Graph-Problem: Sie suchen verdächtige Muster und versteckte Pfade in einem großen Netz aus Konten und Transaktionen.

Jahrelang bedeutete diese Art von Analyse oft, Daten in eine spezialisierte Graph-Datenbank zu exportieren – mit zusätzlicher Komplexität und Overhead. Aber was, wenn Sie diese mächtige Graph-Analyse direkt in Ihrer Alltag-Datenbank ausführen könnten?

Hier glänzt DuckDBs Erweiterbarkeit. In diesem Blogbeitrag tauchen wir in einen Finanzdatensatz ein und nutzen DuckDB mit einer Graph-Query-Erweiterung, um die Art von Mustern zu identifizieren, die auf ein Geldwäsche-Schema oder anderweitig risikoreiche Konten hinweisen könnten.

Von relationalen Tabellen zu einem Property Graph

Bevor wir verdächtige Aktivitäten jagen können, müssen wir unsere Daten verstehen. Wir nutzen den LDBC-Financial-Benchmark-Datensatz, der ein Finanznetzwerk simuliert. Um sich an die Datenbank mit dem Datensatz anzuhängen, führen Sie aus:

ATTACH 'https://blobs.duckdb.org/data/finbench.duckdb' AS finbench;
USE finbench;

Um den Beispielen in diesem Beitrag zu folgen, empfehlen wir DuckDB v1.4.1.

In diesem Blogbeitrag nutzen wir eine Teilmenge des Datensatzes mit Tabellen für Person, Account und die Tabelle AccountTransferAccount, die sie verbindet.

Zuerst ein Gefühl für die Größe unseres Netzwerks:

SELECT
(SELECT count(*) FROM Person) AS num_persons,
(SELECT count(*) FROM Account) AS num_accounts,
(SELECT count(*) FROM AccountTransferAccount) AS num_transfers;
┌─────────────┬──────────────┬───────────────┐
│ num_persons │ num_accounts │ num_transfers │
│ int64 │ int64 │ int64 │
├─────────────┼──────────────┼───────────────┤
│ 785 │ 2055 │ 8132 │
└─────────────┴──────────────┴───────────────┘

Diese Abfrage gibt uns einen schnellen Überblick über die Zahl der Entitäten und Verbindungen. Wie das Schema-Diagramm oben zeigt, bilden diese Tabellen aus Konten und Transfers bereits einen Graphen: eine Struktur aus Knoten oder Vertices (den Entitäten), verbunden durch Kanten (die Relationen zwischen den Entitäten).

Um unsere Abfragen mächtiger zu machen, nutzen wir das Property-Graph-Modell. Das ist nur die formale Art zu sagen, dass wir beschreibende Labels hinzufügen können, allgemeine Typen wie Account und Person, sowie konkrete Properties wie accountId und nickname.

Wenn Sie denken, das klingt sehr nach dem relationalen Modell, liegen Sie genau richtig. Eine Person-Tabelle ist einfach eine Sammlung von Knoten mit dem Label Person, und ihre Spalten sind die Properties. Dieses natürliche Mapping macht eine hochperformante relationale Datenbank wie DuckDB zu einem perfekten Fundament für Graph-Analytics.

Property Graphs in DuckDB

Um unsere Graph-Abfragen zu schreiben, könnten wir DuckDB und das SQL nutzen, das wir kennen. Aber machen wir uns das Leben etwas einfacher und nutzen DuckDBs reiches Extension-Ökosystem. Wir verwenden DuckPGQ, eine Community-Erweiterung, die DuckDBs Parser um eine neue visuelle Graph-Syntax erweitert. Diese neue Syntax ist SQL / Property Graph Queries (SQL/PGQ), Teil des offiziellen SQL:2023-Standards. SQL/PGQ ist teilweise von der beliebten Graph-Abfragesprache Cypher inspiriert.

Die DuckPGQ-Erweiterung begann als Forschungsprototyp und ist jetzt als Community-Erweiterung verfügbar.

Installation und Laden der Erweiterung sind so einfach wie es nur geht:

INSTALL duckpgq FROM community;
LOAD duckpgq;

In DuckPGQ ist der erste Schritt, den Property Graph anzulegen, der als Schicht über den zuvor erstellten Tabellen wirkt.

CREATE PROPERTY GRAPH finbench
VERTEX TABLES (
Person,
Account
)
EDGE TABLES (
AccountTransferAccount
SOURCE KEY (fromId) REFERENCES Account (accountId)
DESTINATION KEY (toId) REFERENCES Account (accountId)
LABEL Transfer,
PersonOwnAccount
SOURCE KEY (personId) REFERENCES Person (personId)
DESTINATION KEY (accountId) REFERENCES Account (accountId)
LABEL PersonOwn
);

Beim Anlegen des Property Graph unterscheiden wir klar zwischen VERTEX-Tabellen und EDGE-Tabellen. Für VERTEX-Tabellen müssen wir nur den Tabellennamen angeben. Für EDGE-Tabellen ist etwas mehr Arbeit nötig: Für SOURCE und DESTINATION müssen wir die Spalte in der Edge-Tabelle angeben, die den Schlüssel für SOURCE bzw. DESTINATION bildet. Das ist dasselbe Prinzip wie das Definieren einer FOREIGN KEY-Constraint, die unsere Edge-Tabelle wieder mit den Knotentabellen verbindet, die sie verknüpft. Die LABEL-Klausel gibt dem Beziehungstyp einen sauberen Namen. Während unsere Tabelle AccountTransferAccount heißt, stellen die Kanten darin eine Transfer-Beziehung dar. Das ist der Name, den wir in unseren Graph-Abfragen verwenden.

Jetzt, da wir unseren Property Graph angelegt haben, können wir die Finanzdaten untersuchen und ihre Geheimnisse aufdecken!

Graph-Verarbeitung

Wenn wir in Datenbanken von Graph-Verarbeitung sprechen, meinen wir typischerweise diese Arten von Operationen:

Schauen wir, wie wir DuckDB und DuckPGQ für diese beiden Aufgaben nutzen können.

Jagd auf verdächtige Aktivitäten

Wie erwähnt führt SQL/PGQ eine visuelle Graph-Syntax ein, um Graph-Muster natürlicher zu formulieren. Nutzen wir sie, um nach Mustern zu suchen, die auf ein Geldwäsche-Schema hinweisen könnten.

Eine gängige Technik, um illegale Mittel zu verbergen, heißt Smurfing. Das Ziel von Smurfing ist, eine einzelne große Überweisung, die Meldepflichten auslösen könnte, in kleinere Transaktionen über die Zeit aufzuteilen.

Wir können nach diesem Verhalten suchen, indem wir Paare von Konten mit einer hohen Zahl von Transaktionen, aber einem relativ niedrigen Durchschnittsbetrag suchen. Setzen wir die Schwelle für den Durchschnittsbetrag auf 50.000 $ und schauen, ob wir hochfrequente Beziehungen finden:

SELECT
fromName,
count(amount) AS number_of_transactions,
round(avg(amount), 2) AS avg_amount,
toName
FROM GRAPH_TABLE (finbench
MATCH (a:Account)-[t:Transfer]->(a2:Account)
COLUMNS (a.nickname AS fromName,
t.amount,
a2.nickname AS toName
)
)
GROUP BY ALL
HAVING avg_amount < 50_000
ORDER BY number_of_transactions DESC, avg_amount ASC
LIMIT 5;

Die Ausführung der Abfrage führt zu folgendem Ergebnis:

┌───────────────────┬────────────────────────┬────────────┬───────────────────┐
│ fromName │ number_of_transactions │ avg_amount │ toName │
│ varchar │ int64 │ double │ varchar │
├───────────────────┼────────────────────────┼────────────┼───────────────────┤
│ Noe Trites │ 1 │ 49365.04 │ Dale Croucher │
│ Madeleine Bussing │ 1 │ 46663.56 │ Delphine Primiano │
│ Bonnie Centeno │ 1 │ 46663.56 │ Maile Boon │
│ Darci Sheedy │ 1 │ 44856.02 │ Carmella Estelle │
│ Marguerita Gurne │ 1 │ 44393.68 │ Delphine Primiano │
└───────────────────┴────────────────────────┴────────────┴───────────────────┘

Die Abfrage hat funktioniert, aber das Ergebnis zeigt keine Anzeichen verdächtiger Aktivität – die Zahl der Transaktionen ist immer 1. Um zu verstehen, warum, schauen wir uns an, wie die Abfrage konstruiert wurde.

Die Magie passiert in der FROM-Klausel. Die Funktion GRAPH_TABLE (finbench ...) erlaubt uns, eine Graph-Abfrage über den gerade angelegten Property Graph auszuführen und ihre Ausgabe wie eine normale Tabelle zu behandeln.

Die Klausel MATCH (a:Account)-[t:Transfer]->(a2:Account) ist der Kern unseres Musters. Sie beschreibt visuell, wonach wir suchen: eine einfache Überweisung von einem Konto (a:Account) zu einem anderen (a2:Account). Die () bezeichnen Knoten, die [] die verbindende Kante, der ASCII-Pfeil -> die Richtung der Kante. Die Klausel COLUMNS(...) wirkt dann wie eine SELECT-Liste für unser Muster und holt den Nickname der Konten und den Betrag der Überweisung.

Das Schöne an SQL/PGQ: Das Ergebnis dieses Graph-Pattern-Match kann nahtlos in das Standard-SQL zurückfließen, das wir schon kennen. Wir nutzen GROUP BY ALL, um alle Transfers zwischen denselben zwei Personen zu aggregieren, und unsere Klausel HAVING avg_amount < 50_000 filtert auf das definierte Smurfing-Muster.

Wir wissen, dass unsere Abfrage korrekt ist, aber auch, dass dieses einfache Smurfing-Muster in unserem Datensatz nicht vorkommt. Das heißt, wir müssen weiter untersuchen, potenziell mit komplexeren Mustern. Das führt uns zu einem mächtigeren Feature von Graph-Abfragen: strukturelle Muster zu finden, die mit traditionellen SQL-JOINs sehr schwer auszudrücken sind, etwa Transaktionspfade.

Pfade in den Transaktionen finden

Ein weiteres klassisches Beispiel für mögliches betrügerisches Verhalten ist ein Zyklus von Transaktionen, bei dem das Geld zur Person zurückkehrt, die die erste Transaktion in der Kette gesendet hat. Eine SQL-Abfrage für diese Frage zu schreiben ist mit der traditionellen Syntax unglaublich schwer. Versuchen Sie es selbst, nachdem Sie diesen Abschnitt gelesen haben! Die Antwort zeigen wir im nächsten Abschnitt.

Mit SQL/PGQ ist das Schreiben von Abfragen, die Pfade finden, deutlich einfacher geworden. Das Diagramm oben illustriert das Query-Muster, das wir nutzen: einen Pfad von einer oder mehr Überweisungen zwischen zwei unterschiedlichen Konten (A1 und A2) zu finden, die derselben Person (P) gehören. Denken Sie daran, dass Personen mehrere Konten besitzen können. Mit der folgenden Abfrage versuchen wir, Zyklen zwischen allen Konten der Person mit der ID 125 zu finden:

FROM GRAPH_TABLE(finbench
MATCH p = ANY SHORTEST
(p:Person)-[o1:PersonOwn]->(a1:Account)
-[t:Transfer]->+
(a2:Account)<-[o2:PersonOwn]-(p:Person)
WHERE
p.personId = 125 AND a1.accountId <> a2.accountId
COLUMNS (
path_length(p) AS path_length,
a1.accountId AS start_account,
a2.accountId AS end_account
)
)
ORDER BY path_length;

Das Ergebnis zeigt, dass es für diese Person mit mehreren Konten Zyklen unterschiedlicher Länge gibt:

┌─────────────┬─────────────────────┬─────────────────────┐
│ path_length │ start_account │ end_account │
│ int64 │ int64 │ int64 │
├─────────────┼─────────────────────┼─────────────────────┤
│ 8 │ 4753267931712848113 │ 4794926228266025204 │
│ 8 │ 4769874955338776819 │ 4794926228266025204 │
│ 8 │ 4796615078126289138 │ 4769874955338776819 │
│ 9 │ 4753267931712848113 │ 4769874955338776819 │
│ 9 │ 4769874955338776819 │ 4753267931712848113 │
│ 9 │ 4794926228266025204 │ 4753267931712848113 │
│ 9 │ 4796615078126289138 │ 4753267931712848113 │
│ 9 │ 4796615078126289138 │ 4794926228266025204 │
│ 12 │ 4794926228266025204 │ 4769874955338776819 │
└─────────────┴─────────────────────┴─────────────────────┘

Wieder passiert die Magie in der FROM-Klausel, wo wir jetzt ein MATCH anlegen, das ANY SHORTEST Pfad entlang des gegebenen Musters findet. Der erste Teil des Musters findet alle Konten der Person 125: (p:Person)-[o1:PersonOwn]->(a1:Account). Im zweiten Teil findet das Path-Finding statt. Achten Sie besonders auf das +, das anzeigt, dass für das Muster (a1:Account)-[t:Transfer]->+(a2:Account) die beiden Knoten (a1) und (a2) nicht unbedingt direkt verbunden sein müssen. Mit dem + geben wir an, dass es eine oder mehr Transfers zwischen diesen beiden Konten geben muss, ohne obere Grenze. Zum Schluss nehmen wir das Zielkonto und prüfen, ob es derselben Person p gehört.

Die Ergebnisse bestätigen unseren Verdacht. Unsere Abfrage hat mehrere nicht-offensichtliche Pfade zwischen den Konten der Person 125 gefunden, mit Pfadlängen von 8 bis 12 Transfers.

Jede Zeile steht für eine versteckte Transaktionskette, die zwei Konten der Person verbindet. Interessanter noch: Wir sehen klare zyklische Muster. Die Abfrage fand zum Beispiel einen 9-Schritt-Pfad von Konto 4753267931712848113 zu 4769874955338776819 und einen weiteren 9-Schritt-Pfad in die Gegenrichtung. Das deutet auf eine raffinierte, absichtliche Bewegung von Geld zwischen Konten hin – ein starker Indikator, der weitere Untersuchung rechtfertigt.

Auf die altmodische Weise

Weiter oben haben wir Sie herausgefordert, darüber nachzudenken, wie Sie diese Besitz-Zyklen mit traditionellem SQL finden würden. Wie versprochen, hier die Antwort.

Bevor wir in die Abfrage eintauchen, zwei wichtige Hinweise beim Vergleich mit der SQL/PGQ-Version:

  1. Performance-Absicherung: Die Abfrage braucht eine manuelle Obergrenze für die Pfadlänge (ps.depth < 11), um unendliche Rekursion und potenziell quadratische Laufzeiten auf dichten Graphen zu verhindern. Die SQL/PGQ-Syntax ->+ braucht das nicht.

  2. Unterschied in der Pfadlänge: Sie werden merken, dass die path_length im Ergebnis dieser Abfrage zwei Hops kürzer ist als das Ergebnis von DuckPGQ. Das liegt daran, dass diese Abfrage nur die Transfer-Kanten zählt, während die DuckPGQ-Abfrage auch die beiden PersonOwn-Kanten in die Pfadberechnung einbezieht.

Mit dem im Hinterkopf hier die traditionelle rekursive CTE, um den kürzesten Pfad zwischen zwei beliebigen Konten derselben Person zu finden:

WITH RECURSIVE
owned_accounts AS (
SELECT accountId
FROM PersonOwnAccount
WHERE personId = 125
),
path_search(start_node, end_node, path, depth) AS (
-- Base case: a direct transfer from one of the person's accounts
SELECT
fromId,
toId,
[fromId, toId],
1
FROM
accounttransferaccount
WHERE
fromId IN (SELECT accountId FROM owned_accounts)
UNION ALL
-- Recursive step: find the next transfer in the path
SELECT
ps.start_node,
t.toId,
list_append(ps.path, t.toId),
ps.depth + 1
FROM path_search ps
JOIN accounttransferaccount t ON ps.end_node = t.fromId
WHERE
t.toId NOT IN (SELECT unnest(ps.path)) AND ps.depth < 11
)
SELECT distinct start_node, end_node, min(depth) AS path_length
FROM path_search
WHERE end_node IN (SELECT accountId FROM owned_accounts)
AND start_node <> end_node
GROUP BY ALL
ORDER BY path_length;

Wie Sie sehen, braucht die Logik eine WITH RECURSIVE-Klausel, manuelles Path-Tracking in einer Liste und explizite Zyklenerkennung. Genau die Art von weitschweifiger, komplexer Abfrage, die die visuelle Syntax von SQL/PGQ eliminieren soll.

Fazit

Wir haben diesen Beitrag mit einem einfachen Ziel begonnen: zu sehen, ob wir DuckDB nutzen können, um die komplexen Muster und versteckten Pfade typischer Graph-Analyse zu jagen. Nach dem Eintauchen in den Financial-Benchmark-Datensatz ist die Schlussfolgerung klar: Ja.

Die zentrale Erkenntnis ist die drastische Verbesserung der Nutzbarkeit. Wir haben gesehen, wie die visuelle Syntax von SQL/PGQ, ermöglicht durch die DuckPGQ-Erweiterung, eine anspruchsvolle „Besitz-Zyklus“-Abfrage von einer monströsen rekursiven CTE in ein paar lesbare Zeilen Code verwandelt hat. Genau diese Ausdruckskraft braucht man für reale analytische Aufgaben. Mehr Informationen und die vollständige Dokumentation zu DuckPGQ finden Sie auf der offiziellen Website: duckpgq.org.

Ebenso wichtig: Diese gesamte Untersuchung lief direkt in DuckDB. Über das Extension-Ökosystem haben wir die Macht von Graph-Abfragen angezapft, ohne jemals Daten zu exportieren oder ein separates, spezialisiertes System zu betreiben. Alles läuft auf DuckDBs hochperformanter vektorisierter Engine, genau dort, wo die Daten leben.

Für mächtige, analytische Graph-Abfragen ist DuckDB nicht nur eine viable Alternative, sondern eine starke, natürliche Lösung. Wenn Sie das nächste Mal Verbindungen in Ihren Daten analysieren wollen, denken Sie daran: Die nötigen Werkzeuge sind nur ein INSTALL entfernt.