Self-Deployed Container
v3.6.0 (latest)

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.Z

Access 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:

FieldTypeRequiredDescription
documentfile uploadYesThe 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).
optionsform field (string)NoA 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:

CodeBodyMeaning
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.

categorycodeMeaningWhat to do
bad_documentextract_invalid_pdfThe file could not be parsed as a valid PDF.Verify the upload is a real, uncorrupted PDF.
bad_documentextract_encrypted_pdfThe PDF is password-protected.Supply a decrypted copy.
bad_documentocr_bad_documentThe document could not be processed even with OCR; it may be corrupted, empty, or unsupported.Confirm the document opens and has renderable pages.
client_errorrequires_ocrThe 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/health

Common Usage Patterns

Some common ways the container is deployed. This is neither an exhaustive list nor a set of recommendations.

  1. 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.
  2. 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.
  3. 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.
  4. 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_healthy

OCR 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:

VariableDefaultDescription
ENABLE_DOCUMENT_OCRtrueRun 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_WORKERS6Number 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=4 with 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.

SettingDefaultDescription
GUNICORN_TIMEOUT1800sMax time for a single request before it is terminated
GUNICORN_GRACEFUL_TIMEOUT1800sMax 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.