← All projects

Health data engineering · GenAI evaluation

From FHIR to OMOP to RAG: answering clinical questions from notes, and measuring it honestly

An end-to-end real-world-evidence pipeline on synthetic patients: FHIR records built into an OMOP common data model with dbt, clinical notes with exact ground truth, and a retrieval-augmented LLM that answers questions about each patient with citations. The test set was frozen before any tuning and scored once, against a plain SQL baseline.

Context
Portfolio project; RWE data engineering and applied GenAI on healthcare data
Data
Synthea: 1,151 synthetic patients, 1.44 million FHIR resources, 8,692 generated notes. All data is synthetic.
Methods
OMOP CDM v5.4 with OHDSI vocabularies, dbt tests, BM25 / dense / hybrid retrieval, cited LLM answers, patient-clustered bootstrap
Stack
Python, SQL, dbt, PostgreSQL + pgvector, bge-small, Claude API, Docker, GitHub Actions
Dot plot of answer accuracy and recall at 5 for five retrieval configurations: accuracy rises with recall, from 0.827 for dense retrieval to 0.925 for hybrid with recency on clean notes
Test set, scored once. Each configuration’s accuracy tracks how often the right excerpt was retrieved.
0.925Answer accuracy on 1,200 held-out questions (95% CI 0.912–0.938), best clean configuration
84 → 31Wrong answers after a retrieval miss, BM25 vs hybrid with recency: the rest became “not documented”
0 / 184Unanswerable questions answered anyway, in every configuration
The question

When does an LLM over notes beat a query over tables?

Clinical questions are answered either from structured data (conditions, drugs, labs) or from free text. Retrieval-augmented generation promises the second, but its accuracy is usually reported on a handful of examples, with no baseline and no account of how it fails. This project builds both routes over the same synthetic patients, so every answer can be checked exactly.

FHIR to OMOP

A tested OMOP CDM, with mapping coverage as a metric

Synthea generates FHIR R4 bundles with fixed seeds and dates, so output is byte-identical across runs. Raw resources land in PostgreSQL as JSON, and dbt builds an OMOP CDM v5.4 subset: person, visits, conditions, drugs, measurements, procedures, observations, death and notes. Source codes are mapped to OHDSI standard concepts and routed to tables by the concept’s domain, not by FHIR resource type.

CDM tableRecordsRecords mappedCodes mapped
Measurement601,76699.99%98.1%
Observation321,04999.93%91.6%
Drug exposure72,577100%100%
Condition17,96999.0%98.2%
Procedure83,54587.3%40.7%

The procedure gap is CDT dental codes, which are licensed separately and not distributed through Athena: a licensing gap, not an ETL bug, and the coverage test warns about it as designed. 108 dbt data tests cover keys, referential integrity, date logic, events after death, plausible lab ranges and conformance to the official CDM field specification, and 6 dbt unit tests show each rule fires on deliberately corrupted rows.

Ground truth

Notes where every fact is known

Notes are generated from the CDM by templates, so every statement has a known position, polarity (negated or not), time (current or historical) and subject (patient or relative). The traps are planted deliberately: “no evidence of…”, “h/o … resolved”, “Mother: …”. Some facts exist only in the notes, such as family history and why a drug was stopped, so there is a category SQL cannot answer.

  • 1,800 questions across 11 templates, each with an answer derived by SQL and the exact facts that support it.
  • Split by patient (75 dev, 150 test), so no patient’s notes appear in both. The test file was frozen by SHA-256 before retrieval was built, and the code refuses to score it outside the final run.
  • A messier copy: an LLM rewrote the test notes into terse clinical shorthand, with each fact tagged so a deterministic validator could confirm nothing was lost or changed; 98.9% of notes passed.
Retrieval and answers

Search one patient’s chart, cite every claim

Notes are chunked and embedded locally (bge-small, CPU) into pgvector. Retrieval is scoped to one patient, as chart search is, and compares BM25, exact dense search, hybrid fusion (reciprocal rank fusion) and hybrid with recency, which re-sorts the top 20 newest-first for time-sensitive questions. Claude answers from the top 5 excerpts in a fixed JSON format; the schema only allows citing excerpts that were shown.

A hard spend cap is enforced in a Postgres ledger before each call, responses are cached so a re-run never pays twice, and the test runs went through the batch API at half price. All five test runs, the note rewrite and the dev pilot cost $70.56.

Results

Retrieval decides accuracy; SQL wins where the data is structured

ConfigurationRecall@5Accuracy (95% CI)Wrong after a retrieval miss
Dense0.7190.827 (0.806–0.846)118
Hybrid (RRF)0.7750.867 (0.849–0.884)109
BM250.8450.908 (0.892–0.923)84
Hybrid + recency0.8940.925 (0.912–0.938)31
Hybrid + recency, rewritten notes0.9130.942 (0.927–0.955)26

Confidence intervals come from a bootstrap that resamples patients, because questions about the same patient are correlated. The four clean configurations rank identically on retrieval and on accuracy. When the right excerpt was shown, the model rarely erred: at most 10 temporal or value errors per run, and no negation errors.

Plain SQL over the CDM scores 1.000 on every structured question type, bar one disagreement in 859 (SNOMED files eclampsia under seizure disorder, so SQL says yes where the note-level answer is no). The notes were generated from the same CDM, so SQL is a ceiling by construction. RAG’s value is in note-only questions, family history and stop reasons, where it scored 1.000 on 157 questions that SQL cannot answer at all.

Failures

How it fails matters more than how often

  • Retrieval misses dominate. Every wrong answer was classified once; retrieval misses account for most errors in every run, and they split into a safe outcome (“not documented”) and an unsafe one (a wrong answer). Hybrid with recency turned most misses into safe abstentions.
  • Stale lab values. The weakest question type is the most recent lab result (0.61). Every failure is a retrieval miss, after which the model reports an older value as the latest: the most clinically dangerous pattern in the results.
  • Rewritten notes are not easier. They scored higher only because they are about 20% shorter, so each chunk carries more facts. Messiness itself lowered rank-1 recall and cut stop-reason accuracy from 1.00 to 0.80.
  • Extra detail beyond the citations. In a 50-claim grounding check, every unsupported claim added a diagnosis or resolution date that its cited excerpts did not show. The automatic grounding rule caught all of them and was, if anything, too strict.
Limits

What this does and doesn’t show

What it supports

  • On these notes, answer quality is limited by retrieval, not by the model’s reasoning.
  • Recency-aware hybrid retrieval cuts unsafe wrong answers by more than half compared with BM25.
  • For facts the CDM already holds, SQL is exact, cheaper and auditable.

What it doesn’t show

  • Clinical performance: the data is synthetic and the notes are templated, so these numbers are an optimistic upper bound.
  • Search across a hospital: retrieval is within one patient’s chart, about 65 chunks each.
  • Run-to-run model variance: one model, one prompt, one run; the intervals cover patient sampling only.

All data is synthetic, generated by Synthea. No real patient data was used. OHDSI vocabularies were downloaded from Athena and are not redistributed.

Get in touch

I’m looking for bioinformatics roles in statistical genetics, transcriptomics and NGS analysis, especially where clinical or cell and gene therapy experience helps. Based in London, open to hybrid and remote.