📄 PDF parser tradeoffs for chitragupta¶
Status: reasoning document.
Written for someone choosing [parser].backend, or revisiting that
choice. Assumed: CONFIG.md for how to set it. Not
covered here: the measured cost of each backend, which is
PERFORMANCE.md.
🔭 Short summary¶
Two backends ship: pdftotext (the default) and docling. Two more were
evaluated on this corpus and removed: markitdown, which fuses adjacent
words (see "Why markitdown was removed"),
and grobid, whose header metadata the bib file already supplies (see
"Why GROBID was removed"). A proposal to bring
GROBID back in a different role, beside docling, for a citation graph,
is in GROBID-CITATION-GRAPH.md.
Four newer parsers (marker, surya, xberg, unstructured) were surveyed and none adopted; the analysis is in "Four newer backends, evaluated and not adopted".
⚖ Comparison table¶
| Tool | Best at | Strengths | Weaknesses | Relative speed vs pdftotext |
Status |
|---|---|---|---|---|---|
pdftotext |
Plain text extraction | Very fast, simple, byte-identical output across runs, no enrich group needed |
Weak on layout, tables, headings and reading order; writes no passage sidecar | 1x | Default |
docling |
Layout-aware PDF parsing | Reading order, sections, tables and structured Markdown; the only backend that writes the passage sidecar a claim can be quoted from | Heavy, slower, model/runtime complexity; not reproducible under concurrency | ~42x slower with OCR on (5 real bib PDFs; see below) | Opt-in |
markitdown |
General file-to-Markdown conversion | Flexible normalization, multi-format support | Fuses adjacent words on this corpus (4.17% of tokens), which silently breaks BM25 ranking | ~17x slower (5 real bib PDFs) | Removed |
grobid |
Scholarly structure and references | Title, abstract, sections and references | Not a general-purpose plain-text extractor; needs a JDK 21 build and a long-running service | Separate from the main speed scale | Removed |
🧭 Which backend to choose¶
[parser].backend is a tier, not a ladder: you pick one, and if it is
unavailable sync warns and skips parsing rather than substituting the
other (ARCHITECTURE.md). The
enrichment layer's docling stage is separate: it always uses docling,
whatever [parser].backend says, and writes content/docling/, which
BM25 never reads.
| If you need | Set | Because |
|---|---|---|
| The fastest parse and the smallest install | backend = "pdftotext" (the default) |
It needs only poppler-utils, and its output is byte-identical across runs |
A quotable paragraph with its page from a search (retrieve search --unit passage), weight_abstract, or the reference-list cut in BM25 |
backend = "docling" |
All three read the corpus layer's passage sidecar, which only docling writes (RETRIEVAL.md) |
| Better chunks for embeddings and topics, without changing what BM25 ranks | pdftotext, plus python -m chitragupta.enrich --stages docling |
The embedding stage prefers content/docling/<doc>.md when it exists |
| Title, authors, year and DOI | either | papers/bibliography.bib is the source of truth for metadata (CONFIG.md); no parser re-derives it |
🧱 What docling costs¶
The ~42x figure is the OCR-on ceiling. It was measured with
Docling's OCR stage on, which is Docling's default but not this
project's ([parser].ocr = false). Measured over the whole corpus,
turning OCR off is 2.08x faster serially and up to 4.79x at 24
workers. PERFORMANCE.md is the figure to plan against:
a full 501-PDF serial parse takes 55m 30s with OCR off against 1h 56m
with it on.
Turning OCR off has a cost: it drops text that the PDF stores as a bitmap instead of as characters, which on this sample was mostly publisher furniture and figure sub-captions but on one document included two whole tables. See PERFORMANCE.md.
Its output is not reproducible under concurrency. With a worker
pool, dense reference blocks are grouped into elements slightly
differently between runs, so a quoted span can change across a re-parse.
Ranking is unaffected, because chitragupta/retrieval.py tokenises on
runs of [a-z0-9]. Docling exposes no determinism setting. pdftotext
does not have this property. The measured rates and the
artifact-by-artifact contract are in
ARCHITECTURE.md.
It also carries a system dependency nothing else in this repository has, and a missing one produces a thoroughly misleading error; see "docling fails every document with an OpenCV recursion error".
🧪 Four newer backends, evaluated and not adopted¶
Surveyed 2026-08-05 from each project's own documentation, without a trial run: marker, surya, xberg and unstructured, all against docling as the incumbent. Nothing was adopted. This is recorded so the next person asking "should we switch parsers?" starts from the analysis instead of repeating it, and so the one finding that would break a naive swap is written down.
🏷 What "fit" means here¶
Parsing a PDF is not enough. This repository depends on specific docling behaviours, and a replacement has to supply all of them:
| What the repo uses | Where |
|---|---|
Per-item label, text, prov[0].page_no, prov[0].bbox |
passage provenance, chitragupta/passages.py |
pic.caption_text(dl_doc): figure caption matched to "Figure N" in prose |
_figure_records, see DEVELOPER.md's "Figures and copyright" (git checkout only) |
export_to_markdown() |
content/docling/<citekey>.md, the artefact downstream stages read |
AcceleratorOptions(device="cuda:N", num_threads=...) set per worker process |
init_worker, one GPU claimed round-robin |
| A togglable OCR flag, default off | [parser].ocr |
A togglable formula-recognition flag, default off; without one, an equation reaches content/parsed/ as a formula-not-decoded marker instead of as LaTeX |
[parser].formulas |
⚖ Comparison¶
| docling (incumbent) | marker | surya | xberg | unstructured | |
|---|---|---|---|---|---|
| Category | End-to-end layout-aware PDF→doc | End-to-end PDF→Markdown/JSON, built on surya | Layout/OCR/table primitives (marker's foundation) | Polyglot doc-intelligence engine, Rust core | ETL "elements" extractor, multi-strategy |
| Native Markdown export | Yes | Yes | No: assembly required | Yes (+ Djot/HTML/JSON) | No: typed Element list only |
| Provenance (page + bbox + label) | Yes, per item | Yes, JSON block tree | Yes, but page/doc assembly is on you | Yes, "Structured" JSON | Yes, per element |
| Figure↔caption auto-linking | Yes | Undocumented | Not provided | Unverified | Undocumented |
| Table extraction | Structured, built in | Structured (HTML), CPU heuristic + VLM fallback | Structured (HTML/cells) | Structured (TATR/SLANet) | Structured (TATR) |
| GPU model | Optional, in-process | External inference server (vLLM/Docker), OCR paths only | Same server requirement | Optional, CPU-first (ONNX) | CPU-historically; GPU auto-detect unverified |
| OCR off-switch | Yes | Yes (--disable_ocr) |
No: inherent to the VLM | Yes, swappable backends | Yes, via strategy |
| Fits the per-process CUDA-device pool | Yes (built for it) | OCR off: yes. OCR on: no | No | Mostly: no persistent CUDA context | Roughly, in-process ONNX |
| Code license | MIT | Apache-2.0 | Apache-2.0 | MIT | Apache-2.0 |
| Model-weight license | Open weights | Modified OpenRAIL-M: free under $5M revenue, else paid | Same OpenRAIL-M variant | N/A | N/A |
| System deps beyond pip | None with OCR off | vLLM + Docker + NVIDIA toolkit, or llama.cpp, only if OCR is used | Same as marker | None (bundles ONNX) | libmagic, poppler, tesseract, libreoffice, pandoc |
| Maturity | Established, IBM-backed | Established; v2.0 rewrite Jul 2026 | Established; v2 rewrite May 2026 | v1.0 days old at survey time | Established |
⚠ The finding that would break a naive swap¶
marker and surya moved OCR and layout to a locally-spawned inference
server (vLLM on GPU, llama.cpp on CPU) in their 2026 rewrites. Workers
become HTTP clients of one shared server instead of each holding its own
CUDA context, so this repository's ProcessPoolExecutor +
init_worker GPU round-robin does not carry over. Adopting either would
be an architecture change, not a backend substitution. See
PARALLELISM.md.
The cost applies only with OCR on. Under --disable_ocr marker starts no
server at all: layout comes from an in-process rf-detr detector, text
from pdftext, tables from CPU heuristics. This project already runs
with OCR off by default, so that is the configuration that matters, and
in it marker is close in shape to docling's own architecture.
📊 Where each one lands¶
- surya: do not target directly. It is the primitive marker is built on. Using it means rebuilding page assembly, Markdown emission, image cropping and caption pairing, all work marker has already done.
- marker: the strongest candidate, and only with OCR off. It still needs a JSON→passage/figures mapping layer (mechanical, bounded), and custom figure↔caption linking, which is undocumented. The OpenRAIL-M weight licence applies even with the VLM disabled.
- xberg: best licence and CPU story, too new to trust. Plain MIT with no weight carve-out, CPU-first, and it sidesteps the fork-versus-CUDA-context problem entirely by having no persistent CUDA context. But the v1.0 line was days old, and figure/caption pairing could not be confirmed. This repository has a documented habit (markitdown, GROBID) of adopting on the strength of documentation and removing after measurement; xberg would need a measured pilot first.
- unstructured: most work, least gain. No native Markdown export
(this repo would own a permanent
Element→Markdown renderer), undocumented caption linking, the heaviest system-dependency footprint, and the least certain GPU story.
🏁 Conclusion of the alternatives review¶
Stay on docling. Its weaknesses, speed and non-determinism under
concurrency, are known, measured and written down
(ARCHITECTURE.md),
which is worth more than an unmeasured alternative's undocumented ones.
marker, xberg and unstructured share one blocker:
figure↔caption auto-linking is undocumented in all three, and
_figure_records depends on it.
If this is revisited, marker with OCR off is the one to pilot, and the pilot must measure against the real 501-PDF corpus, the measurement that removed markitdown and GROBID.
🗑 Why GROBID was removed¶
GROBID's role here was bibliographic-quality header and reference
extraction, and it only ever called one endpoint
(/api/processHeaderDocument) for title/authors/abstract. That is
metadata papers/bibliography.bib already provides for every document
the project cares about: the goal is to parse the PDFs the bib file
names, and those arrive with real metadata already attached via
chitragupta/bib_reader.py.
What only GROBID offered was parsing a paper's own reference list into
structured author/title/year/DOI records, via the
/api/processFulltextDocument endpoint this repo never called. That
serves corpus discovery ("which papers do my papers cite that I don't
have yet"), not grounding. Extracted references are not in the bib file,
so per AGENTS.md's citekey invariant no draft may cite them, and since
chitragupta/enrich/corpus.py sources the enrichment corpus from the ledger and
nothing else, there is nowhere to index them either. A discovered paper
enters this project the way every other one does: catalogue it in your
reference manager, re-export, and re-run python -m chitragupta.corpus sync.
Against that, the operational cost was a JDK 21 pinned exactly (its bundled Kotlin compiler cannot parse a JDK 25 version string), a multi-GB multi-minute Gradle build, and a long-running service on port 8070. That was not worth paying for a capability the project doesn't use.
If corpus-growth-by-snowballing later becomes a real workflow, the case
to revisit is for /api/processFulltextDocument specifically, not the
header endpoint that was here.
🗑 Why markitdown was removed¶
Removed after it was measured on this repository's own corpus instead of judged by its stated feature set.
The symptom. Over the same 10 CPS papers, markitdown produced
3,647 alphabetic tokens longer than 20 characters (4.17% of all
tokens) against pdftotext's 9 (0.01%), a factor of 400, and
23% fewer total words, because words were being fused together, not
dropped. It is visible directly in retrieval snippets:
1 2 3 | |
Why that matters. chitragupta/retrieval.py is BM25 over tokens split on
runs of [a-z0-9], so a fused run is one token: a query for "cyber
physical" cannot match text fused into cyberphysicalsystems. The result
is a silent ranking failure, not a cosmetic one.
The cause. markitdown extracts PDFs via pdfplumber, calling
page.extract_text() with no arguments. pdfplumber's default
x_tolerance is 3 points: glyphs closer than that are treated as one
word. These papers set inter-word spacing below 3pt. Measured on four
of them, dropping to x_tolerance=1 eliminated every over-long token
(179 -> 0, 83 -> 0, 164 -> 0, 141 -> 0) and roughly doubled the word
count. pdftotext reads the same files correctly, so the spacing
information is present in the PDFs; the cause is the extractor's
threshold, not damaged input.
Why it wasn't fixable here. markitdown's PDF converter hardcodes
both page.extract_text() and extract_words(x_tolerance=3,
y_tolerance=3). Its convert() accepts **kwargs but never forwards
them, so the tolerance is unreachable through its public API. Its own
source comments that the heuristic is "not for multi-column text layouts
in scientific documents", which describes this entire corpus.
What replaced it. Nothing: markitdown was worse than pdftotext
on text and worse than docling on structure. Using pdfplumber
directly with a tuned tolerance was considered and deferred: no current
use case needs a backend between the two that ship.
What was added instead. A parse-quality guard
(chitragupta/pdf_text.quality_warning, wired into sync) that warns when more
than 1% of a document's words exceed 20 characters. The two backends sit
three orders of magnitude apart on that measure, so the threshold does
not need precise tuning. A backend that starts fusing words is reported
by sync on its first run rather than noticed by eye in a retrieval
snippet.
🐛 docling fails every document with an OpenCV recursion error¶
The os-deps stage prevents this; it is recorded here because
the error message points at nothing useful, and because a host provisioned
some other way will hit it again.
The symptom. With [parser].backend = "docling", python -m
chitragupta.corpus sync
prints a bare sys.path listing before the bibliography progress and then
fails every document it had to parse:
1 2 3 | |
There is no recursion, and OpenCV is installed correctly. Both halves of that message are wrong.
The mask. cv2/__init__.py sets sys.OpenCV_LOADER = True at the top
of its bootstrap and deletes it at the bottom. If the first import cv2
in a process dies partway, the flag leaks, and every later import cv2
in that process, or in any process forked from it, reports the recursion
error instead of the real reason. The stray sys.path line is cv2's own
print(sys.path) on that path. Reproduce the mask directly:
1 2 | |
Why sync turns that into a whole-run failure. chitragupta/pdf_text/_startup.py's
prestart_pool() starts a forkserver whose preload imports docling while
the parent reads the bibliography, which is why the print lands before
the progress lines. forkserver.main() catches ImportError and discards
it, so the genuine failure is swallowed and the poisoned flag survives in
the server process. Every worker forked from it then re-imports cv2 and
reports the mask. chitragupta/pdf_text.preload_modules' docstring already named
this as a known gap; here it hides the cause of a failed run.
The real cause. OpenCV is a transitive dependency nothing here asks
for: the enrich group's docling pulls docling-slim[standard], which
pulls rapidocr, which requires the opencv-python distribution by
name. That is the GUI-linked wheel. Its cv2.abi3.so vendors Qt but not
libGL.so.1, libglib-2.0.so.0, or the X libraries. ldd resolves
those to the system, and a base image installed with
--no-install-recommends has none of them. So the first import raises,
and installing exactly those libraries fixes it.
The precise first-error string is inferred, not captured: the mask
is what reaches the log, and by the time this was diagnosed the host had
been repaired. On a missing libGL.so.1 the loader's message is
ImportError: libGL.so.1: cannot open shared object file. What is
confirmed is the mask mechanism (reproduced below) and the remedy.
The fix. libgl1 and GLib are in the os-deps package list, so
chitragupta install os-deps covers it. By hand:
1 | |
To see the real error instead of the mask, run the parse serially. The import then happens in-process, with no preload to swallow it and no second attempt to trigger the flag:
1 | |
Why opencv-python-headless is not the fix. It is the right wheel for
a container, but rapidocr requires opencv-python by distribution name
and arrives through docling-slim[standard], which is not droppable. A
constraint would install both distributions; they own the same cv2/
directory and clobber each other. Swapping to headless would have to be
post-install surgery (uninstall one, install the other, after the enrich
group) in the style of ensure_gpu_torch, and was not worth it against
two apt packages.
🐛 docling silently empties every formula on a GPU host¶
The os-deps stage prevents this, like the OpenCV entry above. It is
recorded at more length, because this one does not fail the
run, does not fail the document, and produces output that is
byte-for-byte what you would get from the setting you did not choose. It
only happens with [parser].formulas (or [enrich].docling_formulas)
turned on, which is not the default.
What it looks like. A sync that keeps going, with one line per
affected document buried in a log that is already full of docling's own
table-recovery warnings:
1 2 3 | |
and, further up, the compile that failed:
1 | |
The cause. torch's default Linux wheel bundles triton. With a GPU
visible, triton compiles a small C shim (backends/nvidia/driver.c) the
first time a CUDA kernel is launched: a real gcc invocation, at
runtime, that #includes Python.h. A host with no CPython development
headers fails it. Nothing in this project asks for triton, and nothing
asks gcc to run; both arrive with torch and fire only under --gpus,
which is why a CPU-only host never sees this.
Why it does not look like a failure. docling catches the exception per
batch (docling/models/stages/code_formula/code_formula_vlm_model.py),
logs the line above, and assigns "" to every element in the batch. The
document then converts successfully and the ledger records it as parsed.
What reaches the corpus text is exactly the formulas = false behaviour.
docling_core's Markdown serializer, which both parses go through
(pdf_text/_backends.py's export_to_markdown() and
enrich/docling_parse.py's alike), writes
<!-- formula-not-decoded --> for a FormulaItem whose text is empty
but whose orig is not, and the failure clears only text. So the
marker stays, and it does tell you a formula was there. But the run
reported success, paid for the model download and the extra pass per
page, and produced the output you would have got for free with the key
switched off.
How to tell, if you no longer have the log. The two states are cleanly
separable, because a successful recognition writes LaTeX between $$
delimiters and leaves no marker at all:
1 2 | |
Measured on this project's own 497-document corpus, both states:
[parser].formulas |
Docs with a marker | Docs with $$ LaTeX |
|---|---|---|
false |
148 | 0 |
true, model ran |
0 | 151 |
true, this failure |
as false |
0 |
So one marker while the key is on is enough for the diagnosis: the two
never coexist, and a healthy true run produces none. The false row is
the measurement
CONFIG.md records;
the true row was measured on the same corpus after this key was turned
on.
Re-running does not repair it, and the two settings recover differently. Both caches key on the input PDF, which has not changed, so fixing the host and re-running leaves every already-parsed document exactly as it was. Which lever clears it depends on which parse produced the damage:
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
The fix. python3-dev and gcc are in the os-deps package list, so
bash scripts/install_full_pipeline.sh os-deps (equivalently chitragupta
install os-deps) covers it. A host provisioned another way, or a
long-running container that has not re-run os-deps, needs the apt line
above by hand.
If you do not want formula recognition, the cheaper answer is to leave
[parser].formulas = false. docling then writes
<!-- formula-not-decoded --> and never loads the model that needs
triton at all.