How it works
From a scanned page to a cited answer, with a gate at every step.
One pipeline, two graphs. Ingest turns a document into de-identified chunks and structured facts; query turns a question into a cited, validated, re-identified answer. Every stage below names the module it lives in and the test or eval metric that covers it.
- Document receivedtenant resolved from the API key, job queued on Redis Streams
- Text extractedroute: ocr (no text layer), Tesseract
- PHI removed9 placeholders, map encrypted per tenant
- Clinical facts extracted12 facts, 39 ms
- Chunk embedded384-dim, chunk 128
- Audit writtenjob.ingest.completed · wall time 2.1 s
- Question de-identifiedsame recognizers as the document
- Retrieval scopedto (tenant, patient), vector + BM25
- Answer draftedlocal 7B model, citation [128]
- Validation okcites_chunks true · faithfulness 1.0 · phi_leak false
- Re-identifiedfor the caller only, only the tokens the answer uses
- Audit writtenjob.query.completed · 3.6 s median
Pipeline
Ten stages, in the order they run.
Ingest is stages 1 to 4 and 10. A query runs 5 to 10. The deidentified flag set in stage 2 is asserted by every later stage that touches a model or the index.
Text extraction, with native, OCR and vision routing
PyMuPDF renders each page at 200 dpi and keeps any native text layer. A page with more than 200 characters of native text is read as is. Otherwise Tesseract runs and reports its mean word confidence: at 75 or above with at least 40 words the page is routed to OCR; below that it goes to a vision model (Qwen2.5-VL, behind the
vlmcompose profile) if one is configured, or to OCR withvlm_wantedflagged so the usage stats stay honest.storage_uriis client input, so it is confined toDATA_ROOTafter symlink resolution.Presidio de-identification, with a reversible per-tenant PHI map
Presidio with spaCy
en_core_web_lg, plus recognizers Presidio does not ship: labelled MRNs, NANP phone numbers with extensions, clinician names anchored on a title, labelled address lines and bareCity, ST 12345, and a date filter that keeps dosing frequencies such as "daily". Pages are scrubbed with shared token state, so one person gets one placeholder across a document. Overlapping candidates merge into their union, so a fragment of any above-threshold span never survives. The placeholder-to-value map goes tophi_tokensthroughpgp_sym_encryptunder a per-tenant key; decrypting it with another tenant's key returns "Wrong key or corrupt data".Clinical fact extraction
Three layers over the de-identified text, cheapest first: regex rules for labs, vitals, allergies, medication lines, diagnosis lists, family history and follow-up; Med7 (medications with dose, route and frequency) and bc5cdr (problems) merged by character offset; medspacy sections and ConText assertion, so "Denies pneumonia" is stored as absent and a family-history line as family. Every fact links to the chunk with the largest span overlap. Median 36 ms per page inside the container.
Index
Chunks of up to 800 characters with 120 of overlap, split at headings and blank lines first so a medication list stays whole. Each chunk is embedded by the local embeddings service (bge-small-en-v1.5, 384 dimensions; fastembed on Apple Silicon, TEI on x86) and stored in
chunks(pgvector, HNSW, cosine) with its page, extraction route and character offsets, under the same row-level security as every other PHI table. Only de-identified text is ever embedded.Retrieval scoped to (tenant, patient)
The question is de-identified with the same recognizers, embedded, and matched against this tenant's and this patient's chunks only:
WHERE tenant_id=%s AND patient_id=%s, on top of row-level security. The top 30 by cosine form a pool; BM25 over the pool catches exact terms such as drug names; reciprocal rank fusion (k=60) merges the two rankings and the top 6 go to the model. Placeholders from different documents are renumbered into one namespace per query, so the same label cannot mean two people inside one prompt.Answer
The small tier (
qwen2.5:7b-instructthrough Ollama here; Qwen2.5-7B-Instruct-AWQ on vLLM with a GPU) drafts an answer from the retrieved chunks and must cite each one it uses as[chunk id]. No cloud endpoint is called at any stage.Validation
Three checks on every draft.
cites_chunks: at least one retrieved chunk id appears in the answer.grounded: a faithfulness judge scores the answer against the chunks and must reach 0.7.phi_leak: a second de-identification pass over the answer, with citation markers stripped first so[12]is never mistaken for an identifier.okis all three. The judge's tokens are metered like any other call.Single escalation
A draft that fails validation is regenerated once on the large tier. A PHI leak is never retried, and a second failure returns no answer rather than an unverified one. In the last eval run that was one escalation and one rejection in forty questions.
Re-identification, for the authorized caller only
After validation passes, only the tokens the answer uses are restored: question tokens from the in-memory map, document tokens decrypted from
phi_tokensunder the tenant key, using the per-query namespace index to find which document each came from. Nothing is decrypted before the model has answered, and nothing the answer does not use. Logs, checkpoints and metrics keep the tokenized form.Audit write
Every job ends with an
audit_logrow:job.ingest.completedwith pages, routes and fact counts;job.query.completedfor a delivered answer, orjob.query.rejectedwith the reasons (phi_leak,cites_chunks,grounded,no_chunks) when validation failed, each with the validation summary, whether an escalation ran, and whether anything was re-identified;facts.readon the facts endpoint. The application role holds INSERT and SELECT on the table and nothing else, and row-level security limits its SELECT to its own tenant.
Why an arbiter
The model is not in charge.
Language models are persuasive, and a clinical record is the wrong place to be persuaded. So there is a gate on each side of the model: de-identification between the document and the model, and validation between the model and the caller. Cite the source, clear the faithfulness threshold, leak nothing, or return no answer. The arbiter is the part that says no, and the eval harness is what shows it saying no for the right reasons.
Engineering note
Cost that scales with difficulty, not volume.
Native text is free: PyMuPDF reads the layer. Tesseract costs about 1.5 to 2 seconds per page on the Apple Silicon dev machine; the scanned-page ingest in the trace above is 2.1 s wall, annotation included. A vision model runs only when OCR confidence is under 75 or the page has fewer than 40 words, which means forms, tables and handwriting, not clean scans. On the answer side the 7B model goes first and the large tier runs at most once, after a validation failure: one escalation in forty questions in the last run. The routing is what lets the expensive checks run on every request.
Interface
Two endpoints and a job poll. Asynchronous by design.
Requests return a job id immediately and never block on model inference. The worker reads a Redis stream with at-least-once delivery, and ingest is idempotent so a redelivered message cannot double-process.
POST /v1/documents
Authorization: Bearer hipaa_live_…
Idempotency-Key: ingest-P00001-scan
{ "patient_external_id": "P00001",
"doc_type": "discharge_summary",
"storage_uri": "/data/synthetic/scan/P00001.pdf" }
→ 202 { "job_id": "f9fc0185-…" }
POST /v1/queries
Authorization: Bearer hipaa_live_…
{ "patient_external_id": "P00001",
"question": "What is the most recent HbA1c?" }
→ 202 { "job_id": "…" }
GET /v1/jobs/…
→ { "status": "done",
"result": { "answer": "The most recent HbA1c is 5.7% [128].",
"citations": [128],
"validation": { "ok": true, "cites_chunks": true,
"faithfulness": 1.0, "grounded": true,
"phi_leak": false } } }
Any HTTP client works. The full endpoint table, including GET /v1/patients/{external_id}/facts, is in the docs.
Observability
Every job records what it cost.
The jobs row for each ingest and query carries tokens_small, tokens_large and cost_cents at placeholder rates, judge calls included. That is how the eval reports tokens and cost per query, and how the judge's calls turned out to be half of all model traffic when they were still unmetered. The audit row beside it carries the extraction route mix per document, so the OCR and vision share of a corpus comes from one table.