简介:基于知识图谱的医疗问答系统完整毕业设计源码包,采用Django与Python开发,面向计算机专业学生及医疗信息化研究者。系统以Neo4j图数据库构建医疗知识图谱,覆盖常见疾病、症状、药物等实体及其关联关系,MySQL存储用户信息,涵盖前端交互、后端逻辑、数据脚本与说明文档,完整呈现从症状提问到疾病/用药建议推演的全过程。包内共1207个文件,约186.79MB,包含Python源码、Django模板、JS/CSS静态资源、HTML页面、Neo4j数据文件及SQL脚本,并附有GIF/PNG运行示意图,便于直观理解系统功能与界面。目前已有108人学习,随包附带详尽说明文档,覆盖Python3.6.8、MySQL5.7、Navicat11、PyCharm环境配置,以及数据库设计、API接口调用、部署步骤等,内含Neo4j原始存储文件可直接导入运行,适合毕业设计参考或作为医疗问答系统二次开发的起点。
1. 医疗问答系统用 Django 落地:这个毕业设计到底解决什么问题
先说结论:一个基于知识图谱的医疗问答系统,本质上是把「患者输入症状描述」转成「结构化查询」,再通过图谱关系找到候选疾病、科室、药物,最后用模板或排序生成自然语言答案。用 Django 做后端不是因为 Django 能跑算法,而是因为它把登录鉴权、后台管理、MySQL 数据持久化、REST 接口全部打包好了,你只需要专注写图谱查询和问答逻辑。这个项目适合两类人:一类是正在做毕业设计、需要完整前后端和论文素材的学生;另一类是刚接触知识图谱、想用最短路径把 Neo4j 或自建图结构接进 Web 系统的开发者。它解决的核心痛点是「问答系统听起来高大上,但落地时往往死在数据、图谱构建和前后端联调上」,这套方案把这三块串成了一条可复现的流水线。
下面我会按做这个系统的真实顺序来讲:先讲知识图谱怎么建、数据从哪来,再讲 Django 里的图谱查询和问答接口怎么写,接着给出一套可抄的 MySQL + 图谱混合存储方案,然后独立开一章专门写避坑,最后收在评估脚本和性能优化上。全程没有虚构源码包内容,所有步骤都是这个标题下最常见的可靠做法,你照着改就能用。
2. 知识图谱先立住:医疗数据建模与 Neo4j 导入实战
2.1 为什么医疗问答适合用知识图谱,而不是纯 SQL 或纯 Elasticsearch
医疗问答最典型的查询是「我头疼、发烧、咳嗽,可能是什么病」。纯 SQL 的做法是建一张大宽表,字段塞满症状、疾病、科室、药物,然后WHERE symptom LIKE '%头疼%',结果就是你又得维护一堆难以扩展的关联表,而且“肚子疼”和“腹痛”这种同义词就能把你搞崩溃。Elasticsearch 能解决模糊匹配,但它不理解“头疼”和“神经内科”之间的多跳关系。
知识图谱的核心优势在于把实体和关系显式建模:疾病节点、症状节点、药物节点、科室节点,它们之间的边就是「表现为」「治疗用」「就诊于」。问答系统拿到「头疼、发烧」后,先映射到症状实体,再沿「表现为」反查疾病,再沿「就诊于」拿到科室,整个过程是图遍历,天然支持多跳。而且图谱可视化效果好,答辩时截一张 Neo4j 的关系图,比任何数据表都直观。
2.2 医疗数据从哪来:公开数据清洗与实体对齐
常见做法是去 CCKS 竞赛数据集或公开的中文症状库拿种子数据,但绝大多数原始数据长这样:一行文本「头痛伴发热,多见于上呼吸道感染」,需要自己拆成实体和关系。我一般会先建三张表:疾病表、症状表、疾病_症状关系表。清洗时最烦的是同义词,比如「头疼」和「头痛」、「发烧」和「发热」,需要维护一张同义词映射表,统一成标准术语。
实体对齐这一步决定图谱质量。我的策略是分层:先人工维护一份高频同义词词典,大约 300 条,覆盖常见症状;再用规则做归一化,比如去括号、去空格、把「伴有」替换为分隔符;最后对于疑难杂症,直接走人工复核。不要一上来就上 BERT 做实体链接,毕业设计规模下,规则 + 词典的准确率足够,而且可解释性强。
2.3 Neo4j 导入:Cypher 脚本与 Python 驱动
图谱存储我选 Neo4j 社区版,因为 Django 这边有现成的neo4j驱动,而且 Cypher 查询写起来快。先把清洗后的 CSV 丢到 Neo4j 的 import 目录下,然后写导入脚本。先创建约束,保证实体不重复:
CREATE CONSTRAINT disease_name IF NOT EXISTS ON (d:Disease) ASSERT d.name IS UNIQUE; CREATE CONSTRAINT symptom_name IF NOT EXISTS ON (s:Symptom) ASSERT s.name IS UNIQUE; CREATE CONSTRAINT drug_name IF NOT EXISTS ON (m:Medicine) ASSERT m.name IS UNIQUE; CREATE CONSTRAINT dept_name IF NOT EXISTS ON (d:Department) ASSERT d.name IS UNIQUE;约束建完后,用LOAD CSV导入节点和关系。这里提一句,节点文件要按标签分开放,别混在一个 CSV 里,否则 Cypher 要写一堆条件判断,后期维护很痛苦。
LOAD CSV WITH HEADERS FROM 'file:///diseases.csv' AS line MERGE (d:Disease {name: line.name}) SET d.department = line.department, d.desc = line.desc; LOAD CSV WITH HEADERS FROM 'file:///symptoms.csv' AS line MERGE (s:Symptom {name: line.name}); LOAD CSV WITH HEADERS FROM 'file:///disease_symptom.csv' AS line MATCH (d:Disease {name: line.disease}) MATCH (s:Symptom {name: line.symptom}) MERGE (d)-[:HAS_SYMPTOM]->(s);这段的逻辑很直白:先MATCH找到两端的实体节点,再MERGE关系,防止重复。需要注意MERGE和CREATE的区别,重跑脚本时MERGE不会产生重复边,CREATE会,所以全量导入阶段统一用MERGE。另外,CSV 表头如果不是标准英文,先预处理成disease,symptom这样的字段名,中文表头在 LOAD CSV 里容易出编码问题。
2.4 Django 调 Neo4j:连接池与cypher查询封装
Django 这边不引入太重的东西,直接用官方驱动建一个工具模块。先装依赖:pip install neo4j。然后在utils/neo4j_client.py里封装连接和查询:
# utils/neo4j_client.py from neo4j import GraphDatabase class Neo4jClient: def __init__(self, uri, user, password): self.driver = GraphDatabase.driver(uri, auth=(user, password)) def close(self): self.driver.close() def query(self, cypher, parameters=None): with self.driver.session() as session: result = session.run(cypher, parameters or {}) return [record.data() for record in result] # 实例化一次,全局复用 neo4j_client = Neo4jClient("bolt://localhost:7687", "neo4j", "your_password")这里最值得说的是连接池:GraphDatabase.driver内部自带连接池,所以你不需要在每次请求里新建driver,否则并发一高就会报「数据库连接过多」。我见过很多新手在 views.py 里每个视图都GraphDatabase.driver(...)一次,结果跑五分钟就连接超时。正确做法是像上面这样模块级单例,Django 进程只维护一个 driver。
查询封装好后,写一个图谱查询函数,比如「根据症状找疾病」:
# utils/medical_graph.py from utils.neo4j_client import neo4j_client def find_diseases_by_symptoms(symptom_names): cypher = """ MATCH (d:Disease)-[:HAS_SYMPTOM]->(s:Symptom) WHERE s.name IN $symptoms WITH d, count(s) AS matched_count RETURN d.name AS disease, matched_count ORDER BY matched_count DESC LIMIT 10 """ return neo4j_client.query(cypher, {"symptoms": symptom_names})这个查询的逻辑是:只要疾病节点关联的症状在用户输入列表里,就计数并按命中的症状数排序。比如用户说「头疼、发烧」,感 rain 可能命中 2 个症状排第一,胃病命中 0 个不会出现。排序逻辑虽然简单,但已经足够应付大多数毕业设计 Demo,比纯随机返回强得多。
3. 从问句到答案:规则匹配与问答流程拆解
3.1 意图识别别上深度学习,先做规则分层
问答系统的第一步是判断用户想问什么。医疗场景常见意图就这么几类:查疾病(「头疼是什么病」)、查症状(「感冒有什么症状」)、查科室(「头疼挂什么科」)、查药物(「感冒吃什么药」)。用深度学习做意图识别在这个规模下属于过度设计。规则匹配反而稳定、可解释、答辩好讲。
做法是维护关键词表,比如「挂什么科」「哪个科室」触发科室意图;「吃什么药」「用什么药」触发药物意图;「什么病」「是什么病」触发疾病意图。再配合症状实体抽取,把「头疼、发烧」从问句里抽出来。中文分词可以用 jieba,但医疗术语分词效果一般,我一般先用 jieba 切,再用自定义词典补医疗词。
3.2 实体抽取与归一化的关键代码
实体抽取决定后续图谱查询的准确率。用户输入「头疼」可能对应图谱里的标准实体「头痛」,所以必须做归一化。我维护一个symptom_alias.json文件:
{ "头疼": ["头痛", "头疼痛", "头部疼痛"], "发烧": ["发热", "体温升高", "高热"] }然后写归一化函数:
# utils/text_process.py import json import re with open("symptom_alias.json", "r", encoding="utf-8") as f: SYMPTOM_ALIAS = json.load(f) # 返回标准症状名列表 def extract_symptoms(text): text = re.sub(r"[\s,。?、,.?!]", "", text) matched = set() for standard, aliases in SYMPTOM_ALIAS.items(): if standard in text: matched.add(standard) continue for alias in aliases: if alias in text: matched.add(standard) break return list(matched)逻辑简单直接:先去掉标点和空格,再遍历同义词表,命中任何一个词就把标准名加入集合。这里有个坑:别名列表里的词不能互相包含,比如「头疼痛」和「头痛」同时存在时,匹配顺序会影响结果,所以建表时要把最短的标准词放前面,并且一个别名只属于一个标准词,别交叉。
3.3 问答主流程:路由、查图、拼答案
问答接口的主流程可以沉淀成一张表:
| 步骤 | 动作 | 输入/输出 |
|---|---|---|
| 1 | 接收用户文本 | text = request.POST.get("question") |
| 2 | 提取症状实体 | symptoms = extract_symptoms(text) |
| 3 | 判断意图 | 关键词匹配:科室/药物/疾病 |
| 4 | 查图谱 | 按意图调用不同 Cypher 查询 |
| 5 | 拼接答案 | 模板 + 查询结果 |
| 6 | 返回 JSON | {"answer": ...} |
在 Django 视图里实现如下:
# qa/views.py from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from utils.text_process import extract_symptoms from utils.medical_graph import find_diseases_by_symptoms, find_departments, find_medicines @csrf_exempt def chat(request): if request.method == "POST": question = request.POST.get("question", "").strip() if not question: return JsonResponse({"answer": "请描述你的症状,比如:头疼、发烧。"}) symptoms = extract_symptoms(question) if not symptoms: return JsonResponse({"answer": "暂时没识别到症状,换个说法试试,例如「头疼」或「发烧」。"}) # 找疾病 diseases = find_diseases_by_symptoms(symptoms) if not diseases: return JsonResponse({"answer": "没找到匹配的疾病,建议去医院检查。"}) disease_names = [d["disease"] for d in diseases[:3]] departments = find_departments(disease_names) answer = f"根据你提到的{'、'.join(symptoms)},可能相关的疾病有{ '、'.join(disease_names) }。建议就诊科室:{'、'.join(departments)}。" return JsonResponse({"answer": answer}) return JsonResponse({"answer": "请使用 POST 请求访问。"})这段代码把问答流程压成了一屏:先抽取症状,再查图谱,最后用模板拼接答案。find_departments是另一个 Cypher 查询,通过疾病节点找关联科室。实际开发中,你会发现最难的不是写这段逻辑,而是处理“没识别到症状”时的兜底回复——别返回空字符串,要给用户一个引导性的反馈。
3.4 答案模板的细节:别让人一眼看出是机器
模板拼接不是简单把疾病名串起来,而是要读起来通顺。我一般准备三套模板,按命中疾病数量切换:命中 1 个时用「你的症状可能指向 XX,这种病常表现为…」;命中 2~3 个时用「以上症状可能与 A、B、C 相关,建议去 XX 科进一步检查」;命中 0 个时用「信息不足,请补充症状描述」。模板里还要带上免责声明,比如「以上结果仅供参考,请以医生诊断为准」,这个在毕业设计里也是加分项。
4. 把 MySQL 接进 Django:用户、历史记录与混合存储方案
4.1 为什么需要 MySQL,图谱不是万能的
Neo4j 承担了核心的图谱查询,但用户登录、问答历史、收藏记录这类结构化数据放 Neo4j 里既浪费又难做后台管理。Django 默认对 MySQL 的支持很成熟,ORM 一写就能建表、做分页、做条件查询。所以实际架构是 MySQL 存业务数据,Neo4j 存图谱数据,两者通过 Django 服务层衔接。这个混合方案在答辩时也很容易讲清楚:每种存储选型对应它的数据特征。
4.2 数据库配置与模型设计
先在settings.py里配置 MySQL 连接:
# settings.py DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "medical_qa", "USER": "root", "PASSWORD": "your_password", "HOST": "127.0.0.1", "PORT": "3306", "OPTIONS": { "charset": "utf8mb4", }, } }注意charset必须用utf8mb4,因为用户输入可能带 emoji 表情,utf8存 emoji 会报错。然后设计两个核心模型:问答记录和用户表。用户表直接用 Django 内置的auth.User就行,自定义一个问答记录模型:
# qa/models.py from django.db import models from django.contrib.auth.models import User class QAHistory(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) question = models.TextField() answer = models.TextField() created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"]模型很简单,就四个字段。on_delete=models.CASCADE表示用户删除时历史记录一并删除,避免孤儿数据。ordering让最新记录排前面,前端展示时直接查这个模型就行。
4.3 数据迁移与常用 CRUD 操作
配置好模型后,依次执行两条命令生成表:
python manage.py makemigrations qa python manage.py migrate如果之前建过同名表,会报table already exists,这时候需要到 MySQL 里手动删掉旧表,或者用python manage.py migrate qa --fake跳过已有表。记录问答历史时,在 chat 视图里加两行代码:
if request.user.is_authenticated: QAHistory.objects.create( user=request.user, question=question, answer=answer )查询历史的接口也很常规:
def history_list(request): records = QAHistory.objects.filter(user=request.user)[:20] data = [{"question": r.question, "answer": r.answer, "time": r.created_at.strftime("%Y-%m-%d %H:%M")} for r in records] return JsonResponse({"records": data})这里有个常见误用:有人习惯在循环里一条一条create,或者用objects.all().filter(...),顺序反了但不报错。Django ORM 的链条是先filter再order_by再切片,顺序写反不影响功能但影响代码可读性。另外,历史记录建议只取最近 20 条,别一次查全表回来,数据量大后会卡。
4.4 模糊查询与分页:用户搜历史记录怎么办
用户可能会在前端搜「我上次问头疼的事」。后端就要做模糊查询,同时注意分页:
def search_history(request, keyword): records = QAHistory.objects.filter( user=request.user, question__icontains=keyword ).order_by("-created_at")[:50]icontains在 MySQL 里会翻译成LIKE '%keyword%',能覆盖大多数场景。但icontains不经过分词,如果用户输入「头」会查到「头疼」「头晕」所有包含「头」的记录,这没问题,因为历史记录量小。分页可以用 Django 的Paginator,这里不展开了。
MySQL 部分还有一个必调参数:连接复用。Django 默认每次请求新建数据库连接,高并发下 MySQL 会报Too many connections。设置CONN_MAX_AGE为 60 秒就能复用连接:
# settings.py DATABASES["default"]["CONN_MAX_AGE"] = 60这个参数是我强烈建议加上的,尤其系统跑在公网服务器上时。
5. 避坑指南:知识图谱 + Django 医疗问答的 5 个典型事故
5.1 Neo4j 驱动装不上或连接超时
现象:pip install neo4j后,代码执行到driver.session()一直转圈,最后抛超时异常。原因多半是 Neo4j 服务没启动,或者启动了但只监听本地地址,Django 部署在其他机器时访问不到。解决:先确认 Neo4j 桌面版或 Docker 版本已启动,浏览器访问http://localhost:7474看是否能打开;再确认neo4j.conf里的dbms.connectors.default_listen_address是0.0.0.0而不是localhost。如果驱动版本和 Neo4j 版本差太多也会出现协议不兼容,官方驱动 5.x 配 Neo4j 4.x 大概率握手失败,降级到neo4j==4.4.0这种对应版本最省事。
5.2 中文乱码:从 CSV 到 Neo4j 再到 JSON 全链路排查
现象:Neo4j 里显示的中文实体变成???或乱码,前端返回的 JSON 也乱。原因一般是 CSV 编码不是 UTF-8,或者 Windows 下用记事本保存默认 ANSI 编码。解决:清洗数据时用 Python 明确读写编码,读文件时指定encoding='utf-8',写 CSV 时newline=''。另外一个隐形坑是 MySQL 连接后返回的字符串编码,charset='utf8mb4'只能保证存储,Django 的JsonResponse默认已经处理 UTF-8,不要自己手动json.dumps再加一层编码,否则会双写导致前端显示\uXXXX。
5.3 症状抽取把无关词拽进查询
现象:用户问「我最近老是头疼,睡不好怎么办」,结果抽取出了「疼」和「不」这种单字,图谱查询返回一堆无关疾病。原因:抽取逻辑太粗暴,只判断子串存在。解决:建立最小词长度校验,过滤单字;同时维护一份停用词表,把「我」「你」「吗」「呢」「怎么」「办」这类词在解析前直接去掉。还有一个细节:别名映射里有些词是双刃剑,比如「老」出现在「老咳嗽」里,会被当成症状「老」,这类词要单独加白名单控制。
5.4 Django 静态文件和前端页面 404
现象:前端页面能打开,但 CSS、JS、图片全部 404,页面惨不忍睹。原因:DEBUG=False时 Django 不自动serve 静态文件。解决:最简单的是开发环境保持DEBUG=True,或者配置静态文件路由:
# urls.py from django.conf import settings from django.conf.urls.static import static urlpatterns = [...] if settings.DEBUG: urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)如果部署在 nginx 下,直接让 nginx 指向STATIC_ROOT目录,不要在 Django 层面处理静态文件。毕业设计答辩时演示机器大概率开 DEBUG,但要提前说明生产环境怎么做,否则老师问起来容易翻车。
5.5 MySQL 连接被拒或者表不存在
现象:python manage.py migrate报Unknown database 'medical_qa'或者Access denied for user。原因:数据库没创建,或者用户密码权限不对。解决:先登录 MySQL 执行CREATE DATABASE medical_qa DEFAULT CHARACTER SET utf8mb4;,再确认 Djangosettings.py里的用户名密码是 MySQL 的用户而不是系统用户。如果之前误删表,不要慌,重跑migrate前先makemigrations保证模型和迁移文件同步。
6. 系统评估与进阶优化:让问答结果可量化、可展示
6.1 离线评估脚本:准确率、召回率、Top-K 命中率
问答系统做好后,不能光靠肉眼测。写一个离线评估脚本,准备一组合法的「症状 → 预期疾病」测试集,看系统返回的 Top-K 里有没有预期疾病。评估指标用Top-3 命中率就够,因为医疗问答本身就不要求唯一答案。脚本大概长这样:
# eval/evaluate.py test_data = [ {"symptoms": ["头疼", "发烧"], "expect": ["上呼吸道感染"]}, {"symptoms": ["胃痛", "反酸"], "expect": ["胃炎"]}, ] hit = 0 for item in test_data: candidates = find_diseases_by_symptoms(item["symptoms"]) top_names = [c["disease"] for c in candidates[:3]] if set(item["expect"]) & set(top_names): hit += 1 print(f"Top-3 命中率: {hit / len(test_data):.2%}")评估脚本的价值不只是给你一个数字,它还能帮你发现同义词映射漏了哪些,因为漏掉一个别名,导致本该命中的疾病没出现。建议把测试集做到 50 条以上,覆盖常见病、少见病和边界情况(大量重叠症状的不同疾病)。如果命中率低于 70%,基本可以判断是同义词表或图谱关系不够全,而不是算法问题。
6.2 性能优化:Cypher 查询缓存与索引
首次查询某个症状组合可能要 200 毫秒,第二次同样的查询如果还走 Neo4j,就显得慢。简单有效的方案是加一个内存缓存,比如用 Django 的cache框架,把「症状列表 → 疾病结果」缓存起来:
from django.core.cache import cache def find_diseases_by_symptoms_cached(symptom_names): key = "diseases:" + ",".join(sorted(symptom_names)) cached = cache.get(key) if cached is not None: return cached result = find_diseases_by_symptoms(symptom_names) cache.set(key, result, timeout=300) return result缓存键必须对症状列表排序,否则「头疼,发烧」和「发烧,头疼」会被当成两个键,缓存命中率骤降。缓存时间 300 秒足够,因为医疗图谱数据不会每隔几分钟就变。另外,Neo4j 侧给name建过唯一约束后,MATCH (s:Symptom {name: $name})会自动走索引,所以刚才导入时建约束这一步其实已经在为查询加速了。
6.3 图谱可视化:答辩展示最强辅助
Neo4j 自带的 Browser 可视化在答辩时不适合直接演示,因为原始图谱节点太多太密。更好的做法是写一个入口视图,比如用户问「感冒」,系统把感冒相关的子图(疾病、症状、药物、科室)取出来,用前端可视化库展示。取子图的 Cypher 很简单:
MATCH (d:Disease {name: '感冒'})-[r]-(n) RETURN d, r, n LIMIT 50前端可以用vis.js或ECharts的关系图,把节点和边渲染成可拖拽的图。这一步很震撼,答辩时老师问「知识图谱到底图在哪」,直接展示交互式子图比任何语言都有说服力。前端组件网上教程很多,注意节点颜色按标签区分,比如疾病蓝色、症状橙色、药物绿色,图例写清楚。
6.4 扩展方向:从单轮问答到多轮对话和推荐
目前的系统是单轮问答,用户每次都要把症状说全。进阶一点可以做成简单多轮:用户第一次说「头疼」,系统反问「有没有发烧、咳嗽?」;用户回答「发烧」,系统把发烧合并进症状集合再查图。这个逻辑在现有代码上改动很小,只需要在会话里维护一个症状集合,每次把新抽取的症状合并进去,并保留上一次的候选结果。另外可以加一个「科室推荐」的结果页,展示疾病概率排行和历史统计,这些都能写进论文作为「后续工作与展望」。
我自己的习惯是:每次改完图谱数据或者同义词表,都跑一遍评估脚本,指标没下降到原来水平之前,绝不往前端加新功能。这个习惯帮我挡掉过好几次「以为改好了,实际越改越差」的尴尬。做问答系统最忌拍脑袋调规则,数据驱动地验证,才是毕业设计能拿高分的底气。希望帮到你。
本文还有配套的精品资源,点击获取