Status: accepted (2026-06-16). Supersedes the previous embeddings/vector-based process. This document is the source of truth for the rebuild; per-stage READMEs link back here.
Turn a corpus of niche printer/device specification PDFs (ESC/POS, HPGL, line mode, hardware manuals) into machine-readable artifacts that ground an MCP server. The hard requirements are domain-specific:
- Preserve meaning. These are control-language specs; a mangled
GS ( Lor a dropped hex byte is a correctness bug, not a cosmetic one. - Findability. A user must be able to locate an exact command/term.
- Recall. Search must not silently miss a page that contains the answer.
Resource usage is not the priority (extraction is a one-time job), but we will not push every page through a top-tier hosted model.
12 PDFs across epson/hp/jvc/star, roughly 2,500-3,000 pages. The corpus is heterogeneous:
- Most docs are born-digital with a real text layer plus embedded figures.
- At least one (
pdf/hp/FFONS49JUMXQZJH.pdf: 230 images, no detectable text pages) is effectively scanned/image-only. - Several use object streams, so a clean text layer is not guaranteed per page.
A single extractor is therefore the wrong tool. The pipeline is tiered and quality-gated so each page gets the cheapest method that clears the bar, and the bad minority is escalated.
One-way data flow, three independently runnable Dockerized stages:
pdf/ ──▶ [1] extraction ──▶ [2] indexing ──▶ [3] mcp-server
jpeg + markdown search index serves all + MCP/HTTP
Code and static outputs are kept strictly separate.
pdf/<vendor>/<doc>.pdf # sources
data-extraction/ # STATIC OUTPUTS ONLY (binaries via Git LFS)
jpeg/<vendor>/<doc>/small/page-NN.jpg # render phase: ~1024px previews
jpeg/<vendor>/<doc>/big/page-NN.jpg # render phase: full-resolution renders
text/<vendor>/<doc>/page-NN.txt # text phase: raw text-layer slices
markdown/<vendor>/<doc>/page-NN.md # markdown phase: per-page slices ("md")
markdown/<vendor>/<doc>/document.md # assemble phase: full doc ("md-bulk")
describe/<vendor>/<doc>/page-NN.txt # describe phase (optional): VLM descriptions
quality/<vendor>/<doc>.json # quality phase: per-page QA metrics
quality/report.html # assemble phase: review of flagged pages
pagemap/<vendor>/<doc>.json # assemble phase: source-of-truth map
pagemap/schema.json # JSON Schema for the pagemap contract
meta/<vendor>/<doc>/<phase>.json # per-phase timing + toolchain metadata
index/
fulltext/ # primary index (now)
vector/ # reserved (later) - lets us compare types
manifest.json # builder version, params, counts, checksums
data-extraction-docker/ # CODE (build contexts)
extraction/ Dockerfile + extractor/QA code
indexing/ Dockerfile + index-builder code
mcp-server/ # CODE (Python 3.13 service)
Notes:
markdown/holds both per-page slices (page-NN.md) and the bulk single-file render (document.md). The task names one path,./data-extraction/markdown; keeping both under it avoids a second tree while preserving the md / md-bulk distinction the MCP image bakes.page-NNis zero-padded to the width of the document's page count. The 1:1 mappingpage-NN.md<->jpeg/.../page-NN.jpgis what lets search cite a page and show its render. Nothing downstream assumes filename math:pagemapis authoritative.index/<type>/makes index types siblings so we can build and compare them (fulltext now, vector later) instead of guessing one design up front.
Tiered, quality-gated. Implemented in data-extraction-docker/extraction/.
- Tier A (primary): Docling (Apache-2.0) for layout-aware Markdown - table structure (TableFormer), reading order on multi-column manuals, exports clean Markdown, runs fully local, no per-page API cost. Marker is the alternative; we keep it only as a sample-comparison probe, not a second pipeline.
- Tier B (fallback / cross-check): OCR for pages with missing or garbled text layers (the scanned HP doc, image-only pages). Also an independent signal to detect bad text-layer extraction.
- Tier C (VLM, profile-selectable): an OpenAI-compatible VLM can serve as the
primary
markdownbackend (transcribe the page image), as a faithfulnessvlm-judgeforquality, and for the optionaldescribephase. Which tier runs is a profile choice, not a code edit:defaultkeeps VLMs off (Docling + heuristic, no GPU/network);hosted/cheap-gpu/h200turn them on. Never the default, never a top-tier hosted model unless the profile asks for it. - JPEG rendering: small (~1024px) + big (full-res), driven off the same page
enumeration that produces the markdown, so the 1:1 mapping is guaranteed and
recorded in
pagemap.
The extractor is manifest-driven and shardable: a doc manifest lets a CI matrix split the corpus across parallel runners (doc-level sharding) to stay under the runner time cap.
Each phase's engine/model is set by a profile - a TOML file selected with the
global --profile flag (default default): one place to swap models or downgrade
to a shared runner, never a code change. Secrets are referenced by env-var name
(api_key_env), never stored. Built-ins: default (no GPU/network), hosted
(hosted API, runs on a shared runner), cheap-gpu, h200. Selecting an
unregistered backend fails immediately with the available names.
Extraction is not one opaque pass; it is a sequence of discrete phases sharing one
image, each producing a single artifact kind for a whole document and recording
its own timing + metadata. This keeps intermediates for troubleshooting, lets us
re-run/replace one phase without redoing the others, and makes room to insert new
phases (e.g. a VLM describe step) between existing ones.
| Phase | Input | Output | Backend (profile section) |
|---|---|---|---|
render |
jpeg/<stem>/{small,big}/page-NN.jpg |
- ([render]) |
|
text |
text/<stem>/page-NN.txt (raw text layer) |
- | |
markdown |
markdown/<stem>/page-NN.md |
docling | vlm |
|
quality |
markdown+text | quality/<stem>.json |
heuristic | vlm-judge |
describe |
jpeg + quality | describe/<stem>/page-NN.txt (VLM; optional) |
VLM |
sections |
markdown | sections/<stem>.json (logical chunks) |
headings | llm-text |
assemble |
all the above | pagemap/<stem>.json + markdown/<stem>/document.md + reports |
- |
all-phases runs render -> text -> markdown -> quality -> assemble. describe
and sections are separate opt-in phases, excluded from that convenience.
quality can use vlm-judge, which keeps the heuristic verdict everywhere and
adds a VLM faithfulness check on figure pages only (image + markdown in, OK
or MISSING: ... out) - catching diagrams the text silently drops, since the
heuristic scores an image-only page fine on coverage; figureless pages make no
model call, bounding cost. describe's --gate selects pages - default
illustrated (any figure placeholder plus flagged/empty pages, since a text-rich
page can still hide a diagram), or flagged / all (gates are an extensible
registry); its output is a separate, lower-signal index field, never merged into
the authoritative markdown. sections emits logical chunks (a command/topic spans
pages; a page is a print-media artifact, not the retrieval unit) for section-level
retrieval, via headings (deterministic) or llm-text. All VLM-backed work is
provider-pluggable via any OpenAI-compatible endpoint (local vLLM or a hosted
model).
- Atomic unit is (phase, document): "all or none artifacts of the same kind", no per-page resume state. Re-run a phase to redo it.
- Each phase writes
meta/<stem>/<phase>.json(tool, version, params, start/end, total + per-page timing, status).assemblefolds a summary into the pagemap'sphasesfield, so timing/quality lineage lives with the results.
- Automated per-page metrics ->
quality/<vendor>/<doc>.json: char/alnum ratio, dictionary hit-rate (gibberish detector), OCR-vs-text-layer agreement, table-cell counts, control-char/symbol preservation, and domain token checks (ESC/POS pages should contain mnemonics likeESC @/GS ( L; HPGL pages should containPU/PD/PA). - A confidence gate flags the low-scoring minority for Tier C and/or human review.
- Manual review is bounded:
quality/report.htmlshows JPEG and extracted Markdown side-by-side for flagged pages only - eyeball ~5%, not 3,000 pages. - Before the full run, hand-verify ~3-5 pages per doc (especially command-table pages) to validate the tool choice cheaply.
- Compute: ML extraction over ~3,000 pages is slow on GitHub-hosted runners (2 vCPU, 6h cap). Start there with sharding; spin up private runners of proper config if we hit timeouts or usage limits. The image runs unchanged on either; only the runner label changes.
- Determinism: ML extractors drift across versions. Pin tool + model versions
and record them in
pagemap/manifest. - Hard tables: merged-cell command tables degrade in Markdown. The JPEG page stays the visual authority and Tier C is the escape hatch.
Engine: SQLite FTS5. Single file -> trivially baked into the mcp-server image,
BM25 ranking, no runtime services, custom tokenizer support. Implemented in
data-extraction-docker/indexing/.
- Retrieval is section-first: a command/topic is a logical unit that may span
several pages, so a section (not a page) is the primary search result. Pages
stay indexed as a fallback (exact byte/symbol drilling) and as the displayable
artifact. Sections come from the extraction
sectionsphase (sections/<stem>.json), each carrying the page range/labels it covers. - Tokenizer is the make-or-break detail. Default tokenizers shatter
ESC/POS,GS ( L, hex1B 40on punctuation and destroy findability. Dual indexing per unit - aunicode61index with extended token characters to keep command symbols intact (ranked search) plus atrigramindex for substring/symbol recall ("not missing anything"). Sections rank withbm25weights favouring title/heading over body. - Schema:
sections {stem, vendor, doc, section_no, title, heading_path, level, body, page_start, page_end, page_labels, char_count}(primary) +pages(fallback + display) + a doc-level table forlist_documents/ summaries. Sections are backend-agnostic: the build consumes whateversections.jsonexists (headingsdefault,llm-textlater) with no index change; a doc missingsections.jsonis still indexed at page level. - Summaries: per-doc "what devices/technologies it covers", generated extractively (no LLM), ASCII-clean, stored in the index and searchable.
manifest.jsonrecords builder version, params, doc/page/section counts, checksums.- Eval harness: a fixed query set measures recall/precision per unit (
--unit section|page). Page-level ground truth stays; a section counts relevant if it covers a relevant page. It validates the index and compares index types objectively.
Python 3.13. Implemented in mcp-server/.
- Framework: official Python MCP SDK / FastMCP over streamable HTTP (works on Render; gives an HTTP surface for static assets and the landing page).
- Tools:
list_documents(),get_document_summary(stem),search_specs(query, vendor?, k?)- the primary search, returning ranked sections, each with a snippet, the pages it covers (with image URLs), and asection_id;get_section(stem, section_id)for a section's full Markdown + page list;search_pages(query, vendor?, k?)as a page-level fallback for exact byte/symbol lookups;get_page(stem, page); andget_page_image(stem, page, size?)- the last returns the rendered page as MCP image content (base64) so a vision-capable client can see figures/diagrams the Markdown cannot convey (search/get_page only return image URLs).stemis the vendor-rooted path without extension, e.g.star/star_graphic_cm_en. - Static serving:
/static/jpeg/...and/static/md/.... WhenDOCS_STATIC_BASE_URLis set the server returns CDN URLs and does not serve static itself; when unset it self-serves and returns relative URLs. Switching to a CDN is exactly that one env var - no rebuild. - Landing page (
/) for robots and guests: corpus overview, vendor/doc list, version, how to connect. Plus/healthzand/version. - "Swagger-like UI": there is no literal OpenAPI/Swagger for MCP tools, so we
auto-generate an HTML tool catalog at
/docsfrom the registered tools' JSON schemas, and point to the official MCP Inspector for live interaction. - Logging only, never print. Thorough: every tool call (args, result counts, timing), index load, static hits. Level via env.
version.pyholds__version__, surfaced on the landing page and/version.- ASCII only. No emojis anywhere.
mcp-server:<ver>(lean): application + search index baked, no static files. Static comes from a mounted volume or a CDN (DOCS_STATIC_BASE_URL). This is the future CDN target.mcp-server:<ver>-stuffed: additionally bakes md, md-bulk, and jpegs. Fully self-contained - serves static by itself, or uses a CDN ifDOCS_STATIC_BASE_URLis configured.
Both load the same baked index; the only difference is whether static assets are inside the image.
- Storage: Git LFS.
.gitattributestracks*.jpgand the FTS5*.db; markdown/json stay as normal git text. Caveat: GitHub LFS has storage and bandwidth quotas and the mcp-server build pulls LFS objects on checkout; if we hit the bandwidth ceiling, that is the trigger to move static to a CDN (already the planned direction). Requiresgit lfs installlocally and in CI before the first binary commit. - Pipeline order: extraction -> indexing -> image build (both variants) ->
deploy (Render). Implemented as three workflows chained with
workflow_run(which fires even for GITHUB_TOKEN commits, unlikepush), each with a concurrency group and LFS checkout:extract.yml- manual (workflow_dispatch); inputs choose the phase and scope (all / one stem). Builds the extraction image (buildx + gha cache), runs the phase, commits outputs to LFS. Single job for now; if a run hits the 6h cap, escalate to a private runner or dispatch per-stem (doc-level matrix fan-in with an artifact collect step is the documented next step).index.yml- auto after a successful Extract (plus path-push and dispatch); builds the index, runs the eval gate (evaluate --min-recall), commits the index to LFS.build-mcp-server.yml- auto after a successful Index (plus push tomcp-server/**and dispatch); builds + pushes:<ver>and:<ver>-stuffedto GHCR, then triggers a Render deploy ifRENDER_DEPLOY_HOOK_URLis set.
- Version: image tags come from each component's
version.py(__version__). Bump it to publish a new tag;:latest/:latest-stuffedalways move.
data-extraction/pagemap/<vendor>/<doc>.json, validated against
data-extraction/pagemap/schema.json. One record per document; one entry per
page tying together the page number, its markdown slice, both JPEG renders, the
extraction method used, and its quality score. This is the only authority for
page<->artifact mapping; downstream code reads it instead of recomputing paths.
- Vector index under
index/vector/for later comparison. vlm-windowsections backend (page-image sliding window) - a registry drop-in.- CDN cutover (set
DOCS_STATIC_BASE_URL) once LFS bandwidth or image size warrants it.
- Scaffold
data-extraction/(outputs) anddata-extraction-docker/(extraction, indexing); add.gitattributes(LFS); define the pagemap contract. [in progress] - Extraction image: Docling + OCR + JPEG render + QA + flagged-page report; manifest-driven and shardable; validate on a golden sample.
- Full extraction run via sharded matrix workflow -> commit md + jpeg to LFS; manual review of flagged pages only.
- Indexing image: SQLite FTS5 dual tokenizer, extractive summaries,
manifest.json, eval harness. - mcp-server rewrite: FastMCP HTTP, the four tools, static serving, landing +
/docs, logging-only/ASCII-only,version.py; Dockerfile producing both:<ver>and:<ver>-stuffed. - Wire workflows: extraction (matrix) -> indexing -> image build (both
variants) -> Render deploy, ordered via
needs:/workflow_run, with LFS checkout. - Backfill the TBD README sections as each stage lands.