BioPlayground

🧬
목록으로

LLM 기반 임상 노트 정보 추출 — 비정형 EHR을 구조화 JSON으로 뽑아내는 실전 파이프라인

MIMIC-IV 임상 노트 같은 비정형 EHR 텍스트에서 증상·투약·검사·진단을 구조화 JSON으로 추출하는 하드코어 파이프라인. HIPAA Safe Harbor 개인정보 마스킹, LLM structured output, regex fallback 이중 방어, UMLS CUI 매핑, F1 평가까지.

중급
|
25
|
검증 완료 (2026-07)
진행률0/15 (0%)

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는 무료(등록 필요).

파이프라인 실전 구현

전체 흐름:

mermaid

Step 1. HIPAA Safe Harbor Deidentification

미국 HHS는 임상 데이터 재식별 위험을 낮추기 위해 18개 식별자를 반드시 제거하도록 규정합니다 [3]. 규제 준수 없이 임상 노트를 외부 API로 보내는 것은 명백한 위반입니다.

python
import re
from 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를 활용합니다.

python
import json
import 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 파이프라인이 무너지지 않게 합니다.

python
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 result

Step 4. UMLS CUI 매핑 (검증층)

UMLS Metathesaurus의 REST API [6]로 추출된 진단·증상을 표준 CUI(Concept Unique Identifier)에 매핑합니다. 이 단계는 다운스트림 통계·연구에서 표준 어휘를 강제하는 검증 층입니다.

python
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 None

Step 5. F1 평가

n2c2 2018 벤치마크 스타일로 필드별 F1을 계산합니다. Gold = 전문가가 정형 라벨링한 정답.

python
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

통합 파이프라인

python
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 + SciSpacyn2c2 20180.68Weissman et al., JAMIA 2021 [7]
BioBERT fine-tuningn2c2 20180.83Lee et al., Bioinformatics 2020 [8]
GPT-4 zero-shotMIMIC-III0.79~0.85Agrawal et al., NEJM AI 2024 [9]
Med-Gemini structuredMedQA + 임상 IE0.87~0.91Google 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건 (커뮤니티·논문 수집형)

  1. 약어 문맥 오해석 (MI = myocardial infarction vs mitral insufficiency)
    증상: 심장내과 노트에서 MI를 심근경색으로 뽑았으나 승모판 부전을 의도한 사례.
    원인: 문맥 창이 짧아 앞뒤 문장에서 심장 판막 논의를 놓침.
    회피: 시스템 프롬프트에 도메인 약어 사전을 few-shot으로 주입 + 노트 전체를 컨텍스트로.
    출처: OpenAI Developer Forum, Clinical NLP thread [10].

  2. Structured Output 빈 응답 (JSON schema 준수 실패)
    증상: 특정 노트에서 tool_use가 빈 리스트만 반환.
    원인: 노트 안 특정 유니코드 문자(zero-width space 등)가 파서 붕괴 유발.
    회피: 입력 전처리로 비인쇄 문자 제거 + regex fallback 필수.
    출처: Anthropic Cookbook GitHub Issues, tool_use edge cases [11].

  3. 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로 노출해 자율 임상 에이전트 구성.

참고 문헌

  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/