Case Study · Agentic Document Extraction

From a stack of home documents to a structured, source-traced property record.

An InsurTech platform needs the facts buried inside home paperwork - inspection reports, contractor invoices, equipment manuals, warranties - as structured data its systems can act on. We designed a RAG-based extraction pipeline that classifies every chunk, runs specialised agents against a fixed schema, links each issue to the asset it belongs to, and traces every extracted field back to the page it came from.

  • InsurTech · Home risk & property data
  • LangGraph state machine
  • Dual model providers
  • Documents → schema-compliant entities
How every document becomes structured data
Documents in
PDF · Excel · CSV · max 50MB
Chunk & classify
rule-based keywords + LLM fallback → 4 categories
home
assets
issues
documents
Extract, merge, validate
parallel specialists (OpenAI) or one unified call (Claude)
Structured home record
schema-valid · deduplicated · every field sourced
schema-validated
traced to chunk
retry on incomplete
As-scoped · POC implementation
0

Orchestrated pipeline nodes, schema load to final output

0

Extraction agents across two interchangeable model paths

0

Deterministic supporting services, no LLM involved

0

Entity categories every document chunk is routed to

The client

An InsurTech company working to reduce home-related risk for policyholders and give carriers portfolio visibility, fewer losses, and a route to proactive mitigation.

The challenge

The facts that matter sit in unstructured home documents - inspection reports, invoices, manuals, warranties - in wildly different shapes, read by hand.

The build

A RAG-based extraction pipeline on a LangGraph state machine: category-based retrieval, specialised extraction agents, then validation, sourcing and relationship resolution.

What it produces

Four linked entity types - the home, its assets, the issues observed against them, and document metadata - schema-valid and traceable to the chunk each field came from.

The client

A home is a risk model. Most of its facts are in a PDF.

The client is an InsurTech company built around a simple observation: most home insurance losses are preventable, and the signals that predict them are already written down. Their platform exists to reduce home-related risk for policyholders while giving carriers portfolio visibility, lower loss ratios, and a way to intervene before a claim rather than after one.

To do that, the platform needs to know what a home actually contains. How old is the roof and what is it made of. What is the make, model and serial number of the water heater, and when was it installed. Which issues did the last inspection flag, against which piece of equipment, and how urgent were they. What warranty covers the furnace, and until when.

All of that exists - in a 40-page inspection report, a contractor's invoice, an equipment manual, a warranty certificate. None of it exists as data. Every one of those documents was written for a human reader, by a different author, in a different format, with no shared vocabulary and no obligation to be consistent.

This engagement built the layer that closes that gap: a proof of concept that takes those documents as they are and returns a structured, validated, source-traced record of the home.

The documents in scope
  • Home inspection reports
  • Contractor invoices
  • Equipment manuals
  • Warranties
  • PDF · Excel · CSV
One record, two consumers

The extracted record has to satisfy a machine and a person at the same time - which is what forces both the schema and the audit trail.

Downstream systems
  • Consume it programmatically
  • Need strict types and enums
  • Need stable identifiers to join on
  • Break on a missing required field
The review team
  • Check what was captured, in a UI
  • Need to see where a value came from
  • Must be able to reject a bad read
  • Won't trust a number with no page behind it
The structural fact

A field with no provenance is unusable to both audiences. The carrier's systems can't reconcile it and the reviewer can't defend it - so traceability back to the source chunk was designed in from the first node, not bolted on at the end.

The challenge

The information was all there.
Getting it out reliably was the job.

Handing a whole inspection report to a language model and asking for JSON gets you something that looks right and can't be trusted. The hard part isn't reading the document - it's doing it the same way every time, against a schema, with the relationships intact and every value attributable.

01

Every document a different shape

An inspection report, an invoice and a warranty share almost no structure. Section names, tables, headings and terminology vary by author, tool and trade - so no single parsing rule survives the second document.

02

Four entity types, tangled together

One paragraph can describe the home, name an asset, and flag an issue against it. Pulling all of it in one pass blurs the distinction the schema depends on - and unit-level asset detail leaks into home properties.

03

Hallucination is a data-quality bug

A plausible model number, an invented install date, a cost that was never printed. In a record a carrier acts on, a confidently wrong field is worse than a missing one - and looks identical until someone checks.

04

Relationships carry the meaning

"Corrosion at the base" only matters once it is attached to the water heater. An issue with no asset behind it, or an asset pointing at a document that doesn't exist, is noise dressed as structure.

05

The same asset, three times over

The furnace appears in the inspection report, the invoice that serviced it and its own manual. Without a deduplication rule the record inflates - three furnaces where the home has one.

06

Manual review doesn't scale

Reading a document set by hand takes an hour or more and produces different results depending on who read it. Coverage varies, the backlog grows, and the cost sits between the carrier and any proactive intervention.

Why it mattered

Proactive mitigation is the whole product promise, and it depends entirely on knowing what a home contains and what condition it is in. You cannot act early on data you don't have in a shape you can query - so the extraction layer wasn't a convenience feature, it was the precondition for everything the platform is trying to do.

The mandate

Three non-negotiables, fixed before a line of code.

Focaloid engaged to design and build the extraction system as a proof of concept - trustworthy enough that its output could go into a review UI and, from there, into the platform's own data model. Three constraints were set as hard requirements rather than aspirations.

Non-negotiable · 01

Schema-first, always

No free-form JSON, ever. Every extracted entity is validated against a centralised schema - required fields, data types, enums and value constraints, date and date-time formats, nested objects and arrays, and a check for properties that shouldn't be there at all.

Non-negotiable · 02

Provenance on every entity

Nothing enters the record without a source. Each entity carries the document, page and section it was read from, so any value in the UI can be opened back to the paragraph that produced it - by a reviewer or by an auditor.

Non-negotiable · 03

Deterministic & fault-tolerant

The same document set produces the same record: zero-temperature inference with a fixed seed and version-controlled prompts. And a failure in one extractor degrades to an empty result for that entity rather than taking the whole run down.

The decision that shaped everything

One agent per entity type - not one prompt for the whole document. A single mega-prompt asked to return the home, its assets, its issues and its document metadata at once has to hold four different jobs in its head, and quality drops on all four. So the method was split: each extractor gets a narrow prompt, its own retrieval categories, and one entity type to be right about. The orchestrator's job is sequencing and consolidation, not reasoning.

A gated path from raw documents to a governed record
P1

Schema & category design

Fix the four entity types and the JSON schemas they must satisfy, and define the chunk categories retrieval will route on.

P2

Ingestion pipeline

Parse, chunk, classify and embed each document, then store the chunks in session-scoped vector collections with their metadata.

P3

Orchestrated extraction

Build the LangGraph state machine, the specialised extractors, and the alternative unified path - with retries and graceful degradation.

P4

Post-extraction integrity

Validate against schema, assign sources, resolve cross-entity references, clean the payload, and expose it all in the review UI.

Scope discipline as a safety feature

The system extracts and structures. It deliberately does not score risk, price a policy, or write back into any system of record - the structured output lands in the database and the review UI, and a person confirms it. Equally deliberately, the proof of concept is bounded: four entity types, three file formats, one document set per session. What it refuses to infer is as much a design decision as what it extracts.

How it works

Documents go in. A validated, linked, sourced record comes out.

A user uploads a document set. Each file is parsed, chunked, classified and embedded into a session-scoped vector store. The orchestrator then walks a fixed sequence of nodes: retrieve the right context per entity, extract, merge, validate, source, resolve relationships - and either retry or finish.

The questions a single run answers
Determined & stored
  • Is this chunk about the home, an asset, an issue, or a document?
  • What are the home's core physical attributes and systems?
  • Which assets and equipment does the home contain?
  • What is each asset's manufacturer, model and serial number?
Reported with the record
  • Which defects and observations were recorded, and where?
  • Which issue belongs to which asset?
  • Which chunk, page and section did each field come from?
  • Does every entity satisfy its schema - and if not, which field failed?

Watch a document set become a structured record.

The console replays a representative run: files are parsed and chunked, chunks are classified into the four categories, context is retrieved per entity, the specialised extractors run, and then validation, sourcing and relationship resolution complete the record before it is stored.

  • Classification comes before extraction. Each chunk is labelled first, so an extractor only ever sees context from the categories relevant to its entity.
  • Assets before issues. The issue extractor runs after assets exist, which is what lets an observation be linked to the equipment it describes.
  • Every field carries its source. Document, page and section are attached to each entity, so the record can be opened back to the paragraph behind it.
  • Incomplete means retry, not ship. The final node decides whether the run is done or goes back through extraction.
Ingestion run · session 7c41 · 4 documents
$ POST /api/ingestion  { session: 7c41, model_provider: openai }
[processor] inspection-report.pdf → 41 pages parsed
[processor] invoice-hvac.pdf, manual-waterheater.pdf, warranty.csv parsed
[chunker] recursive split · 1000 chars · 200 overlap → 312 chunks
[classifier] rule-based pass → 274 chunks labelled
[classifier] LLM fallback on 38 ambiguous chunks (batch 25)
          home_characteristics 46 · assets 88 · issues 131 · documents 47
[embeddings] text-embedding-3-small · 1536 dims → Qdrant 7c41
✓ ingestion complete · metadata written to PostgreSQL
 
[node 1] load_schema → 4 entity schemas resolved
[node 2] retrieve_contexts_parallel → home, assets, issues, documents
[node 3] extract_parallel_group
          ├ home_characteristics → 1 Home entity
          ├ assets → 14 HomeAsset entities (MAP_MERGE, 3 batches)
          ├ documents → 4 AssetDocument entities
          └ issues → waits for assets → 26 HomeInfo entities
[node 4] merge_extraction_results → 3 duplicate assets collapsed
[node 5] validate_data → 44/45 entities schema-valid

[node 6] assign_sources → { doc_id, page, section } on every entity
[node 7] resolve_relationships → 22 issues linked, 2 stale refs removed
[node 8] check_completion → complete
[cleaner] null and empty fields stripped · identifiers preserved
✓ record stored · 1 home · 11 assets · 26 issues · 4 documents
✓ run traced in LangSmith · open in review dashboard

Illustrative run with representative data - in the delivered system every chunk, count and validation result is computed from the uploaded documents.

Architecture

Not one model call. A state machine with a retrieval layer under it.

A React front end uploads documents and reviews results over a REST API. A FastAPI backend parses and chunks them, classifies each chunk, and stores embeddings in session-scoped Qdrant collections. The LangGraph orchestrator then drives extraction across either provider, and four deterministic services turn raw extraction into a governed record. PostgreSQL holds state and results; LangSmith traces every call.

Front end · React 18 + Vite + Tailwind
Document upload
drag-and-drop · type & size checks
Session management
create · list · lock state
Extraction results viewer
entities · sources
Review dashboard
team verification
Backend · Python FastAPI
Auth service
JWT
Document processor
PyPDF · Pandas · OpenPyXL
Chunk classifier
rules + LLM fallback
Ingestion Orchestrator · LangGraph StateGraph
8 nodes · MemorySaver checkpointing · routes on model_provider
Home characteristics
extractor · 1 entity
Asset extractor
MAP_MERGE batching
Issue extractor
runs after assets
Doc metadata
extractor
Unified extractor
Claude · single call
Validation service
JSON schema
Source tracker
doc_id · page · section
Relationship resolver
cross-entity refs
Data cleaner
payload reduction
Qdrant · vector DB
session-scoped collections · 1536-dim embeddings · chunk metadata
PostgreSQL 16
document metadata · ingestion results · sessions
OpenAI API
gpt-5-mini · specialised extraction · embeddings
Anthropic API
Claude Sonnet 4.5 · unified extraction
LangSmith
tracing · token usage · errors
Why a state machine and not a chain

Extraction has real branching: two provider paths, a node that must wait for another to finish, and a completion check that can send the run back for a retry. A LangGraph StateGraph makes that explicit - shared state, one node per job, checkpointed so a run can be recovered rather than restarted - and it means every step is a place to gate, log and trace.

The as-scoped system - redrawn from the engagement's architecture documentation, anonymised. Runs under Docker Compose in local and production configurations.

The life of an ingestion run, node by node

The orchestrator executes these in order, holding shared state (IngestionState) across all eight.

Node 01

load_schema

Load and parse the JSON schema definitions every downstream node validates against.

Node 02

retrieve_contexts_parallel

Pull entity-specific context from Qdrant for all four entity types at once, deduplicated by document and chunk index.

Node 03

extract_parallel_group

Run the specialised extractors in parallel on the OpenAI path, or the single unified extractor on the Claude path.

Node 04

merge_extraction_results

Merge the outputs and deduplicate assets, issues and documents by signature.

Node 05

validate_data

Validate every entity against its schema - required fields, types, enums, date formats, nested arrays.

Node 06

assign_sources

Attach the document, page and section metadata that makes each entity traceable to its chunk.

Node 07

resolve_relationships

Validate cross-entity references - issues to assets, assets to issues and documents - and strip the invalid ones.

Node 08

check_completion

The conditional gate: complete the run, or route back through extraction for another attempt.

Inside the build

The decisions that made the output trustworthy.

Anyone can prompt a model for JSON. The engineering that makes an extracted record safe to store, review and act on lives in a handful of deliberate choices - each one below is as implemented in the proof of concept.

Decision A · Retrieval

Classify the chunks first, so each extractor reads less.

The naive approach hands every extractor the whole document. That buries the relevant paragraph in noise, burns context, and makes the asset extractor reason about roof material. So every chunk is classified into one of four categories at ingestion time, and each extractor retrieves only from the categories that matter to it.

Classification

Rules first, model second

Rule-based keyword matching takes priority and handles the clear majority; the LLM is a fallback for genuinely ambiguous chunks only, batched for throughput. The priority order is deliberate - issues are the highest-signal category, home characteristics the hardest to claim on a single keyword.

  • Issues - one keyword match is enough
  • Assets - two or more matches, or a strong marker
  • Home characteristics - three or more matches
  • Documents - one keyword match
  • LLM fallback runs deterministically, in batches of 25
Retrieval

Each entity, its own categories

Retrieval is category-scoped rather than global, and runs in parallel for all four entity types. Chunks are deduplicated by document and chunk index, then aggregated and ranked, and the context string carries its own metadata forward - which is what makes source assignment possible later.

home_characteristics ← [ home_characteristics, documents ] assets ← [ assets, issues ] issues ← [ issues ] documents ← [ documents ] context = { chunk_index, document_id, page, section, score, entity_category }
The non-obvious mapping

The asset extractor also reads the issues category. Equipment is frequently named for the first time inside a defect note - "rust at the base of the 2016 Rheem water heater" is both an issue and the only place that asset appears. Retrieving issues for assets is what stops those assets going missing.

Decision B · Model strategy

Two provider paths behind one contract.

The system supports both OpenAI and Claude, selected per run by a single model_provider parameter. This isn't hedging - the two paths have genuinely different shapes, and having both in the proof of concept is how the trade-off gets measured on the client's own documents rather than argued about.

 OpenAI pathClaude path
ShapeFour specialised extractorsOne unified extractor
ExecutionParallel, with issues sequenced after assetsAll entity types in a single API call
PromptNarrow, one entity type eachAll context types combined into one prompt
Large documentsMAP_MERGE batching splits, processes, mergesHandled within the single call
Trade-offMore round-trips; tighter control per entityFewer round-trips; one place to get everything right
OutputIdentical - { home, home_assets, home_infos, asset_documents }, normalised inside the extractor
Why it matters downstream

Because both paths normalise to the same output contract, everything after node 3 is provider-agnostic - validation, sourcing, relationship resolution and cleaning don't know or care which path ran. Switching providers is a parameter, not a rewrite.

Decision C · Determinism

Same documents, same record - or the tests mean nothing.

An extraction system that returns slightly different values each run cannot be regression-tested, and a reviewer who re-runs a document set and sees different numbers stops trusting both results. So determinism is a configuration standard, not a preference.

Decision D · Large documents

Batch, extract in parallel, merge - rather than truncate.

A 40-page inspection report can produce more relevant asset context than a single call should carry. The alternative to truncation is MAP_MERGE: the context is split into batches, each batch is extracted in parallel, and the results are merged and deduplicated as though they came from one pass.

Split

Context into batches

Retrieved context is divided rather than trimmed, so no chunk is silently dropped for length.

Map

Extract in parallel

Each batch is extracted independently, keeping latency close to a single pass.

Merge

Consolidate & dedupe

Results are merged and collapsed by signature - the same asset seen in two batches lands once.

Two chunking strategies feed this: recursive splitting at 1000 characters with 200 characters of overlap by default, or hybrid chunking that detects document structure - headings, sections, tables, lists - preserves section hierarchy, filters boilerplate, and only falls back to recursive splitting for blocks over 1250 characters.

Decision E · Identity

One furnace, however many documents mention it.

Across a document set the same asset appears repeatedly - in the inspection report, in the invoice that serviced it, in its own manual. Deduplication is therefore a first-class step rather than a cleanup afterthought, and each entity type gets the signature that actually identifies it.

Assets - by signature

Collapsed on the combination of type, manufacturer, model number, serial number and level. Field normalisation runs first - brand becomes manufacturer, model becomes model_number - because two spellings of the same asset won't match on an un-normalised key.

Issues - by normalised text

Observations are deduplicated on normalised content, which is what stops the same defect appearing once per document that mentions it. Property keys on the home entity are deduplicated too, and unit-level asset detail is filtered out of home properties rather than allowed to sit in both places.

Decision F · Fault tolerance

A partial failure returns a partial record, not an error page.

Four extractors running in parallel means four things that can fail independently. The pipeline is built so that one failing extractor yields an empty result for its entity type and the run continues - the reviewer gets the home, the assets and the documents, and sees plainly that issues came back empty.

Validation & trust

Extracted is not the same as correct.

Four deterministic, non-LLM services stand between the extractors' output and the stored record. None of them use a model - they are the part of the system that has to be predictable, because their job is deciding what the language models are allowed to have produced.

Traceability is the feature

Source assignment is what turns an extracted value into an auditable one. A reviewer looking at "water heater, Rheem, installed 2016" can open the page and paragraph it came from - which is the difference between a record a carrier can operate on and a model output someone has to take on faith.

Where the pipeline stops, slows or refuses
GateTriggerOutcomeWhy it exists
Upload validationUnsupported file type, or over 50MBRejected at the APINothing unparseable ever reaches the pipeline
Session lockA second operation on a busy sessionBlocked while busyConcurrent runs on one session would corrupt shared state
Schema validationAn entity or field violates its schemaField dropped or entity filteredAn invalid record is worse than an incomplete one
Reference validationA relationship points at a missing entityReference removedA dangling link is a silent data error downstream
Completion checkExtraction incomplete after a passRetry, or completeA thin result gets another attempt rather than shipping
Extractor failureOne extractor errorsEmpty result for that entity, run continuesPartial data with a visible gap beats no data at all

An empty field is a legitimate answer.

The temptation in document extraction is to fill every field, because a full record looks like a better result. It isn't. A guessed serial number or an inferred install date is indistinguishable from a real one once it is in the database, and it will be trusted exactly as much.

So the system is built to leave fields empty, drop values that fail validation, and let the reviewer see the gap. Deterministic validation, not the model's confidence, decides what survives.

  • Schema-enforced output
  • Source on every entity
  • Invalid references removed
  • Zero-temperature inference
  • Full LangSmith trace
  • Human review before use

> What is the water heater's install date?

The manual gives the model and the manufacture year, and the inspection report notes the unit as "approximately 8 years old" - but no document states an installation date.

I've left install_date empty rather than deriving one, and recorded the two sources that discuss the unit's age. The asset itself is stored with its manufacturer, model number and serial number, each traced to its page.

Open the source chunks →

Infrastructure

Engineered as a service, not a notebook.

The proof of concept runs as a containerised stack under Docker Compose - FastAPI backend, React front end, Qdrant, PostgreSQL - with sessions isolated from each other and every model call traced from the first day rather than the first incident.

Before · reading by hand

Gated on a person with time

  • Someone reads a 40-page report and types what matters into a form
  • Assets, issues and home attributes separated by judgement, not by rule
  • Issues linked to equipment from memory, if at all
  • The same asset entered twice from two documents
  • No record of which page a value came from
  • Coverage and depth vary with who did the reading
After · the extraction pipeline

A run, not a queue

  • A document set is uploaded and processed as one traced run
  • Every chunk classified before any extraction happens
  • Issues linked to assets by resolved, validated references
  • Duplicates collapsed by signature across the whole set
  • Document, page and section attached to every entity
  • Zero-temperature inference - the same set gives the same record

The same reading work, expressed as a deterministic pipeline with an audit trail.

Qdrant

Session-scoped collections holding chunk embeddings and the metadata that makes retrieval category-aware.

Why: per-session collections give multi-tenant isolation for free - one session's documents can never be retrieved into another's extraction run.

PostgreSQL 16

Document metadata, ingestion results and session records - the structured, queryable side of the system.

Why: the extracted record is relational by nature - a home, its assets, the issues against them - and the review UI needs to query it that way.

LangSmith

Automatic tracing of every LangChain operation, with model-call monitoring, token usage, performance metrics and error tracking.

Why: an audit lens and a cost lens from day one - which extractor consumed what, and where a bad field entered the record.

Session management
  • Session-specific vector collections for tenant isolation
  • Session locking prevents concurrent operations on one session
  • Busy-state tracking so the UI can report operation status
  • Document count tracked per session
API surface
  • /api/auth - JWT login and user info
  • /api/documents - upload, search, list, delete
  • /api/sessions - create, list, get, update, delete
  • /api/ingestion - extract and fetch results by ingestion, home or session ID
  • /health - Qdrant and PostgreSQL status
Outcomes

What was built, and what it changes.

We report build facts, not projections. The figures below describe the system as scoped and implemented for the proof of concept; the before-and-after is a description of how the work changes shape, not a claimed metric. Extraction accuracy and throughput are measured on the client's own document sets once the POC runs at volume - and we would rather publish those numbers late than invent them now.

As-scoped · POC implementation

The delivered system, in countable facts

0

Orchestrated nodes in the extraction state machine

0

End-to-end processing steps, schema load to final output

0

Extraction agents - four specialised, one unified

0

Deterministic supporting services, no model involved

0

Linked entity types in the output record

Before · reading documents by handAfter · the extraction pipeline
An hour or more per document set, per readerOne traced run over the whole set, unattended
Home facts, assets and issues separated by judgementEvery chunk classified into four categories before extraction
Output shape varies with the person and the documentSchema-validated entities, or an explicit gap
Issues recorded without the equipment they belong toIssues linked to assets by validated references
The same asset entered once per documentDuplicates collapsed by signature across the set
No trail from a stored value back to a pageDocument, page and section on every entity, traced in LangSmith
What the POC is for

A proof of concept earns its keep by settling arguments cheaply. This one settles three: whether category-based retrieval beats handing an extractor the whole document, which provider path performs better on real inspection reports, and how much of the record survives strict schema validation. All three answers come from the client's own documents - which is exactly why the two model paths and the feature flags exist.

What's next

Built to extend past the proof of concept.

The node structure, the schema layer and the provider abstraction were chosen so the system can grow - more document types, richer confidence signals, corrections that feed back - without re-architecting the core.

Roadmap

Where it goes next

  • Measured accuracy - field-level extraction accuracy and review time from real runs, replacing today's build facts with verified numbers.
  • Confidence per field - a score attached to each extracted value so reviewers can triage the record instead of reading all of it.
  • Corrections that teach - reviewer edits captured as signal for prompt and schema refinement rather than discarded at save.
  • Broader document coverage - more source types and formats behind the same four entity schemas.
  • Contextual enrichment on by default - the optional chunk-enrichment path evaluated for retrieval accuracy at volume.
The reusable pattern

This generalises.

Classify before you retrieve, retrieve per entity, extract with narrow agents against a fixed schema, then let deterministic services validate, source and link the result. The pattern transfers to any domain where the facts live in documents written for people and have to end up in a system built for machines.

  • Property & inspection data
  • Claims documentation
  • Asset & equipment registers
  • Contract & warranty terms
  • Any schema-bound extraction
The stack, end to end
API
FastAPI 0.121.2
UI
React 18.3.1 · Vite 6.0.3
CSS
Tailwind 3.4.17
STATE
Zustand · TanStack Query
LC
LangChain 1.1.3
LG
LangGraph 1.0
AI
gpt-5-mini
AI
Claude Sonnet 4.5
EMB
text-embedding-3-small
VDB
Qdrant 1.15.1
PG
PostgreSQL 16
DOC
PyPDF · Pandas · OpenPyXL
LS
LangSmith 0.4.58
AUTH
JWT · python-jose
OPS
Docker Compose
Let's build

Have facts trapped in documents that your systems need as data?

We design and ship production-grade extraction and agent systems for high-stakes work - schema-bound, validated and traceable at every step, so the output can be trusted without someone re-reading the source.