🕸️ GraphRAG Deep-Dive — 16 min read

Why Vector RAG Fails on
a Bank's Connected Tables

Point plain RAG at a bank's payments, accounts and customer tables and it will answer confidently — and wrongly. Here is exactly why vector search breaks on connected data, and how GraphRAG fixes it, walked through on a real multi-table banking example.

5 linked tables
2 flow diagrams
1 worked query
Production patterns
New to this? Start here. RAG (Retrieval-Augmented Generation) is the standard way to let an AI answer from your data: instead of guessing from memory, it first retrieves relevant information, then writes its answer from that. Classic RAG finds text by meaning (“vector search”). GraphRAG swaps that for a knowledge graph — your data stored as things and the links between them — so the AI can follow connections. This guide gets hands-on with a bank example; the diagrams carry the core idea even if the code doesn't, so skim past anything technical on a first read.

I have spent the last few years building retrieval systems on top of relational data — banking, payments, and fraud. If there is one lesson I keep re-learning, it is this: the moment your questions depend on how records connect to each other, vanilla vector RAG stops working. Not "works a bit worse." Stops working — while still returning a fluent, confident, wrong answer, which is the most dangerous failure mode of all.

This article shows exactly where that wall is, and why GraphRAG walks straight through it. We will use one concrete example the whole way: a bank with five connected tables.

The scenario: a bank with five connected tables

A real payments system is never one table. Ours is a modest five, joined by foreign keys — a column in one table that points to a row in another — the kind of schema (table layout) you would find behind any retail bank:

customers

  • customer_id 🔑
  • name
  • country
  • risk_tier

accounts

  • account_id 🔑
  • customer_id ↗
  • type
  • opened_at

payments

  • payment_id 🔑
  • from_account ↗
  • merchant_id ↗
  • amount, ts

merchants

  • merchant_id 🔑
  • name
  • category
  • country

fraud_flags

  • flag_id 🔑
  • merchant_id ↗
  • reason
  • flagged_at

Notice the arrows (↗): every foreign key is a relationship. An account belongs to a customer. A payment moves from an account to a merchant. A fraud flag attaches to a merchant. The interesting business questions live in those connections, not inside any single row.

Here is the question we want a natural-language assistant to answer — the sort a risk analyst actually asks:

The question
“Which customers in India sent more than ₹5,00,000 in total to fraud-flagged merchants during Q2, and which of their accounts did they use?”

Answering that requires touching all five tables: filter customers by country, walk to their accounts, follow payments to merchants, keep only merchants that appear in fraud_flags, sum the amounts per customer, and filter by a threshold. That is four hops (four jumps from one table to the next) and an aggregation (adding the amounts up). Hold that thought.

Why generic (vector) RAG fails here

Generic RAG has exactly one move: turn each piece of text into a vector (a long list of numbers that captures its meaning — also called an embedding), find the vectors closest in meaning, and stuff that text into the prompt. To make our tables "retrievable," we first have to flatten them into text chunks — usually one chunk per row. Then we search by semantic similarity (how alike two pieces of text are in meaning).

Diagram 1 · Generic RAG on relational data — where it breaks
1 Ask a 4-hop question customer → … → fraud, + a SUM 2 Flatten tables → text chunks one row per chunk foreign keys cut 3 Vector search · top-K nearest embeddings similar ≠ needed 4 LLM reads a partial set ~8 of 800 rows biased 1% sample Confident, wrong answer joins lost · totals guessed
Where it breaks: similarity is not the same as relevance. The retriever returns the K chunks whose text looks most like the question — not the specific rows that satisfy a four-way join. There is no mechanism to follow a foreign key, and no mechanism to add anything up.

Break it down into the four things this question needs, and vector search misses every one:

❌ It can't traverse relationships
"Customer → account → payment → merchant → fraud flag" is a graph walk. Embeddings have no notion of a foreign key; nearest-neighbour search cannot follow one hop, let alone four.
❌ It can't aggregate
"More than ₹5,00,000 in total" is a SUM with a HAVING filter. Retrieval returns text, never a computed total. Even with the right rows in context, the model is guessing at arithmetic over a truncated sample.
❌ Top-K silently truncates
A customer might have 800 qualifying payments. Top-K returns maybe 8. The model answers from a biased 1% sample and has no way to know the other 99% exist.
❌ Exact values blur
Filters like country = 'IN' or a specific merchant_id are exact predicates. Cosine similarity treats "IN" and "Indonesia" as close, and IDs as noise.
The uncomfortable truth: on connected data, a vector-only RAG system doesn't fail loudly with an error. It returns a fluent paragraph citing three real customers and a plausible-looking total — and every number in it is wrong. In a bank, that is worse than no answer.

How GraphRAG works

GraphRAG changes what you retrieve over. Instead of a pile of independent text chunks, you retrieve over a knowledge graph: your data stored as nodes (the things — customers, accounts, payments) and edges (the links between them), which the AI can traverse — walk from one thing to a connected thing. The tables you already have map onto it almost perfectly — foreign keys simply become edges.

OWNS SENT TO FLAGGED Customer India, tier 2 Account savings Payment ₹82,000 Merchant electronics FraudFlag chargeback ring

The five tables as one graph. Each foreign key is now a labelled, walkable edge.

One modelling choice worth calling out: a Payment is a node, not an edge. You could draw it as a single arrow from account to merchant, but because it carries its own properties — amount, timestamp, status — promoting it to a node lets you filter and aggregate on those directly. Rule of thumb: reserve edges for plain "belongs-to" links (OWNS, TO); anything with attributes of its own becomes a node.

Step 1 — Ingestion: tables become a graph, once

This happens offline, on a schedule. An ETL job reads the relational schema and materialises nodes and edges: each row becomes a node, each foreign key becomes a typed edge. Numeric columns (amount, timestamps) ride along as node properties so you can filter and aggregate on them later.

There is nothing exotic about it — a scheduled job streams each table and upserts nodes and relationships. In Neo4j that is a periodic LOAD CSV or a batched driver script built on idempotent MERGE statements, so re-running it never duplicates data. The foreign key is exactly where the edge gets created:

// One payment row -> a Payment node wired to its account and merchant MERGE (a:Account {account_id: row.from_account}) MERGE (m:Merchant {merchant_id: row.merchant_id}) MERGE (p:Payment {payment_id: row.payment_id}) SET p.amount = toFloat(row.amount), p.ts = datetime(row.ts) MERGE (a)-[:SENT]->(p) MERGE (p)-[:TO]->(m)
🗃️
Relational Tables
customers, accounts, payments…
🔧
Map FKs → Edges
schema-driven ETL
🕸️
Knowledge Graph
nodes + typed relationships

Step 2 — Retrieval: translate, traverse, then answer

At query time GraphRAG does something vector RAG never does: it lets the LLM write a structured graph query — in a language called Cypher, which is to graph databases what SQL is to spreadsheet-style tables — runs it against the graph database, and feeds back the resulting subgraph (just the connected records the query pulled out, already aggregated) to the model to phrase the answer.

Diagram 2 · GraphRAG retrieval flow
1 Ask in natural language no schema knowledge needed 2 LLM extracts intent IN · ₹5L · fraud · Q2 exact filters kept 3 LLM writes a graph query Cypher, not free text 4 Graph DB traverses follow edges · SUM · filter joins + maths in the DB 5 Exact subgraph returned every qualifying row + totals complete, not top-K Grounded answer complete & correct
The LLM is used twice — to write the query and to narrate the result — but never to invent facts or do the maths.

Here is the graph query the model generates for our risk question. Because the graph knows the relationships, four hops and an aggregation collapse into a few readable lines:

// Customers in India who sent > ₹5L to fraud-flagged merchants in Q2 MATCH (c:Customer {country: 'IN'})-[:OWNS]->(a:Account) -[:SENT]->(p:Payment)-[:TO]->(m:Merchant)<-[:FLAGGED]-(:FraudFlag) WHERE p.ts >= '2026-04-01' AND p.ts < '2026-07-01' // group per customer, collecting every account they used WITH c, sum(p.amount) AS total, collect(DISTINCT a.account_id) AS accounts_used WHERE total > 500000 RETURN c.name, total, accounts_used ORDER BY total DESC

The database returns a precise, complete result set — every qualifying customer, the exact total, and the specific accounts — and the LLM's only remaining job is to turn that into a sentence:

✓ Grounded answer (illustrative): "Three customers qualify. Rohan S. sent ₹7.4L across account AC-2231; Meera T. sent ₹6.1L across accounts AC-5567 and AC-5570; Anand K. sent ₹5.3L across account AC-8890." — note Meera's total spans two accounts, which is exactly why we aggregate per customer, not per account. Every figure is traceable to a real edge in the graph.

Vector RAG vs GraphRAG, side by side

CapabilityVector RAGGraphRAG
Follows relationships (joins/hops)❌ No — chunks are independent✓ Native — edges are the data
Aggregation (SUM, COUNT, HAVING)❌ Guessed by the LLM✓ Computed by the database
Exact filters (IDs, country, dates)❌ Blurred by similarity✓ Exact predicates
Completeness❌ Top-K truncates✓ Full result set
Best fitUnstructured docs, FAQs, proseConnected / relational / multi-hop data
AuditabilityHard — why these chunks?Easy — the query is the citation

When to reach for GraphRAG (and when not to)

GraphRAG is not a strict upgrade — it is the right tool for a specific shape of problem. Reach for it when:

  • Your answers depend on how records connect — customers to accounts to transactions to counterparties.
  • You need multi-hop reasoning, aggregation, or exact numeric/temporal filters.
  • Completeness and auditability matter — finance, compliance, fraud, healthcare, supply chain.

Stick with plain vector RAG when your corpus is genuinely unstructured prose — policy PDFs, support articles, wikis — and questions are answered by a passage, not a join. And in practice the strongest production systems are hybrid: a graph for the structured backbone, vectors for the free-text notes hanging off it (a payment's memo field, a merchant's description), with a router deciding which to use per question.

“So why not just Text-to-SQL?” That is the honest first question for data that already lives in tables — and often the right answer. If every query is a fixed, known join, letting the LLM write SQL against your existing schema is simpler and needs no second datastore. GraphRAG earns its place when the traversals are variable-depth or open-ended (“find any chain of transfers linking these two accounts” — a query whose join count you don't know in advance), when relationships span systems no single SQL schema covers, or when one connected model has to answer many different question shapes. Reach for Text-to-SQL first; graduate to GraphRAG when the joins stop being predictable.

Field notes: pitfalls I've hit

❌ Treating graph modelling as an afterthought
Your graph schema is your retrieval quality. Model the edges around the questions you actually get asked, not around a mechanical dump of every foreign key.
Fix: start from 20 real analyst questions. Design node/edge types so each question is a short, obvious traversal.
❌ Skipping entity resolution
"Amazon", "Amazon Pay" and "AMZN*retail" as three separate merchant nodes will quietly fragment every aggregation. Dirty nodes break a graph faster than dirty text breaks vector search.
Fix: dedupe and canonicalise entities during ingestion; keep a resolution table so IDs stay stable.
❌ Letting the LLM emit unbounded queries
A model-written Cypher query with no depth or row limit can traverse half the graph and time out — or leak data across tenants.
Fix: constrain generated queries (max hops, LIMIT, read-only role) and validate against an allow-list of patterns before executing.
Can you explain why RAG fails on relational data?
It is one of the fastest-rising GenAI system-design questions. Practise it live in the Interview Simulator — design a retrieval system for connected data and get scored in real time by Claude.
Start Mock Interview →