ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于知识图谱的医疗问答系统:Django+Neo4j实战

2026/9/26 23:40:27 拓冰建站 浏览量
基于知识图谱的医疗问答系统:Django+Neo4j实战 简介基于知识图谱的医疗问答系统完整毕业设计源码包采用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, parametersNone): 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, encodingutf-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_deletemodels.CASCADE) question models.TextField() answer models.TextField() created_at models.DateTimeField(auto_now_addTrue) class Meta: ordering [-created_at]模型很简单就四个字段。on_deletemodels.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( userrequest.user, questionquestion, answeranswer )查询历史的接口也很常规def history_list(request): records QAHistory.objects.filter(userrequest.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( userrequest.user, question__icontainskeyword ).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 大概率握手失败降级到neo4j4.4.0这种对应版本最省事。5.2 中文乱码从 CSV 到 Neo4j 再到 JSON 全链路排查现象Neo4j 里显示的中文实体变成???或乱码前端返回的 JSON 也乱。原因一般是 CSV 编码不是 UTF-8或者 Windows 下用记事本保存默认 ANSI 编码。解决清洗数据时用 Python 明确读写编码读文件时指定encodingutf-8写 CSV 时newline。另外一个隐形坑是 MySQL 连接后返回的字符串编码charsetutf8mb4只能保证存储Django 的JsonResponse默认已经处理 UTF-8不要自己手动json.dumps再加一层编码否则会双写导致前端显示\uXXXX。5.3 症状抽取把无关词拽进查询现象用户问「我最近老是头疼睡不好怎么办」结果抽取出了「疼」和「不」这种单字图谱查询返回一堆无关疾病。原因抽取逻辑太粗暴只判断子串存在。解决建立最小词长度校验过滤单字同时维护一份停用词表把「我」「你」「吗」「呢」「怎么」「办」这类词在解析前直接去掉。还有一个细节别名映射里有些词是双刃剑比如「老」出现在「老咳嗽」里会被当成症状「老」这类词要单独加白名单控制。5.4 Django 静态文件和前端页面 404现象前端页面能打开但 CSS、JS、图片全部 404页面惨不忍睹。原因DEBUGFalse时 Django 不自动serve 静态文件。解决最简单的是开发环境保持DEBUGTrue或者配置静态文件路由# urls.py from django.conf import settings from django.conf.urls.static import static urlpatterns [...] if settings.DEBUG: urlpatterns static(settings.STATIC_URL, document_rootsettings.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(fTop-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, timeout300) 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 扩展方向从单轮问答到多轮对话和推荐目前的系统是单轮问答用户每次都要把症状说全。进阶一点可以做成简单多轮用户第一次说「头疼」系统反问「有没有发烧、咳嗽」用户回答「发烧」系统把发烧合并进症状集合再查图。这个逻辑在现有代码上改动很小只需要在会话里维护一个症状集合每次把新抽取的症状合并进去并保留上一次的候选结果。另外可以加一个「科室推荐」的结果页展示疾病概率排行和历史统计这些都能写进论文作为「后续工作与展望」。我自己的习惯是每次改完图谱数据或者同义词表都跑一遍评估脚本指标没下降到原来水平之前绝不往前端加新功能。这个习惯帮我挡掉过好几次「以为改好了实际越改越差」的尴尬。做问答系统最忌拍脑袋调规则数据驱动地验证才是毕业设计能拿高分的底气。希望帮到你。本文还有配套的精品资源点击获取