Skip to content
This document describes intent at the time it was written. It is part of the design record, not user documentation, and may not reflect the current behaviour of the code.

011 - IR spike: docling pipeline

Status: Research notes. Second of three planned spikes feeding the IR-foundation recommendation in [research note 012] (TBC).

Date of run: 2026-05-12.

What this note is. The docling spike under research note 008. It wires DoclingDocument into the same harness used for note 009 (status quo) and runs the same metric battery against the same 35-fixture corpus. Docling parses storage XHTML through its HTML reader, emits markdown via the built-in serializer, then re-parses that markdown and re-emits HTML for the comparison side of the round-trip.

What this note is not. A judgement about adopting docling — that falls out of note 012 once the Pandoc spike (010) also runs. Docling treats the input as generic HTML; it has no native understanding of the Confluence storage XHTML namespace (ac:, ri:). The numbers below therefore measure docling’s generic fidelity, which is the honest baseline for asking “is the structural layer alone good enough to bolt Confluence semantics on top?”

  • Harness: scripts/ir_experiment/ at commit landing this note. Same as note 009 plus the pipelines/docling.py adapter and wall-clock timing instrumentation.
  • Corpus: test-confluence/MDD/_snapshots/, the same 35 captured pages as note 009. No refresh; numbers are directly comparable.
  • Pipeline: pipelines/docling.pysupports_provenance = True (one self_ref per top-level text item in the parsed DoclingDocument), supports_identity = False (Confluence ac:macro-id / ac:local-id are dropped by docling’s HTML reader).
  • Round-trip mode: in-memory only. Storage XHTML → DoclingDocument(InputFormat.HTML)export_to_markdown()DoclingDocument(InputFormat.MD)export_to_html(html_head='') with the page-div wrapper stripped so comparisons line up with the source fragment shape.
  • Metrics implemented: M1 (text fidelity), M2 (structural fidelity), M3 (identity preservation, gated off here), M4 (provenance coverage), M5 (whitespace drift), M6 (code surface). New since note 009: wall-clock timing per direction, surfaced in the report.
metricdocling meanstatus_quo mean (note 009)delta
text_fidelity0.94480.8559+0.089
structural_fidelity0.41440.6964−0.282
whitespace_drift0.60120.6062−0.005
identity_preservation
provenance_coverage1.0000new

The single most striking pair: docling beats status_quo on text fidelity (it preserves content more cleanly because it normalises through a structured intermediate that doesn’t fall back to verbose {=confluence} raw blocks) but loses on structural fidelity (it flattens the block tree because it has no model of Confluence macros and aggressively re-paragraphs hard-wrapped prose).

provenance_coverage = 1.0 reflects the design choice: every top-level text item in the parsed DoclingDocument has a stable self_ref (e.g. #/texts/2). The pipeline exposes one per markdown block. This is the “yes, docling has per-block identity hooks” signal. Whether those refs survive a fresh parse — i.e. provide a round-trippable identity — is M3’s question and gated off here because we don’t currently round-trip them through markdown.

Healthy: paragraphs, simple inline, nested lists

Section titled “Healthy: paragraphs, simple inline, nested lists”

Pure prose round-trips well on M1 (≥0.99 for most paragraph and heading fixtures). Nested lists score 1.00/1.00 on M1/M2 — docling’s list model survives the markdown intermediate cleanly. Plain bullet list: 1.00/1.00. Where status_quo over-paragraphs list items (p: 1→6, note 009), docling preserves the list shape.

provenance_coverage = 1.00 across the whole corpus: each fixture yields between 3 and 21 text items with stable refs in document order. This is a real improvement over status_quo, which exposed no provenance.

Brittle: hard-wrapped paragraphs explode on re-parse

Section titled “Brittle: hard-wrapped paragraphs explode on re-parse”

Top structural-fidelity offender: “Multiple paragraphs” — 4 paragraphs become 12 in the round-trip (M2 = 0.00). Why: docling’s markdown export inserts a blank line after every hard line wrap in prose, and the markdown re-parse then treats each line as its own paragraph. The character content is preserved (M1 = 0.99) but the block tree inflates. The same shape sinks “External URL link” (p: 2→6, M2 = 0.00), “Acme Internal Engineering Handbook” (M2 = 0.28), and “Platform glossary” (M2 = 0.04).

This is fixable in a future variant by either (a) configuring docling’s markdown serializer to not hard-wrap, or (b) post-processing the markdown to rejoin paragraphs before round-trip. Neither was attempted in this spike — the point is to measure docling out of the box.

Plain code block (no language) — M2 = 0.00, M1 = 0.74. Docling’s HTML reader treats <pre><code> correctly as a code block (good), but on the way back through markdown, the code fence parses as expected and the surrounding prose explodes into per-line <p> — plus the inline <code> spans in the prose round-trip as <span class='inline-group'><code>...</code></span> wrappers, not <p>. The harness’s M2 counts those span wrappers as missing p blocks, deepening the drift.

Code block with characters that need CDATA scores M1 = 0.72. Status_quo scored 0.53 here, so docling is better on the CDATA-sensitive content (no verbose escape inflation) but still not whole — docling’s markdown export reflows the code-block content lightly, and the diff is real.

Plain table — M2 = 0.95 (very close), M1 = 0.95. Tables come through with their shape mostly intact. But Table with mixed inline content in cells drops to M1 = 0.73 / M2 = 0.30, and Table with merged cells is the worst overall M1 at 0.70.

Docling’s HTML reader collapses rich inline structure inside <td> cells (likely model-specific: the docling table model is oriented towards layout-detected tables, not authored HTML tables). The merged-cells case loses the merge metadata entirely — expected, since the cell-merge is encoded as rowspan / colspan that docling normalises away.

Brittle: Confluence-specific elements are invisible

Section titled “Brittle: Confluence-specific elements are invisible”

This is the by-design limitation, not a bug. Storage XHTML elements docling doesn’t recognise (ac:structured-macro, ac:link, ri:page, ri:url, …) are silently dropped at the wrapper level. Macro body content survives; the macro identity does not. Examples from the run:

  • Tip / panel / warning / note / info callouts — body text scores M1 ≥ 0.97 each (content survives) but the macro wrapper is lost entirely. Notably docling here scores higher on M1 than status_quo on most callouts (status_quo collapses to blockquote with content drift); the structural loss is the same shape (macro → no macro).
  • ac:link / ri:page and ac:link / ri:attachment — the link collapses to plain text (the title or alt-text gets emitted), the page/attachment reference is lost. M1 stays ≥0.92 because the visible content is preserved; M2 hits 0.00 because the expected ac:link block is gone.
  • ac:structured-macro status, children, expand, view-file — the macro is dropped; some surface text in the body survives. M1 ≥ 0.96 in each case (much better than status_quo, which expanded these to verbose XML).
  • M1 high, M2 low: paragraph-heavy fixtures with hard-wrapped source. Status_quo had this disagreement on lists; docling has it on prose. Same family of failure, different shape.
  • M1 low, M2 reasonable: the merged-cell table case. M1 = 0.70, M2 = 0.48. The cell content shrinks (M1 sees the loss); the outer block count survives (M2 misses it).
  • provenance_coverage = 1.0 everywhere: by construction. M4 only measures coverage, not whether refs round-trip. A future M4-bis that compares storage→md→storage refs against the originals would tell us whether the self_refs are useful for merge semantics or merely present.

New instrumentation since note 009: wall-clock per direction. Not a benchmark — single-threaded, no warmup, cold caches — but the ratios are informative.

pipelinetotal round-trip (35 fixtures)mean per fixture
status_quo23 ms0.65 ms
docling~330 ms~9.4 ms

Docling is ~15× slower per fixture in memory. The slowest fixture (Platform glossary) takes 71 ms end-to-end vs status_quo’s 2 ms on the same input. This is comfortably within “research tool” territory — a full 35-fixture run is sub-second on either pipeline — but worth recording in case the corpus grows by an order of magnitude or the harness ends up in CI.

The variance (max 71 ms, min 1.7 ms) tracks document size; the docling HTML reader has noticeable startup amortised across each convert() call. A persistent converter (already what we have: one DocumentConverter instance reused per pipeline) is the cheap win and is in place.

  • M3 identity preservation — gated off here. To turn it on we’d need to round-trip ac:macro-id / ac:local-id through the markdown intermediate, which docling’s HTML reader currently discards as unknown attributes. A variant pipeline could pre-extract those IDs to a sidecar map; deferred.
  • Structural fidelity discount for paragraph-explosion — the hard-wrap paragraph explosion is a real structural drift but it inflates the M2 penalty in a way that conflates “real structural loss” with “cosmetic over-segmentation”. Note 012 should weigh this honestly.
  • Live round-trip — same caveat as note 009. The numbers are an upper bound; Confluence-side normalisation can only make fidelity worse.

Versus the status-quo baseline (note 009), docling:

  1. Wins on content preservation (M1 +0.09). It doesn’t fall back to verbose raw-XML blocks for shapes the writer doesn’t recognise — the content just comes through as prose.
  2. Wins on provenance hooks (M4 = 1.00 vs n/a). Per-block self_ref is real, stable within a parse, and free.
  3. Loses on structural fidelity (M2 −0.28). The block tree gets flattened both by paragraph re-segmentation and by silent dropping of unknown elements. The structural loss is different in shape from status_quo’s (which kept the macros but mangled the inline shapes) but not smaller.
  4. Identical on whitespace drift (M5 effectively tied). Both pipelines emit different whitespace from the original on most fixtures; neither is meaningfully tighter than the other.
  5. Massively wins on code surface (M6 = 78 LOC vs 576 LOC). 78 lines of adaptor code drive the docling backend; the status_quo numbers count the full custom converter implementation. This is real but partial: docling itself is a substantial dependency, just not one we maintain.

For note 012 to recommend docling over status_quo, the open questions to answer:

  • Can the hard-wrap paragraph explosion be neutralised cheaply (serializer flag or post-process)?
  • Can macro identity be preserved via a sidecar map without bloating the pipeline?
  • Does the self_ref provenance survive a round-trip well enough to be load-bearing for a merge engine, or is it parse-local only?

If yes / yes / yes, docling is plausible. If any of those is no, the Pandoc-Lua path (note 010, pending) is the next candidate to evaluate before deciding.

  1. Does the markdown serializer have a “no hard-wrap” flag? Worth a quick look before note 012 — if it does, a re-run would tighten M2 significantly and could change the recommendation.
  2. Is the <span class='inline-group'> wrapper avoidable? It’s an artefact of docling’s HTML serializer choosing span-over-p when a paragraph contains inline children at the top level. Looks fixable via the serializer config.
  3. Cost of attaching Confluence semantics on top. Could a thin layer on either side of docling’s parse — pre-extract macros to sidecar before parse, re-emit macros from sidecar after parse — recover the structural loss without docling itself needing to know about Confluence? This is the central question for the comparison note.
mise run ir-experiment -- --pipeline docling --out report.md
page_idtitletext_fidelitystructural_fidelitywhitespace_driftprovenance_coverage
131253Image referenced by external URL0.94450.55560.36121.0000
131272External URL link0.88890.00000.00001.0000
131305Table with mixed inline content in cells0.73020.29630.00001.0000
131332Tip callout (rich body)0.98430.40000.84061.0000
131360Panel callout (rich body)0.97620.75000.68641.0000
131384Layout three-equal columns0.99900.60000.98991.0000
131404Attachment link via ac:link0.91590.00000.13461.0000
163977Nested mixed lists1.00001.00000.92821.0000
164003Multiple paragraphs0.99170.00000.91721.0000
164022Horizontal rule0.97220.57140.69981.0000
164045Warning callout (rich body)0.99430.33330.94261.0000
164060Same-space page link0.98650.40000.79591.0000
164069Attachment link1.00000.50000.83051.0000
164089Expand macro0.99700.00000.97041.0000
164105Status macro0.97560.33330.75001.0000
164122Platform glossary0.99720.04350.90801.0000
65915Plain code block (no language)0.73910.00000.00001.0000
65941Code block with language tag (python)0.91480.50000.15361.0000
65975Multiple inline links in one paragraph0.97560.00000.69921.0000
66011List items with inline formatting0.99800.77780.80351.0000
66045Plain table0.94530.94740.00001.0000
66067Info callout (rich body)0.99600.71430.96011.0000
98308Plain paragraph0.99790.00000.97871.0000
98328Plain blockquote0.90150.00000.00001.0000
98348Code block with characters that need CDATA0.72420.20000.00001.0000
98367Top-level heading (h1)0.97340.75000.75851.0000
98386Bullet list1.00001.00000.95001.0000
98412Ordered list0.94680.62500.41501.0000
98431Inline formatting0.95790.00000.56141.0000
98491Note callout (rich body)0.98990.72730.78531.0000
98505Layout two-equal columns0.99640.53330.95221.0000
98521Table with merged cells0.70420.48390.00001.0000
98544Children macro0.96460.33330.63961.0000
98559Acme Internal Engineering Handbook0.99480.28000.86141.0000
98579Meeting notes — Platform sync, 2026-05-110.99270.84810.76871.0000

text_fidelity:

page_idtitlescore
98521Table with merged cells0.7042
98348Code block with characters that need CDATA0.7242
131305Table with mixed inline content in cells0.7302
65915Plain code block (no language)0.7391
131272External URL link0.8889

structural_fidelity:

page_idtitlescoredrift
131272External URL link0.0000ac:link: 0→1; p: 2→6
131404Attachment link via ac:link0.0000li: 0→1; ol: 0→1; p: 4→11
164003Multiple paragraphs0.0000p: 4→12
164089Expand macro0.0000macro:expand: 1→0; p: 2→4
65915Plain code block (no language)0.0000macro:code: 0→1; p: 2→6
directionmean (ms)median (ms)min (ms)max (ms)
storage_to_md5.033.221.2719.16
md_to_storage3.582.120.5418.88
total8.605.191.9338.04

Total round-trip across 35 fixtures: ~330 ms wall clock.

Total LOC: 78 (pipelines/docling.py). Plus the docling library as an upstream dependency — not counted under M6’s “pipeline implementation” definition, but worth flagging as the real maintenance cost.

  • Research note 006 — IR design space; identifies docling as one of three candidate foundations.
  • Research note 007 — corpus specification.
  • Research note 008 — the harness that produced this report.
  • Research note 009 — status-quo baseline, directly comparable numbers.
  • scripts/ir_experiment/pipelines/docling.py — the pipeline adaptor.
  • HTML report: the harness also emitted a side-by-side HTML diff report with the same data plus the timing slowdown table. It is not carried in this repository.

R10-confluence-ir-spike-pandoc-lua.md — Pandoc CLI with a custom Lua writer for Confluence storage XHTML. Same corpus, same metric battery. Pandoc was deferred chronologically (010) but the comparison is symmetric — note 012 will combine all three.

Site built 2026-07-30.