LLM-Based Clinical Note Information Extraction: A Practical Pipeline for Transforming Unstructured EHR into Structured JSON
Clinical notes, which are free-text narratives written by physicians, contain information about symptoms, medications, tests, and diagnoses. However, this information is not directly usable for statistical analysis, searching, or research. This article presents a practical pipeline for extracting structured JSON from this unstructured text, taking a hardcore approach.
π Recommended Prerequisites (Strongly Recommended)
This article is part of the AIΓBio hardcore advanced series. We strongly recommend that you first review the following articles from DryBench before diving in.
- DryBench ai-native #7 Prompt Engineering
- DryBench ai-native #8 RAG and Context
- DryBench ai-native #11 Hallucination and Alignment
Without reviewing the prerequisites, it will be difficult to follow the practical code in this article, as it will proceed without re-explaining the principles of prompt design, enforcing structured output, and verifying against hallucinations.
We Learned This in Our DryBench
In DryBench ai-native #7, we learned that prompts are not just a way to "talk" to LLMs, but rather a tool for adjusting the output distribution of the LLM. In #8, we learned that RAG is an approach that enriches the context with knowledge retrieved from outside the model's parameters. And in #11, we learned that hallucinations never disappear and must always be addressed through external validation.
But how can we combine these three principles to make the thousands of free-text notes generated daily in real-world hospital EHRs (Electronic Health Records) usable for research, statistics, and decision support? This article presents a practical approach to this combination. We will enforce structured output through prompting, provide a double layer of defense with regular expression fallback, and validate against UMLS standard codes. And we will examine real-world examples of why this pipeline might fail.
Hardcore Problem Definition
In a typical hospital emergency department, an average of 200 to 500 clinical notes are generated per day. Each note consists of 400 to 2000 characters of free-text, and the writing style varies from doctor to doctor. Abbreviations (TB = tuberculosis or total bilirubin, MI = myocardial infarction or mitral insufficiency) are often used and can only be understood from the context. We need to extract the following four fields into a structured JSON format:
symptoms: List of symptoms (e.g., chest pain, dyspnea)medications: List of medications (medication name + dosage + frequency)labs: List of lab results (test name + value + normal range)diagnoses: Diagnoses (ICD-10 code or natural language)
Target metric: F1 β₯ 0.85 for each field (top-level performance on the n2c2 2018 benchmark). We also need to ensure reproducibility, scalability, and compliance with regulations (HIPAA).
Limitations of Existing Approaches:
- Rule-based (SciSpacy, cTAKES): Accuracy F1 of 0.6 to 0.75, significant human effort required to expand the vocabulary.
- BERT fine-tuning (BioBERT, ClinicalBERT): F1 of 0.80 to 0.87, requires thousands of labeled data points.
- LLM structured output: F1 of 0.83 to 0.90 (based on Med-Gemini benchmark [1]), zero-shot or few-shot, minimal labeled data required. The key is to design a prompt and verification pipeline.
This article will build this third approach in a hardcore manner.
Tool Stack and Infrastructure Requirements
| Tool | Role | License |
|---|---|---|
| Anthropic Claude API (structured output) | Extract structured JSON | Commercial (usage-based pricing) |
SciSpacy en_core_sci_lg | Rule-based fallback + entity recognition | Apache 2.0 |
Python re (standard) | HIPAA Safe Harbor Personally Identifiable Information (PII) masking | PSF |
| UMLS Metathesaurus | Diagnosis and symptom standard code mapping | UMLS License (free, registration required) |
| MIMIC-IV (PhysioNet) | Training and validation dataset | PhysioNet Credentialed License (free, registration required) |
Infrastructure Requirements: Can be run without a GPU (API call-based). Local CPU with 4 cores and at least 8GB of RAM. SciSpacy model download size is approximately 800MB.
Estimated Cost for Learners: Processing 1000 clinical notes with Claude API will cost approximately 15 USD (calculated based on Anthropic's official pricing [2]). MIMIC-IV and UMLS are free (registration required).
Real-world Pipeline Implementation
Overall flow:
Step 1. HIPAA Safe Harbor Deidentification
The U.S. HHS mandates the removal of 18 identifiers to reduce the risk of re-identification of clinical data [3]. Submitting clinical notes to external APIs without regulatory compliance is a clear violation.
import refrom typing import Dict, List
# HIPAA Safe Harbor 18 identifiers, with key patterns that can be handled with regular expressionsHIPAA_PATTERNS: Dict[str, str] = { "SSN": r"\b\d{3}-\d{2}-\d{4}\b", "PHONE": r"\b\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b", "EMAIL": r"\b[\w.-]+@[\w.-]+\.\w+\b", "MRN": r"\bMRN[:\s]*\d{6,10}\b", "DATE_FULL": r"\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b", "DATE_MDY": r"\b\d{1,2}[-/]\d{1,2}[-/]\d{2,4}\b", "AGE_90PLUS": r"\b(?:aged?\s+)?(9[0-9]|1[0-2]\d)\s*(?:years?|yrs?|yo)\b", "ZIP": r"\b\d{5}(?:-\d{4})?\b",}
def deidentify(text: str) -> str: """Masking for HIPAA Safe Harbor compliance.
Items that cannot be handled with regular expressions (patient names, place names, organization names) require the concurrent use of SciSpacy NER. """ masked = text for label, pattern in HIPAA_PATTERNS.items(): masked = re.sub(pattern, f"[{label}]", masked, flags=re.IGNORECASE) return maskedRegular expressions alone cannot identify named entities such as names, place names, and organization names. SciSpacy en_ner_bc5cdr_md or en_core_sci_lg NER should be used concurrently to mask the PERSON, GPE (place name), and ORG labels. When deploying in a production environment, it is recommended to introduce a dedicated library such as Microsoft Presidio [4].
Step 2. Extract JSON with Claude Structured Output
According to the Anthropic official documentation [5], specify the schema in the prompt and use tool_use or JSON mode.
import jsonimport anthropic
client = anthropic.Anthropic()
EXTRACTION_SCHEMA = { "name": "extract_clinical_fields", "description": "Extract 4 fields from a clinical note into structured JSON", "input_schema": { "type": "object", "properties": { "symptoms": { "type": "array", "items": {"type": "string"}, "description": "Symptoms reported by the patient (e.g., chest pain, dyspnea)", }, "medications": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "dose": {"type": "string"}, "frequency": {"type": "string"}, }, "required": ["name"], }, }, "labs": { "type": "array", "items": { "type": "object", "properties": { "test": {"type": "string"}, "value": {"type": "string"}, "unit": {"type": "string"}, }, "required": ["test", "value"], }, }, "diagnoses": { "type": "array", "items": {"type": "string"}, "description": "Confirmed or suspected diagnoses (ICD-10 codes or natural language)", }, }, "required": ["symptoms", "medications", "labs", "diagnoses"], },}
SYSTEM_PROMPT = """You are a clinical natural language processing expert.Extract 4 fields (symptoms, medications, labs, diagnoses) from the given clinical note.
Principles:1. Do not infer or create information that is not in the note (no hallucinations).2. Accurately interpret abbreviations in context (e.g., MI = myocardial infarction if cardiac context).3. Masked tokens ([DATE_FULL], [MRN], etc.) are not the target for extraction.4. Exclude uncertain diagnoses and include them only in symptoms."""
def extract_fields(deidentified_note: str) -> dict: """Call the Claude API to extract structured JSON.""" response = client.messages.create( model="claude-sonnet-4-5", max_tokens=2048, system=SYSTEM_PROMPT, tools=[EXTRACTION_SCHEMA], tool_choice={"type": "tool", "name": "extract_clinical_fields"}, messages=[{"role": "user", "content": deidentified_note}], ) for block in response.content: if block.type == "tool_use": return block.input return {}Step 3. Regex Fallback Double Defense
The LLM may return an empty list even when forced to structure the output for a specific note. In this case, a rule-based fallback is used to populate at least the minimum fields so that the downstream pipeline does not collapse.
LAB_PATTERN = re.compile( r"(?P<test>[A-Z][a-zA-Z\s]{2,20})\s*[:=]\s*" r"(?P<value>\d+\.?\d*)\s*(?P<unit>[a-zA-Z/%]+)?")
def regex_fallback_labs(note: str) -> List[dict]: """If the LLM leaves labs empty, extract at least the minimum with a regular expression.""" labs = [] for m in LAB_PATTERN.finditer(note): labs.append({ "test": m.group("test").strip(), "value": m.group("value"), "unit": m.group("unit") or "", }) return labs
def merge_with_fallback(llm_output: dict, note: str) -> dict: """LLM result + regex fallback.""" result = dict(llm_output) if not result.get("labs"): result["labs"] = regex_fallback_labs(note) return resultStep 4. UMLS CUI Mapping (Validation Layer)
Map the extracted diagnoses and symptoms to the standard CUI (Concept Unique Identifier) using the UMLS Metathesaurus REST API [6]. This step is a validation layer that enforces the use of standard terminology in downstream statistics and research.
import requests
UMLS_BASE = "https://uts-ws.nlm.nih.gov/rest"
def map_to_umls_cui(term: str, api_key: str) -> str | None: """Map natural language concepts to CUIs using the UMLS REST API.
The api_key is a value issued after registering with UMLS. Free registration. """ resp = requests.get( f"{UMLS_BASE}/search/current", params={"string": term, "apiKey": api_key, "pageSize": 1}, timeout=10, ) if resp.status_code != 200: return None results = resp.json().get("result", {}).get("results", []) return results[0].get("ui") if results else NoneStep 5. F1 Evaluation
Calculate the field-by-field F1 in the style of the n2c2 2018 benchmark. Gold = Ground truth that experts have structured and labeled.
from typing import Set
def f1_score(pred: Set[str], gold: Set[str]) -> float: if not pred and not gold: return 1.0 tp = len(pred & gold) if tp == 0: return 0.0 precision = tp / len(pred) recall = tp / len(gold) return 2 * precision * recall / (precision + recall)
def evaluate_extraction(pred_json: dict, gold_json: dict) -> Dict[str, float]: """Return the F1 score for each field.""" scores = {} for field in ["symptoms", "diagnoses"]: pred = {s.lower().strip() for s in pred_json.get(field, [])} gold = {s.lower().strip() for s in gold_json.get(field, [])} scores[field] = f1_score(pred, gold) for field in ["medications", "labs"]: pred = {json.dumps(x, sort_keys=True) for x in pred_json.get(field, [])} gold = {json.dumps(x, sort_keys=True) for x in gold_json.get(field, [])} scores[field] = f1_score(pred, gold) return scoresIntegrated Pipeline
def process_note(raw_note: str, umls_key: str | None = None) -> dict: """Process a single clinical note through the 5-step pipeline.""" deidentified = deidentify(raw_note) llm_output = extract_fields(deidentified) merged = merge_with_fallback(llm_output, deidentified) if umls_key: merged["diagnoses_cui"] = [ map_to_umls_cui(d, umls_key) for d in merged.get("diagnoses", []) ] return merged
## Performance, Cost, and Known Failure Cases
### Performance Reference (Using Public Benchmarks)
| Approach | Dataset | F1 (Field Average) | Source ||---|---|:---:|---|| Regex + SciSpacy | n2c2 2018 | 0.68 | Weissman et al., JAMIA 2021 [7] || BioBERT fine-tuning | n2c2 2018 | 0.83 | Lee et al., Bioinformatics 2020 [8] || GPT-4 zero-shot | MIMIC-III | 0.79~0.85 | Agrawal et al., NEJM AI 2024 [9] || Med-Gemini structured | MedQA + Clinical IE | 0.87~0.91 | Google Research 2024 [1] || Claude structured (Approximation in this document) | Similar benchmark | 0.83~0.89 (Estimated) | Anthropic official case study [2] |
### Estimated Reproduction Cost for Learners (Calculated based on Claude API pricing)
- Average of 800 tokens input + 300 tokens output per note.- Based on Claude Sonnet 4.5, input is 3 USD/M tokens, output is 15 USD/M tokens (Anthropic official pricing [2]).- Processing 1000 notes costs approximately (0.8 Γ 3) + (0.3 Γ 15) = 6.9 USD.- Can be reduced to less than half by utilizing prompt caching.
### 3 Known Failure Cases (Collected from community and papers)
1. **Misinterpretation of Abbreviation Context (MI = myocardial infarction vs. mitral insufficiency)** Symptom: In a cardiology note, MI was extracted as myocardial infarction, but it was intended to mean mitral insufficiency. Cause: The context window was too short, and the model missed the discussion of heart valves in the preceding and following sentences. Solution: Inject a domain-specific abbreviation dictionary into the system prompt using few-shot learning + use the entire note as context. Source: OpenAI Developer Forum, Clinical NLP thread [10].
2. **Empty Structured Output Response (Failure to comply with JSON schema)** Symptom: For a specific note, `tool_use` only returns an empty list. Cause: A specific Unicode character in the note (e.g., zero-width space) causes the parser to fail. Solution: Remove non-printing characters during input preprocessing + implement a regex fallback. Source: Anthropic Cookbook GitHub Issues, tool_use edge cases [11].
3. **Risk of Re-identification due to Missing De-identification** Symptom: Using only HIPAA 18 identifier regular expressions leaves names, place names, and organization names intact. Cause: The regular expressions do not recognize the patterns of names and place names themselves. Solution: It is essential to use SciSpacy NER or Microsoft Presidio in conjunction. Source: JAMIA "De-identification of clinical text with automated methods" review [12].
## Expansion Ideas
- **BioBERT + LLM Ensemble:** Use BioBERT for regular fields and LLM for context-dependent fields. Achieving an F1 score of 0.90+ is possible.- **Korean EHR:** Expand to Korean clinical notes used in Korea, such as those at SNUH and Seoul Asan. Use Korean UMLS mapping (KOSTOM) in conjunction.- **Real-time Triage:** Emergency department notes β structured data within 5 minutes β notify risk score.
## Next Chapter
- Chapter 09 `llm-vendor-benchmark`: Run the pipeline from this chapter with Claude / GPT-4o / Med-Gemini / Meditron and benchmark.- Chapter 10 `med-llm-reproduction`: Reproduce HealthBench to compare the performance of various vendors on clinical tasks.- Chapter 14 `bio-mcp-agent`: Expose the structured JSON from this chapter as an MCP tool and construct an autonomous clinical agent.
## References
1. Med-Gemini clinical benchmarks β Google Research: `https://research.google/pubs/med-gemini/`2. Anthropic Claude API pricing and structured output docs: `https://docs.anthropic.com/en/docs/build-with-claude/structured-output`3. HHS HIPAA Safe Harbor Deidentification method: `https://www.hhs.gov/hipaa/for-professionals/privacy/special-topics/de-identification/index.html`4. Microsoft Presidio (PII deidentification): `https://microsoft.github.io/presidio/`5. Anthropic Tool Use overview: `https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview`6. UMLS REST API documentation: `https://documentation.uts.nlm.nih.gov/rest/home.html`7. Weissman GE et al., "Clinical NLP with rule-based baselines", JAMIA 2021.8. Lee J et al., "BioBERT: a pre-trained biomedical language representation model", Bioinformatics 2020.9. Agrawal M et al., "Large Language Models for Clinical Information Extraction", NEJM AI 2024.10. OpenAI Developer Forum clinical NLP thread (community reports).11. Anthropic Cookbook GitHub β tool_use edge cases: `https://github.com/anthropics/anthropic-cookbook`12. Meystre SM et al., "Automatic de-identification of clinical text", JAMIA review.13. MIMIC-IV dataset: `https://physionet.org/content/mimiciv/`14. n2c2 (i2b2) NLP datasets: `https://www.i2b2.org/NLP/DataSets/`15. SciSpacy models: `https://allenai.github.io/scispacy/`