Back to blog
Fraud Detection With Graph and Vector Search in One Query

This blog is written by AI for SEO

Fraud Detection With Graph and Vector Search in One Query

HelixDB12 min read

A fraud analyst flags one account. Within an hour you find that it shares a device with six others and a card with two more. That is a ring, and the graph found it. Then a different problem shows up: a brand-new account with no shared devices, no shared cards, and a transaction pattern that looks a lot like the ring you just caught. The graph sees nothing. Similarity sees everything.

Most teams cover these two signals with two databases. A graph DB answers "who is connected to whom" and a vector DB answers "what looks like this." Then someone writes glue code to join the results, and the join is where latency, drift and 2am pages live.

This guide builds both signals as one scoped query: vector similarity over the graph neighbourhood of a flagged account, filtered to recent transactions. The modelling and scoring steps work on any stack. Where HelixDB collapses the vector DB plus graph DB into one engine, the steps show how. You need a basic feel for graph modelling and an embedding model you already trust for transaction behaviour. That is all.

Step 1: Model the fraud graph (Account, Device, Card, Merchant, Transaction)

Start with five node types: Account, Device, Card, Merchant and Transaction. Resist the urge to add more until a real investigation demands it.

The edges carry the investigative meaning:

  • Account USES Device

  • Account OWNS Card

  • Account MADE Transaction

  • Transaction PAID Merchant

  • Transaction VIA Card (and optionally VIA Device)

Shared infrastructure is what makes a ring. Two accounts pointing at the same Device node is a one-hop fact, not a join you reconstruct at query time. Put addresses and phone numbers in the same shape if you have them: an Address node, an Account LIVES_AT edge.

Make Transaction a node, not an edge property. This is a position, not a preference. Transactions are the unit you will embed, filter by time, and attach scores to. If you bury them as edge properties you lose the ability to treat them as first-class candidates in a similarity search.

Keep node properties boring and typed: account creation timestamp, card BIN, device fingerprint hash, merchant category code, transaction amount and a transaction timestamp stored as an integer or datetime you can range over.

Model time yourself. HelixDB does not give you temporal or point-in-time semantics for free, so a created_at on every Transaction and a first_seen_at on every Device-Account edge is your history. What you do get is efficient time-range indexing, and Step 6 depends on it.

Any graph store can hold this schema. The difference shows up in Step 3, when you decide where the vectors live.

Step 2: Decide what to embed (and what to keep as plain features)

Not everything deserves an embedding. The most common mistake in fraud ML is embedding fields that a simple filter or threshold handles better.

Keep these as plain properties and filter on them directly: amount, currency, merchant category code, country, card BIN, hour of day. They are cheap to index, exact, and explainable to an analyst. "Amount over 500" needs no cosine distance.

Embed behaviour. A Transaction embedding should summarise the shape of what happened: amount bucket, merchant category, velocity features (how many transactions on this card in the last hour), device change flags, time-of-day pattern. You can build the vector from a small feature encoder or a model trained on labelled fraud. Either works. Two transactions that behave alike have to land near each other, and that is the only test that counts.

Optionally embed Accounts too, as an aggregate of their recent transaction vectors. This helps with "find accounts that behave like this flagged account," but it is a second index to maintain, so add it only after the Transaction embedding proves out.

Two practical constraints from HelixDB's vector indexes. Vectors must be top-level numeric-array properties on nodes or edges, so do not nest them inside a map. Pick one distance metric (cosine, euclidean or manhattan) and keep it consistent between training and serving. Cosine is the sensible default for behavioural embeddings.

The rule: if an analyst can state the condition in one sentence, it is a feature. If the condition is "it just feels like the ring from last week," it is a vector.

Step 3: Write the graph and the vectors in one transaction

The two-database version of this step is where fraud systems quietly rot. You write the transaction to the graph, then write the vector to the vector store, and one of them fails. Now a transaction exists in the graph with no embedding, or an embedding points at a node that was rolled back. Nobody notices until a ring slips through the gap.

In HelixDB, vectors are a property on nodes and edges, so the transaction node, its edges and its embedding commit or roll back together. Graph and vector writes are atomic. There is no sync job and no reconciliation pass.

You write through the native SDKs, which build JSON queries sent to POST /v2/query. Nothing is compiled or deployed ahead of time. Here is the shape in TypeScript using an embedded database for local development:

import { Client, g, writeBatch } from "@helix-db/helix-db";

const client = await Client.embedded({ kind: "disk", database: "./fraud-db" });

// Build one write batch: the Transaction node (with its embedding
// as a top-level vector property) plus its edges to the Account,
// Card and Merchant nodes.
const batch = writeBatch(/* add Transaction node with vector + edges */);
await client.query(batch);

// Closing the writer persists data. There is no flush().
await client.close();

Check the SDK reference for the exact builder calls in your version, since the DSL surface is what produces the JSON. The point is structural: one batch, one commit, graph and vector together.

Create the vector index once, on the Transaction vector property. It can be global or tenant-partitioned. If you serve multiple merchants or business units, partition by tenant so one customer's behaviour never ranks against another's. Tenant partitioning isolates data on shared infrastructure. It is not per-user access control, so enforce who can see what in your application layer.

Step 4: Expand from a flagged account to its neighbourhood

A flag arrives from an analyst, a rule, or a chargeback, and it points at one Account. Your first move is graph-only and cheap. Walk outward and collect the entities that share infrastructure with it.

The traversal is two to three hops:

  1. Account to its Devices and Cards.

  2. Those Devices and Cards back to every other Account that touches them.

  3. Those accounts to their Transactions.

That last set is your candidate pool: transactions made by accounts one shared-device-or-card away from the flagged account. This is the ring neighbourhood. On a real fraud graph it is typically a few hundred to a few thousand transactions, not millions, which is exactly why scoping works.

Cap the hop count. Three hops on a graph with a popular Device node (a shared office computer, a public kiosk, a corporate NAT fingerprint) can explode into half your user base. Two defences:

  • Skip or down-weight Device and Card nodes whose degree exceeds a threshold. A device used by 4,000 accounts is not evidence of a ring. It is evidence of a shared computer.

  • Limit the traversal breadth per hop and log what you truncated, so an analyst can see the scope was bounded.

In HelixDB the traversal and the next step are one query, so the candidate set never leaves the engine. In a two-database setup this is the moment you export a list of transaction IDs from the graph DB and ship it to the vector DB as a filter, and the list size becomes your problem. A candidate stream over 1,000,000 unique entities is a query error in HelixDB, which is a useful forcing function: if your neighbourhood is that big, your hop cap is wrong.

Step 5: Run vector similarity scoped to that neighbourhood

Now the part that makes this more than a graph query. Take the embeddings of the flagged account's own confirmed-fraud transactions and ask: which transactions in the neighbourhood behave like these?

This is vector pre-filtering. Similarity runs over the graph-filtered candidate set from Step 4, not the whole index. You are not searching all 400 million transactions for nearest neighbours and then discarding the ones outside the ring. You rank only the few thousand that are structurally connected.

The difference matters in two ways. First, relevance: a global nearest-neighbour search returns lookalike transactions from unrelated accounts, which are mostly noise for a ring investigation. Scoping removes them before ranking. Second, cost: ranking a small candidate set is cheaper than ranking everything and post-filtering, and post-filtering a top-k list can leave you with nothing after the filter removes most of it.

Know the documented limits. An unrestricted vector search caps at 800 effective results. A traversal-scoped search applies that ceiling after the candidate intersection, and it rejects rather than silently clamping. That rejection helps in fraud work: you never get a quietly truncated result that looks complete. If you hit it, tighten the scope or ask for fewer results.

The query shape, in plain terms: start at the flagged Account node, traverse to the neighbouring accounts' Transactions, then run a vector search against those Transactions using the reference embedding, and return the top matches with their distances.

This is where the single engine earns its keep. The graph traversal and the vector ranking share one query and one candidate scope, so there is no export step and no second round trip.

Step 6: Add a time-range filter on recent transactions

A ring from eighteen months ago is history. A ring that started this week is an incident. Add a time-range filter so the candidate set contains only recent transactions.

This is the other half of scoping. Graph traversal narrows by relationship. The time filter narrows by recency. Together they shrink the candidate pool to "transactions by connected accounts in the last 72 hours," and similarity ranks within that.

HelixDB indexes time ranges efficiently, which makes this filter cheap enough to run inside the query instead of as a post-processing step. Index the created_at property on Transaction, then apply a range predicate (greater than or equal to a cutoff, optionally less than an upper bound) alongside your traversal.

Be honest about what this is. It is a range filter on a timestamp you stored. It is not temporal modelling. HelixDB will not reconstruct "what did this account's neighbourhood look like on March 3rd." If you need that, you model it: snapshot edges with valid_from and valid_to properties, or write edge-creation timestamps and filter on them. Decide this early, because retrofitting history onto a graph that only stores current state is painful.

Pick the window deliberately:

  • 24 to 72 hours for active-incident triage.

  • 7 to 14 days for ring investigation, where fraudsters pace themselves.

  • Longer only for retrospective sweeps, run as batch jobs rather than live scoring.

A useful pattern is two passes with different windows. Run the tight window for an immediate block-or-review decision, then the wider window asynchronously to sweep for slow-burn accounts that belong to the same ring.

Step 7: Turn the result into a risk score an analyst can audit

A list of similar transactions is not a decision. You need a score, and the score needs to be explainable, because a fraud analyst who cannot say why an account was blocked will override the system within a week.

Build the score from the two signals you already have, kept separate and visible:

  • Structural signal: how the account connects to the flagged one. Shared Device count, shared Card count, hop distance. An account sharing a card with the flagged account is stronger than one two hops out through a device.

  • Behavioural signal: the vector distance to the flagged account's confirmed-fraud transactions. Convert distance to a similarity, and take the max or the mean of the top three matches.

Combine them with weights you set and can state: for example, a weighted sum with structural evidence at 0.5 and behavioural at 0.5 as a starting point, then tune against labelled outcomes. Keep it linear at first. A logistic regression over these two features plus a few plain features (amount z-score, device age) is auditable. A black box is not.

The audit trail is the product. For every scored account, return and store:

  1. The path from the flagged account (which device or card connected them).

  2. The three nearest confirmed-fraud transactions and their distances.

  3. The time window and hop cap used.

  4. The final score and the weights.

If you built a graph-vector engine's single-query result into this, the path and the matches come back from the same call, which makes the audit record a straight dump of the response. Write that record to the graph itself as a Decision node linked to the Account, and the next investigation can see what the system concluded last time and why.

What to do next: tuning, false positives and troubleshooting

Your first run will over-flag. Expect it, and tune in this order.

False positives from popular infrastructure. If legitimate customers keep getting caught, check the degree of the Device and Card nodes connecting them. Shared hotel Wi-Fi, office NATs and family cards produce real edges that mean nothing. Exclude or down-weight high-degree nodes before touching your embedding.

Too many weak matches. If every neighbour scores "somewhat similar," your embedding is not separating fraud from normal behaviour. Retrain on labelled outcomes, or add a plain feature filter (amount band, merchant category) to the candidate scope so similarity only ranks plausible lookalikes.

Query rejected for exceeding limits. The 800-result ceiling and the 1,000,000-candidate error are telling you the scope is too wide. Lower the hop cap, shorten the time window, or filter by merchant category. Do not raise limits to make the error go away.

Scores drift over time. Fraud behaviour changes. Re-embed on a schedule and compare score distributions week over week. The guide on re-embedding after you change models covers the mechanics, and how to benchmark a graph database for AI retrieval shows how to measure latency honestly before you put this on a live authorisation path.

The same neighbourhood-then-similarity pattern powers other jobs too. The recommendation engine guide uses it with the sign flipped: find what connected users liked instead of what connected accounts did wrong.

Conclusion

Rings are a graph problem and novel fraud is a similarity problem. A fraud system that answers only one of them has a hole the other half of the attackers will find. Run both as one scoped query: traverse from the flagged account, rank by behaviour inside that neighbourhood, cut to the recent window, and store the audit trail.

If you are running a graph DB and a vector DB today, port one flagged-account investigation to HelixDB this week. Use the embedded TypeScript client, load a day of transactions, and compare the single query against your current two-step join. HelixDB is open source under Apache-2.0, so you can read the code before you trust it. Star the repo at github.com/HelixDB/helix-db, and join the Discord to show us the fraud graph you build.

Build with HelixDB

Give your coding agent the setup prompt, or sign up and deploy a database.

Sign up