医药知识图谱实战:从数据清洗到Neo4j智能问答

发布时间:2026/10/11 9:18:10
医药知识图谱实战:从数据清洗到Neo4j智能问答
简介这是一套面向自然语言处理、知识图谱与医疗信息化学习者的完整实战项目从垂直网站采集结构化数据完成以疾病为中心的医疗知识图谱构建实体规模约4.4万、关系30万并基于Neo4j与规则方法实现支持18类问题的自动问答系统。资源共31个文件压缩包18.58MB以8个Python脚本为核心覆盖网页爬取、数据处理、图谱入库、问句分类、答案检索与Cypher查询生成9张PNG图辅助理解整体流程8个TXT词典文件支撑命名实体识别JSON为可直接导入的医疗数据另有PPTX用于方案讲解。项目业务驱动Schema依据页面XPath解析结果设计可在本地快速部署适合希望从零复现医疗问答系统、学习知识图谱构建与Neo4j应用的开发者。已有1654人学习下载按步骤即可完成数据库搭建并体验问答服务。1. 医药知识图谱不是爬数据就完事先想清楚“问什么”再动手做医药知识图谱最容易翻车的不是算法而是你一开始就问错了问题。我见过不少项目先把垂直网站爬下来、清洗入库图倒是挺漂亮可一问智能问答个个答非所问。这份资源从数据爬取到Neo4j图谱构建再到问答服务真正把“药品-疾病-症状”的关系闭环跑通适合准备做医药问答、药品分析或临床辅助检索的工程师——不管是自己练手还是企业POC都能照着路径落地。它解决的不只是“列出几个实体画张图”而是把自然语言问题拆成查询模式让图谱真的能回答“这个药能不能治那个病”“有哪些药不能一起吃”这类具体问题。如果你的目标只是展示图可能不需要这么复杂一旦要让智能问答跑起来就得先想清楚问题类型和查询路径。知识图谱构建的顺序应该是“问题定义 → 本体设计 → 数据抽取 → 图存储”而不是反过来。2. 本体与数据清洗先把“回答什么”翻译成图结构2.1 垂直网站数据怎么清洗成实体关系三元组垂直网站药品百科、疾病库的原始页面是HTML里面通常混着导航、广告、相关推荐直接拿去训练或入库会让实体识别乱掉。我一般的处理顺序是拿页面正文 → 按标题结构切片 → 去噪 → 抽候选三元组。这个码源里给的爬虫脚本不复杂但清洗逻辑值得看它把“适应症”“不良反应”“禁忌”这些章节标题当锚点从锚点后面截文本再交给关系抽取效果比整页正则稳定很多。先看一版清洗入口的代码实际脚本里封装成了clean_medical_htmlimport re from bs4 import BeautifulSoup def clean_medical_html(html_text): soup BeautifulSoup(html_text, html.parser) for tag in soup([script, style, nav, footer, aside]): tag.decompose() text soup.get_text(separator\n) lines [line.strip() for line in text.split(\n) if line.strip()] # 只保留章节锚点出现后的段落减少导航噪音 anchors (适应症, 不良反应, 禁忌, 用法用量, 成份) kept, active [], False for line in lines: if any(line.startswith(a) for a in anchors): active True if active: kept.append(line) return \n.join(kept)这里anchors定义了每个药品详情页里我们关心的章节。运行时你会发现页面上“适应症”可能出现在两个位置左侧导航栏和正文标题所以还需要额外判断该行后面有没有冒号、换行间隔通常正文标题的下一行才是实际内容。这个清洗结果最终只保留文本段落不做实体抽取真正抽实体放到后面NER模块处理。清洗完的文本按药品维度落盘一药一个txt命名为{drug_id}.txt后面做命名实体识别时按文件遍历这样出问题时能快速定位是哪一页、哪一段引入的脏数据。这里有一个容易被忽略的点HTML里的全角空格、不间断空格\xa0、以及列表序号“1.”“1”都会干扰后续正则所以清洗阶段要统一转半角并去掉序号前缀。我见过因为\xa0导致整个实体匹配失败的案例现象就是某一条关系反复抽取不到最后排查了半小时才发现是转义字符在捣乱。2.2 本体与属性约束别把“药品”和“疾病”塞进同一个节点标签知识图谱构建最怕没有本体约束Neo4j里允许任意节点任意标签这既是自由度也是坑。如果你把“感冒”既当疾病又出现在某个药品的类别属性里后面统计“疾病-药品”关系时就会漏掉。这个码源里建议的最小本体分四类实体节点药品、疾病、症状、成分关系分六类用于治疗、包含成分、不良反应、禁忌、产自厂商、相互作用。用表格列一下节点与关键属性节点类型关键属性除了唯一ID示例Drugname, alias, approval_no阿司匹林肠溶片Diseasename, alias, icd10冠心病Symptomname, alias胸痛、心悸Ingredientname, cas_no阿司匹林节点之间用关系连接关系类型统一用大写蛇形命名。比如“药品-疾病”的治疗关系是(:Drug)-[:INDICATES]-(:Disease)不要用drug_disease或treat这种缩法因为后续问答模板要按关系类型做正则映射命名越规范匹配越简单。除了命名还要在Neo4j里做约束。常见做法是建立实体唯一性约束和组合关系约束防止同一知识从多个页面重复写入时形成重复节点。可以在执行导入前先跑两条约束语句CREATE CONSTRAINT drug_name_unique IF NOT EXISTS FOR (d:Drug) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT disease_name_unique IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE;这里的IF NOT EXISTS是Neo4j 4.x以后的语法5.x一样能用。加约束的另一个好处是之后用MERGE建节点时能直接利用唯一索引做去重不需要先查后插。如果数据里药品名有别名约束仍然建在name上别名放到alias数组属性里不要拆成多个节点。最后提醒一句本体设计不是越复杂越好。不要把“科室”“医生”“论文”也拉进来除非你的问答确实需要。多一个实体类型后面NER维护词表就多一层成本。我当时就是因为贪全把“医药机构”也加进去结果训练标注量翻倍问答准确率反而没提升最后又回滚到四类节点。3. 命名实体识别与关系抽取没有标注数据怎么先把问答跑起来3.1 医药NER词典优先模型兜底医药领域的开源NER模型不少但在没标注语料的前提下直接上BERTCRF很容易因为域外词太多而翻车。我的经验是先做词典规则保证返回结果可解释等积累几百条人工标注后再训练一个小模型去覆盖“新实体发现”。这个码源里的NER模块就是词典与规则混跑词典覆盖药品、疾病、症状名规则覆盖“XX片”“XX胶囊”“XX病”“XX综合症”这类通名。代码入口类似这样import re from collections import defaultdict class MedicalNER: def __init__(self, drug_dict, disease_dict, symptom_dict): self.terms defaultdict(list) for name in drug_dict: self.terms[Drug].append(name) for name in disease_dict: self.terms[Disease].append(name) for name in symptom_dict: self.terms[Symptom].append(name) self.patterns { Drug: r(?:[^\s。]{1,12}?(?:片|胶囊|口服液|软膏)), Disease: r(?:[^\s。]{2,20}?(?:病|症|综合征|炎|癌)), } def extract(self, text): entities [] for label, names in self.terms.items(): for name in names: if name in text: entities.append((name, label)) for label, pattern in self.patterns.items(): for match in re.finditer(pattern, text): name match.group(0) if name not in [e[0] for e in entities]: entities.append((name, label)) return entities这段代码的输入是三个列表分别存药品名、疾病名、症状名。第一轮精确匹配词典第二轮用正则补足词典没收录的组合词。实际跑的时候你会发现正则的(?:...)非捕获组很重要它不会额外占用内存而且匹配速度比逐个字窗快。这里的“片|胶囊”这些后缀是从爬到的药品详情页里统计高频词得到的不同网站差异不大可以直接沿用。参数层面最需要调的是每个模式前面的字符数上限。我一般会限定在12个字符内因为药品通用名商品名剂型组成之后很少超过这个长度疾病名则放宽到20个字符因为“慢性阻塞性肺疾病”“急性上呼吸道感染”这类都超过8个字。调这块时别只看单条文本的识别效果要看整批数据的实体召回率后面第6章会讲怎么量化。3.2 关系抽取锚点正则规则模板够用且不飘医药领域的关系抽取和开放域不太一样垂直网站的文本结构相对固定很多句子是“用于治疗”或“不宜与”所以用基于触发词模板的方法能拿回大部分有效关系。这个做法虽然土但可解释性极强出错了你知道是哪个模板漏了。这个码源里把模板拆成了三层主谓关系模板、并列关系模板、反向排除模板。典型实现REL_TEMPLATES [ (INDICATES, r(?:用于治疗|适应症为|主治)[^。]*?((?:[^\s。]{2,20}(?:病|症|综合征|炎|癌)))), (CONTRADICTS, r(?:禁忌|不宜与|不能和)[^。]*?((?:[^\s。]{2,12}?))), (HAS_SYMPTOM, r(?:表现为|症状为|可出现)[^。]*?((?:[^\s。]{2,20}(?:痛|热|气促|乏力)))), ] def extract_relations(article_text, drug_name): relations [] for rel_type, pattern in REL_TEMPLATES: for match in re.finditer(pattern, article_text): target match.group(1).strip() if len(target) 2: relations.append((drug_name, rel_type, target)) return relations抽出来的结果是三元组(subject, relation, object)但object这类实体名不一定都准备好了比如“胸痛”“发热”可能不在Symptom词典里。这里有一个取舍对象实体先不做严格校验直接当字符串存等后面批量导入前再统一做实体对齐。如果你在抽出阶段就要求对象必须在词典里会漏掉大量新词得不偿失。模板正则有一个坑[^。]*?是非贪婪它会打到第一个句号或分号之前。但有些网站上描述适应症是在同一个段落里写多句话第一句是“用于治疗原发性高血压”第二句是“也可用于心绞痛”这样只取到第一句。所以实际脚本里会先用split(。)把段落切成子句再对每个子句套模板而不是在整段上套一个长正则。3.3 从三元组到CSV导入前的数据规整关系抽取完成后统一汇总成两个文件节点文件和关系文件。关系文件里的subject和object需要对齐到各自的节点ID。这里最容易出的问题是同一个实体在不同页面里写法不一致比如“阿司匹林”和“阿司匹林肠溶片”被当作两个实体导致图谱里出现两个近似节点。我一般在写CSV前先做一次实体名归一化维护一个别名映射表手动把常见别名收紧到标准名。这一步永远逃不掉与其在Neo4j里做图合并不如在数据侧先做字符串规整因为你需要保留原始出处。示例节点文件drugs.csvid,name,alias,source_url D001,阿司匹林,阿司匹林肠溶片;Aspirin,http://... D002,布洛芬,布洛芬缓释胶囊;Ibuprofen,http://...关系文件relations.csvstart_id,end_id,type,evidence D001,DI001,INDICATES,用于治疗冠心病 D001,SY001,HAS_SYMPTOM,可出现胃肠不适这里的evidence字段存抽取出来支撑该关系的那句话后面做智能问答时能直接返回给用户“我们依据的是这条描述”。这个字段对可信度评估特别有用比给关系加一堆数字权重更直观。导入Neo4j时evidence可以作为关系属性一起写入查询时随关系返回。4. Neo4j导入与问答服务用Cypher把图变成可回答的接口4.1 LOAD CSV批量建节点与关系数据规整好之后下一步就是导入Neo4j。如果你的关系条数在几十万以内直接用LOAD CSV就够了如果上千万再考虑Neo4j Admin Import。这个码源里用的是标准LOAD CSV适合单机数据量、流程可调试。导入前先要把CSV文件放到Neo4j的import目录或者用file:///绝对路径指向你的数据目录。节点导入示例LOAD CSV WITH HEADERS FROM file:///drugs.csv AS row MERGE (d:Drug {id: row.id}) SET d.name row.name, d.alias split(row.alias, ;), d.source_url row.source_url;这里用MERGE而不是CREATE配合前面建过的唯一约束可以保证同一id不会重复建节点。alias字段在CSV里用分号分隔导入时通过split()转成Neo4j的列表属性方便问答阶段用any(alias IN d.alias WHERE alias IN ...)做实体匹配。关系导入相对要小心因为两端节点可能不在同一批数据里。安全做法是先导完所有节点再导关系LOAD CSV WITH HEADERS FROM file:///relations.csv AS row MATCH (s {id: row.start_id}) MATCH (e {id: row.end_id}) MERGE (s)-[r:INDICATES {type: row.type}]-(e) SET r.evidence row.evidence;两次MATCH会分别扫节点标签和id如果没有为id建索引这里是全库扫描十万节点时可能卡十几秒。所以我在第2章强调在导入前先建CREATE INDEX FOR (d:Drug) ON (d.id)之类的索引。导入脚本里一般会加一个:auto USING PERIODIC COMMIT 5000写法不过Neo4j 5.x中推荐用CALL apoc.periodic.iterate来分批你看码源里的版本用哪种就行。如果用的是社区版且没装APOC就用普通LOAD CSV提交频率由Neo4j自动控制不需要手动写PERIODIC COMMIT。4.2 图查询药品-适应症-不良反应怎么走图建好后第一个要验证的查询是“某药品能治疗哪些疾病、可能引发什么不良反应”。这里关系有方向查询方向要顺着关系类型写。比如INDICATES从Drug指向Disease那么查询阿司匹林的适应症MATCH (d:Drug {name: 阿司匹林})-[:INDICATES]-(dis:Disease) RETURN dis.name AS disease ORDER BY dis.name;如果要连不良反应一起查可以一步取两条关系MATCH (d:Drug {name:阿司匹林}) OPTIONAL MATCH (d)-[:INDICATES]-(dis:Disease) OPTIONAL MATCH (d)-[:HAS_SYMPTOM]-(s:Symptom) RETURN d.name AS drug, collect(DISTINCT dis.name) AS indications, collect(DISTINCT s.name) AS side_effects;注意我用了OPTIONAL MATCH因为不是每条药都有不良反应记录用普通MATCH会把有适应症但没不良反应的药品整行滤掉这是新手最容易犯的错。聚合用collect(DISTINCT ...)保证一个疾病在多处出现时不会重复。问答服务里还有一类高频查询是反向的输入疾病找可用药品。就是换成从Disease出发MATCH (dis:Disease {name:冠心病})-[:INDICATES]-(d:Drug) RETURN d.name AS drug, d.alias AS alias;这里的-[:INDICATES]-和无方向写法-[:INDICATES]-结果一样但方向明确能帮Neo4j优化器走索引。实际项目中我会把所有常用查询模板统一注册到一个路由表里接口层只接收参数不写裸Cypher避免查询语句混乱。4.3 智能问答模板匹配把问题翻译成Cypher问答模块不需要一开始就上BERT语义匹配。先用模板匹配能解决80%的常见问题比如“XX能治什么病”“XX有哪些副作用”“什么药能治XX”。这个码源给出了一个比较轻量的路由思路对用户输入做实体识别识别出药品或疾病名之后按问题里的意图词选择对应的Cypher模板。先看路由代码import re INTENT_CYPHER { indication: MATCH (d:Drug {name: $entity})-[:INDICATES]-(dis:Disease) RETURN collect(dis.name) AS result, side_effect: MATCH (d:Drug {name: $entity})-[:HAS_SYMPTOM]-(s:Symptom) RETURN collect(s.name) AS result, treatment: MATCH (dis:Disease {name: $entity})-[:INDICATES]-(d:Drug) RETURN collect(d.name) AS result, } def infer_intent(question): if re.search(r治|适应|用于, question): return indication if re.search(r副作用|不良反应|症状, question): return side_effect if re.search(r什么药|哪些药|治疗.*用什么, question): return treatment return None def answer(question, entity, driver): intent infer_intent(question) cypher INTENT_CYPHER.get(intent) if not cypher: return 这个问题我还没学会换个问法试试。 with driver.session() as session: record session.run(cypher, entityentity).single() return record[result] if record else 没有查询到相关内容。这里的entity由前面的NER模块从问题里抽出来抽取时需要对“阿司匹林能治什么病”这类句子做实体消歧如果同时命中Drug和Disease意图词决定了用哪个标签。比如“阿司匹林治什么病”里的实体是Drug“什么药治冠心病”里的实体是Disease。我的做法是把问题里的疑问词和实体候选做一个约束当意图是indication时只允许实体来自Drug词典否则返回“没理解”。这样可以避免把疾病名误当成药品名。模板匹配的缺点是不太会处理否定句和比较句比如“为什么不能用阿司匹林治疗胃溃疡”这种模板会错误命中“治疗”。所以问答服务上线前要人工过一遍高频失败样本把否定词加入意图过滤。这个环节不复杂但非常值它决定了用户觉得“好用”还是“智障”。5. 避坑指南医药知识图谱项目里我踩过的五个坑5.1 实体对齐同药异名知识分散现象查询“阿司匹林”只返回一部分关系换成“Aspirin”或“乙酰水杨酸”又能查到另一部分。原因不同网站对同一药品使用了不同别名NER没有做合并导致一个药品在图上分裂成三个节点。解决维护一份别名-标准名映射表在NER抽取和CSV导出前统一执行替换。码源里的做法是把别名表放在一个alias_dict.json里清洗阶段先加载把所有文本中的别名替换为标准名再做NER和关系抽取。替换时注意不要只替换完全匹配也要考虑括号里的英文名用正则扫描。5.2 关系重复同一知识出现N次图越来越胖现象导入后统计INDICATES关系数量明显大于预期药品-疾病对重复。原因多篇网页都写了同一句适应症每次抽取都生成一条关系且没有做唯一性约束。解决给关系加组合唯一约束或者导入时用MERGE而不是CREATE。Neo4j对关系没有原生“多个属性唯一”的约束语法所以更稳妥的是导入前先对关系三元组去重再用MERGE建立关系。如果已经导完可以用一条MATCH找重复再删但数据量大时不建议直接删先聚合出重复对再清理。5.3 中文分词专业术语被切得稀碎现象NER词典明明有“卡托普利”抽取的实体却是“卡托”“普利”。原因是分词器把未登录词切开匹配阶段错误命中了较短的词。解决在Jieba加载自定义医药词典并设置优先级。做法是jieba.add_word(卡托普利, freq100000)覆盖默认的词频让分词器优先保留整词。这个坑不改你的实体匹配就会少一半。另外对于“XX片”“XX胶囊”这类带剂型的词要整词加入词典不要在抽取阶段再拼接。5.4 属性类型和空值int变string查询静默出错现象之前WHERE d.approval_no 123能查某天数据更新后查不到或者LOAD CSV导入时报属性类型冲突。原因CSV里同一列有的是数字有的是“不详”Neo4j就把整列推断成字符串。解决导入前用Python强制类型转换或把空值统一填NULL并在Cypher里用coalesce()兜底。我一般会在清洗脚本里加一行row[year] int(row[year]) if row[year] else None杜绝半吊子类型。5.5 性能问答串起来之后查询越来越慢现象线上问答延迟从200ms涨到2s部分查询直接超时。原因没有为查询涉及到的属性建索引而且关系路径上跑了深度可变匹配。解决先给name、id建索引再给常用关系加方向约束最后限制MATCH最大深度为3。还有个更隐蔽的点问答模块里频繁用contains做模糊匹配只要数据量上万contains就会全库扫描。如果必须用改成Neo4j的全文索引FULLTEXT INDEX后面第6章讲。6. 进阶用全文索引和日志复盘把问答响应压到百毫秒先建立全文索引中文文本的模糊搜索才有救CREATE FULLTEXT INDEX drug_name_fulltext FOR (n:Drug) ON EACH [n.name, n.alias]然后查询时用db.index.fulltext.queryNodesCALL db.index.fulltext.queryNodes(drug_name_fulltext, 阿司匹林 OR 乙酰水杨酸) YIELD node, score RETURN node.name, score ORDER BY score DESC LIMIT 10;这样即使你漏了别名映射全文索引也能靠分词把近似药品捞出来召回率比contains高很多。但要注意全文索引的搜索词不能为空而且对中文的分词依赖Neo4j内嵌的Lucene分析器如果出现“阿司匹林”被拆成“阿斯”和“匹林”需要检查你的查询词后面带不带模糊符号。问答日志复盘是我每次上线后必做的一件事。把所有用户问题、NER结果、命中的Cypher、返回条数落到一张表每天统计一次“实体识别成功但答案为空”的问题这些才是知识图谱里的知识缺口。我会用一个简单脚本按意图分类计数上一周的高频空结果下一周就去找数据源补。比如连续三天有人问“XX药能不能用于孕妇”图谱里完全没有孕妇实体和妊娠禁忌关系那就去药典页面补充相关数据比盲目加模型效果明显得多。从那以后我每次搭医药知识图谱都强制先跑一遍“高频问题清单”再动数据——用100条真实用户问题做验收问不出来就回头补实体和关系而不是最后才检查。希望帮到你。本文还有配套的精品资源点击获取