
简介一套基于Python与Neo4j图数据库的医疗知识图谱智能问答机器人毕业设计项目面向计算机相关专业正在准备毕业设计的学生以及需要完整实战练习的编程学习者也可直接用于课程设计或期末大作业。项目代码经过严格调试可正常运行。压缩包共36个文件约15.34MB核心为8个Python源码文件覆盖问题分析、医疗图谱构建、查询生成、答案检索等完整问答流程另附10个txt说明文档、7张图片以及配套前端样式与脚本方便对照理解与二次开发。当前已有701人学习下载。代码附带超详细中文注释与使用说明重点模块的图谱构建、问答主流程均清晰标注处理逻辑适合参考其图数据库建模思路和意图识别设计并可直接在此基础上扩展药品、疾病、症状等知识节点。1. 医疗知识图谱问答机器人先建图再让问句找到答案拿到这份 Python 基于 Neo4j 图数据库的医疗知识图谱智能问答机器人源码最容易犯的错误是一上来就研究问答代码。把问答拆开看七成工作量在建图三成在匹配真正跑通之后你会发现图建对了问答就成功了一半。它解决的是医疗信息查询里“回答可解释、可溯源”的问题——比如患者问“糖尿病能不能吃西瓜”系统不是甩出一段搜索摘录而是给出“糖尿病—禁忌食物—西瓜”这条关系路径答案自带依据。适合正在做知识图谱练手或毕设、想从普通增删改查转向图查询的 Python 开发者也适合医疗信息化场景做技术验证。下文按“建模→导入→问句解析→查询生成→排错→验证”的顺序把整条链路说透。2. 医疗知识图谱建模与 Neo4j 落地先把图建对再谈问答建图不是把数据塞进 Neo4j 就完事实体怎么分、关系朝哪个方向、别名怎么归一直接决定后面问答代码的复杂程度。顺序反了后面每写一个查询模板都要给前面的设计买单这是新手最容易翻车的地方。2.1 实体与关系怎么定按“用户会怎么问”取舍本体医疗知识图谱本体不需要做到教科书级别够用就好。常见的最小实体集是疾病 Disease、症状 Symptom、药品 Medicine、科室 Department、检查项目 CheckItem按业务需要再加食物 Food。每个实体至少带 name唯一、alias_list别名列表、desc简述三个属性来源字段看场景加。关系按“问句里会出现的主语和宾语”来定义方向统一才能让模板好写(Disease)-[:HAS_SYMPTOM]-(Symptom)症状寻因和疾病查症共用一条边(Medicine)-[:TREAT]-(Disease)治什么病(Medicine)-[:CONTRAINDICATED]-(Disease)禁忌必须单独建关系不能复用 TREAT 反向(Disease)-[:VISIT_DEPT]-(Department)挂什么科(Disease)-[:FORBID_FOOD]-(Food)忌口什么同义词归一化是医疗图谱特有的前置工作。“糖尿病”“2型糖尿病”“消渴症”在口语里可能指同一个病“拜糖平”和“阿卡波糖”是同一成分的不同商品名。常见做法是每个实体维护一个 alias 属性导入时把别名合并到节点上问答层统一查标准名匹配层负责把口语映射过去。这里有一个注意点关系方向全篇保持一致不要“药品治疾病”和“疾病被药品治”混着建否则 4.1 的模板会非常难写。属性取舍上只导查询和展示会用的字段别把整库数据搬进来。医疗数据源经常有大段的说明文本真需要全文展示就放 desc 字段其他中间字段一律不建索引减少导入时间和存储占用。2.2 数据导入的两条路线LOAD CSV 与 Python 驱动小规模数据几百到几万节点用 LOAD CSV 最省事在 Neo4j Browser 或 cypher-shell 里就能直接执行。先给两张表disease.csv 放节点字段relation.csv 放关系三元组。LOAD CSV WITH HEADERS FROM file:///disease.csv AS row MERGE (d:Disease {name: row.name}) SET d.alias row.alias, d.desc row.desc, d.source row.source; LOAD CSV WITH HEADERS FROM file:///relation.csv AS row MATCH (d:Disease {name: row.disease_name}) MATCH (s:Symptom {name: row.symptom_name}) MERGE (d)-[:HAS_SYMPTOM]-(s);逻辑说明LOAD CSV 是 Neo4j 内置的批量导入命令WITH HEADERS 让首行成为字段名MERGE 按 name 判重重复执行不会堆出重复节点第二条语句先定位两个端点再建关系避免用 CREATE 造成重复关系。文件默认放在 Neo4j 的 import 目录下file:/// 后面跟相对路径即可。参数说明文件编码必须 UTF-8Excel 另存的 CSV 大概率是 GBK直接导会乱码见 5.2name 字段建议用医疗标准术语别名放 alias 列MERGE 依赖唯一约束最好在建表前先加约束否则数据量上去后判重性能会退化。要走定时更新或和爬虫结果联动就用 Python 官方驱动做自动化导入from neo4j import GraphDatabase URI bolt://localhost:7687 AUTH (neo4j, password) class KGImporter: def __init__(self): self.driver GraphDatabase.driver(URI, authAUTH) def close(self): self.driver.close() def make_relation(self, med_name, disease_name): with self.driver.session() as session: session.run( MATCH (m:Medicine {name: $med}) MATCH (d:Disease {name: $dis}) MERGE (m)-[:TREAT]-(d) , medmed_name, disdisease_name, ) if __name__ __main__: imp KGImporter() imp.make_relation(阿卡波糖, 2型糖尿病) imp.close()逻辑说明neo4j 驱动是官方库用 $ 占位符传参避免字符串拼接session 内部自动管理事务每次 make_relation 做两次匹配、一次 MERGE适合增量修正某条关系。参数说明URI 里的 7687 是 Bolt 协议默认端口浏览器访问的 HTTP 端口是 7474两者别搞混AUTH 元组里是初始账号密码第一次启动 Neo4j 时会要求修改如果入库脚本跑得频繁可以给 driver 加 max_connection_pool_size 限制连接数防止连接池占满。2.3 建完图先验证三条查询确认结构与质量问答代码还没写先把图“验货”。下面三条 Cypher 从规模、路径、空结果三个角度检查。// 1. 统计各标签节点数量 MATCH (n) RETURN labels(n)[0] AS label, count(*) AS cnt ORDER BY cnt DESC; // 2. 抽查一条医疗路径糖尿病的症状 MATCH (d:Disease {name: 糖尿病})-[:HAS_SYMPTOM]-(s:Symptom) RETURN s.name LIMIT 10; // 3. 查糖尿病的禁忌食物返回空不一定错 MATCH (d:Disease {name: 糖尿病})-[:FORBID_FOOD]-(f:Food) RETURN f.name LIMIT 10;逻辑说明第一条确认导入没有漏表哪个标签数量异常一眼可见第二条验证关系方向如果这里返回空多半是导入脚本里把 HAS_SYMPTOM 建反了第三条空返回有两种可能图谱没这个关系或者问法本身在数据里就没有答案先别急着改代码。接下来建唯一约束防止后续导入脚本重复执行时节点翻倍CREATE CONSTRAINT ON (d:Disease) ASSERT d.name IS UNIQUE;参数说明这个约束语法在 Neo4j 3.x 和部分早期 4.x 版本可用新版改成了 CREATE CONSTRAINT FOR (d:Disease) REQUIRE d.name IS UNIQUE版本差异会在 5.1 展开讲。跑完约束后再用 Neo4j Browser 的可视化视图抽查两条带关系的路径箭头方向能直接暴露导入脚本里的反向错误这一步比看日志直观得多。3. 问句解析与实体识别把“糖尿病能不能吃西瓜”拆成能检索的零件问答链路里的黑匣子不是 Cypher而是“从一句话里找到实体并判断意图”。用户问“高血压能不能吃阿司匹林”句子里有两个实体系统得知道主实体是阿司匹林、意图是禁忌查询这步错了后面图再准也白搭。3.1 词典AC自动机先于NER模型的三条理由医疗问答场景里词典匹配通常比训练 NER 模型更划算。第一医疗实体几乎都是专有名词常见病、常用药几千条别名就能覆盖业务大头词典覆盖率足够高第二标注语料很贵医疗 NER 要处理超长实体和嵌套实体冷启动成本不是小项目能接受的第三NER 模型输出的是概率词典输出的是证据——命中哪个词、对应哪个标准名出了问题可以直接查。用 pyahocorasick 做多模式匹配一次遍历就能命中所有词典词条import ahocorasick import csv class MedicalNER: def __init__(self, dict_csv): self.auto ahocorasick.Automaton() with open(dict_csv, encodingutf-8) as f: for row in csv.DictReader(f): alias row[alias].strip() self.auto.add_word( alias, (len(alias), row[entity_type], row[std_name]) ) self.auto.make_automaton() def extract(self, question): hits [] for end_idx, (length, etype, std_name) in self.auto.iter(question): start_idx end_idx - length 1 hits.append((start_idx, length, etype, std_name)) hits.sort(keylambda x: (x[1], -x[0]), reverseTrue) return hits逻辑说明add_word 把每个别名加入自动机value 里存词长、实体类型、标准名extract 遍历问句拿到所有命中按“长词优先”排序排序后由主流程做重叠过滤保证“高血压”命中时不会同时保留“高血”这个残缺匹配。参数说明dict_csv 至少要有 alias、entity_type、std_name 三列别名一行一个如果同一个别名出现两次后写的会覆盖先写的导入词典前先做去重Aho-Corasick 对中文同样适用不需要额外分词但要注意别名里带括号或空格时匹配前最好把问句和词典做同样的归一化处理。3.2 意图分类先抓否定词再按关键词模板分层意图分类不需要上模型短文本里关键词互斥性很强规则模板足够稳。先把意图切成五类症状查病、疾病查药、药品禁忌、疾病查科室、药品用法。分类顺序很重要否定和禁忌意图必须最先判断。NEG_KEYWORDS [不能, 不可以, 禁忌, 忌, 禁用, 慎用] def classify(question, entities): # 否定/禁忌意图优先于治疗意图 for w in NEG_KEYWORDS: if w in question: return DRUG_CONTRADICTION if 挂什么科 in question or 挂科 in question or 科室 in question: return DISEASE_TO_DEPT if 症状 in question or 表现 in question or 什么病 in question: return SYMPTOM_TO_DISEASE if any(t Medicine for _, _, t, _ in entities): if 怎么用 in question or 怎么吃 in question or 剂量 in question: return DRUG_USAGE return DISEASE_TO_MEDICINE逻辑说明顺序即优先级禁忌 科室 症状 药品用法 默认治疗。先处理否定词防止“高血压不能吃什么药”落到治疗意图句子里带 Medicine 实体且出现用法词时走 DRUG_USAGE否则默认落到疾病治疗查询空结果由 4.2 的兜底逻辑处理。参数说明NEG_KEYWORDS 要按业务持续维护漏掉“忌口”比多写一个“不能”代价更大这类词宁可多写分类结果建议打进日志问答回放时能直接看出意图判断错在哪一步。多实体场景下还要定主实体一句里出现“高血压”和“阿司匹林”时意图是 DRUG_CONTRADICTION主实体取药品而非疾病因为禁忌关系的起点是药品。常见做法是定义各意图的主实体类型优先级比如含药品时优先选 Medicine否则取 Disease 或 Symptom这个逻辑在 4.1 的 pick_primary_entity 里落地。4. 从意图到 Cypher 再到答案模板映射与结果兜底意图和实体都齐了下一步是把它们翻译成图查询。这一步最容易写出“能跑但不敢上线”的代码——字符串拼 Cypher、不设 LIMIT、空结果直接抛异常都是初学者常踩的坑。4.1 意图模板表与参数化查询别再拼字符串每个意图对应一个 Cypher 模板$entity 指的是标准化后的实体名也就是 3.1 里 std_name 字段的值。初版模板保持一跳关系就够扩展多跳留给后面的优化阶段。意图问句示例Cypher 模板返回SYMPTOM_TO_DISEASE头晕伴多饮是什么病MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom) WHERE s.name $entity RETURN d.name, d.desc最多 10 种疾病及简介DISEASE_TO_MEDICINE2型糖尿病吃什么药MATCH (m:Medicine)-[:TREAT]-(d:Disease) WHERE d.name $entity RETURN m.name最多 10 种药品DRUG_CONTRADICTION高血压能吃阿司匹林吗MATCH (m:Medicine {name:$entity})-[:CONTRAINDICATED]-(d:Disease) RETURN d.name禁忌疾病列表DISEASE_TO_DEPT心悸挂什么科MATCH (d:Disease {name:$entity})-[:VISIT_DEPT]-(dept:Department) RETURN dept.name科室名称DRUG_USAGE阿卡波糖怎么吃MATCH (m:Medicine {name:$entity}) RETURN m.usage, m.dosage用法用量Python 端把模板存成字典执行时统一走参数化查询TEMPLATES { SYMPTOM_TO_DISEASE: ( MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom) WHERE s.name $entity RETURN d.name AS name, d.desc AS desc LIMIT 10 ), DISEASE_TO_MEDICINE: ( MATCH (m:Medicine)-[:TREAT]-(d:Disease) WHERE d.name $entity RETURN m.name AS name LIMIT 10 ), DRUG_CONTRADICTION: ( MATCH (m:Medicine {name:$entity})-[:CONTRAINDICATED]-(d:Disease) RETURN d.name AS name LIMIT 10 ), } def query_answer(question, entities, intent): std_name pick_primary_entity(entities, intent) if not std_name: return [] cypher TEMPLATES[intent] with driver.session() as session: result session.run(cypher, entitystd_name) return list(result)逻辑说明所有实体值全部走 $ 占位符由驱动安全序列化药品名里出现引号或百分号也不会破坏查询结构这是参数化查询对字符串拼接的降维打击query_answer 按意图选模板取主实体后执行主实体为空时直接返回不发 Cypher。参数说明LIMIT 统一控制在 10防止把几千条关系一次拉回RETURN 里的 AS 别名方便后续按字段名取值pick_primary_entity 的规则在 3.2 讲过药品优先其次疾病最后症状意图不同优先级也不同。4.2 三态结果与答案包装有、无、太多查询结果只有三种情况分别处理才是完整问答。有结果时做格式化输出无结果时先区分“图谱缺数据”还是“实体识别错”结果太多时截断展示并给用户追问空间。MAX_DISPLAY 5 def render(records, intent): if not records: return 记录里暂时查不到这个答案换个说法或检查名称后重试。 names [r[name] for r in records[:MAX_DISPLAY]] text 、.join(names) if len(records) MAX_DISPLAY: text f 等{len(records)}种 return text 。以上结果仅供查询参考具体以临床医生意见为准。逻辑说明展示层面截断到 5 条多余的用总数说明避免答案列表失控医疗问答必须带免责后缀这是底线不能省。参数说明MAX_DISPLAY 设 5 是因为中文列表一行放 3 到 4 项5 条差不多一屏如果一条查询经常返回 10 条以上说明图的连接太密应该从模板侧收敛而不是在展示侧硬撑。无结果的排查要可操作拿 std_name 再发一条MATCH (x) WHERE x.name $entity RETURN labels(x)如果节点不存在说明词典和图的实体没有对齐如果节点存在但关系为空说明模板写的路径或关系方向有问题。这个判断逻辑可以直接写进问答日志作为诊断字段排错时少走弯路。5. 医疗问答项目的五个常见问题与排查记录版本、编码与实体命中这一章是给同类项目排障后整理的踩坑记录每一条都按“现象 → 原因 → 解决”写你能直接对照自己的报错信息定位。5.1 Neo4j 版本不一致3.5 的写法在 5.x 里报错现象不少这类项目把 Neo4j 锁在 3.5 版本而你机器上新装的是桌面版或社区版 5.x。按照旧说明执行 LOAD CSV 或创建约束时直接报语法错误提示 unknown function 或 procedure not found。原因Neo4j 跨大版本语法不兼容。3.x 时代的CREATE CONSTRAINT ON (d:Disease) ASSERT d.name IS UNIQUE在 4.x 后改成了CREATE CONSTRAINT FOR (d:Disease) REQUIRE d.name IS UNIQUE另外 3.5 时代有些脚本依赖 APOC 扩展存储过程新版不会随安装包附带需要单独装。解决锁版本是最省事的路径。Neo4j 官方 Archives 页面能选历史版本Docker 镜像也保留着对应 tag按项目说明的版本部署就不会有语法迁移问题。验证型项目不值得把时间耗在升级语法上这是踩过之后才想明白的后悔药。如果非要跑新版把查询里的约束语法、存储过程调用统一按新版改写并确认 APOC 是否已安装。5.2 CSV 中文变乱码Excel 保存的 GBK 与 Neo4j 的 UTF-8现象LOAD CSV 完成后中文名称全是方块或乱码按中文名查询关系全部落空。原因Excel 另存的 CSV 默认 ANSI/GBK 编码Neo4j 按 UTF-8 读取字节错位导致乱码。解决导入前统一转成 UTF-8用 Python 几行代码搞定。with open(disease_gbk.csv, encodinggbk) as f: text f.read() with open(disease_utf8.csv, w, encodingutf-8, newline) as f: f.write(text)逻辑说明先用 gbk 把 Excel 导出的文件读回内存再按 utf-8 写新文件顺手把表头也一起规范一下。不要在 Neo4j 端硬解编码导入层统一最省心。参数说明newline 防止 Windows 下写入时空行被插入如果源文件本身就是 UTF-8用 errorsreplace 快速定位坏字节比逐行猜编码更高效。5.3 实体识别命中“高血”而不是“高血压”现象问“高血压注意什么”实体列表里出现“高血”或“血压”这种子串按错误实体进图查询返回空结果。原因词典里多个别名存在前缀重叠匹配逻辑只做了单词命中没有做最长匹配优先。解决在 3.1 的 extract 返回后加一个重叠过滤排序后从左往右扫重叠区间只保留最长的词条。best, last_end [], 0 for start, length, etype, std_name in hits: if start last_end: continue best.append((std_name, etype)) last_end start length逻辑说明按长词优先排序后取第一个未重叠的命中后面与区间重叠的字段全部跳过保证“高血压”覆盖“高血”。参数说明last_end 随采纳词条的结束位置更新漏掉这一步会出现间隔性跳过错误比如两个相邻实体只保留第一个。5.4 一次问答返回上万条路径图查询没有 LIMIT现象单个问题响应时间超过两秒Neo4j Browser 可视化卡死日志里频繁出现查询超时告警。原因模板里漏写 LIMIT数据库把相关路径全部展开多跳查询更明显路径数呈指数级增长。解决所有模板统一加 LIMIT这是 4.1 已经做好的事Neo4j 配置里打开事务超时常见做法是把 dbms.transaction.timeout 设为 5sPython 驱动连接池上限调小GraphDatabase.driver 加 max_connection_pool_size8。多跳查询确实需要时先用 PROFILE 看 Expand 数量是否异常再决定要不要加索引。5.5 重复导入让节点翻倍MERGE 和唯一约束是两件套现象定时导入脚本每次运行后疾病节点数量翻倍同名节点散落多处关系也成对重复。原因导入了 CREATE 而不是 MERGE或者用了 MERGE 但没建唯一约束并发环境下仍可能产生重复。解决导入语句统一用 MERGE对 name 建唯一约束语法按 5.1 的版本差异来选管道跑完做一次幂等校验。MATCH (d:Disease) WITH d.name AS n, count(*) AS c WHERE c 1 RETURN n, c;逻辑说明重复计数是检验导入脚本是否健壮的第一道门返回空说明节点唯一性没问题注意唯一约束建立后更新实体用 SET 改属性不要删了重建否则挂在旧节点上的关系会全部断开。6. 用验证集把问答从“能跑”推到“可信”回放日志与三个扩展判断一套问答系统能不能用标准不是几个测试问句答得好而是能稳定知道“什么时候答不出”。我一般先准备 50 到 100 条问答对覆盖五个意图和边界场景比如否定问法、双实体问法、空结果问法。标好 expect 列后用评估脚本批量跑正确率、兜底率、错答分布一次算清。import csv import logging def evaluate(qa_csv, answer_fn): ok 0 for row in csv.DictReader(open(qa_csv, encodingutf-8)): expect row[expect] got answer_fn(row[question]) if expect in got: ok 1 logging.info(PASS %s, row[question]) else: logging.warning(FAIL q%s expect%s got%s, row[question], expect, got) return ok逻辑说明用隶属判断而不是全等判断因为回答是列表拼接文本标准答案只写关键实体或短语即可logging 同时记录失败样本方便反查是哪一环出了问题。回放日志是迭代的核心工具。每一条问答把 question、intent、std_name、返回条数、耗时写一行每周统计兜底率。兜底率超过三成优先回看是词典缺词还是模板缺类型2.1 里提到的同义词问题会在回放日志里直接暴露成一批“查不到”的 query按日志补词条比凭空想快得多。三个扩展方向按性价比排序第一个是把实体识别接上大模型做增强词典保底处理高频大模型处理长尾口语但知识结果仍以图数据库为准不要让大模型直接生成三元组入库医疗场景经不起幻觉第二个是做多轮追问拿到 TOP5 疾病后反问症状用追加实体收窄结果这是单轮问答走向多轮对话的关键路径第三个是增量更新管道从权威药品说明书和临床指南里结构化抽取新增药品与禁忌定期导入配合 5.5 的幂等校验保证数据不翻倍。这个项目我做得不算顺利最初把大量时间压在 NER 模型选型上最后发现把词典、模板和回放日志当成一个闭环来迭代效果提升反而最快。工具会过时图谱加日志的迭代习惯不会过时。希望帮到你。本文还有配套的精品资源点击获取