LLM 기반 임상 노트 정보 추출 — 비정형 EHR을 구조화 JSON으로 뽑아내는 실전 파이프라인
의사가 남긴 자유서술 임상 노트에는 증상·투약·검사·진단이 모두 담겨 있지만 통계·검색·연구에 바로 쓸 수 있는 형태가 아닙니다. 이 편은 이 비정형 텍스트를 정형 JSON으로 추출하는 실전 파이프라인을 하드코어 관점에서 구축합니다.
📚 선수 편 권고 (강력 권고)
이 편은 AI×바이오 하드코어 심화 편입니다. 진입 전에 DryBench의 다음 편들을 먼저 듣고·보시길 강력히 권고드립니다.
선수 편 없이 진입할 경우, 이 편에서 다루는 프롬프트 설계·구조화 출력 강제·환각 검증의 원리 재설명 없이 실전 코드부터 진행하므로 따라가기 어렵습니다.
우리 DryBench에서 이거 배웠잖아
DryBench ai-native #7에서 프롬프트가 단순한 "말 걸기"가 아니라 LLM 출력 분포를 재조정하는 조향 장치라는 것을, #8에서 RAG가 매개변수 밖 지식을 검색 결과로 컨텍스트에 얹는 접근이라는 것을, #11에서 할루시네이션이 결코 사라지지 않으며 반드시 외부 검증으로 잡아야 한다는 것을 배웠습니다.
그런데 실제 병원 EHR(Electronic Health Record)에서 매일 쏟아지는 수천 건의 자유서술 노트를 연구·통계·의사결정 지원에 쓰려면 이 세 가지 원리를 어떻게 조합해야 할까요. 이 편은 그 조합의 실전형입니다. 프롬프트로 구조화 출력을 강제하고, 정규식 fallback으로 이중 방어하고, UMLS 표준 코드로 검증합니다. 그리고 이 파이프라인이 왜 실패하는지 실제 사례로 봅니다.
하드코어 문제 정의
미국 병원 하나의 응급실에서 하루 평균 200500건의 임상 노트가 생성됩니다. 각 노트는 4002000자 자유서술로, 의사마다 표현 방식이 다르고 약어(TB=tuberculosis or total bilirubin, MI=myocardial infarction or mitral insufficiency)가 문맥으로만 구분됩니다. 여기서 다음 4개 필드를 정형 JSON으로 뽑아야 합니다.
symptoms: 증상 리스트 (예: chest pain, dyspnea)medications: 투약 리스트 (약명 + 용량 + 빈도)labs: 검사 결과 (검사명 + 수치 + 정상 범위)diagnoses: 진단 (ICD-10 코드 또는 자연어)
목표 지표: 각 필드 F1 ≥ 0.85 (n2c2 2018 벤치마크 상위 수준). 재현성·확장성·규제 준수(HIPAA)까지 확보.
기존 접근의 한계:
- 규칙 기반(SciSpacy, cTAKES): 정확도 F1 0.6~0.75 수준, 어휘 확장에 인간 시간 소모 큼.
- BERT 파인튜닝(BioBERT, ClinicalBERT): F1 0.80~0.87, 라벨 데이터 수천 건 필요.
- LLM structured output: F1 0.83~0.90(Med-Gemini 벤치 근거 [1]), zero-shot 또는 few-shot, 라벨 데이터 거의 불필요. 프롬프트+검증 파이프라인 설계가 핵심.
이 편은 세 번째 접근을 하드코어로 구축합니다.
도구 스택과 인프라 요구
| 도구 | 역할 | 라이선스 |
|---|---|---|
| Anthropic Claude API (structured output) | 정형 JSON 추출 | 상용 (사용량 과금) |
SciSpacy en_core_sci_lg | 규칙 기반 fallback + 개체 인식 | Apache 2.0 |
Python re (표준) | HIPAA Safe Harbor 개인정보 마스킹 | PSF |
| UMLS Metathesaurus | 진단·증상 표준 코드 매핑 | UMLS License (무료, 등록 필요) |
| MIMIC-IV (PhysioNet) | 학습·검증 데이터셋 | PhysioNet Credentialed License (무료, 등록 필요) |
인프라 요구: GPU 없이 실행 가능 (API 호출 기반). 로컬 CPU 4코어 · RAM 8GB 이상. SciSpacy 모델 다운로드 약 800MB.
학습자 재현 예상 비용: 임상 노트 1000건 처리 시 Claude API 약 5~15 USD (Anthropic 공식 요금표 기준 계산 [2]). MIMIC-IV·UMLS는 무료(등록 필요).
파이프라인 실전 구현
전체 흐름:
Step 1. HIPAA Safe Harbor Deidentification
미국 HHS는 임상 데이터 재식별 위험을 낮추기 위해 18개 식별자를 반드시 제거하도록 규정합니다 [3]. 규제 준수 없이 임상 노트를 외부 API로 보내는 것은 명백한 위반입니다.
import refrom typing import Dict, List
# HIPAA Safe Harbor 18 식별자 중 정규식으로 처리 가능한 주요 패턴HIPAA_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: """HIPAA Safe Harbor 규제 준수 마스킹. 정규식으로 처리 불가한 항목(환자명·지명·기관명)은 SciSpacy NER 병행 필요. """ masked = text for label, pattern in HIPAA_PATTERNS.items(): masked = re.sub(pattern, f"[{label}]", masked, flags=re.IGNORECASE) return masked정규식만으로는 이름·지명·기관명 같은 명명 개체를 잡지 못합니다. SciSpacy en_ner_bc5cdr_md 또는 en_core_sci_lg의 NER을 병행해 PERSON, GPE(지명), ORG 라벨을 마스킹해야 합니다. 실전 배포 시 Microsoft Presidio [4] 같은 전용 라이브러리 도입 권장.
Step 2. Claude Structured Output으로 JSON 추출
Anthropic 공식 문서 [5]에 따라 프롬프트에 스키마를 명시하고 tool_use 또는 JSON mode를 활용합니다.
import jsonimport anthropic
client = anthropic.Anthropic()
EXTRACTION_SCHEMA = { "name": "extract_clinical_fields", "description": "임상 노트에서 4개 필드를 정형 JSON으로 추출", "input_schema": { "type": "object", "properties": { "symptoms": { "type": "array", "items": {"type": "string"}, "description": "환자가 호소한 증상 (예: 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": "확진·의심 진단명 (ICD-10 코드 또는 자연어)", }, }, "required": ["symptoms", "medications", "labs", "diagnoses"], },}
SYSTEM_PROMPT = """당신은 임상 자연어 처리 전문가입니다.주어진 임상 노트에서 4개 필드(symptoms, medications, labs, diagnoses)를 추출합니다.
원칙:1. 노트에 없는 정보를 추론·창작하지 않습니다 (할루시네이션 금지).2. 약어는 문맥으로 정확히 해석합니다 (예: MI = myocardial infarction if cardiac context).3. 마스킹된 토큰([DATE_FULL], [MRN] 등)은 추출 대상이 아닙니다.4. 불확실한 진단은 diagnoses에서 제외하고 symptoms에만 포함합니다."""
def extract_fields(deidentified_note: str) -> dict: """Claude API를 호출해 정형 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 이중 방어
LLM이 특정 노트에서 구조화 출력 강제에도 불구하고 빈 리스트를 반환할 수 있습니다. 이 경우 규칙 기반 fallback으로 최소한의 필드를 채워 downstream 파이프라인이 무너지지 않게 합니다.
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]: """LLM이 labs를 비우면 정규식으로 최소 추출.""" 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 결과 + regex fallback.""" result = dict(llm_output) if not result.get("labs"): result["labs"] = regex_fallback_labs(note) return resultStep 4. UMLS CUI 매핑 (검증층)
UMLS Metathesaurus의 REST API [6]로 추출된 진단·증상을 표준 CUI(Concept Unique Identifier)에 매핑합니다. 이 단계는 다운스트림 통계·연구에서 표준 어휘를 강제하는 검증 층입니다.
import requests
UMLS_BASE = "https://uts-ws.nlm.nih.gov/rest"
def map_to_umls_cui(term: str, api_key: str) -> str | None: """UMLS REST API로 자연어 개념을 CUI로 매핑. api_key는 UMLS 등록 후 발급받은 값. 무료 등록. """ 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 평가
n2c2 2018 벤치마크 스타일로 필드별 F1을 계산합니다. Gold = 전문가가 정형 라벨링한 정답.
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]: """필드별 F1 반환.""" 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 scores통합 파이프라인
def process_note(raw_note: str, umls_key: str | None = None) -> dict: """임상 노트 1건을 5단계 파이프라인으로 처리.""" 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성능·비용·알려진 실패 케이스
성능 참고 (공개 벤치 인용)
| 접근 | 데이터셋 | F1 (필드 평균) | 출처 |
|---|---|---|---|
| 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 + 임상 IE | 0.87~0.91 | Google Research 2024 [1] |
| Claude structured (본 편 근사) | 유사 벤치 | 0.83~0.89 (추정) | Anthropic official case study [2] |
학습자 재현 예상 비용 (Claude API 요금표 기준 계산)
- 노트 1건 평균 800 토큰 입력 + 300 토큰 출력.
- Claude Sonnet 4.5 기준 입력 3 USD/M tokens, 출력 15 USD/M tokens (Anthropic 공식 요금표 [2]).
- 1000건 처리 시 약 (0.8 × 3) + (0.3 × 15) = 6.9 USD.
- 프롬프트 캐싱 활용 시 절반 이하로 축소 가능.
알려진 실패 케이스 3건 (커뮤니티·논문 수집형)
-
약어 문맥 오해석 (MI = myocardial infarction vs mitral insufficiency)
증상: 심장내과 노트에서 MI를 심근경색으로 뽑았으나 승모판 부전을 의도한 사례.
원인: 문맥 창이 짧아 앞뒤 문장에서 심장 판막 논의를 놓침.
회피: 시스템 프롬프트에 도메인 약어 사전을 few-shot으로 주입 + 노트 전체를 컨텍스트로.
출처: OpenAI Developer Forum, Clinical NLP thread [10]. -
Structured Output 빈 응답 (JSON schema 준수 실패)
증상: 특정 노트에서 tool_use가 빈 리스트만 반환.
원인: 노트 안 특정 유니코드 문자(zero-width space 등)가 파서 붕괴 유발.
회피: 입력 전처리로 비인쇄 문자 제거 + regex fallback 필수.
출처: Anthropic Cookbook GitHub Issues, tool_use edge cases [11]. -
Deidentification 누락으로 재식별 위험
증상: HIPAA 18 식별자 정규식만 사용 시 이름·지명·기관명 잔존.
원인: 정규식은 이름·지명 형태 자체를 인식 못함.
회피: SciSpacy NER 또는 Microsoft Presidio 병행 필수.
출처: JAMIA "De-identification of clinical text with automated methods" 리뷰 [12].
확장 아이디어
- BioBERT + LLM 앙상블: 규칙적 필드는 BioBERT, 문맥 의존 필드는 LLM. F1 0.90+ 도달 가능성.
- 한국어 EHR: 국내 SNUH·서울아산 등에서 사용되는 한국어 임상 노트로 확장. 한국어 UMLS 매핑(KOSTOM) 병용.
- 실시간 트리아지: 응급실 노트 → 5분 이내 정형 → 위험도 스코어 알림.
다음 편
- 편 09
llm-vendor-benchmark: 이 편의 파이프라인을 Claude / GPT-4o / Med-Gemini / Meditron으로 각각 실행해 벤치마크. - 편 10
med-llm-reproduction: HealthBench 재현으로 여러 벤더 임상 태스크 성능 비교. - 편 14
bio-mcp-agent: 이 편의 정형 JSON을 MCP tool로 노출해 자율 임상 에이전트 구성.
참고 문헌
- Med-Gemini clinical benchmarks — Google Research:
https://research.google/pubs/med-gemini/ - Anthropic Claude API pricing and structured output docs:
https://docs.anthropic.com/en/docs/build-with-claude/structured-output - HHS HIPAA Safe Harbor Deidentification method:
https://www.hhs.gov/hipaa/for-professionals/privacy/special-topics/de-identification/index.html - Microsoft Presidio (PII deidentification):
https://microsoft.github.io/presidio/ - Anthropic Tool Use overview:
https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview - UMLS REST API documentation:
https://documentation.uts.nlm.nih.gov/rest/home.html - Weissman GE et al., "Clinical NLP with rule-based baselines", JAMIA 2021.
- Lee J et al., "BioBERT: a pre-trained biomedical language representation model", Bioinformatics 2020.
- Agrawal M et al., "Large Language Models for Clinical Information Extraction", NEJM AI 2024.
- OpenAI Developer Forum clinical NLP thread (community reports).
- Anthropic Cookbook GitHub — tool_use edge cases:
https://github.com/anthropics/anthropic-cookbook - Meystre SM et al., "Automatic de-identification of clinical text", JAMIA review.
- MIMIC-IV dataset:
https://physionet.org/content/mimiciv/ - n2c2 (i2b2) NLP datasets:
https://www.i2b2.org/NLP/DataSets/ - SciSpacy models:
https://allenai.github.io/scispacy/