Document parsing via IBM's MIT-licensed Docling: the highest-fidelity document.parse backend, running real layout analysis rather than handing a model a flat character stream.
The public home of
ryu-docling. Source, builds, and releases live here — binaries for every platform are attached to each release.This tree is generated from the Ryu monorepo, so commits pushed here directly are replaced on the next sync. Pull requests are welcome — open them here and they are ported into the monorepo, then flow back out. Ryu as a whole: https://github.com/amajorai/ryu
App: Install (opens the Ryu desktop app and asks you to confirm)
CLI:
ryu apps add @ryu/doclingThe source of record for the universal Ryu TTS sidecar — a self-contained
Python HTTP front over several text-to-speech engines. Install its
dependencies (pip install -r sidecar/requirements.txt) and run
python -m ryu_tts from sidecar/; Core manages it as a sidecar in a
full Ryu install.
Apache-2.0 — see LICENSE.
Layout, not just characters. A PDF has no reading order; it has glyphs at coordinates. Every other approach to "extract the text" guesses at the order from those coordinates, and a two-column paper, a sidebar, or a footnote block is where the guess fails — the extracted text interleaves the columns and every chunk built from it is nonsense that still looks like prose. Docling runs an actual page-segmentation model, recovers the reading order, and exports from a structured document model. That is the single reason to pay its install cost.
Tables that survive chunking. A dedicated table-structure model (TableFormer) reconstructs merged cells and spanning headers, and the markdown export emits a real markdown table. A financial statement or a results table stays queryable instead of collapsing into a column of loose numbers.
Fully local, and air-gappable. Every model runs on your machine; no document or page image leaves the node. Upstream explicitly supports offline operation once the weights are cached — see Air-gapped nodes.
Be honest with yourself about this before picking it over markitdown.
| Install size | multi-GB. docling pulls torch plus the docling model wrappers. |
| Model weights | not in the wheel. The first parse downloads them (hundreds of MB) and can take minutes. |
| Warm parse speed | seconds to minutes per document — this is model inference, not a string copy. Slower than every other backend here. |
| Memory | expect ~2–4 GB resident while a PDF is converting; two concurrent parses is the default cap for that reason. |
| CPU/GPU | CPU works. Apple Silicon and CUDA are used when torch finds them. A scanned 300-page PDF on CPU is a genuinely long job. |
| Python | 3.10 or newer. Core does not install Python; it builds a venv from whatever python3 the host already has. |
| Native tools | none required. Unlike the Unstructured backend there is no poppler / libreoffice / pandoc to install by hand. |
Pick something else when: you mostly attach .docx, .md and .html
(markitdown is a small pure-Python install and gets the same answer in
milliseconds); you need legacy binary Office formats .doc / .ppt / .xls or
.msg email (unstructured); or the node is a small VPS where a multi-GB
dependency tree and a few GB of RAM per parse are not available.
Pick this one when: the corpus is PDFs that matter (papers, filings, manuals, contracts) and getting the reading order and the tables right is worth minutes per document.
None for the default pipeline. /capability reports tesseract because a caller
may select it as an alternative OCR engine, but it is not counted as a missing
dependency: Docling's default OCR engine needs no binary on PATH, so reporting
tesseract as "missing" on a machine where every document parses fine would be a
lie the UI would repeat.
If you do want Tesseract OCR:
brew install tesseract tesseract-lang # macOS
apt-get install -y tesseract-ocr # Debian/Ubuntu
pip install -e ".[tesseract]" # in the sidecar venvthen submit {"options": {"ocr_engine": "tesseract"}}. Asking for an engine whose
binary is absent fails the job with error_code: "missing_dependency" and names
the install command, rather than returning an empty document.
Core points the sidecar at profile-scoped directories so nothing lands in your home cache and everything is removed with the app:
Env (set by manifest.json) |
Holds |
|---|---|
HF_HOME = ${RYU_DIR}/models/hf |
layout + TableFormer weights, pulled from the Hugging Face hub |
TORCH_HOME = ${RYU_DIR}/models/torch |
torch's own model cache |
DOCLING_CACHE_DIR = ${RYU_DIR}/cache/docling/docling |
Docling's working cache |
RYU_DOCLING_WORKDIR = ${RYU_DIR}/cache/docling |
inline uploads and expanded archives |
${RYU_DIR} is the only token the manifest interpolates, and Core resolves it from
its profile-aware data dir — so a bun dev node writes to ~/.ryu-dev, not to the
release node's directory.
One caveat worth knowing: some optional OCR engines (RapidOCR in particular)
download their own weights into the venv's site-packages, not into any of the
directories above. Those go away when the app's venv is removed, but they are not
shared with anything else on the machine.
Docling honours DOCLING_ARTIFACTS_PATH, which makes it load models from a local
directory only — no hub access at all. It is deliberately not set in the
manifest, because pointing it at an empty directory makes the first parse fail
instead of downloading. To use it, pre-fetch the weights on a connected machine
and then set it yourself:
docling-tools models download --output-dir /path/to/models
export DOCLING_ARTIFACTS_PATH=/path/to/modelsEverything in docs/document-parsing.md §3, unchanged. Summary:
| Path | Method | Auth | Meaning |
|---|---|---|---|
/health |
GET | exempt | liveness + backend identity |
/capability |
GET | required | formats, limits, dependency state |
/parse |
POST | required | submit → 202 { job_id, status }, immediately |
/jobs |
GET | required | listing, results omitted |
/jobs/{job_id} |
GET | required | poll |
/jobs/{job_id} |
DELETE | required | cooperative cancel |
Submit-then-poll is not optional here. The ext-proxy's activity guard drops when
response headers arrive, so a lazy sidecar is killable mid-request — and this
backend's cold first parse is exactly the multi-minute call that would be reaped.
Each poll re-arms the guard.
Two consequences of the contract you can see in the code:
/healthand/capabilitynever build aDocumentConverter. Constructing one resolves and downloads models. Core probes/capabilityon every composer mount with a 2-second budget and reads/healthas liveness, so a converter on either path would make a cold node look like a dead sidecar for minutes. Availability is answered from package metadata alone (deps.py), and the converter is built lazily inside the worker thread and cached, so only the first parse pays.available: falseis a real, supported state.doclingis an optional extra (pip install -e ".[parse]"), so the process boots and answers honestly on a machine where the install has not finished. Core's builtin floor handles.txt/.md/.csvwithout any provider, which is why a half-finished multi-GB install must not make plain text unreadable.
options this backend understands — every other key is ignored, never an
error, because a hint one backend takes must not fail on another:
| Key | Type | Effect |
|---|---|---|
ocr |
bool | run OCR over page images |
ocr_engine |
easyocr | tesseract | tesserocr | rapidocr | ocrmac |
which engine |
ocr_languages |
string[] |
language hints, when the engine takes them |
table_structure |
bool | run the table-structure model |
Docling's pipeline-options surface moves between minor versions, so a request whose
options cannot be attached falls back to the default pipeline and reports a
warnings entry — you get your document, plus a note that the hint was dropped.
Losing a hint is recoverable; losing the document is not.
Every one is env-overridable, and /capability reports the live values.
| Bound | Default | Env |
|---|---|---|
| max input bytes | 200 MiB | RYU_DOCLING_MAX_INPUT_BYTES |
| max output bytes | 8 MiB | RYU_DOCLING_MAX_OUTPUT_BYTES |
| elements echoed in a result | 2000 | RYU_DOCLING_MAX_ELEMENTS |
| per-parse timeout | 600 s | RYU_DOCLING_TIMEOUT_SECS |
| concurrent parses | 2 | RYU_DOCLING_MAX_WORKERS |
| retained jobs | 64 | RYU_DOCLING_MAX_JOBS |
| archive members | 512 | RYU_DOCLING_MAX_ARCHIVE_MEMBERS |
| archive expanded bytes | 512 MiB | RYU_DOCLING_MAX_ARCHIVE_BYTES |
max_output_bytes is one shared budget, spent in contract order across
markdown, then text, then elements — not 8 MiB each. Three independent
8 MiB fields would let a job snapshot exceed the ext-proxy's 10 MiB body cap and
become unreadable, which is the exact failure the output bound exists to
prevent, while /capability reported 8 MiB. A result clipped by the budget is
flagged truncated: true, never dropped.
The 600 s timeout is the contract's, deliberately not raised for this backend. A
first parse that includes a model download can exceed it; when that happens the job
fails with error_code: "timeout" and an error naming both the variable and the
download, so a stalled fetch shows up as a stalled fetch instead of hiding inside a
longer silence. Retry once the models are cached and the same document is usually
seconds.
Timeout honesty: CPython cannot kill a running thread. The watchdog marks the job
failed at the deadline and stops waiting; the worker may run on and its result is
discarded. The job never hangs.
The floor from docs/document-parsing.md §5, in full.
- Fail-closed
RYU_EXT_TOKEN, read once at module level and compared withhmac.compare_digest. No token configured means every request is rejected. /healthis exempt onGETonly — the predicate ispath == "/health" and method == "GET", so the route cannot become an unauthenticated hole if it ever grows a body.- Resolve, then contain. A submitted
pathis resolved through symlinks first, then required to sit under a root fromRYU_DOCLING_ROOTS(Core sets it to${RYU_DIR}; the fallback chain isRYU_DOCLING_ROOTS→RYU_DIR→~/.ryu, in that order, so a dev profile lands correctly). Without the post-resolution check a symlink planted in the blob directory would read/etc/shadowand return it as "document text". An empty allow-list means nothing. - Archive members are refused, not sanitised. Absolute names,
..segments, and symlink/hardlink/device members are rejected; the concrete destination is re-checked after joining; member count and total expanded bytes are bounded. - Only the extension is taken from a caller-supplied
filename. Inline uploads are written withmkstempinto a directory this sidecar owns. - No URL is ever fetched. This backend reads local paths and inline bytes.
manifest.json id, provides block, sidecar port 8095
sidecar/pyproject.toml base server deps; `parse` extra pulls docling
sidecar/ryu_docling/__main__.py uvicorn on RYU_DOCLING_PORT, 127.0.0.1
sidecar/ryu_docling/server.py routes + fail-closed bearer middleware
sidecar/ryu_docling/jobs.py in-process job table, worker pool, watchdog
sidecar/ryu_docling/parser.py lazy cached converter, markdown export
sidecar/ryu_docling/paths.py path containment + safe archive expansion
sidecar/ryu_docling/deps.py availability from metadata — never builds a converter
sidecar/ryu_docling/formats.py what /capability advertises
sidecar/ryu_docling/limits.py the bounds above
sidecar/smoke_test.py the contract, with or without docling installed
cd sidecar
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[parse]" # omit [parse] to exercise the unavailable path
RYU_EXT_TOKEN=dev RYU_DOCLING_ROOTS="$HOME/.ryu" python -m ryu_doclingSmoke test (needs httpx for fastapi.testclient):
cd sidecar && python smoke_test.pyIt passes in both modes and prints which one it ran in — with docling installed
it performs a real parse; without it, it asserts the submission still lands on a
clean library_missing job error rather than an empty document.