---
title: "One database for the whole claim: agentic claims triage on SurrealDB"
description: "An AI agent triaging an insurance claim needs semantic, graph, and document data at once. Do it in one SurrealDB database — vector recall of similar past claims plus graph fraud-ring detection, fused in a single function. Schema, seed data, and queries included."
url: https://surrealdb.com/blog/one-database-for-the-whole-claim-agentic-claims-triage-on-surrealdb
date: 2026-07-29
authors: "Martin Schaer"
---

# One database for the whole claim: agentic claims triage on SurrealDB

![One database for the whole claim: agentic claims triage on SurrealDB](https://cdn.surrealdb.com/ajyfryxatg8eaqzcadygcow2.auto)

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](https://surrealdb.com) 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.

![DB schema diagram](https://cdn.surrealdb.com/w(1600)q(80)/jhhw4c9lljmmtg85hlzc473l.auto)

```surql
-- 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:

```surql
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.

```surql
-- 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:

```surql
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:

1. **`K` and `EF` must be literal integers** — `<|6, 64|>`, never `<|$k, 64|>`.
2. **The query vector must be a bound parameter or literal array.** An inline field access like `claim:c_new.embedding` *inside the operator* silently skips the index and hands back `NONE` distances in insertion order. Pin it to a parameter using `LET` first.

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:

```surql
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:

```surql
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:**

```surql
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:

```surql
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:

```surql
fn::triage(claim:c_new);
```

```json
{
  "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:

```surrealql
-- ----------------------------------------------------------------------------
-- 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:

```surql
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();
```

```text
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=2
```

Drop 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:

1. **The document model** stores the claim as-is — different fields per claim type, no impedance mismatch.
2. **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.
3. **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:
  ```bash
  curl -sSf https://install.surrealdb.com | sh
  surreal start --user root --secret root memory
  ```
  Then import the schema and seed and run the demos:
  ```bash
  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.surql
  ```

- **Read the docs.** Dig into [graph relations](https://surrealdb.com/docs/surrealql/statements/relate), the [HNSW vector index](https://surrealdb.com/docs/surrealql/statements/define/indexes#vector-search-indexes), and [`DEFINE FUNCTION`](https://surrealdb.com/docs/surrealql/statements/define/function) to extend the triage.

- **Wire in a real model.** Swap the 4-dim toy vectors for a real embedding model's output, bump `DIMENSION`, and point `filed_by` / `used_shop` / `payout_to` at 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](https://surrealdb.com/community), and star the project on [GitHub](https://github.com/surrealdb/surrealdb). 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.*
