
简介医疗领域知识图谱构建实战资源包面向希望掌握知识图谱全流程搭建的Python开发者和数据工程师。项目围绕医疗信息结构化展开完整演示从Scrapy爬取百度百科疾病、药物、症状数据到MongoDB存储清洗再到Neo4j构建实体关系图谱并进行可视化展示的链路。压缩包共25个文件以9个Python脚本为核心涵盖爬虫管道、配置、图谱创建等模块另含pyc缓存、xml配置及说明文档整体仅16KB轻量便携。当前已有662人学习下载。通过该资源可直观理解非结构化医疗文本的解析思路、NoSQL与图数据库的协作方式并获得可直接运行的工程骨架适合作为入门知识图谱实战的参考模板。1. 医疗知识图谱的瓶颈不在算法在第一张本体表拿到一摞出院病历、检验报告和药品说明书很多人第一反应是直接上NLP模型做实体识别把“高血压”“头痛”“阿司匹林”抽出来连成图。结果图是建出来了可一查就露馅同一个“高血压”在图里有三个名字两个编码症状挂在疾病下面手术方向反了医生问“卡托普利能不能用于糖尿病肾病”图谱根本答不上来。医疗领域知识图谱构建实战的第一步不是写抽取代码而是先把本体定准。这条路我走过一遍踩过的坑比跑通的模型还多。这篇笔记不讲大而全的理论只讲从零把一张能用的医疗知识图谱建起来的过程本体怎么设计实体关系从哪来数据怎么装进Neo4j以及哪些地方最容易翻车。2. 医疗本体设计先把疾病、症状、检验检查装进同一张表2.1 为什么一上来就要约束术语与语义层医疗数据里同义词多得吓人。同一个“高血压”病历里可能写成“高血压病”“原发性高血压”“essential hypertension”“血压升高”。在实验室指标里“血压升高”还可能只是单次测量值偏高根本够不上诊断标准。如果不先在语义层做约束后面每走一步都要被这些歧义拖住。本体建模要解决的就是这件事把概念、术语、编码之间的关系定死让下游抽取和存储都有据可依。常见做法是先把语义层拆成三个等级标准术语全科室统一叫法比如“原发性高血压”别名集合病历里真实出现的写法比如“高血压病”“EH”编码系统ICD-10、SNOMED CT、ATC 等外部编码映射关系落在数据表里就是一张同义词表。这个表在后续所有环节都要用到NER 模型的标签映射、图谱实体的唯一标识、查询时的别名扩展。忘了做这一层后果在第 5 章详细说。建议实体命名全部统一为“标准中文名(编码)”的格式节点属性里再单独存 code_system 和 code_value。2.2 用 ICD-10 / SNOMED CT 对齐编码先建 JSON Schema 还是先建表对齐编码是医疗图谱特有的前置工作。结构化数据库里的诊断编码一般是 ICD-10药品编码是 ATC检验项目则是 LOINC 或院内 LIS 自编码。直接拿院内码建图换一家医院就废了。我一般会把每个实体节点的 Schema 固定成下面这样{ entity_id: D000123, type: DISEASE, standard_name: 原发性高血压, aliases: [高血压病, essential hypertension, 高血压], code_system: ICD-10, code_value: I10, source: his_2019, verified: true }verified字段尤其关键。自动抽取出来的实体默认置 false经过人工校验或权威数据源匹配后才置 true。查询时优先展示已验证实体未验证的只做候选避免把抽错的实体直接暴露给医生。编码对齐的过程分为两步先自动匹配——用标准术语表的别名字段做字符串匹配跑一轮能覆盖大约七成剩余三成需要人工核对常见问题是同一疾病在 ICD-10 不同版本里编码漂移比如“2型糖尿病”在 ICD-10 里对应 E11但旧版本可能被归到 E14 下。做对齐时要有版本号概念别把两版编码混在一个字段里存。2.3 实体关系模型的最小可用集合医疗知识图谱不必一上来就建几十种实体。我跑过的最小可用集合是七类实体和五类关系实体类型代表节点关键属性疾病原发性高血压ICD-10 编码、是否慢性症状头痛、蛋白尿部位、程度描述检查项目尿常规、血压测量LOINC 编码或院内码药品卡托普利ATC 编码、剂量规格手术冠脉搭桥术手术编码、创伤等级科室心内科医院内部编码病原体肺炎链球菌微生物分类层级五类关系按“主谓宾”固定方向避免同一对实体出现两条方向相反的关系疾病 → 表现为 → 症状疾病 → 可通过 → 检查项目 诊断药品 → 用于治疗 → 疾病手术 → 适用于 → 疾病疾病 → 常见于 → 科室方向统一不是小事。图数据库支持双向查询但如果关系定义不统一导入时就会产生重复边。更关键的是后续做推理时方向错了答案完全相反。比如“卡托普利→用于治疗→高血压”如果反向建成了“高血压→用于治疗→卡托普利”查“高血压有哪些治疗药”就什么都查不到。定义好 Schema 后先用 Cypher 把约束建起来。这一步放在导入之前可以在导入时顺带去重CREATE CONSTRAINT disease_id IF NOT EXISTS FOR (d:Disease) REQUIRE d.id IS UNIQUE; CREATE CONSTRAINT symptom_id IF NOT EXISTS FOR (s:Symptom) REQUIRE s.id IS UNIQUE; CREATE CONSTRAINT drug_id IF NOT EXISTS FOR (d:Drug) REQUIRE d.id IS UNIQUE; CREATE CONSTRAINT surgery_id IF NOT EXISTS FOR (s:Surgery) REQUIRE s.id IS UNIQUE;约束的作用是一箭双雕既保证实体唯一又让后续 MERGE 操作走索引而不是全表扫描。图数据量到百万级时这一条约束能让导入速度快一个数量级。3. 实体与关系抽取从结构化库和非结构化病历里捞数据3.1 结构化数据转化HIS 导出文件到实体 JSON 的映射医院里最容易拿到的结构化数据是 HIS 导出的诊断表、检验结果表和药品处方表。这些表通常是关系模型字段命名五花八门同一个诊断名称在不同表里可能缩写不同。常见做法是写一个 Python 脚本做 ETL把关系表的每一行映射成实体 JSON。import pandas as pd from collections import defaultdict # 读取HIS导出的门诊诊断表 # 常见字段: patient_id, visit_id, diag_code, diag_name df pd.read_csv(his_diagnosis.csv, dtypestr, encodingutf-8-sig) df.fillna(, inplaceTrue) entity_map defaultdict(dict) for _, row in df.iterrows(): code row[diag_code].strip() name row[diag_name].strip() if not code or not name: continue # 跳过空编码或空名称 entity_id fD_{code} entity_map[entity_id] { entity_id: entity_id, type: DISEASE, standard_name: name, aliases: [name], code_system: ICD-10, code_value: code, source: his_2019, verified: False, } # 输出为图谱导入用的暂存文件 result pd.DataFrame(entity_map.values()) result.to_json(staging_disease.json, orientrecords, force_asciiFalse)这段脚本最需要注意的一个参数是dtypestr。诊断编码经常是“I10”“E11.9”这种混合字符串如果让 pandas 自动推断类型“I10”会被读成字符串没问题但纯数字编码就会被读成 int后续匹配直接失败。另一个细节是encoding用utf-8-sig因为 HIS 导出的文件经常带 BOM 头不加 -sig 会在第一行字段名里混入\ufeff。映射完成后再做一轮合并去重。同一个entity_id可能出现在多张表中属性以非空值为准。这一步放在输出 JSON 之前否则导入图库后会多出重复节点。3.2 非结构化病历的 NER微调一个医疗实体识别模型结构化数据只能覆盖诊断和处方真正的“富矿”在出院小结、影像报告这些自由文本里。文本里藏着症状、体征、手术史、过敏史这些恰恰是问答和辅助决策最需要的信息。用现成的通用 NER 模型直接跑医疗文本效果通常很惨。医疗文本的命名实体边界和新闻语料差太多“结核性胸膜炎”是一个疾病还是两个“右肺上叶占位”里“占位”是不是一个独立症状通用模型在这些边界上经常犯迷糊。常见做法是在中文医疗语料上继续微调一个 BERT 类模型套用 BIO 标注体系。from transformers import AutoTokenizer, AutoModelForTokenClassification, Trainer, TrainingArguments # 标签集合: O, B-DISEASE, I-DISEASE, B-SYMPTOM, I-SYMPTOM, ... label_list [O, B-DISEASE, I-DISEASE, B-SYMPTOM, I-SYMPTOM, B-DRUG, I-DRUG] label_map {label: i for i, label in enumerate(label_list)} tokenizer AutoTokenizer.from_pretrained(hfl/chinese-lert-base) model AutoModelForTokenClassification.from_pretrained( hfl/chinese-lert-base, num_labelslen(label_list) ) training_args TrainingArguments( output_dir./ner_checkpoints, learning_rate2e-5, per_device_train_batch_size16, num_train_epochs3, weight_decay0.01, save_steps500, logging_steps50, evaluation_strategyepoch, save_total_limit2, ) # 训练集用标注平台导出的 BIO 格式数据 # dataset 是 Datasets 库的 Dataset 对象内含 input_ids、attention_mask、labels 三个字段 trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, tokenizertokenizer, ) trainer.train()微调 NER 有几个参数值得说明。learning_rate设 2e-5 而不是默认的 5e-5因为医疗标注数据通常只有几千条学习率太大容易过拟合。max_length建议设 128 而不是 512医疗文本的一句子通常够用太长会拖慢训练和推理。更关键的是标签策略嵌套实体场景多时用 BIOES带 E 结束标签会比 BIO 更容易分对边界。比如“慢性淋巴细胞性甲状腺炎”整体是一个疾病BIO 在“甲状腺炎”处会犹豫BIOES 因为有 E 标签会更稳定。微调后还需要一个后处理步骤把标签序列还原成实体文本时要按 token 还原回字符位置这一步用tokenizer.convert_ids_to_tokens拿 token再用 offset_mapping 找回原文字符区间。漏掉 offset_mapping 这一层实体提取结果的边界就会错位。3.3 关系分类与否定词兜底规则永远比模型稳妥实体抽出来只是第一步关系怎么连才是关键。单个文本片段里同时出现“高血压”和“头痛”不一定是“表现为”的关系还可能是“因头痛就诊发现高血压”。处理这类歧义我一般用一条 pipeline先做实体识别再做事件级别的触发词匹配最后补一轮否定词规则。否定词规则在医疗文本里特别重要。“无发热”“未见淋巴结肿大”“否认胸痛史”——模型很容易把“发热”“淋巴结肿大”“胸痛”全当正相关实体抽出来实际上全是阴性表达。常见的规则兜底是用正则先扫一遍否定前缀import re NEG_PATTERNS [ r无(明显|显著)?(?Pentity[^,。;]{1,12}), r未(见|及|发现|触及)(?Pentity[^,。;]{1,12}), r否认(?Pentity[^,。;]{1,12})史?, ] def neg_filter(sentence, ner_results): ner_results: [(entity_text, start, end, label), ...] 返回过滤掉否定上下文实体的结果 kept [] for entity, start, end, label in ner_results: # 取实体前面 20 个字符作为上下文 prefix sentence[max(0, start - 20):start] negated any( re.search(pattern, prefix) for pattern in NEG_PATTERNS ) if not negated: kept.append((entity, start, end, label)) return kept这段规则代码并不复杂但能拦下临床文本里六七成的阴性实体。剩下的边界情况比如“既往有高血压病史目前血压控制可”前半句是阳性、后半句是控制状态单靠规则搞不定需要结合时序信息。没有时序模型的情况下我的处理是打标为“疑似”不进入图谱主关系只在文本原句留痕。关系分类模型可以用一个简单的双句分类器head 实体文本 tail 实体文本拼接输出五类关系的概率分布。训练数据靠人工标注一个月大概能标一万对对一个小型科室级图谱基本够用。医疗场景里关系分类的准确率不需要追求 100%因为图谱的查询入口会用同义词表和规则做二次过滤把候选关系交给规则做方向校验能极大降低错连概率。4. Neo4j 存储与导入从 JSON 到图查询4.1 数据文件预处理先解决 CSV 里的逗号、引号和换行NER 模型跑完得到的是几千个 JSON 文件。直接写 Cypher 一条条 MERGE 不现实必须转成批量导入用的 CSV 文件。这里第一个坑就是 CSV 转义。医疗文本经常出现半角逗号和换行符——一份出院小结里“高血压, 冠心病”这种写法多得要命。用 pandas 直接 to_csv 会把字段切开导入时整行报错。正确的做法是用 Python 的 csv 库写入让它统一处理引号转义import csv import json with open(nodes_disease.csv, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([id, name, code, source, verified]) for line in open(staging_disease.json, encodingutf-8): item json.loads(line) writer.writerow([ item[entity_id], item[standard_name], item[code_value], item[source], item[verified], ])encodingutf-8-sig这里是有讲究的。Neo4j 的 LOAD CSV 认 UTF-8但带 BOM 的文件在 Windows 上更容易被 Excel 直接预览便于核对。如果文件名含中文还建议在 Cypher 里用file:///相对路径避免 Windows 盘符和反斜杠的转义问题。数据量到几十万行以上还要做一件事按实体类型拆文件。不要图省事把全部实体写进一个 nodes.csv因为不同类型节点的属性集合不同拆开后每条 LOAD CSV 语句职责单一排错时能直接定位到某类数据的问题。4.2 LOAD CSV 导入的完整流程先节点后关系Neo4j 导入遵循一条铁律先建节点再建关系。关系导入依赖两端的节点 id如果节点还没 MERGE 进去关系就会挂空事后清理比重新导入还麻烦。// 第一步导入疾病节点 LOAD CSV WITH HEADERS FROM file:///nodes_disease.csv AS row WITH row WHERE row.id IS NOT NULL MERGE (d:Disease {id: row.id}) ON CREATE SET d.name row.name, d.code row.code, d.source row.source, d.verified row.verified;MERGE不是CREATE这一步依赖第 2 章建好的唯一约束。如果约束没建MERGE 会走全库扫描几十万行数据能把 CPU 跑满。关系导入同样用 LOAD CSV但需要先找到两端节点。以“药品用于治疗疾病”为例LOAD CSV WITH HEADERS FROM file:///rels_drug_treats.csv AS row MATCH (d:Drug {id: row.drug_id}) MATCH (dis:Disease {id: row.disease_id}) MERGE (d)-[t:TREATS]-(dis) ON CREATE SET t.evidence row.evidence, t.source row.source;这里有一个常见性能误区MATCH匹配节点依赖 id 索引但如果 id 属性没建索引每处理一行就要全库找一次节点。关系文件十万行全库扫描十万次导入时间从分钟级拖到小时级。所以导入前务必执行第 2 章那一组 CREATE CONSTRAINT。4.3 索引与约束优化文本字段要单独建 TEXT 索引Neo4j 的默认索引对等值查询很友好但医疗图谱里大量查询是“名字包含某个词”的模糊搜索普通 B-tree 索引用不上必须给文本字段建全文索引。CREATE FULLTEXT INDEX disease_name_fulltext IF NOT EXISTS FOR (n:Disease) ON EACH [n.name, n.code, n.source]; CREATE FULLTEXT INDEX drug_name_fulltext IF NOT EXISTS FOR (n:Drug) ON EACH [n.name];全文索引和普通索引的区别可以这样理解普通索引精确匹配字段完整值适合WHERE d.id D000123全文索引做分词匹配适合WHERE d.name CONTAINS 高血压或中文场景下的“模糊搜”。医疗查询里用户输入常常不完整比如“高血”“压高”全文索引能兜住一部分拼写颠倒的情况。需要注意全文索引的查询语法不是 SQL 风格而是CALL db.index.fulltext.queryNodes(disease_name_fulltext, 高血压) YIELD node, score RETURN node.name, node.code, score ORDER BY score DESC LIMIT 10;score是相关性打分中文全文索引对短词的效果一般必要时可以叠加字符串过滤条件缩小范围。还有一个建议关系属性里的evidence字段也值得建全文索引它是图谱可追溯性的来源排查错误关系时直接按 evidence 里的原句文本搜索定位比逐条看快得多。5. 避坑记录医疗图谱构建中最常见的 4 个翻车现场5.1 同一种病在图里出现两个节点编码还不一样现象查“高血压”返回三个节点名字分别是“高血压”“高血压病”“原发性高血压”ICD-10 编码分别是 I10、无编码、I10。原因本体设计阶段没有做术语归一化NER 把不同写法直接建成了不同节点别名映射表又没发挥作用。解决回到第 2 章把同义词表补齐导入前用标准名回写节点。已经建错的图用 Cypher 查出重复节点保留verifiedtrue的实体把其它节点的关系全部 MERGE 到保留节点上再删除冗余节点。别手工逐条改写一段清理脚本一次跑完。5.2 导入时报唯一约束冲突事务整个回滚现象LOAD CSV 跑到一半报ConstraintValidationFailed前面的数据全没了。原因CSV 文件里有重复 id 或重复的“关系对”。分批导入时同一批内出现了两条完全相同的 TREATS 关系MERGE被唯一约束拦住整个事务回滚。解决导入前先在本地做一层去重。sort | uniq对关系文件特别管用。另外 LOAD CSV 时建议加上USING PERIODIC COMMIT 5000让 Neo4j 每五千行提交一次单批失败不会让整个文件白跑USING PERIODIC COMMIT 5000 LOAD CSV WITH HEADERS FROM file:///rels_drug_treats.csv AS row MATCH (d:Drug {id: row.drug_id}) MATCH (dis:Disease {id: row.disease_id}) MERGE (d)-[:TREATS]-(dis);注意PERIODIC COMMIT不能和MATCH后的MERGE有依赖查询它只管 LOAD CSV 的批次提交。5.3 NER 把“胸部CT未见异常”抽出了“胸部CT”和“异常”两个实体现象图谱里出现“异常”这个症状节点还和“胸部CT”挂了个“表现为”关系临床完全看不懂。原因没有做否定词和虚词过滤。“异常”在影像报告里几乎都是作为否定上下文的一部分出现模型把它当成了独立症状。解决第 3.3 节的否定词规则在这里是主力。另外在实体标签设计上把“检查结果描述”单独定义成一个类型不要混进“症状”。影像报告的诊断结论和患者主诉之间有关联但不等价建模时区分开查询时可避免大量噪音。5.4 图建好了但查询性能越来越差一个跳数查询卡几秒现象节点到十万级、关系三十万级后MATCH (d:Disease)--(s:Symptom) RETURN s.name这类路径查询明显变慢。原因导入阶段没有建索引和约束第 4 章的铁律没遵守或者全文索引建在了频繁更新的属性上。解决先检查执行计划EXPLAIN看 Cypher 是走了 NodeIndexSeek 还是 AllNodesScan。AllNodesScan 出现就说明缺索引。另一个注意点verified这类频繁更新的布尔字段不要建索引更新成本比查询收益大。正确方案是只给id、name、code这三个查询入口建索引。5.5 增量更新把已验证节点的属性覆盖了现象上次人工核对过的节点这次增量导入把name里的标准名改成了别名verified被重置成 false。原因LOAD CSV 里用了SET而不是只在创建时赋值。增量更新的数据源质量参差不齐旧数据的非空字段可能本来就是错的。解决区分“创建”和“更新”两种语义。第一次导入用ON CREATE SET全量写属性增量更新只更新source和evidence标准名和编码保持不变。更稳妥的做法是加一个manual_reviewed标志位人工校验过的节点在自动更新时整体跳过避免机器把人工的修正覆盖掉。6. 验证与增量更新把图谱从“能看”做到“能用”6.1 拓扑校验先跑一轮基数检查和孤立节点扫描导入完成后不要急着写查询接口先做三轮校验。第一轮是基数检查每种实体和关系的数量应该在预期范围内比如“高血压”的 TREATS 关系应该有几十条如果有几千条基本是连错了。第二轮是孤立节点检查MATCH (n:Disease) WHERE NOT (n)--() RETURN n.name, n.code LIMIT 50;孤立节点多半是只有编码没有关系的实体它们不会参与任何查询但会拖慢统计。第三轮是临床语义抽查随机抽 30 个疾病节点逐个检查它们“表现为”的症状是否在 ICD-10 和权威指南里有对应描述以及“用于治疗”的药品是否与临床用药指南冲突。三轮走完图谱才有底气给医生用。6.2 增量更新用事件回放代替直接改图图谱上线后不可能一成不变。新的指南发布、新药上市、科室调整都要求图谱跟着更新。直接改图的问题在于改错了一时半会儿发现不了。我常用的做法是参考事件回放的思路把每一次数据更新当成一个事件记录到日志表图谱数据只是事件流的“投影”。修正数据时先改事件日志再从头回放到图库而不是手工改某个节点属性。这样做的好处是每次修正都可追溯重放成本可控配合第 5.5 节的manual_reviewed标志位人工校验过的内容永远不会被批量更新冲掉。概念上类似数仓架构里用事件流同时驱动实时查询和离线批处理的 Kappa 架构思路换到图库就是“日志即真相图谱即投影”。写到这里我最深的体会是医疗知识图谱构建实战里模型和算法只占了三分之一的精力剩下三分之二都花在术语归一化、关系方向定义和增量更新的约束上。代码能跑通只是起点能经受住临床追问才是终点。希望这篇笔记能帮你少走一段我走过的弯路。本文还有配套的精品资源点击获取