A claim graph built from records, not from an LLM
By Farough Darakhshan · 10/20/2026
At any given time I am tracking 500 to 600 water-damage insurance claims. Each one moves through the same six stages: track it, write the invoice, send it, defend it against the adjuster's cuts, collect, and if it goes unpaid, hand it to counsel.
The questions I ask every day are not hard. They are relationship questions:
- Which claims do we have for this homeowner?
- Which adjuster handles this claim, and at which carrier?
- What did this carrier actually pay on that invoice?
- How did we answer this denial reason last time?
Each one has an exact answer sitting in a record somewhere: a contract, an invoice register, an email header, a board row. The problem was getting to it.
The first attempt: let the model build the graph
I had a document library feeding a retrieval system, RAGFlow, which can build a knowledge graph by having a language model read every document and extract entities and relationships.
It looked good in a demo. Then I audited it, twice, with relationship questions I already knew the answers to. It could not answer them reliably. A model that reads a scanned invoice and writes down "adjuster: J. Smith" makes small mistakes, and in a graph small mistakes compound. Two spellings of one adjuster become two nodes. A claim number with one wrong digit becomes a new claim. Ask "what else did this adjuster handle?" and you get half the list.
For a chat answer that is annoying. For a claim worth five figures it is useless, because I have to check every answer against the source anyway.
The second attempt: build it from records in code
On 9/30/2026 I threw the extraction approach out and built the graph in plain code. No language model creates a node or an edge. Every node comes from a record that already has structure:
- the client and property registry
- the metadata sidecar written next to every file in the library
- a claim-tag index that maps files to claims
- email headers: sender, recipients, date, subject (not bodies)
- the invoice queue board, synced from the project management tool
- the invoice register, one line per invoice ever issued
A few inputs were read by a model earlier, such as facts pulled from contracts and the differences between our estimate and the carrier's. Those are allowed in, but every edge that comes from them carries the label "derived". When I see that label I know to open the source before I rely on it.
How it is built and served
The pieces are small Python scripts: one builds the graph, one queries it, one serves it, one evaluates it. The output is a SQLite file plus two JSON Lines files, one for nodes and one for edges, and a build record that says what went in.
It rebuilds every hour and takes one to two minutes. A graph that is a day stale answers yesterday's question.
The same graph is available two ways. In the chat interface I use with the local models, it is a tool the model can call. In Claude Code and Codex, it is an MCP tool. Either way the model does not guess the relationship. It asks the graph, and the graph answers from records.
How I measured it
I wrote a set of 25 relationship questions with known answers, the same kind I ask every day, plus control questions where the right answer is "I don't know".
On 9/30 the graph scored:
- 21 of 25 correct
- 0.89 F1 on questions whose answer is a list
- every control passed
- every lookup under 0.3 seconds
The controls matter as much as the score. A tool that confidently answers a question it cannot answer is worse than one that answers nothing.
Two behaviors I care about more than the score
It says "ask which" on an ambiguous name. Restoration has a lot of shared surnames, and the same last name can belong to several unrelated clients. When a name matches more than one person, the graph lists the candidates and asks which one. It never merges them. Merging two people with the same name is how an invoice goes to the wrong house.
It cites nothing on an unknown name. If the name is not in the records, the answer is empty. No near-miss, no "closest match".
Both behaviors come for free from building in code. A lookup either finds a key or it does not.
What it cannot answer
The four misses and the known gaps are worth stating plainly.
- Line-item facts are not in the graph. It knows that an invoice exists, who it went to and what was paid. It does not know how many supervisory hours were on line 14, or which section of the IICRC standard a line relies on. Those live in the invoice documents.
- Synced mail starts on 9/22/2026. Older correspondence is in the library as files but not yet in the header index.
- A client missing from the registry is missing from the graph. The graph is only as complete as its records. When I find a job that never made it into the registry, the fix is to add it there, not to teach the graph to guess.
Why this matters past my own office
Before this, the same business documents lived in eight different stores, and the same 1,897 files existed three times over with nothing cross-referenced. Most small companies look like that. The tempting fix is to point a language model at the pile and ask it to make sense of it.
My experience is that a model is good at reading a document and bad at being the system of record. Let code own the relationships. Let the model read the one page you need once the graph has told you which page it is.
If you run a restoration company, or any business with many open files and many parties per file, start with the records you already have in structured form. An invoice register and email headers will get you most of the way.
I'm Farough (Frank) Darakhshan, and these are working notes on the systems behind a restoration back office. The other projects are at faroughdarakhshan.com/projects, and the public code is on faroughdarakhshan.org.