☰
医疗知识图谱构建实战:从病历文本抽取到Neo4j落库的完整方案
2026/10/3 4:01:33 网站建设 项目流程

简介:面向医疗信息化与知识图谱入门者的实战项目,完整演示了从医疗数据采集、清洗存储到图谱构建与可视化的全流程。项目基于Scrapy爬虫框架抓取百度百科等公开医疗信息,将非结构化文本解析为结构化条目,借助MongoDB存储海量半结构化数据,再通过Neo4j图数据库构建疾病、药物、症状等实体及治疗、副作用等关系,最后以图形界面直观呈现知识网络。代码共25个文件,包含9个Python脚本、6个pyc编译文件、4个xml配置及README、requirements依赖清单等,压缩包仅16KB,体量轻巧,目录划分清晰。目前已有662人学习下载,适合边读边跑,快速还原项目环境。通过完整代码,学习者可掌握Scrapy规则配置与管道处理、MongoDB数据清洗、Neo4j Cypher建模、可视化联调等关键技能,为后续扩展医疗问答、临床决策支持等应用奠定基础。

1. 医疗领域知识图谱构建实战:一个病历文本脱敏需求把我拉进图数据库的坑

医疗领域知识图谱构建实战,我第一次认真做是在医院内科数据对接的需求里被逼上手的。当时科室要查“高血压患者里有多少人同时用过阿司匹林”,但数据散在门诊病历、检验单和药品目录三套表里,字段口径对不上,关系链靠 SQL join 不起来。知识图谱给的不是一个搜索框,而是一套把“症状-疾病-检查-药品”变成可推理、可回查、可追溯的语义网络。这篇内容适合正在做临床数据治理、病历结构化、或者想把术语表升级成语义层的工程师与数据分析师。读完能照着一套最小方案把三元组装进 Neo4j,也会知道哪些坑值得绕。

2. 先立本体再写抽取:医疗图谱的语义层决定后续所有路径

2.1 从业务问题到本体:症状、疾病、检查、药品的关系怎么定

我见过太多团队拿到一批病历就开始抽实体,抽出来几百个词,关系乱成一团。真正的构建顺序应该是先立本体,再写抽取。本体不是算法团队自嗨的产物,它回答两类问题:一类是查询遍历时要从哪个节点走到哪个节点,另一类是不同科室数据合并时以谁为准。内科写“高血压”,病案室写“高血压病”,药房记录“Essential Hypertension”,在本体里都要归到同一个标准实体。这就是本体建模和语义层的价值:给所有下游统一口径,而不是让每个查询都自带一份别名表。

设计本体的输入不是数据样本,而是业务查询列表。你把未来半年要回答的临床问题列出来,每个问题翻译成图路径,比如“高血压患者用过哪些药”翻译成 Disease→treats→Drug,“这个药的禁忌症有哪些”翻译成 Drug→contraindicated_in→Disease。路径能走得通,说明本体结构够用;走不通,说明缺类或缺关系。这样推导出来的本体一定是最小可落地的,不会一开始就设计出几十个类、上百条属性,最后没人维护。

常见做法是先参考 SNOMED CT 和 ICD-10 的顶层分类思路,但不照搬,因为术语集是给编码用的,层级深、多义多,直接灌进图库反而让查询变慢。医疗场景里真正高频用到的是疾病、症状、药品、检查项、科室这几个核心类,外加对应的核心关系。小本体先跑通,后续要扩再按同样的流程往上加。

2.2 最小可落地的医疗本体:类、属性与两条硬约束

我一般会把第一版本体控制在五个类以内,关系种类控制在五条以内。给你一份可以直接照抄的类设计:

类核心属性说明
Diseaseuid, name, icd_code疾病,uid 用内部统一 ID
Symptomuid, name症状,name 存标准术语
Druguid, name, approval_no药品,approval_no 可选
CheckItemuid, name, unit检查项,unit 存单位
Departmentuid, name科室,用于挂靠关系

关系上,最优先建立的是 has_symptom(疾病表现症状)、treats(药品治疗疾病)、side_effect(药品引起症状)、contraindicated_in(药品禁用于某疾病)、belongs_to(检查项归属科室)。这五条覆盖了绝大多数临床查询路径,也够支撑后面章节的抽取和落库。

建本体时我有两条硬约束。第一条,所有实体必须有 uid 作为唯一标识,不能用 name 做节点主键;不然同一个药在不同科室写法不一样,图谱里就会分裂出两个节点。第二条,任何关系必须带 source 属性,记录这条边来自哪份病历、哪个段落、哪次抽取,没有来源的关系一律不导入。这两条约束在前期看着繁琐,后面做数据溯源和错误修正时就是后悔药。

2.3 用 OWL 把它跑起来:一份可直接改的模型代码

本体定义不一定要用可视化工具拖拽,直接用 owlready2 写 Python 模型最省事,也方便和抽取代码放同一个仓库管理。这是一段可以跑到的最小模型:

from owlready2 import * onto = get_ontology("http://example.org/medical#") with onto: class MedicalEntity(Thing): pass class Disease(MedicalEntity): pass class Symptom(MedicalEntity): pass class Drug(MedicalEntity): pass class CheckItem(MedicalEntity): pass class Department(MedicalEntity): pass class has_symptom(ObjectProperty): domain = [Disease] range = [Symptom] class treats(ObjectProperty): domain = [Drug] range = [Disease] class side_effect(ObjectProperty): domain = [Drug] range = [Symptom] class contraindicated_in(ObjectProperty): domain = [Drug] range = [Disease] class belongs_to(ObjectProperty): domain = [CheckItem] range = [Department] class severity(DataProperty): domain = [Symptom] range = [str]

先解释逻辑。MedicalEntity 是所有实体的父类,Disease、Symptom 这些类继承自它,这样后续做全图统计时可以直接按父类过滤。ObjectProperty 表示实体到实体的关系,domain 和 range 分别约束起点和终点的类型;比如 treats 的 domain 是 Drug、range 是 Disease,意味着药物只能治疗疾病,不能治疗科室。severity 是 DataProperty,它挂在 Symptom 上存“轻度、中度、重度”这类文本值。

参数说明里有两个容易踩的点。第一,domain/range 约束在 owlready2 里默认不自动校验,你需要手动检查三元组是否满足约束,或者调用 sync_reasoner() 做一致性推理,但推理很慢,生产环境不建议每次导入都跑。第二,severity 我刻意用字符串而不是浮点数,因为临床上“轻度”和“中度”之间没有稳定数值边界,硬编码数值会让后续查询误以为可以拿它做排序。如果确实要按严重程度排序,我建议再加一个 severity_level 作为整数属性,和 severity 并存。

3. 从结构化与非结构化病历里抽实体和关系:NLP抽取与Neo4j落库

3.1 实体抽取:先做词典+规则召回,再上BERT NER补召回,最后做实体对齐

实体抽取的常见做法是词典规则先上线,把漏召回样本攒下来,再去微调 NER 模型。直接跳进深度学习不是不行,但病历文本里的缩写、错别字、方言表达会让模型表现很不稳定,且没有词典兜底时错误很难排查。我一般先维护一个别名到标准实体的映射表,用正则做第一轮召回:

import re ALIAS_MAP = { "高血压": "高血压", "高血压病": "高血压", "essential hypertension": "高血压", "阿司匹林": "阿司匹林", "aspirin": "阿司匹林", } def dict_extract(text: str): pattern = "|".join(re.escape(alias) for alias in ALIAS_MAP) for m in re.finditer(pattern, text, re.IGNORECASE): matched = m.group().lower() if matched in ALIAS_MAP: yield ALIAS_MAP[matched]

这段代码的逻辑是,把所有别名拼成一个正则分支,遍历全文匹配,命中后统一输出标准实体名。IGNORECASE 参数用来处理英文别名的大小写差异,中文别名不受影响。注意 re.escape 必须加,因为药品名里可能含有括号、点号等正则特殊字符,不转义会导致匹配位置错乱。

词典法的问题在召回率。我们项目里病历常写“血压偏高”而不是“高血压”,写“心律不齐”而不是“心律失常”,这种口语化表达词典覆盖不了,需要 NER 模型补召回。下面是第二轮的模型推理代码:

from transformers import AutoTokenizer, AutoModelForTokenClassification, pipeline tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") model = AutoModelForTokenClassification.from_pretrained("/path/to/med_ner_model") ner = pipeline( "ner", model=model, tokenizer=tokenizer, aggregation_strategy="first", device=0, ) def bert_extract(text: str): out = ner(text) return {x["word"]: x["entity_group"] for x in out}

逻辑说明:pipeline 会把 BERT 输出的 BIO 标签自动聚合成实体片段,aggregation_strategy 设为 first 表示取每个实体的首字符位置作为整体边界,对医疗实体抽取来说比 simple 策略更稳。返回结果里的 entity_group 对应模型标注的实体类型标签,你需要把它映射到本体里的 Disease、Symptom、Drug。device 参数,GPU 可用时设 0,显存不够就改成 -1 跑 CPU,但单条病历推理会慢 5 到 10 倍,建议攒批处理。

实体对齐放在抽取之后单独做。你从文本里抽出“高血压病”和“高血压”,在知识图谱里必须对应同一个 uid。对齐的依据除了前文那个 ALIAS_MAP,还要看码表,比如 ICD-10 的 I10 和 I10.x 都归到高血压这个标准实体。这一步不做,后面 Neo4j 里就会出现两个名字不同但实际是一回事的节点,所有统计分析都会失真。

3.2 关系识别:基于句法约束的关系抽取,不迷信大模型输出

关系识别是最容易失控的环节。直接用大模型做开放式关系抽取,输出确实漂亮,但格式不稳定、字段名乱飞,大量关系没有病历出处,入库后根本不敢拿去做统计。我现在的做法是用触发词窗口做第一轮筛选,没命中的一律进人工复核队列,绝不强行入图。

基础逻辑是:实体抽出来之后,先定位两个实体在文本里的起止位置,取它们之间的文本片段作为动词窗口,再去触发词表里匹配。代码长这样:

RELATION_TRIGGERS = { ("Drug", "Disease"): ["治疗", "用于", "适应症", "控制"], ("Drug", "Symptom"): ["引起", "导致", "出现", "不良反应", "诱发"], ("Disease", "Symptom"): ["表现为", "症状是", "伴有", "可见"], } def relation_by_window(head, tail, window_text): if not window_text: return None, "missing_context" for trigger in RELATION_TRIGGERS.get((head[2], tail[2]), []): if trigger in window_text: return trigger, "confirmed" return None, "pending"

参数说明:head 和 tail 分别是实体元组,格式为 (start, end, type, uid),第 3 个元素是实体类型。window_text 不是两个实体之间的原始文本,而是这段文本前后各扩展两个字符的结果,防止触发词正好卡在实体的紧邻位置而漏匹配。返回值第一个是命中的触发词,第二个是置信状态,confirmed 表示可以入图,pending 表示要人工看。

这里有几个细节值得注意。其一,触发词表要按实体类型组合建立索引,因为“服用”在 Drug 和 Disease 之间是治疗关系,在 Drug 和 Symptom 之间却是不良反应关系,不区分类型组合会抽出大量错边。其二,一个实体对之间可能命中多个触发词,比如“用于治疗”同时出现“用于”和“治疗”,我默认取触发词长的那个,因为它信息更准确。其三,pending 状态的数据不能丢弃,要落一张人工复核表,每周抽一批由临床人员确认后回填,这是图谱质量上升的主要来源。

3.3 把三元组装进 Neo4j:批量导入的写法与索引选择

抽取完成的三元组最终要落进图库,我用的是 Neo4j 官方 Python 驱动。落库时最容易翻车的点,是直接用 MERGE 语句拼 Cypher,结果标签写死或者参数传错,跑完才发现关系类型全乱了。先看实体写入:

from neo4j import GraphDatabase LABEL_ALLOWED = {"Disease", "Symptom", "Drug", "CheckItem", "Department"} driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) def upsert_entities(tx, rows): for row in rows: label = row["type"] if label not in LABEL_ALLOWED: raise ValueError(f"label not allowed: {label}") tx.run( f"MERGE (n:{label} {{uid: $uid}}) " "SET n.name = $name", uid=row["uid"], name=row["name"], ) with driver.session() as session: session.execute_write(upsert_entities, entity_rows)

这里必须解释清楚:Cypher 不允许把标签类型作为参数传入,你只能字符串插值,但直接拼接 f-string 又可能被病历文本里的奇怪内容污染。所以标签一定要过 LABEL_ALLOWED 白名单,不在名单里直接抛异常。这就是我前文说的硬约束的落地方式。uid 仍然是主键,name 只是展示属性;MERGE 按 uid 去重,保证同一个实体不会反复创建。

关系写入我建议改成批量方式,单条逐句跑在十万级数据量上会慢到怀疑人生:

def upsert_relationships(tx, rel_rows): tx.run( """ UNWIND $rows AS row MATCH (s {uid: row.s_uid}) MATCH (o {uid: row.o_uid}) CALL apoc.create.relationship(s, row.rel_type, {source: row.source}, o) YIELD rel RETURN count(*) """, rows=rel_rows, )

逻辑说明:UNWIND 把列表展开成多行,每一行先 MATCH 找到头尾节点,再用 APOC 的动态关系创建函数把关系连上。row.rel_type 在这里是关系类型名字符串,比如 treats、side_effect,因为 apoc.create.relationship 接受动态关系名,无需拼接标签。但要注意这依赖 APOC 插件,如果环境装不了 APOC,就把 rel_type 同样过一遍白名单再 f-string 拼接,安全性和这里等价。

参数说明两条。第一,每批数据控制在 500 到 1000 条,太多会把写事务打爆,太少浪费网络往返。第二,导入前一定要给 uid 建唯一约束,否则 MERGE 会随着节点数量增长越来越慢。Neo4j 4.x 用CREATE CONSTRAINT ON (n:Disease) ASSERT n.uid IS UNIQUE;,Neo4j 5.x 换成CREATE CONSTRAINT IF NOT EXISTS FOR (n:Disease) REQUIRE n.uid IS UNIQUE;,两个版本的语法不兼容,换库时要留意。

4. 构建实战避坑记录:四条踩过的坑及其解法

4.1 同一个药在不同科室有不同写法,图谱里出现“阿司匹林”和“Aspirin”两个节点

现象:抽查 Neo4j 里的 Drug 节点,发现“阿司匹林”“Aspirin”“拜阿司匹灵”三个节点都存在,每个节点都只有一两条关系,查询“阿司匹林相关禁忌症”时数据残缺。

原因:抽取阶段只做了字符串匹配,没有做实体对齐。不同科室的病历来源不一样,心内科习惯写“阿司匹林”,神内科写“拜阿司匹灵”,英文病历写“Aspirin”,但它们在语义层是同一个实体。我第一版图库直接把原始文本塞进 name 字段当主键,后果就是同类实体被打散。

解决:把所有药品、疾病、症状都加上 uid,并建立别名映射表。映射表的来源包括药品说明书通用名、ICD 编码名称、院内 HIS 系统的药品字典。抽取和入库时,一律用标准实体名,原始展示名存到 alias 属性里。我后来还在实体节点上加了标准名称的全文索引,查询直接用标准名匹配,不再依赖自由文本。

4.2 关系类型设得太笼统,查“没有禁忌症却开了药”这类问题直接哑火

现象:业务方要查“有没有给高血压合并哮喘患者开了禁用的药”,图里明明有相关药品和疾病节点,但跑查询就是返回空。

原因:我把所有 Drug 和 Disease 之间的边都归成 treats 一种类型,没有区分适应症和禁忌症。“阿司匹林治疗高血压”和“阿司匹林禁用于哮喘”在临床上是完全相反的语义,但在图里都是 Drug→Disease 的边,查询时无法区分。

解决:把关系类型拆细,新增 contraindicated_in 表示禁忌,treats 只表示治疗适应症。触发词表里把“禁用于”“禁用”“慎用”“不宜用于”单独归一类,和“治疗”“用于”区分开。这样查询“违规用药”就是找同时存在 treats 和 contraindicated_in 两个方向的药品节点。关系拆细之后,图谱的可解释性明显上来了,业务方也愿意用了。

4.3 大批量 MERGE 写入把事务打爆,慢在实体重复匹配

现象:第一次全量导入 20 万条三元组,跑了一个小时没写完,日志里全是死锁和事务超时,Neo4j CPU 占用飙到 90% 以上。

原因:两处。第一,写关系时每条都先 MATCH 实体再 MERGE 关系,20 万条就是 40 万次索引查找,全部挤在一个大事务里。第二,uid 没有建唯一约束,MERGE 找不到索引,退化成全图扫描匹配,节点越多越慢。

解决:先给所有实体类型的 uid 建唯一约束,再分批导入。实体全部写完后,关系每 500 条一个事务,用 UNWIND 批量提交。如果还是慢,检查是否走了 HTTP 协议,我后来发现 Bolt 协议比 HTTP 快将近一倍,连接字符串改成 bolt:// 开头。做完这三件事,同样的数据量十分钟内导完。

4.4 语义层只管了实体没管上下位,后续数仓口径对不上

现象:图谱里“高血压”和“原发性高血压”是两个平级节点,各自挂了一堆症状关系。数仓团队取数时按“高血压”汇总,结果漏掉了“原发性高血压”,口径对不上,两边扯皮。

原因:本体里只定义了类,没定义上下位关系。高血压是原发性高血压的上位概念,语义层没建模这层,图查询就无法把子类数据自动上卷到父类。知识管理做得不到位,数仓那边就只能按节点名字一个个捞。

解决:在 OWL 模型里为可能需要上卷的概念补充 SubClassOf 关系,比如原发性高血压是高血压的子类,阿司匹林肠溶片是阿司匹林的剂型变体。图谱里用 subClassOf 边连接,查询父节点时用MATCH (d:Disease {name:'高血压'})<-[:subClassOf*1..2]-(sub)这样的变长匹配把所有子类带上。同时给数仓团队输出一版语义层字段映射表,说明哪些概念可上下卷、哪些是平级关系,避免各查各的。

5. 图谱验收与进阶:把验证查询变成回归测试,再谈扩展

图谱做完不是看数据量,而是要看特定查询能不能稳定跑出正确结果。我的习惯是先写一份验证查询集,把业务方最常问的问题固化成 Cypher,每次改完抽取规则或本体定义后先跑一遍。这三条可以作为你的起点:

from neo4j import GraphDatabase QUERIES = { "高血压症状连通性": ( "MATCH (d:Disease {uid:'D001'})-[:has_symptom]->(s:Symptom) " "RETURN count(s) AS n" ), "阿司匹林禁忌症覆盖": ( "MATCH (d:Drug {uid:'DR001'})-[:contraindicated_in]->(dis:Disease) " "RETURN count(dis) AS n" ), "科室关系可上卷": ( "MATCH (c:CheckItem)-[:belongs_to]->(dep:Department) " "RETURN dep.name, count(c) ORDER BY count(c) DESC" ), } driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) for name, query in QUERIES.items(): with driver.session() as session: result = session.run(query) print(name, result.single())

这套验证脚本的逻辑是把验收标准转成数值断言,比如高血压症状数不能少于 5,阿司匹林禁忌症数不能为 0。跑完结果不对,不是去查数据,而是回到本体定义和抽取规则里找原因。回归查询和抽取代码放在同一个仓库里,每次改动先跑回归再上库,比事后人工抽查可靠得多。

进阶方向上,我建议在验证查询稳定之后再做两件事。一是把图谱结果回写到业务系统,给医生工作站提供用药警示接口,这是知识图谱最直接的业务价值;二是给关系加上时间属性,记录这条边从哪个版本的指南来,后续指南更新时可以定向替换。我现在的习惯是,新项目里先花半天写验证查询集,再动本体和抽取代码,这个顺序帮我避掉了大量返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询