简介:面向毕业设计的Python知识图谱医疗问答系统完整项目包,适合计算机相关专业学生及初级开发者作为课程设计或毕设参考。项目采用B/S访问结构,基于Django框架与MySQL数据库构建,覆盖管理员登录、后台首页、医疗问答、问答管理、修改密码、个人信息等核心功能模块,同时包含知识图谱相关数据与逻辑实现。压缩包共1208个文件,约183.55MB,以js、css、png、gif等前端资源为主,同时含jar、py、pyc等后端程序文件,以及sql数据库脚本、说明文档等,便于直接部署与二次开发。目前已有1259人学习使用,适合需要快速理解系统架构与功能实现的学习者。资源附带数据库文件和使用说明,可帮助梳理E-R图、系统流程及数据库设计思路,对完成毕业设计文档和答辩准备有较高参考价值。
1. 一个 Python 知识图谱医疗问答系统,核心是“图”不是“搜”
毕业设计做医疗问答系统时,最容易走偏的方向是把“百度式全文检索”当问答:问题来了先分词,再从文档库里挑一个相似段落返回。基于知识图谱的医疗问答系统走的完全是另一条路——先把疾病、症状、科室、药物这些实体和它们的关系建成图,再把用户问题解析成“某个疾病有哪些症状”“头痛应该挂什么科”这样的结构化查询,答案是从图数据库里按关系检索出来的。这样做的优点是答案可解释、可控,每个回答都能追溯到一条明确的实体关系链,适合在答辩现场演示。对具备 Python 基础、想同时练数据建模、图数据库(Neo4j)和 Web 接口的读者来说,源码、数据库与说明文档三件套正好覆盖毕设交付要求。
2. 医疗问答系统的知识图谱构建与数据库落库:设计好三元组再写 Python
2.1 医疗知识图谱的节点与关系:从问答意图倒推
知识图谱构建的第一步不是写导入脚本,而是先列“系统到底能回答哪些问题”。以常见医疗问答系统为例,我把核心问题收敛成五类:症状查疾病、疾病查症状、疾病查科室、疾病查药物、疾病查检查项目,再额外加一个饮食禁忌。这五类问题直接决定了图里需要哪些节点和关系。
我一般把节点控制在六个以内:Disease(疾病)、Symptom(症状)、Department(科室)、Drug(药物)、Check(检查项目)、Food(食物)。节点的属性不要贪多,Disease上保留name、icd、desc三个就够,其他信息后续需要再补。
| 节点类型 | 关键属性 | 典型例子 |
|---|---|---|
| Disease | name, icd, desc | 高血压、糖尿病、感冒 |
| Symptom | name | 头痛、头晕、多尿 |
| Department | name | 神经内科、内分泌科 |
| Drug | name | 二甲双胍、布洛芬 |
| Check | name | 头颅CT、空腹血糖 |
| Food | name | 动物内脏、苹果 |
关系设计要能直接对上问句模板,否则后面写 Cypher 时会频繁返工。我常用的六条关系如下:
| 关系 | 起点 -> 终点 | 支撑的问句 |
|---|---|---|
| HAS_SYMPTOM | Disease -> Symptom | 糖尿病有什么症状? |
| BELONG_TO | Disease -> Department | 头痛挂什么科? |
| DRUG_FOR | Disease -> Drug | 感冒吃什么药? |
| NEED_CHECK | Disease -> Check | 高血压要做什么检查? |
| GOOD_FOR | Disease -> Food | 高血压适合吃什么? |
| BAD_FOR | Disease -> Food | 高血压不能吃什么? |
图里不要放对问答无关的关系,比如“Drug 属于哪个厂家”。每多一种关系,意图识别和 Cypher 生成就得多维护一套映射。数据量不重要,重要的是每条关系都能被某个问句驱动,这样答辩时老师随便挑一条边,你都能解释它的业务含义。
2.2 用 Python 写 init_db 脚本:MERGE 保证可重复执行
数据一般来自公开医疗网站或现成 CSV,清洗后需要导入 Neo4j。毕业设计里我通常会准备三个 CSV:disease.csv、symptom.csv(包含disease和symptom两列)、relation.csv(包含disease、target、relation_type三列)。导入脚本用 Neo4j 官方 Python 驱动:
# init_db.py 节选 import csv from neo4j import GraphDatabase driver = GraphDatabase.driver( "bolt://localhost:7687", auth=("neo4j", "your-password") ) def import_disease(session, path): with open(path, encoding="utf-8-sig") as f: for row in csv.DictReader(f): session.run( """ MERGE (d:Disease {name: $name}) ON CREATE SET d.icd = $icd, d.desc = $desc """, name=row["name"], icd=row["icd"], desc=row.get("desc", "") ) def import_relation(session, path): relation_cypher = { "has_symptom": """ MATCH (d:Disease {name: $disease}) MATCH (t:Symptom {name: $target}) MERGE (d)-[:HAS_SYMPTOM]->(t) """, "belong_to": """ MATCH (d:Disease {name: $disease}) MATCH (t:Department {name: $target}) MERGE (d)-[:BELONG_TO]->(t) """, } with open(path, encoding="utf-8-sig") as f: for row in csv.DictReader(f): cypher = relation_cypher[row["relation_type"]] session.run(cypher, disease=row["disease"], target=row["target"]) with driver.session() as session: import_disease(session, "data/disease.csv") import_relation(session, "data/relation.csv") driver.close()代码里用MERGE而不是CREATE,作用是“存在就跳过,不存在才创建”,重复执行脚本不会产生重复节点。ON CREATE SET表示只有新建节点时才写icd和desc,避免把已有节点的属性覆盖掉。CSV 用utf-8-sig打开,是为了兼容 Excel 导出时带 BOM 头的情况,这也是从数据库直接看中文不乱码的关键。
当数据量到几千条以上时,逐条session.run太慢。更好的做法是按 500 条一批提交,驱动会为每批开启一个事务。批量写入的核心不是多进程,而是减少网络往返次数,这一点在说明文档里提出来会显得你明白性能边界。
2.3 唯一约束、索引与常用的 Cypher 模板
导入完成后不要急着写问答逻辑,先给节点建约束和索引。用 Neo4j 的cypher-shell执行:
CREATE CONSTRAINT disease_name IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT symptom_name IF NOT EXISTS FOR (s:Symptom) REQUIRE s.name IS UNIQUE; CREATE INDEX disease_icd_idx IF NOT EXISTS FOR (d:Disease) ON (d.icd);约束和MERGE是配合使用的:没有唯一约束时,MERGE要靠全表扫描判断节点是否存在,数据量大就会明显变慢。加了约束后,MERGE可以直接走索引。课程设计数据量小看不出差别,但这一条常被答辩老师追问“你的图数据库做了哪些优化”,属于性价比很高的加分点。
后续问答核心查询就三类典型写法。一类是单向关系查询:
MATCH (d:Disease {name: $name})-[:HAS_SYMPTOM]->(s:Symptom) RETURN s.name AS result另一类是同时查多个关系,减少往返:
MATCH (d:Disease {name: $name}) OPTIONAL MATCH (d)-[:HAS_SYMPTOM]->(s:Symptom) OPTIONAL MATCH (d)-[:BELONG_TO]->(dep:Department) RETURN d.name AS disease, collect(DISTINCT s.name) AS symptoms, collect(DISTINCT dep.name) AS departments注意 Cypher 里的$name是参数占位符,实际运行时由驱动传入,不能直接把用户输入拼到查询字符串里。这一条既是防注入,也是让 Neo4j 能复用查询计划。
3. 医疗问答链路的实体识别、意图识别与 Cypher 生成:让图数据库被问起来
3.1 用 jieba 自定义词典做医疗实体识别
医疗问答的实体识别有个典型问题:通用分词器会把“胃食管反流”切成“胃”“食管”“反流”,导致知识图谱里匹配不到节点。毕业设计最稳妥的方案是维护一份和节点名称同源的词典,让分词器按实体词优先切分。
import jieba jieba.load_userdict("dict/medical_dict.txt") def extract_entities(question): words = jieba.lcut(question) entities = [] for word in words: if word in entity_type_map: entities.append({"name": word, "type": entity_type_map[word]}) return entitiesentity_type_map来自初始化阶段,通过读取数据库或 CSV 得到,结构是{实体名: 实体类型}。jieba.load_userdict加载的每一行格式是“词 词频 词性”,其中词频和词性可以省略,所以直接按实体名一行一个写就行。
自定义词典能解决分词边界,但覆盖不了同义词。比如用户问“头疼挂什么科”,图里存的是“头痛”。我一般的做法是给Symptom节点加一个alias属性,在实体识别环节做一次别名映射:
normalized_name = alias_map.get(word, word)如果词表不大,直接用字符串包含匹配也可以,不需要 jieba。对几千个节点,逐个遍历entity_type_map判断if name in question即可,代码更短,效果也更直观。这个方案的缺点是没有边界判断,比如“高血压”会命中“血压”实体,所以匹配后要按实体名长度排序,优先选最长命中的那个,这是我在实际调试里最常用的一条规则。
3.2 意图识别:关键词模板驱动 Cypher 生成
意图识别不引入深度学习模型,规则表就够用。我把上一章的六类问句映射成一张配置表,这张表同时承担“识别意图”和“选择 Cypher”两个职责。
| 意图 | 问句特征词 | 目标实体类型 | Cypher 模板示例 |
|---|---|---|---|
| symptom | 症状、有什么表现 | Disease | MATCH (d:Disease {name:$name})-[:HAS_SYMPTOM]->(s:Symptom) RETURN s.name AS result |
| department | 挂什么科、哪个科室 | Disease | MATCH (d:Disease {name:$name})-[:BELONG_TO]->(dep:Department) RETURN dep.name AS result |
| drug | 吃什么药、用什么药 | Disease | MATCH (d:Disease {name:$name})-[:DRUG_FOR]->(drug:Drug) RETURN drug.name AS result |
| check | 做什么检查、检查项目 | Disease | MATCH (d:Disease {name:$name})-[:NEED_CHECK]->(c:Check) RETURN c.name AS result |
| diet | 不能吃、适合吃什么 | Disease | MATCH (d:Disease {name:$name})-[:BAD_FOR]->(f:Food) RETURN f.name AS result |
对应的识别代码:
INTENT_RULES = [ {"intent": "symptom", "keywords": ["症状", "表现"]}, {"intent": "department", "keywords": ["挂什么科", "哪个科室"]}, {"intent": "drug", "keywords": ["吃什么药", "用什么药"]}, {"intent": "check", "keywords": ["什么检查", "检查项目"]}, {"intent": "diet", "keywords": ["不能吃", "适合吃"]}, ] def classify_intent(question): for rule in INTENT_RULES: if any(keyword in question for keyword in rule["keywords"]): return rule["intent"] return "unknown"规则顺序很重要。symptom和check都可能被问成“糖尿病有什么症状”和“高血压要做什么检查”,关键词没有重叠还好。但如果两条规则的“特征词”都能命中同一个问句,排在前面的会赢,所以设计时要把更具体的规则往前放,例如“什么检查”永远在“什么”前面。
3.3 多实体、空实体与同义词的兜底逻辑
一个问句里可能出现多个实体:“头痛和发热挂什么科”会同时识别出“头痛”和“发热”。这时候按意图的目标实体类型过滤:
TARGET_TYPE = { "symptom": "Disease", "department": "Disease", "drug": "Disease", "check": "Disease", "diet": "Disease", } def pick_entity(intent, entities): for entity in entities: if entity["type"] == TARGET_TYPE.get(intent): return entity return entities[0] if entities else None这里的核心逻辑是“意图决定了实体类型优先顺序”。问“挂什么科”时,系统要找的是疾病,而不是把“神经内科”当成实体去查询,否则 Cypher 会直接空转。若实体列表为空,则直接返回兜底话术,例如“我暂时无法回答这个问题,可以换个说法,比如‘头痛挂什么科’”。兜底话术本身也放进回答模板里,不要让它暴露 Python traceback。
4. 把知识图谱医疗问答系统跑起来:Flask 服务、Neo4j 连接与网页交互
4.1 Flask 问答接口:一次请求的完整数据流
毕业设计的可视化层我用 Flask 做 Web 服务,原因是对新手友好、模板渲染简单。整个后端只需要一个server.py,负责接收请求、调用第三章的识别函数、查询 Neo4j、组装回答。
# server.py import os from flask import Flask, request, jsonify, render_template from neo4j import GraphDatabase app = Flask(__name__) driver = GraphDatabase.driver( os.getenv("NEO4J_URI", "bolt://localhost:7687"), auth=(os.getenv("NEO4J_USER", "neo4j"), os.getenv("NEO4J_PASSWORD", "neo4j")) ) entity_type_map = load_entity_map() # 读取实体名到类型的映射 INTENT_TEMPLATES = load_intent_templates() # 读取第 3 章的意图配置表 def answer(question): entities = extract_entities(question) intent = classify_intent(question) if intent == "unknown" or not entities: return {"intent": intent, "answer": "换个问法试试,例如:头痛应该挂什么科"} entity = pick_entity(intent, entities) cypher = INTENT_TEMPLATES[intent]["cypher"] with driver.session() as session: records = session.run(cypher, name=entity["name"]).data() results = [record["result"] for record in records] answer_text = "、".join(results) if results else "没有查到相关结果" return {"intent": intent, "entities": [e["name"] for e in entities], "answer": answer_text} @app.post("/api/ask") def ask_api(): payload = request.get_json() question = payload.get("question", "") return jsonify(answer(question)) @app.get("/") def index(): return render_template("index.html") if __name__ == "__main__": app.run(host="127.0.0.1", port=5000, debug=True)需要重点说明两点。第一,GraphDatabase.driver必须是全局单例,不能放在每个请求里重复创建,否则每次问答都会新建连接池,并发时必然出问题。第二,session.run(cypher, name=entity["name"])是参数化查询,Cypher 里的$name与关键字参数name一一对应,用户输入始终走参数,不能做字符串拼接。record["result"]取回的是 Cypher 中AS result的别名,所以模板里的返回列别一定要统一。
4.2 一个 HTML 页面完成问答演示
在templates/index.html里放这个最小页面:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>医疗知识图谱问答</title> </head> <body> <h1>医疗知识图谱问答</h1> <input id="q" type="text" style="width: 300px;" placeholder="例如:头痛挂什么科"> <button onclick="ask()">提问</button> <pre id="result"></pre> <script> async function ask() { const question = document.getElementById("q").value; const resp = await fetch("/api/ask", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({question: question}) }); const data = await resp.json(); document.getElementById("result").innerText = "意图:" + data.intent + "\n回答:" + data.answer; } </script> </body> </html>页面逻辑不复杂:点击按钮后通过fetch把问题以 JSON 发送到/api/ask,拿到返回后把意图和回答渲染在页面上。教师机演示时如果浏览器打不开,直接用一个curl命令演示接口也可以,页面只是展示层。
4.3 数据库环境变量与说明文档的写法
连接 Neo4j 的地址和密码不要硬编码在源码里,用环境变量覆盖,既方便别人复现,也避免把密码提交到源码包。初始化脚本和server.py都从同一个环境变量读取,保证两边一致。
说明文档是毕设评审会翻的重点。它不需要写算法推导,但必须覆盖:Python 安装(3.8 以上)、虚拟环境创建、pip install -r requirements.txt、Neo4j 社区版启动、初始化数据库、启动服务、演示用例、常见错误排查。文档里要强调“先跑init_db.py再跑server.py”,并在“常见问题”里写清楚连接被拒绝时先检查 Neo4j 服务状态、密码错误时看环境变量是否生效。文档的验收标准是:一个只懂 Python 基础的同学按步骤执行也能把系统跑起来。
5. 答辩前的数据库巡检、一键初始化和问题回归表
5.1 一键初始化的命令验证顺序
演示前最怕数据库状态不对,所以初始化脚本加一个--clean参数,清空旧数据再重新导入,保证每次演示环境一致:
python init_db.py --clean python server.py--clean内部执行MATCH (n) DETACH DELETE n,这会把图里所有节点和关系清空,再重新导入 CSV。执行完观察终端日志,看到“导入完成”等字样后再启动 Flask。
接着用 curl 验证接口:
curl -s -X POST http://127.0.0.1:5000/api/ask \ -H "Content-Type: application/json" \ -d '{"question":"头痛应该挂什么科"}' | python -m json.tool返回里intent是department,answer是“神经内科”这类科室名,就说明实体识别、意图识别、Neo4j 查询三层链路都通了。
5.2 演示问题回归表
不要现场随机输入问题,准备一份回归表,按固定顺序演示:
| 问题 | 意图 | 目标实体 | 预期回答 |
|---|---|---|---|
| 头痛应该挂什么科 | department | 头痛 | 神经内科 |
| 糖尿病有什么症状 | symptom | 糖尿病 | 多尿、多饮、多食 |
| 感冒吃什么药 | drug | 感冒 | 感冒灵颗粒、布洛芬 |
| 高血压需要做什么检查 | check | 高血压 | 血压监测、心电图 |
| 痛风不能吃什么 | diet | 痛风 | 动物内脏、海鲜、啤酒 |
这五条正好覆盖五种关系类型,每条演示完可以说一句对应的图结构,评审会认为系统是“可解释的”。如果某条返回空结果,说明该关系在 CSV 里缺失,需要回数据文件补边,而不是改代码。
5.3 最后看一眼词表是否同步
最容易翻车的是实体识别成功但查询结果为空——原因是导入节点用的 CSV 和 jieba 词表不一致。我的做法是:entity_type_map和medical_dict.txt在初始化阶段由同一个函数生成,索引是实体名,内容写“实体名 + 空格 + 词频”,这样词表只会多不会少。如果用的是 Neo4j 5.x,连接失败时错误里会直接给出协议不兼容的提示,确认本机 Java 版本和驱动版本一致即可。
回归表里五条问答全部返回预期结果,源码、数据库、说明文档三者就真正闭环了。
本文还有配套的精品资源,点击获取