Source: http://www.poma-ai.com/docs/guides/pdf-chunking-for-rag/

# PDF Chunking for RAG: Split PDFs Without Losing Structure

<ByAuthor />

PDF chunking for RAG is the step where most retrieval pipelines quietly lose their quality. The file looks structured on screen, so it is tempting to extract the text, cut it every 1,000 characters, and move on. A PDF does not store the structure you see. It stores glyphs at coordinates. Headings, columns, tables and page furniture are visual conventions that a parser has to reconstruct, and whatever your parser fails to reconstruct, your chunker cannot respect.

This page is the practical how-to for PDFs specifically: why they are the hard case, the four approaches that actually work with runnable Python, and what to do about tables, page-number citations, scans and chunk size. For the full taxonomy of chunking strategies across all formats, read [the chunking strategies guide](/rag-chunking-strategies-text-splitters); this page assumes it and stays on PDFs.

## Quick start: chunk a PDF in Python

The shortest path that keeps structure. PrimeCut parses the PDF and chunks it in one call, so there is no separate parser to configure.

```bash
pip install poma
```

```python
from poma import PrimeCut

client = PrimeCut()  # reads POMA_API_KEY
result = client.ingest("report.pdf")

print(len(result.chunks), len(result.chunksets))
print(result.chunksets[0].to_embed)  # embedding-ready text with its heading path
```

`result.chunks` are the individual content units, each with its `depth` in the document hierarchy; the source page travels on the archive's raw chunk records rather than on the typed SDK object. `result.chunksets` are [chunksets](/learn/chunking/chunksets): root-to-leaf paths through that hierarchy, so a retrieved sentence still arrives with the chapter and section it lives under. `to_embed` is the string you hand to your embedding model.

If you would rather keep your existing parser, the three other approaches below are laid out with their real APIs.

## Why PDFs are the hard case

Every other input format tells you something about its own structure. Markdown has `##`. HTML has `<h2>`. A PDF has a heading only in the sense that some glyphs are larger and bolder than others. Five specific problems follow from that.

**Layout is not structure.** Text extraction gives you a stream of strings in the order the file happens to store them. Whether that order matches reading order is not guaranteed.

**Two-column pages read straight across.** A naive extractor emits the first line of the left column, then the first line of the right column. The sentences interleave. Nothing downstream can repair it, and it is invisible unless you read the extracted text yourself. Layout-aware extraction modes exist precisely for this.

**Tables are drawn, not marked up.** A table is usually lines and positioned text. Extractors that do not detect tables emit the cells as a run of loose numbers, and a character splitter then cuts between the header row and the data.

**Headers and footers repeat on every page.** Running titles, page numbers and confidentiality notices get interleaved into the body text once per page. They add noise to every chunk that straddles a page boundary and dilute embeddings.

**Scans have no text layer at all.** A photographed or faxed PDF returns empty strings. The pipeline does not error; it indexes nothing, and the failure only shows up as bad answers weeks later.

Both the heading structure and the page boundaries are worth recovering. Headings are what make a chunk self-explanatory. Page numbers are what let an answer cite its source.

## PDF chunking strategy for RAG: the four approaches

### 1. Text extract, then recursive split (LangChain)

The default, and the right baseline to measure everything else against. `PyPDFLoader` returns one `Document` per page and the splitter copies each page's metadata onto every chunk it produces.

```bash
pip install langchain-community pypdf langchain-text-splitters
```

```python
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# mode="page" is the default: one Document per page.
# extraction_mode="layout" preserves the visual arrangement — worth it on multi-column PDFs.
loader = PyPDFLoader("report.pdf", mode="page", extraction_mode="layout")
pages = loader.load()

splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = splitter.split_documents(pages)

print(chunks[0].metadata)  # {'source': 'report.pdf', 'page': 0, 'page_label': '1', 'total_pages': 42}
```

What you get: page numbers for free, no configuration, and cuts that fall wherever the character budget runs out. Headings end up separated from the text they govern, and tables are cut mid-row. The LlamaIndex equivalent behaves the same way:

```python
from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import SentenceSplitter

documents = SimpleDirectoryReader(input_dir="./pdfs").load_data()
nodes = SentenceSplitter(chunk_size=1024, chunk_overlap=20).get_nodes_from_documents(documents)
```

`SentenceSplitter` at least respects sentence boundaries, which removes the most obvious damage. It still knows nothing about headings.

### 2. Convert to Markdown, then split on headers

A clear improvement when the PDF is a digital-born document with consistent typography. `pymupdf4llm` converts a PDF to Markdown, detects tables, and can leave out headers and footers.

```bash
pip install pymupdf4llm langchain-text-splitters
```

```python
import pymupdf4llm
from langchain_text_splitters import MarkdownHeaderTextSplitter

# page_chunks=True returns a list of dicts, one per page, with keys
# metadata, toc_items, tables, images, graphics and text.
pages = pymupdf4llm.to_markdown("report.pdf", page_chunks=True, table_strategy="lines_strict")

splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
)

docs = []
for page in pages:
    for doc in splitter.split_text(page["text"]):
        doc.metadata["page"] = page["metadata"]["page_number"]  # keep the citation
        docs.append(doc)
```

Cuts now land on structural boundaries and each chunk's metadata carries the headers above it. Two limits remain. Heading detection depends on the source typography, so design-heavy and scanned documents under-mark badly. And because you split page by page, a section that spans pages 12 to 14 becomes three unrelated fragments.

### 3. Layout parser, then element chunking

Run a document AI engine that returns typed elements — title, paragraph, table, figure, with bounding boxes — then pack those elements into chunks up to a size limit while respecting section and page boundaries. This is the strongest option for scans, forms and dense business documents, because the engine reads the page as a layout rather than as a text stream.

The engines each have their own output shape, and PrimeCut ingests those results directly instead of re-running OCR: [AWS Textract](/optimal-chunker-textract), [Azure Document Intelligence](/optimal-chunker-azure-document-intelligence), [Docling](/optimal-chunker-docling), [LlamaParse](/optimal-chunker-llamaparse), [Marker](/optimal-chunker-marker), [Mistral OCR](/optimal-chunker-mistral-ocr), [PaddleOCR-VL](/optimal-chunker-paddleocr-vl) and [Unstructured](/optimal-chunker-unstructured). Engines outside that list that emit Markdown can be ingested as a `.md` file.

Element chunking gets the boundaries right. What it does not give you is hierarchy: the elements are typed and located, but they are flat and per-page, so a `title` element on page 41 has no link to the chapter that opened on page 3.

### 4. Structure-first chunksets

The fourth option changes the retrieval unit instead of the cut point. PrimeCut parses the PDF itself, infers the document hierarchy across pages, and returns chunks grouped into chunksets — root-to-leaf paths where every sentence keeps its chapter, section and paragraph context. There is no chunk size to tune and no overlap to add, because the boundary is structural rather than positional.

It also drops into an existing LangChain pipeline in place of the character splitter:

```bash
pip install 'poma[langchain]'
```

```python
from poma import PrimeCut
from poma.integrations.langchain import PomaFileLoader, PomaChunksetSplitter

documents = PomaFileLoader("./pdfs").load()
chunkset_docs = PomaChunksetSplitter(PrimeCut(), verbose=True).split_documents(documents)

# Each Document holds the chunkset text in page_content and its source chunks in metadata.
first_chunk = chunkset_docs[0].metadata["chunks"][0]
# raw chunk record; the typed PomaChunk has no page field
print(first_chunk["page"], first_chunk["depth"])

vector_store.add_documents(chunkset_docs)  # your store, unchanged
```

Full details in the [LangChain integration docs](/sdk/integrations/langchain); the LlamaIndex equivalents are `PomaFileReader` and `PomaChunksetNodeParser`.

## Which approach keeps what

| Approach | Keeps headings | Tables whole | Page numbers | Handles scans | Effort |
| --- | --- | --- | --- | --- | --- |
| Text extract + recursive split | ✗ | ✗ | ✓ per page | ✗ | Lowest |
| Markdown convert + header split | ✓ within a page | Usually | Manual, per page | ✗ | Low |
| Layout parser + element chunking | Per-page elements only | ✓ | ✓ | ✓ | Medium, plus engine cost |
| PrimeCut chunksets | ✓ across pages | ✓ | ✓ per chunk | ✓ | Lowest, one call |

## Chunk size and overlap for PDFs

The honest version: there is no size that is right for all documents, and tuning it is a proxy for the structure you did not recover.

The common baseline is 512 tokens with 50 to 100 tokens of overlap, roughly 10 to 20 percent. Concise fact lookup often improves at 128 to 256 tokens; questions that need surrounding argument often improve at 512 to 1024. Overlap exists to soften the damage of arbitrary cuts, and it costs you a larger index and near-duplicate results crowding your top-k.

PDFs add one specific interaction. A dense page is often several hundred tokens, so a chunk size under that means most chunks sit entirely inside a page, below the heading that governs them. Raising the size to catch the heading makes retrieval coarser. That tension is the reason structure-aware approaches exist. The full parameter discussion, with the study data, is in the [chunking strategies guide](/rag-chunking-strategies-text-splitters).

## Tables in PDFs

A table is the clearest case where character-based cutting fails outright. Cut a financial table at 1,000 characters and the piece holding the numbers no longer holds the header row naming the columns. Embedded, that fragment is a list of figures with no referent, and it will be retrieved for the wrong queries.

The rule is simple: a table is one chunk. Serialize it as Markdown or HTML, embed the whole thing, and keep it out of the general text splitter. When a table exceeds the embedding model's limit, partition it on row boundaries and repeat the header rows in every part. PrimeCut does this by default: tables stay table units, and oversized tables are split by rows with the detected header rows included in each piece.

## Page numbers and citations

If your answers need to say "page 14", the page number has to survive every hop from parser to vector store payload.

With `PyPDFLoader` you get this for free in the default `mode="page"`: each page Document carries `page` (zero-based) and `page_label` (the printed label, which differs whenever a document has roman-numeral front matter), and `split_documents` copies that metadata onto every chunk. Store both in your payload and index the numeric one for range filters.

With `pymupdf4llm` the number is in `page["metadata"]["page_number"]` when you pass `page_chunks=True`, and you must attach it yourself, as in the snippet above.

PrimeCut assigns a page to every chunk during chunking, from page markers threaded through the conversion stage, and the pipeline raises rather than emitting a chunk it cannot place on a page. A chunk without a page number never reaches your index.

## Scanned PDFs

If the text layer is missing, nothing above helps. Check before you build anything: extract the first few pages and look at the strings. Empty output, or a few characters per page, means a scan.

Scans need OCR, then chunking of the OCR output as a separate step. The engine choice is yours and mostly about cost, latency and data locality. Keep the OCR result in its raw JSON rather than a flattened string, because that is what still contains page indexes, table side-channels and image references. See the [OCR for RAG guide](/guides/ocr-for-rag/) for engine selection, and the per-engine pages above for chunking each engine's output without re-running OCR.

## Frequently asked questions

### What is the best way to chunk PDF for RAG?

Recover the document's structure first, then cut on that structure. Cutting raw extracted text by character count is the one approach that is wrong for every PDF. In practice: if the file has real headings, convert it to Markdown and split on the headers; if it is a scan or a layout-heavy document, run OCR or a layout parser first; if you want parsing and chunking in one step, PrimeCut parses the PDF itself and returns chunks and chunksets that keep their heading path and their page number. Whatever you pick, keep tables whole and keep a page number on every chunk.

### Is there a chunk-PDF-for-RAG Python snippet I can copy?

Two lines of setup either way. With LangChain: `PyPDFLoader("report.pdf").load()` returns one Document per page, then `RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200).split_documents(pages)` cuts them, and each chunk inherits the page metadata. With PrimeCut: `PrimeCut().ingest("report.pdf")` parses and chunks the PDF in one call and returns `result.chunks` and `result.chunksets`, where every chunkset has an embedding-ready `to_embed` string.

### How do I do PDF chunking with LangChain?

Use `PyPDFLoader` from `langchain_community.document_loaders` with `RecursiveCharacterTextSplitter` from `langchain_text_splitters`. The loader defaults to `mode="page"` and returns one Document per page with `source`, `page`, `page_label` and `total_pages` in metadata; the splitter copies that metadata onto every chunk it cuts. Set `extraction_mode="layout"` to preserve the visual arrangement of the page, which helps with two-column documents. For structure-aware chunks inside the same pipeline, `PomaChunksetSplitter` is a drop-in replacement for the character splitter.

### What chunk size should I use for PDF chunking in RAG?

512 tokens with 50 to 100 tokens of overlap is the common baseline, and it is a starting point rather than an answer. Concise fact lookup often does better at 128 to 256 tokens; questions needing broader context do better at 512 to 1024. PDFs add one wrinkle: a chunk size below the size of an average page means most chunks cannot see the heading that governs them, so the size you pick interacts with how much structure your parser recovered. Structure-first chunking sidesteps the parameter, because the boundary is the document's own hierarchy.

### How do I keep page numbers on PDF chunks for citations?

Carry the page from the loader into the chunk metadata and then into your vector store payload. `PyPDFLoader` puts `page` and `page_label` on each page Document, and the text splitter copies that metadata to every chunk cut from it, so no work is needed beyond storing it. PrimeCut assigns a page to every chunk record during chunking and fails loudly if the conversion stage did not emit page markers, so a chunk without a page number never reaches your index.

### How do I chunk tables in a PDF for RAG?

Treat a table as its own chunk instead of letting the text splitter walk through it. A character splitter cuts between rows, and the fragment that carries the numbers no longer carries the header row that names the columns, which makes it unusable in retrieval. Extract the table as Markdown or HTML, embed it as one unit, and if it is too large for the embedding model, split it on row boundaries and repeat the header rows in each piece. PrimeCut does exactly that: tables stay table units, and oversized ones are partitioned by rows with the header rows included in every part.

### How do I do RAG on a scanned PDF?

A scanned PDF has no text layer, so a text extractor returns empty strings or garbage and the chunker silently indexes nothing. Run OCR first, then chunk the OCR output. Any strong OCR engine works. If you already run one, PrimeCut ingests the result JSON of Mistral, LlamaParse, Azure Document Intelligence, Unstructured, Docling, Marker, Textract and PaddleOCR-VL directly, so you chunk without paying for OCR twice.

### Does semantic chunking work well on PDFs?

Semantic chunking places boundaries where the embedding similarity between consecutive sentences drops. It works on the text stream it is given, so it inherits every extraction error above it: if a two-column page was read straight across, the sentence sequence is already scrambled and the similarity signal is noise. On a clean single-column PDF it is a reasonable improvement over fixed-size cutting. It still produces isolated fragments with no heading context, and it costs an embedding pass at ingest time.

## Continue reading

- [The Ultimate Guide to RAG Chunking Strategies & Text Splitters](/rag-chunking-strategies-text-splitters) — every strategy, across every format, with the comparison table
- [Document Ingestion & Chunking for RAG](/document-ingestion-chunking-rag) — the whole pipeline, from file to retrieval
- [POMA chunksets](/learn/chunking/chunksets) — what a root-to-leaf retrieval unit looks like
- [OCR for RAG](/guides/ocr-for-rag/) — engine selection for scans and image-only PDFs
- [Pipeline recipes](/pipelines/) — wiring chunks into the vector database you already run

<AuthorBio />