Azure Troubleshooting

Azure Document Intelligence Misreads: Low-Confidence Fields, Broken Table Extraction, and Custom Model Drift

The pipeline ran clean for three weeks, then finance flagged it: an invoice for ₹4,18,500 was booked as ₹4,185.00 because the decimal moved, and a supplier name came back as Acme lndia Pvt Ltd with a lowercase L where a capital I should be. Nobody changed the code. The model is the same model. But the supplier started sending scanned PDFs instead of digital ones, and that one change quietly demolished your extraction accuracy. This is the most common production reality of Azure AI Document Intelligence — the managed OCR-plus-structure service (the artist formerly known as Form Recognizer) that turns invoices, receipts, IDs and your own custom forms into typed JSON — and it is maddening because the service almost never errors. It returns a 200 OK with a confident-looking payload that happens to be wrong.

That is the whole problem with debugging document AI: there is rarely an exception to catch. A misread is a successful API call with a bad value inside it, often carrying a confidence score the caller never checked. A broken table is a tables array that parsed — it just put the total in the wrong column. A drifted custom model still returns fields; they’re just blank or shifted on the new document layout the model never saw. The failures hide inside well-formed responses, which is why teams ship them straight to a database and find the damage weeks later in a reconciliation report.

This is the diagnostic playbook for those silent failures. We treat low-confidence fields, broken table extraction and custom-model drift not as three bugs but as three symptom classes, each confirmed with a specific check — the raw JSON, the confidence and spans, the Document Intelligence Studio overlay, the training labels, or the API version you pinned. By the end you stop trusting the green checkmark: when a value looks wrong you will know whether you face a low-DPI scan, a model probing the wrong region, a prebuilt-invoice field that doesn’t exist for your locale, a table whose header merged into the body, or a custom model trained on five clean samples now meeting the real world. Knowing which, in minutes rather than days, is the difference between a contained data-quality issue and a quarter of corrupted records.

What problem this solves

Document Intelligence hides enormous machinery — OCR, layout analysis, key-value association, table reconstruction, and for custom models a trained extractor — so you can POST a PDF and get back typed fields. That abstraction is a gift until the values are subtly wrong, and then it becomes an opaque wall. The service deliberately returns a structured result rather than throwing, because for most callers a best-effort answer with a confidence number is more useful than a hard failure. But that design means the burden of judging correctness is on you, and if you don’t read the confidence, inspect the spans, or look at the bounding regions, you have no idea the answer is wrong until a human downstream catches it.

What breaks without this knowledge: a team pipes fields.InvoiceTotal.content straight into accounts payable with no threshold, so a 0.42-confidence misread posts as fact. Another trains a custom model on a dozen pristine samples, sees 0.98 on the test set, ships it — then the first real batch arrives rotated, at 150 DPI, with a logo the model now treats as an anchor, and extraction collapses. A third expects a Tax field its region’s receipts don’t print, gets null, and assumes the API is broken. None of these throw; all are diagnosable in the raw response; all are ignored because the response said 200.

Who hits this: anyone automating document workflows — AP automation, KYC/onboarding, claims, contract intake, expense management. It bites hardest on teams feeding scanned or photographed documents (DPI, skew and noise dominate accuracy), using custom models trained on too few or too clean samples (drift is near-universal), that never set a confidence threshold (so every misread ships), or that pinned an old API version and don’t realise newer models extract tables and fields differently. The fix is almost never “the model is bad” — it’s “find the stage degrading the answer and feed it, configure it, or threshold it correctly.”

To frame the whole field before the deep dive, here is every symptom class this article covers, the question it forces, and the one place to look first:

Symptom class What the result is really telling you First question to ask First place to look Most common single cause
Low-confidence field “I read something here but I’m unsure” Is the input legible, or is the model wrong? The confidence + the source pixels at that boundingRegion Low-DPI / skewed / noisy scan
Wrong-value field (high confidence) “I’m confident — and wrong” Did it read the right location on the page? The field’s spans / bounding box in Studio Field bound to the wrong region or wrong model
Null / missing field “That field isn’t in my schema for this doc” Does this field even exist for this model/locale? The model’s documented field list Field unsupported for the document type/region
Broken table “I reconstructed a grid that doesn’t match” Did header/row/column structure survive OCR? tables[].cells rowIndex/columnIndex/kind Merged cells, missing ruling lines, multi-page split
Custom-model drift “New layout, same model, blank fields” Did the production doc match the trained layout? Per-field confidence vs training docInfo Too few / too clean training samples
Quota / throttle (429) “You’re sending faster than your tier allows” Is it the model, or the rate limit? Retry-After header + tier TPS Free (F0) tier or burst over the limit

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand the basics: Document Intelligence is one of the Azure AI Services (the multi-service family formerly branded Cognitive Services), provisioned as a resource with an endpoint and keys (or, better, a managed identity). You should know how to run az in Cloud Shell, read JSON, and that the service exposes prebuilt models (invoice, receipt, ID, business card, layout, read) and lets you train custom models on your own labelled documents. Familiarity with REST APIs, HTTP status codes and async long-running operations (POST to start, GET to poll) helps, because the analyze flow is asynchronous.

This sits in the AI/ML & Observability track. It assumes you have a resource provisioned and a document to analyse; the resource-model and identity fundamentals live in Azure AI Foundry: Hub & Project Resource Model Explained, and securing the keys with identity is covered in Managed Identity: System-Assigned vs User-Assigned Patterns. If you are pushing extracted fields into a search index for retrieval, Azure AI Search: Create Your First Index, Indexer & Skillset is the natural next hop — Document Intelligence is a common skill inside that skillset. For storing the source documents, Azure Blob Storage Fundamentals is where they live, and it is the input to batch analysis.

A quick map of who owns what when a misread lands, so you escalate to the right person fast:

Stage What lives here Who usually owns it Failure classes it can cause
Source document DPI, skew, noise, format, language Whoever supplies the doc Low confidence, OCR misreads
Ingestion / upload Blob, SAS, content-type, size App / platform team 400 (bad input), truncated reads
OCR / read Text recognition, lines, words Microsoft (model) Misread characters, missing text
Layout Tables, selection marks, regions Microsoft (model) Broken tables, wrong regions
Field extraction Typed fields + confidence Microsoft (prebuilt) / you (custom) Null fields, drift, wrong values
Your post-processing Threshold, mapping, validation App / dev team Shipping unreviewed low-confidence values

Core concepts

Five mental models make every later diagnosis obvious.

A misread is a successful call with a bad value, not an error. Document Intelligence is best-effort: it almost always returns 200 OK with an analyzeResult, even when it read garbage. The signal of correctness is not the status code — it is the confidence on each field, line and word, and whether the field’s bounding region sits over the pixels you expected. If your code only checks for HTTP errors, you are blind to every misread the service ever makes. The first discipline of this whole topic is: read the confidence, look at the location.

The pipeline has stages, and a misread belongs to exactly one. Every analysis flows OCR/read (turn pixels into words with positions) → layout (group words into lines, paragraphs, tables, selection marks, and detect document regions) → field extraction (map content to typed fields — done by a prebuilt model or your custom model) → your post-processing (threshold, map, validate, store). A wrong character is an OCR problem (fix the input). A total in the wrong column is a layout/table problem. A blank InvoiceTotal on a doc that clearly has one is a field-extraction problem (model or drift). Localising the failure to a stage tells you which knob to turn — and stops you from “retraining the model” when the real fix was a higher-DPI scan.

Prebuilt and custom models fail differently. Prebuilt models (prebuilt-invoice, prebuilt-receipt, prebuilt-read, prebuilt-layout, prebuilt-idDocument, and the general prebuilt-document/key-value model) ship with a fixed, documented field schema and are trained by Microsoft on huge corpora — they fail by not having a field you want (it’s not in their schema or not in your locale) or by mis-mapping on an unusual layout. Custom models you train on your own labelled documents fail by drift: they learned the layout of your training set and degrade on anything that differs (new vendor template, different DPI, rotation). Knowing which kind you’re calling tells you whether the fix is “accept this field doesn’t exist” or “expand the training set.”

Confidence is per-element and bounded 0 to 1 — and your threshold is a product decision. Each field, line and word carries a confidence in [0.0, 1.0]. It expresses the model’s certainty, not a guarantee of correctness — a confident misread is possible (and is its own symptom class). The right pattern is a threshold with a human-in-the-loop fallback: above the threshold, auto-process; below it, route to a person. There is no universal “good” number — a 0.80 cut-off that’s fine for a free-text comment field is reckless for a payment amount. Treating confidence as a binary “it worked” rather than a tunable risk dial is the root cause of most shipped misreads.

Every config — model, API version, page range, locale — changes the answer. The result depends on which model id you call, which API version you pinned (newer versions extract tables and add fields the old one didn’t — the service has moved from the v2.x Form Recognizer API to the v3.x / 2024-11-30 GA Document Intelligence API, with prebuilt-layout gaining far better table handling along the way), the page range you analysed, and the locale/language you hinted. A pipeline that “suddenly” extracts differently usually had its model id or API version changed, or started receiving a document type the chosen model wasn’t built for.

The vocabulary in one table

Before the deep sections, pin down every moving part. The glossary at the end repeats these for lookup; this table is the mental model side by side:

Concept One-line definition Where it lives Why it matters to misreads
analyzeResult The JSON envelope holding pages, tables, fields API response body The single source of truth — read it, not just the field value
confidence Model certainty for a field/line/word, 0–1 On every extracted element Your only built-in signal that a value may be wrong
content The recognised text of an element On fields/lines/words What you usually consume; can be confidently wrong
spans Offset+length into the flat content stream On elements Lets you trace a field back to the exact recognised text
boundingRegions Page number + polygon of where it sits On fields/tables/cells Proves the model read the right place
Prebuilt model Microsoft-trained fixed-schema extractor prebuilt-* model ids Fails by missing fields / unusual layout
Custom model Your model trained on your labelled docs Your model id Fails by drift on unseen layouts
Template vs neural Custom model build modes Build-time choice Template = layout-rigid; neural = layout-flexible
Selection mark A checkbox/radio detected as selected/unselected selectionMarks in layout Mis-detected → wrong boolean fields
Confidence threshold Your cut-off for auto-accept vs review Your code The control that stops misreads shipping
API version The dated service contract you call api-version query param Changes fields/tables returned
DPI Scan resolution (dots per inch) The input file Low DPI is the top OCR-accuracy killer

The model and API-version reference

Before the per-symptom anatomy, here is the lookup table you scan first: which model produces which output, and what each is for. Half of all “the API is broken” tickets are someone calling the wrong model for their document — asking prebuilt-receipt to extract invoice line items, or prebuilt-read (pure OCR, no fields) and wondering where the typed fields went.

Model id What it returns Use it for What it does NOT do
prebuilt-read Text, lines, words, languages (OCR only) Raw text extraction, language detection No tables, no typed fields, no key-value pairs
prebuilt-layout Text + tables + selection marks + structure Anything needing table/structure, including custom-model prep No semantic field names (it’s structure, not meaning)
prebuilt-document (general key-value) Key-value pairs + tables + structure Generic forms with no dedicated prebuilt No schema specific to invoices/receipts
prebuilt-invoice Invoice fields (vendor, total, line items, etc.) Invoices, bills Fields outside its schema; non-invoice docs
prebuilt-receipt Receipt fields (merchant, total, tax, items) Retail/expense receipts Invoice-specific fields; un-printed fields (region-dependent)
prebuilt-idDocument ID fields (name, DOB, document number) Passports, driving licences, national IDs Document types it wasn’t trained on
prebuilt-businessCard Contact fields (name, company, phone, email) Business cards Free-form documents
Custom (template) Your labelled fields, layout-rigid Consistent fixed layouts, fast/cheap training Generalising to unseen layouts
Custom (neural) Your labelled fields, layout-flexible Varying layouts of the same doc type Magic on truly random documents
Custom classifier Document type classification Routing mixed batches to the right model Field extraction (it classifies, then you extract)

Three reading notes that save the most time:

Distinction The trap How to tell them apart
prebuilt-read vs prebuilt-layout Expecting tables from read read has no tables array at all; use layout for structure
Prebuilt-missing-field vs model bug Hours blaming the model for a null If the field isn’t in the model’s documented schema (or your locale doesn’t print it), null is correct behaviour
Old API version vs model regression “It used to extract this table” Diff the api-version you send; v3.x / 2024-11-30 extracts tables the old v2.x API didn’t

Reading a result: confidence, content, spans, and regions

You cannot debug what you don’t look at, and the field value alone is not enough. Every diagnosis in this article starts by opening the raw analyzeResult and reading four things about the suspicious element: its content (what was recognised), its confidence (how sure), its spans (where in the flat text stream it came from), and its boundingRegions (which page and what polygon — i.e. where on the page). A value that’s wrong-but-confident has the wrong bounding region; a value that’s uncertain has a low confidence; a value that’s missing simply isn’t in the fields object.

The call is asynchronous: you POST to ...:analyze and get a 202 with an Operation-Location header, then GET that URL until status is succeeded (the lab below shows the full POST). Poll and inspect the suspicious field in one slice:

ENDPOINT="https://di-shop-prod.cognitiveservices.azure.com"; KEY="<key>"   # prefer a managed-identity token
# Poll the operation, then read the field the way you must ALWAYS inspect it — value + confidence + region:
curl -s "$ENDPOINT/documentintelligence/documentModels/prebuilt-invoice/analyzeResults/<operationId>?api-version=2024-11-30" \
  -H "Ocp-Apim-Subscription-Key: $KEY" | jq '.status, .analyzeResult.documents[0].fields.InvoiceTotal'

That jq slice returns the field as value and confidence and region together:

{
  "type": "currency",
  "valueCurrency": { "amount": 4185.00, "currencyCode": "INR" },
  "content": "₹4,185.00",
  "confidence": 0.412,
  "spans": [ { "offset": 1842, "length": 9 } ],
  "boundingRegions": [ { "pageNumber": 1, "polygon": [6.1,8.9, 7.0,8.9, 7.0,9.1, 6.1,9.1] } ]
}

Three things to read off this immediately: confidence is 0.412 (well below any sane auto-accept threshold — this should have gone to review, not the ledger); the content is ₹4,185.00 where the source said ₹4,18,500 (the Indian digit grouping confused the parse); and the boundingRegions polygon tells you exactly which pixels to go look at to confirm. The fields you must learn to read, and what each tells you:

Result field What it is What it tells you in a misread
status Operation state (running/succeeded/failed) failed is a real error; succeeded ≠ correct
analyzeResult.modelId Which model actually ran Confirms you called what you think you did
documents[].fields.<F>.content The recognised text of field F The value you consume — verify against pixels
documents[].fields.<F>.confidence Certainty 0–1 for field F Below threshold → don’t auto-accept
documents[].fields.<F>.spans Offset/length into content stream Trace the field to its source text
documents[].fields.<F>.boundingRegions Page + polygon of the field Proves it read the right place
pages[].words[].confidence Per-word OCR certainty Pinpoints the exact bad word, not just the field
pages[].angle Detected page rotation in degrees Large angle → skew is hurting OCR
tables[] Reconstructed grids Inspect cells to debug table breaks

The fastest way to see all of this is Document Intelligence Studio (https://documentintelligence.ai.azure.com): upload the document, run the model, and it overlays the bounding boxes on the page with confidence colour-coding. When a value is wrong, the overlay shows you in one glance whether the box is over the right text (an OCR misread) or over the wrong region entirely (a field-mapping or drift problem). Keep Studio open beside the JSON during any investigation.

Symptom 1 — Low-confidence fields

A low-confidence field is the service saying “I read something here, but I’m not sure.” It is the honest failure mode — the value may still be right, but the model is flagging risk, and if you’re not reading confidence you’re throwing that signal away. Five distinct causes. Scan the matrix, then read the detail for whichever row matches:

# Low-confidence cause Tell-tale signal Confirm with Real fix Band-aid that masks it
1 Low-DPI / blurry scan Whole-page word confidence low; fuzzy pixels pages[].words[].confidence; view source at the region Rescan at ≥300 DPI; preprocess Lower the threshold (ships misreads)
2 Skewed / rotated page pages[].angle large; text slanted Check angle; Studio overlay tilted De-skew/auto-rotate before upload Accept the bad read
3 Handwriting vs print Cursive/handwritten fields low, printed fields fine styles[].isHandwritten; per-field confidence Expect lower bar for handwriting; HITL review Treat like print (it isn’t)
4 Unsupported / wrong language Whole document low; wrong script Compare doc language to supported list Use a supported language; prebuilt-read for detection Force it anyway
5 Genuinely ambiguous source One field low, rest fine; the source IS unclear Look at the pixels — a human can’t read it either Human-in-the-loop; fix at source Guess

Cause 1 — Low-DPI, blurry or noisy scans (the dominant cause)

OCR accuracy is dominated by input quality, and resolution is the single biggest lever. A document scanned or photographed at 150 DPI, compressed hard, or full of speckle noise gives the read model fuzzy glyphs, and it returns plausible-but-uncertain characters: 5 read as S, 0 as O, 1 as l, a moved decimal. The field comes back with low word-level confidence because the model genuinely could not see clearly.

Confirm. Look at per-word confidence on the page, not just the field, to see whether the whole image is degraded or just one field:

# Lowest-confidence words on page 1 — a broadly low floor means the SCAN is the problem
curl -s "$ENDPOINT/documentintelligence/documentModels/prebuilt-invoice/analyzeResults/<operationId>?api-version=2024-11-30" \
  -H "Ocp-Apim-Subscription-Key: $KEY" \
  | jq '[.analyzeResult.pages[0].words[] | {content, confidence}] | sort_by(.confidence) | .[0:10]'

In Studio, the page overlay highlights low-confidence words — if most of the page lights up, it’s the image, not the model. If you have to squint to read the pixels at the bounding region, so did the model.

Fix. Fix the input. Source at 300 DPI or higher (Microsoft’s recommended OCR minimum is around 300 DPI; tiny fonts benefit from more). Where you can’t control the source, preprocess — de-noise, raise contrast, and keep the file within the service’s input limits.

Input attribute Recommended Why it matters What goes wrong below it
Resolution ≥ 300 DPI Glyphs resolve cleanly Character substitutions, moved decimals
Format PDF, JPEG, PNG, TIFF, BMP, HEIF Supported, lossless-friendly Unsupported → 400; heavy JPEG → artefacts
Max file size Within tier limit (standard tier larger than free) Service rejects oversize 400 / InvalidContentLength
Max pages per analyze Bounded (standard tier far higher than free) Service truncates/limits Later pages silently not analysed
Min text height Legible at the DPI used Tiny fonts under-resolve Low confidence on fine print
Colour / contrast High contrast, minimal noise Clean foreground/background split Speckle read as characters

Causes 2–5 — skew, handwriting, language, and genuine ambiguity

The other four low-confidence causes share the same diagnostic move — read the right field of the result — so handle them together:

Symptom 2 — Confidently wrong values

The nastier sibling of low confidence: a field comes back with high confidence and the wrong value. The model isn’t flagging risk because it isn’t uncertain — it read something cleanly, just not what you wanted. This is where teams that “trust high confidence” get burned, because a threshold alone won’t catch it. Four causes:

# Wrong-value cause Tell-tale signal Confirm with Real fix
1 Field bound to the wrong region High confidence, value is some other field’s text Bounding box in Studio sits over the wrong area Custom model with correct labels; or post-validate
2 Two candidates on the page E.g. subtotal picked instead of total Both values present; model chose the salient one Validate by business rule; custom-label the right one
3 Wrong model for the document Invoice run through prebuilt-receipt etc. analyzeResult.modelId ≠ the right model Call the correct prebuilt/custom model
4 Locale/format misparse Right text, wrong typed value (date/number/currency) content correct but valueDate/valueCurrency wrong Check locale; validate parsed value vs content

The wrong-region check is the anchor for all four. When confidence is high but the value is wrong, the field read clean text from the wrong place, so the value is irrelevant — the location is the bug. In Studio, click the field and see where its box sits; in JSON, read boundingRegions[].polygon and compare to where the right value lives. The fix is a custom model with the correct field labelled, and/or a post-extraction validation that rejects the impossible. The four variants:

Symptom 3 — Broken table extraction

Tables are the hardest thing OCR does, because a table is spatial structure inferred from text positions, not something explicitly tagged in a scanned PDF. When extraction breaks, the tables array still parses — it just has the wrong shape: a header merged into row one, a total in the wrong column, a two-page table split into two tables, or a borderless table under-segmented. Five causes. Remember: tables come from prebuilt-layout (or any model that includes layout) — prebuilt-read returns no tables at all.

# Table-break cause Tell-tale signal Confirm with Real fix
1 Merged / spanning cells One cell’s columnSpan/rowSpan > 1; values shift tables[].cells[].columnSpan/rowSpan Handle spans in code; map by kind not position
2 Missing ruling lines (borderless) Columns bleed together; wrong column count Compare columnCount to the real table Use a model/version with better borderless handling
3 Header fell into the body Row 0 has data, no columnHeader cells cells[].kind (columnHeader vs content) Detect header by kind, not by assuming row 0
4 Multi-page table split Same table returns as two tables entries Two tables, page break between them Stitch by column structure across pages
5 Used prebuilt-read (no tables) tables array absent/empty analyzeResult.modelId is prebuilt-read Switch to prebuilt-layout

Reading a table the right way

The crucial discipline: never address table data by raw row/column number alone — read kind and the span fields. Each cell carries rowIndex, columnIndex, rowSpan, columnSpan, and kind (columnHeader, rowHeader, stubHead, or content). Code that assumes “header is row 0, amount is column 3” shatters the moment a cell spans two columns or the header detection shifts.

# Dump a table's cells with their structure — the only honest way to see what was reconstructed
curl -s ".../analyzeResults/<operationId>?api-version=2024-11-30" -H "Ocp-Apim-Subscription-Key: $KEY" \
  | jq '.analyzeResult.tables[0] | {rowCount, columnCount,
        cells: [.cells[] | {r:.rowIndex, c:.columnIndex, rs:.rowSpan, cs:.columnSpan, kind, content}]}'

The cell anatomy you must understand to debug any table:

Cell property Meaning Default Why it breaks naive code
rowIndex / columnIndex Zero-based grid position Shifts when a span consumes positions
rowSpan / columnSpan How many rows/cols the cell covers 1 A merged header throws off every later column index
kind columnHeader / rowHeader / stubHead / content content The real way to find headers — not “row 0”
content The cell’s text May itself be a multi-line wrap
boundingRegions Page + polygon of the cell Confirms which page (matters for split tables)
rowCount / columnCount Table dimensions Wrong count = under/over-segmentation

The five ways tables break, and the fix for each:

Symptom 4 — Custom-model drift and training problems

A custom model that scored beautifully at training and now returns blank or wrong fields in production hasn’t “broken” — it’s meeting documents that differ from what it learned. This is the most preventable failure class and the one teams most consistently walk into, because the test-set score feels like a guarantee. Five causes:

# Drift / training cause Tell-tale signal Confirm with Real fix
1 Too few training samples Great on test docs, poor on new ones Compare training doc count to variety in prod Add diverse samples (cover the real spread)
2 Too-clean / non-representative training Trained on digital PDFs, prod is scanned Compare training input quality to production Train on production-representative docs
3 Template model on varying layouts New vendor template → blank fields Model build mode = template; layout differs Rebuild as neural, or per-template models + classifier
4 New document variant in production A field that always worked is now null Per-field confidence collapses on the new variant Add the new variant to training; retrain
5 Inconsistent / sparse labelling Some fields flaky from day one Review the labelled training set in Studio Re-label consistently; label every occurrence

Cause 1 — Too few training samples

A template custom model can train on as few as five documents, and that’s the trap: five near-identical samples produce a model that memorised that exact layout. It scores ~0.98 on a held-out copy of the same layout and falls apart on real variety. The high training score is overfitting, not quality.

Confirm. Look at how many documents — and how much variety — the model was trained on, versus the variety arriving in production. A handful of samples against dozens of real-world variants is the diagnosis.

# List your custom models and inspect one's build details
az cognitiveservices account list --query "[?kind=='FormRecognizer'].{name:name, rg:resourceGroup}" -o table
# Model details (created date, description, doc types) via REST:
curl -s "$ENDPOINT/documentintelligence/documentModels/<your-model-id>?api-version=2024-11-30" \
  -H "Ocp-Apim-Subscription-Key: $KEY" | jq '{modelId, createdDateTime, description, docTypes: (.docTypes|keys)}'

Fix. Add training samples that span the real variation: every vendor template, both digital and scanned, the DPI range you actually receive, the rotations you see. Quantity matters less than representative coverage.

Cause 2 — trained too clean. You trained on pristine digital PDFs because they were easy to gather, but production is phone-photographed receipts at 150 DPI — the model never learned noise it never saw, so confidence collapses on real inputs. Confirm: compare training input quality (DPI, scanned vs digital, skew) to production. Fix: make the training set look like production.

Cause 3 — Template model meeting layout variation

Custom models build in two modes. A template model is fast and cheap, excellent on a fixed layout but layout-rigid — a new vendor’s arrangement returns blank fields. A neural model tolerates layout variation by learning the meaning of fields, at the cost of longer training. Calling a template model “drifted” when you fed it a new layout is a category error. Fix: rebuild as neural for varying layouts; or one template model per layout plus a classifier that routes. Choose by how much your layouts vary:

Aspect Template (custom) Neural (custom)
Layout flexibility Rigid — best on one fixed layout Flexible — tolerates layout variation
Min training docs As few as ~5 A few labelled docs (more helps)
Training time Fast (minutes) Longer
Best for Consistent, structured forms Varying layouts of the same doc type
Failure mode Blank on a new layout Degrades gracefully; still needs coverage
Cost/effort to maintain One model per layout One model spanning layouts

Cause 4 — a new production variant. A field that worked for months suddenly returns null because a supplier changed their template or a new vendor was onboarded — drift is often event-driven, not gradual. Confirm: per-field confidence drops sharply for that variant while old ones still work; bucket production confidence by vendor to find it. Fix: add the variant to training and retrain; monitor per-field confidence so you catch the next one the day it arrives, not in a quarterly audit.

Cause 5 — inconsistent/sparse labelling. If you labelled a field in some training docs but missed it in others, or inconsistently (sometimes including the currency symbol, sometimes not), the model learns a muddled signal and the field is flaky from day one — this looks like drift but was a training defect. Confirm: review the labels in Studio — is every occurrence labelled, consistently? Fix: re-label every occurrence with the same boundaries and retrain. Consistent labels matter as much as sample count.

Architecture at a glance

The system that turns a scanned PDF into validated fields is a left-to-right pipeline, and every misread lives at a specific stage on it. Documents land in Blob Storage (the input zone) and are submitted to the Document Intelligence resource over its *.cognitiveservices.azure.com endpoint, authenticated by a managed identity rather than a static key. Inside the service, analysis runs as a chain: the read/OCR stage turns pixels into positioned words (this is where DPI and skew bite — badge 1), the layout stage assembles tables and selection marks (where merged cells and borderless tables break — badge 2), and the field-extraction stage — a prebuilt model or your custom model — maps content to typed fields (where confidence drops and custom models drift — badge 3). The result is an async operation: you POST, receive an Operation-Location, and poll until succeeded.

The right-hand zone is the part teams forget exists and the part that actually protects production: your post-processing. Here you read each field’s confidence against a threshold; anything above it auto-flows to the downstream store, while anything below it — or anything that fails a business-rule validation (total = subtotal + tax) — is routed to a human-in-the-loop review queue (badge 4). Telemetry from every stage flows to Azure Monitor, where per-field confidence over time is the leading indicator that a custom model is drifting (badge 5) long before a human notices bad data. Follow the arrows left to right and you can place any symptom in this article onto exactly one hop — and that placement is the diagnosis.

Left-to-right Azure Document Intelligence pipeline showing Blob Storage input feeding the Document Intelligence resource via managed identity, the internal read/OCR then layout then field-extraction stages with a custom model, then post-processing that applies a confidence threshold and routes low-confidence results to a human review queue, with Azure Monitor observing per-field confidence drift, annotated with five numbered failure badges

Real-world scenario

Lumio Retail Finance (the same fictional outfit from our App Service playbook, now automating accounts payable) processed roughly 8,000 supplier invoices a month: invoices arrived by email, landed in a Blob container, and a Function called prebuilt-invoice (pinned at an old v2.1 default from the original build) to push vendor, date, total and line items straight into their ERP. For a quarter it was a quiet success — ~92% auto-posted, the rest kicked to a clerk.

Then reconciliation flagged a cluster of wrong totals, all from one large supplier. First instinct: “the model regressed” — and a support ticket. The real investigation took twenty minutes once they opened the raw analyzeResult instead of consuming InvoiceTotal.content blindly. Three findings stacked up. That supplier had switched from digital PDFs to scanned copies at ~150 DPI; per-word confidence sat around 0.5–0.6 and the Indian digit grouping (₹4,18,500) was misread with a moved decimal — low-DPI OCR (Symptom 1, Cause 1). The supplier’s line-item table was borderless, and the old API under-segmented it, merging the rate and amount columns (Symptom 3, Cause 2). And most damning: the pipeline had no confidence threshold at all — every value, including the 0.41-confidence total, posted to the ERP as fact.

The fix was three changes, none of them “a better model.” A confidence threshold of 0.85 on monetary fields with a human-in-the-loop queue below it (the highest-leverage change — it would have caught every bad total on day one); an upgrade to api-version=2024-11-30, whose prebuilt-layout segmented the borderless table correctly; and working with the supplier to send digital PDFs again, or scan at 300 DPI. Within a week the wrong-total cluster was gone, auto-post actually rose to 94% (the new API extracted more fields cleanly), and the review queue held exactly the documents that needed a human — the low-DPI stragglers — not a random sample of everything. The lesson: the model was never the bug. The bug was trusting a 200 OK without reading the confidence, and pinning an API version that pre-dated the table improvements they needed.

Advantages and disadvantages

Document Intelligence is the right tool for a large class of problems and the wrong tool for a few — knowing which keeps you out of the failure modes above.

Advantages Disadvantages
Managed OCR + structure + typed fields in one call Best-effort: returns confident-looking values that can be wrong
Strong prebuilt models (invoice, receipt, ID) — zero training Prebuilt schemas are fixed; missing fields can’t be added
Custom models trainable on as few as ~5 docs Few/clean samples overfit → production drift
Per-element confidence you can threshold on Confidence is not a correctness guarantee (confident misreads)
Studio gives a visual overlay for fast debugging Tables are inferred structure — fragile on borderless/multi-page
Async API scales to large batches Async polling adds latency and orchestration complexity
Identity-based auth + private networking available Quota/throttling on lower tiers (F0 especially)
Improves with each API version (better tables/fields) Pinning an old version silently misses those gains

Where each matters: the prebuilt models are unbeatable when your documents are standard invoices/receipts/IDs — don’t train a custom model for those. Custom models earn their keep on your proprietary forms, but only if you respect the training discipline (representative, well-labelled samples). The confidence-as-risk-dial advantage is only realised if you use it — a pipeline with no threshold throws away the service’s best safety feature. And the “improves each version” point cuts both ways: it’s an advantage if you track versions and a silent liability if you pin once and forget.

Hands-on lab

A free-tier-friendly walk-through: provision a Document Intelligence resource, analyse a document with a prebuilt model, read the confidence and bounding regions from the raw result, and implement a threshold check. You need an Azure subscription and the Azure CLI.

1. Create a resource (free F0 tier for the lab). F0 allows a low request rate and is perfect for experimenting.

RG="rg-di-lab"; LOC="eastus"; NAME="di-lab-$RANDOM"
az group create -n $RG -l $LOC
az cognitiveservices account create \
  --name $NAME --resource-group $RG --location $LOC \
  --kind FormRecognizer --sku F0 \
  --custom-domain $NAME --yes

2. Get the endpoint and a key. (In production you’d use a managed identity; for the lab a key is fine.)

ENDPOINT=$(az cognitiveservices account show -n $NAME -g $RG --query properties.endpoint -o tsv)
KEY=$(az cognitiveservices account keys list -n $NAME -g $RG --query key1 -o tsv)
echo "$ENDPOINT"

3. Analyse a sample invoice with prebuilt-invoice. Use any invoice URL (or a Microsoft sample). The POST returns 202 with an Operation-Location header.

OP=$(curl -s -D - -o /dev/null -X POST \
  "$ENDPOINT/documentintelligence/documentModels/prebuilt-invoice:analyze?api-version=2024-11-30" \
  -H "Ocp-Apim-Subscription-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"urlSource":"https://raw.githubusercontent.com/Azure-Samples/cognitive-services-REST-api-samples/master/curl/form-recognizer/sample-invoice.pdf"}' \
  | tr -d '\r' | awk '/[Oo]peration-[Ll]ocation:/ {print $2}')
echo "$OP"

4. Poll until succeeded, then read totals with confidence.

# Repeat until status is "succeeded"
curl -s "$OP" -H "Ocp-Apim-Subscription-Key: $KEY" | jq '.status'

# Then read the field with its confidence and region
curl -s "$OP" -H "Ocp-Apim-Subscription-Key: $KEY" \
  | jq '.analyzeResult.documents[0].fields.InvoiceTotal | {content, confidence, boundingRegions}'

Expected output: a JSON object with a content like "$1,098.23", a confidence near 0.9x, and a boundingRegions polygon. That is the shape you must always inspect — value, confidence, and location together.

5. Implement the threshold check (the actual point of the lab). Pull every field and split into auto-accept vs needs-review at 0.85:

curl -s "$OP" -H "Ocp-Apim-Subscription-Key: $KEY" \
  | jq '.analyzeResult.documents[0].fields
        | to_entries
        | map({field:.key, content:.value.content, confidence:.value.confidence,
               decision: (if (.value.confidence // 0) >= 0.85 then "auto" else "review" end)})'

This is the pattern your production code implements: fields at or above the threshold flow downstream; the rest go to a human queue.

6. (Optional) Inspect tables with prebuilt-layout. Re-run the POST against prebuilt-layout:analyze, poll, then dump tables[0].cells to see rowIndex/columnIndex/kind — the structure you debug table breaks with.

7. Teardown. Delete the resource group so nothing lingers.

az group delete -n $RG --yes --no-wait

For repeatability, the resource as Bicep (use a managed identity in real deployments):

resource di 'Microsoft.CognitiveServices/accounts@2024-10-01' = {
  name: 'di-shop-prod'
  location: location
  kind: 'FormRecognizer'
  sku: { name: 'S0' }            // S0 standard for production; F0 free for the lab
  identity: { type: 'SystemAssigned' }   // identity over keys
  properties: {
    customSubDomainName: 'di-shop-prod'
    publicNetworkAccess: 'Disabled'      // pair with a private endpoint
  }
}

Common mistakes & troubleshooting

This is the centerpiece — the playbook you keep open while a misread is live. Each row is a real failure: symptom → root cause → how to confirm (exact command / Studio path) → fix. Scan to your symptom, then read the per-symptom detail below for the ones that bite hardest.

# Symptom Root cause Confirm (exact command / portal path) Fix
1 Field value wrong; nobody noticed for weeks No confidence threshold — every value auto-accepted Read documents[0].fields.<F>.confidence in the raw result; it was low all along Add a threshold + human-in-the-loop for anything below it
2 Whole-page values fuzzy/wrong (5↔S, 0↔O, moved decimal) Low-DPI / noisy scan `jq '.analyzeResult.pages[0].words[] {content,confidence}'` shows a low floor
3 Text slanted, accuracy poor Skewed / rotated page jq '.analyzeResult.pages[].angle' is large; Studio overlay tilts De-skew/auto-rotate before upload
4 Handwritten field scores low, printed fields fine Handwriting held to print threshold `jq '.analyzeResult.styles[] select(.isHandwritten)'`
5 Whole document low confidence / nonsense Unsupported or wrong language Run prebuilt-read; compare detected language to model’s supported list Use a supported model/locale; prebuilt-read for widest OCR
6 High confidence but value is some other field Field bound to the wrong region Studio: click field, box sits over wrong area; check boundingRegions.polygon Custom model with correct labels; post-validate by rule
7 Subtotal returned where total expected Two candidates, model picked the salient one Both values in content; field box points at the wrong one Business rule (total ≥ subtotal); custom-label the right field
8 Every field cleanly wrong Wrong model called for the doc type jq '.analyzeResult.modelId' ≠ intended model Route to correct model; add a custom classifier for mixed batches
9 content right, typed value wrong (date/number) Locale / format misparse Compare content vs valueDate/valueCurrency Apply known locale; re-derive from content when they disagree
10 Amounts land in the wrong column Merged/spanning cells shift indices `jq '.analyzeResult.tables[0].cells[] {columnIndex,columnSpan,kind}'`
11 Columns bleed together; wrong column count Borderless table under-segmented (old API) tables[0].columnCount too low vs real table Upgrade to api-version=2024-11-30; or custom model
12 Header row treated as data (or a data row dropped) Header detection failed; no columnHeader cells `jq '.analyzeResult.tables[0].cells[] select(.kind==“columnHeader”)'` returns nothing
13 A long table comes back as two tables Multi-page table split per page Two tables entries, matching columnCount, adjacent pageNumber Stitch by column structure across pages
14 tables array empty Used prebuilt-read (OCR only, no tables) jq '.analyzeResult.modelId' is prebuilt-read Switch to prebuilt-layout
15 Custom model great in test, poor in prod Too few / too-clean training samples (overfit) Compare training doc count+variety to production spread Add representative samples (vendors, DPI, scanned)
16 New vendor’s invoice → all fields blank Template model on an unseen layout Model build mode = template; layout differs from training Rebuild as neural; or per-template models + classifier
17 A field that always worked is now null New production document variant Per-field confidence collapses on that variant only Add the variant to training; retrain; monitor confidence
18 A field flaky since the model launched Inconsistent/sparse labelling at training Review labels in Studio — not every occurrence labelled Re-label every occurrence consistently; retrain
19 404 / ModelNotFound on analyze Wrong model id or wrong region/resource az cognitiveservices account list; check model id spelling Use correct model id + the resource that owns the custom model
20 429 TooManyRequests under load Tier rate limit exceeded (F0 especially) Response has Retry-After; check sku.name Honour Retry-After; upgrade F0→S0; add backoff/queue
21 400 InvalidRequest / InvalidContent Bad/oversize file, unsupported format, bad SAS Error body code/message; check size/format/SAS Supported format within limits; valid read-SAS or MI
22 401/403 from the endpoint Wrong key, or MI lacks the data role az cognitiveservices account keys list; role assignment Use correct key; grant Cognitive Services User to the MI

The error/status codes you’ll see, separated from the (more common) silent misreads:

Code Meaning Likely cause How to confirm Fix
200 + low confidence Succeeded, but uncertain Poor input or hard field Read the confidence per field Threshold + human-in-the-loop
202 then running forever Operation not yet done (normal) or stuck Large doc; transient Poll Operation-Location; check status Keep polling with backoff; investigate if failed
status: failed The analysis itself failed Corrupt/unreadable file Read error.code/error.message in result Fix/replace the input document
400 InvalidContent Input rejected Unsupported format, oversize, bad SAS Error message Supported format, within size/page limits, valid SAS/MI
401 Unauthorized Auth failed Wrong/expired key or bad token Re-fetch key / token Correct credential; rotate if leaked
403 Forbidden Authn ok, not authorised MI missing data-plane role; network blocked Role assignment; firewall/PE Grant Cognitive Services User; fix networking
404 ModelNotFound Model id/region wrong Typo, or custom model in another resource az cognitiveservices account list Correct model id and resource
429 TooManyRequests Rate limited Over the tier’s TPS (F0 tiny) Retry-After header; sku.name Backoff; upgrade tier; queue requests

Three points from that table deserve emphasis because teams get them wrong most often. Row 1 is the one that matters most: a wrong value ships for weeks only because the pipeline consumed fields.<F>.content with no confidence threshold — pull the historical result and the confidence was below any sane cut-off the whole time. A threshold with a human-in-the-loop queue (start ~0.80–0.85 for money/identity fields, tune from the review queue’s hit rate) is the single highest-leverage change in this article. For broken tables (rows 10–13), the universal fix is to address cells by kind and header text, never by raw index, and to upgrade to api-version=2024-11-30 for far better borderless handling. For drift (rows 15–18), the operational fix is to monitor per-field confidence by vendor so you catch the next new variant the day it arrives — drift is event-driven, and a quarterly reconciliation is the most expensive way to find it.

Best practices

The signals worth wiring before the next bad-data incident — leading indicators, not a quarterly reconciliation:

Monitor / alert on Signal Threshold (starting point) Why it’s leading
Per-field confidence (by vendor) Mean field confidence Drop > 0.1 vs baseline First sign of a new variant / drift
% routed to human review Below-threshold rate Sudden rise Input quality or model degraded
Throttling 429 rate / Retry-After Any sustained 429 Tier too small / burst over limit
Failed analyses status: failed count > 0 sustained Corrupt inputs entering the pipeline
Auto-post correction rate Downstream edits to auto-accepted fields Rising Threshold set too low (shipping misreads)

Security notes

The security knobs that also reduce these incidents — secure and reliable pull the same direction:

Control Setting / mechanism Secures against Also prevents
Managed identity + data role identity + Cognitive Services User Leaked keys Key-rotation breaking the pipeline
Private endpoint publicNetworkAccess: Disabled + PE Public exposure / interception Documents traversing the internet
Read-only, short SAS Scoped SAS on urlSource Document-store exfiltration Stale/over-broad access lingering
Region choice Resource location Data-residency violations Cross-border compliance gaps
No-content logging Scrub fields from logs/telemetry PII in logs Sensitive data in observability tools

Cost & sizing

The bill is driven by pages analysed, not API calls, and which model you use:

A rough monthly picture for Lumio’s ~8,000 invoices/month (multi-page, so more analysed pages): an S0 resource billed per page for prebuilt-invoice, plus negligible Blob storage, plus the labour of reviewing the ~6–8% below threshold. The dominant cost was never the API — it was the clerk-hours, which the right threshold minimised by sending only the genuinely-ambiguous documents to review. The cost drivers and what each one buys:

Cost driver What you pay for Rough scale What it controls Watch-out
Pages analysed (field model) Per-page extraction Largest line item Throughput Don’t run field models where OCR suffices
Pages analysed (prebuilt-read) Per-page OCR Cheaper per page OCR-only volume Use it when you don’t need fields
F0 free tier $0, tiny limits Dev only Experiments 429s under any real load
Custom training/storage Train + store model Modest Custom-model upkeep Wasteful if retrained on bad samples
Human review labour Clerk time per reviewed doc Often the biggest total cost Threshold setting Threshold too high floods the queue
Blob + Monitor Source storage + telemetry Small Retention Lifecycle old docs; sample telemetry

Interview & exam questions

1. Why is a Document Intelligence misread usually not an exception, and what does that imply for your code? The service is best-effort and returns 200 OK with an analyzeResult even when it read wrongly — a misread is a successful call with a bad value inside. The implication is that checking only for HTTP errors is blind to every misread; your code must read each field’s confidence and, where it matters, verify the boundingRegions sit over the right pixels.

2. What is a confidence threshold and why is a human-in-the-loop fallback essential? A confidence threshold is your cut-off for auto-accepting a field versus routing it to a person. Above it you auto-process; below it a human reviews. It’s essential because confidence is a risk dial, not a guarantee — without the fallback, every low-confidence value (and any misread) ships unreviewed straight into downstream systems.

3. Difference between prebuilt-read and prebuilt-layout? prebuilt-read is OCR only — text, lines, words, language — with no tables and no typed fields. prebuilt-layout adds tables, selection marks and document structure. If you call read and wonder where the tables went, that’s why; switch to layout for any structure.

4. A field comes back with high confidence but the wrong value. What’s happening and how do you confirm? The model read clean text from the wrong location — it bound the field to the wrong region (e.g. subtotal instead of total, or a custom model anchored on something that moved). Confirm with the field’s boundingRegions.polygon (or click it in Studio) and see the box is over the wrong area. Fix with a correctly-labelled custom model and/or a business-rule validation.

5. Why might table extraction put values in the wrong column, and how do you make consuming code robust? Merged/spanning cells (columnSpan/rowSpan > 1) shift the grid so fixed-index code reads the wrong column, and header detection can fail so row 0 holds data. Make code robust by reading each cell’s kind (columnHeader vs content) and span fields, and mapping values to their header text rather than a hardcoded column index.

6. What is custom-model drift and what most commonly causes it? Drift is a custom model returning blank or wrong fields in production despite a high training score, because production documents differ from the training set. The most common causes are too few or too-clean training samples (overfitting), a template model meeting a new layout, and a new document variant (a vendor changing their template). The fix is representative training data and, for varying layouts, a neural model.

7. Template vs neural custom models — when do you pick each? A template model is fast/cheap, trains on as few as ~5 docs, and is excellent on a single fixed layout but rigid (blank on a new layout). A neural model tolerates layout variation by learning field meaning, at the cost of longer training. Pick template for consistent fixed forms, neural for varying layouts of the same document type.

8. A custom model scored 0.98 on its test set but performs badly in production. What likely went wrong? It overfit to a small, non-representative training set — a handful of clean, near-identical samples. The 0.98 reflects memorising that exact layout/quality, not generalisation. Fix by training on production-representative documents (multiple templates, scanned and digital, real DPI range) and monitoring per-field confidence.

9. How do you handle a table that spans two pages? A multi-page table often returns as two separate tables entries, one per page. Detect it by matching columnCount and adjacent pageNumbers in the cells’ bounding regions, then stitch the rows together in code, validating that the column headers match.

10. You suddenly see 429 TooManyRequests. What is it and how do you respond? You exceeded the tier’s request rate — the F0 free tier is especially small. The response includes a Retry-After header. Respond by honouring Retry-After, adding exponential backoff and a submission queue, and moving from F0 to S0 for real throughput.

11. The model returns null for a field you expected. Is the API broken? Usually no — either the field isn’t in that model’s schema (e.g. asking a receipt model for an invoice-only field), or your locale’s documents don’t print that field. Confirm against the model’s documented field list; if it’s not there or not on the document, null is correct behaviour, not a bug.

12. How do you authenticate to Document Intelligence securely, and what role is needed? Use the application’s managed identity (system- or user-assigned) rather than a static key, disable local auth where possible, and grant the identity the Cognitive Services User data-plane role. Pair with a private endpoint so the resource isn’t internet-exposed. This keeps keys out of code and the endpoint off the public internet.

These map to AI-102 (Azure AI Engineer Associate)plan and manage an Azure AI solution; implement knowledge mining and document intelligence (Document Intelligence/Form Recognizer, prebuilt and custom models, confidence handling) — and touch AZ-204 where it’s wired into an app, and AI-900 for the foundational “what is Document Intelligence” concepts. A compact cert mapping:

Question theme Primary cert Objective area
Prebuilt vs custom; read/layout/fields AI-102 Implement document intelligence
Confidence, thresholds, human-in-the-loop AI-102 Build/optimise document solutions
Template vs neural; drift; training AI-102 Train & manage custom models
Securing the resource (identity, PE) AI-102 / AZ-500 Secure an AI solution
Document AI fundamentals AI-900 Describe features of Azure AI

Quick check

  1. A field returns 200 OK with content that’s clearly wrong. Why didn’t the API throw, and what’s the one property you check first to catch this in code?
  2. Your prebuilt-layout table puts the amount in the wrong column. Name the two cell properties you must read to make your consuming code robust (instead of using a fixed column index).
  3. True or false: a custom model that scores 0.98 on its test set is safe to ship to production.
  4. A field that extracted correctly for months suddenly returns null for one supplier’s invoices. What’s the most likely cause and the fix?
  5. You’re getting 429 TooManyRequests. What header tells you when to retry, and what’s the production fix beyond backoff?

Answers

  1. The service is best-effort and returns a successful response with a confident-looking value even when it read wrongly — a misread is a 200, not an error. Check the field’s confidence first (and verify boundingRegions for important fields); a threshold on confidence is what catches it.
  2. Read the cell’s kind (columnHeader vs content) and its columnSpan/rowSpan. Map values to their header text found via kind, not by a hardcoded column index, so merged/spanning cells don’t shift your mapping.
  3. False. A high test score on a small, non-representative set usually means overfitting — the model memorised that layout/quality. It can still fail on real-world variety. Train on production-representative data and monitor per-field confidence.
  4. A new document variant — that supplier changed their invoice template, which the custom model never saw, so its fields don’t map and confidence collapses. Fix by adding the new variant to the training set and retraining, and monitor per-field/per-vendor confidence to catch the next one immediately.
  5. The Retry-After header tells you how long to wait. Beyond honouring it with exponential backoff, the production fix is to move off F0 to S0 (and queue submissions), because the free tier’s request rate is the constraint.

Glossary

Next steps

You can now localise any Document Intelligence misread to a pipeline stage and fix it. Build outward:

AzureDocument IntelligenceForm RecognizerAI ServicesOCRCustom ModelsTroubleshootingConfidence Scores
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading