Skip to content
White Paper2026-07-24

Clean-Room PDF Extraction at the Edge: How OpexMX Runs pdftotext Inside a Cloudflare Worker for RAG

Industrial manuals use subset CID fonts with no ToUnicode map, so every JavaScript PDF library emits glyph garbage — and RAG over garbage text is garbage retrieval. Here is how OpexMX wrote its own clean-room PDF extractor in Rust, compiled it to WASM, and runs it inside the edge Worker that also embeds and indexes the chunks.

DA
Dzulfikar Ats Tsauri
Solution Engineer
Share:

Every maintenance team has the same pile of PDFs: OEM manuals, electrical schematics, spare-parts catalogs, lubrication charts, safety data sheets. The value locked inside them is obvious — a technician who can ask "what is the torque spec on the main bearing housing?" and get the answer with a page number back is dramatically faster than one leafing through a 400-page binder. That is retrieval-augmented generation, or RAG, over your own documents.

The unsexy part nobody warns you about is step zero: turning those PDFs into text your embedding model can actually read. And on industrial manuals, almost every off-the-shelf PDF library fails at exactly that step. This is the story of why, and of the clean-room Rust extractor we wrote to fix it — one that compiles to a native binary for on-prem and to WebAssembly that runs inside the same Cloudflare Worker doing the embedding and indexing.

The Problem: Industrial PDFs Use Fonts That Defeat JavaScript Parsers

A normal PDF — a generated invoice, an exported Word doc — embeds a clean Unicode-to-glyph mapping. JavaScript parsers like pdf.js (and its server sibling unpdf) read text out of these without trouble.

Industrial manuals are not normal PDFs. They are exported from CAD and desktop-publishing toolchains that subset fonts aggressively to keep file size down. The common pattern: a Type 1 or CID font where glyphs are addressed by a numeric ID, with a private glyph-naming convention like G42 or G108, and no ToUnicode map — the table that translates those IDs back to real characters. The exporter knew which glyph meant "A" when it drew the page; it did not bother to record that fact for anyone reading the file later.

When a JavaScript parser hits one of these, it does the only thing it can: it emits the raw glyph name or a private-use codepoint. The result is page after page of G104 G42 G77 G91 where the text "Bearing torque: 120 Nm" should be. We call this glyph garbage, and on our real manual corpus it affected the majority of pages.

Why This Is Fatal for RAG Specifically

For a human eyeballing a rendered page, none of this matters — the page draws fine because the font program still has the glyphs. The disaster is purely in the extracted text layer, which is the only layer RAG ever sees.

RAG retrieval is a chain, and every link propagates the damage:

  1. Chunking splits garbage into smaller pieces of garbage. A chunk of G104 G42 G77 carries no signal.

  2. Embeddings turn it into a vector, but a vector of noise. The model maps "G104 G42 G77" to some region of embedding space that has nothing to do with bearing torque.

  3. Vector search returns the wrong chunks because the noisy vectors land in meaningless neighborhoods.

  4. The LLM gets garbage context and either hallucinates confidently or admits it cannot find the answer.

Garbage in, garbage out — but garbage at the extraction step is uniquely deadly because it is invisible. The page renders correctly, the pipeline reports success, the chunk count looks healthy, and the only symptom is that the chatbot is confidently wrong. You do not debug "the extractor produced private-use codepoints"; you debug "the AI gives bad answers," three abstraction layers downstream.

This is why we refused to ship RAG on top of a known-broken extractor. The extractor had to be fixed first.

The Obvious Fix, and Why We Rejected It

The obvious fix is poppler — the battle-tested,GPL-licensed PDF library behind most Linux pdftotext tools. It handles CID fonts correctly. We even shipped it: a poppler sidecar running in a Cloudflare Container, called from the Worker over HTTP, that overlaid authoritative text onto the pages the JavaScript parser mangled.

It worked. We did not like it. Four problems:

  • Licensing. Poppler is GPL. Bundling it creates a copyleft obligation we did not want anywhere near a commercial product.

  • The dependency chain. A poppler build drags in conda or micromamba, a forest of shared libraries, and an environment-managed lib folder. That is exactly what you do not want inside a self-contained on-prem tarball that a customer installs behind their firewall with no internet access.

  • Edge economics. A poppler Container is a whole separate process, cold-starting on its own, billed as a sidecar, reached over the network from the Worker. For per-document extraction at the edge that is heavy and slow.

  • Architectural split. Half the pipeline ran in the Worker; the extraction step left the Worker to call a container. Two failure modes, two scaling stories, two places to debug.

We wanted the extractor to run in the Worker, next to the embedding call and the vector upsert, with no sidecar and no GPL baggage.

What We Built: pdftotext-rs, a Clean-Room Extractor

We wrote our own PDF text extractor, pdftotext-rs. It is a clean-room implementation: built from the ISO 32000-2 PDF specification and Adobe's published technical notes (Type 1 charstrings, CMap resources, OpenType, CIDFont), never from the source code of poppler, mupdf, xpdf, pdfium, or Ghostscript. That discipline is what keeps it MIT-licensed rather than a derivative work of a GPL engine.

For the low-level object, cross-reference, and stream-decompression primitives it leans on lopdf and flate2 — both independent MIT libraries. Everything above that — the content-stream interpreter, the font and encoding resolution, the Adobe Glyph List lookup, the CID-to-Unicode reconstruction for fonts that ship no ToUnicode map — is ours, written from the spec.

The clean-room rules are enforced hard. No contributor may read the source of an existing PDF engine, and no engine's expected-output files may be imported as a test oracle. Our test goldens are generated by our own binary and frozen after a human readability sign-off. Parity is measured by two gates: less than five percent private-use, replacement, or control characters per page (we hit 0.00% on the real manual corpus), and word-recall versus poppler at or above 70% where poppler is available for comparison (we hit 100% — every word poppler extracts is a substring of our flattened output).

The result is poppler-quality text with zero glyph garbage, on exactly the CID-font manuals that broke every JavaScript parser.

One Codebase, Two Targets: Native Binary and WASM

The decisive design choice was making the crate compile to two targets from one source tree.

Target one: a static native binary. Cross-compiled with cargo-zigbuild to the host target (musl, fully statically linked, no lib folder). This binary ships inside the on-prem tarball under dist/opexmx-server-.../bin/pdftotext. No conda, no micromamba, no shared-library chain, no GPL obligation. The on-prem server resolves it exactly like the old poppler binary, so nothing in the server changed.

Target two: WebAssembly. The same crate compiles to wasm32-unknown-unknown with wasm-pack, and that WASM runs inside the Cloudflare Worker as the default PDF extractor. The release WASM is roughly 0.23 MB gzipped — comfortably under Cloudflare's free-tier 3 MB ceiling. Critically, the library code path is deterministic, so the WASM output is bit-identical to the native binary. One extractor, one behavior, everywhere.

Making lopdf cooperate on WASM meant building it with default features off and the wasm_js feature on, which drops the chrono, rayon, and time dependencies and gives it a JavaScript-backed source of randomness. The release profile turns on size optimization, link-time optimization, symbol stripping, a single codegen unit, and abort-on-panic to keep the module small and panic-free. wasm-opt is deliberately disabled — it chokes on wasm-bindgen's reference-types output, and its marginal size win was not worth a binaryen dependency on every build host.

The WASM module is dynamically imported by the Worker's manual processor, which matters for the next section.

The Pipeline: How a Manual Becomes Searchable

Extracting text is one phase of a longer pipeline that turns an uploaded PDF into a queryable knowledge base. The whole thing runs in a Cloudflare Durable Object, driven by an alarm-based state machine rather than a single long-running function.

That choice is not aesthetic. A Worker request has a CPU-time ceiling, and an unawaited promise dropped from a Durable Object is killed when the object is evicted. We learned the hard way that fire-and-forget extraction leaves manuals stuck on "indexing" forever: the promise died, no alarm was armed, and nothing ever resumed. Long pipelines at the edge have to be an alarm state machine that re-arms itself after each phase, so a killed or timed-out tick simply resumes on the next alarm.

The phases are extract, embed, and finalize.

Extract. Download the PDF from object storage, then run it through the WASM extractor. Pages that come back nearly empty are flagged as image-heavy — schematic drawings, scanned pages — and their embedded raster images are pulled out (here unpdf earns its keep, as a pure image puller) and sent to a vision model for OCR. Text-heavy manuals never touch the OCR path at all. The extracted pages are then chunked.

Embed. Chunks are embedded in batches through Cloudflare Workers AI, using the bge-base-en-v1.5 model at 768 dimensions, and upserted into a Vectorize index configured for 768 dimensions with cosine similarity. The same chunk rows are persisted to D1 (the relational database) so the full text, section path, and page number can be rejoined to a vector match at query time. Batching is essential: a 300-page manual produces hundreds of chunks, and embedding them one at a time would blow the per-tick budget. Each alarm tick embeds one batch, advances a cursor, and re-arms.

Finalize. A knowledge document record is created, the manual row is marked ready, and a best-effort pass tries to extract structured preventive-maintenance schedules and threshold tables from the chunked text.

Chunking Matters More Than the Model

A common mistake is to assume a better embedding model rescues bad chunking. It does not. The chunk is the unit of retrieval, so the chunk boundary is where semantic meaning is either preserved or destroyed. Naive fixed-size splitting breaks a procedure across two chunks and buries a torque spec in the middle of a paragraph.

Our chunker is structure-aware. It walks the detected section headers to maintain a section path, starts new chunks at section boundaries, and otherwise splits when a token budget is exceeded. Each chunk is classified — preventive-maintenance schedule, troubleshooting, procedure, specification, table, or plain text — so downstream steps can target relevant content. A 500-token budget with a 50-token overlap keeps chunks inside the embedding model's context window while preserving continuity.

One non-obvious detail: token estimation takes the maximum of a word-based estimate and a character-based estimate. The character ceiling exists specifically because of the glyph-garbage failure mode. A low-whitespace run of private-use codepoints can read as fifty "words" to a naive splitter while being eighteen thousand characters long — silently blowing past the budget and overflowing the embedding model's context window. The character bound catches exactly those runs.

One Vector Interface, Two Backends

The embedding and vector layer is abstracted behind a single interface with two implementations: Cloudflare Workers AI plus Vectorize in the cloud, and pgvector plus an OpenAI-compatible local embedding endpoint on premises. The dimension is 768 everywhere, and a scope field replaces Vectorize's domain metadata convention. The ingest code and the query code are identical across platforms; only the resolved backend differs. When no backend is configured, a null backend degrades semantic features to no-ops while lexical features keep working, so the application never hard-fails on a missing binding.

This is what lets the same RAG features run on a multi-tenant Cloudflare deployment and on a customer's air-gapped on-prem Postgres. The hard part — extraction, chunking, retrieval — is platform-agnostic.

Query Time: Fan-Out and Merge

At query time, the user's question is embedded with the same model, and the resulting vector is queried against the index. The interesting part is how filtering works.

Vector indexes only support AND filters. But a maintenance question about a specific asset should surface three things at once: asset-specific documents, documents for that asset's equipment type (so a manual shared across all machines of the same model is found), and global documents like SOPs and lockout procedures. Those are OR conditions, not AND.

The solution is fan-out. For each query we run parallel vector searches across scopes (manual and document) and across filter variants (global, asset-scoped, equipment-type-scoped), then merge the results by best score per chunk. A chunk that matches multiple variants is counted once, at its highest score. The merged chunk ids are then rejoined to D1 to pull the actual text, section path, and page number, and that assembled context is handed to the LLM. The same retrieval primitive powers semantic manual search, problem-capture suggestions, and symptom-to-cause matching across the product.

Edge Hardening: The Lessons That Made It Production-Ready

Getting the extractor to work was the easy part. Making it survive the edge runtime took the real engineering.

Keep the WASM out of the cold-start bundle. Cloudflare Workers have a startup-CPU budget, and a 0.23 MB WASM module instantiated on every cold start eats into it. The extractor module is dynamically imported only when a PDF actually needs processing, so requests that never touch a PDF never pay for it.

Guard memory before it guards you. The WASM extractor uses lopdf, which holds the entire decompressed object graph in memory. For a large PDF that means tens of times the file size in live objects, and the Worker has a hard memory ceiling. Crucially, exceeding it kills the Worker outright — not a catchable exception. A manual above our size guard is skipped on the WASM path and falls back to the lighter JavaScript extractor, which produces garbage on CID fonts but at least completes, rather than killing the Worker and leaving the manual stuck in an infinite retry loop.

Keep the fallback honest. The JavaScript extractor (unpdf) is retained on purpose: as the fallback when the WASM throws, and as the image puller for OCR on scanned pages. A toggle can force the legacy path for debugging. The old poppler container is now optional — the WASM matches its quality — and is kept only for the legacy fallback.

Re-arm, do not await. Every pipeline phase persists its progress and re-arms the alarm before returning. A phase that times out or is evicted resumes from its last checkpoint on the next tick, rather than restarting or stalling.

The Payoff

On our full corpus of real industrial manuals, the clean-room extractor produces poppler-parity text with zero glyph garbage — the CID-font pages that came back as private-use codepoints from every JavaScript parser now read correctly, in-Worker, with no sidecar. That text flows into structure-aware chunks, 768-dimensional embeddings, and a Vectorize index queried by fan-out and merge, and the whole pipeline runs identically on Cloudflare and on an on-prem Postgres behind a customer firewall.

The lesson, if there is one: in a RAG system the extractor is not a checkbox. It is the foundation. Get the text layer right and retrieval, embeddings, and generation all behave. Get it wrong and no amount of model quality downstream will save you — you will ship a chatbot that is confidently, invisibly wrong, and you will spend months blaming the model for a problem that lived in step zero.

See how OpexMX turns your manuals into a searchable, source-cited knowledge base →

Get maintenance insights in your inbox

Join operators getting practical CMMS tips, case studies, and product updates. No spam.