ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python+BERT+词典构建医药知识图谱问答系统实战

Python+BERT+词典构建医药知识图谱问答系统实战

简介:本资源为基于Python、BERT与词典融合开发的医药知识图谱自动问答系统完整项目包,面向计算机相关专业做毕业设计、课程设计或项目实战的学生与开发者。项目涵盖医药知识图谱构建、问答后端搭建与前端交互三大模块,通过爬虫重构疾病症状三元组99492个,并引入BERT+CRF做命名实体识别、SBERT完成实体链接,支持多症状疾病交集推理,前端用ECharts力引导图展示图谱。压缩包共363个文件,以117个py源码、90个js脚本、25个html页面、27个css样式及pkl模型、txt词典、md文档为主,整体约72.36MB,目录结构清晰。已有270人学习。资源内含训练好的模型、数据集、超详细安装教程与文档说明,代码均经测试可运行,答辩评审96.5分,适合直接复用或二次开发。

1. 医药知识图谱问答系统:从一句“阿司匹林怎么吃”到结构化答案的完整链路

医药领域的问答需求有个很别扭的特点:用户问得极短,但答案必须极准。“阿司匹林怎么吃”“二甲双胍能和什么一起吃”“布洛芬和对乙酰氨基酚能一起用吗”——这些问题背后牵扯的是药品说明书、适应症、禁忌症、不良反应、药物相互作用等一整套结构化知识。通用搜索引擎能给你一堆网页,但没法直接给你一个可验证的答案。基于Python+BERT+词典开发的医药知识图谱自动问答系统,解决的正是这个断层:把医药知识组织成图谱,用BERT做语义理解,用词典做实体兜底,最终让系统从图谱里检索出结构化答案。这套方案适合有一定Python基础、想入门知识图谱问答的开发者,也适合做医疗信息化、智能客服方向的技术团队做原型验证。整条链路不依赖外部API,训练好的模型和数据都在本地,跑通之后你能清楚看到“问句→实体识别→意图分类→图谱查询→答案生成”每一步在做什么。

2. 系统架构与核心模块拆解:为什么是BERT+词典+图谱三件套

2.1 三个模块各自解决什么问题

先把这个系统的骨架说清楚。医药问答的难点不在“问答”本身,而在“医药”两个字带来的三个约束:术语多、同义词多、容错低。

词典模块负责的是确定性匹配。医药领域有大量固定术语,比如“阿司匹林肠溶片”“布洛芬缓释胶囊”“二甲双胍片”,这些词在问句里出现时,用词典做精确匹配和别名映射是最稳的。词典里通常存三样东西:标准实体名、别名列表、实体类型(药品/疾病/症状/食物)。比如“阿司匹林”的别名可能有“乙酰水杨酸”“拜阿司匹灵”,词典匹配能把这些变体统一到同一个实体ID上。

BERT模块负责的是语义理解。用户不会总按标准术语提问。“吃了阿司匹林胃疼怎么办”这句话里,“胃疼”不是标准疾病名,但BERT能把它映射到“胃部不适”这个意图上。BERT在这里主要做两件事:一是命名实体识别(NER),从问句里抽出药品名、疾病名、症状名;二是意图分类,判断用户问的是“用法用量”“禁忌”“相互作用”还是“不良反应”。

知识图谱模块负责的是答案检索。图谱用三元组存储医药知识,形如(阿司匹林,适应症,解热镇痛)、(阿司匹林,禁忌,胃溃疡患者禁用)。当BERT识别出实体和意图后,系统把实体链接到图谱节点,按意图对应的关系类型去查询,拿到答案后做自然语言模板生成。

三者是互补关系,不是替代关系。词典解决“这个词我认识”,BERT解决“这句话什么意思”,图谱解决“答案在哪里”。缺了词典,同义词和别名会把BERT带偏;缺了BERT,口语化问句没法处理;缺了图谱,答案就是一堆散乱文本,没法保证一致性。

2.2 数据准备:图谱三元组和问句标注怎么来

这套系统能跑起来的前提是数据。医药知识图谱的数据来源常见做法是三类:公开药品说明书结构化数据、医学教材里的关系抽取、以及人工整理的问答对。我一般会先把药品说明书里的“适应症”“用法用量”“禁忌”“不良反应”“药物相互作用”这几个字段抽出来,转成三元组。

下面是一个把药品说明书CSV转成图谱三元组的脚本示例:

import pandas as pd import json # 读取药品说明书结构化数据 # 字段:药品名, 适应症, 用法用量, 禁忌, 不良反应, 相互作用 df = pd.read_csv("drug_labels.csv", encoding="utf-8") triples = [] for _, row in df.iterrows(): drug = row["药品名"].strip() # 适应症可能用分号分隔多个值 for indication in str(row["适应症"]).split(";"): if indication.strip(): triples.append((drug, "适应症", indication.strip())) # 禁忌通常是一整段,保留原文 if pd.notna(row["禁忌"]): triples.append((drug, "禁忌", str(row["禁忌"]).strip())) # 不良反应按分号拆分 for adr in str(row["不良反应"]).split(";"): if adr.strip(): triples.append((drug, "不良反应", adr.strip())) # 相互作用按分号拆分 for inter in str(row["相互作用"]).split(";"): if inter.strip(): triples.append((drug, "相互作用", inter.strip())) # 输出为JSON,每行一个三元组 with open("medical_triples.json", "w", encoding="utf-8") as f: for h, r, t in triples: f.write(json.dumps({"head": h, "relation": r, "tail": t}, ensure_ascii=False) + "\n") print(f"共生成 {len(triples)} 条三元组")

这段代码的逻辑很直接:把CSV里每个字段按分隔符拆开,拼成(头实体,关系,尾实体)的格式。参数上要注意两点:一是分隔符要跟原始数据对齐,中文说明书里常用“;”而不是“;”,写错了拆不出来;二是禁忌和相互作用这类字段往往是一整段话,不要强行拆,保留原文反而更利于后续答案生成。

问句标注数据是另一块。BERT的NER和意图分类都需要标注样本。常见做法是先用模板生成一批问句,再人工修正。比如针对“适应症”意图,模板可以是“{药品}能治什么病”“{药品}的适应症是什么”“什么情况下用{药品}”。每个模板生成几十条,覆盖不同药品名,就能得到几百条标注数据。意图类别一般设6到8类:用法用量、适应症、禁忌、不良反应、相互作用、注意事项、药品对比、其他。

2.3 BERT微调:NER和意图分类的双任务设计

BERT部分我一般做成两个头:一个序列标注头做NER,一个分类头做意图识别。两者共享BERT编码层,但输出层分开。这样做的好处是训练时两个任务互相正则化,推理时一次前向就能拿到实体和意图。

下面是NER标注数据的格式和训练代码的核心部分:

import torch from torch.utils.data import Dataset from transformers import BertTokenizer, BertForTokenClassification # NER标签体系:B-DRUG, I-DRUG, B-DISEASE, I-DISEASE, B-SYMPTOM, I-SYMPTOM, O label_list = ["O", "B-DRUG", "I-DRUG", "B-DISEASE", "I-DISEASE", "B-SYMPTOM", "I-SYMPTOM"] label2id = {l: i for i, l in enumerate(label_list)} class NERDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text = self.texts[idx] label = self.labels[idx] encoding = self.tokenizer( text, max_length=self.max_len, padding="max_length", truncation=True, return_tensors="pt" ) # 对齐标签:BERT的[CLS]和[SEP]位置设为-100,不计算loss label_ids = [-100] + [label2id[l] for l in label] + [-100] # 补齐到max_len label_ids = label_ids[:self.max_len] label_ids += [-100] * (self.max_len - len(label_ids)) return { "input_ids": encoding["input_ids"].squeeze(), "attention_mask": encoding["attention_mask"].squeeze(), "labels": torch.tensor(label_ids, dtype=torch.long) } # 训练循环核心 model = BertForTokenClassification.from_pretrained("bert-base-chinese", num_labels=len(label_list)) optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5) for epoch in range(5): model.train() for batch in dataloader: outputs = model( input_ids=batch["input_ids"], attention_mask=batch["attention_mask"], labels=batch["labels"] ) loss = outputs.loss loss.backward() optimizer.step() optimizer.zero_grad()

这里有几个参数需要重点说。学习率用2e-5是BERT微调的常规起点,医药领域数据量不大时不要超过3e-5,否则容易过拟合。max_len设128足够覆盖绝大多数问句,医药问句很少超过50个字。标签对齐是最容易翻车的地方:BERT的tokenizer会把一个词拆成多个subword,标注时要确保每个subword都拿到正确的标签,通常做法是只给第一个subword标B-或I-,后续subword标I-或-100。如果这一步错了,模型学出来的实体边界会乱七八糟。

意图分类的训练类似,只是把序列标注头换成分类头,取[CLS]位置的输出接一个全连接层。两个任务可以分开训练,也可以联合训练,联合训练时loss取加权和,NER的loss权重一般设1.0,意图分类设0.8到1.0之间。

2.4 词典匹配与实体链接:BERT没抽出来的词怎么兜底

BERT不是万能的。医药领域有大量长尾药品名和罕见病名,训练数据里没覆盖到的,BERT的NER很可能漏抽。这时候词典匹配就是后悔药。

词典匹配的常见做法是Aho-Corasick自动机,它能在一遍扫描中匹配所有词典词,时间复杂度是O(n),n是问句长度。Python里可以用pyahocorasick库,也可以自己用字典树实现。匹配到候选实体后,还要做实体链接:把匹配到的字符串映射到图谱里的标准实体ID。

import ahocorasick # 构建词典自动机 A = ahocorasick.Automaton() drug_dict = { "阿司匹林": "DRUG_001", "乙酰水杨酸": "DRUG_001", # 别名指向同一实体 "拜阿司匹灵": "DRUG_001", "布洛芬": "DRUG_002", "芬必得": "DRUG_002", } for alias, entity_id in drug_dict.items(): A.add_word(alias, (alias, entity_id)) A.make_automaton() def dict_match(text): """返回问句中匹配到的所有实体及其位置""" matches = [] for end_idx, (alias, entity_id) in A.iter(text): start_idx = end_idx - len(alias) + 1 matches.append({ "alias": alias, "entity_id": entity_id, "start": start_idx, "end": end_idx }) return matches # 示例 query = "拜阿司匹灵和芬必得能一起吃吗" print(dict_match(query)) # 输出会包含两个匹配:拜阿司匹灵→DRUG_001,芬必得→DRUG_002

词典匹配的结果要和BERT的NER结果做融合。融合策略我一般用“BERT优先,词典补漏”:如果BERT抽出了某个实体,就用BERT的;如果BERT没抽出来但词典匹配到了,就用词典的。两者都抽到但边界不一致时,以BERT的边界为准,因为BERT考虑了上下文。这里有个坑:词典匹配容易把“阿司匹林肠溶片”拆成“阿司匹林”和“肠溶片”两个词,如果词典里没有“阿司匹林肠溶片”这个完整词条,就会误匹配。解决办法是词典里尽量收全称,同时设置最长匹配优先。

3. 从零跑通问答链路:环境、训练、推理的完整命令

3.1 环境搭建与依赖安装

这套系统的依赖不算复杂,但版本要对齐。Python建议3.8到3.10,太高或太低都可能遇到wheel包不兼容。PyTorch用1.12以上,transformers用4.20以上。下面是我常用的环境安装命令:

# 创建虚拟环境 python -m venv medqa_env source medqa_env/bin/activate # Windows用 medqa_env\Scripts\activate # 安装核心依赖 pip install torch==1.13.1 transformers==4.28.0 pip install pandas numpy scikit-learn pip install pyahocorasick # 词典匹配 pip install fastapi uvicorn # 如果要起HTTP服务 # 验证transformers能否加载中文BERT python -c "from transformers import BertTokenizer; t = BertTokenizer.from_pretrained('bert-base-chinese'); print(t.tokenize('阿司匹林怎么吃'))"

最后一行验证很关键。如果输出是['阿', '##司', '##匹', '##林', '怎', '么', '吃']这样的subword序列,说明tokenizer工作正常。如果报网络错误,说明模型权重没下载下来,需要提前把bert-base-chinese的权重放到本地缓存目录,或者在有网环境先跑一次让它自动缓存。

3.2 图谱构建与存储选型

三元组数据准备好之后,存储选型有三个常见方案:Neo4j、SQLite、内存字典。Neo4j适合大规模图谱和复杂查询,但部署重;SQLite轻量,适合单机原型;内存字典最快,但数据量大了吃内存。我一般原型阶段用SQLite,因为查询逻辑可以用SQL表达,调试方便。

import sqlite3 import json # 建库建表 conn = sqlite3.connect("medical_kg.db") cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS triples ( id INTEGER PRIMARY KEY AUTOINCREMENT, head TEXT NOT NULL, relation TEXT NOT NULL, tail TEXT NOT NULL ) """) cursor.execute("CREATE INDEX idx_head ON triples(head)") cursor.execute("CREATE INDEX idx_head_rel ON triples(head, relation)") # 导入三元组 with open("medical_triples.json", "r", encoding="utf-8") as f: for line in f: obj = json.loads(line) cursor.execute( "INSERT INTO triples (head, relation, tail) VALUES (?, ?, ?)", (obj["head"], obj["relation"], obj["tail"]) ) conn.commit() # 查询示例:查阿司匹林的禁忌 cursor.execute( "SELECT tail FROM triples WHERE head = ? AND relation = ?", ("阿司匹林", "禁忌") ) for row in cursor.fetchall(): print(row[0])

索引是关键。idx_head_rel这个联合索引能让“按头实体+关系查尾实体”的查询走索引,数据量到十万级时查询时间能从几百毫秒降到几毫秒。如果只建idx_head,查询时还要回表过滤relation,效率差一截。

3.3 推理流程:一次问答的完整代码

把前面几个模块串起来,一次问答的流程是:问句输入→BERT NER抽实体→词典匹配补实体→BERT意图分类→实体链接到图谱→按意图查图谱→模板生成答案。

def answer_question(query, ner_model, intent_model, tokenizer, kg_cursor): # 1. BERT NER抽实体 bert_entities = extract_entities(ner_model, tokenizer, query) # 2. 词典匹配补实体 dict_entities = dict_match(query) # 3. 融合实体(BERT优先,词典补漏) entities = merge_entities(bert_entities, dict_entities) # 4. 意图分类 intent = predict_intent(intent_model, tokenizer, query) # 5. 按意图映射到图谱关系 relation_map = { "用法用量": "用法用量", "适应症": "适应症", "禁忌": "禁忌", "不良反应": "不良反应", "相互作用": "相互作用" } relation = relation_map.get(intent) if not relation or not entities: return "抱歉,我暂时无法理解这个问题。" # 6. 查图谱 answers = [] for ent in entities: kg_cursor.execute( "SELECT tail FROM triples WHERE head = ? AND relation = ?", (ent["entity_id"], relation) ) for row in kg_cursor.fetchall(): answers.append(row[0]) # 7. 模板生成 if not answers: return f"知识库中暂未收录关于{entities[0]['alias']}的{intent}信息。" return f"关于{entities[0]['alias']}的{intent}:{';'.join(answers)}"

这段代码里,merge_entities的融合逻辑要仔细写。我的做法是:先按位置排序BERT实体,然后遍历词典实体,如果词典实体的位置和某个BERT实体重叠超过50%,就丢弃词典实体;否则加入。这样能避免同一个词被重复识别。意图分类的输出是类别ID,要映射回中文意图名,再映射到图谱的关系名。这里有个细节:图谱里的关系名要和意图名对齐,如果图谱里存的是“禁忌症”而意图分类输出的是“禁忌”,映射表里就要写清楚,否则查不到。

4. 避坑与排查:医药问答系统最容易翻车的五个地方

4.1 实体边界切错导致图谱查不到

现象:问“阿司匹林肠溶片怎么吃”,系统识别出的实体是“阿司匹林”,图谱里查“阿司匹林”的用法用量能查到,但查“阿司匹林肠溶片”查不到,答案不准确。

原因:BERT的NER训练数据里如果只有“阿司匹林”没有“阿司匹林肠溶片”,模型学到的边界就是到“林”为止。词典里如果也只收了“阿司匹林”,匹配也会截断。

解决:训练数据里要覆盖药品全称和剂型后缀,词典里把“阿司匹林肠溶片”“阿司匹林片”“阿司匹林肠溶胶囊”都收进去,并且设置最长匹配优先。图谱里如果“阿司匹林肠溶片”和“阿司匹林”是不同实体,要建立上下位关系,查询时如果精确匹配不到,回退到上位实体。

4.2 意图分类被高频类别带偏

现象:问“布洛芬有什么副作用”,系统分类成“适应症”,返回的是布洛芬能治什么病,答非所问。

原因:训练数据里“适应症”类样本远多于“不良反应”类,模型倾向于预测多数类。医药问句里“有什么”这个句式在适应症和不良反应里都常见,模型没学到区分特征。

解决:做类别平衡,对少数类过采样或对多数类欠采样。更有效的做法是在输入里加入意图关键词特征,比如“副作用”“不良反应”这些词出现时,给“不良反应”类一个先验加分。也可以在BERT后面接一个CRF层,让意图分类考虑标签间的转移概率。

4.3 词典匹配把长词拆成短词

现象:问“二甲双胍缓释片能和什么一起吃”,词典匹配出“二甲双胍”和“缓释片”两个实体,“缓释片”被当成药品去查图谱,查不到。

原因:Aho-Corasick默认是找到所有匹配,不区分最长匹配。如果词典里同时有“二甲双胍”和“二甲双胍缓释片”,两个都会匹配到。

解决:匹配后做后处理,按位置排序,如果短词被长词完全包含,丢弃短词。或者用最长匹配模式,pyahocorasick本身不直接支持,但可以在匹配后按长度过滤。

4.4 图谱关系名和意图名不一致

现象:意图分类输出“用法”,图谱里存的关系是“用法用量”,查询返回空。

原因:图谱构建时的关系命名和意图分类的类别命名是两拨人做的,没有对齐。

解决:建一个映射表,把意图类别映射到图谱关系名。映射表要作为配置文件单独维护,改的时候两边一起改。更彻底的做法是图谱关系名直接采用意图类别名,从源头统一。

4.5 训练数据泄露导致评估虚高

现象:模型在验证集上F1到0.95,上线后实际问句准确率不到0.6。

原因:训练集和验证集里有重复问句,或者验证集的问句模板和训练集完全一样,模型只是记住了模板,没学到泛化能力。

解决:划分数据集时按问句模板划分,同一模板生成的问句只出现在训练集或验证集之一。评估时另外准备一批人工写的、模板没覆盖的问句做测试集。如果测试集准确率和验证集差距大,说明过拟合了,要加数据或加正则。

5. 进阶技巧:用规则+模型混合策略把准确率再拉一截

跑通基础链路之后,准确率通常卡在70%到80%之间。想再往上走,单靠调BERT参数收益有限,更有效的是做混合策略。

第一个技巧是问句改写。用户问“吃了阿司匹林胃疼”,系统需要理解成“阿司匹林的不良反应是否包括胃疼”。可以在BERT前面加一个改写模块,用规则把口语化表达转成标准问句。规则不用多,覆盖高频句式就行,比如“吃了X后Y”转“X的不良反应是否包括Y”,“X和Y能一起用吗”转“X和Y的相互作用”。改写后再走NER和意图分类,准确率能提升5到8个百分点。

第二个技巧是答案置信度排序。图谱查询可能返回多条答案,比如“阿司匹林的不良反应”可能返回十几条。不要全部拼接返回,而是按置信度排序。置信度可以来自三个信号:答案在说明书里的位置(靠前的通常更重要)、答案的长度(太短的可能是噪声)、以及答案和问句的语义相似度(用BERT算余弦相似度)。取Top3返回,用户体验会好很多。

第三个技巧是冷启动兜底。图谱里没有的实体,不要直接回“不知道”,而是走一个相似实体检索。用BERT把问句里的实体编码成向量,在实体向量库里找最相似的Top1,如果相似度超过阈值(我一般设0.85),就用相似实体的答案加上“您是不是想问XX”的提示返回。这样能覆盖一部分长尾问句。

下面是一个置信度排序的示例代码:

from sklearn.metrics.pairwise import cosine_similarity import numpy as np def rank_answers(query_embedding, answers, answer_embeddings, top_k=3): """ query_embedding: 问句的BERT向量,shape=(768,) answers: 答案文本列表 answer_embeddings: 答案的BERT向量矩阵,shape=(n, 768) """ # 计算问句和每个答案的余弦相似度 sims = cosine_similarity( query_embedding.reshape(1, -1), answer_embeddings )[0] # 按相似度排序,取TopK ranked_idx = np.argsort(sims)[::-1][:top_k] results = [] for idx in ranked_idx: results.append({ "answer": answers[idx], "score": float(sims[idx]) }) return results

这段代码的关键参数是top_k,设3比较合适。设1容易漏掉正确答案,设5以上用户读起来累。相似度阈值不用在这里卡,排序后取TopK就行,如果最高分低于0.3,说明问句和答案都不太相关,可以触发兜底逻辑。

最后一个习惯:每次改完模型或规则,不要只看整体准确率,要按意图类别分开看。我踩过的坑是整体准确率涨了2个点,但“相互作用”这个类别的准确率掉了10个点,原因是新加的规则把一些相互作用问句误判成了适应症。分类别看指标,才能发现这种此消彼长的问题。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进