医疗文本结构化的混合方案

医疗文本结构化:为什么“规则 + 词典 + 大模型”比单一路线更稳

摘要:病历、检验描述和业务记录往往是半结构化文本。只用正则难以覆盖语言变化,只用大模型又可能产生不稳定输出。本文介绍一种分层抽取方案,让不同技术各自处理最擅长的问题。

标签: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,不允许猜测。

三、推荐的处理流水线

一个可维护的流程通常是:

  1. 文本预处理:统一全半角、空格、单位和特殊字符;
  2. 规则抽取:解析数值、日期、编码等确定性字段;
  3. 词典召回:生成标准术语候选;
  4. 模型判断:识别主体、否定、时间和关系;
  5. 结果校验:通过 JSON Schema、范围规则和字段依赖检查;
  6. 冲突处理:保留冲突来源,并将低置信结果交给人工;
  7. 质量回流:把人工修改转为评测样本和词典增量。

这种方案的关键不是“多用几种技术”,而是让每一层都有明确输入、输出和失败方式。

四、先定义一个能表达“不确定”的数据模型

结构化模型最常见的问题,是只有最终结果,没有判断过程。以疾病提取为例,如果只有 namecode,就无法区分明确诊断、疑似诊断、家族史和排除项。

更完整的结果可以设计为:

{
  "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"
}

这里最重要的不是字段数量,而是允许 unknownneeds_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 都没有,则应补充词典或改善规范化。

十二、上线后的持续维护

医疗术语、业务模板和记录习惯会持续变化。系统上线后,应定期统计未知词、低置信结果、人工修改最多的字段和高频错误表达。不要把每个错误都堆成一条新规则,而应先判断它是否代表一个可复用模式。

规则、词典、提示词和模型都需要版本化。每次变更记录负责人、变更原因、影响范围和评测结果,并保留回滚能力。对于已结构化的历史数据,明确哪些变更需要重新处理,哪些只影响未来数据。

医疗文本结构化的核心目标不是追求“全自动”,而是用可解释的流水线把高确定性内容自动化,把真正模糊的部分明确暴露给人工。这样的系统更慢一点,却更容易维护,也更接近真实业务需要。

说明:本文仅讨论通用数据工程方法,示例不包含真实病历或个人信息。

posted @ 2026-09-20 15:43  楼主好菜啊  阅读(4)  评论(0)    收藏  举报