<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Liji Builds]]></title><description><![CDATA[Building, learning and occasionally dumping my thoughts here.]]></description><link>https://lijibuilds.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Liji Builds</title><link>https://lijibuilds.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 21 Sep 2026 06:01:34 GMT</lastBuildDate><atom:link href="https://lijibuilds.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Docling Drops Pages and Footnotes Without a Word. Three Numbers Catch It.]]></title><description><![CDATA[When Docling loses part of a document, nothing tells you. No exception, no warning, no empty result. The parse finishes, the tree looks complete, the vector store fills up, and a clinician's question ]]></description><link>https://lijibuilds.hashnode.dev/docling-drops-pages-and-footnotes-without-a-word-three-numbers-catch-it</link><guid isPermaLink="true">https://lijibuilds.hashnode.dev/docling-drops-pages-and-footnotes-without-a-word-three-numbers-catch-it</guid><category><![CDATA[DOCLING]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[pdf]]></category><category><![CDATA[document parsing]]></category><category><![CDATA[debugging]]></category><dc:creator><![CDATA[Liji Alex]]></dc:creator><pubDate>Tue, 08 Sep 2026 03:07:23 GMT</pubDate><content:encoded><![CDATA[<p>When Docling loses part of a document, nothing tells you. No exception, no warning, no empty result. The parse finishes, the tree looks complete, the vector store fills up, and a clinician's question later returns nothing because the rule they asked about was never indexed.</p>
<p>I found this three separate ways while building a RAG pipeline over a hospital's internal documents: eleven PDFs (nursing procedures, a diagnostic reference, drug formularies, policies) and one billing guide in Markdown. Each time the symptom was different, the cause was different, and the logs were clean. This post is the three failures, the one that was my own mistake, and the check I now run on every parse.</p>
<p>Before the failures, four words this post leans on. Docling reads a document into a <strong>tree</strong> of <strong>items</strong>, each with a label: <code>section_header</code>, <code>text</code>, <code>list_item</code>, <code>table</code>, <code>footnote</code>, <code>page_header</code>. Items can have <strong>children</strong>; a table's children are its caption and footnotes. A <strong>chunker</strong> walks that tree and cuts it into the pieces that get embedded and indexed. Docling ships one, <code>HybridChunker</code>. If a line never lands in a chunk, no query will ever find it, however good the parse was.</p>
<p><strong>In short.</strong> Docling can return a parse that looks complete with pages missing. A threading race in docling-parse up to 7.16 with the default four-thread PDF backend drops whole pages with no error, and Docling's chunker never emits table footnotes. Three numbers catch it: pages returned versus pages in the file, items per page, and the share of raw text-line words that survived, which is 0.83 to 0.92 on a clean parse. Until you are on a parser without the race, parse on one thread.</p>
<h2>Failure one: the parse that changes between runs</h2>
<p>The first sign was a unit test that passed, then failed on the next run with no code change. It asserted that a chunk carried its section heading. The heading was gone. Later, a staff handbook came back with 54 items where the previous run had produced 94. Same file. Then the crashes started, roughly one run in six: <code>exit 134</code> (an abort; on macOS the message is "pointer being freed was not allocated", Linux prints a different <code>free()</code> complaint) or <code>exit 139</code> (a segfault). Both mean native code, not Python.</p>
<p><strong>Am I affected.</strong> Docling has a backend per format. The PDF backend is <code>docling-parse</code>, a C++ library that pulls text lines out of the file before the layout model, the vision model that labels each box on the rendered page, runs. Docling 2.123 made a threaded version of that backend the default: four native threads decode pages at the same time. <code>docling-parse</code> up to 7.16.0 has a data race in that path. Two threads occasionally write the same memory at once, and what happens next depends on timing. Sometimes the process dies. Sometimes one thread's work is quietly overwritten and the document comes back short. Check where you stand:</p>
<pre><code class="language-bash">pip show docling docling-parse | grep -E "^(Name|Version)"
</code></pre>
<p>Docling ≥ 2.123 with default settings, or an earlier version with <code>ThreadedDoclingParseDocumentBackend</code> opted in, is exposed. The issue is docling #4147.</p>
<p><strong>What it looks like.</strong> Here is a 9-page reference guide parsed five times with default settings, each in a fresh process:</p>
<pre><code>run 1: exit 134
run 2: result.pages = 7   pages present: 2 4 5 6 7 8 9      words kept 97%
run 3: result.pages = 6   pages present: 1 3 5 7 8 9        words kept 97%
run 4: result.pages = 7   pages present: 2 4 5 6 7 8 9      words kept 97%
run 5: result.pages = 6   pages present: 1 4 5 7 8 9        words kept 97%
</code></pre>
<p>Single-threaded, three runs: 9 pages, identical items, every time.</p>
<p>The missing pages are not empty. They are <strong>absent</strong>. <code>len(result.pages)</code> is 6 or 7 while <code>result.input.page_count</code> still says 9. Page 2 in run 2 holds the whole introduction, seven items:</p>
<pre><code>section_header  1. Introduction to Mobile Networks
text            Mobile networks have evolved through several generations...
text            2G (GSM) networks introduced digital voice and basic data...
text            3G (UMTS/HSPA) networks brought mobile broadband...
text            4G (LTE) networks delivered a major leap forward...
text            5G networks represent the current frontier...
text            Network coverage depends on the frequency band used...
</code></pre>
<p>In run 3, page 2 does not exist. No item, no page object, no raw text lines. Every page that did survive is internally perfect, so nothing downstream complains. That matters for the check later: a test that asks "does every page have content" passes, because the lost pages are not there to be asked about.</p>
<p><strong>Fix.</strong> Parse on one thread until you are on a <code>docling-parse</code> build you have verified against this:</p>
<pre><code class="language-python">from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.base_models import InputFormat
from docling.backend.docling_parse_backend import ThreadedDoclingParseBackendOptions

converter = DocumentConverter(
    format_options={
        InputFormat.PDF: PdfFormatOption(
            backend_options=ThreadedDoclingParseBackendOptions(parser_threads=1)
        )
    }
)
</code></pre>
<p>Cost: none I could measure. An eight-page manual took 5.0 seconds on four threads and 5.1 on one, because the layout model dominates, not page decoding. <code>docling-parse</code> 7.17.0 shipped shortly after with a parse speed-up; its release notes do not mention the race and the issue was still open when I checked (September 2026). Run the five-process test above on your own files before you drop the setting.</p>
<blockquote>
<p>Aside, if you also see <code>exit 134</code> at shutdown: <code>onnxruntime</code> 1.29, pulled in for OCR (on by default), aborts from its telemetry thread when the interpreter exits (<code>recursive_mutex lock failed</code>). Harmless to results, same exit code, easy to confuse with the real crash. <code>ORT_DISABLE_TELEMETRY=1</code> removes it.</p>
</blockquote>
<h2>Failure two: the footnote under the table</h2>
<p>The diagnostic reference has a table of radiology reporting flags, and under it two lines:</p>
<pre><code>Modality             Urgent means                              Routine TAT
Chest X-ray (CXR)    Pneumothorax, free air, mis-placed line   Reported within 24 h
CT head              Bleed, mass effect, acute infarct         Reported within 12 h
USS abdomen          Free fluid, obstruction, aneurysm         Reported within 24 h
· Urgent radiology turnaround: verbal/critical alert within 1 hour of acquisition.
· Reports are viewed on the PACS system; clinicians acknowledge critical reports.
</code></pre>
<p>Real rules, the kind a clinician asks about. Docling parsed them correctly, labelled them <code>footnote</code>, and made them children of the table:</p>
<pre><code>table   (3 columns, 7 rows)
├── footnote  · Urgent radiology turnaround: verbal/critical alert within 1 hour of acquisition.
└── footnote  · Reports are viewed on the PACS system; clinicians acknowledge critical reports.
</code></pre>
<p>Then the chunker dropped them. When <code>HybridChunker</code> reaches a table it converts the whole table to text in one go, then skips everything under it in the tree so nothing is emitted twice. Captions go into that text. Footnotes do not. The chunk under that heading, before any fix:</p>
<pre><code>headings: ['7. Radiology Reporting Flags']
text:     Chest X-ray (CXR), Urgent means = Pneumothorax, free air, mis-placed line.
          Chest X-ray (CXR), Routine TAT = Reported within 24 h. CT head, Urgent means = ...
</code></pre>
<p>Grep every chunk for "Urgent radiology turnaround": nothing. Ask "how fast must a critical radiology finding be phoned through?" and the table chunk may come back, but the one-hour rule is not in it.</p>
<p>My first fix was to relabel the two items from <code>footnote</code> to <code>text</code>. Grep again: still nothing. The label was never the problem. Their position in the tree was. The fix that works moves them up one level, so they sit <em>after</em> the table instead of <em>under</em> it. <code>prov</code> is provenance, the page and box an item came from; it is carried across so page counts stay right.</p>
<pre><code class="language-python">from docling_core.types.doc import DocItemLabel

for table in doc.tables:
    notes = [ref.resolve(doc) for ref in table.children]
    notes = [n for n in notes if getattr(n, "label", None) == DocItemLabel.FOOTNOTE]
    anchor = table
    for note in notes:
        anchor = doc.insert_text(
            sibling=anchor, label=DocItemLabel.TEXT, text=note.text,
            prov=note.prov[0] if note.prov else None, after=True,
        )
    if notes:
        doc.delete_items(node_items=notes)
</code></pre>
<p>After the fix the two lines are ordinary paragraphs under <code>7. Radiology Reporting Flags</code>, and the chunker merges them into the same chunk as the table. Two items in one file out of twelve. Small, but it is a clinical timing rule.</p>
<h2>The third loss was mine</h2>
<p>Docling's Markdown backend splits a list item at every inline formatting boundary. This line from the billing guide:</p>
<pre><code>2. Admission note with **provisional diagnosis** and matching ICD-10 code from `billing_codes.pdf`.
</code></pre>
<p>comes out as six items:</p>
<pre><code>list_item  ''
text       'Admission note with'
text       'provisional diagnosis'
text       'and matching ICD-10 code from'
code       'billing_codes.pdf'
text       '.'
</code></pre>
<p>Each chunk records which items it was built from, in <code>chunk.meta.doc_items</code>. I ran a coverage check: is every item in the document referenced by at least one chunk? 85 of 239 were not. I wrote "85 items dropped, the checklists are gutted" and started fixing it.</p>
<p>Then I checked the words instead of the references. The chunk text:</p>
<pre><code>2. Admission note with **provisional diagnosis** and matching ICD-10 code from `billing_codes.pdf` .
</code></pre>
<p>All 85 unreferenced fragments were present. The chunker's list serialiser had stitched them back together and simply not recorded the pieces as sources. Nothing was lost.</p>
<p>What <em>was</em> wrong is visible in that same line: the <code>**</code> and the backticks survived into the text that went to the embedding model. 96 bold markers and 44 backticks across the file, and phrases broken by them. The source says the claim deadline is "typically <strong>30 days post-discharge</strong>"; grep the chunk text for <code>typically 30 days post-discharge</code> and you get nothing, because two asterisks sit in the middle. A real problem, worth the Markdown-stripping pass I had already written. Not the problem I had claimed.</p>
<p>The lesson generalises. Item counts, reference counts, node counts measure the tree, not the words. When you suspect content loss, probe for the words. Pick five phrases you know are in the source and grep the output for them. If they are there, the tree shape is a detail.</p>
<h2>The check that catches the first failure</h2>
<p>Two of the three failures were invisible in the logs and visible in the numbers. So every parse now ends with a check, and a parse that fails it is retried once, then rejected. Three signals, in the order the code runs them.</p>
<p><strong>Pages returned equals pages in the file.</strong> <code>len(result.pages)</code> against <code>result.input.page_count</code>. This is the only signal that catches the race, because the lost pages vanish entirely. Nothing else you compute from <code>result</code> can see them.</p>
<p><strong>Every returned page has content.</strong> Page headers, footers and page numbers are furniture, present on every page and worthless in a chunk; they do not count. A page with zero real items is a page the layout model gave up on.</p>
<p><strong>Raw words survive.</strong> Before the layout model runs, the PDF parser extracts text lines into <code>result.pages[i].parsed_page.textline_cells</code>. Every word in the final tree had to come from one of those lines, so their count is the ceiling. Count the words in the final items too, table cells separately because a <code>TableItem</code> has no <code>.text</code>, and compare. On a clean parse 83 to 92 percent survive across my eleven PDFs; the rest is furniture. This signal catches a page the layout model half-read: items present, paragraphs missing. It does not catch the race, because the lost pages take their text lines with them (the racy runs above all scored 97 percent).</p>
<pre><code class="language-python">from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.backend.docling_parse_backend import ThreadedDoclingParseBackendOptions
from docling_core.types.doc import DocItemLabel

FURNITURE = {DocItemLabel.PAGE_HEADER, DocItemLabel.PAGE_FOOTER}

def parse_problems(result, min_ratio=0.8):
    doc = result.document
    problems = []

    if result.input.page_count and len(result.pages) != result.input.page_count:
        problems.append(f"{len(result.pages)} of {result.input.page_count} pages returned")

    per_page = {}
    for item, _ in doc.iterate_items():
        if item.prov and item.label not in FURNITURE:
            per_page[item.prov[0].page_no] = per_page.get(item.prov[0].page_no, 0) + 1
    empty = [p.page_no for p in result.pages if per_page.get(p.page_no, 0) == 0]
    if empty:
        problems.append(f"empty pages {empty}")

    raw = sum(len(c.text.split()) for p in result.pages if p.parsed_page
              for c in p.parsed_page.textline_cells)
    kept = 0
    for item, _ in doc.iterate_items():
        if item.label in FURNITURE:
            continue
        if item.label == DocItemLabel.TABLE:
            kept += sum(len(c.text.split()) for c in item.data.table_cells)
        else:
            kept += len((getattr(item, "text", "") or "").split())
    if raw and kept / raw &lt; min_ratio:
        problems.append(f"only {kept / raw:.0%} of raw words survived")

    return problems


opts = PdfPipelineOptions(generate_parsed_pages=True)   # keeps textline_cells; without it raw is 0
converter = DocumentConverter(
    format_options={InputFormat.PDF: PdfFormatOption(
        pipeline_options=opts,
        backend_options=ThreadedDoclingParseBackendOptions(parser_threads=1),
    )}
)

for attempt in range(2):
    result = converter.convert(path)
    problems = parse_problems(result)
    if not problems:
        break
    print(f"parse attempt {attempt + 1} of {path}: {problems}")
else:
    raise RuntimeError(f"{path} failed the parse check: {problems}")
</code></pre>
<p>What it prints on the nursing manual, single thread:</p>
<pre><code>pages   8 of 8 returned, 8 with content
items   [3, 21, 15, 14, 10, 15, 16, 15]
words   998 of 1203 raw (83%)
problems []
</code></pre>
<p>And on the 9-page guide with the threaded backend, exactly as the loop prints it, three separate processes:</p>
<pre><code>parse attempt 1 of guide.pdf: ['6 of 9 pages returned']
pages   9 of 9 returned, 9 with content     &lt;- second attempt
words   2695 of 2785 raw (97%)
problems []

parse attempt 1 of guide.pdf: ['7 of 9 pages returned']
pages   9 of 9 returned ...

parse attempt 1 of guide.pdf: ['6 of 9 pages returned']
pages   9 of 9 returned ...
</code></pre>
<p>Three for three: the first attempt lost pages, the second got them all. Note the word ratio on the bad first attempts was 97 percent; only the page count saw the loss.</p>
<p>Two things to set expectations. The 0.8 floor is below my clean range with margin; measure your own clean floor first and set the threshold under it. And on scanned PDFs the parser has no text lines, <code>raw</code> is zero, and the word check silently skips itself; the page checks still run.</p>
<p>The retry is there for the threaded case above, and for whatever the next intermittent thing turns out to be. With <code>parser_threads=1</code> it should never fire; when it does, that is information.</p>
<p><strong>What this check does not catch.</strong> The footnotes. Their words are in the tree, so every ratio is happy; it is the chunker that drops them. For that, grep the chunk text, not the document: five phrases from the source, checked after chunking. The half-parsed 54-item handbook I never measured with this check; it happened before the check existed and I have not reproduced it since.</p>
<h2>One habit</h2>
<p>Before you trust a parse, print three numbers per document: pages returned versus pages in the file, items per page, and the share of raw words that survived. Then pick five phrases from the source and grep the chunk text for them. Thirty lines of code, a few seconds per file.</p>
<p>Docling will not tell you when it drops something. The numbers will. Make them part of the parse, not a thing you run when a test fails.</p>
<h2>Questions this post answers</h2>
<p><strong>Why does Docling return fewer pages on some runs?</strong>
A data race in the docling-parse PDF backend when Docling decodes pages on four threads. Set <code>ThreadedDoclingParseBackendOptions(parser_threads=1)</code> and compare <code>len(result.pages)</code> to <code>result.input.page_count</code> on every parse.</p>
<p><strong>Why are my table footnotes missing from the chunks?</strong>
<code>HybridChunker</code> serialises a table as one unit and marks its children as visited without emitting footnotes. Re-insert them as text items after the table with <code>doc.insert_text(sibling=table, after=True)</code>; relabelling them does not help.</p>
<p><strong>What do exit codes 134 and 139 mean during Docling parsing?</strong>
134 is an abort and 139 a segmentation fault, both from native code. Mid-parse they are the docling-parse race. At interpreter exit only, it is onnxruntime's telemetry thread; set <code>ORT_DISABLE_TELEMETRY=1</code>.</p>
]]></content:encoded></item><item><title><![CDATA[I Tried Six Ways to Get Heading Levels Out of a PDF. Docling Already Had One.]]></title><description><![CDATA[Docling's PDF pipeline returns every heading at level 1 unless you switch on the stage that ranks them. I spent a day finding that stage and tried five other things first. This is the comparison, with]]></description><link>https://lijibuilds.hashnode.dev/i-tried-six-ways-to-get-heading-levels-out-of-a-pdf-docling-already-had-one</link><guid isPermaLink="true">https://lijibuilds.hashnode.dev/i-tried-six-ways-to-get-heading-levels-out-of-a-pdf-docling-already-had-one</guid><category><![CDATA[DOCLING]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[pdf]]></category><category><![CDATA[chunking]]></category><category><![CDATA[document parsing]]></category><dc:creator><![CDATA[Liji Alex]]></dc:creator><pubDate>Tue, 08 Sep 2026 03:06:43 GMT</pubDate><content:encoded><![CDATA[<p>Docling's PDF pipeline returns every heading at level 1 unless you switch on the stage that ranks them. I spent a day finding that stage and tried five other things first. This is the comparison, with numbers, and the ten-minute script that would have skipped the day. The mechanism is in <a href="https://lijibuilds.hashnode.dev/docling-flattens-your-pdf-headings-here-is-why-and-how-to-fix-it">the longer version</a>; what follows stands alone.</p>
<p><strong>In short.</strong> Docling's PDF pipeline gives every heading level 1 by default. Of six ways to fix it, the built-in <code>HeadingHierarchyOptions(enabled=True)</code> with <code>generate_parsed_pages=True</code> nested headings correctly on all eleven test documents with no other change. A third-party post-processor, a regex rule, font-size clustering and the vision pipeline each fell short on this corpus.</p>
<h2>The setup</h2>
<p>Docling reads a PDF into labelled items. A layout model looks at each rendered page and decides what each box is: <code>text</code>, <code>table</code>, <code>list_item</code>, <code>section_header</code>. Every <code>section_header</code> carries an integer <code>level</code>; 1 is the top. A nested document should come out 1, 2, 3.</p>
<p>One page of an ICU nursing manual, seven SOPs (standard operating procedures):</p>
<pre><code>SOP 1 - Central Venous Catheter (CVC) Care     &lt;- 16pt bold heading
    Frequency                                  &lt;- 12pt bold sub-heading
    Dressing change every 72 hours...
    Equipment checklist                        &lt;- sub-heading
    Procedure                                  &lt;- sub-heading
    1 Perform hand hygiene...                  &lt;- numbered step, a list item
SOP 2 - Mechanical Ventilator Management
    Initial settings
</code></pre>
<p>Docling's <code>HybridChunker</code> walks those items and attaches to each chunk the headings above it, one per level, in <code>chunk.meta.headings</code>. That list is the breadcrumb, the only place the hierarchy reaches the retriever. If the 72-hour chunk's breadcrumb is <code>['Frequency']</code>, it never says which line it is about. When I measured it, the chunk still ranked first on its body text, but the next two results were <code>Procedure</code> chunks from two different SOPs with identical breadcrumbs, and nothing in the retrieved text lets a reader or a model tell them apart.</p>
<p>A PDF can also carry bookmarks, the outline in a viewer's sidebar. Each entry has a depth, so a bookmarked PDF already knows its levels. PDFs converted from HTML get them free from <code>&lt;h1&gt;</code>, <code>&lt;h2&gt;</code>, <code>&lt;h3&gt;</code>. PDFs from ReportLab, which produced all of mine, and from most internal generators, have none.</p>
<p>The corpus: eleven ReportLab PDFs, zero bookmarks. The ICU manual above; an equipment manual with four devices, each with its own <code>Fault codes</code> section; treatment protocols A to G, each with <code>Diagnostic criteria</code> and <code>Monitoring</code>; a drug formulary of numbered tables; a staff FAQ grouped into themes; and six policy documents with <code>1.</code>, <code>2.</code>, <code>3.</code> sections (billing codes, diagnostic reference, code of conduct, leave policy, staff handbook, infection control).</p>
<h2>What correct means here</h2>
<p>Two checks, both scriptable.</p>
<p>First, do the levels nest? Print <code>(level, text)</code> for every <code>section_header</code> and read it. <code>SOP 1</code> should be above <code>Frequency</code>, not beside it.</p>
<p>Second, do repeated sub-headings get distinct parents? <code>Fault codes</code> appears four times in the equipment manual. After chunking, each of those four chunks should carry a different device in its breadcrumb. If they all say <code>Manual &gt; Fault codes</code>, the hierarchy did not reach the chunker, whatever the levels claim. This is the check that matters for retrieval, and the one that catches tools that look right on a heading dump.</p>
<pre><code class="language-python">from collections import defaultdict
from docling.chunking import HybridChunker

def repeated_subheadings(doc):
    """Headings that label more than one chunk, and how many of those have &gt;1 distinct parent path."""
    parents, seen = defaultdict(set), defaultdict(int)
    for chunk in HybridChunker().chunk(doc):
        path = chunk.meta.headings or []          # the breadcrumb
        if path:
            parents[path[-1]].add(tuple(path[:-1])); seen[path[-1]] += 1
    repeated = {h for h, n in seen.items() if n &gt; 1}
    separated = {h for h in repeated if len(parents[h]) &gt; 1}
    return separated, repeated
</code></pre>
<p><code>separated/repeated</code> is the number I report below. Read it as a change: <code>0/2 -&gt; 2/2</code> means two sub-headings that shared one parent now have their own; <code>0/4 -&gt; 0/4</code> means four sections merely span several chunks each, which is fine.</p>
<p>Six approaches follow, in three families: do nothing, infer the levels outside Docling (a package, a regex, font sizes, a vision model), or let Docling infer them.</p>
<h2>1. The default gives every heading level 1</h2>
<pre><code class="language-python">from docling.document_converter import DocumentConverter
doc = DocumentConverter().convert("manual.pdf").document
print(sorted({t.level for t in doc.texts if t.label == "section_header"}))   # doc.texts: every text item; headings carry .level
</code></pre>
<p>Every document: <code>[1]</code>. Breadcrumb on the 72-hour chunk: <code>['Frequency']</code>. The layout model decides that a box is a heading. It never compares two headings to each other, so it has no idea <code>SOP 1</code> is bigger than <code>Frequency</code>. Docling has a separate stage that does compare them, and it is off unless you switch it on. That is approach 6. Here is what I did before finding it.</p>
<h2>2. A package that is right with bookmarks and confidently wrong without</h2>
<p>Search for the problem and you find <code>docling-hierarchical-pdf</code>, which describes itself as a package that "enables inference of header hierarchy in the docling PDF parsing pipeline." It installs as <code>docling-hierarchical-pdf</code> and imports as <code>hierarchical</code>. You run it on the conversion result after Docling is done:</p>
<pre><code class="language-python">from docling.document_converter import DocumentConverter
from hierarchical.postprocessor import ResultPostprocessor

result = DocumentConverter().convert("manual.pdf")
ResultPostprocessor(result).process()
</code></pre>
<p>Three signals in order: PDF bookmarks read with pymupdf, then heading numbering, then font size and style. On a PDF converted from HTML with 54 bookmarks it produced a perfect three-level tree, the case its README leads with.</p>
<p>On my bookmark-less corpus, results split. The FAQ, the treatment protocols and the staff handbook nested correctly. The ICU manual went from 34 headings to 56: the numbering pass read <code>1 Perform hand hygiene</code>, <code>2 Remove the old dressing</code> and twenty other procedure steps as numbered headings, promoted those 22 list items, and built a six-level tree out of them. <code>SOP 1</code> stayed at level 1; the 72-hour breadcrumb stayed <code>['Frequency']</code>.</p>
<p>The lesson is not "avoid this package". A heuristic tuned for bookmarks and outline numbering does something confident and wrong on a corpus with neither, and you only find out by printing the tree.</p>
<h2>3. A regex that works until the twelfth document</h2>
<p>If the package's numbering heuristic is too loose for your corpus, write a tighter one. On the default output, headings that start with <code>1.</code>, <code>A.</code> or <code>SOP n</code> are sections; the rest are sub-sections:</p>
<pre><code class="language-python">import re
SECTION = re.compile(r"^(\d+\.\s|[A-Z]\.\s|SOP\s\d+|Appendix\b)")   # corpus-specific

first = True
for t in doc.texts:
    if t.label != "section_header":
        continue
    if first:
        t.level = 1; first = False          # document title
    elif SECTION.match(t.text.strip()):
        t.level = 2                         # section
    else:
        t.level = 3                         # sub-section
</code></pre>
<p><code>HybridChunker</code> honours whatever <code>level</code> you set, so this works; the 72-hour breadcrumb becomes <code>['ICU Nursing Procedures Manual', 'SOP 1 - …', 'Frequency']</code>. Thirty lines with edge cases. Correct on all eleven documents because I wrote it after reading all eleven; useless on the twelfth if it numbers sections differently. Right for a corpus you control, wrong for a pipeline that will see documents you have not read.</p>
<h2>4. Font sizes from pymupdf, and a segfault</h2>
<p>Headings in these files are printed at 26, 16, 12, 10.8 and 9.3 points, cleanly tiered. So: read every text span with its size using pymupdf, match Docling's headings to spans, assign levels by size rank.</p>
<p>Two problems killed it. Matching a heading's text back to a span is fuzzy, because Docling joins wrapped lines and normalises whitespace while pymupdf returns one span per line and style run; six of about two hundred headings matched the wrong span or none (from my notes, not re-measured). And pymupdf and Docling's PDF parser both bundle native PDF code: import pymupdf before any Docling module and the process segfaults the moment Docling runs; import Docling first and it works. The package in approach 2 gets away with it because its own modules import Docling before pymupdf. Not an ordering constraint I want in an ingestion job.</p>
<h2>5. The vision pipeline reads the page and still says level 1</h2>
<p>Docling's default pipeline detects boxes on the page and reads the text out of each. Its VLM pipeline instead hands the whole page image to a small vision-language model that writes the structured output directly, the way a person reads a scan. If any approach could see that one heading is bigger than another, I expected this one.</p>
<p>Recalled numbers, not re-run, because nine minutes per document makes eleven documents a ninety-minute job: nine minutes for the eight-page ICU manual, one page absent from the output for a reason I never established, and every heading still at level 1. Reading the page like a human and ranking headings are different problems. This pipeline exists for scans and dense layouts.</p>
<h2>6. The option that was there all along</h2>
<pre><code class="language-python">from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import HeadingHierarchyOptions, PdfPipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption

opts = PdfPipelineOptions()
opts.heading_hierarchy_options = HeadingHierarchyOptions(enabled=True)
opts.generate_parsed_pages = True   # without this the font-size signal is silently skipped

converter = DocumentConverter(format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=opts)})
doc = converter.convert("manual.pdf").document
</code></pre>
<p>Docling ships its own heading-hierarchy stage. Same three signals as the package, but it runs inside the pipeline on Docling's own parsed text cells, never touches list items unless a bookmark confidently says so (exactly what tripped the package on the ICU manual), and needs no second PDF library.</p>
<p>The same ICU page, six ways. Tree = <code>(level, text)</code> for the first headings; breadcrumb = <code>chunk.meta.headings</code> on the 72-hour chunk. Rows 1, 2, 3 and 6 re-run for this post; 4 and 5 recalled.</p>
<table>
<thead>
<tr>
<th>approach</th>
<th>tree for SOP 1 / Frequency / Equipment checklist / Procedure / Escalate / SOP 2</th>
<th>breadcrumb on the 72-hour chunk</th>
</tr>
</thead>
<tbody><tr>
<td>1 default</td>
<td><code>1 1 1 1 1 1</code></td>
<td><code>['Frequency']</code></td>
</tr>
<tr>
<td>2 package</td>
<td><code>1 1 1 1 1 …</code> plus 22 procedure steps promoted to headings at levels 1–6</td>
<td><code>['Frequency']</code></td>
</tr>
<tr>
<td>3 regex</td>
<td><code>2 3 3 3 3 2</code></td>
<td><code>['ICU Nursing Procedures Manual', 'SOP 1 - …', 'Frequency']</code></td>
</tr>
<tr>
<td>4 font size</td>
<td>recalled: tiers right, 6 of ~200 headings matched the wrong span</td>
<td>mostly right; wrong where the span match failed</td>
</tr>
<tr>
<td>5 VLM</td>
<td>recalled: every heading level 1, one page absent</td>
<td><code>['Frequency']</code></td>
</tr>
<tr>
<td>6 native</td>
<td><code>2 3 3 3 4 2</code></td>
<td><code>['ICU Nursing Procedures Manual', 'SOP 1 - …', 'Frequency']</code></td>
</tr>
</tbody></table>
<p>Level 4 in row 6 is the callout labels (<code>Escalate</code>, <code>Safety</code>, <code>Principle</code>) printed smaller inside a sub-section.</p>
<p>One thing to know before the results: the levels are relative to the document, not absolute. A file whose headings come out <code>[1, 3, 4]</code> has three tiers; nothing is missing at 2.</p>
<p>Results on all eleven documents, re-measured for this post. "Separated" is the second check: repeated sub-headings with distinct parents, default versus native.</p>
<table>
<thead>
<tr>
<th>document shape</th>
<th>levels: default → native</th>
<th>chunks: default → native</th>
<th>separated / repeated: default → native</th>
</tr>
</thead>
<tbody><tr>
<td>ICU manual, SOP 1–7</td>
<td><code>[1]</code> → <code>[1, 2, 3, 4]</code></td>
<td>28 → 28</td>
<td>0/2 → 2/2 (<code>Procedure</code>, <code>Safety</code>)</td>
</tr>
<tr>
<td>equipment manual, devices A–D</td>
<td><code>[1]</code> → <code>[1, 3, 4]</code></td>
<td>31 → 31</td>
<td>0/2 → 2/2 (<code>Fault codes</code>, <code>Remove from service</code>)</td>
</tr>
<tr>
<td>treatment protocols A–G</td>
<td><code>[1]</code> → <code>[1, 2, 3, 4, 5]</code></td>
<td>36 → 36</td>
<td>0/6 → 5/6 (<code>Monitoring</code>, <code>Diagnostic criteria</code>, …; the sixth is an appendix split across chunks)</td>
</tr>
<tr>
<td>drug formulary, numbered tables</td>
<td><code>[1]</code> → <code>[1, 2, 3]</code></td>
<td>21 → 21</td>
<td>0/4 → 0/4 (sections split across chunks; no cross-section repeats)</td>
</tr>
<tr>
<td>staff FAQ, themes with Q1–Q25</td>
<td><code>[1]</code> → <code>[1, 2, 3]</code></td>
<td>26 → 26</td>
<td>0/0 → 0/0</td>
</tr>
<tr>
<td>billing codes reference</td>
<td><code>[1]</code> → <code>[1, 2, 3]</code></td>
<td>23 → 23</td>
<td>0/4 → 0/4</td>
</tr>
<tr>
<td>diagnostic reference</td>
<td><code>[1]</code> → <code>[1, 3]</code></td>
<td>14 → 14</td>
<td>0/2 → 0/2</td>
</tr>
<tr>
<td>code of conduct</td>
<td><code>[1]</code> → <code>[1, 3]</code></td>
<td>16 → 16</td>
<td>0/0 → 0/0</td>
</tr>
<tr>
<td>leave policy</td>
<td><code>[1]</code> → <code>[1, 3]</code></td>
<td>14 → 14</td>
<td>0/1 → 0/1</td>
</tr>
<tr>
<td>staff handbook</td>
<td><code>[1]</code> → <code>[1, 3, 4]</code></td>
<td>22 → 22</td>
<td>0/0 → 0/0</td>
</tr>
<tr>
<td>infection control</td>
<td><code>[1]</code> → <code>[1, 3, 4]</code></td>
<td>16 → 16</td>
<td>0/0 → 0/0</td>
</tr>
</tbody></table>
<p>Every document nests. Chunk counts are identical before and after: the chunker groups the same text and just labels it correctly. In the ICU manual only <code>Procedure</code> (SOP 1 and 6) and <code>Safety</code> (SOP 3 and 6) recur across SOPs, so 2/2 is the whole set. <code>Fault codes</code> now has four different parents. Zero promoted list items.</p>
<p>Two quirks worth knowing, with the lines each needs. Numbered sections like <code>1. Scope</code> often land at level 1 beside the document title, so the title drops out of paths under them:</p>
<pre><code class="language-python">title = next(t.text for t in doc.texts if t.label == "section_header")   # first heading = the title
path = list(chunk.meta.headings or [])
if path[:1] != [title]:
    path = [title, *path]
</code></pre>
<p>And in the ICU manual the callout labels sit at level 4 under a level-3 sub-section, so if you only want three tiers, cap them:</p>
<pre><code class="language-python">HeadingHierarchyOptions(enabled=True, max_level=3)
</code></pre>
<h2>The ten-minute evaluation</h2>
<p>Run this on your corpus before choosing anything. Both checks, every PDF, default versus native.</p>
<pre><code class="language-python">from collections import defaultdict
from pathlib import Path
from docling.chunking import HybridChunker
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import HeadingHierarchyOptions, PdfPipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption

default = DocumentConverter()
opts = PdfPipelineOptions(generate_parsed_pages=True, heading_hierarchy_options=HeadingHierarchyOptions(enabled=True))
native = DocumentConverter(format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=opts)})

def levels(doc):
    return sorted({t.level for t in doc.texts if t.label == "section_header"})

def repeated_subheadings(doc):
    parents, seen = defaultdict(set), defaultdict(int)
    for chunk in HybridChunker().chunk(doc):
        path = chunk.meta.headings or []
        if path:
            parents[path[-1]].add(tuple(path[:-1])); seen[path[-1]] += 1
    repeated = {h for h, n in seen.items() if n &gt; 1}
    return len({h for h in repeated if len(parents[h]) &gt; 1}), len(repeated)

for pdf in sorted(Path("docs").rglob("*.pdf")):
    d0, d1 = default.convert(str(pdf)).document, native.convert(str(pdf)).document
    s0, r0 = repeated_subheadings(d0); s1, r1 = repeated_subheadings(d1)
    print(f"{pdf.name:30} levels {levels(d0)} -&gt; {levels(d1)}   separated {s0}/{r0} -&gt; {s1}/{r1}")
</code></pre>
<p>Two lines of its output, one document that needed the option and one that did not:</p>
<pre><code>equipment_manual.pdf           levels [1] -&gt; [1, 3, 4]   separated 0/2 -&gt; 2/2
code_of_conduct.pdf            levels [1] -&gt; [1, 3]      separated 0/0 -&gt; 0/0
</code></pre>
<p>Read the output, then one document's heading tree by hand. If the native option nests and the repeated sub-headings separate, you are done. If not, your corpus has neither bookmarks nor consistent numbering nor size tiers, and you are in regex territory with your eyes open.</p>
<h2>Which approach, by document</h2>
<table>
<thead>
<tr>
<th>Your PDFs</th>
<th>How to tell</th>
<th>Use</th>
</tr>
</thead>
<tbody><tr>
<td>Have bookmarks</td>
<td><code>pymupdf.open(f).get_toc()</code> is non-empty; the viewer shows an outline sidebar</td>
<td>Native option; bookmarks are authoritative. The package gives the same result plus a dependency.</td>
</tr>
<tr>
<td>No bookmarks, numbered or size-tiered headings</td>
<td>Heading dump shows <code>1.</code>, <code>1.1</code>, <code>A.</code>, or <code>pdffonts</code> shows several sizes</td>
<td>Native option. Verify with the script; expect the title-drop quirk.</td>
</tr>
<tr>
<td>No bookmarks, no numbering, one font size</td>
<td>Heading dump is flat and unnumbered</td>
<td>Nothing infers what is not there. Regex if you know the corpus; otherwise put the section name into the chunk text another way.</td>
</tr>
<tr>
<td>Scanned</td>
<td>No text layer (<code>pdffonts</code> lists nothing)</td>
<td>Untested here. The docs say numbering still works and the style signal is lost.</td>
</tr>
</tbody></table>
<p>I spent a day getting to the sixth approach. The option was in the pipeline the whole time, one flag away, documented on a page I had not read. The three signals are the whole story: bookmarks, numbering, style. Find out which ones your PDFs carry before you write a line.</p>
<h2>Questions this post answers</h2>
<p><strong>Why are all my Docling headings level 1?</strong>
Docling's layout model classifies boxes as headings but does not rank them, and the pipeline stage that assigns levels is off by default. Enable <code>HeadingHierarchyOptions</code> and keep <code>generate_parsed_pages=True</code> so the font-size signal has data.</p>
<p><strong>Does docling-hierarchical-pdf fix heading levels?</strong>
When the PDF carries bookmarks, yes. Without bookmarks it inferred levels from numbering and promoted numbered procedure steps to headings on my documents. The built-in option uses the same signals with stricter guardrails.</p>
<p><strong>Will Docling's VLM pipeline give better heading levels?</strong>
No. On an eight-page manual it took nine minutes, dropped a page, and still returned every heading at level 1. Layout awareness and heading hierarchy are different problems.</p>
]]></content:encoded></item><item><title><![CDATA[Layout-Aware and Hierarchical Chunking Are Stages, Not Strategies]]></title><description><![CDATA[Every chunking guide lists the strategies side by side: fixed size, semantic, hierarchical, layout-aware. Pick one. The guides define layout-aware as "respect the visual structure of the page" and hie]]></description><link>https://lijibuilds.hashnode.dev/layout-aware-vs-hierarchical-chunking-they-are-not-the-choice-you-think</link><guid isPermaLink="true">https://lijibuilds.hashnode.dev/layout-aware-vs-hierarchical-chunking-they-are-not-the-choice-you-think</guid><category><![CDATA[DOCLING]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[chunking]]></category><category><![CDATA[pdf]]></category><category><![CDATA[document parsing]]></category><dc:creator><![CDATA[Liji Alex]]></dc:creator><pubDate>Tue, 08 Sep 2026 00:28:00 GMT</pubDate><content:encoded><![CDATA[<p>Every chunking guide lists the strategies side by side: fixed size, semantic, hierarchical, layout-aware. Pick one. The guides define layout-aware as "respect the visual structure of the page" and hierarchical as "nest chunks under their headings and keep the parent". Both definitions are right. Neither says where in the pipeline the work happens, and for document RAG that is the whole problem. These are the two strategies that know what a document <em>is</em>, and the listicle framing cost me a day.</p>
<p>Layout-aware and hierarchical are not alternatives. They happen at different stages. You will almost always use both, and the real decisions are how much layout machinery you need and whether your heading structure is trustworthy. This post is what each one actually does, where each one runs, and how to decide.</p>
<p><strong>In short.</strong> Layout-aware and hierarchical chunking are not two strategies to choose between. Layout awareness is a property of the parser: Docling's default PDF pipeline already finds headings, tables and reading order from the page image. Hierarchical chunking is the step after it: <code>HybridChunker</code> walks the parsed tree and attaches each chunk's heading path. You use both; the real decisions are whether the default parser reads your pages (check labels per page) and whether the heading levels it returns are trustworthy (print them). The heavier vision-language pipeline is for scans and broken reading order, not for heading levels or missing pages.</p>
<h2>Parse, then chunk</h2>
<p>A document goes through two steps before it becomes chunks.</p>
<pre><code class="language-plaintext">PDF or Markdown  ──parse──▶  document tree  ──chunk──▶  chunks
                 (layout-aware)              (hierarchical)
</code></pre>
<p><strong>Parsing</strong> turns pixels or markup into a tree of labelled items. A page of a procedures manual comes out roughly like this:</p>
<pre><code class="language-plaintext">section_header  SOP 1 - Central Line Care
  section_header  Frequency
    text            Dressing change every 72 hours...
  section_header  Equipment checklist
    list_item       Sterile gloves
    list_item       Chlorhexidine 2%
</code></pre>
<p>This is where layout awareness lives. It is the parser's job to know that a two-column page reads top-to-bottom per column, that a grid of numbers is a table, that a bold line at the top is a heading.</p>
<p><strong>Chunking</strong> walks that tree and decides where one retrievable piece ends and the next begins. Hierarchical chunking uses the heading structure the parser found. It has no idea what the page looked like. It only sees the tree.</p>
<p>So "layout-aware chunking" is really "chunking a tree that was built by a layout-aware parser". If the parser got the layout wrong, no chunker can fix it. If the parser got it right, a hierarchical chunker is the natural way to consume it.</p>
<h2>What layout-aware parsing means in practice</h2>
<p>The code in this post uses Docling, the open-source document converter I work with (<code>pip install docling</code>; this is Docling 2.123 with docling-core 2.92). It turns PDF, DOCX, HTML and Markdown into one tree type, <code>DoclingDocument</code>, and ships the chunkers that walk it. The code is Docling. The argument is not.</p>
<p>Docling's default PDF pipeline is already layout-aware. A layout model, an object-detection model trained on page images, looks at each rendered page and draws labelled boxes. It does not read the words; it decides what each box is. A table model recovers rows and columns. OCR runs on regions with no text layer, the invisible run of characters a digital PDF carries under its pixels, the thing that lets you select text. The pipeline then reconstructs reading order.</p>
<pre><code class="language-python">from collections import Counter
from docling.document_converter import DocumentConverter

doc = DocumentConverter().convert("nursing_manual.pdf").document
# iterate_items() yields (item, depth); depth is tree position, not heading level
print(Counter(item.label.value for item, _ in doc.iterate_items()))
</code></pre>
<p>On an eight-page procedures manual that prints:</p>
<pre><code class="language-plaintext">Counter({'list_item': 58, 'section_header': 34, 'text': 13, 'table': 4})
</code></pre>
<p>Every bullet is a <code>list_item</code>, every paragraph is <code>text</code>, every table is a <code>table</code> (its cells live inside it, not as separate items), every heading is a <code>section_header</code>. I configured none of that. Five seconds on a warm run.</p>
<p>A bad parse looks different, and you can see it in the same counter. Here is the same manual after a heading-inference tool promoted its numbered procedure steps to headings:</p>
<pre><code class="language-plaintext">Counter({'section_header': 56, 'list_item': 36, 'text': 13, 'table': 4})
</code></pre>
<p>Twenty-two list items became headings, the tree went six levels deep, and every downstream chunk inherited a breadcrumb like <code>Procedure &gt; 1 Perform hand hygiene</code>. The counter caught it before anything else did.</p>
<p>There is a heavier form of layout awareness: Docling's VLM pipeline. A vision-language model is an LLM that takes an image as input; this pipeline replaces the layout model, table model and OCR with one model that looks at the page and writes it back out as markup. I tried it hoping "reads the page like a human" would also mean "knows which heading is bigger", because heading levels come from font size and the layout model does not compare fonts.</p>
<table>
<thead>
<tr>
<th></th>
<th>Default pipeline</th>
<th>VLM pipeline</th>
</tr>
</thead>
<tbody><tr>
<td>Time, eight-page manual</td>
<td>5 seconds (warm)</td>
<td>about nine minutes (recalled, not re-run)</td>
</tr>
<tr>
<td>Headings found</td>
<td>34, all level 1</td>
<td>34, all level 1</td>
</tr>
<tr>
<td>Content</td>
<td>complete</td>
<td>one page missing</td>
</tr>
</tbody></table>
<p>Layout awareness and heading hierarchy are different problems. The VLM path is for documents the default layout model cannot read: scans with no text layer, dense multi-column layouts, figure-heavy pages. It is not an upgrade for clean digital text. Check which kind you have with <code>pdffonts file.pdf</code> (from poppler): if it lists fonts, there is a text layer and the default pipeline will read it.</p>
<h2>What hierarchical chunking means in practice</h2>
<p>Take the tree from above and walk it. Docling's <code>HierarchicalChunker</code> emits one chunk per leaf item, tagged with the heading path it sits under. List items under the same heading are merged into one chunk by default (<code>merge_list_items=True</code>). Headings are never chunk bodies; they only update the current path.</p>
<p>The page of the manual:</p>
<pre><code class="language-plaintext">SOP 1 - Central Line Care          &lt;- heading
    Frequency                      &lt;- sub-heading
    Dressing change every 72 hours...
    Equipment checklist            &lt;- sub-heading
    - Sterile gloves
    - Chlorhexidine 2%
    - Sterile drape
</code></pre>
<p>Becomes two chunks. <code>chunk.meta.headings</code> is the path, <code>chunk.text</code> is the body. <code>Manual</code> is the document title; Docling puts it at the root of every path.</p>
<pre><code class="language-plaintext">headings: ['Manual', 'SOP 1 - Central Line Care', 'Frequency']
text:     Dressing change every 72 hours...

headings: ['Manual', 'SOP 1 - Central Line Care', 'Equipment checklist']
text:     - Sterile gloves
          - Chlorhexidine 2%
          - Sterile drape
</code></pre>
<p>That path is what I mean by breadcrumb from here on. Embed it together with the body and the chunk carries its own context.</p>
<p>I wanted to see what that buys over a plain window, so I indexed the same manual twice: once through this chunker (28 chunks), once through a 500-character window with 50 overlap over the Markdown export (23 chunks). Query: <em>how often do I change a central line dressing</em>. Both found the paragraph. What came with it differed.</p>
<pre><code class="language-plaintext">fixed window, top hit:
  #### Frequency | Dressing change every 72 hours, or sooner if the dressing is
  soiled... | #### Equipment checklist | - Sterile gloves and sterile dressing pack...

hierarchical, top hit:
  ICU Nursing Procedures Manual &gt; SOP 1 - Central Venous Catheter (CVC) Care &gt; Frequency
  Dressing change every 72 hours, or sooner if the dressing is soiled...
</code></pre>
<p>The window has a heading fragment and half of the next section. Nothing in it says which line it is about. The hierarchical chunk names the SOP. Ask something where the discriminating word lives only in the heading and the gap opens: <em>safety rules when suctioning the airway</em> returned, as the window's first hit, the tail of the restraint-documentation section with the suctioning heading dangling at the bottom; the hierarchical first hit was the suctioning SOP itself, and the second was <code>SOP 6 - Endotracheal Suctioning &gt; Procedure &gt; Safety</code> with the one-line rule. Windows straddle section boundaries. Tree chunks do not.</p>
<p><code>HybridChunker</code> adds a token budget on top. Every embedding model has a hard input limit counted in its own tokens, so you hand the chunker the same tokenizer and <code>max_tokens</code> becomes that limit in the model's units. It splits any chunk that is too long, then merges undersized neighbours that share a heading path (<code>merge_peers=True</code>), so three one-line paragraphs under one heading come out as one chunk and a nine-step procedure stays together. A 16-row drug table gets split by rows, and every piece still says which column is which, because Docling does not embed a table as a grid. Each cell becomes <code>row header, column header = value</code>:</p>
<pre><code class="language-plaintext">Amoxicillin, Class = Penicillin. Amoxicillin, Route = Oral. Amoxicillin, Standard Dose = 500 mg TDS. ...
</code></pre>
<p>A piece holding rows 9 to 16 still knows what every value means.</p>
<pre><code class="language-python">from docling.chunking import HybridChunker
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=128,   # MiniLM accepts 256; 128 here so the splits below are easy to see
)
chunker = HybridChunker(tokenizer=tokenizer, merge_peers=True)

for chunk in chunker.chunk(doc):
    text_to_embed = chunker.contextualize(chunk)   # heading path + body: embed this
    body = chunk.text                               # body alone: store and show this
</code></pre>
<p>Two things the docs say quietly and I learned loudly.</p>
<p><strong>First, <code>max_tokens</code> caps the body. <code>contextualize()</code> adds the heading path afterwards.</strong> On the antimicrobials table, one piece had a body of 123 tokens and a contextualized text of 130, over the cap of 128. The two tokens past the cap were the end of <code>1.2 g Q8H</code>, the dosing frequency of the last drug in the piece. The worst piece in that document was 145 tokens; everything after 128 was <code>antihistamine vs antihypertensive. clonazepam, confused with =</code>, the second half of a look-alike-drug warning. If your embedding model truncates at exactly the cap, those tails are never embedded. The text is still stored, so the chunk looks complete when you print it; the vector simply does not know the end of it. Nineteen of the 38 chunks in that document were over the cap, and all nineteen were table pieces. Tables hit the cap because the splitter packs rows right up to it (table bodies: median 124 tokens, max 128); prose ends at paragraph boundaries and rarely gets close (median 63, max 96). Set the cap below the model limit by the length of your longest heading path:</p>
<pre><code class="language-python">longest_path = max(tokenizer.count_tokens("\n".join(c.meta.headings)) for c in chunks)  # 28 on my corpus
</code></pre>
<p>Then <code>max_tokens = 256 - longest_path</code>, with a little margin.</p>
<p><strong>Second, hierarchical chunking is only as good as the heading levels in the tree.</strong> Docling's PDF parser emits every heading at level 1 unless you enable its heading-hierarchy option. With flat levels the chunker keeps one heading per level, so <code>Frequency</code> overwrites <code>SOP 1</code> and the 72-hour chunk comes out as:</p>
<pre><code class="language-plaintext">flat levels:    headings: ['Frequency']
nested levels:  headings: ['ICU Nursing Procedures Manual', 'SOP 1 - Central Venous Catheter (CVC) Care', 'Frequency']
</code></pre>
<p>Same chunk boundaries, same body. Only the breadcrumb changed, and with it whether the chunk knows it is about a central line. The fix is one option on the PDF pipeline:</p>
<pre><code class="language-python">from docling.datamodel.pipeline_options import PdfPipelineOptions, HeadingHierarchyOptions
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.base_models import InputFormat

opts = PdfPipelineOptions(generate_parsed_pages=True,
                          heading_hierarchy_options=HeadingHierarchyOptions(enabled=True))
converter = DocumentConverter(format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=opts)})
</code></pre>
<p>I wrote that one up separately: <a href="https://lijibuilds.hashnode.dev/docling-flattens-your-pdf-headings-here-is-why-and-how-to-fix-it">Docling Flattens Your PDF Headings</a>. Notice what kind of bug it is. A parse-stage setting that you only see at the chunk stage. That is this whole post in one bug.</p>
<h2>Where each one is the wrong tool</h2>
<p><strong>Hierarchical chunking is wrong when there is no hierarchy.</strong> A CSV of FAQ rows, a flat list of product descriptions, a transcript. The tree is one heading and a hundred paragraphs. You get a hundred chunks with the same breadcrumb, which is fixed-size chunking with extra steps. For rows, make each row a chunk with its own fields as text. For flat prose, a plain token-window splitter does the same job with less machinery.</p>
<p><strong>Hierarchical chunking is also wrong when the hierarchy is a lie.</strong> Sub-headings the parser missed, or numbered steps the parser mistook for headings, like the 56-heading counter above. Print the heading levels first (the snippet is in the last section). If the tree does not match the page, fix the parse before you chunk.</p>
<p><strong>Extra layout machinery is wrong when the default already reads the page.</strong> The VLM pipeline costs a hundred times the time and, on my manual, lost a page of its own. Reach for it when the default pipeline mangles reading order or returns a page as one <code>text</code> blob with no headings and no tables. You can check for that by counting labels per page. A page that is missing altogether is a different problem. On my corpus it turned out to be a threading bug in the PDF parser (<code>docling-parse</code> up to 7.16 with the default multi-threaded backend, <a href="https://github.com/docling-project/docling/issues/4147">docling issue #4147</a>), not a layout limit, and a heavier layout model would not have brought it back. <a href="https://lijibuilds.hashnode.dev/docling-drops-pages-and-footnotes-without-a-word-three-numbers-catch-it">A separate post covers that bug</a>. Here is the check, run on a nine-page PDF with the default backend:</p>
<pre><code class="language-python">from collections import Counter
# prov is provenance: the page and box each item was read from. [0] because an item can span pages.
per_page = Counter(item.prov[0].page_no for item, _ in doc.iterate_items() if item.prov)
print(sorted(per_page.items()))   # every page should appear with a non-zero count
</code></pre>
<pre><code class="language-plaintext">run 1:  [(1, 3), (3, 9), (4, 8), (5, 8), (6, 7), (7, 7), (8, 11), (9, 8)]   # page 2 gone
run 2:  [(1, 3), (4, 8), (5, 8), (6, 7), (7, 7), (8, 11), (9, 8)]           # pages 2 and 3 gone
run 3:  [(1, 3), (3, 9), (5, 8), (7, 7), (8, 11), (9, 8)]                   # pages 2, 4 and 6 gone
</code></pre>
<p>Different pages each run, no error, and the parse reported success every time. A page missing from that list is a parser problem. No chunking strategy will bring it back, and neither will a heavier layout model. Check the parser version before you change pipeline.</p>
<h2>The decision, as a table</h2>
<table>
<thead>
<tr>
<th>Your input</th>
<th>Parse</th>
<th>Chunk</th>
<th>Check</th>
</tr>
</thead>
<tbody><tr>
<td>Digital PDF with headings (manuals, policies, protocols)</td>
<td>Docling default, heading hierarchy enabled</td>
<td><code>HybridChunker</code>, cap below embedding limit</td>
<td>levels nest when printed; one chunk's <code>contextualize()</code> names its section</td>
</tr>
<tr>
<td>Markdown or HTML</td>
<td>Docling default (levels come from the syntax)</td>
<td><code>HybridChunker</code></td>
<td>same</td>
</tr>
<tr>
<td>Scanned or figure-heavy PDF</td>
<td>Docling with OCR; VLM pipeline only if reading order or table detection fails</td>
<td><code>HybridChunker</code>, expect weaker heading levels (OCR loses font sizes)</td>
<td>label counts per page look like a document, not one <code>text</code> blob</td>
</tr>
<tr>
<td>Table-shaped data (CSV, one record per row)</td>
<td>No Docling. Read rows directly.</td>
<td>One row = one chunk</td>
<td>a chunk reads as a complete record</td>
</tr>
<tr>
<td>Flat prose with no headings</td>
<td>Docling default</td>
<td>Token-window splitter, or <code>HybridChunker</code> accepting one breadcrumb for all</td>
<td>breadcrumbs are all identical, so nothing is lost by dropping them</td>
</tr>
</tbody></table>
<p>The left column is the only real decision. Look at the document, not the strategy list.</p>
<h2>One habit</h2>
<p>Before ingesting anything, print four things for one document. Thirty seconds.</p>
<pre><code class="language-python">from collections import Counter
from docling_core.types.doc import DocItemLabel

print(Counter(item.label.value for item, _ in doc.iterate_items()))          # 1. labels

for item, _ in doc.iterate_items():                                            # 2. heading levels
    if item.label == DocItemLabel.SECTION_HEADER:
        print(item.level, item.text)

chunk = next(iter(chunker.chunk(doc)))                                         # 3. one chunk, with its token count
text = chunker.contextualize(chunk)
print(tokenizer.count_tokens(text), text)

per_page = Counter(item.prov[0].page_no for item, _ in doc.iterate_items() if item.prov)
print(sorted(per_page.items()))                                                # 4. pages
</code></pre>
<p>The second print, on the manual with the hierarchy option on:</p>
<pre><code class="language-plaintext">1 ICU Nursing Procedures Manual
2 SOP 1 - Central Venous Catheter (CVC) Care
3 Frequency
3 Equipment checklist
3 Procedure
4 Escalate
2 SOP 2 - Mechanical Ventilator Management
</code></pre>
<p>If the labels look right, layout awareness is done. If the levels nest, the hierarchy is trustworthy. If the contextualized chunk reads like something you would want an answer built from and its token count is under your model's limit, the chunker is doing its job. If every page is in the fourth list, the parser read the whole file. Those four checks caught every problem in this post before it reached the vector store.</p>
<h2>Questions this post answers</h2>
<p><strong>What is the difference between layout-aware and hierarchical chunking?</strong>
Layout-aware describes parsing: turning a page into labelled blocks (headings, tables, lists) using a layout model. Hierarchical describes chunking: splitting along the heading tree the parser produced and keeping each chunk's heading path. They run at different stages, so you use both.</p>
<p><strong>When should I use Docling's VLM pipeline instead of the default?</strong>
When the default pipeline mangles reading order or returns a page as one text blob with no headings or tables. Not for heading levels (it still returns level 1) and not for missing pages, which on my documents were a threading bug in the PDF parser, not a layout limit.</p>
<p><strong>Why do my table chunks exceed the embedding token limit?</strong>
<code>HybridChunker</code> caps the chunk body, then <code>contextualize()</code> prepends the heading path. Table pieces are packed right up to the cap, so the added headings push them over and the tail is silently never embedded. Set the cap to the model limit minus your longest heading path.</p>
]]></content:encoded></item><item><title><![CDATA[Docling Gives Every PDF Heading Level 1. One Flag Fixes It.]]></title><description><![CDATA[Docling reads PDFs well, and it took me a day to notice the one thing it was not doing. Every heading it found came back at the same level. My RAG chunks lost their context, retrieval still looked fin]]></description><link>https://lijibuilds.hashnode.dev/docling-flattens-your-pdf-headings-here-is-why-and-how-to-fix-it</link><guid isPermaLink="true">https://lijibuilds.hashnode.dev/docling-flattens-your-pdf-headings-here-is-why-and-how-to-fix-it</guid><category><![CDATA[DOCLING]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[pdf]]></category><category><![CDATA[chunking]]></category><category><![CDATA[document parsing]]></category><dc:creator><![CDATA[Liji Alex]]></dc:creator><pubDate>Tue, 08 Sep 2026 00:09:08 GMT</pubDate><content:encoded><![CDATA[<p>Docling reads PDFs well, and it took me a day to notice the one thing it was not doing. Every heading it found came back at the same level. My RAG chunks lost their context, retrieval still looked fine, and the answers quietly got worse.</p>
<p>I hit this building a RAG pipeline over a set of internal hospital manuals, where each chunk carries the headings above it so a paragraph about "72 hours" still knows it is about central lines. This post is what I learned figuring out why the headings were flat, what the fix is, and which PDFs make the fix trivial. Docling 2.123 throughout; older versions do not have the option described below.</p>
<p><strong>In short.</strong> Docling's PDF pipeline labels every heading <code>level 1</code> because the stage that ranks headings is off by default. Turn on <code>HeadingHierarchyOptions(enabled=True)</code> and set <code>generate_parsed_pages=True</code> on the PDF pipeline options; headings then nest from bookmarks, numbering or font size, and each chunk's heading path carries its parent section again. PDFs converted from HTML already have bookmarks and get exact levels from that one flag; PDFs from ReportLab and most internal generators rely on numbering and font size.</p>
<h2>What Docling gives you</h2>
<p>Docling reads a document into a tree. Every piece of content becomes an item with a label. The two that matter here are <code>section_header</code> and <code>text</code>; tables, lists and page furniture get their own labels. Each <code>section_header</code> also carries a <code>level</code>: 1 for a title, 2 for a section under it, 3 for a sub-section, exactly what <code>#</code>, <code>##</code>, <code>###</code> mean in Markdown.</p>
<p>How Docling gets there depends on the format. For Markdown it reads the syntax, so the level is free. For a PDF, a layout model, a vision model that looks at the rendered page, draws a box around each block and classifies it, decides what each box is. It judges one box at a time. It can say "this is a heading". It has no idea whether this heading is bigger than the one above it.</p>
<p>You can inspect the headings directly:</p>
<pre><code class="language-python">from docling.document_converter import DocumentConverter

doc = DocumentConverter().convert("manual.pdf").document
for item in doc.texts:                     # every text-like item, in reading order
    if item.label == "section_header":     # only headings carry a meaningful .level
        print(item.level, item.text)
</code></pre>
<p>On a Markdown billing guide from the same corpus that prints the nesting you would expect:</p>
<pre><code class="language-plaintext">1 Purpose &amp; Scope
2 How claims flow
1 1. Cashless Claim Process
2 1.1 Pre-authorisation timeline
2 1.2 Documents required for pre-authorisation
</code></pre>
<p>On a PDF nursing manual it prints this:</p>
<pre><code class="language-plaintext">1 ICU Nursing Procedures Manual
1 SOP 1 - Central Venous Catheter (CVC) Care
1 Frequency
1 Equipment checklist
1 Procedure
1 Escalate
1 SOP 2 - Mechanical Ventilator Management
</code></pre>
<p>Every heading level 1. Every time, with the default settings, whatever the PDF looks like. If you prototype on Markdown and switch to PDFs later, this is the moment everything changes: same code, same chunker, different results.</p>
<h2>The heading problem</h2>
<p>That manual looks like this on the page:</p>
<pre><code class="language-plaintext">ICU Nursing Procedures Manual                &lt;- title, 26pt bold
SOP 1 - Central Venous Catheter (CVC) Care   &lt;- section, 16pt bold
    Frequency                                &lt;- sub-section, 10.8pt bold
    Dressing change every 72 hours...           body, 9.7pt
    Equipment checklist                      &lt;- sub-section, 10.8pt bold
    - Sterile gloves...
    Procedure                                &lt;- sub-section, 10.8pt bold
    1. Perform hand hygiene...

SOP 2 - Mechanical Ventilator Management     &lt;- section, 16pt bold
    Initial settings                         &lt;- sub-section, 10.8pt bold
    ...
</code></pre>
<p>Three heading sizes. Anyone can see <code>Frequency</code> belongs to <code>SOP 1</code>.</p>
<p>Docling's layout model classifies "this box is a heading" and stops. Docling does have a later pipeline stage that ranks headings against each other, but it is switched off by default, so every detected heading is emitted at <code>level=1</code>, even when the file carries a perfectly good outline. The tree says <code>SOP 1</code>, <code>Frequency</code>, <code>Procedure</code>, <code>SOP 2</code> are siblings.</p>
<p>Now feed that to a hierarchical chunker. A chunker cuts a document into pieces small enough to embed and retrieve. A hierarchical one also writes down the headings each piece sits under and puts them in front of the text before embedding. That heading path is the breadcrumb, and it is the only way a paragraph about "72 hours" knows it is about central lines. Docling's default chunker, <code>HybridChunker</code>, does this.</p>
<p>Here is how it tracks the path. Think of one slot per level. <code>SOP 1</code> arrives at level 1 and goes in slot 1. <code>Frequency</code> also arrives at level 1, so it goes in slot 1 and evicts <code>SOP 1</code>. When the chunker reaches the paragraph about 72 hours, slot 1 holds <code>Frequency</code> and nothing else exists. The text it hands to the embedding model, <code>chunker.contextualize(chunk)</code>, is:</p>
<pre><code class="language-plaintext">Frequency
Dressing change every 72 hours, or sooner if the dressing is soiled, loose or no longer occlusive.
</code></pre>
<p>Frequency of what? The chunk does not say. The words "central line" appear nowhere in it.</p>
<p>I expected retrieval to miss it. It did not. I asked "how often do I change a central line dressing" against a store built from this flat parse and the 72-hour chunk came back first, because the dense embedding matched "dressing change". What broke was everything after rank one:</p>
<pre><code class="language-plaintext">#1  Manual &gt; Frequency          Dressing change every 72 hours...
#2  Manual &gt; Procedure          1 Perform hand hygiene and don PPE...
#3  Manual &gt; Procedure          1 Hand hygiene; don gloves, apron and eye protection...
</code></pre>
<p>Two <code>Procedure</code> chunks from two different SOPs, indistinguishable. Ask "how often should I change the dressing" and the top three include <code>Frequency: 72 hours</code> and <code>Cannula change: 72-96 hours</code> with no way to tell the central line rule from the cannula rule. The LLM that gets those three chunks has to guess which applies. Multiply by every manual with repeated sub-headings (<code>Fault codes</code> under four devices, <code>Monitoring</code> under seven protocols) and the answers degrade while the retrieval metrics look fine.</p>
<p>This is not a chunker bug. The chunker did exactly what the tree told it. The tree was flat.</p>
<h2>Some PDFs make the fix trivial. Check yours.</h2>
<p>A PDF can carry an outline: the bookmarks sidebar you see in a PDF viewer. Each entry already has a level. PDFs converted from HTML (wkhtmltopdf, a browser's print-to-PDF, most documentation-site exporters) get one automatically, because <code>&lt;h1&gt;</code>, <code>&lt;h2&gt;</code>, <code>&lt;h3&gt;</code> become bookmarks with their depth intact. Once you switch Docling's heading-hierarchy stage on, it matches that outline to the detected headings and, when the match is confident, uses it as the hierarchy. No inference involved.</p>
<p>PDFs from ReportLab, many Word exports, scanners, and the internal systems I have seen have no outline at all. For those, Docling has to infer the hierarchy from numbering and font style. It can, but it is guessing, so you check the result.</p>
<p>Find out which kind you have before you spend a day on chunking:</p>
<pre><code class="language-python">import pymupdf
print(pymupdf.open("manual.pdf").get_toc()[:3])
</code></pre>
<p>On an HTML-converted policy document with 54 bookmarks:</p>
<pre><code class="language-plaintext">[[1, 'HR Policies', 1], [2, 'Employment &amp; Onboarding', 1], [3, 'Offer and Joining Formalities', 1]]
</code></pre>
<p>The first number in each entry is the level. On the ReportLab nursing manual the list is empty. Empty means you are in the inference case. Either way, the next step is the same flag. On that 54-bookmark file the default converter gave <code>[1]</code> for the set of levels; with the flag on, <code>[1, 2, 3]</code>.</p>
<h2>The fix is built in and off by default</h2>
<p>Docling has a heading-hierarchy stage that assigns <code>SectionHeaderItem.level</code>. It is off by default. My guess at why: a wrong level silently breaks a downstream chunker, while a flat level is at least predictable, so Docling leaves the choice to you.</p>
<pre><code class="language-python">from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import HeadingHierarchyOptions, PdfPipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption

pipeline_options = PdfPipelineOptions()
pipeline_options.heading_hierarchy_options = HeadingHierarchyOptions(enabled=True)
pipeline_options.generate_parsed_pages = True   # keeps font sizes for the style signal; see below

converter = DocumentConverter(
    format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)}
)
doc = converter.convert("manual.pdf").document
</code></pre>
<p><code>generate_parsed_pages</code> needs a sentence now, not later. Docling normally throws away the low-level parser output (each run of text with its font, size and weight) once the document tree is built. This flag keeps it. The font-size signal reads from there, so without it that signal has nothing to look at.</p>
<p>With both lines on, the same manual comes out as:</p>
<pre><code class="language-plaintext">1 ICU Nursing Procedures Manual
2   SOP 1 - Central Venous Catheter (CVC) Care
3     Frequency
3     Equipment checklist
3     Procedure
4       Escalate
2   SOP 2 - Mechanical Ventilator Management
3     Initial settings
</code></pre>
<p>And the text handed to the embedding model for the same chunk becomes:</p>
<pre><code class="language-plaintext">ICU Nursing Procedures Manual
SOP 1 - Central Venous Catheter (CVC) Care
Frequency
Dressing change every 72 hours, or sooner if the dressing is soiled, loose or no longer occlusive.
</code></pre>
<p>Same chunk boundaries as before. Only the heading path changed. The same three queries against a store built from this parse:</p>
<pre><code class="language-plaintext">#1  Manual &gt; SOP 1 - Central Venous Catheter (CVC) Care &gt; Frequency          72 hours
#2  Manual &gt; SOP 1 - Central Venous Catheter (CVC) Care &gt; Procedure
#3  Manual &gt; SOP 1 - Central Venous Catheter (CVC) Care &gt; Equipment checklist
</code></pre>
<p>And for "how often should I change the dressing", the two 72-hour rules now read <code>SOP 1 - Central Venous Catheter &gt; Frequency</code> and <code>SOP 4 - IV Cannula Insertion &gt; Cannula change</code>. The LLM no longer has to guess.</p>
<h3>Three signals, in order</h3>
<p>The stage tries three signals per heading. The first one that applies wins.</p>
<ol>
<li><strong>Bookmarks.</strong> The PDF outline, matched to detected headings by title and page. A confident match is authoritative.</li>
<li><strong>Numbering.</strong> Leading markers in the heading text: <code>PART I</code>, <code>CHAPTER 2</code>, <code>1.</code>, <code>1.1</code>, <code>A.</code>, <code>(a)</code>, <code>(i)</code>.</li>
<li><strong>Visual style.</strong> Font size first. Then, if two headings share a size, weight (bold above regular), slant (upright above italic) and letter case (all-caps above mixed).</li>
</ol>
<p>Bookmarks and numbering work on the parsed text alone. Style needs the raw text cells, which is what <code>generate_parsed_pages = True</code> keeps around. Forget that flag and the style pass is skipped silently. On my manuals, which have no bookmarks and whose sub-headings carry no numbering, forgetting it gave back exactly the flat tree from the default run, every heading level 1, and I spent twenty minutes convinced the option did nothing. It was doing everything it could with the two signals it had, and those two had nothing to say.</p>
<h3>The two options you might touch</h3>
<table>
<thead>
<tr>
<th>Option</th>
<th>Default</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>enabled</code></td>
<td><code>False</code></td>
<td>Master switch.</td>
</tr>
<tr>
<td><code>use_bookmarks</code></td>
<td><code>True</code></td>
<td>Use the PDF outline when present.</td>
</tr>
<tr>
<td><code>use_style</code></td>
<td><code>True</code></td>
<td>Fall back to font size. Needs <code>generate_parsed_pages</code>.</td>
</tr>
<tr>
<td><code>max_level</code></td>
<td><code>6</code></td>
<td>Deepest level assigned.</td>
</tr>
</tbody></table>
<p>Most documents need nothing beyond <code>enabled=True</code>. Two worth knowing: <code>use_style=False</code> when a document uses the same font for everything and style inference invents levels that are not there, and <code>max_level</code> when you know the corpus never goes deeper than a few levels and want callout labels clipped. The full option list (numbering schemes, size tolerance, bookmark match threshold) is in the <a href="https://docling-project.github.io/docling/usage/heading_levels/">Docling docs</a>.</p>
<h3>Applying it to a document you already parsed</h3>
<p>If you cached converted documents and do not want to pay for the layout model again, you can run the same stage on a <code>DoclingDocument</code> after the fact. One constraint first: the standalone call only sees the document. The parsed pages that style inference needs live on the conversion result, not the document, so style cannot run here. Bookmarks and numbering can. Turn style off and run it:</p>
<pre><code class="language-python">from docling.datamodel.pipeline_options import HeadingHierarchyOptions
from docling.models.stages.heading_hierarchy.heading_hierarchy_model import HeadingHierarchyModel

model = HeadingHierarchyModel(options=HeadingHierarchyOptions(use_style=False))
model.assign_heading_levels(doc)
</code></pre>
<p>If you run Docling as a server instead of a library, the same switch is <code>do_pdf_heading_hierarchy: true</code> in the request, with the options under <code>pdf_heading_hierarchy_options</code>.</p>
<h2>One habit that would have saved me a day</h2>
<p>Print the heading tree for every document, before and after, and read it:</p>
<pre><code class="language-python">headers = [(t.level, t.text) for t in doc.texts if t.label == "section_header"]
print(sorted({level for level, _ in headers}))
for level, text in headers:
    print("  " * (level - 1), text)
</code></pre>
<p>A healthy tree indents. A flat one is a list of <code>1</code>s flush left, and that is the output that tells you to go back and check bookmarks, numbering and the parsed-pages flag. The whole recipe is three lines: count the bookmarks, turn on <code>enabled=True</code> with <code>generate_parsed_pages=True</code>, print the tree and read it. Docling is doing the hard part well. It just needs to be told that headings have ranks, and then checked.</p>
<h2>Questions this post answers</h2>
<p><strong>Why are all my Docling headings level 1?</strong>
The layout model decides that a box is a heading but never compares two headings, and the pipeline stage that assigns levels is disabled by default. Every PDF heading comes out at level 1 until you enable it; Markdown keeps its <code>#</code>/<code>##</code> levels.</p>
<p><strong>Does Docling use PDF bookmarks for heading levels?</strong>
Only once <code>HeadingHierarchyOptions(enabled=True)</code> is on. With the default converter a PDF with 54 bookmarks still returned every heading at level 1; with the flag it returned a clean three-level tree.</p>
<p><strong>What does generate_parsed_pages do in Docling?</strong>
It keeps the raw text cells from the PDF parser, which carry font size and weight. The font-style signal of the heading-hierarchy stage reads them; without the flag that signal is skipped silently and only bookmarks and numbering apply.</p>
]]></content:encoded></item><item><title><![CDATA[Solving the Memory Problem]]></title><description><![CDATA[Claude Code already has memory. That is not the issue. The issue is that real product work rarely stays inside one neat repository. A feature may start in repo1, spill into repo2, touch an API contrac]]></description><link>https://lijibuilds.hashnode.dev/solving-the-memory-problem</link><guid isPermaLink="true">https://lijibuilds.hashnode.dev/solving-the-memory-problem</guid><dc:creator><![CDATA[Liji Alex]]></dc:creator><pubDate>Fri, 12 Jun 2026 01:56:41 GMT</pubDate><content:encoded><![CDATA[<p>Claude Code already has memory. That is not the issue. The issue is that real product work rarely stays inside one neat repository. A feature may start in repo1, spill into repo2, touch an API contract in repo3, and then come back to repo1 for the actual fix. Sometimes we know this upfront. Most of the time, we do not, unless we built the product from the ground up.</p>
<p>Claude Code’s native memory is useful, but it is mostly organised around the current project (cwd) or repository. That works well when your work is repo-shaped. It breaks when your work is feature-shaped.</p>
<p>And most meaningful software work is feature-shaped. That is the gap I am trying to solve with <a href="https://github.com/LijiAlex/layered-memory">layered-memory</a>.</p>
<h2>What Claude Code already gives you</h2>
<p>Before building anything new, it is worth being clear about what already exists. Claude Code has three different mechanisms that get grouped under “memory”.</p>
<h3><strong>CLAUDE.md</strong></h3>
<p>This is the memory file you author. It is plain markdown. You can keep instructions, project conventions, commands, architectural notes and working preferences in it. Claude loads it into context when you start a session.</p>
<p>The important point is this: <strong>CLAUDE.md is not really learning</strong>. <strong>It is configuration. You write it. You maintain it. It is authoritative and always loaded.</strong></p>
<h3><strong>Auto Memory</strong></h3>
<p>Claude Code also has Auto Memory. It can record learnings by itself: build commands, debugging notes, conventions and other facts it discovers while working. This is much closer to actual memory. <strong>The useful design choice is progressive disclosure.</strong> Claude does not load every detail at startup. It keeps a lightweight index and loads detailed topic files only when needed. That part is good.</p>
<p>The limitation is where things get interesting: <strong>Auto Memory is organised per project (cwd) or repository</strong>. If your feature spans multiple repos, the memory gets split across multiple silos.</p>
<h3><strong>Transcripts / context</strong></h3>
<p>Every Claude Code session is saved as a transcript you can resume, but <strong>the conversation history lives only within that session</strong>. As the context window fills, response quality degrades ("context rot"), so Claude Code auto-compacts — summarising turns to free up space. That summary is lossy, and some details and decisions can get dropped along the way.</p>
<p>The clean mental model is:</p>
<blockquote>
<p>CLAUDE.md → rules; you author</p>
<p>Auto Memory → facts Claude learns, mostly per repo</p>
<p>Transcript → session level history</p>
</blockquote>
<p>Useful? Yes. Enough for multi-repo product work? Not for me.</p>
<h2><strong>Where it breaks</strong></h2>
<p>Here is the actual situation we run into regularly at work. We work on a product that spans multiple repositories. Each repo has a specific role. But features do not care about repo boundaries.</p>
<p>One feature may involve:</p>
<ul>
<li><p>repo1 + repo2</p>
</li>
<li><p>repo1 + repo2 + repo3</p>
</li>
<li><p>repo1 only</p>
</li>
<li><p>repo2 first, then repo1 later</p>
</li>
<li><p>repo1, repo2 and a Kafka pipeline which I discover while exploring</p>
</li>
</ul>
<p>But Claude’s native memory does not naturally think in terms of features. It thinks in terms of the <em>current project</em> derived from <em>current working directory</em>.</p>
<p>So the same feature ends up fragmented like this: (here read <em>repo</em> as <em>cwd</em>)</p>
<ul>
<li><p>~/.claude/projects/repo1/memory/</p>
</li>
<li><p>~/.claude/projects/repo2/memory/</p>
</li>
<li><p>~/.claude/projects/repo3/memory/</p>
</li>
</ul>
<p>We may have solved half the problem yesterday in repo2, but when we open repo1 today, that knowledge is not naturally available in the same feature context.</p>
<blockquote>
<p>The memory exists. It is just in the wrong place.</p>
</blockquote>
<p>There is a workaround. You can point multiple repos to one shared memory directory, or work from the base folder containing all repos. We do this when we already know a problem is going to involve multiple repositories.But that creates a different problem. Everything collapses into one shared heap. Now memories from unrelated features sit together in one flat space. You have avoided fragmentation, but created mush.</p>
<p>That is the core gap:</p>
<blockquote>
<p>Claude Code gives you repo memory or shared memory. What I wanted was feature memory. Cross-repo, feature-organised, progressively loaded memory.</p>
</blockquote>
<p><em>layered-memory</em> is my attempt to solve this.</p>
<h2>What I am building: <strong>layered-memory</strong></h2>
<p>Instead of keying memory only by repository, it reads what actually happened across Claude Code sessions and organises the useful parts by a theme.</p>
<blockquote>
<p>A theme is a durable topic. In practice, the theme is usually a feature, a subsystem, a long-running bug, a design decision, or a recurring workflow.</p>
</blockquote>
<p>The storage is deliberately boring:</p>
<pre><code class="language-plaintext">~/.claude/memory/
├── index.md # lightweight (always loaded at session start)
└── themes/
    ├── &lt;slug&gt;.md  # one file per theme (loaded on demand)
    └── ...
</code></pre>
<p>Plain markdown. Readable. Editable.Portable. No mystery database. No hidden vector store. No black box.</p>
<p>The system has three parts:</p>
<ul>
<li><p><strong>Build</strong> — read transcripts and extract durable memory.</p>
</li>
<li><p><strong>Reconcile</strong> — merge duplicates and clean overlaps.</p>
</li>
<li><p><strong>Recall</strong> — load the right theme when it is useful.</p>
</li>
</ul>
<h3>Build: mine the history, not just the current repo</h3>
<p>The build step walks through Claude Code session transcripts across repositories. It reads the actual history of work: what was tried, what broke, what got fixed, what decisions were made, which files mattered and what conventions emerged. Each transcript is distilled into durable facts and grouped by theme. This matters because a lot of useful memory does not get written down in the moment.</p>
<p>It appears across the shape of the whole session:</p>
<ul>
<li><p>the first assumption that turned out wrong</p>
</li>
<li><p>the actual root cause</p>
</li>
<li><p>the final working fix</p>
</li>
<li><p>the file that mattered but was not obvious</p>
</li>
<li><p>the command that became reliable</p>
</li>
<li><p>the convention discovered after three failed attempts</p>
</li>
</ul>
<p><em>Native memory may capture some of this. But layered-memory looks back at the full transcript after the fact and asks:</em></p>
<ul>
<li>What should survive this session?</li>
</ul>
<p>That is the difference. It is not just jotting notes.... It is mining history....</p>
<h3>Reconcile: keep memory coherent</h3>
<p>Once you start building memory from many sessions, another problem appears.</p>
<p>Themes overlap. You get things like:</p>
<ul>
<li><p>foo-plugin</p>
</li>
<li><p>foo-plugin-setup</p>
</li>
<li><p>foo-plugin-debugging</p>
</li>
<li><p>foo-plugin-tuning</p>
</li>
</ul>
<p>They may all be the same real topic.</p>
<p>So layered-memory has a reconcile step.It looks across the full set of themes and merges duplicates or near-duplicates. It preserves distinct facts, removes true redundancy and keeps the theme focused.</p>
<p>The goal is simple:</p>
<blockquote>
<p>Memory should not become a junk drawer. It should become sharper over time.</p>
</blockquote>
<h3>Recall: load only what matters</h3>
<p>The final part is recall. This is where the system becomes useful inside a real Claude Code session.</p>
<p>At startup, Claude sees only a small index:</p>
<ul>
<li><p>theme name</p>
</li>
<li><p>one-line summary</p>
</li>
<li><p>keywords</p>
</li>
</ul>
<p>That gives Claude awareness of what memory exists without stuffing the whole past into context. When the current task matches a theme, the recall skill loads the full theme file on demand. So if I am working on an authorisation issue, Claude can load the authorisation theme. It does not need to load memories about governance workflows, plugin setup, release automation, or some old debugging session from another feature.</p>
<p>That is the point:</p>
<blockquote>
<p>Index always. Detail only when relevant.</p>
</blockquote>
<p>This mirrors the best part of Claude’s native memory, but applies it across repositories and themes rather than keeping it trapped inside repo boundaries.</p>
<h3><strong>What this gives me</strong></h3>
<p>With layered-memory, the unit of memory becomes the work itself. Not the repo. Not the folder. Not the current shell location. The work.</p>
<p>That means:</p>
<ul>
<li><p>a feature spanning three repos can have one coherent memory</p>
</li>
<li><p>old debugging lessons can resurface in the right context</p>
</li>
<li><p>related sessions can consolidate into a single theme</p>
</li>
<li><p>memory can stay readable as markdown</p>
</li>
<li><p>context stays lean because only the relevant theme is loaded</p>
</li>
</ul>
<blockquote>
<p>It does not replace Claude Code’s native memory.It adds another layer on top. That is why I called it layered-memory.</p>
</blockquote>
<h2>What is coming next</h2>
<p>This is just the beginning. I have already hit and solved several issues while building <code>layered-memory</code>. Some are still open. Some I probably have not discovered yet.</p>
<p>The next entries will go issue by issue.</p>
<p>Not theory. Actual problems from the build: what broke, what I tried, what worked, what failed and how the system improved.</p>
<p>This first post is the map. The rest of the series is the build log.</p>
]]></content:encoded></item></channel></rss>