☰
基于疾病为中心的医药知识图谱问答系统:Neo4j建模与意图识别实战
2026/10/3 3:50:04 网站建设 项目流程

简介:这份资源是面向医药信息化与知识图谱方向的开发者、学生及科研人员的问答系统设计源码,以疾病为中心构建一定规模的医药领域知识图谱,并实现自动问答与分析服务,适合作为课程设计、毕业设计或知识图谱入门到进阶的实战参考。压缩包共31个文件,约49.19MB,包含8个Python源码文件、9个PNG图片、9个文本文件、3个pyc编译文件、1个JSON数据文件、1个Markdown文档和1份PPT演示文稿;其中Python文件承担图谱构建、问题分类、问句解析与答案检索等核心逻辑,文本与JSON文件提供疾病、症状、药品、科室等原始数据,图片与PPT则用于图谱可视化与项目展示。目前已有307人学习下载。读者可据此获得一套结构完整的医药知识图谱问答实现方案,理解从数据准备、图谱构建到自然语言问答的完整链路,并借助现成脚本与数据快速复现、二次开发或用于教学演示。

1. 疾病为中心的医药知识图谱问答:为什么"以疾病为圆心"比"以药品为圆心"更抗造

做过医药问答的人大多踩过同一个坑:用户问"糖尿病能吃二甲双胍吗",系统答出一堆药品说明书,却答不出"糖尿病"和"二甲双胍"之间那条"一线用药"的边。问题不在模型,在知识图谱的建模中心选错了。以药品为中心建图,节点是药,疾病只是属性标签,一旦用户从症状、并发症、禁忌症切入,图就散了。以疾病为中心建图,疾病是主键,症状、检查、药品、手术、科室、饮食全部挂在这颗主键上,问答的召回路径天然收敛。

这套"基于疾病为中心的医药领域知识图谱问答系统",本质是把医药知识组织成一张以疾病实体为根的有向图,再在上面套一层自然语言到图查询的翻译层。它解决的是"用户用大白话问病,系统用结构化知识答"这件事,适合三类人:想拿知识图谱做课程设计或毕设的学生、想把院内零散医药数据串起来的工程师、想验证 Neo4j 加问答链路可行性的技术负责人。源码层面通常包含本体定义、数据抽取脚本、图数据库导入、问句解析和答案生成五块,下面按落地顺序拆开讲。

2. 疾病中心本体怎么定:实体、关系与属性的一次性设计

2.1 为什么先定本体再写代码

医药领域最怕的不是数据少,是同一件事有五种叫法。"2型糖尿病""II型糖尿病""成人发病型糖尿病"在文本里是三串字符,在图里必须是同一个节点。本体(Ontology)就是提前把"有哪些实体类型、哪些关系、哪些属性"钉死的契约。以疾病为中心的本体,核心实体一般控制在七类:疾病、症状、药品、检查项目、科室、并发症、人群(年龄/性别/特殊生理状态)。关系则围绕疾病展开,常见的有:疾病-症状(has_symptom)、疾病-药品(treated_by / contraindicated_drug)、疾病-检查(diagnosed_by)、疾病-并发症(complication_of)、疾病-科室(belongs_to_dept)。

这里有个选型理由要说清:为什么不把"症状"也做成中心?因为症状的歧义度远高于疾病。一个"头晕"能指向几十种病,把它当主键会让图查询的扇出爆炸。疾病作为中心,扇出可控,且符合临床"先诊断再治疗"的思维顺序。属性方面,疾病节点至少要有:标准名称、ICD编码、别名列表、概述、病因、预防措施。ICD编码是后续和外部数据对齐的锚点,别名列表是问句匹配的救命稻草,这两个字段千万别省。

2.2 用 Neo4j 建约束和索引的最小命令

本体定完,落到 Neo4j 上第一件事不是导数据,是建约束。没有唯一约束,重复导入会把图撑成毛线团。下面这段 Cypher 是我一般会先跑的骨架:

// 疾病节点:以 ICD 编码作为唯一键,别名单独存数组 CREATE CONSTRAINT disease_icd_unique IF NOT EXISTS FOR (d:Disease) REQUIRE d.icd IS UNIQUE; // 药品节点:以通用名唯一 CREATE CONSTRAINT drug_name_unique IF NOT EXISTS FOR (dr:Drug) REQUIRE dr.name IS UNIQUE; // 症状节点:症状名唯一 CREATE CONSTRAINT symptom_name_unique IF NOT EXISTS FOR (s:Symptom) REQUIRE s.name IS UNIQUE; // 为疾病名称建索引,加速按名匹配 CREATE INDEX disease_name_index IF NOT EXISTS FOR (d:Disease) ON (d.name); // 为别名建全文索引,问句里出现别名时能命中 CREATE FULLTEXT INDEX disease_alias_fulltext IF NOT EXISTS FOR (d:Disease) ON EACH [d.name, d.aliases];

逻辑说明:前三条是唯一约束,保证同一疾病、药品、症状不会重复建点;第四条是普通索引,用于精确按名查;第五条是全文索引,专门对付"成人发病型糖尿病"这类别名。参数上,IF NOT EXISTS保证脚本可重复执行,不会因为约束已存在而报错中断。全文索引的ON EACH后面跟的字段列表,就是参与模糊匹配的属性,别名一定要放进去,否则用户换个说法就查不到。

提示:Neo4j 5.x 之后全文索引语法和 4.x 略有差异,导入前先CALL dbms.procedures()确认版本,别照抄旧教程。

2.3 实体和关系的属性字段清单

把本体落成一张字段表,比口头描述靠谱得多。下面是我常用的最小字段集,字段名直接对应后续抽取脚本的输出列:

实体/关系关键字段说明
Diseaseicd, name, aliases, overview, cause, preventionicd 为唯一键,aliases 为字符串数组
Symptomname, body_part, severitybody_part 用于按部位过滤
Drugname, category, usage, adverse_reactioncategory 区分处方药/非处方药
Examinationname, category, normal_rangenormal_range 存参考值文本
Departmentname, levellevel 区分门诊/专科
treated_bydisease_icd, drug_name, line_of_treatmentline_of_treatment 存一线/二线
has_symptomdisease_icd, symptom_name, frequencyfrequency 存常见/偶见

这张表的价值在于:抽取脚本按列产出 CSV,导入脚本按列读,问答层按字段拼答案,三方对齐,谁也不用猜对方要什么。字段名一旦定下就别中途改,改一次全链路返工,这是血泪经验。

3. 从半结构化文本到图数据:抽取、清洗与批量导入

3.1 数据从哪来,怎么抽

医药数据来源通常三类:公开的疾病百科类文本、药品说明书、院内脱敏病历摘要。前两类适合做通用图谱,第三类适合做院内定制。抽取方式上,规则加词典优先,模型兜底。原因很直接:医药实体边界清晰,用词典匹配的准确率往往比通用 NER 模型还高,而且可解释、可回溯。我一般会先维护三份词典——疾病词典(含别名)、药品词典、症状词典,用它们做第一轮匹配,再用模型补漏。

下面是一个基于词典加正则的抽取脚本骨架,输入是纯文本,输出是三元组 CSV:

import re import csv from collections import defaultdict # 三份词典:实际项目里从文件加载,这里示意结构 DISEASE_DICT = {"2型糖尿病": "E11", "高血压": "I10", "冠心病": "I25"} DRUG_DICT = {"二甲双胍", "阿卡波糖", "胰岛素"} SYMPTOM_DICT = {"多饮", "多尿", "体重下降", "头晕"} # 关系触发词:命中即认为前后实体存在对应关系 REL_PATTERNS = { "has_symptom": re.compile(r"(症状|表现为|常见症状)[::是为]?\s*([^。;;]+)"), "treated_by": re.compile(r"(治疗|用药|首选)[::是为]?\s*([^。;;]+)"), } def extract(text, disease_name): """从一段疾病描述文本中抽取三元组""" triples = [] icd = DISEASE_DICT.get(disease_name) if not icd: return triples # 1. 症状抽取:先按触发词切段,再在段内匹配症状词典 for rel, pattern in REL_PATTERNS.items(): for match in pattern.finditer(text): segment = match.group(2) if rel == "has_symptom": for sym in SYMPTOM_DICT: if sym in segment: triples.append((disease_name, rel, sym)) elif rel == "treated_by": for drug in DRUG_DICT: if drug in segment: triples.append((disease_name, rel, drug)) # 2. 兜底:全文扫描药品词典,避免触发词漏召回 for drug in DRUG_DICT: if drug in text and (disease_name, "treated_by", drug) not in triples: triples.append((disease_name, "treated_by", drug)) return triples def save_csv(triples, path): with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["head", "relation", "tail"]) writer.writerows(triples) if __name__ == "__main__": sample = "2型糖尿病常见症状为多饮、多尿、体重下降,首选治疗药物为二甲双胍。" result = extract(sample, "2型糖尿病") save_csv(result, "triples.csv") print(result)

逻辑说明:REL_PATTERNS用触发词把文本切成"关系段",再在段内做词典匹配,这样能避免把"预防措施"里的药名误当成治疗药。兜底那一步是防止触发词写法多变导致漏召回,代价是可能引入少量噪声,后续用人工抽检过滤。参数上,DISEASE_DICT的 value 存 ICD 编码,是为了让三元组的 head 能对齐到图里的唯一键,而不是靠疾病名硬匹配。save_csv统一输出 head-relation-tail 三列,导入脚本直接读,不用改格式。

3.2 清洗:别名归一和冲突消解

抽取出来的三元组,十有八九带着别名和冲突。别名归一就是把"II型糖尿病""成人发病型糖尿病"统一映射到"2型糖尿病"的 ICD 上。做法是维护一张别名映射表,抽取阶段就做替换,而不是等到导入时再处理。冲突消解针对的是同一对实体出现矛盾关系,比如 A 药既被标为"治疗"又被标为"禁忌"。这时按来源可信度排序:说明书 > 百科 > 病历摘要,高可信来源覆盖低可信来源,同级别来源则保留两条边并打上来源标签,让问答层决定怎么呈现。

# 别名归一:抽取后立刻做,别拖到导入 ALIAS_MAP = { "II型糖尿病": "2型糖尿病", "成人发病型糖尿病": "2型糖尿病", "原发性高血压": "高血压", } def normalize(triples): fixed = [] for h, r, t in triples: h = ALIAS_MAP.get(h, h) t = ALIAS_MAP.get(t, t) fixed.append((h, r, t)) # 去重:同一三元组只留一条 return list(set(fixed))

这段代码短,但位置很关键。放在抽取之后、入库之前,能省掉后面一大堆脏数据排查。ALIAS_MAP建议单独存成 CSV 或 JSON,方便非技术人员维护,别硬编码在脚本里。

3.3 用 LOAD CSV 批量导入 Neo4j

数据干净了,导入用 Neo4j 自带的LOAD CSV就够,不必上 APOC,除非数据量到千万级。下面按"先建点、再建边"的顺序写:

// 1. 导入疾病节点 LOAD CSV WITH HEADERS FROM 'file:///disease.csv' AS row MERGE (d:Disease {icd: row.icd}) SET d.name = row.name, d.aliases = split(row.aliases, '|'), d.overview = row.overview; // 2. 导入药品节点 LOAD CSV WITH HEADERS FROM 'file:///drug.csv' AS row MERGE (dr:Drug {name: row.name}) SET dr.category = row.category, dr.usage = row.usage; // 3. 导入治疗关系,先 MATCH 两端再建边 LOAD CSV WITH HEADERS FROM 'file:///treated_by.csv' AS row MATCH (d:Disease {icd: row.disease_icd}) MATCH (dr:Drug {name: row.drug_name}) MERGE (d)-[r:TREATED_BY]->(dr) SET r.line_of_treatment = row.line_of_treatment;

逻辑说明:MERGE而非CREATE,保证重复执行不会产生重复节点和边。split(row.aliases, '|')把 CSV 里用竖线分隔的别名字符串转成数组,对应前面全文索引的字段。第三步先MATCH两端再MERGE边,是 Neo4j 导入关系的标准姿势,直接MERGE整条路径在数据量大时会很慢。参数上,CSV 文件要放在 Neo4j 的 import 目录下,路径写相对路径,绝对路径在部分版本会被拒绝。

注意:导入前把dbms.memory.heap.max_size调大,默认值导几十万条边就会 OOM,这个坑我踩过不止一次。

4. 问句怎么翻译成图查询:意图识别加模板槽位

4.1 意图分类:先分清用户问的是哪类问题

医药问答的问句,粗分五类意图:症状查询("糖尿病有什么症状")、用药查询("高血压吃什么药")、禁忌查询("糖尿病不能吃什么药")、检查查询("确诊糖尿病要做哪些检查")、并发症查询("糖尿病会引起什么病")。意图识别不需要上大模型,用关键词加轻量分类器就够。关键词规则负责高置信场景,分类器兜底长尾。下面是一个规则优先的意图识别函数:

INTENT_RULES = [ ("symptom", ["症状", "表现", "有什么感觉"]), ("drug", ["吃什么药", "用什么药", "治疗", "首选药"]), ("contraindication", ["不能吃", "禁忌", "慎用", "禁用"]), ("examination", ["检查", "化验", "怎么确诊", "诊断"]), ("complication", ["并发症", "会引起", "导致什么病"]), ] def detect_intent(question): for intent, keywords in INTENT_RULES: if any(kw in question for kw in keywords): return intent return "unknown"

逻辑说明:规则按优先级从上到下匹配,contraindication放在drug前面,是因为"不能吃什么药"同时含"吃什么药",如果 drug 先匹配就答反了。这个顺序是踩坑踩出来的,别随意调。unknown意图交给后续的兜底策略,比如返回图谱里该疾病的所有关联,或者提示用户换个问法。

4.2 实体链接:把问句里的病名对到 ICD 上

识别出意图还不够,得知道用户问的是哪个病。实体链接分两步:先做字符串精确匹配,命中不了再走全文索引模糊匹配。精确匹配用疾病名和别名做字典查找,模糊匹配用前面建的disease_alias_fulltext索引。

from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) def link_disease(question): """返回匹配到的疾病 icd,匹配不到返回 None""" with driver.session() as session: # 第一步:精确匹配 name 或 aliases exact = session.run( """ MATCH (d:Disease) WHERE d.name = $q OR $q IN d.aliases RETURN d.icd AS icd LIMIT 1 """, q=question ).single() if exact: return exact["icd"] # 第二步:全文索引模糊匹配,取相关性最高的一条 fuzzy = session.run( """ CALL db.index.fulltext.queryNodes('disease_alias_fulltext', $q) YIELD node, score RETURN node.icd AS icd, score ORDER BY score DESC LIMIT 1 """, q=question ).single() return fuzzy["icd"] if fuzzy else None

逻辑说明:精确匹配优先,是因为它零误召回;模糊匹配兜底,解决用户说法不规范的问题。LIMIT 1是必要的,医药问句通常只针对一个疾病,返回多个反而让下游难处理。参数上,全文索引查询的$q直接传原始问句即可,Lucene 会做分词,不用自己切。如果问句里同时出现多个疾病,这套逻辑只取第一个,多疾病场景需要额外做实体消歧,属于进阶内容。

4.3 意图加实体生成 Cypher 模板

意图和疾病 ICD 都有了,剩下的就是把它们填进预定义的 Cypher 模板。模板化查询的好处是可控、可审计,不会像大模型生成 Cypher 那样偶尔写出删库语句。

QUERY_TEMPLATES = { "symptom": """ MATCH (d:Disease {icd: $icd})-[:HAS_SYMPTOM]->(s:Symptom) RETURN s.name AS answer, s.frequency AS freq ORDER BY freq DESC """, "drug": """ MATCH (d:Disease {icd: $icd})-[r:TREATED_BY]->(dr:Drug) RETURN dr.name AS answer, r.line_of_treatment AS line ORDER BY line ASC """, "contraindication": """ MATCH (d:Disease {icd: $icd})-[:CONTRAINDICATED_DRUG]->(dr:Drug) RETURN dr.name AS answer """, "examination": """ MATCH (d:Disease {icd: $icd})-[:DIAGNOSED_BY]->(e:Examination) RETURN e.name AS answer, e.normal_range AS ref """, "complication": """ MATCH (d:Disease {icd: $icd})-[:COMPLICATION_OF]->(c:Disease) RETURN c.name AS answer """, } def answer_question(question): intent = detect_intent(question) icd = link_disease(question) if not icd or intent not in QUERY_TEMPLATES: return "暂时无法理解这个问题,换个说法试试" with driver.session() as session: records = session.run(QUERY_TEMPLATES[intent], icd=icd) answers = [r["answer"] for r in records] return "、".join(answers) if answers else "图谱中暂无相关记录"

逻辑说明:模板按意图索引,每个模板只查一类关系,返回字段统一叫answer,方便上层拼装。ORDER BY让答案有稳定顺序,症状按频率排、药品按治疗线数排,用户看到的第一条就是最相关的。参数$icd由实体链接传入,模板本身不含任何用户输入拼接,杜绝了 Cypher 注入。这套结构简单,但覆盖了医药问答八成的常见问法,剩下的长尾再考虑上语义解析。

5. 避坑与排查:上线前必须过的五道坎

5.1 现象:问"糖尿病"答不出任何东西,问"2型糖尿病"却正常

原因:实体链接只做了精确匹配,没走全文索引,或者全文索引里没放别名。用户口语里说"糖尿病",图里存的是"2型糖尿病""1型糖尿病",精确匹配自然落空。

解决:确认disease_alias_fulltext索引的ON EACH字段包含aliases,并且导入时别名确实写进了数组。再检查link_disease的模糊匹配分支有没有被异常吞掉。测试时专门用简称、俗称、英文缩写各问一遍。

5.2 现象:导入几十万条边时 Neo4j 卡死或报内存不足

原因:默认堆内存太小,LOAD CSV又是逐行事务,边建边查两端节点,内存和事务日志双重压力。

解决:导入前调大dbms.memory.heap.max_size和dbms.memory.pagecache.size,关系导入用CALL {} IN TRANSACTIONS分批提交,每批五千到一万条。数据量再大就先用neo4j-admin import离线导入,别硬扛在线导入。

5.3 现象:同一个药在答案里出现两次,一次说治疗一次说禁忌

原因:不同来源的数据冲突,抽取时没做消解,两条边都进了图。

解决:在抽取阶段加来源可信度字段,导入时按可信度覆盖;如果两条边可信度相同,保留但打上source标签,问答层按意图只取对应关系。禁忌查询只走CONTRAINDICATED_DRUG,用药查询只走TREATED_BY,从查询侧隔离,比在数据侧强行合并更安全。

5.4 现象:问句里带否定词,比如"糖尿病不能吃什么",答成了能吃什么

原因:意图识别规则里drug的关键词"吃什么药"先于contraindication匹配,或者否定词没进规则。

解决:把contraindication的规则提到drug前面,并把"不能""禁忌""慎用""禁用""避免"都加进关键词。更稳的做法是单独做一个否定词检测,命中否定词就强制走禁忌意图,不依赖关键词顺序。

5.5 现象:全文索引查询报 "index not found"

原因:索引没建成功,或者建在了错误的数据库上。Neo4j 多数据库环境下,索引是分库的。

解决:先SHOW INDEXES确认索引存在且状态为 ONLINE,再确认会话连的是哪个数据库。建索引和查索引必须在同一个库,跨库查不到是常见误操作。

6. 让答案更可信:把推理路径和置信度一起返回

模板查询能答对问题,但答得"没底气"——用户看不到依据。医药场景里,答案的可信度和答案本身一样重要。我后来养成的习惯是:每条答案都带上它走过的图路径和来源,让用户能自己判断。做法是在 Cypher 里把路径也返回,再在应用层拼成一句人话。

def answer_with_evidence(question): intent = detect_intent(question) icd = link_disease(question) if not icd or intent not in QUERY_TEMPLATES: return {"answer": "暂时无法理解这个问题", "evidence": []} # 在模板基础上加路径返回 cypher = QUERY_TEMPLATES[intent].replace( "RETURN", "RETURN [n IN nodes(p) | n.name] AS path," ).replace( "MATCH", "MATCH p = ", 1 ) with driver.session() as session: records = session.run(cypher, icd=icd) rows = [dict(r) for r in records] if not rows: return {"answer": "图谱中暂无相关记录", "evidence": []} answer = "、".join(r["answer"] for r in rows) evidence = [" -> ".join(r["path"]) for r in rows] return {"answer": answer, "evidence": evidence}

逻辑说明:这段代码用字符串替换把原模板改造成带路径的版本,MATCH p =把匹配到的整条路径绑定到变量p,nodes(p)取出路径上的所有节点,再映射成名称数组。返回的evidence就是"疾病 -> 关系 -> 目标"的可读链路,前端可以折叠展示。参数上,替换MATCH时加了count=1,只替换第一个,避免误伤子查询里的 MATCH。这个改造对性能有轻微影响,路径越长开销越大,所以模板里的路径深度都控制在一跳,够用且快。

再进一步,可以给每条边加confidence属性,来源可信度高的边给 0.9,百科给 0.7,病历摘要给 0.6,答案按置信度加权排序。这样用户问"高血压首选什么药",系统返回的不只是药名,还有"依据:高血压 -TREATED_BY-> 氨氯地平(置信度 0.9,来源:药品说明书)"。这套东西做出来,问答系统才从"能答"变成"敢用"。

我自己做这类项目最大的教训是:别一上来就想着上大模型做端到端问答。先用本体加模板把主干跑通,把数据质量、实体链接、意图识别这三块打磨扎实,再考虑用模型补长尾。主干不稳,模型再强也是在流沙上盖楼。图谱这东西,脏数据进去,脏答案出来,清洗和归一的工作量永远比写查询大。希望帮到你。

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

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

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

立即咨询