An insurance carrier gets a new auto claim. Before a cent is paid, something has to answer some questions: Have we seen a claim like this before, and how did it end? Is this claimant structurally connected to fraud we already know about? And can we hand all of that to an adjuster — or an LLM — as one clean brief?
That is a perfect job for an AI agent. But the agent is only as good as what it can retrieve, and the retrieval spans three shapes of data at once:
Semantic — "find past claims that read like this one," which is a vector-similarity search over text embeddings.
Relational graph — "is this claimant wired to a known fraud ring through a shared shop, phone, or bank account?", which is a graph traversal.
Document — the claim itself, a semi-structured record with different fields per claim type.
The usual answer is three systems: a vector database, a graph database, and a document or relational store, stitched together with glue code and a nightly ETL job to keep them in sync. Every agent query then becomes a distributed join across three network hops.
This post does it in one SurrealDB database, one query language, no ETL. Every schema, query, and output below was run against SurrealDB 3.2, its latest version.
Why one multi-model database beats the three-store stack
The traditional three-database pattern isn't just operationally heavy — it actively hurts the agent:
Staleness. The graph store learns about a fraudulent claim only after the ETL job copies it over. An agent triaging a claim now queries yesterday's fraud graph.
No cross-shape queries. "Find claims semantically similar to this one that also share a repair shop with a known fraud" is one thought, but across a vector store and a graph store it's two round trips and a manual join in application code.
Three schemas to keep honest. A claim's id, amount, and status are duplicated into all three systems, and they drift.
SurrealDB stores the claim document, the HNSW (or DISKANN) vector index over its embedding, and the graph edges to shops, phones, and accounts in the same record space. The agent asks one database one question and gets a fused answer inside a single transaction.
The schema
Node tables for the entities, edge tables for the connections, one HNSW index for semantic recall, and a single function that packages everything the agent needs.
-- The policyholder who files claims.
DEFINE TABLE policyholder SCHEMAFULL;
DEFINE FIELD name ON policyholder TYPE string;
-- Entities a claim touches. Shared ones are the fraud signal.
DEFINE TABLE repair_shop SCHEMAFULL;
DEFINE FIELD name ON repair_shop TYPE string;
DEFINE TABLE phone SCHEMAFULL;
DEFINE FIELD number ON phone TYPE string;
DEFINE TABLE bank_account SCHEMAFULL;
DEFINE FIELD iban ON bank_account TYPE string;
-- The claim: a document with the adjuster-readable description, plus the
-- embedding of that text for semantic recall. `fraud` is the ground-truth
-- label on resolved claims — the memory the agent learns from.
DEFINE TABLE claim SCHEMAFULL;
DEFINE FIELD kind ON claim TYPE string
ASSERT $value IN ['collision', 'theft', 'water_damage', 'injury'];
DEFINE FIELD description ON claim TYPE string;
DEFINE FIELD amount ON claim TYPE number;
DEFINE FIELD status ON claim TYPE string
ASSERT $value IN ['pending', 'paid', 'denied'];
DEFINE FIELD fraud ON claim TYPE option<bool>;
DEFINE FIELD embedding ON claim TYPE array<float>
ASSERT $value.len() = 4;
-- Semantic index over the embedding. HNSW is approximate-nearest-neighbour;
-- DIST COSINE matches how text embeddings are compared.
DEFINE INDEX claim_vec ON claim
FIELDS embedding
HNSW DIMENSION 4 DIST COSINE TYPE F32
EFC 150 M 12 M0 24;The embeddings are 4-dimensional on purpose so the examples stay readable without calling an embedding API. Each axis is a claim-semantics topic: [ collision, theft, water, injury ]. In production you swap in a real model's output (such as 384 or 1536 dimensions) and change DIMENSION to match; nothing else changes.
Now the edges. Each RELATION pins down exactly what it may connect — a schema-level guardrail — and because edges are real records the traversal reads like the question you're asking:
DEFINE TABLE filed_by TYPE RELATION IN claim OUT policyholder SCHEMAFULL;
DEFINE TABLE used_shop TYPE RELATION IN claim OUT repair_shop SCHEMAFULL;
DEFINE TABLE payout_to TYPE RELATION IN claim OUT bank_account SCHEMAFULL;
DEFINE TABLE has_phone TYPE RELATION IN policyholder OUT phone SCHEMAFULL;Seed data
The seed data involves one honest customer, a three-person collusion ring, and one fresh claim waiting to be triaged. The ring — Bob, Carol, and Dan — filed three "separate" collision claims that all route to Rapid Auto Body and all pay into one bank account; Bob and Carol even share a phone. Alice is a clean customer. Erin is the newcomer whose claim just landed.
-- Claims. Embedding axes: [ collision, theft, water, injury ].
-- Resolved claims carry a ground-truth `fraud` label; the new one does not.
CREATE claim:c_alice CONTENT { kind:'collision', status:'paid', amount:3200, fraud:false,
description:'Rear-ended at a stop light, bumper and tailgate damage, no injuries.',
embedding: [0.90, 0.00, 0.10, 0.05] };
CREATE claim:c_bob CONTENT { kind:'collision', status:'paid', amount:8500, fraud:true,
description:'Side-swipe collision on the highway, extensive panel damage.',
embedding: [0.88, 0.00, 0.05, 0.10] };
CREATE claim:c_carol CONTENT { kind:'collision', status:'paid', amount:9100, fraud:true,
description:'Multi-car collision, front and side damage, whiplash claimed.',
embedding: [0.91, 0.05, 0.00, 0.12] };
CREATE claim:c_dan CONTENT { kind:'collision', status:'paid', amount:7800, fraud:true,
description:'Collision at an intersection, heavy front-end damage.',
embedding: [0.86, 0.00, 0.08, 0.14] };
CREATE claim:c_flood CONTENT { kind:'water_damage', status:'paid', amount:5400, fraud:false,
description:'Burst pipe flooded the basement, ruined flooring and drywall.',
embedding: [0.05, 0.00, 0.94, 0.02] };
-- THE NEW CLAIM. Just filed by Erin, still pending — no fraud label yet.
CREATE claim:c_new CONTENT { kind:'collision', status:'pending', amount:8900,
description:'Side collision on the freeway, major panel and door damage.',
embedding: [0.89, 0.00, 0.06, 0.09] };
-- Repair shops. The ring — and Erin's new claim — all route to Rapid Auto Body.
RELATE claim:c_bob -> used_shop -> repair_shop:rapid;
RELATE claim:c_carol -> used_shop -> repair_shop:rapid;
RELATE claim:c_dan -> used_shop -> repair_shop:rapid;
RELATE claim:c_new -> used_shop -> repair_shop:rapid;
-- The ring all pays into one account; Erin uses her own.
RELATE claim:c_bob -> payout_to -> bank_account:acc_ring;
RELATE claim:c_carol -> payout_to -> bank_account:acc_ring;
RELATE claim:c_dan -> payout_to -> bank_account:acc_ring;
RELATE claim:c_new -> payout_to -> bank_account:acc_erin;
-- Bob and Carol share a phone — and so, supposedly a stranger, does Erin.
RELATE policyholder:bob -> has_phone -> phone:p_ring;
RELATE policyholder:carol-> has_phone -> phone:p_ring;
RELATE policyholder:erin -> has_phone -> phone:p_ring;(The full seed — every policyholder, account, and edge — follows the same pattern.)
Triaging the claim
The document
The new claim is just a record. Read it back as-is:
SELECT kind, status, amount, description FROM claim:c_new;
-- [{ kind:'collision', status:'pending', amount:8900,
-- description:'Side collision on the freeway, major panel and door damage.' }]Semantic recall — "have we seen a claim like this?"
Vector KNN over the HNSW index finds the resolved claims whose descriptions are closest in embedding space. Two things the KNN operator insists on, and both are easy to trip over:
**`K` and `EF` must be literal integers** — ``, never ``.
The query vector must be a bound parameter or literal array. An inline field access like
claim:c_new.embeddinginside the operator silently skips the index and hands backNONEdistances in insertion order. Pin it to a parameter usingLETfirst.
The KNN operator also has to be the sole WHERE condition to use the index, so we filter out the pending claim in an outer query:
LET $vec = claim:c_new.embedding;
SELECT id, kind, amount, fraud, distance
FROM (
SELECT id, kind, amount, fraud, status, vector::distance::knn() AS distance
FROM claim
WHERE embedding <|6, 64|> $vec
)
WHERE status != 'pending'
ORDER BY distance ASC
LIMIT 4;
-- [
-- { id: claim:c_bob, kind:'collision', amount:8500, fraud:true, distance:0.00013 },
-- { id: claim:c_alice, kind:'collision', amount:3200, fraud:false, distance:0.00196 },
-- { id: claim:c_dan, kind:'collision', amount:7800, fraud:true, distance:0.00213 },
-- { id: claim:c_carol, kind:'collision', amount:9100, fraud:true, distance:0.00418 }
-- ]Three of the four closest past claims were fraudulent, and the honest water-damage claim doesn't make the list at all (its cosine distance is 0.878 — worlds apart in vector space). That's a strong prior. But similarity is soft evidence: Alice's legitimate claim reads a lot like these too. On its own, "this claim resembles some frauds" isn't something you deny a payout over.
Structural fraud links — "is this claimant wired to fraud we already know?"
This is what the vector search can't see and a flat table can't ask: not does it look similar, but is it connected. Walk out from the new claim to a shared entity, then back in to any claim already labelled fraud.
Shared repair shop — out to the shop, back to other fraudulent claims that used it:
SELECT VALUE id
FROM claim:c_new->used_shop->repair_shop<-used_shop<-(claim WHERE fraud = true);
-- [ claim:c_carol, claim:c_dan, claim:c_bob ]Erin's claim routes to the same shop as all three known frauds. The inline (claim WHERE fraud = true) filters the traversal mid-hop — no post-processing.
Shared payout account:
SELECT VALUE id
FROM claim:c_new->payout_to->bank_account<-payout_to<-(claim WHERE fraud = true);
-- [ ]Empty — Erin pays into her own account. Not every signal fires, and that's exactly why you want all of them: a careful fraudster closes the obvious door and leaves another open.
Shared phone — a two-hop identity link through the policyholder:
SELECT VALUE id
FROM claim:c_new->filed_by->policyholder->has_phone->phone
<-has_phone<-policyholder<-filed_by<-(claim WHERE fraud = true);
-- [ claim:c_bob, claim:c_carol ]Erin — supposedly a stranger — shares a phone number with two ring members. Two independent structural links to a known ring is no longer a coincidence.
One call for the whole brief
An agent shouldn't issue five queries and reassemble them in Python. Wrap the whole triage in a DEFINE FUNCTION and it becomes one call:
fn::triage(claim:c_new);{
"claim": "claim:c_new",
"similar_claims": [
{ "id": "claim:c_bob", "fraud": true, "amount": 8500, "distance": 0.00013 },
{ "id": "claim:c_alice", "fraud": false, "amount": 3200, "distance": 0.00196 },
{ "id": "claim:c_dan", "fraud": true, "amount": 7800, "distance": 0.00213 },
{ "id": "claim:c_carol", "fraud": true, "amount": 9100, "distance": 0.00418 }
],
"fraud_links": {
"shared_shop": [ "claim:c_carol", "claim:c_dan", "claim:c_bob" ],
"shared_bank": [ ],
"shared_phone": [ "claim:c_bob", "claim:c_carol" ]
}
}The function body is just the queries above, run server-side and returned as one object. Vector recall and three graph traversals, one round trip, live data.
This is the complete function definition:
-- ----------------------------------------------------------------------------
-- fn::triage — everything the agent needs about one incoming claim, in a
-- single call: the semantically closest resolved claims (with their fraud
-- outcome), plus any structural link from this claimant to a known-fraud claim
-- through a shared repair shop, phone, or payout account.
-- ----------------------------------------------------------------------------
DEFINE FUNCTION OVERWRITE fn::triage($claim: record<claim>) {
-- Bind the query vector into a parameter. Two gotchas the KNN operator
-- enforces: K and EF must be literal integers, and the query vector must be
-- a bound param (or literal array) — an inline field access like
-- `$claim.embedding` in the operator silently skips the HNSW index and
-- returns null distances. So we pin it here first.
LET $vec = $claim.embedding;
-- Semantic recall: nearest neighbours by the index (KNN must be the sole
-- WHERE condition), then filter to resolved claims and keep the closest 4.
LET $similar = (
SELECT id, kind, description, amount, fraud, distance
FROM (
SELECT id, kind, description, amount, fraud, status,
vector::distance::knn() AS distance
FROM claim
WHERE embedding <|6, 64|> $vec
)
WHERE status != 'pending'
ORDER BY distance ASC
LIMIT 4
);
-- Structural fraud links: known-fraud claims reachable from this one
-- through a shared repair shop, a shared phone, or a shared payout account.
LET $via_shop = (
SELECT VALUE id FROM $claim->used_shop->repair_shop<-used_shop<-(claim WHERE fraud = true)
);
LET $via_bank = (
SELECT VALUE id FROM $claim->payout_to->bank_account<-payout_to<-(claim WHERE fraud = true)
);
LET $via_phone = (
SELECT VALUE id FROM $claim->filed_by->policyholder->has_phone->phone<-has_phone<-policyholder<-filed_by<-(claim WHERE fraud = true)
);
RETURN {
claim: $claim,
similar_claims: $similar,
fraud_links: {
shared_shop: array::distinct($via_shop),
shared_bank: array::distinct($via_bank),
shared_phone: array::distinct($via_phone)
}
};
};The LLM-ready context block
The last step for an agent is turning that object into a prompt. SurrealDB can build the string too, so the agent's application code does nothing but forward it:
LET $t = fn::triage(claim:c_new);
RETURN "INCOMING CLAIM: " + $t.claim.description
+ "\n\nSIMILAR PAST CLAIMS:\n"
+ $t.similar_claims.map(|$s| "- " + <string>$s.id + " (" + $s.kind
+ ", $" + <string>$s.amount + ", fraud=" + <string>$s.fraud + ")").join("\n")
+ "\n\nFRAUD LINKS: shop=" + <string>($t.fraud_links.shared_shop).len()
+ " bank=" + <string>($t.fraud_links.shared_bank).len()
+ " phone=" + <string>($t.fraud_links.shared_phone).len();INCOMING CLAIM: Side collision on the freeway, major panel and door damage.
SIMILAR PAST CLAIMS:
- claim:c_bob (collision, $8500, fraud=true)
- claim:c_alice (collision, $3200, fraud=false)
- claim:c_dan (collision, $7800, fraud=true)
- claim:c_carol (collision, $9100, fraud=true)
FRAUD LINKS: shop=3 bank=0 phone=2Drop that between a system prompt and "Recommend an action and justify it," and the model has grounded, current evidence — the semantic precedent and the structural links — instead of hallucinating from the claim text alone.
Putting it together
Three data shapes, one database, one function:
The document model stores the claim as-is — different fields per claim type, no impedance mismatch.
The HNSW vector index answers "have we seen a claim like this, and how did it end?" — semantic recall the agent uses as a prior.
Graph edges answer "is this claimant connected to fraud we already know?" — the structural evidence similarity search is blind to.
Neither signal convicts on its own. The vector search flags a resemblance to past fraud; the graph proves two independent ties to a specific ring. Fused, they turn a pending claim into a defensible "investigate before paying" — and because it's all one database, the agent asks once and always reads live data. No vector store, no separate graph engine, no ETL keeping three copies of the truth in sync.
Try it yourself
Spin it up in two minutes. Install SurrealDB and start an in-memory instance:
curl -sSf https://install.surrealdb.com | sh surreal start --user root --secret root memoryThen import the schema and seed and run the demos:
surreal import --ns insurance --db claims -u root -p secret schema.surql surreal import --ns insurance --db claims -u root -p secret seed.surql surreal sql --ns insurance --db claims -u root -p secret --pretty < queries.surqlRead the docs. Dig into graph relations, the HNSW vector index, and
DEFINE FUNCTIONto extend the triage.Wire in a real model. Swap the 4-dim toy vectors for a real embedding model's output, bump
DIMENSION, and pointfiled_by/used_shop/payout_toat your live claims feed — you've got a triage agent reading one database instead of three.Join the community. Share what you build and get help on the SurrealDB Discord, and star the project on GitHub. The #all-ai channel is the best place to get started.
Semantic recall and graph traversal, one query language — the whole claim in one place.