Skip to content

fix(retrieval): parse PDFs in cloud loaders instead of decoding them as text - #1691

Open
CoronRing wants to merge 4 commits into
mainfrom
fix/1690-cloud-loader-binary
Open

CoronRing wants to merge 4 commits into
mainfrom
fix/1690-cloud-loader-binary

Conversation

@CoronRing

@CoronRing CoronRing commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Closes #1690.

S3Loader, GCSLoader and AzureBlobLoader decoded every object with the text encoding, including objects they had already tagged DocumentType.PDF. A real PDF raised UnicodeDecodeError and stopped the whole load, and any other binary file (an image, a .docx) did the same.

  • PDFs are parsed, not decoded. The page extraction in PyPDFLoader._stream_file moves into a module-level stream_pdf() in pdf_loader.py, which reads either a path or bytes. PyPDFLoader and the cloud loaders both call it, so a PDF in a bucket produces the same documents and metadata as the same file on disk, plus the provider metadata (bucket, key, ...). Matching on .pdf ignores case.
  • New pdf_breakdown_strategy option on the three cloud loaders, with the same values and default ("page") as PyPDFLoader.breakdown_strategy.
  • pypdf stays optional. Text-only loads don't need the pdf extra. If any listed key is a PDF and pypdf is missing, the load raises an ImportError with the install hint before any object is downloaded, so a store is never left half-ingested.
  • Undecodable objects: a .txt, .md, .markdown, .csv, .json or .jsonl object that is not valid text in encoding raises a ValueError naming the object and encoding=, since that means the encoding is wrong. Anything else that is not valid text (images, Office files, extension-less binaries) is skipped with a warning.
  • One shared loop. The three loaders now subclass ObjectStoreLoader in cloud/_common.py, which owns option validation, astream, and the parse-or-decode step. Each provider only lists keys, downloads bytes, and names its source and metadata.
  • Off the event loop: text decoding and the document strategy's page extraction now run in worker threads, like the other strategies.
  • Shared validation: validate_breakdown_strategy in _pdf_options.py (no pypdf import) now takes the allowed values, and PyPDFOCRLoader and GLMOCRLoader use it too.
  • Docs: the three storage pages describe PDF parsing, the PDF metadata keys and the decode behaviour, and module_packages.md notes that the cloud extras need [pdf] to parse PDFs.

Type of change

  • Bug fix
  • Feature
  • Breaking change
  • Docs
  • Refactor / chore / build / tests

Checklist

  • Lint & format pass (ruff check . && ruff format .)
  • Tests added/updated and pass locally (pytest tests)
  • Docs updated if user-facing behavior changed
  • Breaking changes include migration notes

Notes

Depends on #1687 for page ids. Every page or paragraph of one PDF still shares a source, so it shares a Document.id, and the runtime keeps only the last one. That bug is #1648, already on main for PyPDFLoader, and #1687 fixes it by putting the page into source (path#page=N). The plan is to merge #1687 first, then rebase this PR onto it so stream_pdf carries that scheme and cloud PDFs get it too.

Tests: test_binary_objects.py runs every case against all three loaders: page, paragraph and document splitting of a real PDF, empty pages skipped, metadata merged, an uppercase .PDF key, a corrupt PDF (raises), an invalid strategy rejected, PDF and text objects kept in order, a PNG skipped with a warning, a .txt in the wrong encoding raising, a custom encoding=, and pypdf unavailable (text still loads; a PDF raises before anything is yielded). The PDFs come from a small make_pdf() builder in the cloud conftest.py, so the tests run the real pypdf path. All 631 retrieval unit tests pass, and mkdocs build --strict passes.

Behaviour changes:

  • A PDF in a bucket now yields one Document per non-empty page by default, where it used to raise. Pass pdf_breakdown_strategy="document" for one Document per object.
  • A non-text object that used to raise UnicodeDecodeError is now skipped with a warning, unless it has a known text extension, which raises a ValueError instead.
  • Invalid breakdown_strategy errors from the PDF and OCR loaders now read ... must be one of 'page', 'paragraph', 'document', got ....

Left out:

  • An encrypted or corrupt PDF still raises from pypdf and stops the load, as it does in PyPDFLoader. Containing one document's failure is [09] [Bug] [Retrieval] One bad document aborts the whole ingest run #1654.
  • PDFs are recognised by extension only. An extension-less PDF is not valid text, so it is skipped with the warning.
  • Other binary formats (images, Office files) are skipped, not loaded.

…as text

S3Loader, GCSLoader and AzureBlobLoader decoded every object with the text encoding, so a PDF raised UnicodeDecodeError and stopped the load. PDFs are now parsed with the same logic as PyPDFLoader, moved into a shared stream_pdf function that reads a path or bytes, and split by a new pdf_breakdown_strategy option. pypdf is imported only when a PDF is met. Any other object that is not valid text is skipped with a warning.

Closes #1690
@CoronRing CoronRing added bug This is an issue outlining a bug in the framework area:retrieval Issues related to the retrieval/RAG submodule labels Oct 15, 2026
@CoronRing CoronRing self-assigned this Oct 15, 2026
@CoronRing
CoronRing marked this pull request as ready for review October 22, 2026 17:31
…oding behaviour

- Move the duplicated `__init__` validation and `astream` loop of S3Loader, GCSLoader and AzureBlobLoader into an `ObjectStoreLoader` base; each provider now only lists, downloads and names its objects.
- Check for `pypdf` once, before any object is downloaded, when a listed key is a PDF, so a missing extra no longer leaves a half-ingested store. Missing-module detection uses `find_spec`, so other import errors surface unchanged.
- Raise a `ValueError` for a `.txt`/`.md`/`.csv`/`.json`/`.jsonl` object that does not decode (a wrong `encoding`); other undecodable objects are still skipped with a warning.
- Decode text and extract `document`-strategy pages in worker threads instead of on the event loop.
- Simplify `stream_pdf` around one document builder, and reuse `validate_breakdown_strategy` (now taking `allowed`) in PyPDFOCRLoader and GLMOCRLoader.
- Docs: case-insensitive `.pdf` matching, PDF metadata keys in the field tables, and the `[pdf]` note on the cloud extras.
@Pooria90
Pooria90 self-requested a review October 22, 2026 18:23
Comment thread packages/railtracks/src/railtracks/retrieval/loaders/pdf_loader.py Outdated
Comment thread packages/railtracks/src/railtracks/retrieval/loaders/pdf_loader.py Outdated

@Pooria90 Pooria90 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Guan. I just have two blocking comments on the pdf_loader. They are carried over from previous code; I appreciate you fixing them.

…loaders

Adds cloud-loader cases for an uppercase `.PDF` key and a corrupt PDF (raises today; containment is #1654), and makes the missing-pypdf tests also evict the cached `pdf_loader` module so they run the real import path.
@CoronRing
CoronRing force-pushed the fix/1690-cloud-loader-binary branch from 3fe7970 to f4e67f2 Compare October 23, 2026 18:39
@CoronRing

Copy link
Copy Markdown
Collaborator Author

@Pooria90 For the record, following our chat: the page-id fix is out of this PR. It's #1648's bug, and #1687 already fixes it by putting the page into source (path#page=N), the same pattern the CSV/JSON/HF loaders use. That scheme also avoids a trap with an explicit id: the re-ingest skip check matches on source + content hash, so two identical pages in one PDF would wrongly skip the second. Plan is to merge #1687 first and rebase this onto it, so cloud PDFs pick up the same ids through stream_pdf.

Your other two points are in: the document strategy extracts pages via asyncio.to_thread, and the tests now hide pypdf itself and cover paragraph, an uppercase .PDF key and a corrupt PDF. The description is updated with the full scope.

Thanks for the sharp eyes! (๑•̀ㅂ•́)و✧

This branch has not been deployed

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

Labels

area:retrieval Issues related to the retrieval/RAG submodule bug This is an issue outlining a bug in the framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cloud loaders (S3, GCS, Azure Blob) decode binary files such as PDFs as UTF-8

2 participants