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.

PatternWhat It IsWhat Traditional Tools Don’t Catch
Duplicate InvoicesSame invoice and vendor, near-identical amount, close datesExact match misses slight variations
Circular RingsVendors invoicing each other in a closed loopEach invoice looks legitimate alone
Money Mule RingsMany vendors funneling to one controlled accountNo single payment is anomalous
Vendor CollusionIndependent-looking vendors sharing underlying attributesNo cluster detection across entities
Payment AnomaliesInvoice approved by someone outside their department’s authorityRole-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:

EntityKey PropertiesRole
Vendortax_id, name, country, cityWho issues invoices
Invoiceinvoice_id, amount, date, categoryWhat was billed
Paymentpayment_id, amount, dateWhat was paid
BankAccountaccount_id, routing_number, bank_nameWhere money went
Employeeemployee_id, name, departmentWho approved
Organizationorg_id, name, departmentsStructural 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.