Getting Started with Document Extraction
This guide shows how to deploy and use the document extraction container for processing PDF documents. The container runs an HTTP server that accepts PDF bytes and returns structured extraction results. The output uses the same JSON structure as the hosted Extract API, documented on the Output Format page. The JSON is designed to be consumed directly. Optionally, the open-source Kenverters toolkit can convert it to markdown, plain text, or pandas DataFrames if one of those formats is more convenient.
The container supports digital (text-based) PDFs, scanned PDFs, and mixed documents (pages with both native text and scanned images). OCR runs by default on pages that lack extractable text, and can be turned off.
Prerequisites
- Docker or any OCI-compatible container runtime
- No GPU required (CPU-only ML inference)
Many of the examples in this guide use Kubernetes and deploy the extraction container as a sidecar to your main application. Neither is required. The container is a standalone HTTP server that runs anywhere you can run an OCI image, and your application can call it over any network path that can reach it.
Container Image
208007848330.dkr.ecr.us-east-1.amazonaws.com/kensho-containerized-extract:X.Y.ZAccess to the image is provisioned by Kensho. If you do not have access yet, email extract@kensho.com.
Endpoints
GET /health
Returns {"status": "ready"} once the server is up. Use it as a readiness and liveness check. While the ML models are still loading, a request to /health blocks rather than returning an error, and completes as soon as loading finishes. The endpoint also responds while an extraction is in progress.
POST /extract
Accepts a PDF document and returns structured extraction results.
Request format: multipart/form-data with two fields:
| Field | Type | Required | Description |
|---|---|---|---|
document | file upload | Yes | The PDF file to extract. Supports digital (text-based) PDFs, scanned PDFs, and mixed documents. OCR runs by default on pages that lack extractable text (see OCR for Scanned Documents). Maximum size: 500 MB (configurable). |
options | form field (string) | No | A JSON string containing extraction parameters. Defaults to {} (all defaults applied). |
Options JSON fields (all optional):
output_format
string Default: structured_document_with_locations
Controls the structure and detail level of the extraction output. See Output Format for the full JSON structure.
structured_document- Core document structure (text blocks, tables, figures) without positional information. Smallest output.structured_document_with_locations- (Default) Includes bounding box coordinates for each element. Use when you need to know where content appears on the page.structured_document_with_char_offsets- Includes character-level offset information in addition to locations. Use when you need to map extracted text back to exact positions in the source PDF.
figure_extraction
bool Default: true
When enabled, figures in the document are detected, classified by type (bar chart, pie chart, scatter plot, line plot), and parsed into structured data. Disabling this skips figure detection entirely, which reduces processing time for documents where figures are not needed.
enhanced_table_extraction
bool Default: true
When enabled, identifies rows, columns, and cell boundaries for more accurate table extraction, especially on complex tables. Disabling it is faster but less accurate.
include_images
bool Default: false
When enabled, includes the locations of the images.
include_relations
bool Default: false
When enabled, includes relationships between document layout segments.
extract_table_row_header_hierarchy
bool Default: false
When enabled, includes the hierarchical relationships among table row headers.
merge_multi_page_tables
bool Default: false
Detects tables that continue across consecutive pages and merges them into a single table annotation. Recommended for multi-page documents where tables commonly span page breaks: enabling it yields one coherent table instead of several fragments.
Response codes:
| Code | Body | Meaning |
|---|---|---|
| 200 | {"result": {...}, "processing_time_seconds": N} | Extraction succeeded. |
| 400 | {"error": "<message>"} | Invalid request, rejected before extraction. Returned when: no document field in the form, the uploaded file is empty, the options field is not valid JSON, an unrecognized option or form field is provided, an option has the wrong type (e.g. string instead of boolean), or the output_format value is not one of the allowed values. |
| 413 | {"error": "Request payload exceeds N MB limit"} | The uploaded file exceeds the maximum allowed size (default 500 MB, configurable via MAX_CONTENT_LENGTH_MB env var). |
| 422 | {"error": {"category": "...", "code": "...", "message": "..."}} | The request was well-formed but the document could not be processed (e.g. not a valid PDF, password-protected, or requires OCR on an OCR-disabled instance). category and code are stable, machine-readable strings you can branch on; message is a human-readable sentence for codes that warrant one and null otherwise. See 422 error codes below. |
| 429 | {"error": "Server is busy processing another request"} | The server is already processing another extraction. The server handles one extraction at a time. Retry after a delay or scale horizontally. |
| 500 | {"error": "<message> [error_id=<uuid>]"} | An internal error occurred during extraction. The body is a single generic message that never contains internal detail, followed by an opaque error_id. The server logs the failure at ERROR tagged with the same error_id, so the details can be given to Kensho support if needed. |
422 error codes
A 422 body is a JSON object, {"error": {"category": "<category>", "code": "<code>", "message": <string|null>}}. The code is the stable identifier to branch on; category groups related codes; message is a human-readable sentence for the codes that warrant extra guidance (currently requires_ocr) and null for the rest.
| category | code | Meaning | What to do |
|---|---|---|---|
bad_document | extract_invalid_pdf | The file could not be parsed as a valid PDF. | Verify the upload is a real, uncorrupted PDF. |
bad_document | extract_encrypted_pdf | The PDF is password-protected. | Supply a decrypted copy. |
bad_document | ocr_bad_document | The document could not be processed even with OCR; it may be corrupted, empty, or unsupported. | Confirm the document opens and has renderable pages. |
client_error | requires_ocr | The document has pages with no extractable text (e.g. scanned or image-based) and this instance runs with OCR disabled. Carries a message spelling this out. | Enable OCR (ENABLE_DOCUMENT_OCR=true; see OCR for Scanned Documents), or send a text-based PDF. |
New codes may be added over time, so treat an unrecognized code as a generic bad_document or client_error failure rather than erroring on it.
Quick Start (curl)
# Basic extraction
curl -F "document=@report.pdf" http://localhost:8000/extract
# With options
curl -F "document=@report.pdf" \
-F 'options={"output_format":"structured_document","figure_extraction":false}' \
http://localhost:8000/extract
# Health check
curl http://localhost:8000/healthCommon Usage Patterns
Some common ways the container is deployed. This is neither an exhaustive list nor a set of recommendations.
- Sidecar, called synchronously. The container runs alongside your application and is called over localhost while handling a request or job. The least moving parts when your application already controls when documents arrive.
- Sidecar behind an async queue. Your application consumes jobs from a broker such as RabbitMQ, Kafka, or SQS, posts each document to the local container, and publishes the result. Gives you retries and backpressure.
- Shared internal service. One or more instances called over the network by several of your services. Needs timeouts raised at every hop, since extractions can run for many minutes.
- Autoscaled pool. Replicas scaled on queue depth or request volume, for example with KEDA on Kubernetes. Each instance handles one extraction at a time, so replica count is the main throughput lever.
Deployment Requirements
These apply to every pattern above, not just the examples that follow.
The container needs a writable temp directory
The server writes temporary files while loading models and needs a writable temp directory (default /tmp). If you run with a read-only root filesystem, mount a writable volume at /tmp or point TMPDIR at one. On Kubernetes that means an emptyDir (ideally medium: Memory) when readOnlyRootFilesystem: true is set. Without a writable temp directory the server cannot finish loading models, and /health never becomes ready.
Extractions outlive most default timeouts
Extractions can run for many minutes, so every intermediary between your application and the container needs timeouts long enough to match. Load balancers, ingress controllers, service meshes, and API gateways commonly default to 30-120 seconds and will kill the connection mid-extraction. Co-locating the container with your application sidesteps this entirely; otherwise raise the timeouts at each hop.
Deployment Example: Kubernetes Sidecar
One option is to run the extraction server as a sidecar in the same K8s pod as your application. Both containers share a network namespace, so your service calls localhost:8000/extract and no traffic leaves the pod.
The resource values and tuning variables below are illustrative. Size them for your own document mix, throughput targets, and platform.
apiVersion: apps/v1
kind: Deployment
metadata:
name: extract
namespace: extract
spec:
replicas: 3
selector:
matchLabels:
app: extract
template:
metadata:
labels:
app: extract
spec:
# Must be >= GUNICORN_GRACEFUL_TIMEOUT so in-flight extractions
# finish before K8s sends SIGKILL.
terminationGracePeriodSeconds: 1800
containers:
- name: your-app
image: your-org/your-service:latest
# Your service calls http://localhost:8000/extract
- name: extract
image: 208007848330.dkr.ecr.us-east-1.amazonaws.com/kensho-containerized-extract:X.Y.Z
imagePullPolicy: Always
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 10
# A liveness probe on /health is safe: the endpoint stays
# responsive while an extraction is in progress. Keep
# failureThreshold generous so only a truly stuck instance is
# restarted.
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 5
resources:
requests:
memory: "4Gi"
cpu: "2"
limits:
memory: "8Gi"
cpu: "4"
env:
# MAX_DOCUMENT_OCR_WORKERS caps how many pages are OCR'd at once;
# OMP_NUM_THREADS sizes the analysis stage that runs after OCR. Both are
# set here for the 4-core limit above. Leaving OMP_NUM_THREADS unset lets
# the inference library size itself from the host's core count, which
# ignores this container's CPU limit.
- name: OMP_NUM_THREADS
value: "2"
- name: MAX_DOCUMENT_OCR_WORKERS
value: "2"
- name: GUNICORN_TIMEOUT
value: "1800"
- name: GUNICORN_GRACEFUL_TIMEOUT
value: "1800"Waiting for the Server at Startup
The extraction server takes 10-20 seconds to load ML models before it can serve requests. If your application calls /extract before loading completes, the response will hang until models finish loading.
The first request can block while models load
Poll /health on startup before sending extraction requests. A call to /extract during model loading does not fail, it blocks, so an unprepared first request looks like a 20-second stall or an unresponsive container.
import time
import requests
def wait_for_extract_server(timeout: int = 120) -> None:
"""Block until the extraction server is ready."""
for _ in range(timeout):
try:
resp = requests.get("http://localhost:8000/health", timeout=2)
if resp.status_code == 200:
return
except (requests.ConnectionError, requests.Timeout):
pass
time.sleep(1)
raise RuntimeError("Extraction server not ready")Call this once at your application's startup before sending your first extraction request.
Deployment Example: Docker Compose
For local development and testing, use Docker Compose with network_mode so both containers share a network namespace:
services:
extract-server:
image: 208007848330.dkr.ecr.us-east-1.amazonaws.com/kensho-containerized-extract:X.Y.Z
platform: linux/amd64
ports:
- "8000:8000"
environment:
GUNICORN_TIMEOUT: "1800"
GUNICORN_GRACEFUL_TIMEOUT: "1800"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 10s
timeout: 5s
retries: 30
start_period: 120s
your-app:
image: your-org/your-service:latest
network_mode: "service:extract-server"
depends_on:
extract-server:
condition: service_healthyOCR for Scanned Documents
The container runs local OCR by default. The server detects pages with no extractable text (scanned or image-based pages, or pages whose fonts don't map to readable characters) and runs OCR on them. Pages that already have native text are unaffected; OCR is only applied where needed.
To turn OCR off, set ENABLE_DOCUMENT_OCR=false. With OCR disabled, a document that needs OCR (because some of its pages have no extractable text) cannot be processed, and the request fails with a 422 response explaining that OCR is disabled. Digital PDFs with native text are unaffected.
env:
# Optional: turn OCR off (default: true)
- name: ENABLE_DOCUMENT_OCR
value: "false"
# Optional: number of parallel workers for OCR page processing (default: 6)
- name: MAX_DOCUMENT_OCR_WORKERS
value: "8"Configuration:
| Variable | Default | Description |
|---|---|---|
ENABLE_DOCUMENT_OCR | true | Run local OCR on pages that lack native text. Set to false to disable; requests that require OCR then fail with a 422. |
MAX_DOCUMENT_OCR_WORKERS | 6 | Number of parallel workers for OCR page processing. Each worker processes one page concurrently. Increasing this speeds up OCR by processing more pages in parallel; set it to roughly the number of available CPU cores, since each worker is CPU-bound. Decrease to reduce memory pressure. |
Limitations:
- OCR quality depends on scan resolution and document complexity. Dense tables or low-resolution scans may produce incomplete text.
- Processing time increases proportionally with the number of scanned pages.
Resource Tuning
The container is CPU-bound. CPU allocation, OMP_NUM_THREADS, and MAX_DOCUMENT_OCR_WORKERS all compete for the same cores and should be tuned together. Each OCR worker runs as a separate process, so MAX_DOCUMENT_OCR_WORKERS sets how many pages are OCR'd at once, while OMP_NUM_THREADS governs the layout, table, and figure analysis that runs after OCR completes. Because those stages are sequential, raising one does not speed up the other. Allocating more concurrent work than you have cores available can reduce throughput rather than increase it.
Two illustrative cases, assuming an 8-core allocation:
- Mostly digital PDFs with dense tables and figures. Few pages reach OCR, so most time goes to analysis. Favoring threads (for example
OMP_NUM_THREADS=4with 2 OCR workers) puts cores where the work is. - Mostly scanned documents. Every page goes through OCR, and page-level parallelism scales well across processes. Favoring workers (for example 4 OCR workers with
OMP_NUM_THREADS=2) shortens the dominant phase.
These are illustrations rather than recommendations. Measure a representative sample of your own documents, comparing the processing_time_seconds field in each successful response, and tune from there.
Set OMP_NUM_THREADS explicitly
Left unset, the inference library sizes its thread count from the host's core count, ignoring any CPU limit applied to the container. On a large node that means heavy oversubscription with no error and no obvious symptom beyond slower extractions.
Timeout Configuration
The server processes one extraction at a time. Extractions can take seconds (small documents) to tens of minutes (large documents with many tables and figures). Configure timeouts to match your expected workload.
| Setting | Default | Description |
|---|---|---|
GUNICORN_TIMEOUT | 1800s | Max time for a single request before it is terminated |
GUNICORN_GRACEFUL_TIMEOUT | 1800s | Max time to finish in-flight requests after SIGTERM |
| Orchestrator shutdown grace period | - | However your runtime controls the time between SIGTERM and SIGKILL (e.g., terminationGracePeriodSeconds in Kubernetes), set it >= GUNICORN_GRACEFUL_TIMEOUT so in-flight extractions can finish. |
| HTTP client timeout | - | Your responsibility; should match expected extraction time |
All four should be tuned based on your document sizes and extraction times. Start with the defaults and adjust based on observed performance.
Too short a shutdown grace period discards in-flight extractions
If your runtime kills the container before GUNICORN_GRACEFUL_TIMEOUT elapses, any extraction still running is lost. On Kubernetes that setting is terminationGracePeriodSeconds, whose 30-second default sits far below the container's 1800-second graceful timeout, so the default alone discards in-flight work. This happens on every deploy and scale-down, and nothing in the logs identifies it as lost work.
Monitoring
The container logs to stderr. Use standard log aggregation:
# Example: Kubernetes
kubectl logs -n extract deployment/extract -f
# Example: Docker
docker logs -f <container-id>The server logs every internal failure (500) at ERROR, tagged with the same error_id that appears in the 500 response body. Grep your logs for this error_id to find the details. Document-level failures (422) are logged at INFO, since they are expected outcomes rather than faults.
Calling from Your Service
import requests
response = requests.post(
"http://localhost:8000/extract",
files={"document": ("document.pdf", pdf_bytes, "application/pdf")},
data={"options": '{"figure_extraction": true, "enhanced_table_extraction": true}'},
timeout=1800,
)
if response.status_code == 200:
result = response.json()["result"]
elif response.status_code == 429:
# Server busy, retry after a delay
...
elif response.status_code == 422:
# The document could not be processed. Branch on the machine-readable code.
err = response.json()["error"]
code = err["code"] # e.g. "extract_invalid_pdf", "requires_ocr"
...
else:
# 400 (bad request) or 500 (internal error). Here "error" is a string. On a 500 it ends with
# an "[error_id=<uuid>]" token worth capturing and quoting to Kensho support to correlate the
# failure with the server-side logs.
error = response.json()["error"]
...Concurrency
The server handles one extraction at a time
A request arriving while another extraction is in progress is rejected with HTTP 429, not queued. Clients that assume ordinary HTTP concurrency will drop documents under load unless they retry on 429 or keep to one in-flight request per instance.
To increase concurrency, scale horizontally.