跳到正文
DEV Community· Priyanshu Singh·· 3 小時前AI 評分53

Tripwire 如何以 FalkorDB 建構圖譜式 AML 調查 Agent

Follow the Money: Building a Graph-Based AML Investigator with FalkorDB

AI 導讀

Tripwire 以 LightGBM 排序合成交易,再透過 FalkorDB 的 5,078,345 筆交易圖譜和三個受限工具調查高風險個案。查詢修正把調查延遲由 3.5 s 降至約 25 ms,並修正少算交易對手的問題。在較難的 9 月 9–10 日測試區段,AP 由 0.088 升至 0.600;這些數字是在貨幣檢查修正前得出,所用資料亦為合成 AML 數據,不代表真實世界表現。

正文

Tripwire: a fraud investigator that walks the money trail in FalkorDB. Built for Graph Hacks (WeMakeDevs × FalkorDB), Track 1: Agents That Act on Connected Data.

On day two, our investigator took 3.5 seconds per case. That was annoying. Then we found out it was also wrong. For our demo transaction, hi8-tx-03863669, it said the sender had 2 counterparties and no fan-out. The real answer was 7 counterparties and 7 fan-out matches. One Cypher quirk had quietly cut the investigation window short.

This post covers how we built an agent that follows money through a five-million-transaction graph, the query bugs that made it slow and wrong, and the evaluation trap that almost gave us a headline number we'd have had to walk back.

A score tells you where to look

A fraud model scores one row: an amount, two accounts, a timestamp. Laundering doesn't live in one row. It lives in what happens next: the receiver forwards almost the same amount an hour later, then the next account does it again.

Fig 1. The same transaction, before and after you follow the money.
Fig 1. One row and one score, versus the same transaction traced: 65 paths, 15 transactions, 17 counterparty records.

Tripwire splits the work up. LightGBM ranks transactions. When one is worth a look, an investigator walks the graph in FalkorDB and writes a report where every claim links to a transfer you can check. The design comes from Coinbase's write-up on their fraud agent: a bounded LLM reviewer that sits behind the ML and the rules, not in place of them. We wanted to see how far that idea goes when the reviewer gets a graph instead of a flat history.

The graph, and the rule we didn't break

The data is IBM's synthetic AMLworld HI-Small set: 5,078,345 transactions over 18 days, with 5,177 labelled as laundering (fan-outs, fan-ins, cycles, layering).

The rule: a feature can only use what was knowable before the transaction happened. That means a frozen chronological split, same-minute groups kept in one split, half-open windows [t - w, t), and labels kept out of the graph, the features and the API. The rule paid off. Partway through, we caught our first feature file still carrying the label column, one careless join away from a fake result. Now features are built from an explicit allowlist, and labels are joined only inside the trainer.

We started with (:Account)-[:TRANSFER]->(:Account), then promoted every transfer to its own node:

(:Account)-[:SENT]->(:Transaction)-[:RECEIVED]->(:Account)

Fig 2. Every transfer is a node the agent can cite. Labels never enter the graph.. Every transfer is a node the agent can cite. Labels never enter the graph._

The investigator's job is to cite transfers, so each one needs a stable ID (hi8-tx-{row}). A path it returns is then a list of real records, not something inferred from edges. CALL db.propertyKeys() on the loaded graph shows no label property at all, so nothing, the agent included, can peek.

Three tools, zero generated Cypher

The agent never writes a query. It picks from three parameterised tools:

  1. trace_funds follows money upstream or downstream, 1 to 3 hops, inside a time window.
  2. inspect_counterparties shows who's on each side and what else they've been doing.
  3. detect_flow_patterns checks for fan-out, fan-in and pass-through.

Every tool has hard limits (depth 1–3, a window of 30 days at most, 100 results at most, a 5 s timeout), and every result comes back with transaction IDs, amounts, the path and query_duration_ms. This is the outgoing depth-2 branch of trace_funds:

MATCH (anchor:Transaction {transaction_id: $transaction_id})
WITH anchor, anchor.timestamp_epoch - $time_window_seconds AS lo,
     anchor.timestamp_epoch + $time_window_seconds AS hi
MATCH (anchor)-[:RECEIVED]->(a0)-[:SENT]->(t1)-[:RECEIVED]->(a1)-[:SENT]->(t2)-[:RECEIVED]->(a2)
WHERE t1.timestamp_epoch >= lo AND t1.timestamp_epoch <= hi
  AND t2.timestamp_epoch >= lo AND t2.timestamp_epoch <= hi
RETURN anchor.transaction_id, t1.transaction_id, t1.amount_paid,
       t2.transaction_id, t2.amount_paid, a0.account_key, a1.account_key, a2.account_key
LIMIT $result_limit

At depth 1, the demo case returns 15 transfers, all one hop away: payments into the anchor's sender (its receiver sent nothing onward inside the window). At depth 2 the same bounded call returns 65 paths through the same 15 transactions: 15 one-hop records plus 50 two-hop chains that link them through the sender account, every record keeping its own ID and amount.

Without a graph, that's a self-join on a five-million-row table for every hop. In FalkorDB, it's one pattern.

The LLM never gets the last word

  • LightGBM scores. A deterministic policy turns the score into allow or review. The LLM can't change either one.
  • The investigator picks the tools and drafts the report. Gemini is opt-in, and the default report is deterministic.
  • A verifier checks every ID, path, amount and date in the report against what the tools actually returned. Anything that doesn't match gets dropped.

Fig 3. The LLM chooses graph tools and writes. It never scores, and it never gets the last word.. The LLM chooses graph tools and writes. It never scores, and it never gets the last word._

Because of that, the agent can say no. On a quiet transaction from the same slice, hi8-tx-03988788, the deterministic investigation returns outcome: no_matching_path: zero paths, seven related transfers on record, and no attempt to dress the empty result up as safety. The workbench spells it out: "The graph was queried and nothing matched within these bounds. This is not evidence that the transaction is safe." For a compliance tool, a refusal like that matters as much as a catch.

The same three tools are exposed over MCP: api.mcp_graph serves them as typed, bounded MCP tools that delegate to the same parameterised queries, the agent calls them through an in-process MCP client session by default (direct transport is one config flag away), and the same server runs over stdio for external clients.

The day fan_in took 891 ms

On a one-million-transaction slice, one investigation (five tool calls) took about 3.5 s. Even an account with five transfers spent around a second finding zero matches. We found four causes:

  1. The planner picked the wrong index. For account-anchored queries, our range index on timestamp_epoch made the planner scan every transaction in the window (528,982 rows for one day) before filtering down to the account. We dropped that index and bound the indexed anchor with WITH before expanding. Now cost grows with the account's degree, not with the size of the graph.
  2. UNION ... ORDER BY ... LIMIT only applies to the last branch. Our "latest activity" lookup returned the wrong epoch for 497 of the 500 accounts in our equivalence fixture. That's the bug that ended windows early and turned 7 counterparties into 2. It also let a 3-hop trace with LIMIT 50 return 3,404 paths.
  3. Pass-through built a cross product of every incoming and outgoing leg before limiting. Now it builds a histogram of outgoing timestamps first.
  4. Pointless second hops went out just to read a key the transaction node already had.

Fig 4. Dropping one index took a zero-match query from about 1 s to about 3 ms.
Fig 4. Dropping one index took a zero-match query from about 1 s to about 3 ms.

We froze the old queries and wrote equivalence tests before touching anything, then re-measured on the same slice:

Fig 5. Same real slice, same case, P50 latency on a log scale.
Fig 5. Same real slice, same case, P50 latency on a log scale.

Then we loaded all 5,078,345 transactions: 521 s to load, every count reconciled, 2.5 GB of graph memory (we'd projected about 7 GiB from a sample). The demo case now investigates in 23.2 ms P50, and hub accounts with up to 169,756 connections take 207 ms P95.

Fig 6. Measured beats projected. Serial, single client, shared dev machine.
Fig 6. Measured beats projected. Serial, single client, shared dev machine.

Did the graph help the model? Yes, with a twist

On the frozen test set (761,639 rows), all three candidate models were committed before any of them saw test data, and each was scored on test once. The selected model reached 0.753 average precision and caught 47.7% of laundering in the top 0.1% of alerts, up from 15.9% for the baseline.

Fig 7. One easy tail inflates the headline. The Sept 9-10 bulk number is the one to trust.
Fig 7. One easy tail inflates the headline. The Sept 9-10 bulk number is the one to trust.

Here's the twist. The last 1,108 test rows are 59% laundering, and every model finds them easily. That tail holds 42% of all test positives. The fair comparison is the Sept 9–10 bulk, where AP goes from 0.088 to 0.600, close to the 0.609 we saw on validation. We report both numbers.

There was a second trap too. In our first iteration, graph features made AP worse (0.159 → 0.152). The cause turned out to be one overfit model, not the features. Across seeds on validation, the same graph columns helped every time.

One caveat on those headline numbers. Review caught afterwards that log_received_over_paid, one of the feature-family columns, compared raw amounts even when the paid and received legs were in different currencies. The guard is in place now: the ratio is null unless both legs share one currency and are strictly positive, and a separate currency_mismatch flag carries the mismatched case, with tests for mismatched, zero, and missing amounts. The 0.753 and 0.600 figures above were produced before that guard. Re-running the corrected candidate on the frozen test cohort means a new, ledgered test look, and that has not happened yet, so the numbers stay labelled pre-fix rather than quietly re-scored.

Where FalkorDB earned its place

Computing offline features through FalkorDB took 1,563 s for 5 columns. DuckDB does 76 features in 177 s, and its graph columns match on all 5,078,345 rows. So batch features run as SQL, and FalkorDB answers the live, per-case questions: where did this money go, who's around it, does it look like a fan-in. Those answer in milliseconds, as a path you can show a person.

Fig 8. Scan with SQL. Walk outward from one entity with the graph.
Fig 8. Scan with SQL. Walk outward from one entity with the graph.

FalkorDB features we leaned on: parameterised Cypher; range indexes on account_key and transaction_id (and learning when dropping one makes the planner better); db.propertyKeys() as a leakage check; client-side timeout= on graph.query; GRAPH.MEMORY USAGE and INFO memory for capacity; and the FalkorDB Browser on localhost:3001 for eyeballing paths. No algo.* procedures made the cut.

What I'd tell myself on day one

  • Use the graph for questions that start at one entity and walk outward, not for scans. Index the wrong property and the planner turns the first kind into the second.
  • Measure on real data early. Our 7 GiB projection and our 3.5 s investigation were both wrong.
  • Freeze old queries before optimising. A faster answer that's different is a bug.
  • Check where your positives sit in time.
  • Make the LLM optional and the verifier mandatory.

TL;DR

Tripwire scores synthetic transactions with LightGBM, then investigates the risky ones by walking a five-million-transaction FalkorDB graph through three bounded tools. Every claim in its reports is checked against real transfers. Query fixes took an investigation from 3.5 s to about 25 ms and made it correct. On the hard part of the test set, past-only graph features lifted AP from 0.088 to 0.600. Synthetic data, research demo: it says nothing about real-world AML performance.

What would you want an investigator agent to refuse to claim?


Built by Kanishka (@Kaniisshka), Ashwani Jha (@Ashwanijha1405), Parth Garg (@Parth06102006), and Priyanshu Singh (@PriyanshuSingh9). The graph model, queries, evaluation protocol and decisions are ours, and we can explain every one.

來源:DEV Community · dev.to