Our PDF Pipeline Was Burning $15 Per Adventure (So I Rewrote It for 7 Cents)
I found a $15 surprise in my extraction logs
Been building TableTalesAi for a while now. Players upload D&D adventure PDFs, the AI extracts chapters, NPCs, encounters, maps, the whole module. Seeds the campaign so the Game Master knows the story. Cool feature. Users love it.
I never looked at what it cost per PDF. Why would I? It's one upload, maybe once per campaign. Then I added cost tracking to the extraction pipeline (should have done this months ago, yk how it goes) and watched a 153-page adventure module rack up twelve dollars in a single extraction.
Twelve. Dollars. For one PDF. Multiply that by 100 users uploading 2 adventures each and you're looking at $2,400/month just in extraction costs. That's not a business model, that's a charity. (My wife would have opinions about this particular charity.)
Why was it so expensive?
The old pipeline did something spectacularly wasteful. It took the raw PDF binary, base64 encoded the whole thing, and shipped it directly to Claude Sonnet as a document. Every image, every decorative border, every watermark.
Think of it like shipping a library across the country by photographing every page (including the blank ones and the covers and that coffee stain on page 47) instead of just reading the words and writing them down.
Sonnet costs $3 per million input tokens and $15 per million output tokens. A 153-page PDF as base64 generates a LOT of tokens. The text content inside that PDF? Maybe 5x smaller.
The PDF itself was the tax.
Three passes instead of one
I stopped sending the PDF to the AI entirely.
┌────────────────────────────────────────────────────┐
│ Pass 1: Text Extraction (FREE) │
│ pdfjs-dist reads every page locally │
│ Detects chapters, counts words, finds map pages │
│ Cost: $0.00 │
├────────────────────────────────────────────────────┤
│ Pass 2: Structured Extraction (CHEAP) │
│ Send extracted TEXT to Haiku ($1/$5 per 1M) │
│ Chunks by chapter boundaries, not fixed pages │
│ Cost: ~$0.07 for 153 pages │
├────────────────────────────────────────────────────┤
│ Pass 3: Image Extraction (FREE) │
│ pdfjs-dist renders map pages to PNG │
│ Classifies: map vs illustration vs decorative │
│ Uploads maps to R2 for VTT backgrounds │
│ Cost: $0.00 │
└────────────────────────────────────────────────────┘The surprise in Pass 1 was chapter detection. I expected to need AI for it. Turns out "Chapter 3: The Dark Tower" and "PART IV" and "Appendix A" all follow regex-friendly patterns. Even Roman numerals. The detector finds chapter boundaries, and Pass 2 chunks by those boundaries instead of arbitrary 8-page blocks. Better semantic coherence, fewer API calls.
The real savings stack in Pass 2. Haiku instead of Sonnet is 3x cheaper per token. But text is also 5x smaller than PDF binary tokens. Cheaper model on smaller input. The math compounds in your favor. (The rare engineering moment where two optimizations multiply instead of just adding.)
Pass 3 was the bonus I didn't plan for. Once you have pdfjs-dist loaded, rendering individual pages to PNG is basically free. Classify by aspect ratio and text density (maps are square-ish with no text), upload to R2, link to the campaign's dungeon data. VTT gets battle map backgrounds without a single extra API call.
What about scanned PDFs?
Not every PDF has extractable text. Scanned adventures, image-only exports, that 1990s module your friend photocopied. If Pass 1 finds mostly garbage text, the pipeline quietly falls back to the old Sonnet binary approach. No user-facing error. It just costs more for that particular PDF.
The whole thing is feature-flagged behind EXTRACTION_PIPELINE=v2 so I can kill it instantly if something goes sideways. (Learned that lesson the hard way on a different feature. Always have a kill switch.)
Play while we're still reading
The old pipeline made users wait 3-5 minutes while the AI chewed through all 153 pages. Staring at a progress bar. That's an eternity in web time.
My first attempt at fixing this was dumb. I tried to parallelize all the Haiku calls at once. Faster wall time, right? Except the merge logic choked on out-of-order chunks and the JSON repair pipeline got confused about which chapter boundaries belonged where. Rolled that back in about an hour.
The fix that actually worked: extract chapters 1-2 first, seed the campaign, tell the frontend "you're playable." The user starts their adventure in 30-60 seconds. Chapters 3 through 8 extract sequentially in the background. SSE pushes progress updates. By the time most players finish reading the opening scene, the full extraction is done. (The best optimization is the one the user never sees.)
Free creature stat blocks (thank you, regex)
D&D 5e stat blocks have a very consistent format. "Armor Class 15", "Hit Points 27 (5d8+5)", "Challenge 1/4 (50 XP)", that STR/DEX/CON/INT/WIS/CHA row. I wrote a regex parser that scans the extracted text and pulls out creature data for free. Zero AI tokens.
// creature-extractor.ts
// regex go brrrr
const AC_PATTERN = /Armor Class\s+(\d+)/i;
const HP_PATTERN = /Hit Points\s+(\d+)\s*(?:\(([^)]+)\))?/i;
const CR_PATTERN = /Challenge\s+([\d/]+)\s*\([\d,]+ XP\)/i;
// the six sacred numbers
const ABILITY_SCORES = /STR\s+DEX\s+CON\s+INT\s+WIS\s+CHA
\s*(\d+).*?(\d+).*?(\d+).*?(\d+).*?(\d+).*?(\d+)/i;Haiku also extracts creatures from context. In the "Souls for the Taking" test, Haiku caught the named villains and the regex caught stat blocks Haiku glossed over. Merge, deduplicate, done.
The production test
First real test. "Souls for the Taking: The Return of Larloch." 153 pages. Classic D&D adventure. Uploaded it to production with the v2 pipeline enabled.
| Metric | Old Pipeline | New Pipeline |
|---|---|---|
| Cost | ~$12.00 | $0.075 |
| API Calls | ~19 (8-page chunks) | 2 (chapter-boundary chunks) |
| Model | Sonnet ($3/$15) | Haiku ($1/$5) |
| Time to playable | 3-5 minutes | 30-60 seconds |
| Errors | Occasional JSON truncation | Zero |
Two API calls. 76 memories seeded with vector embeddings. Full NPC relationship graphs, encounter difficulty ratings, world facts with importance weights. The whole thing took about a weekend to build, which felt fast until I remembered I'd been running $12 extractions for months without noticing.
What doesn't work (yet)
Scanned PDFs still fall back to Sonnet. Your 1990s module scan still costs $10+. I'd need OCR to fix that, and I haven't built it.
Chapter detection isn't perfect. If your PDF uses non-standard headers (bold text instead of "Chapter N"), the detector falls back to fixed 20-page chunks. Works fine, just not as clean as real chapter boundaries.
Image classification is simple heuristics (aspect ratio + word count). It works for most D&D maps (full-page, square-ish, no text) but occasionally tags a full-page illustration as a map. Harmless but not beautiful. (The kind of thing I'll fix when someone actually complains about it.)
Portrait pre-generation for extracted creatures isn't wired up yet. The callback exists in the pipeline but the route doesn't pass it through. One-line fix I'll get to next week.
The PDF was never the hard part
I spent months assuming the AI was the expensive part of extraction. Turns out the AI was fine. The expensive part was making the AI read a format it didn't need to read. Separate what needs intelligence from what doesn't, and the costs fall away on their own.
Somewhere in your pipeline there's a step where you're paying for intelligence to do a job that doesn't require any. Might be worth looking.
