Multimodal Document Processing in Production: Extract Tables, Forms, and Images Reliably
Stop babysitting broken OCR. Build a production document pipeline that extracts tables, forms, and figures with validation, retries, and human review.
Your document pipeline does not fail because OCR is bad. It fails because you trusted one model, one prompt, and one happy-path PDF.
Multimodal document processing production work is a different beast from a demo. Demos extract one clean invoice. Production eats sideways scans, merged table headers, handwritten checkboxes, embedded charts, missing pages, duplicate uploads, and PDFs that were clearly assembled during a caffeine emergency.
This tutorial shows how to build a production-grade pipeline for extracting tables, forms, and images reliably. The trick is not “use a bigger model.” The trick is routing, validation, confidence scoring, retries, and human review before garbage reaches your database.
What You Are Building
You are building a document processing pipeline with five stages:
- Ingest and normalize documents
- Classify page types
- Extract text, tables, forms, and visual elements
- Validate outputs against strict schemas and business rules
- Route low-confidence results to review instead of pretending they are fine
The pipeline works with vendor APIs like Google Document AI, Amazon Textract, Azure Document Intelligence, or multimodal LLMs. The architecture matters more than the logo on the invoice.
Prerequisites
You do not need to be a machine learning engineer. You do need basic comfort with APIs, JSON, and queues.
You will need:
- A document storage bucket, such as S3, Google Cloud Storage, Azure Blob Storage, or equivalent
- A backend service in Python, Node, Go, or similar
- A database for extraction results
- A queue for async processing
- Access to at least one document AI provider
- A small labeled test set of real documents
For local examples, this tutorial uses Python-style pseudocode. Translate it to your stack. The logic is the important bit.
Install the basics:
pip install pydantic pillow pypdf python-magic opencv-python
Expected result: you have enough tooling to inspect PDFs, validate JSON, split pages, and do basic image checks before sending anything to a model.
Step 1: Define the Output Before Touching AI
Most teams start by asking, “Can AI read this PDF?”
Wrong question.
Start with: “What exact fields are allowed to enter our system?”
For an invoice, your schema might look like this:
from pydantic import BaseModel, Field
from typing import List, Optional
from decimal import Decimal
class LineItem(BaseModel):
description: str
quantity: Decimal
unit_price: Decimal
total: Decimal
class ExtractedInvoice(BaseModel):
document_type: str = Field(pattern="^invoice$")
vendor_name: str
invoice_number: str
invoice_date: str
due_date: Optional[str]
currency: str = Field(min_length=3, max_length=3)
subtotal: Decimal
tax: Decimal
total: Decimal
line_items: List[LineItem]
confidence: float = Field(ge=0, le=1)
This schema gives your AI system a spine. It also gives you a way to reject bad output without drama.
Expected result: every document type has a strict target schema before extraction begins. No random blobs. No “misc” field where failures go to hide.
Step 2: Normalize Every Document
Production documents arrive as PDFs, TIFFs, PNGs, JPEGs, phone photos, email attachments, and occasionally files named scan_final_FINAL_really.pdf.
Normalize them before extraction.
Your normalization stage should:
- Detect file type
- Split multi-page PDFs
- Convert pages into images when needed
- Correct rotation
- Check resolution
- Remove blank pages
- Assign stable page IDs
Example:
def normalize_document(file_path: str) -> dict:
file_info = detect_file(file_path)
pages = split_into_pages(file_path)
normalized_pages = []
for page in pages:
image = render_page_to_image(page, dpi=300)
image = correct_rotation(image)
quality = inspect_image_quality(image)
normalized_pages.append({
"page_id": page.id,
"image_path": save_page_image(image),
"width": image.width,
"height": image.height,
"quality": quality,
"is_blank": quality["blank_score"] > 0.98
})
return {
"source_file": file_path,
"file_type": file_info["mime_type"],
"page_count": len(normalized_pages),
"pages": [p for p in normalized_pages if not p["is_blank"]]
}
Expected result: every downstream extractor receives predictable page images or clean page references. This alone kills a surprising amount of “AI failure.”
Step 3: Classify Pages Before Extracting Fields
Do not run the same extractor across every page. A contract, invoice, bank statement, handwritten form, and chart-heavy report need different strategies.
Use a cheap classifier first:
def classify_page(page_image_path: str) -> dict:
result = model_classify_page(page_image_path)
return {
"page_type": result["page_type"],
"confidence": result["confidence"],
"signals": result["signals"]
}
Useful page types:
invoicereceiptbank_statementtax_formmedical_formtable_pagesignature_pagechart_pageunknown
Routing example:
def choose_extractor(page_type: str):
routes = {
"invoice": "invoice_extractor",
"medical_form": "form_extractor",
"table_page": "table_extractor",
"chart_page": "vision_summary_extractor",
"unknown": "general_layout_extractor"
}
return routes.get(page_type, "general_layout_extractor")
Expected result: the pipeline stops treating every page like the same problem. Accuracy improves because the extractor is chosen by document shape, not blind hope.
Step 4: Extract Forms With Key-Value Logic
Forms are not just text. They are relationships.
You need labels, values, checkboxes, radio buttons, signatures, and sometimes handwriting. Tools like Google Document AI Form Parser, Amazon Textract Forms, and Azure Document Intelligence can return key-value pairs and selection marks. Use that structure. Do not flatten the document into plain text unless you enjoy pain.
Example normalized form output:
{
"fields": [
{
"key": "Patient Name",
"value": "Maya Chen",
"confidence": 0.97,
"page": 1,
"bbox": [120, 244, 510, 281]
},
{
"key": "Smoker",
"value": "No",
"confidence": 0.93,
"page": 1,
"bbox": [120, 720, 180, 760]
}
],
"selection_marks": [
{
"label": "No",
"state": "selected",
"confidence": 0.95
}
]
}
Then map messy field labels into your canonical schema:
FIELD_ALIASES = {
"patient name": "full_name",
"name": "full_name",
"dob": "date_of_birth",
"date of birth": "date_of_birth",
"smoker": "smoking_status"
}
def canonicalize_field(label: str) -> str | None:
clean = label.lower().strip().replace(":", "")
return FIELD_ALIASES.get(clean)
Expected result: “DOB,” “Date of Birth,” and “Birth Date” can land in the same database column instead of creating three fake fields.
Step 5: Extract Tables as Tables, Not Text Soup
Tables are where naive OCR goes to embarrass itself.
A table extractor should preserve:
- Row index
- Column index
- Header rows
- Merged cells
- Currency symbols
- Empty cells
- Page number
- Bounding boxes
- Confidence per cell
Example output shape:
{
"tables": [
{
"page": 2,
"row_count": 4,
"column_count": 5,
"headers": ["Date", "Description", "Quantity", "Unit Price", "Total"],
"rows": [
["2026-07-01", "API usage", "1200", "$0.01", "$12.00"],
["2026-07-02", "Storage", "1", "$5.00", "$5.00"]
],
"confidence": 0.91
}
]
}
Now validate the math:
from decimal import Decimal
def money(value: str) -> Decimal:
return Decimal(value.replace("$", "").replace(",", "").strip())
def validate_invoice_table(rows: list[dict]) -> list[str]:
errors = []
for idx, row in enumerate(rows):
expected = money(row["quantity"]) * money(row["unit_price"])
actual = money(row["total"])
if abs(expected - actual) > Decimal("0.01"):
errors.append(f"row_{idx}_total_mismatch")
return errors
Expected result: your system catches obvious table extraction errors before they become accounting errors. AI reads; software verifies.
Step 6: Extract Images, Figures, and Charts Separately
Multimodal processing matters because many documents hide important facts in images: damage photos, medical diagrams, charts, stamps, maps, signatures, and scanned IDs.
Do not ask a text extractor to handle visual content. Detect visual regions and route them separately.
Visual extraction should capture:
- Image or figure type
- Caption text
- Surrounding headings
- Visual summary
- Any extracted values
- Whether the visual needs human review
Example:
{
"figures": [
{
"page": 7,
"figure_type": "bar_chart",
"caption": "Quarterly revenue by region",
"summary": "North America is the largest region; APAC shows the fastest growth.",
"extracted_values": [
{"label": "North America Q4", "value": "42.1M"},
{"label": "APAC Q4", "value": "18.4M"}
],
"confidence": 0.84,
"review_required": true
}
]
}
Charts deserve extra skepticism. A multimodal model can describe a chart convincingly while misreading one axis tick. If the number matters, validate it manually or use a specialized chart extraction flow.
Expected result: visual content becomes searchable and reviewable instead of disappearing into a PDF thumbnail.
Step 7: Add Confidence Scoring That Actually Means Something
A single model confidence score is not enough. Build your own document-level score.
Use multiple signals:
- OCR confidence
- Field-level confidence
- Schema validity
- Math validation
- Required field completeness
- Document quality
- Model agreement across retries or providers
- Historical accuracy for that document type
Example:
def compute_confidence(extraction: dict, validation: dict, quality: dict) -> float:
score = 1.0
if validation["missing_required_fields"]:
score -= 0.25
if validation["math_errors"]:
score -= 0.20
if quality["blur_score"] > 0.7:
score -= 0.15
if extraction["average_field_confidence"] < 0.85:
score -= 0.20
if validation["schema_valid"] is False:
score -= 0.30
return max(0.0, min(1.0, score))
Route by threshold:
def route_result(confidence: float) -> str:
if confidence >= 0.92:
return "auto_approve"
if confidence >= 0.75:
return "human_review"
return "retry_or_reject"
Expected result: clean documents flow through automatically. Ugly documents get trapped before they infect downstream systems.
Step 8: Retry Intelligently
Retrying the same request three times is not resilience. It is billing cosplay.
Better retry strategies:
- Re-render page at a higher DPI
- Crop the table region and extract again
- Rotate the page manually
- Use a different extractor for low-confidence fields
- Ask a multimodal model to reconcile two conflicting outputs
- Send only suspicious fields to human review
Example retry flow:
def extract_with_retries(page):
first = run_primary_extractor(page)
if first["confidence"] >= 0.92:
return first
enhanced_page = enhance_image(page)
second = run_primary_extractor(enhanced_page)
if second["confidence"] > first["confidence"] + 0.05:
return second
if has_tables(page):
table_result = run_table_specialist(page)
return merge_results(second, table_result)
return mark_for_review(second)
Expected result: retries change the input or strategy. You spend extra compute only where it has a shot at improving the result.
Step 9: Store Evidence, Not Just Answers
Never store extracted values without proof.
For every extracted field, store:
- Value
- Confidence
- Page number
- Bounding box
- Source extractor
- Timestamp
- Model or processor version
- Raw response ID
- Validation status
Example database record:
{
"document_id": "doc_93ab",
"field": "invoice_total",
"value": "1482.43",
"confidence": 0.96,
"page": 1,
"bbox": [410, 812, 520, 840],
"extractor": "layout_v4_primary",
"model_version": "2026-08-01",
"validation_status": "passed"
}
This matters when someone asks, “Why did we pay this invoice?”
Your answer should not be “the AI said so.” Your answer should show the page, box, value, confidence, and validation trail.
Expected result: your system becomes auditable. That is the difference between a useful automation and a liability generator.
Step 10: Build the Human Review Queue
Human review is not failure. It is how production systems stay honest.
A good review screen shows:
- The original page image
- Highlighted bounding boxes
- Extracted fields
- Confidence warnings
- Validation errors
- Side-by-side table preview
- One-click approve, edit, reject
Review payload:
{
"document_id": "doc_93ab",
"reason": "math_validation_failed",
"priority": "high",
"fields_to_review": ["subtotal", "tax", "total"],
"suggested_values": {
"subtotal": "1280.00",
"tax": "202.43",
"total": "1482.43"
}
}
Expected result: reviewers fix the risky 10% instead of retyping the boring 90%.
Common Pitfalls
Pitfall 1: Treating PDFs Like Plain Text
Digital PDFs can contain embedded text, scanned images, hidden layers, and weird reading order. Extracting raw text is not enough.
Fix: preserve layout, page numbers, bounding boxes, and table structure.
Pitfall 2: Trusting Pretty JSON
A model can return perfect JSON with wrong values. Schema validity only proves the shape is right.
Fix: validate business logic. Totals should add up. Dates should be plausible. Required IDs should match known formats.
Pitfall 3: Ignoring Low-Quality Inputs
Blurry uploads, shadows, skew, and low resolution destroy extraction quality.
Fix: score image quality during ingestion. Ask for a better upload when the file is hopeless.
Pitfall 4: Using One Prompt for Everything
A purchase order, insurance form, bank statement, and product spec sheet do not deserve the same prompt.
Fix: classify first, then route to document-specific extractors.
Pitfall 5: No Versioning
If your extraction model changes and results shift, you need to know what happened.
Fix: store extractor version, prompt version, schema version, and provider response metadata with every result.
Production Checklist
Before shipping, your pipeline should pass this checklist:
- Every document type has a strict schema
- Every page is normalized before extraction
- Multi-page PDFs are handled intentionally
- Tables preserve rows, columns, headers, and merged cells
- Forms preserve key-value relationships and selection marks
- Images and figures are routed to visual extraction
- Required fields are validated
- Money fields pass arithmetic checks
- Low-confidence outputs go to review
- Extracted values link back to page evidence
- Prompts, models, processors, and schemas are versioned
- Test documents include ugly real-world samples, not just pristine demos
If you skip the checklist, you are not building document automation. You are building an expensive guessing machine.
Expected End State
A production-ready multimodal document processing system should behave like this:
- Clean invoices process automatically
- Weird invoices get reviewed
- Tables survive as structured rows and columns
- Form checkboxes do not vanish
- Charts and figures get separate visual treatment
- Every field has evidence
- Bad inputs are rejected early
- Downstream systems receive validated data, not AI vibes
That is the bar.
Final Takeaway
Reliable document extraction is not one model call. It is a pipeline.
Use OCR and layout models for structure. Use multimodal models for visual reasoning. Use schemas and validation for discipline. Use human review where confidence breaks down.
Start with 50 real documents from your messiest workflow. Build the pipeline around those. If it survives the ugly files, it will crush the clean ones.
Sources
> Want more like this?
Get the best AI insights delivered weekly.
> Related Articles
AI Agent Approval Workflows: Put Humans at the Right Control Points
Human approval can make an agent safer—or merely slower. Design checkpoints around irreversible actions, changing risk, and evidence people can actually review.
LLM Trace Redaction in Production: Debug Without Logging Private Data
LLM traces are debugging gold and privacy dynamite. Capture structure, decisions, and timing while removing secrets and personal data before storage.
Secret Management for AI Agents: Stop Leaking Credentials Into Prompts
An agent needs tools, not a backpack full of API keys. Keep secrets outside model context, issue short-lived capability tokens, and audit every use.
Tags
> Stay in the loop
Weekly AI tools & insights.