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
Enter fullscreen mode Exit fullscreen mode
# 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 |
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
{
"chunks": [
{
"byte_range": [0, 483],
"heading_path": ["Employee Handbook"],
"source": "handbook.pdf",
"text": "# Employee Handbooknn## Remote WorknnEmployees may work remotely up to three days per week..."
},
{ "…": "1 more chunk" }
],
"meta": { "source_format": "pdf", "page_count": 1, "…": "…" }
}
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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.
답글 남기기