AntflyAntfly

Index PDFs and Scanned Documents

A table that extracts text from PDFs, reads scanned pages and images with OCR, and serves the result to full-text search

The Result#

You write a document whose url points to a PDF or an image, and Antfly extracts each page. Pages with a usable text layer keep their native text; scanned pages, photos, and images are read by an OCR model. The text is chunked into full-text search, and each hit names the document and the page that matched:

curl -X POST http://127.0.0.1:8080/db/v1/tables/scans/query \
  -H 'Content-Type: application/json' \
  -d '{"full_text_search": {"query": "grid"}, "fields": ["_id"], "limit": 3}'
{
  "responses": [{
    "hits": {
      "total": {"value": 1, "relation": "exact"},
      "hits": [{
        "_id": "scan-1",
        "_score": 2.17,
        "hierarchy": {
          "level": "source",
          "parent_doc_key": "scan-1",
          "matched_artifact": {"name": "document_chunks_v1", "kind": "chunk", "chunk_id": 0, "unit_id": "page:000001"}
        }
      }]
    }
  }]
}

Before You Start#

Antfly running in standalone mode:

curl -fsS http://127.0.0.1:8080/db/v1/status

Build It#

1. Install an OCR Model#

LightOnOCR-3 transcribes page images, including scans and photos, into Markdown with tables and formulas. Pull the 0.8B checkpoint:

antfly inference pull lightonai/LightOnOCR-3-0.8B:safetensors@36d8636925327c671bd8e479c517a004f7566bd9

The 4B checkpoint, lightonai/LightOnOCR-3-4B, is more accurate on dense or degraded pages and needs about 9 GB instead of 1.7 GB.

2. Create the Table#

curl -fsS -X POST http://127.0.0.1:8080/db/v1/tables/scans \
  -H 'Content-Type: application/json' -d '{"num_shards": 1}'

Add a document_extraction enrichment that reads the url field. The ocr block names the reader:

curl -fsS -X PUT http://127.0.0.1:8080/db/v1/tables/scans/artifacts/document_units_v1/enrichment \
  -H 'Content-Type: application/json' \
  -d '{
    "kind": "asset",
    "field": "url",
    "content_type": "application/json",
    "producer_json": "{\"type\":\"document_extraction\",\"config\":{\"ocr\":{\"reader\":{\"provider\":\"antfly\",\"model\":\"lightonai/LightOnOCR-3-0.8B\"}}}}"
  }'

Then chunk the extracted text into the table's full-text index:

curl -fsS -X PUT http://127.0.0.1:8080/db/v1/tables/scans/artifacts/document_chunks_v1/enrichment \
  -H 'Content-Type: application/json' \
  -d '{
    "kind": "chunk",
    "source_artifact_name": "document_units_v1",
    "field": "text",
    "chunk_size": 256,
    "full_text_index": true
  }'

3. Write a Scanned Document#

url can be a remote URL or an inline data URI. This writes a local PDF inline:

curl -fsS -X POST http://127.0.0.1:8080/db/v1/tables/scans/batch \
  -H 'Content-Type: application/json' \
  -d "{\"inserts\": {\"scan-1\": {\"url\": \"data:application/pdf;base64,$(base64 < scan.pdf | tr -d '\n')\"}}, \"sync_level\": \"write\"}"

PNG and JPEG images work the same way with a data:image/png;base64,... or data:image/jpeg;base64,... URL.

4. Check the Extracted Pages#

Each page becomes a unit under its source document. List them:

curl -fsS -X POST http://127.0.0.1:8080/db/v1/tables/scans/query \
  -H 'Content-Type: application/json' \
  -d '{
    "fields": ["unit_type", "extraction_status", "text", "provenance.page_number"],
    "hierarchy": {"children": {"parent": {"level": "source", "id": "scan-1"}, "level": "unit"}},
    "order_by": [{"field": "_hierarchy.position"}]
  }'

Each unit's text holds the page's native text or the reader's transcription, and extraction_status reports where it is: pending_ocr while the reader runs, completed when the text is ready, failed_ocr if the reader failed, and ocr_not_configured if the page needed OCR but the enrichment has no ocr block. LightOnOCR returns Markdown, so a scanned table comes back as a table and a figure as a short description.

5. Search the Text#

Query the table as shown in The Result. A hit is the source document, and hierarchy.matched_artifact.unit_id names the page whose chunk matched; read that page's text with the query in step 4. Keep fields narrow, because the source document includes its url, which for an inline upload is the whole file. OCR output is chunked and indexed like any other text, so it also works with hybrid search once you add an embeddings index over document_chunks_v1.

OCR Options#

The ocr block takes:

FieldMeaning
readerA reader provider config. Every provider except apple names a model.
generatorInstead of reader, a vision-capable generator, which receives a transcription instruction.
modeauto (default) reads images and the PDF pages whose native text fails the quality checks. always reads every page.
promptOverrides the prompt. Readers run their own OCR task when it is omitted.
renderHow PDF pages are rasterized: dpi (default 150), resolution (requested_dpi or model_input), max_pixels, max_dimension.
qualityThresholds that decide when native PDF text is too poor to keep, such as min_content_chars (default 50).

Without an ocr block, document_extraction extracts native text only: born-digital PDFs, Office documents, HTML, email, and plain text, with no model. Pages and images that would need OCR keep whatever native text they have and report extraction_status: "ocr_not_configured", so a scan never silently indexes as empty.

Other Readers#

Any reader or vision-capable generator can take LightOnOCR's place:

  • Apple Vision on macOS, with no model to install: {"reader": {"provider": "apple"}}. See Apple.
  • Smaller local readers: antflydb/Florence-2-base or monkt/paddleocr-onnx for a small CPU footprint on clean printed text.
  • A vision-capable generator, local or hosted, such as Gemma 4 or a model from OpenAI or Google: {"generator": {"provider": "...", "model": "..."}}.

Tradeoffs#

The reader decides accuracy, cost, and speed, and mode decides how often you pay for it. LightOnOCR-3 0.8B is the default choice: it keeps table and reading-order structure, but it generates text token by token, so a page costs far more than native extraction. Handwriting, photos, and degraded scans go through the same OCR path; when they read poorly, switch to the 4B checkpoint, Apple Vision, or a hosted vision model rather than changing anything else in the pipeline. mode: "auto" sends only the pages that need it to the reader, which keeps born-digital PDFs cheap; use always when a document's text layer is present but untrustworthy, for example a scanner's own OCR layer. Raise render.dpi for small print, at the cost of memory and time per page. A hosted generator moves the cost to per-request pricing and sends page images to that provider.

If the reader fails, for example because the model is not installed, the page reports extraction_status: "failed_ocr". Fix the cause and reprocess the artifact to retry.

Use Agent Skills#

Everything above is also encoded in the Antfly skill, so a coding agent can execute this guide for you:

npx skills add antflydb/antfly-skills

Then prompt it with the outcome, for example "Index the scanned PDFs in my contracts bucket with OCR and make them searchable", and use this page to judge the result.

Next Steps#