Unstructured: The enterprise document preprocessing library (Apache 2.0, 9k stars)
Extracting and chunking text from PDFs, HTML, Word docs, and images for RAG pipelines — the enterprise document preprocessing library.
The Problem
Every RAG pipeline begins with a document. A PDF contract. A Word report. A scanned invoice. An HTML knowledge base. The LLM does not read these formats — it reads text. The gap between “I have 10,000 PDFs” and “I have clean, chunked text ready for embedding” is where most RAG projects stall.
The naive approach is pdftotext or PyMuPDF — extract text, split by character count, feed to the embedding model. This works for simple text PDFs. It fails catastrophically on real-world documents: multi-column layouts, tables, headers and footers, scanned images, nested lists, embedded charts. The extracted text is a jumble of out-of-order columns, table cells merged into paragraphs, and page numbers scattered through the content.
| Dimension | pdftotext / PyMuPDF (naive) | Tika / pdfplumber (intermediate) | Unstructured (production) |
|---|---|---|---|
| Table extraction | None (cells merged) | Partial (no structure) | Full (HTML table output) |
| Multi-column layout | Columns concatenated | Some detection | Full reordering |
| Header/footer handling | Included in text | Manual filtering | Automatic removal |
| OCR for scanned docs | Not supported | Limited (Tesseract wrapper) | Built-in (hi_res + OCR) |
| File formats supported | ~5 (PDF, TXT) | ~10 (PDF, DOCX, HTML) | 25+ (PDF, DOCX, PPTX, XLSX, images, email) |
| Element type detection | None | None | 20+ types (Title, NarrativeText, ListItem, Table) |
| Chunking strategies | Character split only | Character split only | Basic, by_title, by_similarity |
| Metadata preservation | Page number only | Page number | Page, coordinates, file type, language, parent section |
| Setup time | 5 minutes | 15 minutes | 2 minutes (pip install) |
| RAG accuracy (legal contracts) | 34% top-3 | ~55% top-3 | 89% top-3 (hi_res + by_title) |
Why this matters: The quality of your RAG pipeline is bounded by the quality of your document preprocessing. A 34% retrieval accuracy on raw PDF text means the LLM gets the wrong context 66% of the time. The LLM cannot hallucinate its way to a correct answer from bad context. Document preprocessing is not a nice-to-have — it is the foundation that determines whether your RAG system works or produces confident-sounding wrong answers.
The Investigation
Unstructured started in 2022 as a Y Combinator-backed company (W23) founded by Brian Raymond, Crag Mattes, and Matt Robinson. The founding insight was that enterprise RAG was being held back not by the LLMs or the vector databases, but by the document preprocessing layer. Every enterprise team building a RAG pipeline was spending 60-80% of their engineering time on document parsing — and getting it wrong.
Finding 1: Enterprise documents are structurally complex.
The team analyzed 10,000 enterprise documents from Fortune 500 companies. They found that 73% of PDFs had multi-column layouts, 41% contained tables that spanned multiple pages, 28% had headers or footers that were not visually distinct from body text, and 19% were scanned images with no extractable text layer. The standard PDF text extraction libraries (pdfminer, PyMuPDF, pdfplumber) were designed for simple text extraction, not for understanding document structure. They extracted characters in reading order — but “reading order” for a two-column layout is column A top-to-bottom, then column B top-to-bottom, not a line-by-line interleave.
Finding 2: File format diversity is the norm, not the exception.
The average enterprise knowledge base contains documents in 8-12 different file formats. PDFs from different sources behave differently — a PDF generated from LaTeX is structurally clean; a PDF generated from a scanned magazine is a collection of images. DOCX files have internal XML structures that vary by Word version. PPTX files have text in shapes, notes, and slide masters. EML and MSG files have nested attachments. The investigation found that no single parser handled more than 40% of the document types in a typical enterprise corpus. The solution was a routing architecture: auto-detect the file type, dispatch to a format-specific parser, and normalize the output into a common element model.
Finding 3: Chunking strategy is a retrieval design problem.
The team found that character-count-based chunking (split every N characters) destroyed retrieval quality on structured documents. A chunk that starts mid-table and ends mid-paragraph is semantically incoherent — the embedding model cannot produce a meaningful vector for it. The solution was to chunk along document-structure boundaries: each Title element starts a new chunk, tables are kept intact, and lists are grouped. This structure-aware chunking improved retrieval accuracy by 2.5x in controlled tests.
The Solution
Unstructured is an Apache 2.0-licensed document preprocessing library written in Python with Cython-accelerated parsers. As of June 2026, it has ~14,900 GitHub stars, 1,252 forks, and 140+ contributors across 232 releases. The latest stable version is v0.23.1.
┌──────────────────────────────────────────────────────────────────────────────┐
│ Unstructured Architecture │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ File Detection Layer │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ PDF │ │ DOCX │ │ PPTX │ │ XLSX │ │ HTML / MD │ │ │
│ │ │ .pdf │ │ .docx │ │ .pptx │ │ .xlsx │ │ .html/.md │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ │
│ │ ┌────┴─────┐ ┌────┴─────┐ ┌────┴─────┐ ┌────┴─────┐ ┌──────┴───────┐ │ │
│ │ │ EML/MSG │ │ PNG/JPG │ │ TIFF │ │ RTF/TXT │ │ EPUB/XML │ │ │
│ │ │ .eml.msg │ │ .png.jpg │ │ .tiff │ │ .rtf.txt │ │ .epub.xml │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────┴────────────────────────────────────────┐ │
│ │ Partitioning Strategies │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ auto │ │ fast │ │ hi_res │ │ ocr_only │ │ │
│ │ │ (detect + │ │ (text-based │ │ (Detectron2 │ │ (Tesseract │ │ │
│ │ │ route) │ │ extraction) │ │ + YOLOX) │ │ + layout) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────┴────────────────────────────────────────┐ │
│ │ Element Extraction Layer │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ │
│ │ │ Title │ │ Narrative│ │ ListItem │ │ Table │ │ Header/Footer │ │ │
│ │ │ │ │ Text │ │ │ │ (HTML) │ │ (filtered) │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └────────────────┘ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ │
│ │ │ Image │ │ Figure │ │ PageBreak│ │ Formula │ │ Unsupported │ │ │
│ │ │ │ │ Caption │ │ │ │ │ │ (raw text) │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────┴────────────────────────────────────────┐ │
│ │ Chunking Layer │ │
│ │ │ │
│ │ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ │ │
│ │ │ basic │ │ by_title │ │ by_similarity │ │ │
│ │ │ (fixed-size + │ │ (section-boundary │ │ (semantic │ │ │
│ │ │ overlap) │ │ aware) │ │ clustering) │ │ │
│ │ └──────────────────────┘ └──────────────────────┘ └──────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────┴────────────────────────────────────────┐ │
│ │ Output Layer │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Elements │ │ Chunks │ │ JSON │ │ Connectors │ │ │
│ │ │ (list) │ │ (list) │ │ (serialized) │ │ (Chroma, │ │ │
│ │ │ │ │ │ │ │ │ Pinecone) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
Here is what each layer does:
- File Detection Layer: Auto-detects file type by MIME, extension, and content inspection. Routes to the correct format-specific parser. Supports 25+ file formats including PDF, DOCX, PPTX, XLSX, HTML, Markdown, EPUB, PNG, JPG, TIFF, EML, MSG, RTF, and TXT.
- Partitioning Strategies: Four strategies for PDF/image extraction.
autodetects and routes.fastuses text-based extraction (pdfminer.six) for speed.hi_resuses Detectron2 or YOLOX models for layout-aware extraction of complex documents.ocr_onlyuses Tesseract for scanned documents with no text layer. - Element Extraction Layer: Normalizes all document types into a common element model with 20+ element types. Each element carries rich metadata: page number, bounding box coordinates, file type, detected languages, parent section, and custom fields.
- Chunking Layer: Post-processes elements into LLM-ready chunks. Three strategies:
basic(fixed-size with overlap),by_title(section-boundary aware),by_similarity(semantic clustering, platform only). - Output Layer: Elements and chunks can be consumed as Python lists, serialized to JSON, or pushed to vector databases via 20+ destination connectors (Chroma, Pinecone, Weaviate, Qdrant, Milvus, etc.).
Setup
# Install the core library
pip install unstructured
# Install with all document parsers (recommended for production)
pip install "unstructured[all-docs]"
# Install with specific parsers to reduce dependency footprint
pip install "unstructured[pdf]" # PDF support only
pip install "unstructured[docx,pptx]" # Office documents only
pip install "unstructured[local-inference]" # + Detectron2 for hi_res
# Pull the Docker image for production API deployment
docker pull downloads.unstructured.io/unstructured-io/unstructured:latest
# Run the API server
docker run -p 8000:8000 downloads.unstructured.io/unstructured-io/unstructured:latest
Production-Grade Configuration
# config.py — production Unstructured pipeline setup
from unstructured.partition.auto import partition
from unstructured.chunking.title import chunk_by_title
from unstructured.staging.base import elements_to_json
import json
def process_document(file_path: str, strategy: str = "auto") -> dict:
"""Partition, chunk, and serialize a document for RAG ingestion."""
elements = partition(
filename=file_path,
strategy=strategy,
pdf_infer_table_structure=True,
include_page_breaks=False,
languages=["eng"],
)
chunks = chunk_by_title(
elements,
max_characters=2000,
new_after_n_chars=1500,
combine_text_under_n_chars=500,
multipage_sections=False,
)
return {
"file": file_path,
"element_count": len(elements),
"chunk_count": len(chunks),
"chunks": [
{
"text": chunk.text,
"type": type(chunk).__name__,
"metadata": chunk.metadata.to_dict(),
}
for chunk in chunks
],
}
Code Walkthrough: The Core Operations
# 1. Partition a PDF with auto-detection
from unstructured.partition.auto import partition
elements = partition("annual_report_2025.pdf")
print(f"Extracted {len(elements)} elements")
# Output: Extracted 187 elements
# 2. Inspect element types
from collections import Counter
type_counts = Counter(type(el).__name__ for el in elements)
print(type_counts)
# Output: Counter({'Title': 23, 'NarrativeText': 89, 'ListItem': 31,
# 'Table': 7, 'Header': 4, 'Footer': 4, 'PageBreak': 29})
# 3. Extract tables as HTML
for el in elements:
if el.metadata.text_as_html:
print(f"Table on page {el.metadata.page_number}:")
print(el.metadata.text_as_html[:200])
print("---")
# 4. Filter out headers and footers
body_elements = [
el for el in elements
if el.category not in ("Header", "Footer", "PageBreak")
]
print(f"Body elements: {len(body_elements)}")
# 5. Access element metadata
first_title = next(el for el in elements if el.category == "Title")
print(f"Text: {first_title.text[:80]}")
print(f"Page: {first_title.metadata.page_number}")
print(f"Coordinates: {first_title.metadata.coordinates}")
print(f"File type: {first_title.metadata.filetype}")
Code Walkthrough: Partitioning Strategies Compared
# Compare extraction quality across strategies
from unstructured.partition.pdf import partition_pdf
# Fast strategy — text extraction only, no layout model
fast_elements = partition_pdf(
"complex_layout_report.pdf",
strategy="fast",
)
print(f"Fast: {len(fast_elements)} elements, {sum(len(t.text) for t in fast_elements)} chars")
# Hi-res strategy — Detectron2 layout model for complex documents
hi_res_elements = partition_pdf(
"complex_layout_report.pdf",
strategy="hi_res",
pdf_infer_table_structure=True,
)
print(f"Hi-res: {len(hi_res_elements)} elements, {sum(len(t.text) for t in hi_res_elements)} chars")
# OCR-only strategy — for scanned documents with no text layer
ocr_elements = partition_pdf(
"scanned_document.pdf",
strategy="ocr_only",
)
print(f"OCR: {len(ocr_elements)} elements, {sum(len(t.text) for t in ocr_elements)} chars")
# Check how many tables were detected
for name, els in [("fast", fast_elements), ("hi_res", hi_res_elements), ("ocr", ocr_elements)]:
tables = [e for e in els if e.category == "Table"]
print(f"{name}: {len(tables)} tables detected")
How to Use Effectively
Step 1: Choose the right partitioning strategy
# auto — let Unstructured detect the best strategy (recommended for most cases)
elements = partition("document.pdf", strategy="auto")
# fast — text-based extraction, 7x faster than hi_res
# Use for: simple text PDFs, bulk processing, speed-critical pipelines
elements = partition("text_only.pdf", strategy="fast")
# hi_res — layout-aware extraction with Detectron2/YOLOX
# Use for: complex layouts, multi-column, tables, scanned docs
elements = partition("complex_report.pdf", strategy="hi_res")
# ocr_only — Tesseract OCR for image-based PDFs
# Use for: scanned documents with no text layer
elements = partition("scanned_contract.pdf", strategy="ocr_only")
The auto strategy inspects the document and routes to the appropriate parser. For PDFs, it checks whether the document has a text layer. If it does, it uses fast. If it does not, it falls back to ocr_only. The hi_res strategy must be explicitly requested — it is not part of the auto-detection path because it requires a GPU for acceptable performance.
Production pitfall: The
hi_resstrategy requires Detectron2 or YOLOX, which are not installed by default. You must installunstructured[local-inference]orunstructured[all-docs]to use it. On a CPU,hi_resis 5-10x slower thanfast. On a GPU (NVIDIA T4), the gap narrows to ~2x. Always benchmark your specific document types before committing to a strategy.
Step 2: Configure chunking for your retrieval use case
from unstructured.chunking.title import chunk_by_title
from unstructured.chunking.basic import chunk_elements
# By-title chunking — preserves section boundaries (recommended for RAG)
chunks = chunk_by_title(
elements,
max_characters=2000, # hard max chunk size
new_after_n_chars=1500, # soft max (preferred size)
combine_text_under_n_chars=500, # merge small chunks below this threshold
multipage_sections=False, # don't let chunks span pages
)
# Basic chunking — fixed-size with overlap (for homogeneous text)
chunks = chunk_elements(
elements,
max_characters=500,
overlap=50,
)
The by_title strategy is the recommended default for RAG. It starts a new chunk at each Title element, keeping sections together. Tables are always isolated in their own chunk. Lists are grouped. The combine_text_under_n_chars parameter prevents over-chunking — small trailing sections are merged into the previous chunk rather than creating a 50-character orphan.
Step 3: Clean and filter elements before chunking
from unstructured.cleaners.core import clean_extra_whitespace, clean_dashes
# Apply cleaning functions
for el in elements:
el.text = clean_extra_whitespace(el.text)
el.text = clean_dashes(el.text)
# Filter out unwanted element types
filtered = [
el for el in elements
if el.category not in ("Header", "Footer", "PageBreak", "UncategorizedText")
]
# Filter by page range
page_3_to_5 = [el for el in filtered if 3 <= (el.metadata.page_number or 0) <= 5]
Step 4: Serialize for downstream ingestion
from unstructured.staging.base import elements_to_json, elements_to_text
# Export to JSON (preserves all metadata)
json_output = elements_to_json(elements)
with open("extracted_elements.json", "w") as f:
f.write(json_output)
# Export to plain text (metadata stripped, for quick inspection)
text_output = elements_to_text(elements)
with open("extracted_text.txt", "w") as f:
f.write(text_output)
# Export chunks to JSONL for vector DB ingestion
import json
with open("chunks.jsonl", "w") as f:
for chunk in chunks:
record = {
"text": chunk.text,
"metadata": chunk.metadata.to_dict(),
}
f.write(json.dumps(record) + "\n")
Step 5: Use the Docker API for production
# Production deployment: self-hosted API server
# docker run -p 8000:8000 downloads.unstructured.io/unstructured-io/unstructured:latest
from unstructured.partition.api import partition_via_api
elements = partition_via_api(
filename="document.pdf",
api_url="http://localhost:8000/general/v0/general",
strategy="auto",
pdf_infer_table_structure=True,
)
Use Cases
1. Legal Contract Analysis Pipeline
When you’d use this: Your legal team has 50,000 PDF contracts (NDAs, MSAs, SOWs) and needs to extract key clauses, parties, dates, and obligations for a RAG-powered contract review system.
Why Unstructured fits: Legal PDFs are structurally complex — multi-column layouts, nested lists, tables within tables, headers and footers on every page, scanned signatures. The hi_res strategy with Detectron2 correctly identifies section boundaries even in two-column layouts. The by_title chunking keeps each clause in its own chunk. The Table element type captures pricing tables and term schedules as HTML, preserving the tabular structure that naive extraction destroys. Metadata filtering by page number lets you exclude signature pages and exhibit attachments from the search index.
2. Enterprise Knowledge Base Ingestion
When you’d use this: Your company has 200,000 internal documents in 12 different formats — PDF reports, Word docs, PowerPoint decks, Excel spreadsheets, HTML intranet pages, and email threads — and you want a single RAG system that searches across all of them.
Why Unstructured fits: The auto-detection layer handles all 12 formats transparently. A single partition() call routes each file to the correct parser. The common element model means your chunking, embedding, and retrieval code is identical regardless of source format. The 20+ source connectors (S3, Azure, Google Drive, SharePoint, Confluence) let you ingest directly from your document stores without writing custom download logic.
3. Scanned Document Digitization for Healthcare
When you’d use this: A hospital has 100,000 scanned patient intake forms, lab reports, and insurance documents in TIFF and JPEG format. They need to extract text for a searchable patient record system.
Why Unstructured fits: The ocr_only strategy uses Tesseract OCR with layout analysis to extract text from scanned images. The hi_res strategy adds Detectron2-based layout detection for complex forms with checkboxes, signature lines, and multi-column layouts. The languages parameter supports multilingual OCR (English, Spanish, French, German, Chinese, and 50+ more). The Docker deployment keeps all data on-premises for HIPAA compliance.
4. Financial Report Processing for RAG
When you’d use this: An investment firm processes 5,000 quarterly reports, earnings transcripts, and SEC filings per quarter. They need to extract financial tables, management commentary, and risk disclosures for a RAG-powered analyst assistant.
Why Unstructured fits: The pdf_infer_table_structure=True parameter extracts tables as HTML with row and column structure preserved. The Table element type is always isolated in its own chunk, preventing table data from being mixed with surrounding text. The by_title chunking keeps each section (Revenue, Operating Expenses, Risk Factors) as a coherent chunk. The NarrativeText elements capture management commentary, while ListItem elements capture bullet-point risk disclosures.
5. Email and Attachment Processing for Compliance
When you’d use this: A regulated company archives 50,000 emails per day with attachments. They need to extract text from both the email body and all attachments for e-discovery and compliance monitoring.
Why Unstructured fits: The EML and MSG parsers extract email bodies, subject lines, sender/recipient metadata, and attachments. Attachments are automatically extracted and can be recursively partitioned. The Header and Footer element types let you filter out email signatures and disclaimers. The metadata includes email-specific fields (sender, recipients, subject, date) that can be used for downstream filtering. The connector to Elasticsearch enables full-text search across the entire email archive.
Cheat Sheet
| Aspect | Detail |
|---|---|
| Repository | github.com/Unstructured-IO/unstructured |
| License | Apache 2.0 |
| Language | Python (primary), Cython (accelerated parsers) |
| GPU Requirements | Optional (hi_res strategy benefits from GPU; fast/ocr_only run on CPU) |
| Setup Time | 2 minutes (pip install) |
| Key Features | 25+ file formats, 4 partitioning strategies, 3 chunking strategies, 20+ element types, 20+ source/destination connectors, Docker API |
| Common Gotchas | hi_res requires Detectron2 (not in base install); OCR quality depends on Tesseract installation; dependency footprint is ~2GB for all-docs; by_similarity chunking is platform-only |
| Partitioning Strategies | auto (detect+route), fast (text-based), hi_res (layout model), ocr_only (Tesseract) |
| Chunking Strategies | basic (fixed-size+overlap), by_title (section-boundary aware), by_similarity (platform only) |
| Default Chunk Size | 500 characters (max_characters) |
| Element Types | Title, NarrativeText, ListItem, Table, Header, Footer, Image, FigureCaption, PageBreak, Formula, and 10+ more |
| Supported Formats | PDF, DOCX, PPTX, XLSX, HTML, MD, EPUB, PNG, JPG, TIFF, EML, MSG, RTF, TXT, XML, JSON, CSV, and more |
| Missing Features | No by_similarity chunking in OSS; no image description generation; no NER enrichment; no embedding generation; no SOC 2/HIPAA in OSS |
| Docker Image Size | ~2GB (includes all dependencies, LibreOffice, Tesseract, Detectron2) |
Vibe Coding Projects
Project 1: Personal Document Search Engine
What it does: A command-line tool that watches a directory of PDFs, Word docs, and markdown files, partitions them with Unstructured, chunks by title, embeds with Sentence Transformers, and stores in ChromaDB. You type natural language queries and get the most relevant document snippets with source citations.
What you’ll learn: How to set up a complete RAG ingestion pipeline from scratch. How to compare partitioning strategies on real documents. How to configure chunking parameters for different document types. How to serialize Unstructured output for vector DB ingestion. How to evaluate retrieval quality by inspecting chunk boundaries.
Effort: 3-4 hours. $0 (local embeddings, local vector DB).
Project 2: Multi-Format Email and Attachment Analyzer
What it does: A FastAPI web app that accepts uploaded EML or MSG files, extracts the email body and all attachments, partitions each attachment by its format, and returns a unified JSON response with all extracted text, tables, and metadata. The frontend shows the email thread with expandable attachment previews.
What you’ll learn: How to use Unstructured’s email parsers. How to recursively partition nested attachments. How to filter out email signatures and disclaimers. How to serialize multi-document extractions into a unified JSON schema. How to build a FastAPI endpoint that streams large document processing results.
Effort: 4-5 hours. $0.
Project 3: Production RAG Pipeline with Docker API
What it does: A Docker Compose setup with three services: Unstructured API server (for document processing), ChromaDB (for vector storage), and a Python worker (for orchestration). The worker watches an S3 bucket for new documents, sends them to the Unstructured API for partitioning and chunking, embeds the chunks, and stores them in ChromaDB. A FastAPI query service provides a RAG endpoint.
What you’ll learn: How to deploy Unstructured as a production API. How to use the partition_via_api client for network-based processing. How to build a document ingestion pipeline with error handling and retries. How to scale document processing with parallel workers. How to monitor pipeline throughput and latency.
Effort: 8-10 hours. ~$5-10 in cloud costs (S3, compute).
Problems Solved Efficiently
| Problem Type | Why Unstructured Fits | When to Look Elsewhere |
|---|---|---|
| PDF text extraction for RAG | Structure-aware extraction, table support, 4 strategies | Use PyMuPDF for simple text-only PDFs (faster, lighter) |
| Multi-format document ingestion | 25+ formats, auto-detection, common element model | Use Tika for Java-based ecosystems |
| Scanned document OCR | Built-in Tesseract + layout analysis, 50+ languages | Use Azure Document Intelligence for higher OCR accuracy |
| Table extraction from PDFs | HTML table output, hi_res layout detection | Use Camelot or Tabula for table-only extraction |
| Email + attachment processing | EML/MSG parsers, recursive attachment extraction | Use Python’s email library for simple email parsing |
| Enterprise RAG pipeline | Connectors to 20+ vector DBs, Docker API, metadata preservation | Use LlamaIndex for end-to-end RAG orchestration |
| Compliance document processing | Header/footer filtering, page-level metadata, on-prem Docker | Use Azure AI Document Intelligence for managed compliance |
| Batch document processing | 7x faster with fast strategy, parallel processing support | Use Spark NLP for distributed document processing |
Architectural Tradeoffs
What we gained:
- Structure-aware extraction. Unstructured does not just extract text — it understands document structure. Titles, lists, tables, headers, and footers are identified and tagged. This is the single biggest differentiator and the reason Unstructured has 14,900 GitHub stars. The difference between raw PDF text and Unstructured’s element output is the difference between a jumble of characters and a structured document.
- 25+ file formats, one API. A single
partition()call handles PDFs, Word docs, PowerPoint decks, Excel spreadsheets, HTML pages, markdown files, emails, images, and more. The auto-detection layer routes to the correct parser transparently. Your downstream code never needs to know what format the source document was in. - Four partitioning strategies for different quality/speed tradeoffs. The
faststrategy is 7x faster thanhi_resand sufficient for simple text documents. Thehi_resstrategy uses computer vision models for complex layouts. Theocr_onlystrategy handles scanned documents. Theautostrategy picks the right one. You choose the tradeoff per document type. - Rich metadata preservation. Every element carries page number, bounding box coordinates, file type, detected languages, and parent section. This metadata enables downstream filtering (search only page 5-10), provenance tracking (which document and page did this come from), and debugging (why did this chunk get extracted this way).
- Chunking that respects document structure. The
by_titlestrategy chunks along section boundaries, keeping semantically related content together. Tables are always isolated. Lists are grouped. This is fundamentally better than character-count splitting for RAG retrieval quality. - Production-ready Docker deployment. The official Docker image includes all dependencies (LibreOffice, Tesseract, Detectron2, NLTK models) in a security-hardened Wolfi base image. The API server handles concurrent requests, file uploads, and JSON serialization. You can deploy it behind a load balancer for scale.
What we sacrificed:
- Large dependency footprint. The
[all-docs]install is ~2GB. This includes LibreOffice (for DOCX/PPTX/XLSX conversion), Tesseract (for OCR), Detectron2 (for hi_res layout detection), and NLTK data. For a serverless function or a lightweight container, this is prohibitive. The solution is to install only the parsers you need (unstructured[pdf]is ~200MB) and use the Docker API for heavy processing. - No by_similarity chunking in open source. The
by_similaritychunking strategy (semantic clustering using sentence transformers) is only available in the Unstructured Platform/API product. The open-source library hasbasicandby_titleonly. For documents with subtle topic shifts that do not align with section headings, you need the platform. - No image description or table description enrichment. The open-source library extracts images as elements but does not generate descriptions. The platform adds VLM-based image captioning and table summarization. If your RAG pipeline needs to search over chart content or table semantics, you need to add a separate VLM step.
- No embedding generation. Unstructured stops at chunking. You need a separate embedding step (OpenAI, Sentence Transformers, etc.) to convert chunks to vectors. Some competitors (e.g., LlamaIndex) bundle embedding into the pipeline. Unstructured’s philosophy is to be the best at document preprocessing and let you choose your embedding stack.
- No SOC 2, HIPAA, or FedRAMP in open source. The open-source library has no compliance certifications. The Unstructured Platform adds SOC 2 Type II, HIPAA BAA, and GDPR compliance. For regulated industries, you either self-deploy the open-source library in a compliant environment or use the platform.
- OCR quality is bounded by Tesseract. Unstructured uses Tesseract for OCR, which is good but not state-of-the-art. Azure Document Intelligence, Google Document AI, and AWS Textract all provide better OCR accuracy, especially for handwriting, low-quality scans, and complex layouts. The tradeoff is cost and data residency — Tesseract is free and runs locally.
The real lesson: Unstructured’s architectural tradeoffs reflect a deliberate choice: be the best open-source document preprocessing library, not the best end-to-end RAG platform. The library excels at what it does — structure-aware extraction from 25+ formats — and deliberately stops at the chunking boundary. This is the right design for teams that want control over their embedding, retrieval, and generation stack. It is the wrong design for teams that want a turnkey RAG solution with built-in enrichment, embedding, and compliance.
Course-Style Deep Dive
How the Partitioning Pipeline Works Under the Hood
When you call partition("document.pdf"), Unstructured executes a multi-stage pipeline:
-
File type detection. The library checks the file extension, MIME type, and content magic bytes. For ambiguous files (e.g., a
.pdfthat is actually a scanned image), it inspects the internal structure. PDFs with no text layer are routed to OCR. PDFs with text are routed to the text-based parser. -
Format-specific parsing. Each file format has a dedicated parser module. The PDF parser (
unstructured.partition.pdf) usespdfminer.sixfor text extraction andpdfplumberfor table detection. The DOCX parser (unstructured.partition.docx) usespython-docxto traverse the XML document tree. The PPTX parser (unstructured.partition.pptx) usespython-pptxto extract text from shapes, notes, and slide masters. The HTML parser (unstructured.partition.html) useslxmlto parse the DOM and extract text by semantic tags. -
Layout analysis (hi_res only). For the
hi_resstrategy, the library runs a document understanding model (Detectron2 or YOLOX) that identifies regions on each page: title regions, text regions, table regions, figure regions, header/footer regions. The model outputs bounding boxes with class labels. The library then extracts text from each region in reading order, using the region labels to assign element types. -
Element construction. Each extracted text fragment is wrapped in an element object with its category (Title, NarrativeText, ListItem, Table, etc.) and metadata (page number, coordinates, file type). Tables are further processed to extract row and column structure, output as HTML.
-
Post-processing. Cleaning functions remove extra whitespace, dashes, trailing punctuation, and other artifacts. The
autostrategy may re-run with a different strategy if the initial extraction produces too few elements (indicating a misdetection).
How the Chunking Pipeline Works
The chunk_by_title function implements structure-aware chunking:
-
Element grouping. Elements are grouped by their position in the document hierarchy. A Title element starts a new group. All subsequent elements (NarrativeText, ListItem, Table) belong to that group until the next Title.
-
Size management. Within each group, elements are combined into chunks. If the combined text exceeds
max_characters, the group is split at element boundaries (not mid-element). If a single element exceedsmax_characters, it is text-split at the character level. -
Table isolation. Table elements are always placed in their own chunk, regardless of size. If a table exceeds
max_characters, it becomes aTableChunkwith a continuation marker. -
Overlap handling. When a chunk is split, the
overlapparameter controls how many characters from the end of the previous chunk are prepended to the next chunk. This prevents information loss at chunk boundaries. -
Small chunk merging. After initial chunking, chunks smaller than
combine_text_under_n_charsare merged into the previous chunk (if the combined size does not exceedmax_characters). This prevents orphan chunks of 50-100 characters.
Advanced Pattern 1: Parallel Document Processing Pipeline
# Process multiple documents in parallel with error isolation
from concurrent.futures import ProcessPoolExecutor, as_completed
from unstructured.partition.auto import partition
from unstructured.chunking.title import chunk_by_title
import json
import time
def process_single_document(file_path: str) -> dict:
"""Process one document with error handling."""
try:
elements = partition(
filename=file_path,
strategy="auto",
pdf_infer_table_structure=True,
)
chunks = chunk_by_title(
elements,
max_characters=2000,
new_after_n_chars=1500,
combine_text_under_n_chars=500,
)
return {
"file": file_path,
"status": "success",
"element_count": len(elements),
"chunk_count": len(chunks),
"chunks": [
{
"text": chunk.text,
"type": type(chunk).__name__,
"metadata": chunk.metadata.to_dict(),
}
for chunk in chunks
],
}
except Exception as e:
return {
"file": file_path,
"status": "error",
"error": str(e),
}
def batch_process(file_paths: list, max_workers: int = 4) -> list:
"""Process multiple documents in parallel."""
results = []
start = time.time()
with ProcessPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(process_single_document, fp): fp
for fp in file_paths
}
for future in as_completed(futures):
result = future.result()
results.append(result)
fp = futures[future]
status = result["status"]
print(f"[{status.upper()}] {fp}: {result.get('chunk_count', 'N/A')} chunks")
elapsed = time.time() - start
successes = sum(1 for r in results if r["status"] == "success")
errors = sum(1 for r in results if r["status"] == "error")
print(f"Processed {len(results)} files in {elapsed:.1f}s ({successes} ok, {errors} errors)")
return results
Advanced Pattern 2: Recursive Email and Attachment Processing
# Process emails with nested attachments recursively
from unstructured.partition.auto import partition
from unstructured.partition.email import partition_email
from unstructured.chunking.title import chunk_by_title
import os
import tempfile
def process_email_with_attachments(eml_path: str) -> list:
"""Extract text from an email and all its attachments."""
all_chunks = []
# Partition the email body
email_elements = partition_email(filename=eml_path)
email_chunks = chunk_by_title(email_elements)
for chunk in email_chunks:
chunk.metadata["source_type"] = "email_body"
chunk.metadata["source_file"] = eml_path
all_chunks.extend(email_chunks)
# Extract and process attachments
# Unstructured's email parser extracts attachments to a temp directory
with tempfile.TemporaryDirectory() as tmpdir:
# Re-partition with attachment extraction enabled
elements = partition(
filename=eml_path,
strategy="auto",
extract_images_in_table=True,
)
# Find attachment file references in metadata
attachment_paths = set()
for el in elements:
if el.metadata.attached_filename:
# Search for the extracted attachment
for root, dirs, files in os.walk(tmpdir):
for f in files:
if el.metadata.attached_filename in f:
attachment_paths.add(os.path.join(root, f))
# Process each attachment
for att_path in attachment_paths:
try:
att_elements = partition(filename=att_path, strategy="auto")
att_chunks = chunk_by_title(att_elements)
for chunk in att_chunks:
chunk.metadata["source_type"] = "attachment"
chunk.metadata["source_file"] = eml_path
chunk.metadata["attachment_file"] = os.path.basename(att_path)
all_chunks.extend(att_chunks)
except Exception as e:
print(f"Failed to process attachment {att_path}: {e}")
return all_chunks
Advanced Pattern 3: Custom Element Filtering and Enrichment
# Add custom metadata and filter elements before chunking
from unstructured.documents.elements import Element, Title, NarrativeText, Table
import re
def enrich_elements(elements: list) -> list:
"""Add custom metadata to elements based on content analysis."""
enriched = []
for el in elements:
text = el.text or ""
# Detect document type from content patterns
if re.search(r'(?:CONFIDENTIAL|PRIVILEGED|ATTORNEY-CLIENT)', text, re.IGNORECASE):
el.metadata["classification"] = "confidential"
elif re.search(r'(?:PUBLIC|UNCLASSIFIED|OPEN)', text, re.IGNORECASE):
el.metadata["classification"] = "public"
else:
el.metadata["classification"] = "internal"
# Detect language (if not already detected)
if not el.metadata.languages:
el.metadata["languages"] = ["eng"]
# Add word count for downstream filtering
el.metadata["word_count"] = len(text.split())
# Add section depth based on title hierarchy
if el.category == "Title":
# Estimate heading level from font size or formatting
el.metadata["heading_level"] = 1 # simplified
enriched.append(el)
return enriched
# Usage
elements = partition("report.pdf", strategy="hi_res")
enriched = enrich_elements(elements)
# Filter out confidential sections for public-facing search
public_elements = [el for el in enriched if el.metadata["classification"] != "confidential"]
# Filter out very short elements (likely extraction artifacts)
meaningful_elements = [el for el in public_elements if el.metadata["word_count"] >= 3]
chunks = chunk_by_title(meaningful_elements)
Production Considerations
Memory management. The hi_res strategy loads Detectron2 or YOLOX models into memory. Each model uses 2-4 GB of RAM. For batch processing, use a single model instance and process documents sequentially or with a bounded thread pool. The fast strategy uses ~200 MB and is safe for memory-constrained environments.
Dependency management. The [all-docs] install pulls in LibreOffice (for DOCX/PPTX/XLSX conversion), which is a ~1 GB dependency. For containerized deployments, use the official Docker image which has all dependencies pre-installed. For serverless deployments, install only the parsers you need:
# Minimal install for PDF-only processing
pip install "unstructured[pdf]"
# Add DOCX support
pip install "unstructured[docx]"
# Add OCR support
pip install "unstructured[ocr]"
Error handling. Document processing is inherently fragile. Malformed PDFs, corrupted DOCX files, and password-protected documents will throw exceptions. Always wrap partition() in a try/except block and log the error with the file path for later investigation.
Performance tuning. The fast strategy processes ~10 pages/second on a single CPU core. The hi_res strategy processes ~1 page/second on CPU and ~4 pages/second on GPU (NVIDIA T4). For batch processing of 10,000+ documents, use the fast strategy for initial triage and re-process complex documents with hi_res only when needed.
Caching. Partitioning is CPU-intensive. Cache the extracted elements to avoid re-processing:
import hashlib
import pickle
import os
def partition_with_cache(file_path: str, cache_dir: str = "./cache") -> list:
"""Partition a document with disk-based caching."""
os.makedirs(cache_dir, exist_ok=True)
cache_key = hashlib.md5(open(file_path, "rb").read()).hexdigest()
cache_path = os.path.join(cache_dir, f"{cache_key}.pkl")
if os.path.exists(cache_path):
with open(cache_path, "rb") as f:
return pickle.load(f)
elements = partition(file_path, strategy="auto")
with open(cache_path, "wb") as f:
pickle.dump(elements, f)
return elements
The Results
| Metric | Before Unstructured | After Unstructured | Improvement |
|---|---|---|---|
| RAG accuracy (legal contracts, top-3) | 34% (pdftotext + character split) | 89% (hi_res + by_title) | 2.6x better |
| RAG accuracy (multi-column PDFs, top-3) | 22% (pdftotext, columns merged) | 85% (hi_res, layout-aware) | 3.9x better |
| Table extraction accuracy | ~15% (pdftotext, cells merged) | ~92% (hi_res + table model) | 6.1x better |
| File formats supported | 5 (PDF, TXT, HTML, DOCX, RTF) | 25+ (all common enterprise formats) | 5x more formats |
| Time to process 10-page PDF (fast) | 0.8s (pdftotext) | 1.2s (fast strategy) | 1.5x slower (acceptable) |
| Time to process 10-page PDF (hi_res) | N/A (not possible) | 8.4s (hi_res, CPU) | New capability |
| Time to process 47-page scanned PDF | N/A (not possible) | 94s (hi_res + OCR, CPU) | New capability |
| Time to process 30-slide PPTX | 0.5s (python-pptx raw) | 4.1s (auto strategy) | 8x slower (structure-aware) |
| Engineering time on document parsing | 60-80% of RAG project | 5-10% of RAG project | 6-12x less effort |
| Pipeline bug rate (document-related) | ~5 bugs per prototype | ~1 bug per prototype | 5x fewer bugs |
What this means for you: Unstructured is not the fastest document parser for any single format. pdftotext is faster for simple PDFs. python-docx is faster for Word docs. But Unstructured is the only library that handles 25+ formats with structure-aware extraction, and it is the only library that produces element-typed output ready for RAG chunking. The 2.6x improvement in retrieval accuracy on legal contracts is the number that matters — it is the difference between a RAG system that works and one that produces wrong answers with high confidence.
What to Watch Out For
-
The
hi_resstrategy requires Detectron2, which is not in the base install. If you runpartition("document.pdf", strategy="hi_res")without installingunstructured[local-inference], you will get an ImportError. The error message is clear, but the fix requires a 2GB dependency install. Always test your strategy choice in a clean environment before deploying. -
OCR quality depends on your Tesseract installation. Unstructured uses the system Tesseract for OCR. The default Tesseract installation on macOS and Ubuntu has limited language packs and no LSTM models. For production OCR, install Tesseract 5.x with all language packs and the LSTM engine. On Ubuntu:
apt-get install tesseract-ocr tesseract-ocr-eng tesseract-ocr-script-latn. -
The
[all-docs]install is ~2GB. This is not a problem for Docker deployments (the official image is pre-built) but is a problem for serverless functions, CI/CD pipelines, and lightweight containers. Install only the parsers you need. For a PDF-only pipeline,pip install "unstructured[pdf]"is ~200MB. -
Chunking parameters are document-type dependent. A 500-character chunk is too small for narrative text (splits paragraphs mid-sentence) and too large for table cells (includes unrelated rows). There is no universal chunking configuration. Test
max_characters,new_after_n_chars, andcombine_text_under_n_charson each document type in your corpus. Start with 2000/1500/500 for general text and 1000/800/200 for table-heavy documents. -
The
by_similaritychunking strategy is platform-only. If you need semantic chunking (grouping elements by topic similarity rather than section boundaries), you need the Unstructured Platform/API. The open-source library hasbasicandby_titleonly. For topic-based chunking, you can approximate it with a separate sentence-transformers clustering step, but it is not built in. -
Metadata is not preserved across all serialization formats. The
elements_to_text()function strips all metadata. Theelements_to_json()function preserves metadata. If you serialize to text for downstream processing, you lose page numbers, coordinates, and element types. Always use JSON serialization for production pipelines. -
The Docker image is large and slow to start. The official Docker image is ~2GB and takes 30-60 seconds to start (loading Detectron2 models, initializing LibreOffice, warming NLTK caches). For serverless or auto-scaling deployments, use a warm pool of containers or a long-running API server with health checks.
Lesson 1: “I spent a week debugging why my RAG pipeline was returning garbage for a set of legal contracts. The problem was that the contracts were scanned PDFs with no text layer, and I was using the default
autostrategy, which detected a text layer (from OCR artifacts in the PDF metadata) and usedfastinstead ofocr_only. The extracted text was a mix of partial OCR and garbage characters. The fix was to explicitly setstrategy='ocr_only'for scanned documents. Always verify the text layer before trusting auto-detection.” — RAG engineer, legal tech startup
Lesson 2: “We processed 50,000 PDFs with the
hi_resstrategy on CPU. It took 116 hours. We switched tofastfor documents that had a text layer andhi_resonly for complex layouts. The total time dropped to 14 hours, and the retrieval accuracy dropped by only 3%. The lesson: use the cheapest strategy that meets your quality threshold. Not every document needs a computer vision model.” — ML engineer, enterprise search company
Lesson 3: “The
by_titlechunking strategy is great for well-structured documents with clear section headings. It is terrible for documents with no headings — it creates one giant chunk for the entire document. We now check the number of Title elements before choosing a chunking strategy. If there are fewer than 3 titles per 10 pages, we fall back tobasicchunking with a larger chunk size.” — Data engineer, financial services firm
Advice for Getting Started
-
Start with
strategy="auto"on a sample of 5-10 documents from your corpus. Inspect the extracted elements manually. Check that titles are detected as Titles, tables as Tables, and lists as ListItems. If the element types are wrong, the chunking will be wrong. -
Compare
fastvshi_reson your most complex document. Run both strategies and compare the element counts and text quality. Iffastproduces acceptable results (titles detected, tables extracted, text in reading order), usefastfor everything. If not, usehi_resonly for complex documents. -
Test chunking parameters on a single document before processing your corpus. Start with
max_characters=2000, new_after_n_chars=1500, combine_text_under_n_chars=500. Adjust based on your document types. Narrative-heavy documents need larger chunks. Table-heavy documents need smaller chunks. -
Always add metadata to your chunks. At minimum, include the source file name, page number, and element type. This metadata enables downstream filtering, provenance tracking, and debugging.
-
Cache your partitioned elements. Partitioning is the slowest step in the pipeline. Cache the elements to disk (pickle or JSON) so you can iterate on chunking parameters without re-partitioning.
-
Use the Docker API for production. The Python library is great for development and batch processing, but the Docker API is better for production: it handles concurrent requests, has a smaller memory footprint per request, and can be scaled horizontally.
-
When you need features beyond the open-source library (by_similarity chunking, image descriptions, table descriptions, NER enrichment, compliance certifications), evaluate the Unstructured Platform. The open-source library is the best free option for document preprocessing. The platform adds the enrichment and compliance layers that enterprise RAG requires.
Next in the Open-Source AI Tools Mastery series: Docling
Written by Nivant Labs Team
Engineer at Nivant Labs