Skip to content

feat: optional OCR fallback for text-less PDFs (scanned / CAD exports)#307

Open
D4NGYY wants to merge 2 commits into
danny-avila:mainfrom
D4NGYY:feat/pdf-ocr-fallback-pr
Open

feat: optional OCR fallback for text-less PDFs (scanned / CAD exports)#307
D4NGYY wants to merge 2 commits into
danny-avila:mainfrom
D4NGYY:feat/pdf-ocr-fallback-pr

Conversation

@D4NGYY

@D4NGYY D4NGYY commented Jun 17, 2026

Copy link
Copy Markdown

Problem

Scanned and CAD-export PDFs have no usable text layer. Today they ingest as zero
chunks
and silently report success, so File Search returns nothing for them. PDFs
that pypdf cannot parse at all surface only a generic "Error processing file".

Cause

In app/utils/document_loader.py, SafePyPDFLoader relies on pypdf text
extraction, which returns empty strings for image-only pages without raising. The
empty result is split into 0 chunks and treated as a successful ingest.

Fix

An opt-in OCR fallback (off by default — no behavior change unless enabled):

  • New app/utils/ocr.py:
    • has_text_layer(pages, min_chars) — detects an empty/garbage text layer.
    • ocr_pdf(filepath, *, pages=None, min_confidence=..., ...) — rasterizes pages with
      pypdfium2 and OCRs them with rapidocr-onnxruntime (already a dependency),
      returning one Document per page. Accepts an explicit page subset (for page-aware
      fallback) and drops OCR blocks below a confidence threshold.
    • NoExtractableTextError — raised when nothing is recoverable even after OCR.
  • SafePyPDFLoader now performs a page-aware fallback: it extracts normally, then
    OCRs only the pages whose text layer is below RAG_PDF_OCR_MIN_CHARS, keeping
    native text pages untouched. This handles mixed PDFs (some pages have a text layer,
    some are image-only) instead of treating the document as all-or-nothing. If the whole
    document still has no usable text, it raises NoExtractableTextError.
  • Confidence filtering: low-confidence OCR blocks (below RAG_PDF_OCR_MIN_CONFIDENCE)
    are dropped before chunking, so noisy recognitions don't pollute the vector store.
  • Structured metrics: each OCR pass emits a single JSON log line
    (pdf_ocr_metrics: pages_total, pages_ocr_applied, chars_extracted,
    ocr_confidence_avg) so ingestion behavior is observable in production.
  • document_routes.py maps a non-recoverable document to a clear 422 ("no extractable
    text, even after OCR"), and maps encrypted/password-protected PDFs to a clear 422 as
    well — instead of a generic failure or silent success.
  • Config (env): RAG_PDF_OCR_ENABLED (default False), RAG_PDF_OCR_MIN_CHARS
    (16), RAG_PDF_OCR_DPI (200), RAG_PDF_OCR_MAX_PAGES (50),
    RAG_PDF_OCR_MIN_CONFIDENCE (0.0, i.e. keep everything; raise to ~0.5 to filter noise).

Dependencies

  • Adds one pip dependency: pypdfium2 (permissive license, ships its own binary
    — no system packages).
  • Reuses the existing rapidocr-onnxruntime + opencv-python-headless.
  • No Dockerfile change: the image already installs libgl1 / libglib2.0-0 for
    opencv, and pip install -r requirements.txt picks up pypdfium2.

Backward compatibility

OCR is opt-in (RAG_PDF_OCR_ENABLED=False by default). With it off, the PDF path is
byte-for-byte unchanged. Verified: the existing SafePyPDFLoader tests still pass.

Tests

  • tests/utils/test_ocr.py — unit tests with an injected fake OCR (fast) + one
    integration/slow test running the real engine on a committed fixture. Covers
    confidence filtering, mean-confidence reporting, page-subset selection, and the
    tuple-returning OCR callable.
  • tests/utils/test_document_loader.py — wiring tests: OCR off skips OCR; OCR on
    recovers text; OCR on but still empty raises; text-present PDFs skip OCR; mixed PDF
    OCRs only the deficient page
    ; configured min_confidence is threaded to ocr_pdf.

How to try

# in .env
RAG_PDF_OCR_ENABLED=true
# upload a scanned/CAD PDF via File Search → it is now chunked & searchable

Scope / honesty

OCR is reliable on scanned text and table-like layouts (e.g. terminal plans). Dense
graphical schematics with very small labels remain hard — this PR makes the broad
"no text layer" case work and fail loudly when it can't, rather than chasing perfect
recognition of dense line art.

Future work (out of scope here)

  • Vision-language extraction for graphically complex pages (wiring diagrams,
    schematics), behind a separate flag — a model that reads layout/relationships, not
    just glyphs, is the real answer where classic OCR is weak.
  • OCR result caching (by file hash) to avoid re-OCRing the same document on re-upload.
  • Surfacing the OCR limitation in the LibreChat UI so users know a scanned upload was
    recovered best-effort and extracted values should be verified against the source.

D4NGYY added 2 commits June 17, 2026 17:34
Scanned and CAD-export PDFs have no text layer, so pypdf extract_text()
returns "" without raising and the file silently ingests as zero chunks --
appearing successful but not searchable.

Add an opt-in OCR fallback (RAG_PDF_OCR_ENABLED, default off):
- app/utils/ocr.py: has_text_layer(), ocr_pdf() rasterizing pages with
  pypdfium2 and OCR'ing with the already-present rapidocr-onnxruntime,
  plus NoExtractableTextError.
- SafePyPDFLoader: when enabled and the text layer is below
  RAG_PDF_OCR_MIN_CHARS, re-derive pages via OCR; raise if still empty.
  The OCR-off path is unchanged.
- Config: RAG_PDF_OCR_ENABLED/MIN_CHARS/DPI/MAX_PAGES with startup validation.
- Routes: map no-extractable-text and encrypted PDFs to a clear HTTP 422
  instead of a generic failure or a silent success.
- Adds pypdfium2; no Dockerfile/system-package change (libgl already present).
- .gitattributes pins *.pdf binary and keeps README's CRLF verbatim.

Tests: unit (injected OCR) + loader wiring + encrypted-heuristic + a slow
integration test running the real engine on a committed fixture.
Build on the opt-in OCR fallback with three quality improvements, all still
gated behind RAG_PDF_OCR_ENABLED (off by default; OCR-off path unchanged):

- Page-aware fallback: SafePyPDFLoader now OCRs only the pages whose text
  layer is below RAG_PDF_OCR_MIN_CHARS and keeps native text pages as-is, so
  mixed PDFs (some text pages, some scanned) are handled instead of being
  treated as all-or-nothing. ocr_pdf() gains a `pages` subset argument.
- Confidence filtering: drop OCR blocks below RAG_PDF_OCR_MIN_CONFIDENCE
  (default 0.0 = keep everything) so low-quality recognitions never reach the
  vector store. rapidocr confidence scores were previously discarded.
- Structured metrics: each OCR pass emits a single JSON log line
  (pdf_ocr_metrics: pages_total, pages_ocr_applied, chars_extracted,
  ocr_confidence_avg) so ingestion is observable in production.

Config: add RAG_PDF_OCR_MIN_CONFIDENCE with [0,1] startup validation; README
documents it and the page-aware behavior.

Tests: confidence filtering + mean-confidence reporting, page-subset
selection, tuple-returning OCR callable, mixed-PDF page-aware OCR, and
min_confidence threading. Full unit suite green (pre-existing Windows-only
symlink failures unrelated).
@SebasSotoA

Copy link
Copy Markdown

🙏🙏🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants