Back to blog

Docling Chunking: HybridChunker vs HierarchicalChunker on Real PDFs

Docling 2.145's HierarchicalChunker and HybridChunker, plus Markdown splitters and a Markdown round trip, run on the same five PDFs. Chunk sizes, tables, headings, runtime and failure modes, measured.

HybridChunker is the Docling chunker that respects a token limit, and in our tests it worked best with one change from its defaults: Markdown tables. On a 60-page report with a 256-token limit, the default triplet tables gave 5,592 chunks and 1.2 million tokens in 383 seconds. The same chunker with Markdown tables gave 1,107 chunks and 233,000 tokens in 2.3 seconds. HierarchicalChunker has no size limit, and its largest chunk on that report was 197,486 tokens. If you only have Docling’s Markdown, you can feed it back through Docling and run HybridChunker on the result. Chunk sizes stay almost the same, and page numbers are lost.

We ran both chunkers from Docling 2.145.0 and docling-core 2.105.0 on five PDFs, along with fixed-size splitting and LangChain’s Markdown splitters on the same documents’ Markdown export. We measured chunk sizes, table handling, heading context and runtime. We did not measure retrieval quality, so nothing below says which method retrieves better. Disclosure: Parsebridge, which publishes this blog, sells a hosted Docling API.

Docling chunking at a glance

HierarchicalChunkerHybridChunkerFixed-size splitMarkdown headers plus recursive split
InputDoclingDocumentDoclingDocumentMarkdown textMarkdown text
Size limitNonemax_tokens on the tokenizerWindow sizechunk_size
Default table formatTriplet text, one chunk per tableTriplet text, split to fitWhatever the Markdown hasWhatever the Markdown has
Heading contextmeta.headings, added by contextualize()meta.headings, added by contextualize()Only if a heading falls inside the windowIn metadata, removed from the text by default
Page numbersYes, through doc_itemsYes, through doc_itemsNoNo
Report, 256 tokens: chunks4285,592 (1,107 with Markdown tables)621881
Report, 512 tokens: chunks428704 (450 with Markdown tables)241321

Hosted Docling API

Parsebridge, which runs this blog, is a hosted Docling API. You send a PDF as a file upload or a URL and get Markdown back in a JSON response, with no servers, GPUs or model downloads to manage. It does not return chunks or a DoclingDocument, so you chunk the Markdown yourself, and the Markdown section below covers how that went in our tests. Signing up gives you 50 free pages, and plans start at $17 a month for 5,000 pages.

Set up HybridChunker

pip install docling includes what you need for Hugging Face tokenizers. For OpenAI tokenizers, also run pip install "docling-core[chunking-openai]". If you are new to Docling itself, start with our Python guide. This is the setup we would start from:

import tiktoken
from docling.chunking import HybridChunker
from docling.document_converter import DocumentConverter
from docling_core.transforms.chunker.hierarchical_chunker import (
    ChunkingDocSerializer,
    ChunkingSerializerProvider,
)
from docling_core.transforms.chunker.tokenizer.openai import OpenAITokenizer
from docling_core.transforms.serializer.markdown import MarkdownTableSerializer


class MarkdownTables(ChunkingSerializerProvider):
    def get_serializer(self, doc):
        return ChunkingDocSerializer(doc=doc, table_serializer=MarkdownTableSerializer())


doc = DocumentConverter().convert("report.pdf").document

# cl100k_base is what tiktoken lists for text-embedding-3-small and -large
tokenizer = OpenAITokenizer(tokenizer=tiktoken.get_encoding("cl100k_base"), max_tokens=512)
chunker = HybridChunker(tokenizer=tokenizer, serializer_provider=MarkdownTables())

for chunk in chunker.chunk(doc):
    text_to_embed = chunker.contextualize(chunk)  # headings + chunk.text
    pages = sorted({p.page_no for item in chunk.meta.doc_items for p in item.prov})

For a Hugging Face embedding model, use HuggingFaceTokenizer instead:

from docling_core.transforms.chunker.tokenizer.huggingface import HuggingFaceTokenizer
from transformers import AutoTokenizer

tokenizer = HuggingFaceTokenizer(
    tokenizer=AutoTokenizer.from_pretrained("sentence-transformers/all-MiniLM-L6-v2"),
    max_tokens=254,
)

We use 254 there because Docling counts tokens without the [CLS] and [SEP] markers. In our check, a MiniLM tokenizer call with max_length=256 kept 256 ids including those two markers, which leaves 254 for text. At a limit of 256, 20 of the report’s 1,107 chunks were over 254 tokens, so a model that truncates at 256 would lose their last tokens. With no arguments, HybridChunker() resolved to this MiniLM tokenizer and 256 tokens.

Passing HybridChunker(tokenizer="model-id", max_tokens=128) still works and raises a DeprecationWarning. During counting, transformers also logged Token indices sequence length is longer than the specified maximum sequence length. That warning came from token counting on long text, and none of the HybridChunker chunks went over the limit in our runs.

How we tested

We used the five PDFs from our Docling vs Unstructured test:

The machine was a Mac mini with an Apple M4 and 16 GB RAM, on macOS 26.6 and Python 3.12.15. We installed Docling 2.145.0, docling-core 2.105.0, transformers 5.22.1, semchunk 3.2.5, tiktoken 0.14.0 and langchain-text-splitters 1.1.3. Docling converted each PDF once with DocumentConverter() and no options, and every method then worked from that same document and its export_to_markdown() text.

We ran two token budgets. One was sentence-transformers/all-MiniLM-L6-v2 at 256 tokens, which is the HybridChunker default. The other was tiktoken’s cl100k_base at 512 tokens. The methods were:

  • HierarchicalChunker() with defaults. It takes no tokenizer or limit, so we only used the tokenizer to count its output.
  • HybridChunker(tokenizer=...) with defaults, which uses triplet tables, and the same chunker with the Markdown table serializer shown above.
  • A fixed-size split of the Markdown, cut every 256 or 512 tokens with no overlap.
  • LangChain’s MarkdownHeaderTextSplitter on #, ## and ### with defaults, alone and followed by RecursiveCharacterTextSplitter at the same token budget with no overlap, which is the combination LangChain’s docs show for size limits.

Token counts are for the text you would embed, which is contextualize() for Docling chunkers and page_content or the raw slice for the Markdown methods. Each method ran twice per document in one process and we report the second run, except that a first run over 60 seconds was reported as it was. We started the main pass with the one-minute load average below 3.

Results

The 60-page report at 256 tokens with the MiniLM tokenizer:

MethodChunksMedianp95MaxTotal tokensSeconds
HierarchicalChunker4283318,642197,4861,122,2220.23
HybridChunker, default triplet tables5,5922152562561,207,087383.4
HybridChunker, triplets, repeat_table_header=False5,7142062532561,192,4315.0
HybridChunker, Markdown tables1,107224252256232,9062.3
Fixed-size split of the Markdown621256256256158,8100.14
MarkdownHeaderTextSplitter831336,63315,994158,0890.01
Header splitter plus recursive split881186256256158,0890.83

The same report at 512 tokens with cl100k_base:

MethodChunksMedianp95MaxTotal tokensSeconds
HierarchicalChunker428337,85419,883309,5910.15
HybridChunker, default triplet tables704510511512317,4446.8
HybridChunker, triplets, repeat_table_header=False712502512512315,7501.3
HybridChunker, Markdown tables450454506512178,3941.1
Fixed-size split of the Markdown241512512512123,1240.04
MarkdownHeaderTextSplitter831225,48612,402122,7470.01
Header splitter plus recursive split321449501510122,7290.13

The 10-page paper at 256 tokens with the MiniLM tokenizer:

MethodChunksMedianp95MaxTotal tokensSeconds
HierarchicalChunker761223821,34812,3490.01
HybridChunker, default triplet tables6221925525612,2980.14
HybridChunker, Markdown tables6621625225412,7870.12
Fixed-size split of the Markdown4625625625711,6300.01
MarkdownHeaderTextSplitter154181,9322,65111,512under 0.01
Header splitter plus recursive split6221125525611,5120.06

The scan and the form are short. HybridChunker gave 3 chunks for the scan and 9 for the form at 256 tokens, and the largest chunk from any size-limited method on either document was at or under the limit.

The two tokenizers count this report very differently because of its dot leaders, the runs of dots between a row label and its values. The MiniLM tokenizer counts each dot as one token, while cl100k_base encoded 19 dots as 2 tokens. Removing runs of three or more dots from the HierarchicalChunker text took the report from 1,122,222 to 259,839 MiniLM tokens, and from 309,591 to 274,056 cl100k_base tokens. MiniLM also counts each dash in a table separator row as a token. Check your own documents for this if you use a BERT-style tokenizer.

Apart from the slow triplet runs with the MiniLM tokenizer on the report, every method finished each document in under 7 seconds. Converting that report took 234 seconds, so chunking was a small part of the total in those runs.

How each method handles tables

HierarchicalChunker keeps every table in one chunk, as triplet text in the form row, column = value. On the one-page table that single chunk was 14,583 tokens at MiniLM and 8,448 at cl100k_base, against about 2,800 for the same table in the Markdown export. Empty cells become empty triplets such as Energy Production, 2025.Q1 = ..

HybridChunker with defaults uses the same triplets and splits them to fit. In docling-core 2.105.0 each split table chunk also starts with a stray | - | line, and the split falls at token boundaries inside a row label:

| - |
 Oil Production (a) (million barrels per day) ........................, 2025.Q3 = 13.82. Crude Oil Production (a) (million barrels per day) ........................, 2025.Q4 = 13.91. Crude Oil Production ...

That was 62 of the 66 chunks on the table page and 5,473 of the 5,592 chunks on the report. It is an open bug, docling-core #931. We also ran docling-core 2.102.0, the release before the table token limit fix that the bug report traces it to. That version had no | - | lines, but chunks reached 266 tokens with a limit of 256 on the table page and 289 on the report.

Setting repeat_table_header=False on the triplet path removed the | - | lines, cut the report from 383 to 5.0 seconds, and ended table chunks on a triplet boundary (all 64 on the table page and 5,364 of 5,571 on the report). Setting omit_header_on_overflow=True also removed the lines, but it still took 379 seconds.

HybridChunker with Markdown tables repeats the header row and separator in each table chunk, so the chunks stay valid Markdown:

|  | 2025 - Q1 | 2025 - Q2 | 2025 - Q3 | 2025 - Q4 | 2026 - Q1 | ... | Year - 2025 |
| - | - | - | - | - | - | ... | - |
| Dry Natural Gas Production (billion cubic feet per day) ..................... | 105.5 | 106.9 | 108.3 | 109.7 | 110.0 | ... | 107.6 |

Repeating the header costs tokens. On the table page the Markdown-table chunks added up to 4,950 tokens at MiniLM, against 2,810 for the plain header-plus-recursive split.

The token budget matters on wide tables. When a row and the header do not fit together, the chunker splits the table by columns. At 256 MiniLM tokens, that happened to all 22 table chunks on the table page. The first 21 chunks held the row labels and 13 data columns, and the last chunk held the remaining two columns with no row labels at all:

| Year - 2026 | Year - 2027 |
| - | - |
|  |  |
| 13.87 | 14.30 |
| 112.2 | 116.1 |

On the report, 895 of the 995 table chunks at 256 MiniLM tokens were column slices like this. At 512 cl100k_base tokens the table page needed none, and every one of its 8 table chunks had all 16 columns and the full header. On the report, 20 of the 365 table chunks were column slices. With wide tables, we would use the larger budget or check for these slices.

The two switches that avoid column slicing have their own cost at 256 MiniLM tokens. With Markdown tables and omit_header_on_overflow=True, only 1 of the 17 chunks on the table page had the header. With Markdown tables and repeat_table_header=False, 1 of 14 table chunks had it, and the separator row itself was cut across chunks.

The Markdown methods behave differently:

  • Fixed-size split: 8 of the 26 rows on the table page were cut in the middle at 256 tokens and 4 at 512. On the report, 454 of 1,123 rows were cut at 256 and 195 at 512.
  • Header splitter alone: no rows are cut, since it has no size limit. A section with a table stays whole, which on the report meant a largest section of 15,994 MiniLM tokens.
  • Header splitter plus recursive split: no rows were cut on the table page. On the report, 23 of 1,123 were cut at 256 and 1 at 512. The header row appears only in the first chunk of each table. With the MiniLM tokenizer, separator rows were long enough to be split on their own, and 76 of the 881 report chunks held only dashes and pipes. At 512 cl100k_base tokens there were none.

Heading context

Every HybridChunker chunk in our five documents carried its heading in meta.headings. chunk.text leaves it out, and contextualize() puts it in front, so embed the contextualized text and store whichever you want to show. From the paper:

chunk.meta.headings        # ['1. Introduction']
chunk.text                 # 'Recently, there has been a shift in the research literature...'
chunker.contextualize(chunk)  # '1. Introduction\nRecently, there has been a shift in the research literature...'

By default the PDF pipeline gives every heading level 1, so meta.headings holds one entry and the Markdown export writes every heading as ##. In the paper, chunks under “9.1. Training Details” do not mention “9. Appendix”. Docling can infer heading levels with an option that is off by default:

from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import HeadingHierarchyOptions, PdfPipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption

options = PdfPipelineOptions(heading_hierarchy_options=HeadingHierarchyOptions(enabled=True))
converter = DocumentConverter(format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=options)})

On the paper this gave ['9. Appendix', '9.1. Training Details'], and the Markdown wrote those subsections as ###. It also put “References” under “8. Acknowledgments”. On the report, paths reached four levels, and some did not match the document. “Table 9a. U.S. Macroeconomic Indicators and CO2 Emissions” landed under “Economy, CO2, and Weather”, “Weather” and “Short-Term Energy Outlook Chart Gallery”. Check the paths on your own documents before relying on them, since those strings are embedded with every chunk. The nesting survived a trip through Markdown and back on both documents. Parsebridge’s API does not turn this option on, so its Markdown has flat ## headings.

A heading with no text under it produces no chunk by default. “9. Appendix” in the paper, “Step 3: Claim Dependent and Other Credits” and “Step 5: Sign Here” in the form, and 7 of the 92 headings in the report never appear on any chunk. always_emit_headings=True adds a chunk for each of those with an empty text and the heading in its metadata. Pictures also get no chunk (3 in the paper, 57 in the report), though their captions do.

The Markdown methods handle this differently. MarkdownHeaderTextSplitter moves the heading into metadata and removes it from page_content unless you set strip_headers=False, so add it back yourself before embedding. It also joins lines with two spaces and one newline, which removes the blank lines between paragraphs. A fixed-size split has a heading only when one happens to fall inside the window, which was 48 of 621 report chunks at 256 tokens.

Chunking when you only have Markdown

This applies to you if your Markdown came from export_to_markdown(), or from a service that returns Markdown such as Parsebridge. Docling can read Markdown back into a DoclingDocument, and HybridChunker runs on that:

import io

from docling.datamodel.base_models import DocumentStream, InputFormat
from docling.document_converter import DocumentConverter

converter = DocumentConverter(allowed_formats=[InputFormat.MD])
stream = DocumentStream(name="report.md", stream=io.BytesIO(markdown.encode()))
doc = converter.convert(stream).document

# then build the chunker as above
chunker = HybridChunker(tokenizer=tokenizer, serializer_provider=MarkdownTables())
chunks = [chunker.contextualize(chunk) for chunk in chunker.chunk(doc)]

It worked on all five documents. Reading the report’s Markdown took 1.3 seconds and loaded no layout or table models, though pip install docling still installs PyTorch. With Markdown tables at 256 tokens the report gave 1,107 chunks, the same count as the PDF-derived document, with a median of 224 and a maximum of 256. At 512 cl100k_base tokens it gave 448 chunks against 450. The paper gave 63 against 66.

Here is what changed in the document itself on the report:

From the PDFRebuilt from Markdown
Pages600
Items with a page and bounding box595 of 5950 of 561
Section headers9292
Tables3333
Pictures570
Captions as captions230 (now plain text)
Footnotes as footnotes130 (now plain text)
Table cells spanning rows or columns3750
Cells flagged as row headers1,0020
Cells flagged as column headers504452

The loss that matters most for RAG is page numbers. No chunk in the rebuilt documents had one, so you cannot cite a page from them. Table values came through: all 168 non-header cell values on the paper and all 341 on the table page matched, and 6 of 15,067 on the report did not match exactly. <!-- image --> and <!-- formula-not-decoded --> placeholders were dropped. On the form, the three checkboxes became plain list items, and on the table page a line starting - = no data available became a list item.

With Parsebridge’s Markdown

Parsebridge runs an older Docling than the one above, so we repeated this with its engine. We ran docling-serve 1.32.0 (Docling 2.124, docling-core 2.93) locally with the options the Parsebridge API sends, which are table_mode=accurate, image_export_mode=placeholder and OCR on for the two PDFs its scanned page check flags (the scan and the report), then fed the resulting Markdown through Docling 2.145 as above. At 256 tokens with Markdown tables it gave 67 chunks for the paper, 22 for the table page, 3 for the scan, 9 for the form and 1,016 for the report, with no chunk over the limit and no page numbers.

The scan’s Markdown was identical across the two Docling versions, and the others differed. Docling 2.124 detected four tables in the paper where 2.145 detected five, and it treats two-row table headers differently. 2.145 writes one row with 2025 - Q1, and 2.124 writes 2025 in the header row and Q1 in the first body row:

|  | 2025 | 2025 | 2025 | 2025 | 2026 | ...
|---|---|---|---|---|---|...
|  | Q1 | Q2 | Q3 | Q4 | Q1 | ...

The repeated header then holds only the years, and just 1 of the 18 table chunks on that page had the quarter labels. If your tables have two-row headers, merge those rows in the Markdown before chunking.

If you do not want the Docling dependency, the LangChain route needs pip install langchain-text-splitters tiktoken. langchain-text-splitters is MIT-licensed, and tiktoken is needed for the token-based splitter below:

from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

sections = MarkdownHeaderTextSplitter([("#", "h1"), ("##", "h2"), ("###", "h3")]).split_text(markdown)
splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", chunk_size=512, chunk_overlap=0
)
chunks = splitter.split_documents(sections)

With this route, table headers appear only in a table’s first chunk, and headings stay in metadata. Our RAG pipeline page shows where this step fits, and the free PDF to Markdown converter shows you the Markdown you would be splitting.

Chunking problems we reproduced

We tried the problems people report in Docling’s issue tracker on the current release.

Headings-only PDFs return nothing. Issue #2257 is still open. The PDF attached to it now returns one chunk, because Docling 2.145 reads its second line as body text. A PDF we generated with three headings and nothing else returned 0 chunks from both chunkers, while export_to_markdown() returned all three headings. With always_emit_headings=True it returned 3 chunks with empty text, and contextualize() gave Annual Security Review\n and so on.

One-column tables. Issue #3023 reported no chunks for a one-column CSV, and it is closed. We now get a chunk with a missing header. A CSV with the header city and rows Zurich, Bern and Geneva gave Zurich = Bern. Zurich = Geneva, with city gone. The Markdown table serializer kept all four cells.

Hanging on a Hacker News page. Issue #2510 is still open, and its reporter measured 1,510 seconds of chunking on Docling 2.61.1. We ran the HTML file attached to the issue with the same settings, an OpenAI tokenizer at 256 tokens. Conversion took 4.1 seconds and chunking took 3.5 seconds, giving 2,674 chunks.

Exporting chunks as Markdown. Issue #964 was closed in 2025 with no export_to_markdown() on chunks, and that method is still absent. In practice chunk.text is already close to Markdown. Lists keep their - markers and checkboxes keep - [ ], and only tables differ, as triplets. With the Markdown table serializer above, tables come out as pipe tables too. Headings stay out of chunk.text, so add them from meta.headings if you want them as ## lines.

docling-serve chunking endpoints

docling-serve 1.32.0, the version Parsebridge’s workers run, has eight chunking routes in its source and in the OpenAPI schema of the local server we ran:

POST /v1/chunk/hybrid/file          POST /v1/chunk/hybrid/source
POST /v1/chunk/hybrid/file/async    POST /v1/chunk/hybrid/source/async
POST /v1/chunk/hierarchical/file        POST /v1/chunk/hierarchical/source
POST /v1/chunk/hierarchical/file/async  POST /v1/chunk/hierarchical/source/async
curl -X POST http://localhost:5001/v1/chunk/hybrid/file \
  -F "files=@report.pdf" \
  -F "chunking_use_markdown_tables=true" \
  -F "chunking_max_tokens=512"

The response has a chunks array, and each chunk has text, headings, page_numbers, num_tokens and doc_items. The tokenizer, max_tokens, merge_peers, use_markdown_tables and include_raw_text options take a chunking_ prefix on the form routes, and repeat_table_header and always_emit_headings are not among the options in 1.32.0. The 1.32.0 install we ran, and Parsebridge’s workers, use docling-core 2.93. It does not produce the | - | lines, but it can go over the limit. On the table page num_tokens reached 266 with the default limit of 256, and 515 with chunking_max_tokens=512. /v1/convert/file with to_formats=chunks and no chunking options returned no chunks in our one test, so use the /v1/chunk routes. Our docling-serve guide covers running the server.

Parsebridge does not expose any of this. Its API calls docling-serve’s convert route only and returns Markdown.

Which to pick

  • You run Docling yourself and have the DoclingDocument: use HybridChunker with the Markdown table serializer, and keep contextualize() for embedding and doc_items for page numbers. With wide tables, use a budget where a row and its header fit together.
  • You keep triplet tables: set repeat_table_header=False until docling-core #931 is fixed.
  • You want whole elements and will handle size yourself: use HierarchicalChunker, and check your largest table chunk first.
  • You only have Markdown and want the HybridChunker behavior: read it back through InputFormat.MD. Page numbers will be gone.
  • You only have Markdown and want few dependencies: use the header splitter plus a recursive split, and add headings back to the text you embed.

A fixed-size split was the fastest method and the easiest to write, and it cut the most table rows. We would not use it on documents with tables.

These results describe chunk shape, and a shape that looks clean does not prove better retrieval. Test on your own questions before you settle on one. Run the option you pick on your worst document first, since one table-heavy report behaved very differently from the paper here.

Parsebridge

Parsebridge is a hosted Docling API. You send a PDF and get Markdown back without running Docling yourself. It runs Docling 2.124 inside docling-serve 1.32.0, with TableFormer in accurate mode, and turns OCR on when it detects scanned pages. It accepts PDFs only, up to 50 MB, and returns Markdown only, so chunking happens on your side with one of the Markdown methods above. New accounts get 50 free pages once, and plans start at $17 a month for 5,000 pages.

To see Docling’s output on one of your own PDFs first, the free PDF to Markdown converter converts the first page of a PDF up to 10 MB without an account, five times per hour.


Chunker defaults and behavior were read from the installed docling-core 2.105.0 source and confirmed by running it on November 2, 2026, and the docling-serve details from its v1.32.0 source and a local server on the same day. See Docling’s chunking docs and serialization example for the upstream documentation.