ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Neo4j与Python的医疗知识图谱问答系统构建实战

基于Neo4j与Python的医疗知识图谱问答系统构建实战 简介这是一套以Python知识图谱技术为核心的医疗领域问答系统毕业设计资源面向计算机相关专业准备做毕业设计、课程大作业以及需要实战练习的学习者旨在解决从零搭建垂直领域问答系统的数据建模、知识抽取与检索链路等问题。资源共包含一千四百零三个文件压缩包约三十八点七二兆字节源码以Python与Java混编为主另有大量XML配置文件、JSON数据、CSV语料、H5模型及Markdown文档涵盖代码、数据、论文资料、项目配置与说明文档等完整结构。项目曾获导师认可与评审高分源码经过本地编译调试可运行且难度适中、目录清晰便于快速定位需求模块。目前已有一百余人学习参考适合用来参照完整实现思路、替换数据集或二次开发。整体内容既包含可运行的问答引擎代码也附带论文与数据支撑可帮助理解医疗知识图谱构建及问答交互全流程。1. 医疗知识图谱问答系统一个能跑通全链路且有答辩底气的毕设选题很多同学一听“基于Python知识图谱的医疗领域问答系统”第一反应是这东西很玄既要建图谱又要做问答还要写论文怕自己hold不住。实际上你把它拆开看就是三件事把医疗数据存进Neo4j图数据库把用户问句翻译成图查询再把查询结果拼成人话。围绕这套逻辑整个系统从数据到界面是一条能讲完整的链路。这份毕业设计资源包含完整可运行的Python源码、已经整理好的医疗实体与关系数据、配套论文资料评审拿到了98分源码在本地编译调试过可以直接跑。它适合三类人正在开题、想选一个有算法含量又不至于失控题目的计算机专业学生只会做单机管理系统、想往知识图谱方向跳一步的练习者以及后续想参加知识图谱比赛、需要一份基线系统的同学。下面按我复现这套资源的顺序把每个环节实际怎么搭、参数怎么设、坑在哪一次说清楚。2. 构建医疗知识图谱实体关系建模与Neo4j导入脚本医疗问答要回答“高血压有什么症状”“高血压挂什么科”本质是查实体的关联路径。关系型数据库也能做但要表达“疾病-症状-药物”这种多跳关系图数据库在查询表达上直观得多Neo4j社区版免费、Cypher上手快、自带Browser可视化界面做毕设演示再合适不过。构建这一步决定后面问答能不能查到东西我建议按“先看数据格式再写导入脚本最后定schema”的顺序来做。2.1 数据准备实体表、关系表与字段约定拿到资源包后先打开data目录核心是两张CSV表。实体表描述有哪些节点关系表描述节点之间怎么连文件关键字段示例entity.csvname, label高血压, Disease头晕, Symptom硝苯地平, Drugrelation.csvhead_name, head_label, relation, tail_name, tail_label高血压, Disease, HAS_SYMPTOM, 头晕, Symptomhead_name是头实体tail_name是尾实体relation是关系类型。这两张表就是整个知识图谱的三元组基础也是后面所有查询模板的判据。label字段我建议控制在6类以内Disease、Symptom、Drug、Department、Check、Food。这个分类能覆盖医疗问答90%以上的常见问法。label分多不是不行但每多一类问答规则表就要多配一组关键词和一条Cypher模板工作量和bug概率都翻倍。资源包的数据体量在千级实体、万级关系以内一台普通笔记本跑起来毫无压力完全不需要分布式。提示我拿到数据的第一件事是做实体名去重。同一实体在不同来源里写法可能不一样比如“高血压病”和“高血压”必须统一成标准名否则后面问答会莫名其妙返回空结果。2.2 用py2neo把CSV数据写进Neo4j写脚本前先确认Neo4j已经启动浏览器能打开http://localhost:7474管理界面。下面的脚本放到项目根目录改两个地方就能跑Neo4j密码、CSV路径。# create_graph.py —— 读取 CSV 写入 Neo4j支持重复执行 import csv from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, 你的密码)) def clear_graph(): graph.run(MATCH (n) DETACH DELETE n) print(历史数据已清空) def create_nodes(): with open(data/entity.csv, encodingutf-8) as f: for row in csv.DictReader(f): node Node(Entity, namerow[name], labelrow[label]) graph.merge(node, Entity, name) def create_relations(): batch [] with open(data/relation.csv, encodingutf-8) as f: for row in csv.DictReader(f): h graph.nodes.match(Entity, namerow[head_name]).first() t graph.nodes.match(Entity, namerow[tail_name]).first() if h is None or t is None: print(f跳过缺失实体: {row[head_name]} - {row[tail_name]}) continue batch.append(Relationship(h, row[relation], t)) tx graph.begin() for rel in batch: tx.create(rel) tx.commit() print(关系写入完成:, len(batch)) if __name__ __main__: clear_graph() create_nodes() create_relations()代码逻辑分三步清空旧图保证重复执行脚本不会产生两份一样的节点建节点时用graph.merge而不是graph.createmerge按第三个参数“name”去重这是可重复执行的关键建关系时把所有关系放进一个事务统一提交速度远快于逐条create。连接参数里bolt://localhost:7687是Neo4j的Bolt协议默认端口管理页面走HTTP 7474两者协议不同别混用。auth里的用户名默认是neo4j密码是安装时自己设的改成你自己的。如果你的Neo4j是4.x且只有一个数据库不需要额外指定库名如果开的默认库被改过可以在Graph()里加一个nameneo4j参数指定。2.3 Schema设计统一Entity节点与关系方向约束这里有一个设计取舍我没有把疾病、症状、药物分别建成Disease、Symptom、Drug节点类型而是统一放进Entity节点用label属性区分。数据量在千级时这种单节点类型的建模方式让导入脚本和查询模板都少一半代码查询时用WHERE n.labelDisease过滤即可。如果你想看起来更“学术”分成多类型节点也行但每个类型都要单独写导入语句后面每类查询都要写明节点类型工作量会明显增加。比节点类型更要紧的是关系方向。导入脚本里固定写成(疾病)-[关系]-(症状)后面的查询模板必须沿用这个方向。比如“高血压有什么症状”对应的Cypher是(高血压)-[:HAS_SYMPTOM]-(症状)反着写就查不到。方向约定这种事看起来小实际是查询模板和数据一致的命门建议把关系方向写进项目README防止三天后自己都忘。给name建唯一约束和索引也建议在导入数据后立刻执行否则实体匹配每次都是全图扫描数据涨到几万条以后问答响应会明显变慢CREATE CONSTRAINT entity_name_unique IF NOT EXISTS FOR (n:Entity) REQUIRE n.name IS UNIQUE;这段是Neo4j 5.x的写法。用的4.x版本要把第二行换成FOR (n:Entity) ASSERT n.name IS UNIQUE;语法略有差异跑之前先确认自己的版本这也是资源包里常被问到的第一个区别。3. 问答核心链路实体识别、意图分类与Cypher模板生成图谱建好之后问答主体就是三个环节认出问句里的疾病实体判断用户要问哪类关系生成Cypher查图拼答案。这套“规则词典”路线不需要训练模型但效果足够支撑毕设演示在几百条测试问句上命中率能到80%以上而且每个环节都能在答辩时讲得清清楚楚。相比端到端的深度学习模型这种可解释的链路反而更适合本科毕设。3.1 实体识别最长匹配jieba兜底实体识别最稳妥的方式是词典匹配。先把实体名全部加入jieba自定义词典再做两层匹配。# question_parser.py —— 实体识别与意图分类 import jieba class QuestionParser: def __init__(self, entity_list): self.entity_set set(entity_list) for word in self.entity_set: jieba.add_word(word) def extract_entity(self, question): # 第一层直接命中问句中出现的实体名取最长 hits [w for w in self.entity_set if w in question] if hits: return max(hits, keylen) # 第二层jieba 分词后逐个比对兜底 words jieba.lcut(question) for w in words: if w in self.entity_set: return w return None逻辑上先做子串匹配再从命中的候选里取最长的比如“高血压”和“高血压病”同时存在时问句“高血压病吃什么药”应该命中“高血压病”。第一层没命中再用jieba分词后逐词比对兼容“我得了高血压怎么办”这种带口语前缀的问法。entity_list可以直接从entity.csv里读name列生成注意实体量到几千时逐个jieba.add_word会拖慢启动可以用jieba.load_userdict(path)一次加载文件格式是“词语 词频 词性”每行一个。3.2 意图分类关键词规则表与顺序陷阱意图分类决定走哪条查询关系六类意图的规则如下顺序是设计过的意图触发关键词对应问法HAS_SYMPTOM症状、表现、有什么症高血压有什么症状TREAT_DRUG药、吃什么、治疗、怎么治高血压吃什么药CHECK检查、确诊、做什么检查高血压要做什么检查DEPARTMENT挂什么科、哪个科、挂号高血压挂什么科EAT_GOOD吃什么好、能吃、饮食高血压吃什么好CAUSE原因、引起、病因高血压是什么原因引起的def classify_intent(self, question): # 规则按优先级排列互斥关键词要放在前面 rules [ (HAS_SYMPTOM, [症状, 表现, 有什么症]), (TREAT_DRUG, [药, 吃什么, 治疗, 怎么治]), (CHECK, [检查, 确诊, 做什么检查]), (DEPARTMENT, [挂什么科, 哪个科, 挂号]), (EAT_GOOD, [吃什么好, 能吃, 饮食]), (CAUSE, [原因, 引起, 病因]), ] for intent, kws in rules: if any(kw in question for kw in kws): return intent return UNKNOWN这里有一个很容易翻车的顺序陷阱TREAT_DRUG里有“吃什么”EAT_GOOD里也有“吃什么好”。“高血压吃什么药”如果先走EAT_GOOD就会答成饮食建议。所以规则列表必须按优先级排更具体、更长的关键词先命中这是我在第一次跑通后吃了一次亏才加上的注释。返回UNKNOWN的情况要在后面兜底处理否则会拿空串去查图。3.3 生成Cypher并返回答案意图和实体都拿到后组装查询模板。# answer_search.py —— 查询 Neo4j 并整理答案 class AnswerSearcher: def __init__(self, graph): self.graph graph def search(self, intent, entity): query_map { HAS_SYMPTOM: ( MATCH (d:Entity {name: $entity}) -[:HAS_SYMPTOM]-(s:Entity) RETURN s.name AS name ), TREAT_DRUG: ( MATCH (d:Entity {name: $entity}) -[:TREAT_DRUG]-(dr:Entity) RETURN dr.name AS name ), DEPARTMENT: ( MATCH (d:Entity {name: $entity}) -[:DEPARTMENT]-(dept:Entity) RETURN dept.name AS name ), CHECK: ( MATCH (d:Entity {name: $entity}) -[:CHECK]-(c:Entity) RETURN c.name AS name ), EAT_GOOD: ( MATCH (d:Entity {name: $entity}) -[:EAT_GOOD]-(f:Entity) RETURN f.name AS name ), CAUSE: ( MATCH (d:Entity {name: $entity}) -[:CAUSE]-(caus:Entity) RETURN caus.name AS name ), } cypher query_map.get(intent, ) if not cypher: return [] records self.graph.run(cypher, entityentity).data() return [r[name] for r in records]每条Cypher都遵循“疾病实体-关系-目标实体”的方向用$entity做参数化查询不手工拼字符串。这样既规避了实体名里带引号之类的转义问题查询性能也会更好。返回的name列表就是答案候选比如“高血压有什么症状”返回的是“头晕、头痛”这一串症状名。答案生成函数把整个流程串起来作为后面Flask接口和评估脚本共用的入口def chat(question): entity parser.extract_entity(question) if entity is None: return 没识别到疾病实体请带上疾病名称再问一次比如“高血压有什么症状” intent parser.classify_intent(question) if intent UNKNOWN: return 这个问题我暂时没法归类换个问法试试 answers searcher.search(intent, entity) if not answers: return f知识库里暂时没有“{entity}”相关的“{intent}”类答案 unique_answers list(dict.fromkeys(answers))[:5] return f关于“{entity}”{、.join(unique_answers)}dict.fromkeys在保持原顺序的同时去重截取前5条避免一长串答案刷屏。一个疾病的症状可能有十几个全列出来用户记不住演示效果也差。这个函数的返回值就是最终展示给用户的文本格式可以根据自己论文里的用例设计微调。4. 把问答链路包成Web服务Flask接口与前端问答页核心链路跑通之后答辩演示还差最后一环让用户在浏览器里输入问题、拿到答案。资源包里的Flask工程把所有逻辑串成一个可访问的Web服务开本地服务就能现场演示不需要部署到公网服务器。这一章的重点不是前端花样而是接口约定和调试手段。4.1 Flask路由与接口返回值约定# app.py —— Flask 问答服务入口 from flask import Flask, request, jsonify from qa_system import chat # 第 3 章的 chat 函数 app Flask(__name__) app.route(/api/chat, methods[POST]) def api_chat(): data request.get_json(forceTrue) question data.get(question, ).strip() if not question: return jsonify({code: 400, message: 问题不能为空}) try: answer chat(question) return jsonify({code: 0, message: ok, data: {question: question, answer: answer}}) except Exception as exc: return jsonify({code: 500, message: str(exc)}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)接口约定是客户端POST一个JSON带question字段返回code为0表示成功data.answer是答案文本。这个结构是前端页面和后续评估脚本共同依赖的协议字段名不要随意改。debugTrue只在开发调试时开答辩现场演示建议关掉否则异常堆栈会直接打到页面上观感很差。host设成0.0.0.0是为了同一局域网内可以用另一台设备访问答辩时如果连接的是投影电脑直接访问笔记本IP加端口就能演示而不必局限于本机localhost。端口5000被占用时改成5001前端页面里的请求地址要同步改。服务启动后先在浏览器访问http://localhost:5000确认页面能打开再用curl模拟一次请求验证后端联通curl -X POST http://localhost:5000/api/chat -H Content-Type: application/json -d {question:高血压有什么症状}curl返回的JSON里能看到答案列表这一步是检查“前端-后端-Neo4j”三层联通的最快路径。curl通但页面不通问题出在前端curl返回500回去看后端日志里实体识别、意图分类哪一步断了。按这个顺序定位比在浏览器里反复刷新高效得多。4.2 前端问答页与链路调试面板资源包templates目录下有一个单页前端核心逻辑就是发POST拿答案渲染到消息区!-- templates/index.html -- !DOCTYPE html html langzh head meta charsetutf-8 title医疗知识图谱问答/title /head body div idchat-box/div div input idq placeholder例如高血压有什么症状 stylewidth:300px button onclicksend()发送/button /div script function send() { const question document.getElementById(q).value.trim(); const box document.getElementById(chat-box); fetch(/api/chat, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({question: question}) }).then(res res.json()).then(data { box.innerHTML pb问/b question /p; box.innerHTML pb答/b (data.data ? data.data.answer : data.message) /p; }); } /script /body /html页面只有输入框、发送按钮、消息列表三样东西。fetch用POST打后端接口拿到JSON后拼HTML插入消息区。innerHTML直接拼字符串在本地演示没问题后续要挂公网展示的话建议改成textContent赋值避免把HTML标签当脚本注入。想看每次请求的完整返回打开浏览器开发者工具的Network面板即可。调试时建议在Flask路由里临时加几行日志打印“问句、实体、意图、答案”。答辩演示到一半出问题时能直接定位是实体识别挂了还是Cypher方向写反不用对着黑色终端猜if app.debug: print(问句:, question) print(实体:, parser.extract_entity(question)) print(意图:, parser.classify_intent(question)) print(答案:, answer)这四行日志价值很高下一章你会看到问答系统现场翻车基本都集中在这几个判断环节。我在自己跑通这套系统的过程中超过一半的排查时间都花在区分“实体没识别出来”和“实体识别对了但查询方向错”这两种情况上日志能直接给出答案。5. 常见问题与排查五类现场翻车记录与对应解法这套系统我在不同环境跑过多次把复现过程中踩到频率最高的五个问题列在这里每条按“现象、原因、解决”写按出现概率排序。遇到报错先别怀疑自己的代码逻辑大概率是环境、数据或方向问题。下面五条能覆盖我实际遇到过九成以上的故障场景。5.1 Neo4j连不上服务没起、端口写错、密码过期现象py2neo或Flask启动时报Failed to establish connection或者ServerDiscoveryError: Unable to retrieve routing information浏览器也打不开7474页面。原因最常见的是Neo4j服务根本没启动其次是连接串写错比如用http协议连Bolt端口或者忘写端口号还有一种情况是密码在Neo4j管理界面改过代码里还是旧密码Bolt连接会直接拒绝认证。解决先确认服务状态Linux环境到Neo4j安装目录bin下执行neo4j start新版本可以用neo4j-admin server status查看。连接串统一写成bolt://localhost:7687与Browser页面的http://localhost:7474是两个协议、两个端口不要混。密码不记得就先停服务、临时去掉认证配置启动进去改完密码再恢复。5.2 LOAD CSV找不到文件import目录与路径写法现象执行LOAD CSV WITH HEADERS FROM file:///entity.csv报Couldnt load the external resource at: file:///entity.csv。原因Neo4j社区版对LOAD CSV做了目录白名单限制只允许读取数据库import目录下的文件路径直接用Python相对路径是读不到的Windows和Linux对斜杠的解析还不完全一样。解决把CSV放进{Neo4j安装目录}/import/entity.csv路径用file:///entity.csv三个斜杠。如果不想折腾目录权限就改用我推荐的做法在Python脚本里读CSV文件再通过py2neo写入Neo4j完全绕开LOAD CSV的路径限制。5.3 实体识别老出错分词切碎、编码错乱现象问“高血压病患者应该注意什么”实体匹配成“高血”或者压根识别不到还有的问句能识别但答案返回乱码。原因实体词典没加进jieba自定义词典“高血压病患者”被切成了“高血压/病/患者”实体匹配自然命中不了另一种高频原因是CSV在Windows记事本里保存成了GBK编码Python按utf-8读出来全是乱码名称对不上怎么匹配都失败。解决读CSV时统一用encodingutf-8-sig或者保存时明确选UTF-8把实体名全部加入jieba词典并在实体识别函数里坚持“先最长匹配→再分词兜底”的顺序。实体量到几千个时逐个add_word会让启动变慢改用jieba.load_userdict()一次性加载即可。5.4 实体识别正常但查询恒为空关系方向是重灾区现象日志里能看到实体“高血压”和意图HAS_SYMPTOM但答案列表返回空换其他疾病也是一样。原因几乎都是关系方向写反或者某类关系在导入时漏了。比如数据导入时建的是(疾病)-[:HAS_SYMPTOM]-(症状)查询模板却写成(疾病)-[:HAS_SYMPTOM]-(症状)Cypher关系是单向的反了就什么都没有。另一种情况是关系类型大小写不一致HAS_SYMPTOM和has_symptom是两个完全不同的类型。解决在Neo4j Browser里执行一条探查语句MATCH (n:Entity {name:高血压})-[r]-(m:Entity) RETURN type(r), m.name看这个实体实际有哪些出边关系再对照查询模板里的方向。这一条能解决七成“查询为空”的问题排查速度远快于反复改代码重启。5.5 py2neo版本抽风API差异与固定版本安装现象from py2neo import Graph直接报ImportError或者Graph.run()返回的对象取不出字段还有人遇到py2neo.database.Graph不存在。原因py2neo 7.x和5.x的API差异很大网上大量老教程用的是py2neo.database.Graph的写法新版早就拆掉了。随手pip install安装的是最新版跟资源包里基于旧版写的代码对不上。解决严格按资源包里的requirements.txt安装依赖不要自己装最新版。已经装错的先pip uninstall py2neo再按固定版本装比如pip install py2neo7.0.24并且所有导入统一写成from py2neo import Graph取结果统一用graph.run(...).data()。排查顺序其实有规律先看Neo4j服务通不通再看实体识别准不准最后查Cypher方向。我后来把顺序固定成一条判断链日志能打出实体、打不出意图去查规则表实体正常但查询为空去Browser里查边前端不通先跑curl。按这套顺序走整个系统极少有超过十分钟定位不到的问题答辩现场也不会因为一个低级错误卡住。6. 进阶玩法给问答系统加评估脚本与可扩展方向跑通基本问答之后答辩时最怕被问“你怎么证明你的系统有效”。与其说一堆设计理念不如直接跑一个最小评估脚本用数据说话。这一步是拉开差距的关键很多答辩拿高分的设计都做了这个工作。6.1 做一个最小的问答对测试集手工标注30到50个问答对预期答案用“包含目标实体”而不是“完全相等”来判断因为一个疾病有多个症状返回顺序每次也不固定# evaluate.py —— 最小评估脚本 test_pairs [ (高血压有什么症状, 头晕), (高血压吃什么药, 硝苯地平), (高血压挂什么科, 心内科), ] def evaluate(test_pairs): total len(test_pairs) hit 0 for question, expected in test_pairs: answer chat(question) if expected in answer: hit 1 else: print(未命中:, question, -, answer) print(f整体命中率: {hit}/{total} {hit / total:.2%})命中率统计出来之后还要记录失败样本集中在哪一类。如果都是实体没进词典那说明数据覆盖问题如果都是意图分类混乱那要调规则表优先级。把这两类失败原因写进论文实验部分比只报一个准确率数字可信得多。6.2 两个低成本扩展方向规则替换与多跳查询扩展方向一是把意图分类从纯关键词规则换成BERT文本分类用transformers库微调一个小模型训练数据用测试集加上更多人工标注问句。换完之后意图判断会更鲁棒但答辩时要把规则版和模型版的对比数据都列出来说明为什么值得付出训练成本。扩展方向二是把单跳查询改成多跳比如“高血压不能和什么药一起吃”需要先查高血压关联药物再查药物之间的相互作用。图谱里多建一个关系类型查询模板里连写两条MATCH就能支持展示效果比单跳问题更显得系统有深度。我自己第一次做评估时犯过一个错测试集写得过于“整齐”全部是词典里出现过的疾病名结果命中率99%显得像假的。从那以后我每次改动数据文件都强制走一遍实体覆盖率检查——统计测试集里有多少实体不在当前词典中、有多少关系类型被实际命中并把覆盖率数字写进论文实验部分。这个过程很普通但能提前发现数据漏导入、关系方向不统一这类隐藏问题。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进