·15 min read

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. auto detects and routes. fast uses text-based extraction (pdfminer.six) for speed. hi_res uses Detectron2 or YOLOX models for layout-aware extraction of complex documents. ocr_only uses 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_res strategy requires Detectron2 or YOLOX, which are not installed by default. You must install unstructured[local-inference] or unstructured[all-docs] to use it. On a CPU, hi_res is 5-10x slower than fast. 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

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 fast strategy is 7x faster than hi_res and sufficient for simple text documents. The hi_res strategy uses computer vision models for complex layouts. The ocr_only strategy handles scanned documents. The auto strategy 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_title strategy 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_similarity chunking strategy (semantic clustering using sentence transformers) is only available in the Unstructured Platform/API product. The open-source library has basic and by_title only. 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:

  1. File type detection. The library checks the file extension, MIME type, and content magic bytes. For ambiguous files (e.g., a .pdf that 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.

  2. Format-specific parsing. Each file format has a dedicated parser module. The PDF parser (unstructured.partition.pdf) uses pdfminer.six for text extraction and pdfplumber for table detection. The DOCX parser (unstructured.partition.docx) uses python-docx to traverse the XML document tree. The PPTX parser (unstructured.partition.pptx) uses python-pptx to extract text from shapes, notes, and slide masters. The HTML parser (unstructured.partition.html) uses lxml to parse the DOM and extract text by semantic tags.

  3. Layout analysis (hi_res only). For the hi_res strategy, 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.

  4. 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.

  5. Post-processing. Cleaning functions remove extra whitespace, dashes, trailing punctuation, and other artifacts. The auto strategy 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:

  1. 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.

  2. 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 exceeds max_characters, it is text-split at the character level.

  3. Table isolation. Table elements are always placed in their own chunk, regardless of size. If a table exceeds max_characters, it becomes a TableChunk with a continuation marker.

  4. Overlap handling. When a chunk is split, the overlap parameter controls how many characters from the end of the previous chunk are prepended to the next chunk. This prevents information loss at chunk boundaries.

  5. Small chunk merging. After initial chunking, chunks smaller than combine_text_under_n_chars are merged into the previous chunk (if the combined size does not exceed max_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

  1. The hi_res strategy requires Detectron2, which is not in the base install. If you run partition("document.pdf", strategy="hi_res") without installing unstructured[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.

  2. 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.

  3. 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.

  4. 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, and combine_text_under_n_chars on each document type in your corpus. Start with 2000/1500/500 for general text and 1000/800/200 for table-heavy documents.

  5. The by_similarity chunking 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 has basic and by_title only. For topic-based chunking, you can approximate it with a separate sentence-transformers clustering step, but it is not built in.

  6. Metadata is not preserved across all serialization formats. The elements_to_text() function strips all metadata. The elements_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.

  7. 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 auto strategy, which detected a text layer (from OCR artifacts in the PDF metadata) and used fast instead of ocr_only. The extracted text was a mix of partial OCR and garbage characters. The fix was to explicitly set strategy='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_res strategy on CPU. It took 116 hours. We switched to fast for documents that had a text layer and hi_res only 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_title chunking 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 to basic chunking with a larger chunk size.” — Data engineer, financial services firm

Advice for Getting Started

  1. 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.

  2. Compare fast vs hi_res on your most complex document. Run both strategies and compare the element counts and text quality. If fast produces acceptable results (titles detected, tables extracted, text in reading order), use fast for everything. If not, use hi_res only for complex documents.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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

NL

Written by Nivant Labs Team

Engineer at Nivant Labs

Share this post