医疗文本结构化的混合方案
医疗文本结构化:为什么“规则 + 词典 + 大模型”比单一路线更稳
摘要:病历、检验描述和业务记录往往是半结构化文本。只用正则难以覆盖语言变化,只用大模型又可能产生不稳定输出。本文介绍一种分层抽取方案,让不同技术各自处理最擅长的问题。
标签:NLP 医疗数据 大模型 信息抽取 Python
一、结构化不是简单地“转成 JSON”
医疗文本结构化通常包含实体识别、属性抽取、关系判断、标准化映射和质量校验。例如从一句话中提取疾病名称并不难,真正困难的是同时判断:
- 是患者本人还是家族成员;
- 当前存在、既往存在,还是已经排除;
- 严重程度和发生时间是什么;
- 原文术语应映射到哪个标准编码;
- 信息冲突时以哪一条为准。
因此,“模型返回了一个 JSON”只是格式完成,不代表语义正确。
二、把任务分成三类
1. 确定性强的内容交给规则
日期、数值、单位、身份证式编号、标准格式编码等内容,适合使用正则和解析器。它们可解释、速度快,也方便编写单元测试。
import re
pattern = re.compile(r"(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>mmHg|mmol/L|mg/L)")
def extract_measurement(text: str):
return [m.groupdict() for m in pattern.finditer(text)]
规则的边界也很清楚:表达一旦变得灵活,例如“较前明显升高”“否认长期用药”,仅靠正则会迅速膨胀。
2. 标准名称映射交给词典和检索
同一个概念可能有简称、旧称、英文名和书写变体。可先做文本归一化,再使用别名词典召回候选,最后根据上下文选择标准项。
这里要保留“原始值”和“标准值”:
{
"raw_text": "原文中的名称",
"normalized_name": "标准名称",
"code": "标准编码",
"match_method": "alias_dictionary",
"score": 0.96
}
保留原文有两个好处:一是方便追溯,二是词典更新后可以重新映射,而不必重新解析全部文档。
3. 上下文判断交给大模型
否定、时间、主体和复杂关系更适合让语言模型判断。例如“母亲有高血压,患者否认相关病史”中出现了疾病词,但不能简单标记为患者现病。
给模型的任务应尽量窄,并提供严格的枚举值:
- 主体:患者 / 家属 / 其他 / 未知;
- 状态:当前 / 既往 / 排除 / 怀疑 / 未知;
- 证据:必须返回对应原文片段;
- 无法判断时返回 unknown,不允许猜测。
三、推荐的处理流水线
一个可维护的流程通常是:
- 文本预处理:统一全半角、空格、单位和特殊字符;
- 规则抽取:解析数值、日期、编码等确定性字段;
- 词典召回:生成标准术语候选;
- 模型判断:识别主体、否定、时间和关系;
- 结果校验:通过 JSON Schema、范围规则和字段依赖检查;
- 冲突处理:保留冲突来源,并将低置信结果交给人工;
- 质量回流:把人工修改转为评测样本和词典增量。
这种方案的关键不是“多用几种技术”,而是让每一层都有明确输入、输出和失败方式。
四、先定义一个能表达“不确定”的数据模型
结构化模型最常见的问题,是只有最终结果,没有判断过程。以疾病提取为例,如果只有 name 和 code,就无法区分明确诊断、疑似诊断、家族史和排除项。
更完整的结果可以设计为:
{
"raw_text": "原文片段",
"entity_type": "condition",
"normalized_name": "标准名称或 null",
"code": "标准编码或 null",
"subject": "patient | family | other | unknown",
"assertion": "present | absent | possible | conditional | unknown",
"temporality": "current | historical | future | unknown",
"evidence_start": 12,
"evidence_end": 26,
"review_status": "auto_accepted | needs_review"
}
这里最重要的不是字段数量,而是允许 unknown 和 needs_review。业务系统若要求每个字段必须有值,模型只能用猜测补齐,最后得到一份格式漂亮但不可信的数据。
证据位置最好使用字符偏移或 token 偏移,而不只是复制一段文字。这样可以在界面中准确高亮原文,也能检查模型返回的证据是否真的存在。
五、规则、词典和模型如何分工
可以用一张决策表确定处理方式:
| 任务特征 | 首选方法 | 原因 |
|---|---|---|
| 格式固定、边界明确 | 规则 | 可解释、可穷举测试 |
| 存在大量别名、目标集合有限 | 词典/检索 | 易维护、可控制候选范围 |
| 依赖上下文语义 | 大模型 | 能处理否定、指代和复杂表达 |
| 风险高且样本少 | 人工复核 | 自动化收益低于错误成本 |
同一个字段也可以分层处理。例如药品剂量先用规则抽取数值和单位,再用词典标准化药品名,最后由模型判断“该剂量是当前使用、既往使用还是计划使用”。
这比让模型一次完成所有字段更容易调试。某条结果错误时,可以定位到底是数值解析失败、候选召回失败,还是上下文判断错误。
六、错误处理应成为正式流程
抽取系统不应只返回“成功”或“失败”。建议建立错误分类:
invalid_input:输入为空、乱码或格式不支持;rule_conflict:多条规则得到冲突结果;no_dictionary_candidate:没有可用标准术语候选;ambiguous_candidate:多个候选分数接近;model_schema_error:模型输出不符合结构;evidence_mismatch:返回证据不在原文中;business_validation_error:字段组合违反业务规则。
错误分类一方面便于监控,另一方面决定后续动作。格式错误可以自动重试,候选歧义应进入人工复核,业务校验失败则可能需要重新执行整条链路。
人工复核界面也应尽量降低操作成本:展示原文高亮、标准候选和模型判断依据;允许审核者一键确认、修改或标记“无法判断”。如果人工只能面对一大段 JSON,反馈数据的质量也很难保证。
七、用处理器链组织代码
与其把全部逻辑写进一个函数,不如让每个处理器只完成一个职责:
from dataclasses import dataclass, field
from typing import Protocol
@dataclass
class Context:
raw_text: str
normalized_text: str = ""
entities: list[dict] = field(default_factory=list)
issues: list[dict] = field(default_factory=list)
class Processor(Protocol):
async def process(self, ctx: Context) -> Context: ...
class NormalizeText:
async def process(self, ctx: Context) -> Context:
ctx.normalized_text = normalize_width_and_spaces(ctx.raw_text)
return ctx
class RuleExtractor:
async def process(self, ctx: Context) -> Context:
ctx.entities.extend(extract_dates_and_measurements(ctx.normalized_text))
return ctx
class TerminologyLinker:
async def process(self, ctx: Context) -> Context:
ctx.entities = link_to_candidates(ctx.entities)
return ctx
流水线按顺序执行处理器,每一层都可以记录输入摘要、输出数量和耗时。模型抽取器发生超时时,规则结果仍可保留并返回 partial_success,不必让整个任务失败。
处理器链还便于 A/B 测试:同一批文本分别经过两个术语链接器或两个提示词版本,再比较标准化准确率和人工修改量。
八、原文偏移量如何避免错位
一旦对文本做了全半角转换、空格折叠或 Unicode 规范化,规范化文本的字符位置可能与原文不同。如果模型在规范化文本上返回偏移量,前端直接高亮原文就会错位。
可在规范化时维护字符映射:
@dataclass(frozen=True)
class NormalizedText:
text: str
# normalized_to_raw[i] 表示规范化文本第 i 个字符来自原文哪个位置
normalized_to_raw: list[int]
def restore_span(nt: NormalizedText, start: int, end: int) -> tuple[int, int]:
raw_start = nt.normalized_to_raw[start]
raw_end = nt.normalized_to_raw[end - 1] + 1
return raw_start, raw_end
如果处理过程会合并或删除字符,映射关系需要在每一步更新。另一种方案是始终让模型返回证据文本,再由服务端在原文中做受约束定位;遇到多处同文时结合上下文窗口消歧。
九、把字段级评测写成自动化测试
下面的测试不只验证“抽到了疾病词”,还检查主体和否定状态:
import pytest
@pytest.mark.parametrize(
("text", "subject", "assertion"),
[
("患者既往有高血压病史", "patient", "present"),
("患者否认高血压病史", "patient", "absent"),
("母亲患有高血压", "family", "present"),
("考虑高血压可能", "patient", "possible"),
],
)
async def test_condition_context(extractor, text, subject, assertion):
result = await extractor.extract(text)
item = result.items[0]
assert item.subject == subject
assert item.assertion == assertion
assert item.evidence in text
真实评测集应使用合规的脱敏或合成样本。每发现一种新错误表达,就先补充失败测试,再修改规则或提示词,避免修复一个问题却破坏原有能力。
十、不要把置信度当成概率
模型输出的 confidence: 0.95 往往只是语言生成结果,不能直接当成统计意义上的正确率。更可靠的置信信号来自多个维度:
- 规则是否命中且无冲突;
- 词典候选的区分度;
- 模型是否能返回精确证据片段;
- 多次抽取结果是否一致;
- 该类型字段在验证集上的历史准确率。
可以把这些信号组合成风险等级,而不是展示一个看似精确的小数。
十一、如何建立最小可用评测集
不要一开始追求几万条标注。先抽取 100~300 条具有代表性的文本,覆盖正常样例、否定表达、时间变化、缩写、错别字和信息冲突。每次修改规则、词典或提示词后都跑一遍回归测试。
评测不仅看实体是否提取出来,还要分别计算主体、状态、时间、标准化编码和证据定位。只有把错误拆开,才知道下一步应改规则、词典还是提示词。
除了整体准确率,还应关注类别不平衡。例如“否定”样本只占 5%,一个始终判断为“存在”的模型也可能得到很高的表面准确率。此时需要分别计算每个状态的精确率、召回率和 F1。
对于标准化映射,可以使用 Top-1 和 Top-3 准确率。若正确项经常进入 Top-3 却没有排到第一,说明召回能力尚可,问题主要在排序;若连 Top-10 都没有,则应补充词典或改善规范化。
十二、上线后的持续维护
医疗术语、业务模板和记录习惯会持续变化。系统上线后,应定期统计未知词、低置信结果、人工修改最多的字段和高频错误表达。不要把每个错误都堆成一条新规则,而应先判断它是否代表一个可复用模式。
规则、词典、提示词和模型都需要版本化。每次变更记录负责人、变更原因、影响范围和评测结果,并保留回滚能力。对于已结构化的历史数据,明确哪些变更需要重新处理,哪些只影响未来数据。
医疗文本结构化的核心目标不是追求“全自动”,而是用可解释的流水线把高确定性内容自动化,把真正模糊的部分明确暴露给人工。这样的系统更慢一点,却更容易维护,也更接近真实业务需要。
说明:本文仅讨论通用数据工程方法,示例不包含真实病历或个人信息。

浙公网安备 33010602011771号