I kept running into the same unglamorous wall.
Every time I built something on top of an LLM — a search-over-docs feature, a RAG pipeline, an internal assistant — the first step was the same boring problem: "here's a folder of docx, pdf and pptx files, give me clean text." Not a hard problem. Just one that, somehow, every available tool solved by making me pay a price I didn't want to pay.
And in my case there was an extra constraint that quietly kills half the options: the documents couldn't leave the machine.
The three usual answers, and what each one costs
If you've done document ingestion, you know the shortlist.
Apache Tika is the honest default. It's been the de-facto standard for 15 years, it reads hundreds of formats, and it works. It's also a JVM. That means standing up a JVM, feeding it, watching it, and carrying it around inside every container — which feels deeply out of place in a Rust or Go service. Tika is great. The JVM tax is the part people are trying to get away from.
The Python stacks — unstructured, Docling, MarkItDown — are powerful and popular in every RAG tutorial. But "just parse a document" turns into pip, native wheels, and downloading ML model weights before you get a single line of text. They're heavier and slower to cold-start than the job needs for clean, born-digital files, and the ML paths aren't deterministic. Overkill when the document already has real text in it.
Cloud parsers like LlamaParse are the easiest to start with — until you read the two lines of fine print. You upload your documents to someone else's server, and you pay per page. For anyone in a bank, a hospital, a law firm, or any air-gapped environment, "upload the private documents" is where the conversation ends.
None of them answered the thing I actually wanted: any document → clean Markdown, one binary, on my machine, deterministically.
So I wrote it. It's called DeepDoc.
What I actually wanted
A single static Rust binary. No JVM, no Python, no model downloads, no runtime dependencies at all. Copy it into a scratch Docker layer and go. Nothing touches the network, nothing shells out. Same input, same output, every time — because a RAG index you can't reproduce is a bug waiting to happen.
Here's the whole thing on a real file:
$ deepdoc report.docx
# Q3 Engineering Report
Prepared by the Platform team. This memo covers delivery and headcount for the third quarter.
## Delivery
We shipped the ingestion rewrite and cut p95 latency by 38%.
The nightly batch now runs in under nine minutes.
## Headcount
| Team | Engineers | Open roles |
| -------- | --------- | ---------- |
| Platform | 9 | 2 |
| Search | 6 | 1 |
| Data | 4 | 0 |
That's a Word document — headings, paragraphs, and a real table — coming out as clean Markdown, table intact, in one command. No server, no upload, no warm-up.
How it works (it's boring on purpose)
Every format gets parsed into one neutral Document model — headings, paragraphs, lists, tables, metadata, page markers. Then that model is serialized to Markdown, JSON or plain text by pure functions. That's the whole design, and the boringness is the point: the parser for .docx and the parser for .pptx disagree about almost everything, but once they're in the same shape, "turn this into Markdown" is one code path, tested once.
v0.1 covers the born-digital formats I actually hit in ingestion work: docx, pptx, xlsx, odt, ods, odp, epub, html, rtf, csv, md, txt, and text-based pdf — thirteen formats in one binary. Point it at a folder and it walks the tree in parallel and mirrors the structure:
$ deepdoc ./docs --recursive -o out/
./docs/changelog.txt → out/changelog.md
./docs/meeting-notes.md → out/meeting-notes.md
./docs/report.docx → out/report.md
✓ 3 extracted, 0 skipped
The part that matters for RAG: chunking that keeps context
Naive chunkers split on character count and throw away structure. So a chunk about "refunds" stops knowing it lived under Billing → Refunds, and you've thrown away the exact breadcrumb your embedding could have used.
DeepDoc parses structure first, then chunks on block boundaries while carrying the heading path and the byte range for every chunk:
$ deepdoc handbook.pdf --chunk 800 --format json | jq
{
"chunks": [
{
"byte_range": [0, 483],
"heading_path": ["Employee Handbook"],
"source": "handbook.pdf",
"text": "# Employee Handbook\n\n## Remote Work\n\nEmployees may work remotely up to three days per week..."
},
{ "…": "1 more chunk" }
],
"meta": { "source_format": "pdf", "page_count": 1, "…": "…" }
}
Every chunk knows which section it came from and where in the source it lives. That heading_path goes straight onto the vector as metadata, and the byte_range means you can always trace a retrieved chunk back to the exact offset in the original file.
The honest boundary
I'll be upfront about what DeepDoc is not, because overselling a dev tool is how you lose the people you're trying to reach.
v0.1 is born-digital documents — files with real embedded text. It is not an OCR engine and not a complex-table-from-scans reconstructor. That's genuinely hard, it's ML territory, and tools like Docling and Marker do it well. If you hand DeepDoc a scanned PDF that's really just images, it detects that and exits with a specific code (4) telling you so — instead of confidently emitting garbage into your index. OCR is planned as an optional, feature-gated path later; it is deliberately not bundled into the tiny default binary.
The bet is simple: roughly 80% of the documents in a real ingestion pipeline are born-digital, and for those you don't need a GPU or a model download. You need something fast, deterministic, and local. That's the lane.
It's also a library
The CLI is a thin wrapper. The real entry point is the deepdoc-core crate, so you can embed extraction directly in a Rust service — no subprocess, no FFI, no shelling out:
use deepdoc_core::{extract, to_markdown};
let doc = extract("handbook.pdf")?;
let md = to_markdown(&doc);
The closest thing in the ecosystem, extractous, is actually Tika under GraalVM native-image — not pure Rust, and not a small binary. DeepDoc is Rust top to bottom, and every dependency in the graph is permissively licensed on purpose, so nothing stops you from wrapping it in whatever you're building.
Try it
brew install deeplabua/tap/deepdoc
# or
cargo install deepdoc
It's open source (MIT / Apache), v0.1, and on GitHub: https://github.com/deeplabua/deepdoc
If you're building document ingestion for a local or compliance-bound RAG system, I'd genuinely love your feedback — especially on which formats or edge cases to prioritize next. And if you've been fighting the JVM/Python/cloud trade-off yourself, tell me what you ended up doing; I want to know if I missed a better option.
DeepDoc is part of a small set of local-first, dependency-light tools I'm building under DeepLab — same idea as DeepShrink, which fits any video under a size limit from one command. Small, honest, no cloud.
Top comments (4)
The exit code 4 for scanned PDFs is a small but important interface choice: unsupported without OCR is very different from success with empty text. In an ingestion pipeline, that gives you a clean routing signal to an OCR worker instead of letting blank or garbage chunks reach the index. I would pair heading_path and byte_range with a content hash per chunk; that makes parser upgrades auditable and lets you detect when the same source produces materially different index content.
Exit 4 was deliberate, so I'm glad it reads that way from the outside: "recognized the format, found no text" is a different fact from "extracted successfully, the page was empty", and only one of them should wake up an OCR worker.
The content hash I hadn't planned, and you're right that it belongs next to
heading_pathandbyte_range- those two say where a chunk came from, and a hash says whether it's still the same chunk. Opened it as #1 and quoted you there.Two leanings I noted in the issue, both worth arguing with: I'd hash
textplusheading_path, because a chunk only contains its own heading when it happens to start with one - otherwise the context you'd want to detect changes in sits outside the hash. And re-running over a document that has since been through OCR will change its hashes, which I think is the feature rather than a bug: that's precisely "the same source now produces materially different index content".Open questions there: default-on or behind a flag, SHA-256 or BLAKE3, and whether to hash normalized or raw text. If you've run this in a real pipeline, I'd take your defaults over mine.
the deterministic-by-default call is the right one for born-digital files. the wall right after it is the folder that isn't uniformly born-digital.
a real docx/pdf/pptx folder usually hides a few scanned pdfs with no text layer. a correct deterministic parser returns almost nothing for those, which is honest, but if the pipeline can't tell parsed-and-genuinely-short from parsed-with-no-text-layer-at-all, those files quietly never make it into the index. then search comes back empty and everyone blames retrieval instead of ingestion.
emitting a per-file status alongside the markdown fixes it cheaply, and keeps the ocr decision outside the deterministic path where it belongs.
"Everyone blames retrieval instead of ingestion" - that's the whole failure mode in one line, and yes, today DeepDoc tells a human why a file was skipped, not a pipeline. Opened #2 for the structured per-file status, with the reason enum you're implicitly asking for:
no_text_layerhas to stay distinct fromunsupported_formatand from "extracted fine, genuinely short".Something concrete came out of writing that issue up. You can route today, but only per file, because exit 4 is a per-file signal — a batch run reports skips in prose and exits 0. So you hand-roll a loop... and then you re-implement what
--recursivealready does. My first attempt wroteout/${name%.*}.mdand silently collapsedreport.pdfandreport.docxinto one file; the built-in batch keeps them apart. Structured status removes that choice between batching and routing.Your last point is also why OCR lives in a separate binary here: DeepOCR shipped this week, and as of DeepDoc 0.1.1 the no-text error names it. Recognition is probabilistic; keeping it out of the parser is what keeps the parser deterministic.
One open question in the issue I'd value your take on: one run-level manifest, or a status sidecar next to each output file?