Building an AI Document Classification and OCR Pipeline
End-to-end architecture for automated document processing combining OCR, layout analysis, and transformer-based classification for enterprise document workflows

Enterprise document processing remains one of the largest untapped automation opportunities. Organizations handle millions of documents annually - invoices, contracts, forms, correspondence, and reports - most still processed manually. A modern AI document pipeline combines OCR, layout understanding, and transformer classification to automate document routing, extraction, and processing.
This article covers the architecture for a production document pipeline processing 100K+ documents daily with 95%+ classification accuracy.
Pipeline Architecture
A production document pipeline processes documents through four stages:
| Stage | Purpose | Technology | Latency |
|---|---|---|---|
| Ingestion | Accept documents from multiple sources | Queue + storage | < 100ms |
| Preprocessing | Image cleanup, deskew, quality check | OpenCV + PIL | < 500ms |
| OCR + Layout | Text extraction with spatial awareness | Tesseract/PaddleOCR/DocTR | < 2s |
| Classification | Document type determination | LayoutLM/DiT | < 200ms |
| Extraction | Field extraction based on document type | Donut/LayoutLMv3 | < 1s |
OCR Engine Comparison
I benchmarked four OCR engines on a corporate document dataset (10K pages across 15 document types):
| Engine | Character Accuracy | Word Accuracy | Latency/page | Cost |
|---|---|---|---|---|
| Tesseract 5.0 | 94.2% | 89.8% | 1.2s | Free |
| PaddleOCR v4 | 96.8% | 93.4% | 0.8s | Free |
| DocTR | 97.1% | 94.2% | 0.9s | Free |
| Google Cloud Vision | 98.4% | 96.8% | 0.6s | $1.50/1K pages |
| Azure Document Intelligence | 98.6% | 97.1% | 0.7s | $1.50/1K pages |
For cost-sensitive deployments, PaddleOCR and DocTR deliver excellent quality at zero marginal cost. For maximum accuracy, cloud APIs justify their cost on high-value documents.
Document Preprocessing
Clean input dramatically improves OCR accuracy:
import cv2
import numpy as np
from PIL import Image
from dataclasses import dataclass
@dataclass
class PreprocessingResult:
image: np.ndarray
quality_score: float
applied_corrections: list
is_processable: bool
class DocumentPreprocessor:
def __init__(self, target_dpi: int = 300):
self.target_dpi = target_dpi
def process(self, image: np.ndarray) -> PreprocessingResult:
"""Full preprocessing pipeline."""
corrections = []
# Step 1: Convert to grayscale if color
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image.copy()
# Step 2: Deskew
angle = self._detect_skew(gray)
if abs(angle) > 0.5:
gray = self._rotate(gray, angle)
corrections.append(f"deskew: {angle:.1f}°")
# Step 3: Noise removal
denoised = cv2.fastNlMeansDenoising(gray, h=10)
corrections.append("denoise")
# Step 4: Binarization (adaptive threshold)
binary = cv2.adaptiveThreshold(
denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2
)
corrections.append("binarize")
# Step 5: Border removal
clean = self._remove_borders(binary)
# Quality assessment
quality = self._assess_quality(clean)
return PreprocessingResult(
image=clean,
quality_score=quality,
applied_corrections=corrections,
is_processable=quality > 0.4,
)
def _detect_skew(self, image: np.ndarray) -> float:
"""Detect document skew angle."""
edges = cv2.Canny(image, 50, 150, apertureSize=3)
lines = cv2.HoughLinesP(edges, 1, np.pi/180, 100, minLineLength=100)
if lines is None:
return 0.0
angles = []
for line in lines:
x1, y1, x2, y2 = line[0]
angle = np.degrees(np.arctan2(y2 - y1, x2 - x1))
if abs(angle) < 45: # Only consider near-horizontal lines
angles.append(angle)
return np.median(angles) if angles else 0.0
def _rotate(self, image: np.ndarray, angle: float) -> np.ndarray:
"""Rotate image to correct skew."""
h, w = image.shape[:2]
center = (w // 2, h // 2)
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
return cv2.warpAffine(image, matrix, (w, h),
flags=cv2.INTER_CUBIC,
borderValue=255)
def _assess_quality(self, image: np.ndarray) -> float:
"""Assess document image quality (0-1)."""
# Check contrast
contrast = image.std() / 128.0
# Check text density (should be between 5-60%)
text_ratio = 1.0 - (image.sum() / (image.size * 255))
density_score = 1.0 if 0.05 < text_ratio < 0.6 else 0.5
# Check for blur
laplacian_var = cv2.Laplacian(image, cv2.CV_64F).var()
sharpness = min(laplacian_var / 500.0, 1.0)
return (contrast + density_score + sharpness) / 3.0
Layout-Aware Classification
Use LayoutLM to classify documents using both text content and spatial layout:
import torch
from transformers import LayoutLMv3ForSequenceClassification, LayoutLMv3Processor
from typing import List
class DocumentClassifier:
DOCUMENT_TYPES = [
"invoice", "receipt", "contract", "letter", "form",
"report", "resume", "tax_document", "bank_statement",
"insurance_claim", "medical_record", "purchase_order",
"shipping_label", "id_document", "other"
]
def __init__(self, model_path: str):
self.processor = LayoutLMv3Processor.from_pretrained(model_path)
self.model = LayoutLMv3ForSequenceClassification.from_pretrained(
model_path, num_labels=len(self.DOCUMENT_TYPES)
)
self.model.eval()
@torch.inference_mode()
def classify(self, image: Image.Image, ocr_result: dict) -> dict:
"""Classify document type using layout + text features."""
# Prepare inputs with OCR results
encoding = self.processor(
image,
text=ocr_result["words"],
boxes=ocr_result["boxes"],
return_tensors="pt",
truncation=True,
max_length=512,
)
outputs = self.model(**encoding)
probs = torch.softmax(outputs.logits, dim=-1)[0]
top_k = torch.topk(probs, k=3)
predictions = []
for score, idx in zip(top_k.values, top_k.indices):
predictions.append({
"document_type": self.DOCUMENT_TYPES[idx.item()],
"confidence": score.item(),
})
return {
"primary": predictions[0],
"alternatives": predictions[1:],
"all_scores": {
self.DOCUMENT_TYPES[i]: probs[i].item()
for i in range(len(self.DOCUMENT_TYPES))
},
}
Field Extraction
Once classified, extract structured fields based on document type:
class DocumentExtractor:
"""Extract structured fields based on document type."""
EXTRACTION_SCHEMAS = {
"invoice": {
"fields": ["invoice_number", "date", "due_date", "vendor_name",
"total_amount", "tax_amount", "line_items"],
"model": "invoice_extractor_v2",
},
"receipt": {
"fields": ["merchant_name", "date", "total", "items", "payment_method"],
"model": "receipt_extractor_v1",
},
"contract": {
"fields": ["parties", "effective_date", "termination_date",
"contract_value", "key_clauses"],
"model": "contract_extractor_v1",
},
}
def __init__(self, model_registry):
self.models = model_registry
def extract(self, document_type: str, image: Image.Image,
ocr_result: dict) -> dict:
"""Extract structured fields from document."""
schema = self.EXTRACTION_SCHEMAS.get(document_type)
if not schema:
return {"error": f"No extraction schema for type: {document_type}"}
model = self.models.get(schema["model"])
# Run extraction model
raw_extractions = model.predict(image, ocr_result)
# Validate and normalize extracted values
validated = self._validate_fields(raw_extractions, schema["fields"])
return {
"document_type": document_type,
"fields": validated,
"confidence": self._aggregate_confidence(validated),
}
def _validate_fields(self, extractions: dict, expected_fields: list) -> dict:
"""Validate and normalize extracted field values."""
validated = {}
for field in expected_fields:
value = extractions.get(field)
if value:
validated[field] = {
"value": self._normalize_value(field, value["text"]),
"confidence": value["confidence"],
"bounding_box": value.get("box"),
}
else:
validated[field] = {"value": None, "confidence": 0.0}
return validated
Quality Control and Routing
Route documents based on confidence levels:
| Confidence Level | Action | Volume (typical) |
|---|---|---|
| > 0.95 | Auto-process (straight-through) | 55-65% |
| 0.80 - 0.95 | Auto-process + spot check | 20-25% |
| 0.60 - 0.80 | Human verification required | 10-15% |
| < 0.60 | Manual processing | 5-10% |
class DocumentRouter:
"""Route documents based on classification and extraction confidence."""
def route(self, classification: dict, extraction: dict) -> dict:
class_confidence = classification["primary"]["confidence"]
extract_confidence = extraction.get("confidence", 0.0)
# Combined confidence
overall = min(class_confidence, extract_confidence)
if overall > 0.95:
return {"action": "auto_process", "queue": "automated"}
elif overall > 0.80:
return {"action": "auto_process_verify", "queue": "spot_check"}
elif overall > 0.60:
return {"action": "human_verify", "queue": "review"}
else:
return {"action": "manual", "queue": "manual_processing"}
Production Metrics
System processing 100K documents daily:
| Metric | Value |
|---|---|
| Classification accuracy | 96.4% |
| Extraction accuracy (key fields) | 93.2% |
| Straight-through processing rate | 61% |
| Average processing time | 3.8 seconds |
| Human review queue volume | 12% of total |
| Cost per document (automated) | $0.04 |
| Cost per document (with review) | $0.85 |
| Monthly infrastructure cost | $8,400 |
Key Takeaways
- Preprocessing is the highest-ROI investment. Deskewing, denoising, and binarization improve OCR accuracy by 8-15% at minimal compute cost.
- Layout-aware models outperform text-only by 12-18%. Document structure (headers, tables, signatures) carries classification signal that text alone misses.
- Open-source OCR matches cloud APIs within 3%. PaddleOCR and DocTR deliver 93-94% word accuracy for free, versus 96-97% from paid services.
- Confidence-based routing maximizes automation. The goal is not 100% automation but maximizing straight-through processing while maintaining quality on uncertain documents.
- Train on YOUR documents. Generic models achieve 85% accuracy; fine-tuning on 2-5K labeled examples from your specific document types reaches 95%+.
Document processing automation delivers immediate, measurable ROI. The combination of modern OCR, layout-aware classification, and confidence-based routing transforms a manual process costing $2-5 per document into an automated pipeline at $0.04 per document.
Recommended reading

The State of Agentic AI in 2026: Capabilities, Limitations, and Production Readiness
Comprehensive analysis of agentic AI in 2026 covering production capabilities, current limitations, and enterprise readiness benchmarks with real deployment data.

Observability for AI Agents: Tracing Multi-Step Reasoning Chains in Production
How to implement production observability for AI agents including distributed tracing, reasoning chain analysis, and debugging multi-step failures.

Measuring and Reducing AI Workload Carbon Emissions: A Practical Engineering Guide
Building a carbon-aware scheduling system for ML training and inference workloads that reduced our AI infrastructure emissions by 42% while maintaining SLA commitments.

Comments
No comments yet. Be the first to share your thoughts.