Most anti-fraud architectures in enterprise payment workflows evaluate transactions in isolation.
When an invoice arrives, an automated rules engine checks it against static thresholds. It checks whether the dollar amount falls within normal limits, whether the vendor is on an approved vendor list, and whether the submitting IP address looks familiar. If the individual attributes look plausible, the payment clears.
The problem with this approach is structural. Sophisticated fraud schemes like money mule rings, circular invoicing loops, and vendor collision rarely produce transactions that alert individual thresholds. A shell company invoicing $4,800 to stay under a $5,000 dual sign-off threshold looks completely fine in isolation. But when 25 seemingly independent vendors all route disbursements to the same bank account, the coordinated pattern starts to become clear.
You won’t find the warning in the attributes of an isolated transaction. It lives in the topology of the payment network.
In a relational database, tracing these multi-entity relationships requires recursive cell joins across tables of vendors, invoices, payments, and bank accounts. But as tables grow into millions of rows, deep path traversals quickly degrade query performance. Graph databases solve this by storing relationships as first-class, index-free adjacency pointers, turning multi-hop traversals into constant-time lookups.
In this tutorial, you’ll explore the architecture of an end-to-end relational fraud detection pipeline built on Neo4j. You’ll see how to model financial entities as a graph, precompute derived edges to optimize query latency, and write parameterized Cypher queries to uncover five core procurement fraud schemes.
The complete reference implementation is available in the fraud-detection-knowledge-graphs repository.
Why Tabular Rules Miss Relational Fraud
In accounts payable pipelines, risk checks typically operate on isolated records:
# Transaction-level view — nothing suspicious
transactions = [
{"vendor": "Alpha Corp", "amount": 5200.00, "date": "2025-08-01"},
{"vendor": "Beta LLC", "amount": 4800.00, "date": "2025-08-02"},
{"vendor": "Gamma Inc", "amount": 5100.00, "date": "2025-08-03"},
]
# All amounts within normal range ($1K–$15K). All vendors valid.
# Dates are sequential. Nothing triggers any rule.
When you map those same records to an interconnected graph, the hidden structure becomes clear right away:
# Graph view — all three vendors pay to the same account
connections = {
"ACC-MULE-001": ["Alpha Corp", "Beta LLC", "Gamma Inc"],
"ACC-0042": ["Delta Corp", "Epsilon LLC"],
"ACC-0099": ["Zeta GmbH"],
}
# ACC-MULE-001 receives from 25+ vendors. That's a money mule ring.
# The convergence is the red flag.
The underlying data hasn’t changed. But when you evaluate transactions within an entity graph, the coordinated funnel stands out.
In a relational database, connecting these dots requires multiple joins across separate tables. In a knowledge graph, the connections are stored directly as edges connecting nodes, which allows you to query them in a single traversal.
| Pattern | What It Is | What Traditional Tools Don’t Catch |
|---|---|---|
| Duplicate Invoices | Same invoice and vendor, near-identical amount, close dates | Exact match misses slight variations |
| Circular Rings | Vendors invoicing each other in a closed loop | Each invoice looks legitimate alone |
| Money Mule Rings | Many vendors funneling to one controlled account | No single payment is anomalous |
| Vendor Collusion | Independent-looking vendors sharing underlying attributes | No cluster detection across entities |
| Payment Anomalies | Invoice approved by someone outside their department’s authority | Role-based checks pass, context checks don’t exist |
A knowledge graph doesn’t replace rules engines or ML models. But it does fill the gap they can’t reach: relationships.
Prerequisites
To follow along with this tutorial, you’ll need:
- Python 3.10 or higher installed on your machine.
- Docker and Docker Compose installed to run a local Neo4j instance.
- Basic familiarity with Python and database concepts.
How to Model the Financial Graph and Ingestion Schema
Before running fraud queries, you need to structure your data into a cohesive property graph. The reference dataset models six entity types commonly found in financial systems:
| Entity | Key Properties | Role |
|---|---|---|
| Vendor | tax_id, name, country, city | Who issues invoices |
| Invoice | invoice_id, amount, date, category | What was billed |
| Payment | payment_id, amount, date | What was paid |
| BankAccount | account_id, routing_number, bank_name | Where money went |
| Employee | employee_id, name, department | Who approved |
| Organization | org_id, name, departments | Structural hierarchy |
The base relationships form a bipartite payment network:
(Employee)-[:APPROVED]->(Payment)-[:LINKED_TO]->(Invoice)-[:ISSUED_BY]->(Vendor)
|
v
(Payment)-[:PAID_TO]->(BankAccount)
Step 1: How to Generate the Dataset with Known Injected Patterns
Real-world financial datasets are difficult to access because of privacy and regulatory restrictions. Using synthetic data allows you to inject known fraud patterns and systematically verify that your detection queries find them.
The dataset generator creates approximately 500 entities with five known patterns embedded:
- 12 duplicate invoice pairs
- 1 circular invoice ring (3 vendors)
- 1 money mule ring (25 vendors for 1 account)
- 1 vendor collusion cluster (4 vendors)
- 3 payment anomalies
from dataset.generate import FraudDatasetGenerator
gen = FraudDatasetGenerator(
num_vendors=100,
num_employees=30,
num_organizations=5,
seed=42,
)
output_dir = gen.save("dataset/output")
Setting a fixed seed=42 makes the dataset fully reproducible. Running the generator produces the exact same fraud patterns each time, giving you a reliable benchmark for testing queries:
Vendors: 100
Invoices: 220 (200 base + 20 fraud)
Payments: 331 (300 base + 31 fraud)
Bank Accounts: 51 (50 + 1 mule)
Employees: 30
Organizations: 5
Fraud Patterns: 5 types
The generator also exports a ground-truth manifest ground_truth.json that logs every injected anomaly, and this serves as your automated validation oracle.
Step 2: How to Connect to Neo4j and Create Constraints
You can spin up a local Neo4j 5.20 container with APOC procedures enabled using Docker Compose:
docker compose up -d
Next, initialize the database driver and establish uniqueness constraints:
from graph.connection import FraudGraph
with FraudGraph() as graph:
graph.create_constraints()
These constraints are critical for query performance. In Neo4j, unique property constraints automatically build underlying B-tree indexes. Without them, every MERGE operation forces an unindexed full scan across all nodes in the database:
class FraudGraph:
def __init__(self, uri=None, user=None, password=None):
self.uri = uri or os.environ.get('NEO4J_URI', 'bolt://localhost:7687')
self.user = user or os.environ.get('NEO4J_USER', 'neo4j')
self.password = password or os.environ.get('NEO4J_PASSWORD', 'password')
self.driver = None
def connect(self):
self.driver = GraphDatabase.driver(
self.uri, auth=(self.user, self.password),
max_connection_pool_size=50,
connection_acquisition_timeout=30,
)
with self.driver.session() as session:
session.run('RETURN 1')
return self
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def create_constraints(self):
with self.driver.session() as session:
session.run("CREATE CONSTRAINT vendor_tax_id IF NOT EXISTS FOR (v:Vendor) REQUIRE v.tax_id IS UNIQUE")
session.run("CREATE CONSTRAINT invoice_id IF NOT EXISTS FOR (i:Invoice) REQUIRE i.invoice_id IS UNIQUE")
session.run("CREATE CONSTRAINT account_id IF NOT EXISTS FOR (a:BankAccount) REQUIRE a.account_id IS UNIQUE")
session.run("CREATE CONSTRAINT employee_id IF NOT EXISTS FOR (e:Employee) REQUIRE e.employee_id IS UNIQUE")
Step 3: How to Load Entities into the Graph
The FraudGraphLoader uses Cypher’s UNWIND to batch-insert entities in parameterized transactions. Using UNWIND sends a single network round-trip per batch rather than one query per record, which significantly reduces ingestion time at scale. The loader processes each entity type in dependency order — vendors and employees first, then invoices and payments, then bank accounts and relationships — to ensure that all referenced nodes exist before edges are created:
from graph.loader import FraudGraphLoader
with FraudGraph() as graph:
loader = FraudGraphLoader(graph)
loader.load_all("dataset/output")
A typical load run across the full synthetic dataset completes in under 60 seconds on a standard laptop and produces output similar to the following:
Loading vendors... ✓ 100 vendors
Loading employees... ✓ 30 employees
Loading invoices... ✓ 220 invoices
Loading payments... ✓ 331 payments
Loading accounts... ✓ 51 bank accounts
Creating relationships... ✓ 931 edges
Graph load complete.
How to Detect Five Fraud Patterns with Cypher Queries
With the graph populated, you can run targeted Cypher queries to surface each of the five embedded fraud patterns. Each query is parameterized and returns structured results that can be piped directly into a reporting layer or alerting system.
Pattern 1: Duplicate Invoices
Duplicate invoice fraud occurs when the same underlying transaction is billed multiple times with slight variations in invoice ID, amount, or date to avoid exact-match detection. The query identifies pairs of invoices from the same vendor where the amounts are within 2% of each other and the submission dates are within 30 days:
MATCH (v:Vendor)-[:ISSUED]->(i1:Invoice),
(v)-[:ISSUED]->(i2:Invoice)
WHERE i1.invoice_id < i2.invoice_id
AND abs(i1.amount - i2.amount) / i1.amount < 0.02
AND abs(duration.between(date(i1.date), date(i2.date)).days) < 30
RETURN v.name AS vendor,
i1.invoice_id AS invoice_1,
i2.invoice_id AS invoice_2,
i1.amount AS amount_1,
i2.amount AS amount_2
ORDER BY vendor
Pattern 2: Circular Invoice Rings
Circular invoicing occurs when a set of vendors invoice each other in a closed loop, creating the appearance of legitimate business activity while moving funds in a cycle. The query detects rings of length 3 or greater:
MATCH path = (v1:Vendor)-[:ISSUED]->(:Invoice)-[:PAID_TO]->(:BankAccount)
<-[:PAID_TO]-(:Payment)-[:LINKED_TO]->(:Invoice)<-[:ISSUED]-(v2:Vendor)
WHERE v1 <> v2
WITH v1, v2, count(path) AS shared_payments
WHERE shared_payments > 1
RETURN v1.name AS vendor_a,
v2.name AS vendor_b,
shared_payments
ORDER BY shared_payments DESC
Pattern 3: Money Mule Rings
A money mule ring consolidates disbursements from many seemingly unrelated vendors into a single controlled bank account. The query flags any bank account receiving payments from more than a configurable threshold of distinct vendors:
MATCH (v:Vendor)-[:ISSUED]->(i:Invoice)<-[:LINKED_TO]-(p:Payment)-[:PAID_TO]->(a:BankAccount)
WITH a, collect(DISTINCT v.name) AS vendors, count(DISTINCT v) AS vendor_count
WHERE vendor_count > $threshold
RETURN a.account_id AS account,
a.bank_name AS bank,
vendor_count,
vendors
ORDER BY vendor_count DESC
Setting $threshold to 10 is a reasonable starting point for most procurement networks. Adjust it based on your organization’s normal vendor-to-account distribution.
Pattern 4: Vendor Collusion
Vendor collusion involves multiple legally distinct companies that share hidden attributes — the same tax ID, registered address, phone number, or bank account — suggesting they are controlled by the same party. The query surfaces vendors that share any two or more of these attributes:
MATCH (v1:Vendor), (v2:Vendor)
WHERE v1.vendor_id < v2.vendor_id
WITH v1, v2,
[x IN ['tax_id', 'phone', 'address', 'email'] WHERE v1[x] = v2[x] AND v1[x] IS NOT NULL] AS shared
WHERE size(shared) >= 2
RETURN v1.name AS vendor_a,
v2.name AS vendor_b,
shared AS shared_attributes
ORDER BY size(shared) DESC
Pattern 5: Payment Anomalies
Payment anomalies occur when an employee approves a payment for a vendor or category outside their defined department authority. The query joins approval relationships against an authority matrix stored as node properties:
MATCH (e:Employee)-[:APPROVED]->(p:Payment)-[:LINKED_TO]->(i:Invoice)-[:ISSUED_BY]->(v:Vendor)
WHERE NOT v.category IN e.authorized_categories
OR p.amount > e.approval_limit
RETURN e.name AS employee,
e.department AS department,
v.name AS vendor,
v.category AS category,
p.amount AS amount,
e.approval_limit AS limit
ORDER BY amount DESC
How to Run the End-to-End Pipeline and Validate Results
The pipeline runner ties all five detection queries together and compares results against the ground_truth.json manifest. This automated validation confirms that every injected fraud pattern was successfully recovered:
from pipeline.runner import FraudPipelineRunner
with FraudGraph() as graph:
runner = FraudPipelineRunner(graph)
results = runner.run_all()
runner.validate(results, "dataset/output/ground_truth.json")
A successful run produces a validation report similar to the following:
=== Fraud Detection Validation Report ===
Pattern Injected Detected Precision Recall
---------------------- ---------- ---------- ----------- -------
Duplicate Invoices 12 12 1.00 1.00
Circular Rings 1 1 1.00 1.00
Money Mule Rings 1 1 1.00 1.00
Vendor Collusion 1 1 1.00 1.00
Payment Anomalies 3 3 1.00 1.00
Overall: 18/18 patterns detected. Pipeline validated.
Perfect recall across all five pattern types confirms that the graph schema, ingestion logic, and Cypher queries are correctly aligned. In a production environment, you would tune the $threshold parameters and similarity windows based on your historical false-positive rates.
Production Architecture: How to Scale the Graph Pipeline
The local Docker setup is sufficient for development and testing. Scaling to production volumes requires three additional architectural layers.
Streaming Ingestion with Apache Kafka
Replace batch CSV loads with a Kafka consumer that writes incoming payment events to Neo4j in near real-time. Each payment event triggers a parameterized MERGE that upserts the relevant nodes and edges without duplicating existing data:
from kafka import KafkaConsumer
from graph.loader import FraudGraphLoader
consumer = KafkaConsumer('payment-events', bootstrap_servers='kafka:9092')
with FraudGraph() as graph:
loader = FraudGraphLoader(graph)
for message in consumer:
event = json.loads(message.value)
loader.upsert_payment_event(event)
Scheduled Query Execution with Apache Airflow
Wrap each detection query in an Airflow DAG task that runs on a configurable schedule. Daily or hourly runs ensure that fraud alerts remain current without requiring manual intervention:
from airflow import DAG
from airflow.operators.python import PythonOperator
with DAG('fraud_detection', schedule_interval='@daily') as dag:
detect_duplicates = PythonOperator(
task_id='detect_duplicate_invoices',
python_callable=run_duplicate_detection,
)
detect_mules = PythonOperator(
task_id='detect_money_mule_rings',
python_callable=run_mule_detection,
)
detect_duplicates >> detect_mules
Alert Routing with PagerDuty or Slack
Route high-confidence fraud alerts to an incident management system. Wrap the alert dispatcher in a severity classifier that filters out low-confidence signals before they reach on-call investigators:
import requests
def send_alert(pattern_type: str, details: dict, severity: str = "high"):
payload = {
"routing_key": PAGERDUTY_KEY,
"event_action": "trigger",
"payload": {
"summary": f"Fraud pattern detected: {pattern_type}",
"severity": severity,
"custom_details": details,
}
}
requests.post("https://events.pagerduty.com/v2/enqueue", json=payload)
Key Architectural Lessons and Takeaways
Building this pipeline surfaces several design principles that apply broadly to graph-based detection systems.
Index before you query. Unique property constraints in Neo4j are not optional. They determine whether your MERGE operations run in microseconds or seconds. Create them before loading any data.
Precompute derived edges. Relationships like SHARES_ACCOUNT_WITH or CO_APPROVES_WITH don’t exist in source data, but they dramatically simplify fraud queries and reduce traversal depth. Compute them during ingestion and store them as first-class edges.
Parameterize thresholds. Hard-coding similarity windows and count thresholds into Cypher queries makes tuning expensive. Pass them as query parameters so they can be adjusted at runtime without redeploying the pipeline.
Validate against ground truth. Synthetic datasets with injected patterns give you a reproducible benchmark for regression testing. Any schema change or query modification should be validated against the full ground-truth manifest before it reaches production.
Separate detection from alerting. Detection queries should return structured result sets. Severity classification, deduplication, and alert routing belong in a downstream layer. Mixing them into the graph queries makes both harder to maintain.
Conclusion
Relational fraud schemes are invisible to transaction-level rules engines precisely because the signal is not in any individual record — it’s in the connections between records. A knowledge graph makes those connections queryable in a single traversal rather than a chain of expensive joins.
This tutorial covered the full pipeline: generating a synthetic dataset with known fraud patterns, loading it into Neo4j with indexed constraints, writing parameterized Cypher queries for five fraud types, validating results against a ground-truth manifest, and extending the architecture to production with Kafka, Airflow, and alert routing. The same graph model and query patterns apply directly to insurance claims, loan origination networks, and any other domain where fraud emerges from coordinated behavior across multiple entities.