feat: optional OCR fallback for text-less PDFs (scanned / CAD exports)#307
Open
D4NGYY wants to merge 2 commits into
Open
feat: optional OCR fallback for text-less PDFs (scanned / CAD exports)#307D4NGYY wants to merge 2 commits into
D4NGYY wants to merge 2 commits into
Conversation
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).
|
🙏🙏🙏 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
pypdfcannot parse at all surface only a generic "Error processing file".Cause
In
app/utils/document_loader.py,SafePyPDFLoaderrelies onpypdftextextraction, 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):
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 withpypdfium2and OCRs them withrapidocr-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.SafePyPDFLoadernow performs a page-aware fallback: it extracts normally, thenOCRs only the pages whose text layer is below
RAG_PDF_OCR_MIN_CHARS, keepingnative 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.RAG_PDF_OCR_MIN_CONFIDENCE)are dropped before chunking, so noisy recognitions don't pollute the vector store.
(
pdf_ocr_metrics:pages_total,pages_ocr_applied,chars_extracted,ocr_confidence_avg) so ingestion behavior is observable in production.document_routes.pymaps a non-recoverable document to a clear 422 ("no extractabletext, even after OCR"), and maps encrypted/password-protected PDFs to a clear 422 as
well — instead of a generic failure or silent success.
RAG_PDF_OCR_ENABLED(defaultFalse),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.5to filter noise).Dependencies
pypdfium2(permissive license, ships its own binary— no system packages).
rapidocr-onnxruntime+opencv-python-headless.libgl1/libglib2.0-0foropencv, and
pip install -r requirements.txtpicks uppypdfium2.Backward compatibility
OCR is opt-in (
RAG_PDF_OCR_ENABLED=Falseby default). With it off, the PDF path isbyte-for-byte unchanged. Verified: the existing
SafePyPDFLoadertests still pass.Tests
tests/utils/test_ocr.py— unit tests with an injected fake OCR (fast) + oneintegration/slowtest running the real engine on a committed fixture. Coversconfidence 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 onrecovers text; OCR on but still empty raises; text-present PDFs skip OCR; mixed PDF
OCRs only the deficient page; configured
min_confidenceis threaded toocr_pdf.How to try
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)
schematics), behind a separate flag — a model that reads layout/relationships, not
just glyphs, is the real answer where classic OCR is weak.
recovered best-effort and extracted values should be verified against the source.