PDF extraction with Haystack 2.x: a custom converter that handles tables and scans
Direct answer: Haystack 2.x’s built-in PyPDFToDocument converter wraps pypdf — fast, but it returns raw text with no table structure and no OCR fallback for scanned pages. For anything beyond clean digital PDFs, write a small custom @component-decorated converter around pdfmux (pip install pdfmux) that returns Markdown-formatted Document objects, with per-document confidence in meta so a downstream component can filter bad pages before they reach your embedder. About 30 lines of code, drops into any existing Haystack pipeline via pipeline.add_component.
Where the built-in converters fall short
Haystack ships several PDF converters out of the box:
| Converter | Backend | Tables | OCR | Output |
|---|---|---|---|---|
PyPDFToDocument | pypdf | No | No | Plain text |
AzureOCRDocumentConverter | Azure Document Intelligence | Yes | Yes | Text (cloud, paid) |
TikaDocumentConverter | Apache Tika | Partial | No | Plain text |
PyPDFToDocument is the default most people reach for because it needs no external service and no API key. It’s also the one that silently fails on the documents that matter most: scanned contracts, financial reports with real tables, multi-column academic PDFs. It has no error signal — a page that extracts to three garbled characters looks the same to the pipeline as a page that extracted cleanly. Both become a Document and both get embedded.
AzureOCRDocumentConverter fixes tables and OCR, but only if you’re willing to send every PDF to Azure and pay per page. For a self-hosted or cost-sensitive pipeline, that’s not always an option.
This is the same gap covered from the LangChain side in PDF extraction with LangChain — Haystack has the identical problem, just with a different plumbing API.
Building a custom pdfmux converter
Haystack 2.x components are plain Python classes marked with @component. A converter component’s run() method needs to return a dict with a documents key, matching the convention every built-in converter follows.
pip install pdfmux haystack-ai
from pathlib import Path
from typing import List
from haystack import component, Document
from pdfmux import process
@component
class PdfmuxConverter:
"""Converts PDFs to Haystack Documents using pdfmux's self-healing pipeline."""
def __init__(self, quality: str = "standard", min_confidence: float = 0.5):
self.quality = quality
self.min_confidence = min_confidence
@component.output_types(documents=List[Document])
def run(self, sources: List[str]):
documents = []
for source in sources:
result = process(source, quality=self.quality)
if result.confidence < self.min_confidence:
# Still returned, but flagged — let a downstream
# component decide whether to drop or re-route it.
status = "low_confidence"
else:
status = "ok"
documents.append(
Document(
content=result.text,
meta={
"file_path": str(source),
"confidence": result.confidence,
"extractor": result.extractor_used,
"status": status,
"warnings": result.warnings,
},
)
)
return {"documents": documents}
This mirrors what PyPDFToDocument does — take a list of source paths, return {"documents": [...]} — so it’s a drop-in replacement anywhere a converter slots into a pipeline. The difference is result.text is Markdown with tables and headings preserved (not raw pypdf text), and meta["confidence"] gives you a real signal instead of silence.
Wiring it into a pipeline
from haystack import Pipeline
from haystack.components.preprocessors import DocumentSplitter
from haystack.components.embedders import SentenceTransformersDocumentEmbedder
from haystack.components.writers import DocumentWriter
from haystack.document_stores.in_memory import InMemoryDocumentStore
document_store = InMemoryDocumentStore()
pipeline = Pipeline()
pipeline.add_component("converter", PdfmuxConverter(quality="standard"))
pipeline.add_component("splitter", DocumentSplitter(split_by="passage", split_length=5))
pipeline.add_component("embedder", SentenceTransformersDocumentEmbedder())
pipeline.add_component("writer", DocumentWriter(document_store=document_store))
pipeline.connect("converter.documents", "splitter.documents")
pipeline.connect("splitter.documents", "embedder.documents")
pipeline.connect("embedder.documents", "writer.documents")
pipeline.run({"converter": {"sources": ["contracts/msa-2026.pdf", "reports/q3-earnings.pdf"]}})
Five components, no cloud OCR bill, and every document written to the store carries meta.confidence so the retrieval side can weight or exclude low-quality sources.
Filtering low-confidence pages before they pollute the index
Writing garbled OCR into your vector store is worse than not indexing the page at all — a bad chunk that retrieves for the right query and returns nonsense erodes trust in the whole system faster than a missing answer does. Add a filter component between the converter and the splitter:
from typing import List
from haystack import component, Document
@component
class ConfidenceFilter:
def __init__(self, min_confidence: float = 0.5):
self.min_confidence = min_confidence
@component.output_types(documents=List[Document], dropped=List[Document])
def run(self, documents: List[Document]):
keep, dropped = [], []
for doc in documents:
if doc.meta.get("confidence", 1.0) >= self.min_confidence:
keep.append(doc)
else:
dropped.append(doc)
return {"documents": keep, "dropped": dropped}
pipeline.add_component("confidence_filter", ConfidenceFilter(min_confidence=0.6))
pipeline.connect("converter.documents", "confidence_filter.documents")
pipeline.connect("confidence_filter.documents", "splitter.documents")
dropped documents aren’t discarded silently — route them to a logging sink or a manual-review queue instead of into the embedder. This is the same self-healing philosophy covered in self-healing PDF extraction: don’t let a bad page fail silently, flag it and let the pipeline (or a human) decide.
Handling tables specifically
pdfmux extracts tables as Markdown pipe tables inline in result.text, so DocumentSplitter sees them as part of the surrounding prose — usually the right behavior for retrieval, since a table’s meaning is tied to the paragraph introducing it. If you need tables as separately queryable structured data (for a text-to-SQL style pipeline rather than plain RAG), request JSON output instead and build a second, table-specific Document stream:
result = process(source, quality="standard", output_format="json")
for table in result.tables: # [{headers: [...], rows: [[...]], page: 1}, ...]
header_row = " | ".join(table["headers"])
body_rows = "\n".join(" | ".join(str(cell) for cell in row) for row in table["rows"])
documents.append(
Document(
content=f"{header_row}\n{body_rows}",
meta={"file_path": str(source), "type": "table", "page": table["page"]},
)
)
Full table-extraction methods and when to reach for which are covered in how to extract tables from PDF in Python.
Why not just fine-tune PyPDFToDocument’s output
It’s tempting to keep PyPDFToDocument and patch its output downstream — strip garbled characters, re-run OCR on pages that look empty. In practice this reimplements the routing logic pdfmux already does (classify page type, pick the right backend, audit the result, re-extract if it fails) one bug report at a time. On the opendataloader-bench — 200 real-world PDFs spanning financial filings, contracts, and scanned documents — pdfmux scores 0.903 overall against 8 engines, including a 0.911 TEDS score on tables specifically. Full methodology in the benchmark writeup. pypdf, the backend behind PyPDFToDocument, isn’t part of that comparison because it doesn’t attempt table structure or OCR at all — it’s a text-extraction library, not a document-understanding one.
Handling extraction failures without crashing the pipeline
A corrupt PDF, a password-protected file, or a truly unreadable scan will raise from process() rather than return a low-confidence result. A converter that lets one bad file kill a batch run of 500 documents is a worse failure mode than a single skipped document, so wrap the extraction call and route failures the same way you route low-confidence results:
import logging
import pdfmux
logger = logging.getLogger(__name__)
@component
class PdfmuxConverter:
def __init__(self, quality: str = "standard", min_confidence: float = 0.5):
self.quality = quality
self.min_confidence = min_confidence
@component.output_types(documents=List[Document], failed=List[str])
def run(self, sources: List[str]):
documents, failed = [], []
for source in sources:
try:
result = process(source, quality=self.quality)
except pdfmux.PdfmuxError as e:
logger.warning(f"Extraction failed for {source}: {e.user_message}")
failed.append(source)
continue
status = "ok" if result.confidence >= self.min_confidence else "low_confidence"
documents.append(
Document(
content=result.text,
meta={
"file_path": str(source),
"confidence": result.confidence,
"extractor": result.extractor_used,
"status": status,
},
)
)
return {"documents": documents, "failed": failed}
pdfmux’s exceptions carry a .user_message and .suggestion field specifically so a caller doesn’t have to parse a stack trace to know what happened — log both, and surface failed as a second pipeline output so a caller can retry or alert on it instead of it silently vanishing from the document count.
Checking extraction quality before it’s in production
Before wiring PdfmuxConverter into a pipeline that’s about to run against thousands of real documents, run a quick sanity check against a representative sample:
pdfmux benchmark sample-contract.pdf
pdfmux doctor
pdfmux benchmark runs every installed extraction backend against one file and reports confidence and timing per backend — useful for confirming the quality tier you picked is actually routing through the backend you expect. pdfmux doctor lists which optional backends are installed (OCR, table extraction, LLM fallback) so a missing pip install "pdfmux[ocr]" shows up before it becomes an unexplained wave of low-confidence documents in production, not after.
FAQ
Does this work with Haystack’s Pipeline.run() async mode?
Yes — the component above is synchronous, but Haystack 2.x pipelines run sync components fine inside an async pipeline. If you’re processing hundreds of PDFs, wrap the run() body in a thread pool executor rather than making the component itself async; pdfmux’s extraction backends (PyMuPDF, OCR engines) are CPU-bound, not I/O-bound, so asyncio alone won’t parallelize them.
Can I use this with AzureOCRDocumentConverter for a hybrid pipeline?
Yes — route by page type. Use the PdfmuxConverter above for the bulk of documents (it’s free and runs locally), and only send documents where pdfmux’s confidence comes back low to Azure OCR as a fallback. That keeps the cloud OCR bill limited to genuinely hard pages instead of every PDF.
What embedding model should I pair this with?
Any Haystack-supported embedder works — SentenceTransformersDocumentEmbedder for local/free, or OpenAIDocumentEmbedder for hosted. The converter’s job ends at producing clean Document objects; embedding choice is independent. If you’re also setting up the vector store side in Postgres, see PDF to pgvector: an end-to-end embeddings pipeline.