资讯动态

医药知识图谱问答系统:BERT+AC自动机+实体链接实现精准症状匹配

发布时间:2026/10/4 1:10:06 来源:尧图企业网站定制
简介基于Python、BERT与词典的医药知识图谱自动问答系统是一套面向计算机相关专业毕业生、知识图谱与自然语言处理初学者的完整项目。项目完整覆盖知识图谱构建、问答后端搭建、前端交互展示三大模块通过爬虫重新采集疾病症状描述结合AC算法对齐症状库重构出99492条疾病-症状三元组当症状描述口语化、无法精确匹配时引入基于BERTCRF的命名实体识别与SBERT语义相似度计算完成实体链接还支持多个症状的疾病交集推理并沿用规则模板完成意图识别。整体方案代码可运行、结构清楚毕业设计评审得分96.5分。压缩包72.36MB共363个文件以117个Python源码文件为核心配合前端展示所需的HTML、CSS、JavaScript文件以及模型pkl、说明文档和安装脚本整理成清晰目录便于按模块学习。目前已有269人浏览学习核心交付包括完整源码、超详细安装教程、配套数据与训练好的模型参数适合用于毕业设计、课程设计或项目进阶练习。1. 医药知识图谱自动问答系统把一个“答非所问”的毕设改造成能用的样子基于PythonBERT词典的医药知识图谱自动问答系统核心链路是“用户口语化描述症状 → 系统给出候选疾病”。这个项目参考过两个开源实现原版有个典型通病用户说“这两天失眠多梦还心慌”系统一个症状都匹配不上因为词典里只有标准名“失眠”“心悸”没有任何口语映射。改进版做了三件关键事用AC自动机把每个疾病的症状描述文本与4377个标准症状名对齐重构出99492个疾病-症状三元组用BERTCRF做命名实体识别、SBERT做实体链接把口语化提及词拉回标准症状多症状提问从并集改成交集候选疾病按命中症状数排序。这套链路适合正在做知识图谱或QA方向毕设、想看到完整前后端落地的人。下面按数据层、模型层、推理层讲怎么复现以及我实际踩过的坑。2. 建图谱数据层AC自动机对齐症状与99492个三元组2.1 为什么图谱要放Neo4j而不是MySQL医疗问答和电商搜索最大的差别在于用户问的是关系不是单条记录。比如“发热伴皮疹”要查的是同时具备两个症状的疾病集合MySQL得做多轮JOIN写出来很长而且一旦追加第三个症状SQL结构就得重写。Neo4j里这只是MATCH一个模式的问题加条件等于在IN列表里加一个值查询结构完全不动。另外一个原因是图谱将来要给推理用。疾病、症状、药品、科室天然是网状结构Cypher的变长路径能直接查“这个疾病的推荐药里有哪些也覆盖另一个疾病的症状”。后面想扩展患病率排序、药品推荐在图上加属性和关系比改表结构成本低得多。项目用的是neo4j-community-4.1.4注意这个版本要求Java 11启动前先确认java版本否则bin/neo4j start会直接报JVM相关错误。2.2 症状库构建与疾病症状描述文本爬取原项目只支持“标准症状名”精确匹配症状库之外的口语全部落空。改进的第一步是重新整理数据源先把原网站的症状库全量爬下来得到4377个标准症状名存成symptom.txt再重新爬每个疾病的症状描述文本。这里的描述文本不是结构化数据而是自然语句比如同一个疾病页里会出现“咳嗽、咳痰有时伴有低热”这么一段话目标是这段话里把“咳嗽”“咳痰”“低热”这些标准症状名对齐出来。爬虫只做一件事把“疾病页 → 描述文本”这段结构转成纯文本不做字段解析。这里最容易翻车的是编码我复现时目标页面是GBK编码直接用utf-8解析症状名全变乱码。用requests拿text之后先看response.encoding必要时手动指定GBK再转码。# spider_symptom.py - 抓取症状描述文本的核心片段 import requests from bs4 import BeautifulSoup def fetch_disease_desc(url): resp requests.get(url, timeout10) # 关键先看响应头里的charset很多站点是gbk if resp.encoding.upper() not in (UTF-8, GBK): resp.encoding gbk soup BeautifulSoup(resp.text, html.parser) # description_box是疾病描述所在div各站点结构不同 box soup.select_one(.disease-desc) return box.get_text(separator , stripTrue) if box else 这段代码里requests本身会猜编码但猜错是常态所以显式指定。select_one的类名需要按目标站调整这也是整个爬虫部分唯一需要改的地方。拿到纯文本后直接存本地不落数据库因为下一步AC自动机要一次性处理全部文本。2.3 AC自动机从描述文本里对齐标准症状名把4377个症状名构建成AC自动机再拿每个疾病的描述文本去跑匹配。AC自动机相比朴素遍历的好处是一次扫描文本能同时匹配出所有词典词复杂度从O(N*M)降到O(NZ)。4377个症状名如果每个都做一次字符串查找一个疾病的描述就要跑几千次整个图构建会非常慢。核心实现分两步建Trie和补fail指针。以下代码是AC自动机的标准实现insert负责把症状名灌进Triebuild负责构造fail指针match负责扫描文本并返回命中的症状ID。# ac_matcher.py - 基于AC自动机的症状名对齐 from collections import deque class AhoCorasick: def __init__(self): self.trie [{}] self.fail [0] self.output [set()] # 每个节点命中的症状ID集合 def insert(self, word: str, idx: int) - None: node 0 for ch in word: if ch not in self.trie[node]: # 每个节点都是一个dict键是字符值是子节点编号 self.trie.append({}) self.fail.append(0) self.output.append(set()) self.trie[node][ch] len(self.trie) - 1 node self.trie[node][ch] self.output[node].add(idx) def build(self) - None: q deque() # 第一层节点的fail指向根节点0 for ch, child in self.trie[0].items(): self.fail[child] 0 q.append(child) while q: r q.popleft() for ch, u in self.trie[r].items(): q.append(u) f self.fail[r] while f and ch not in self.trie[f]: f self.fail[f] self.fail[u] self.trie[f].get(ch, 0) # 把fail节点的output合并过来保证长词匹配时子词也能输出 self.output[u] | self.output[self.fail[u]] def match(self, text: str): node 0 for pos, ch in enumerate(text): while node and ch not in self.trie[node]: node self.fail[node] if ch in self.trie[node]: node self.trie[node][ch] for sid in self.output[node]: yield pos, sid逻辑说明insert里每个节点就是一个字符output里存的是“以当前节点为结尾的症状ID”。build阶段最关键的是self.output[u] | self.output[self.fail[u]]这一步它保证匹配到“失眠多梦”时“失眠”和“多梦”这两个更长词覆盖到的症状也能同时被返回。match返回的pos是命中字符在原文中的位置后续做三元组生成时主要用sidpos只用来去重。参数上需要注意症状名全部转小写再灌入描述文本同样转小写否则“头痛”和“头痛”会因为大小写不一致被漏掉。4377个词跑完构建只需要几百毫秒但前提是Python版本别太老3.8以下dict迭代性能差不少。2.4 重构三元组并启动Neo4jAC自动机跑完所有疾病描述文本之后得到的是“疾病ID → 命中的症状ID集合”。这一步直接生成原项目缺失的疾病-症状关系把所有结果汇总成99492个三元组。我这里说“重构”是因为原项目没有疾病-症状-症状名这层显式关系只有零散的疾病实体和症状实体中间关系没建导致问答时根本查不出“什么病有这些症状”。批量导入用Neo4j的LOAD CSV比逐条CREATE快一个数量级。先把三元组导出成CSV两个字段disease_name、symptom_name再用Cypher导进去。// import_triples.cypher - 批量导入疾病-症状关系 LOAD CSV WITH HEADERS FROM file:///disease_symptom.csv AS row MATCH (d:Disease {name: row.disease_name}) MATCH (s:Symptom {name: row.symptom_name}) MERGE (d)-[:HAS_SYMPTOM]-(s);逻辑说明MATCH确保两个实体都存在MERGE而不是CREATE是为了幂等重复执行不会生成重复关系。如果实体还没建需要先执行UNWIND一次性创建所有Disease和Symptom节点。导入完成后在neo4j浏览器里执行MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom) RETURN count(*)验证数量应该是99492左右。# 启动Neo4j服务4.1.4需要Java 11 cd neo4j-community-4.1.4 export JAVA_HOME/usr/lib/jvm/java-11-openjdk-amd64 bin/neo4j start如果忘了设JAVA_HOME会看到Unsupported Java version或者JVM cannot start的报错。这是复现时第一道坎不是代码问题是环境问题。3. BERTCRF与SBERT把口语化症状接回标准词典3.1 BIOC标注方案与数据集构建思路AC自动机解决的是标准名对齐但用户不会按标准名提问。真实问法往往是这样“我奶奶最近老是忘事脾气也变差了”对应的标准症状是“记忆力减退”“易怒”。词典匹配在这里会全军覆没所以要先做命名实体识别把句子里的“忘事”“脾气变差”标出来再映射回标准症状名。标注方案用BIOCC字段表示实体类型取值有drug、symptom、disease等。比如“最近总是失眠”这句话里“失”标B-symptom“眠”标I-symptom。这个项目和一般NER数据集最大的区别在于训练数据不是人工标注的而是用模板自动生成的每个领域词在数据里只出现一次避免模型死记词表。生成逻辑是先准备一批问句模板模板里留出槽位再从领域词典随机抽词填进去。# gen_ner_data.py - 基于问题模板生成BIO标注数据 import random # 模板里的[NER]槽位会被随机替换成领域词 TEMPLATES [ 最近一直[NER]不知道是什么原因, [NER]持续好几天了该挂什么科, 我想问一下[NER]和[NER]是同一个病吗, ] def make_tag(words, start, end, etype): tags [O] * len(words) tags[start] fB-{etype} for i in range(start 1, end): tags[i] fI-{etype} return tags def generate_one(template, entity, etype): # 把实体词替换进[NER]再加一个仿真实句子的噪声词 text template.replace([NER], entity) words text.split() start words.index(entity.split()[0]) end start len(entity.split()) tags make_tag(words, start, end, etype) return list(zip(words, tags))逻辑说明槽位替换后实体类型通过etype参数指定这样同一个模板能同时生成symptom、disease、drug三类数据。start的定位方式依赖entity.split()的首词在句子里的位置要求模板里的[NER]前后都有空格否则index会错位。生成完成后按9:1切分train.txt和dev.txt格式是“字 标签”每行一个空行分隔句子这是BERT序列标注的标准输入格式。3.2 训练BERTCRF模型参数与输出目录BERTCRF的训练脚本遵循transformers的标准写法但有几个参数必须按这个项目的语料规模调。训练数据是模板生成的单个体量不大BERT全参数微调容易过拟合学习率要比常规任务小一档。# run_ner.sh - BERTCRF训练命令参考 python med_kg/ner_model/run_ner.py \ --do_train \ --do_eval \ --train_data data/train.txt \ --dev_data data/dev.txt \ --model_name_or_path chinese_bert_base \ --output_dir med_kg/ner_model/outputs \ --max_seq_length 128 \ --batch_size 32 \ --learning_rate 3e-5 \ --num_train_epochs 3 \ --crf_lr 1e-3参数说明这里把CRF层的学习率单独设成1e-3比BERT主干高一个量级。原因是CRF层随机初始化BERT是预训练权重两者收敛速度不同共用同一个学习率会让CRF层学得慢。max_seq_length设128就够问句一般不超过50个字超过只会浪费显存。训练完后outputs目录下会有pytorch_model.bin和label2id.json直接用。如果不想自己训练资源包里已经有训练好的模型参数解压放到med_kg/ner_model/outputs目录下推理代码会自动加载。label2id.json里的标签顺序必须和训练时一致否则模型加载后预测结果全偏。3.3 SBERT相似度计算与重叠字兜底策略NER只能识别出实体提及词比如“忘事”它还不是标准症状名。要把“忘事”映射回“记忆力减退”这里用SBERT做相似度计算。做法提前把所有词典词用SBERT编码成向量存起来推理时把识别出的提及词也编码做余弦相似度取top20候选再结合重叠字长度做最终排序。重叠字这个兜底策略很关键。SBERT的向量空间对短文本不总是靠谱“忘事”和“健忘”语义接近但余弦分可能只有0.7左右而“忘事”和“尿频”这种完全不相关的词有时也能到0.5。所以只看相似度不够要把两边的公共子串长度加权进去。# entity_link.py - 实体链接的top20重叠字兜底 import numpy as np from sentence_transformers import SentenceTransformer sbert_model SentenceTransformer(sbert_model) def link_entity(mention, type_embeddings, type_names, topk20): mention: NER识别出的提及词如忘事 type_embeddings: 词典词向量矩阵shape [N, 768] type_names: 词典词列表长度N mention_emb sbert_model.encode([mention], normalize_embeddingsTrue) emb_norm type_embeddings / np.linalg.norm(type_embeddings, axis1, keepdimsTrue) scores mention_emb emb_norm.T # 余弦相似度 top_idx np.argsort(-scores[0])[:topk] best_name, best_overlap None, 0 for idx in top_idx: candidate type_names[idx] # 重叠字长度计算公共最长连续子串 overlap_len longest_common_substring(mention, candidate) if overlap_len best_overlap: best_overlap overlap_len best_name candidate return best_name def longest_common_substring(s1, s2): m, n len(s1), len(s2) dp [[0] * (n 1) for _ in range(m 1)] max_len 0 for i in range(1, m 1): for j in range(1, n 1): if s1[i - 1] s2[j - 1]: dp[i][j] dp[i - 1][j - 1] 1 max_len max(max_len, dp[i][j]) return max_len逻辑说明normalize_embeddingsTrue把向量归一化到单位长度这样点积就是余弦相似度省一次显式计算。topk取20是经验值太小可能漏掉正确实体太大则引入噪声。重叠字用动态规划求最长公共子串而不是简单计数避免“头痛”和“头晕”因为共用一个“头”字产生误判。这个逻辑跑在全部候选上实际效果是“忘事”最终映射到“记忆力减退”因为“忘事”和“记忆力减退”虽然没有公共子串但SBERT分数最高而“低热”能在top20里靠子串“热”直接命中。3.4 意图识别规则模板为什么在这里够用意图识别没有上深度学习模型而是沿用规则模板。原因在于医疗问句的意图类别本来就少主要就“症状→疾病”“疾病→症状”“疾病→药品”三类。规则方式识别这类意图非常稳定BERT反而可能因为训练数据不足把“头痛挂什么科”和“头痛吃什么药”搞混。实现上是提问词提及词类型的组合匹配。比如“挂什么科”对应科室咨询意图“吃什么药”“用什么药”对应用药意图“怎么办”“是什么病”对应疾病判断意图。每个意图预设一组提问词再结合NER识别出的实体类型组合判定。这个方案边界清晰而且后续如果要做深度学习意图识别现在的规则结果可以直接当弱标签数据。# intent_rule.py - 基于提问词实体类型的意图判定 INTENT_PATTERNS { disease_detect: [是什么病, 怎么办, 怎么回事, 啥原因], drug_recommend: [吃什么药, 用什么药, 吃啥药], department: [挂什么科, 看什么科], } def detect_intent(question, entities): intent disease_detect # 默认意图 max_score 0 for intent_name, keywords in INTENT_PATTERNS.items(): for kw in keywords: if kw in question: score len(kw) # 实体类型对意图有加权药品实体出现时询问更偏向用药 if intent_name drug_recommend and drug in entities: score 2 if score max_score: max_score score intent intent_name return intent参数说明INTENT_PATTERNS里的提问词覆盖不够时直接在字典里加词就行不用改代码。score的加权规则是关键比如“头痛吃什么药有效”这句话同时含“什么药”和“什么”不加权会误判成默认意图。这里用提及词的实体类型作为信号drug实体存在时drug_recommend的分数抬升意图判定就稳了。4. 推理与前端多症状疾病交集与echarts力引导展示4.1 从单症状匹配升级成多症状疾病交集原项目处理“头痛而且发热”这类多症状问题时做法是单个症状分别查疾病再合并结果。这个逻辑有个反直觉的问题一个疾病只要有其中任意一个症状就会被返回最后列出来一大堆不相关的病。用户说“头痛发热”本意是同时具备这两个症状合并逻辑完全违背这个意图。改进后改成交集先分别查出每个症状对应的疾病集合再取这些集合的交集。实现上用Cypher的INTERSECTION或者先MATCH多个路径再取共同节点。项目里实际用的方式是这样的// multi_symptom_intersect.cypher - 多症状取疾病交集 MATCH (d:Disease)-[:HAS_SYMPTOM]-(s1:Symptom {name: 头痛}) WITH d MATCH (d)-[:HAS_SYMPTOM]-(s2:Symptom {name: 发热}) RETURN d.name AS disease, size([(d)-[:HAS_SYMPTOM]-(s:Symptom) | s.name]) AS total_symptoms ORDER BY total_symptoms DESC LIMIT 10;逻辑说明第一个MATCH先圈定“有头痛的疾病集合”第二个MATCH在集合内部再过滤“同时有发热的”两个条件取的是交集。这样“头痛发热”返回的疾病一定同时包含两个症状。ORDER BY total_symptoms是后续高发性候选排序的依据症状越多的疾病在描述上覆盖越全。4.2 Django后端接口与前端力引导图问答系统的后端跑在Django上启动命令很简单cd medical_knowledge_graph_app-master python med_kg/manage.py runserver 8000访问http://localhost:8000就能看到问答页面。前端用echarts的力引导图做知识图谱可视化用户输入问句后后端把答案实体和与它直接相连的三级关系返回前端渲染成一张可拖拽的图谱。力引导图不适合展示全量99492个三元组所以接口层做了限制只返回答案疾病节点、它的症状节点、推荐药品节点最多30个关系。这里有个容易被忽略的问题Django默认的runserver是单线程的BERT模型推理一次大约耗时200-400毫秒并发用户一多响应时间会成倍上涨。复现阶段无所谓但如果要演示建议用gunicorn启动或者至少把模型全局实例化别每次请求都load一遍。模型加载本身就是秒级操作放在视图函数里会导致第一个请求特别慢后面每来一个请求也快不了多少。4.3 避坑记录复现时真正卡住我的五个问题以下五条是按现象到原因到解决的顺序写的全部在复现这个项目时遇到过。Neo4j服务起不来提示Unsupported Java version。原因是系统默认Java版本是1.8neo4j 4.1.4要求Java 11。解决方式是安装OpenJDK 11并把JAVA_HOME指向它启动前在shell里export一下。AC自动机匹配出的症状名夹杂大量单字噪声。原因是output节点合并逻辑缺失只匹配到长词但不返回其前缀子词比如“头痛”匹配到了但“头”也会被当成症状。解决方式是确保self.output[u] | self.output[self.fail[u]]这一行在build循环里执行不能漏。BERT模型加载时报label2id尺寸不匹配。原因是训练时数据里包含三类实体下载的训练好的模型是五类实体label2id.json不一致。解决方式是把资源包里的label2id.json和模型文件放在同一个outputs目录不要混用不同版本。echarts力引导图渲染空白。原因是后端接口返回的JSON里节点ID重复echarts要求每个节点唯一重复时graph组件直接整个不画。解决方式是在Django返回前对节点做去重保证id唯一。口语化问句实体链接结果很奇怪比如“感冒”被链接到“感染”。原因是SBERT的embedding对同义词对表现不如预期而且SBERT模型本身没有针对医药语料微调。解决方式是增大topk到20并且保留重叠字兜底逻辑不要只用余弦相似度。这五条里面第3条最隐蔽因为它不是报错崩溃而是模型正常加载但预测结果错误率高不对比train.txt和label2id.txt根本发现不了。5. 验证与进阶换数据集前先做这三件事拿到源码先别急着改按顺序做三件事跑通原链路、验证三元组数量、再考虑替换数据。第一件是跑通原链路。启动Neo4j导入数据后启动Django服务在问答框里输入“头痛发热”确认返回疾病列表再输入“我最近老是忘事脾气也变差了”确认走BERTCRF这条链路而不是直接报空。这两条测试分别覆盖精确匹配和口语化匹配两条路径前者走词典命中后者走NER实体链接跑通这两条基本就验证了整个架构。第二件是验证数据质量。打开Neo4j浏览器执行MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom) RETURN count(*)看关系数和项目说明比较。再做一次抽样检查随机挑一个疾病如“偏头痛”看它关联的症状里有没有“头晕”“恶心”排除AC自动机匹配到无意义子词的情况。第三件才是替换数据。如果你想换成其他领域的知识图谱问答比如消化科或儿科核心要改的不是模型而是词典。把symptom.txt替换成新领域的症状名重新生成问句模板数据再训练BERT实体链接的词典embedding也要重新生成。模型本身不需要换BERTCRF的结构是通用的换领域后只需要微调少数epoch。我从这个项目里学到的教训是BERT这类模型不是银弹在短文本实体链接这种任务上一个简单的重叠字规则就能把准确率拉回好几个点。从那以后我每次做实体链接都强制走一遍“向量相似度top20字面特征兜底”的组合逻辑先看向量再用字面特征修正最后才交付。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑