Source: http://www.poma-ai.com/docs/optimal-chunker-unstructured

# Unstructured Chunking for RAG: From Elements to Retrieval Units

<ByAuthor />

Unstructured chunking has two halves, and only one of them is in the library. Partitioning is the hard half, and Unstructured does it very well: `partition()` turns a PDF, DOCX, HTML page or email into a flat, ordered list of typed elements with page numbers, table HTML and stable ids. The second half is deciding what a single embedding vector should represent. The built-in strategies answer that with a character budget and a section boundary, which is a reasonable default and still leaves every chunk unable to say which chapter it came from.

This page covers both halves with real API names: what `partition()` returns, what `basic`, `by_title`, `by_page` and `by_similarity` each do and with which parameters, and how to chunk the raw element list so that each retrieval unit keeps its heading path, its tables and its page numbers.

## Quick start: partition, save the elements, chunk the JSON

Run partitioning exactly as you do today. Save the element list. Hand the JSON to PrimeCut, which auto-detects the Unstructured shape.

```bash
pip install "unstructured[pdf]" poma
```

```python
from unstructured.partition.pdf import partition_pdf
from unstructured.staging.base import elements_to_json
from poma import PrimeCut

# 1. Your existing Unstructured call, unchanged. No chunking_strategy here.
elements = partition_pdf(
    filename="contract.pdf",
    strategy="hi_res",
    infer_table_structure=True,            # populates metadata.text_as_html
    extract_image_block_types=["Image"],
    extract_image_block_to_payload=True,   # inline base64 instead of files on disk
)
elements_to_json(elements, filename="contract.unstructured.json")

# 2. Chunk the raw element list. The parsing is not repeated.
client = PrimeCut()  # reads POMA_API_KEY
result = client.ingest("contract.unstructured.json")

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

The hosted Unstructured API works the same way: write the response's element list to a JSON file and ingest that file. Both a bare list and an `{"elements": [...]}` envelope are accepted.

Saving the element list before chunking is worth the extra file regardless of which chunker you end up using. Partitioning is the expensive, slow, GPU-shaped step. Chunking is the decision you will revisit after your first evaluation run, and you do not want to re-run `hi_res` inference to change `max_characters`.

## What `partition()` returns

The output is a flat, ordered list of typed elements. Element order is document order, which means Unstructured has already solved reading order across columns and pages.

```json
[
  {
    "type": "Title",
    "text": "Financial highlights",
    "element_id": "8f4a2c…",
    "metadata": { "page_number": 3, "category_depth": 1, "filename": "report.pdf" }
  },
  {
    "type": "Table",
    "text": "Revenue 2025 …",
    "element_id": "b91e07…",
    "metadata": { "page_number": 3, "text_as_html": "<table>…</table>" }
  }
]
```

The element types you will meet most often are `Title`, `NarrativeText`, `ListItem`, `Table`, `Image`, `Header`, `Footer`, `PageNumber` and `PageBreak`. The metadata fields that matter downstream:

| Field | What it carries |
| --- | --- |
| `page_number` | The source page, per element. This is what lets an answer cite a page later. |
| `text_as_html` | The table as HTML, with rows and cells intact. Requires `infer_table_structure=True`. |
| `image_base64` | Inline figure bytes, when `extract_image_block_to_payload=True`. |
| `image_path` | A file on disk, when `extract_image_block_output_dir` is set instead. |
| `parent_id` | Unstructured's own words: it "may be used to infer where an element resides within the overall hierarchy of a document". |
| `category_depth` | "The depth of an element relative to other elements of the same category", set by the partitioner. |
| `coordinates` | Bounding boxes, useful for debugging layout, noise in an index. |

Two things follow from that list. First, the typing is genuinely valuable: knowing a block is a `Table` and not a paragraph is exactly what a plain PDF-to-text pass destroys. Second, `parent_id` and `category_depth` are hints attached to individual elements, not a document tree. Whether they are populated depends on the partitioner and on the hierarchy post-processor, and no chunker turns them into text a language model can read.

## Unstructured chunking strategies, and what each one does

Chunking is a separate pass over the elements. Run it inline by passing `chunking_strategy` to `partition`, or afterwards by calling the chunkers directly. Four strategies exist:

| Strategy | Boundary rule | Availability |
| --- | --- | --- |
| `basic` | Fills chunks with consecutive elements up to the size limits | Open source, API, Platform |
| `by_title` | Also closes the chunk when a new section starts | Open source, API, Platform |
| `by_page` | Never combines elements from different pages | API and Pipelines only |
| `by_similarity` | Groups topically similar consecutive elements by embedding similarity, `similarity_threshold` default `0.5` | API and Platform only |

The parameters are shared:

- `max_characters` (open-source default `500`) is the hard cap. No chunk exceeds it.
- `new_after_n_chars` is the soft cap. Once a chunk passes it, no further elements are added, but the chunk is not split.
- `combine_text_under_n_chars` applies to `by_title` and defaults to `max_characters`. Sections shorter than this are merged with the next one.
- `overlap` characters are carried across the splits of an oversized element. `overlap_all` extends that overlap to every chunk boundary, not just split ones.
- `multipage_sections` applies to `by_title` and defaults to `True`. Set it to `False` and a section is closed at a page break.

Called directly, `by_title` looks like this:

```python
from unstructured.chunking.title import chunk_by_title

chunks = chunk_by_title(
    elements,
    max_characters=1500,
    new_after_n_chars=1200,
    combine_text_under_n_chars=400,
    overlap=0,
    multipage_sections=True,
)

for chunk in chunks:
    print(type(chunk).__name__, chunk.metadata.page_number, len(chunk.text))
```

The output types are `CompositeElement` for text, `Table` for a table that fits inside `max_characters`, and `TableChunk` for one that does not and gets text-split. A `CompositeElement` keeps its constituents in `metadata.orig_elements`, so the original typed elements and their `page_number` and `coordinates` survive the chunking pass and can be recovered with `elements_from_base64_gzipped_json`.

`by_title` is a real structure-aware chunker, and it is a clear improvement over a character splitter run over concatenated text. Section boundaries are respected. Tables are kept as their own elements rather than sliced into the surrounding prose. That is more than most chunking libraries offer.

## Where CompositeElements stop

The limit is not the boundary rule. It is what ends up inside the unit you embed.

A `CompositeElement` is an isolated unit. Its text starts at a section heading and ends at a character budget, and nothing in that text says which chapter the section belongs to. When the chunk is embedded and later retrieved, the model reads a paragraph about early termination without knowing it sits inside a master services agreement, under termination clauses, in the section that only applies to enterprise customers.

Three specific consequences:

- **A section's chunk does not carry its chapter path.** `by_title` gives you the nearest `Title`. The `Title` above that one is not in the chunk. `parent_id` might point at it, but as an id in metadata, not as context in the embedded text.
- **Size caps split sections again.** A section longer than `max_characters` becomes several chunks, and the tail ones lose even the nearest-title link. `overlap` mitigates the seam by duplicating characters, which costs storage and crowds the top-k with near-duplicate vectors.
- **Page rules cut structure.** `multipage_sections=False` and `by_page` both close chunks at page boundaries, which is fine for citation and wrong for a section that runs across three pages.

This is the fragment problem, and it is where most Unstructured-based RAG pipelines lose quality, long after the partitioning was already correct. The [chunking strategies guide](/rag-chunking-strategies-text-splitters) has the full taxonomy, and [common failure modes](/learn/chunking/common-failure-modes) shows how each one reads at retrieval time.

## Hierarchy-aware chunking on the element list

PrimeCut is an ingestion and chunking engine. Given a PDF it parses the document itself and is a direct alternative to a text splitter. If you already run Unstructured, the bring-your-own-OCR path takes that engine's output instead and runs only the downstream stages, so you pay for chunking rather than for parsing twice.

What it does with an Unstructured element list, field by field:

1. **Shape validation up front.** The payload is fingerprinted on elements carrying `type` and `element_id`. A payload that detects as a different tool fails immediately with a 422 rather than degrading the index silently.
2. **Grouping by `metadata.page_number`.** Elements are collected per page in document order, so page numbers survive onto every chunk.
3. **Heading levels from `category_depth`.** Unstructured tags every heading as `Title`, including the document title. `category_depth` is read and mapped to a markdown heading level (`0` becomes `#`, `1` becomes `##`, capped at six), so a three-level document does not collapse into a flat wall of `##`. When `category_depth` is missing, the element becomes a section heading rather than an assumed document title.
4. **Tables via `text_as_html`.** The HTML is used instead of the flattened `text`, so rows and columns reach the chunk layer intact and an embedding never sees half a table.
5. **Images in both Unstructured cases.** Inline `image_base64` on an `Image` or `Figure` element is turned into a data URI and described, which makes figures searchable text; the MIME type is sniffed from the base64 prefix when `image_mime_type` is absent. Bytes offloaded to disk by `extract_image_block_output_dir` cannot be described, so they are counted in `content_metadata` and the dangling reference is neutralized. The loss is visible, never silent.
6. **Page furniture out of the body, into metadata.** `Header`, `Footer`, `PageNumber` and `PageBreak` elements are removed from the text, and their snippets are recorded with the pages they appeared on. Unstructured already identified this furniture; that work is honoured instead of repeated.
7. **Tree, then chunks and chunksets.** Under-marked headings are restored, the heading tree is rebuilt across page boundaries, and every sentence becomes a chunk with its `chunk_index` and `depth`. Chunks are grouped into [chunksets](/learn/chunking/chunksets): root-to-leaf paths through the hierarchy, and it is the chunkset that carries the embedding-ready `to_embed`, so each retrieved passage explains itself without overlap.

`parent_id` is not used, because `category_depth` plus document order is the more reliable signal in practice, and the tree is rebuilt rather than inherited.

One illustration of what the path is worth: on a legal document in our reference set, the same question was answered from 337 tokens of retrieved context with chunksets versus 1,542 with a recursive character splitter, with nothing lost. One document is an illustration, not a benchmark; the [ingestion guide](/document-ingestion-chunking-rag) has the broader numbers.

To force or suppress detection, set `external_ocr_source` to `"unstructured"` or `"none"` on the ingest request. The API also exposes `chunk_external_ocr_result` for explicit routing ([API reference](https://api.poma-ai.com/v3/docs)).

## An Unstructured RAG pipeline, end to end

Partition, chunk, embed, retrieve. Qdrant here because the SDK ships an integration for it; every other store is plain client code over `chunkset.to_embed`.

```bash
pip install "unstructured[pdf]" "poma[qdrant]"
```

```python
import os
from unstructured.partition.pdf import partition_pdf
from unstructured.staging.base import elements_to_json
from poma import PrimeCut
from poma.integrations.qdrant import PomaQdrant

elements = partition_pdf(
    filename="contract.pdf",
    strategy="hi_res",
    infer_table_structure=True,
    extract_image_block_types=["Image"],
    extract_image_block_to_payload=True,
)
elements_to_json(elements, filename="contract.unstructured.json")

result = PrimeCut().ingest("contract.unstructured.json")

qdrant = PomaQdrant(
    url=os.environ["QDRANT_URL"],
    api_key=os.environ["QDRANT_API_KEY"],
    cloud_inference=False,  # False embeds locally via fastembed; works on OSS Qdrant
    collection_name="contracts",
    dense_model="sentence-transformers/all-MiniLM-L6-v2",
    sparse_model="Qdrant/bm25",
    dense_size=384,
    auto_create_collection=True,
)
qdrant.upsert_poma_points(result)

cheatsheets = qdrant.get_cheatsheets(
    query="What are the early termination conditions?",
    limit=3,
)
print(cheatsheets[0]["content"])
```

Inside a LangChain application the same result arrives as `Document` objects:

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

client = PrimeCut()
documents = PomaFileLoader("./contract.unstructured.json").load()
chunkset_docs = PomaChunksetSplitter(client).split_documents(documents)
```

LlamaIndex has the mirror image in `PomaFileReader` and `PomaChunksetNodeParser`. Details: [LangChain](/sdk/integrations/langchain), [LlamaIndex](/sdk/integrations/llamaindex), [Qdrant](/sdk/integrations/qdrant). For any other store, embed `chunkset.to_embed` with your own model and upsert with `file_id`, `depth` and `chunk_index` as metadata — page attribution lives on the archive's chunk records; the [Unstructured to Qdrant recipe](/pipelines/unstructured-to-qdrant) shows the field mapping in full.

**Multimodal documents.** The reason to set `extract_image_block_to_payload=True` is that it is the only case where figure content can still be recovered downstream. With the bytes inline, each figure is described and the description is embedded like any other text, so a chart or a diagram becomes retrievable. With `extract_image_block_output_dir`, the bytes live in a directory that never reaches the chunker, and the figure can only be counted. Decide that at partition time, because it cannot be fixed later without re-running the parse.

## Chunking options for an Unstructured element list, compared

| Approach | Boundaries | Ancestor path in the unit | Tables | Images | Page attribution |
| --- | --- | --- | --- | --- | --- |
| Flatten to text, character splitter | Character count | No | Cut mid-row | Dropped | No |
| `basic` | Element + size caps | No | Own element, split if oversized | Not described | In metadata |
| `by_title` | Section starts + size caps | Nearest `Title` only | Own element, split if oversized | Not described | In metadata |
| `by_similarity` | Embedding similarity | No | Own element | Not described | In metadata |
| PrimeCut on the element list | Rebuilt heading tree | Full root-to-leaf path | `text_as_html`, kept whole | Described, or counted | Kept in the parse (archive chunk records) |

## Frequently asked questions

### What is the best Unstructured chunking strategy?

Among the built-in strategies, `by_title` is the best default: it closes a chunk when a new section starts, so a chunk never straddles a section boundary. `basic` only fills to a character budget, `by_page` keeps pages apart, and `by_similarity` groups by embedding similarity. All four emit `CompositeElement` objects that carry their own text and their original elements, but no path to the sections above them. If retrieval quality matters more than the character budget, chunk the element list with a hierarchy-aware chunker instead and keep the heading path inside every unit.

### Unstructured chunking by_title: how does it work?

`chunk_by_title` walks the element list in document order and starts a new chunk whenever a `Title` element begins a new section. `combine_text_under_n_chars` merges sections that are too small to stand alone, `max_characters` is the hard cap, `new_after_n_chars` is the soft cap, and `multipage_sections` (default `True`) decides whether one section may span a page break. Oversized elements are split by character count, with `overlap` characters carried across the split. The result is a list of `CompositeElement`, `Table` and `TableChunk` objects.

### Unstructured semantic chunking: is it supported?

Yes, as the `by_similarity` strategy, which embeds elements and groups topically similar consecutive ones, controlled by `similarity_threshold` (default `0.5`). It is available on the Unstructured API and Platform, not in the open-source chunkers. It is a good way to keep related prose together, but it is still a grouping pass over a flat list: the resulting chunk knows which elements it contains, not which chapter contains it.

### Can I do contextual chunking with Unstructured?

Not with a built-in strategy. Contextual chunking means each retrieval unit ships with the context needed to read it alone, and Unstructured's chunkers do not prepend section or chapter context to chunk text. You can build it yourself by walking `parent_id` or `category_depth` and prefixing headings before embedding, or you can hand the element list to PrimeCut, which rebuilds the heading tree and returns chunksets: root-to-leaf paths where every sentence keeps its chapter, section and paragraph context.

### How do I chunk a PDF with Unstructured?

Call `partition_pdf` with `strategy="hi_res"` and `infer_table_structure=True` so tables arrive with `metadata.text_as_html`, and add `extract_image_block_types=["Image"]` with `extract_image_block_to_payload=True` so figures arrive as inline base64 instead of files on disk. Then either pass `chunking_strategy="by_title"` to `partition`, or save the elements with `elements_to_json` and chunk the JSON downstream. Saving the JSON is the better habit: the partition run is the expensive part, and the chunking decision is the one you will want to change later.

### Can I chunk Unstructured output without re-running partition?

Yes. PrimeCut's bring-your-own-OCR path accepts the saved element list as-is, so the parsing you already paid for is not repeated: only the downstream structure, chunking and retrieval-preparation stages run. A bare list or an `{"elements": [...]}` envelope both work, the Unstructured shape is auto-detected from elements carrying `type` and `element_id`, and `external_ocr_source="unstructured"` forces the routing when you prefer to be explicit.

### What happens to tables and images from Unstructured during chunking?

PrimeCut uses each `Table` element's `metadata.text_as_html` rather than its flattened text, so rows and columns reach the chunk layer as HTML and a table is never cut mid-row. `Image` and `Figure` elements with inline `image_base64` are decoded and described, which makes figures searchable text; the base64 payload is sniffed for JPEG or PNG when `image_mime_type` is missing. Images written to disk by `extract_image_block_output_dir` are outside the payload, so they are counted in `content_metadata` instead of being silently dropped.

### Do `parent_id` and `category_depth` give me document hierarchy?

They are hints, not a tree. Unstructured documents `parent_id` as a way to infer where an element resides in the document hierarchy, and `category_depth` as the depth of an element relative to other elements of the same category. Both are per-element fields, both depend on the partitioner, and neither is turned into readable context by the chunkers: a `CompositeElement`'s text still starts mid-document. PrimeCut reads `category_depth` to reconstruct heading levels, then rebuilds the tree across pages so each chunk carries its full ancestor path.

## Take it straight to your vector database

End-to-end recipes for Unstructured.io + POMA + your store:

- [Unstructured.io → Chroma](/pipelines/unstructured-to-chroma)
- [Unstructured.io → Elasticsearch](/pipelines/unstructured-to-elasticsearch)
- [Unstructured.io → LanceDB](/pipelines/unstructured-to-lancedb)
- [Unstructured.io → Milvus](/pipelines/unstructured-to-milvus)
- [Unstructured.io → MongoDB Atlas](/pipelines/unstructured-to-mongodb-atlas)
- [Unstructured.io → OpenSearch](/pipelines/unstructured-to-opensearch)
- [Unstructured.io → pgvector](/pipelines/unstructured-to-pgvector)
- [Unstructured.io → Pinecone](/pipelines/unstructured-to-pinecone)
- [Unstructured.io → Qdrant](/pipelines/unstructured-to-qdrant)
- [Unstructured.io → Redis](/pipelines/unstructured-to-redis)
- [Unstructured.io → Turbopuffer](/pipelines/unstructured-to-turbopuffer)
- [Unstructured.io → Vespa](/pipelines/unstructured-to-vespa)
- [Unstructured.io → Weaviate](/pipelines/unstructured-to-weaviate)

## The rest of the series

The optimal chunker, for every OCR and parsing tool you already run:

- [AWS Textract](/optimal-chunker-textract)
- [Azure Document Intelligence](/optimal-chunker-azure-document-intelligence)
- [DeepSeek-OCR](/optimal-chunker-deepseek-ocr)
- [Docling](/optimal-chunker-docling)
- [LlamaParse](/optimal-chunker-llamaparse)
- [Marker](/optimal-chunker-marker)
- [MinerU](/optimal-chunker-mineru)
- [Mistral OCR](/optimal-chunker-mistral-ocr)
- [PaddleOCR-VL](/optimal-chunker-paddleocr-vl)

Or start with the fundamentals: [RAG chunking guide](/guides/rag-chunking/) · [Document ingestion guide](/guides/document-ingestion/) · [POMA chunksets](/learn/chunking/chunksets).