简介:面向计算机相关专业毕业设计与项目实战场景,这套基于Python+BERT+词典的医药知识图谱自动问答系统,完整覆盖知识图谱构建、问答后端与前端交互三大环节。项目在原有基础上针对症状到疾病推理做了优化,从症状描述文本中利用AC算法对齐出4377个症状名,重构疾病-症状-症状名三元组达99492个,并引入BERT+CRF模型处理口语化症状表达,再通过SBERT相似度计算完成实体链接,最终结合规则实现多症状疾病交集推理,整体流程清晰、可扩展性强。压缩包共363个文件,以117个py源码、90个js交互脚本、25个html页面、27个css样式为主,辅以10个md文档、12个txt数据说明及6个pkl模型文件,便于按模块阅读与二次开发,包体约72MB。配套超详细安装教程、训练好的NER模型与原始数据,NEO4J启动后即可运行,适合有一定Python基础、希望快速上手医药问答系统或完成毕设答辩的学习者。目前已有269人学习下载,项目获导师认可、评审96.5分,代码经测试可稳定运行,是一份完整度很高的实战参照。
1. 医药知识图谱自动问答:BERT、词典、图谱如何三合一,落地值不值得做
用户问“感冒了吃什么药”,普通搜索给的是网页,而医药知识图谱自动问答系统给的是结构化答案——按症状、药品、禁忌等关系把知识链直接抽出来答给你。项目把三条技术线串在一起:BERT负责意图理解,词典负责医药实体收敛,知识图谱负责答案组织。三者各管一段,比单用BERT端到端生成更可控,也比纯规则匹配更抗口语变形。适合正在做医学问答客服、导诊问答的开发者,以及想用Python完整跑一遍BERT加知识图谱项目的NLP学习者。会基础Python、能看文档装依赖,就能跟完全流程。
2. 建库阶段:从原始医药数据到知识图谱三元组,预处理代码和存储选型一次讲清
2.1 图谱结构设计:实体、关系、属性三种要素怎样支撑后续问答
知识图谱的核心是三元组(head, relation, tail),外加挂在边上的属性。医药场景最典型的三元组是:(阿莫西林, 治疗, 细菌性感染)、(布洛芬, 副作用, 胃肠道反应)、(磺胺类药, 禁忌, 孕妇)。回答“阿莫西林能治什么”时,沿head=阿莫西林找所有治疗关系;回答“布洛芬有什么副作用”时,沿布洛芬找副作用边。设计阶段别把关系名定得太细,“治疗”和“用于治疗”语义相同,统一成“治疗”就好,否则图谱查询和意图映射都要跟着写分支。
实体类型建议按药品、疾病、症状、人群四类起步,关系按治疗、缓解、禁忌、副作用、成分、相互作用六类起步。这个粒度对问答已经够用。设计原则是先想清楚哪些查询会落到图谱上,别把成段的用药说明硬拆成三元组,那些内容放在属性里更合适。多一跳查询就多一份维护成本,初期宁少勿多。
2.2 词典与实体对齐:同一种药有多个名字,词典该以什么粒度建
医药名词天然多名一实:通用名、商品名、别名。“阿莫西林”又叫“阿莫仙”“阿莫西林胶囊”“羟氨苄青霉素”。系统要回答,就必须先把用户话里的说法指到同一个图谱实体上。做法是维护一份别名到标准名的映射词典,比如一行“阿莫仙,阿莫西林”。这个词典同时喂给后面词典匹配用。
粒度上建议首选标准名作为图谱节点,别名存入同义列。词典条目至少含三列:标准名、别名列表、实体类型。如果源头数据来自科室表、药品说明书、疾病词库的拼接,格式一定不统一,构建阶段就要做实体对齐:先统一大小写和全半角,再把括号内容剥掉(“阿司匹林(Aspirin)”只保留“阿司匹林”)。这两步覆盖医药数据里绝大部分脏噪音。图谱节点、词典标准名、三元组json里出现的名字,三者必须严格一致,这是整个系统能对上号的前提。
2.3 数据预处理代码:Pandas清洗原始表并生成三元组的实战脚本
原始医学数据常见格式是CSV,表头一般是[药品, 疾病, 关系, 剂量, 备注]。真正实现时按下面这段脚本处理最少踩坑:
import pandas as pd import re import json df = pd.read_csv("medical_raw.csv", encoding="utf-8") df = df.dropna(subset=["药品", "疾病", "关系"]) def normalize(text): if not isinstance(text, str): return "" text = re.sub(r"[((][^))]*[))]", "", text) # 去掉括号内别名 text = re.sub(r"\s+", "", text) # 去掉空白和全半角空格 return text.strip().lower() df["药品"] = df["药品"].apply(normalize) df["疾病"] = df["疾病"].apply(normalize) df["关系"] = df["关系"].str.strip() df["剂量"] = df["剂量"].astype(str).str.replace(r"[\s,,]", "", regex=True) # 关系名统一,减少图谱查询时的分支 df["关系"] = df["关系"].replace({"用于": "治疗", "适应症": "治疗"}) triples = [] for _, row in df.iterrows(): triples.append({ "head": row["药品"], "relation": row["关系"], "tail": row["疾病"], "attrs": {"dosage": row["剂量"]} }) with open("triples.json", "w", encoding="utf-8") as f: json.dump(triples, f, ensure_ascii=False, indent=2) entities = set() for t in triples: entities.add(t["head"]) entities.add(t["tail"]) with open("entity_dict.txt", "w", encoding="utf-8") as f: f.write("\n".join(sorted(entities))) print(f"三元组 {len(triples)} 条,实体 {len(entities)} 个")逻辑说明:这段代码的核心是把原始表清洗成统一的三元组结构。normalize把括号里的商品名或英文名剥掉,避免“阿司匹林(Aspirin)”在实体匹配时因为括号对不上而漏配;关系名替换成统一叫法,是为了后面查询时不用为两种写法写两条分支;剂量列的清理直接去掉逗号和空格,防止剂量这类属性文本带干扰。输出两个文件:triples.json是图谱本体,entity_dict.txt是后续词典匹配和BERT标注都要用的实体表。
参数说明:关系替换表可以根据你手里的数据自定义,原则是每个语义只保留一种写法;正则r"[((][^))]*[))]"剥括号,如果你希望保留部分括号信息,改成只剥英文括号即可。编码统一用utf-8,医药中文数据最容易在这里出乱码。
2.4 存储选型:本地JSON够不够,什么时候上Neo4j
预处理产出JSON三元组后,下一步是决定存哪。对1000到5000条三元组的量级,本地JSON加内存邻接表完全够用,启动快、排错直观,不需要额外依赖。多数医药问答应试点用这个方案就能跑起来。
当查询需要两跳以上,比如“治疗高血压的药里,哪些和利尿剂有相互作用”,邻接表在内存里手写多跳也可以做,但代码会越来越绕。到这一步再上Neo4j更划算:一条Cypher语句MATCH (a)-[:治疗]->(b) WHERE b.name='高血压' RETURN a.name就能查完。常见做法是用Docker起一个Neo4j容器存放三元组,用官方驱动在Python里查询。但在项目初期别为了图数据库而图数据库——多一个组件就多一份部署和排错成本。建议路径是先JSON跑通链路,等出现真实二跳需求再迁Neo4j。
注意:无论哪种存储,实体名必须和词典里的标准名保持一致。最容易翻车的地方是同一个实体在数据里有“阿莫西林”和“阿莫西林胶囊”两种写法,图谱查询就永远查不到第二条。
3. 模型阶段:加载BERT做意图识别和实体抽取,词典增强让长尾问题不落空
3.1 任务拆分:为什么BERT在这里做意图分类而不是端到端生成答案
BERT是理解模型,不是生成模型,拿来直接做问答生成(比如让它编一句“你应该吃阿莫西林”)很容易一本正经地胡说八道,医学场景承受不起这种幻觉。所以常见做法是把BERT拆成两个任务:一是意图分类,判断“感冒吃什么药”属于用药查询、“xx有什么副作用”属于副作用查询;二是序列标注(NER),在用户话里抽取药品、疾病等实体。这两个任务的输出都进图谱检索层,由图谱给出事实答案,而不是由BERT现场编话。这样的好处是答案带出处、可解释,用户追问“为什么是这个答案”时能指回三元组来源。
意图分类类别建议控制在六到八类以内,太少会互相吞并,太多在数据不足时互相踩。参考分类和对应查询方向如下:
| 意图类别 | 用户问法示例 | 图谱查询方向 |
|---|---|---|
| 用药查询 | 感冒了吃什么药 | 疾病 →(治疗)→ 药品 |
| 副作用查询 | 布洛芬有什么副作用 | 药品 →(副作用)→ 症状 |
| 禁忌查询 | 孕妇能不能吃这个药 | 药品 →(禁忌)→ 人群 |
| 剂量查询 | 阿莫西林一天吃几次 | 药品 →(属性)→ 剂量 |
NER直接用BERT做Token分类也可以,但如果训练数据只有几千条,先用词典匹配把能识别的实体全部识别掉,再把词典漏掉的口语化实体交给BERT的NER兜底,命中率会高很多。这也是标题里“BERT+词典”组合的真实用意:模型管理解,词典管精度。
3.2 代码:用transformers加载已训练BERT并写推理函数的完整流程
项目里已经包含训练好的模型,落地时只需要加载推理,不需要重新训练。下面这段是意图识别的推理代码,NER加载方式类似,只是模型类换成AutoModelForTokenClassification:
from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch intent_tokenizer = AutoTokenizer.from_pretrained("model/intent_model") intent_model = AutoModelForSequenceClassification.from_pretrained("model/intent_model") intent_model.eval() INTENT_LABELS = ["用药查询", "疾病查询", "副作用查询", "禁忌查询", "剂量查询", "其他"] def predict_intent(question, threshold=0.6): inputs = intent_tokenizer( question, max_length=128, truncation=True, return_tensors="pt" ) with torch.no_grad(): outputs = intent_model(**inputs) scores = torch.softmax(outputs.logits, dim=-1) score, idx = torch.max(scores, dim=-1) if score.item() < threshold: return "其他", score.item() return INTENT_LABELS[idx.item()], score.item() print(predict_intent("感冒了应该吃什么药")) # 期望输出: ("用药查询", 0.9x)逻辑说明:用AutoTokenizer和AutoModelForSequenceClassification从预训练模型目录加载模型文件,eval()切到推理模式;max_length=128表示把用户输入截断到128个token,因为医疗问题的有效信息基本都在前128个token内,过长会被截掉后半部分;threshold=0.6是置信度阈值,低于0.6一律归到“其他”,避免低置信度答案污染业务。torch.no_grad()关闭梯度计算,推理时减少显存占用,也快一些。
参数说明:如果跑在CPU上,可以在from_pretrained里保持默认torch.float32,避免类型转换的额外开销;如果显存紧张,推理时一次传入一个batch,别在循环里单挑调用。threshold的取值跟着业务走,医药场景宁可错杀也别放低质量的回答。
3.3 词典与BERT融合:匹配结果如何合并,优先级怎么定
BERT能理解句子,但医药实体离了词典会漂。词典匹配输出的是确定的实体名,可靠性最高;BERT的NER输出的是模型认为的实体,有一定概率识别错。常见做法是先在词典层做一次全量匹配,然后把BERT识别到的实体在词典里再查一遍,命中就置信度加一档,未命中但属于药品或疾病类型时保留进候选。最终实体集合以“优先词典命中,其次BERT高置信度命中”的顺序生成查询条件。
具体实现上,词典匹配用最长串优先:先把词典按实体长度从长到短排,逐个在问题文本里查找,命中就记录。先短后长会把“阿莫西林胶囊”拆成“阿莫西林”加“胶囊”,明显不对。这个匹配函数写起来不复杂,但效率是关键,真正落地时要把词典先构建成前缀树,否则几千个实体逐个in扫描,每个问题都要轮一遍,CPU下会拖出秒级延迟。如果只是验证流程,下面这个简化版够用:
def extract_entities_by_dict(text, entity_list): text = text.lower() matched = [] for entity in sorted(entity_list, key=len, reverse=True): if entity in text and entity not in matched: matched.append(entity) # 去掉互为包含的冗余,保留最长实体 final = [] for e in matched: if not any(e in other for other in matched if other != e): final.append(e) return final参数说明:这个函数只做演示,实际线上建议把entity_list构建成Trie前缀树,匹配复杂度从O(N×L)降到O(L),N是词典条目数,L是文本长度。医药词典几千条时不明显,上到几万条时差距就出来了。
3.4 必调参数:max_length、batch_size、threshold对线上效果的影响
这几个参数直接决定推理速度和回答质量。max_length过短会截掉重要实体,过长浪费计算——中文医疗问题平均十几个字,128足够。batch_size在CPU推理时调成1最稳,GPU可以试着8或16,但要注意显存上限,爆显存就调小。
threshold是整个系统里唯一值得反复调的参数:调高了,系统倾向于拒绝回答,用户体验差;调低了,低置信度的错误回答会漏到用户面前,医药场景绝不允许。建议拿一批真实客服日志跑一遍,统计各个threshold下的准确率和拒答率,取平衡点。我一般先按0.6上线,跑两周看日志再往0.65或0.55挪。
另外还有个容易忽略的点:BERT分词对中文是按字切,max_length统计的是token数而不是字数。“阿莫西林胶囊”切完是5个token,128上限一般不会碰壁。但如果用户输入是1000字的病历,截断是必然的,这时候要在预处理阶段按句子切分,只把包含实体的句子喂给模型,比硬截更合理。
4. 问答主链路:从用户输入到图谱查询和答案生成的完整实现
4.1 工作流设计:预处理、意图识别、实体抽取、图谱查询、答案合成五步走
一个完整的医药问答请求走五步:第一步文本预处理,把全角字符统一、去掉无意义标点;第二步意图识别,用上一章的BERT模型判断用户想问什么;第三步实体抽取,用词典加BERT融合提取药品、疾病等实体;第四步图谱查询,把意图和实体拼成图谱查询条件;第五步答案合成,把三元组结果组装成人话。所有步骤在一个medical_qa函数里串起来,业务方只需调用一个入口。
这个设计的好处是每一段都能单独替换:意图模型想升级就只换模型目录,词典想更新就只改词典文件,图谱想换Neo4j就只重写查询段。不要把所有逻辑写进一个大循环里,否则项目迭代到第二个月就开始互相改坏。
4.2 代码:一个medical_qa函数串起全流程,本地图谱类可直接复用
import json class MedicalKG: def __init__(self, triples_path): self.triples = json.load(open(triples_path, encoding="utf-8")) self.adj = {} for t in self.triples: self.adj.setdefault(t["head"], []).append((t["relation"], t["tail"], t["attrs"], 1)) self.adj.setdefault(t["tail"], []).append((t["relation"], t["head"], t["attrs"], -1)) def query(self, entity, intent): intent_to_rel = { "用药查询": "治疗", "疾病查询": "治疗", "副作用查询": "副作用", "禁忌查询": "禁忌", "剂量查询": "用药剂量", } rel = intent_to_rel.get(intent) out = [] for r, target, attrs, direction in self.adj.get(entity, []): if rel is None or r == rel: out.append({"relation": r, "target": target, "attrs": attrs, "direction": direction}) return out def medical_qa(question, intent_func, dict_entity_func, kg): # 1. 意图识别 intent, intent_score = intent_func(question) # 2. 实体抽取:词典优先 entities = dict_entity_func(question) if not entities: return "我没识别出药品或疾病实体,请换个说法试试。", { "intent": intent, "entities": [], "score": intent_score} # 3. 图谱查询 answers = [] for entity in entities: for a in kg.query(entity, intent): a["entity"] = entity answers.append(a) # 4. 答案合成 if not answers: return "知识库里没有找到相关答案,你可以问“xx药治什么病”或“xx病的治疗方法”。", { "intent": intent, "entities": entities, "score": intent_score} lines = [] for a in answers[:3]: dosage = a["attrs"].get("dosage", "") if intent == "用药查询": if a["direction"] == 1: lines.append(f"{a['target']}可用{a['entity']}治疗,剂量参考:{dosage}") else: lines.append(f"{a['target']}可用于治疗{a['entity']},剂量参考:{dosage}") elif intent == "副作用查询": lines.append(f"{a['entity']}的副作用包括:{a['target']}") elif intent == "禁忌查询": lines.append(f"{a['entity']}禁忌人群或情况:{a['target']}") else: lines.append(f"{a['entity']} → {a['relation']} → {a['target']},{dosage}") return "\n".join(lines), { "intent": intent, "entities": entities, "score": intent_score}逻辑说明:这个函数是可跑通的最小实现。MedicalKG从JSON构建双向邻接表,query按意图映射关系类型,避免“用药查询”时把副作用结果混进来。medical_qa先调意图、再抽实体,实体为空直接拒答并记录状态;图谱结果为空时给兜底话术;答案合成把三元组拼成带剂量说明的可读文本。整个流程没有用Neo4j,适合跑通验证,等需要多跳查询再替换MedicalKG.query为Cypher调用即可。
参数说明:answers[:3]限制了返回条数,医药答案同一实体下可能有很多条,取前3条可读性最好;attrs.get("dosage", "")保证剂量缺失时不报错。如果你已经上了Neo4j,只需要把MedicalKG换成驱动连接,保持query方法签名一致,上层的medical_qa不用动。
4.3 兜底与日志:答案为空时怎么办,未命中数据如何采集
问答系统最怕的不是答错,而是答错还不自知。兜底逻辑要做两层:第一层是置信度兜底,意图识别低于threshold直接进“其他”,不一味乱答;第二层是图谱兜底,实体匹配到了但图谱没对应关系,给用户一个引导式话术。两层的共同点是都记日志,把问题、识别出的意图、实体、阈值分数全部落盘,供后续迭代用。
日志字段至少五个:时间、原始问题、预测意图、实体列表、是否命中。每周刷一次日志,把未命中的问题聚类,凡是聚到10次以上的,就是词典缺词或图谱缺三元组的明确信号。这套朴素的数据驱动流程,比拍脑袋加规则实用得多,也是项目从“能跑”走向“能用”的关键。
5. 安装与部署避坑:环境、版本、模型加载和数据踩过的四个大坑
5.1 安装顺序与requirements:为什么版本锁得越死,后续越省心
拿到项目压缩包后先看目录,一般包括requirements.txt、data目录、model目录、src目录和docs目录。安装前先确认Python版本,项目如果写明是3.8或3.9,就用那个版本,别拿3.12硬跑,transformers的老版本在3.12上会有兼容性问题。安装顺序固定为:Python → pip源配置 → PyTorch → transformers和其他依赖。PyTorch要和CUDA版本匹配,用CPU只装CPU版,别急着装GPU版找罪受。
# 以CPU环境为例,GPU环境把PyTorch安装命令换成对应CUDA版本 python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install -U pip pip install torch --index-url https://download.pytorch.org/whl/cpu pip install -r requirements.txt python scripts/quick_check.py # 验证模型能不能加载、图谱有没有数据逻辑说明:先建虚拟环境是为了避免污染系统Python;CPU版PyTorch安装体积小,也能跑通全流程验证;requirements.txt里如果有版本区间限定,尽量按项目给的原样装,不要手动升级大版本。quick_check.py是我习惯先写的一个脚本,只跑模型加载和小规模图谱查询,确认环境没问题再启动正式服务。
5.2 坑一:transformers与PyTorch版本打架,模型加载直接崩溃
现象:执行from_pretrained时报RuntimeError或AttributeError: module 'torch' has no attribute 'xxx',看起来像代码写错,实际上是版本错位。
原因:transformers新特性和旧版PyTorch不兼容,或者PyTorch新版需要新版transformers;模型文件是safetensors新格式时,旧版加载库直接报错。
解决:按项目requirements.txt原样安装,不要单点升级。安装时用pip install "transformers==4.36.2" "torch==2.1.2"这类锁定写法。如果项目没给版本,按transformers 4.x加torch 2.x的组合选,这两个大版本内部兼容性较好。
5.3 坑二:中文BERT对医药实体产出偏移,实体边界对不上
现象:模型识别“布洛芬缓释胶囊”时只抽出“布洛芬”,实体边界总是少几个字,词典匹配时对不上图谱里的完整节点。
原因:中文BERT按字切分,预训练时没有医药实体标注数据,边界本来就漂;如果训练数据里实体标注老是有头无尾,模型就学了个坏习惯。
解决:不要在模型端硬改,回到词典层兜底。把“布洛芬缓释胶囊”这类高频完整名直接放进词典实体表,词典匹配优先命中,BERT结果作为补充。同时检查训练数据的标注质量——标注时要求实体范围和图谱节点完全一致,不一致的样本直接剔除。这个坑最容易在“看起来模型能跑就行”的心态下糊弄过去,上线后误答一堆。
5.4 坑三:词典越长匹配越慢,CPU推理超时
现象:本地跑单条问题要2到3秒,用户明显等不了;查日志发现时间全花在词典匹配段。
原因:词典实体条数上了万,还在用for entity in entity_list: if entity in text这种线性扫描,每个用户请求都要轮一遍万级词典。
解决:词典匹配段改成Trie前缀树实现,复杂度从O(N×L)降到O(L)。构建一次放内存,查询按字符走树,毫秒级返回。另一个技巧是先把实体长度过滤到2字以上,医药领域单字实体基本是噪音。
5.5 坑四:Neo4j容器反复重启,图谱查询连不上
现象:Docker起Neo4j后端口能通,但Python连接时bolt://localhost:7687报ConnectionRefused,容器日志显示反复重启。
原因:多数是内存配置超了容器上限,或数据目录权限不对。Neo4j默认堆内存偏大,在Docker默认限制下直接OOM。
解决:单机跑用docker run -e NEO4J_AUTH=neo4j/your_password -p 7474:7474 -p 7687:7687 -v ./neo4j_data:/data -m 2g neo4j:4.4限制容器内存为2G,并挂载数据目录。如果只是验证流程,用JSON方案更省事。真上线时医疗数据不要用默认密码,至少改成强密码,数据卷定期备份。
6. 验证与进阶:一套实测方法、两个升级方向、一个工程化习惯
6.1 验证方法:80道测试题,算准确率和召回率
准备80条真实问题,覆盖六大意图、每类至少10条,带上标准答案。跑一遍系统,按“回答正确”和“能回答”两个维度统计:准确率=正确回答数除以总问题数,召回率=能产生回答的问题数除以总问题数。准确率反映答得对不对,召回率反映敢不敢答。医药场景宁可召回率低一点也别让准确率掉下来,答错一句比拒答一句严重得多。80条的规模不大但够找短板,跑完再针对长尾问题补词典。
6.2 升级方向:从词典匹配到向量检索再到Neo4j多跳查询
词典匹配的优点是快且可控,缺点是遇到没见过的口语说法直接漏。下一步可以在词典之外加一层向量检索:把实体名和用户问题都转成向量,用余弦相似度召回候选实体,再拿候选去图谱里查。再到更大规模,把图谱迁到Neo4j,做多跳查询,比如“高血压患者不能吃哪种感冒药”这种跨实体链问题,一条Cypher就能查出结果。升级时保持query接口不变,上层逻辑不用动。
6.3 工程化习惯:日志驱动的词典与图谱迭代
把每次未命中的请求记录到日志,每周刷一遍,找出出现次数最多的未命中问题,补充对应的词典别名和图谱三元组。这个习惯比任何花哨算法都出效果,因为线上用户的话术永远比测试集丰富。我自己的经验是坚持两个月,系统针对率可以稳定提升十几个点,比盲目微调BERT模型更划算。
希望这些落地的参数和坑能帮你把这个系统跑起来,少走一些当时的弯路。
本文还有配套的精品资源,点击获取