
简介一套融合领域知识图谱与医疗实体识别的医生推荐系统完整工程文件面向自然语言处理、知识图谱方向的学习者与医疗信息化开发者解决医疗实体识别、知识抽取及智能问答落地问题。资源采用BERTCRFBiLSTM实现医疗实体识别并构建医学知识图谱与问答系统较适合具备一定深度学习基础、希望了解端到端项目实践的读者。压缩包共115个文件包含37个Python脚本、18个XML配置、10个JSON数据、7个HTML页面及少量模型权重文件pkl与医生/疾病CSV数据整体约80.8MB涵盖爬虫采集、模型训练、图谱构建、Web展示与问答接口等模块目录结构清晰。已有567人学习下载。通过该包可获取完整项目代码、医疗语料与预处理数据、模型文件、前端交互页面及配置说明便于复现流程、二次开发或直接参考其工程化组织方式。1. 医生推荐系统为什么需要先解决医疗实体识别这一层把“推荐医生”做成知识图谱驱动而不是简单按医院热度排序首先要回答一个底层问题用户说“我妈妈最近总是口渴、尿频体重还降了”系统怎么知道这些描述对应“多饮、多尿、消瘦”这三个症状又如何关联到“糖尿病”这个疾病再去匹配内分泌科医生这一整套链路的关键起点是把非结构化的中文句子里的医学实体准确切出来。没有这一步后面建立医学知识图谱、做知识问答、算医生匹配度都是空中楼阁。之所以选择BERTBiLSTMCRF这条路线而不是直接用大模型是因为在医疗领域对实体边界的精确要求极高而且很多实体是嵌套的、简繁混合的比如“2型糖尿病伴视网膜病变”需要拆成疾病和并发症两个实体。BERT负责把上下文语义编码成向量BiLSTM在序列维度进一步捕捉前后依赖CRF在输出层强制约束标签转移的合法性。这套组合在中文医疗NER上仍然是最稳、最容易产业落地的基线训练成本远低于微调大模型推理延迟也能控制在几十毫秒以内。本文会沿着实体识别、知识图谱构建、问答链路、医生推荐排序这条主线给你一套可以直接复现的工程方案。2. 用BERTBiLSTMCRF训练医疗实体识别模型的最小闭环2.1 为什么是BERT加上BiLSTM和CRF而不是只用BERTBERT本身已经是一个双向Transformer编码器输出的每个token向量里含有丰富的上下文信息。但直接拿BERT的最后一层向量接一个softmax做序列标注会忽略两个问题。第一相邻标签之间是有强约束的比如“B-Disease”后面不能跟“I-Symptom”除非这个症状是同一个实体的一部分这种约束在训练数据里不一定充分体现softmax无法显式建模。第二BERT输出的每个token是独立的概率分布没有考虑“从一个实体转移到另一个实体”的全局路径。BiLSTM在这里不是用来解决长距离依赖的——那本来就是BERT的长处——而是对BERT输出的上下文表征做一次特征压缩和时序平滑。BiLSTM会把前向和后向的隐状态拼起来让每个位置的特征同时包含“左边已经出现的标签信息”和“右边即将出现的上下文”CRF则负责从所有可能的标签序列里找到全局最优的一条。三者的分工可以简单理解成BERT提供语义向量BiLSTM做局部时序特征CRF做全局标签约束。这套结构在医疗数据噪声大、标注不一致的场景里比纯BERT要高二到三个点的F1值。2.2 数据准备与标签体系BIOES还是BIO医疗实体识别通常要抽取的症状、疾病、药物、检查、手术、解剖部位这几大类。标签体系我建议用BIOES也就是把实体首尾单独标记中间用I外部用O。BIOES比BIO多出了E和S两个标签在实体边界预测上误差更小尤其是“高血压”这种单字实体S标记能让CRF学到“独立成实体”的模式。表里列一下标准标签集实体类型标签示例疾病B-Disease, I-Disease, E-Disease, S-Disease症状B-Symptom, I-Symptom, E-Symptom, S-Symptom药物B-Drug, I-Drug, E-Drug, S-Drug检查B-Exam, I-Exam, E-Exam, S-Exam非实体O数据标注时要遵循一个原则一个实体的边界内不能混入另一个实体。比如“头痛伴恶心”如果“头痛”和“恶心”是两个症状实体那么“痛伴恶”这个区间就不能标成一个症状。另外中文BERT用的分词是字级别的所以处理的是每个汉字不是词。你需要把原始句子按字切分同时把实体标签按字对齐。对于英文和数字建议统一转成小写并按原样保留在序列里不要拆成单字母否则BERT的WordPiece会把它们分得很碎。2.3 PyTorch实现BERTBiLSTMCRF的关键代码下面这段代码是我常用的模型结构基于transformers库加载中文BERT预训练权重然后接BiLSTM和CRF。这里用TorchCRF实现CRF层它已经实现了维特比解码和前向计算比自己手写对数损失要稳。import torch import torch.nn as nn from transformers import BertModel, BertConfig from TorchCRF import CRF class BertBiLstmCrf(nn.Module): def __init__(self, bert_dir, num_tags, lstm_hidden256, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_dir) self.dropout nn.Dropout(dropout) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizelstm_hidden, num_layers2, bidirectionalTrue, batch_firstTrue ) # 双向LSTM的hidden_size要乘2 self.fc nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags) def forward(self, input_ids, attention_mask, labelsNone): # 取出BERT的序列输出形状为(batch, seq_len, hidden) bert_out self.bert(input_idsinput_ids, attention_maskattention_mask)[0] bert_out self.dropout(bert_out) # BiLSTM输出形状为(batch, seq_len, 2*hidden) lstm_out, _ self.bilstm(bert_out) lstm_out self.dropout(lstm_out) emissions self.fc(lstm_out) # 训练时计算CRF损失并返回 if labels is not None: mask attention_mask.bool() loss -self.crf(emissions, labels, maskmask) return loss # 推理时返回维特比解码得到的标签序列 else: mask attention_mask.bool() predictions self.crf.viterbi_decode(emissions, maskmask) return predictions这段代码里有几个容易出错的地方。attention_mask必须同时传给BERT和CRFBERT用它忽略padding部分的注意力CRF用它跳过padding位置的标签计算。CRF的viterbi_decode返回的是一个列表列表里每个元素是当前样本的标签索引数组形状是(seq_len,)所以后面做评估时不能直接张量化需要手动对齐长度。另外LSTM的batch_firstTrue要和BERT输出的维度保持一致否则batch维和seq维会错位。调用模型训练时的核心代码写得比较直接这里只看loss的计算。我用DataLoader组织数据样本里包含input_ids、attention_mask、labels其中labels里的padding位置填-100方便在计算CRF损失前屏蔽。TorchCRF会要求mask是布尔张量attention_mask本来就是0/1构成的转成bool()即可。2.4 训练时的四个关键参数与一个容易炸的坑训练参数的选择直接影响最终F1值。这里列一组我常用的配置做医疗NER效果稳定你的数据量如果比它小可以适当调低learning_rate。参数数值说明learning_rate3e-5BERT层用的学习率过大会破坏预训练权重其他层学习率1e-3BiLSTM和全连接层可以从更大的学习率起步max_seq_len128医疗句子一般不长超过128后BERT计算量陡增batch_size16显存不够时降到8不要降学习率epoch5~8医疗数据量少时5个epoch足够多了容易过拟合optimizerAdamW配合weight_decay 0.01使用最容易炸的坑是BERT学习率没做分层。很多人用一个统一的AdamW把整个模型的参数一股脑传进去然后设learning_rate3e-5这样BiLSTM和CRF层的学习率也只有3e-5训练几百步后损失下降非常慢模型看起来像没学一样。正确做法是对参数分组BERT的参数学用小学习率其他参数用1e-3。下面这段代码展示如何配置两个不同学习率的参数组bert_params list(map(id, model.bert.parameters())) other_params filter(lambda p: id(p) not in bert_params, model.parameters()) optimizer torch.optim.AdamW([ {params: model.bert.parameters(), lr: 3e-5}, {params: other_params, lr: 1e-3} ], weight_decay0.01)另一个常见坑是标签里的O类样本数量远大于实体类如果直接用CRF loss模型会偏向预测O导致实体的召回率很低。处理办法是计算loss时实体的损失在反向传播中的贡献已经由CRF转移矩阵自动平衡一部分但如果你发现模型把所有词都预测成O就要检查数据里实体标注是否完整或者考虑给实体标签加权重。但CRF本身不支持直接给标签加权所以我通常会在模型输出层额外加一个辅助loss比如把fc输出接一个交叉熵辅助损失只对实体部分的预测做监督然后和CRF loss按比例相加。这个技巧能让模型更快学会实体边界。3. 从实体序列到医学知识图谱构建与存储3.1 医学知识图谱的模式设计实体、关系、属性实体识别完成后得到的是一串“症状-疾病-药物-检查”的离散标签它们之间还没有联系。知识图谱要做的就是把实体连接起来。医学领域的核心关系并不复杂常见的有疾病与症状之间的has_symptom、疾病与药物之间的treats、疾病与检查之间的diagnosed_by、科室与疾病之间的department、药物与成分之间的contains。属性则包括实体的名称别名、ICD编码、定义、科室分类等。我习惯用Neo4j作为存储引擎因为它的Cypher查询语言非常适合做多跳关系推理而医学问答里“吃什么药治什么病”这种问题天生就是图遍历。在Neo4j中节点代表实体用不同的标签Label区分类型比如:Disease、:Symptom、:Drug。边代表关系用关系类型区分。属性以键值对形式挂在节点或关系上。字段设计上每个节点至少要有name和normalized_name两个属性。name是原文本里的实际写法比如“头疼”“头痛”“头胀痛”normalized_name是归一化后的标准名称比如统一成“头痛”。这样在推荐医生时用户说“头疼”也能匹配到疾病图谱里的标准节点。关系也可以带属性比如has_symptom关系可以带一个weight字段表示这个症状对该疾病的支持度这个权重可以来自频次统计或医学指南。3.2 用Neo4j导入实体和关系的Cypher操作下面是一个导入操作的示例。假设实体识别结果已经整理成CSV文件那么用LOAD CSV导入是最高效的方式。先看节点导入LOAD CSV WITH HEADERS FROM file:///diseases.csv AS row MERGE (d:Disease {normalized_name: row.normalized_name}) ON CREATE SET d.name row.name, d.icd_code row.icd_code, d.category row.category;这里用MERGE而不是CREATE是为了避免重复导入同样的实体。MERGE会先查找有没有相同normalized_name的节点有就复用没有才创建。注意MERGE后面花括号里的属性是唯一性约束所以normalized_name必须保证唯一性。如果你的数据源里有别名不同的同一个疾病绝对不要用别名作为MERGE的键否则会有大量重复节点。关系导入也类似但需要先匹配到两个已存在的实体节点然后再创建关系。给一个建立“疾病与症状关系”的脚本LOAD CSV WITH HEADERS FROM file:///disease_symptom.csv AS row MATCH (d:Disease {normalized_name: row.disease}) MATCH (s:Symptom {normalized_name: row.symptom}) MERGE (d)-[r:has_symptom {weight: toFloat(row.weight)}]-(s);MATCH在找不到节点时会跳过这一行所以建议先确认CSV里的实体名称和节点里的normalized_name完全一致。如果大量行匹配失败说明前面实体归一化没做好。导入完成后可以在Neo4j浏览器里执行一句简单查询验证图谱规模MATCH (n) RETURN count(n) AS node_count; MATCH ()-[r]-() RETURN count(r) AS rel_count;节点数和关系数都大于0并且比原始CSV去重后的行数少说明合并逻辑生效了。3.3 实体对齐与归一化别把“高血糖”和“血糖高”建成两个节点医疗文本里同义异形的情况特别多这是知识图谱构建中最耗时间的部分。比如“高血糖”和“血糖升高”实际上指向同一个概念但在实体识别阶段它们很有可能被切分成不同的文本片段。如果直接导入图谱就会产生两个孤立节点问答系统在回答“血糖高挂什么科”时查不到“高血糖”关联的疾病推荐自然就断了。常用的归一化手段有三个层面。第一是规则归一把中文里的“升高/增高/偏高”统一替换为“高”“降低/偏低/减少”统一替换为“低”。第二是词典映射维护一张同义词表把“头痛/头疼/头部疼痛/头痛欲裂”映射到标准实体。第三是向量相似度召回用BERT对实体名做句向量编码用余弦相似度找出高于阈值的候选对再用人工审核补全映射关系。我一般先跑规则和词典覆盖大概七成的常见实体对剩下的用向量召回加人工判断这样成本最低。把归一化后的标准名作为节点的唯一键才能让知识图谱真正“连通”。如果你发现Neo4j里Disease节点数量比原始实体数量少一半不用慌那是正常现象——说明很多同义表达被合并了。反而如果节点数几乎等于原始行数就要怀疑是不是没有做归一化。4. 基于知识图谱的问答系统从查询到答案4.1 问题意图分类与槽位填充知识问答系统的输入是用户的自然语言问句输出是图谱查询结果。中间要解决两个问题用户到底在问什么以及问句里哪些词是实体。用知识图谱做医疗问答时问句类型通常有限一般就几类查询疾病症状、查询药物适应症、查询疾病挂什么科、查询检查对应的疾病。所以意图分类不需要用太复杂的模型我用BERT做多分类训练数据只有几百条就能覆盖主要情况。槽位填充其实还是NER的活可以直接用第2章训练好的BERTBiLSTMCRF模型来抽取问句里的实体。与普通医疗文本不同的是问答场景里的实体往往带缩写或口语化表达比如“糖尿病挂什么科”里的“糖尿病”是疾病实体模型能抽出来但“高血糖”抽出来之后还需要映射到标准节点。所以问答系统会把NER输出经过一层归一化最终变成一组标准实体和它们对应的类型。意图和实体都拿到后解析逻辑就简单了。举例用户问“高血压有什么症状”意图是“query_symptom_of_disease”实体是“高血压”且类型为Disease。解析器根据意图模板决定要查询的关系方向这个模板表可以直接配置在代码里意图模板示例问题disease_symptom(d:Disease)-[:has_symptom]-(s:Symptom)高血压有什么症状drug_indication(d:Disease)-[:treats]-(drug:Drug)什么药治高血压disease_department(d:Disease)-[:department]-(dept:Department)高血压挂什么科exam_disease(e:Exam)-[:diagnosed_by]-(d:Disease)血糖高做什么检查4.2 将自然语言问题转换为Cypher查询得到意图模板和实体值后需要拼接Cypher查询语句。这里需要注意防注入问题——实体值不能直接拼进Cypher而是要作为参数传入。例如“高血压有什么症状”转换成Cypher如下MATCH (d:Disease {normalized_name: $disease_name})-[:has_symptom]-(s:Symptom) RETURN s.name AS symptom_name, s.description AS description LIMIT 10在Python脚本里可以用neo4j驱动执行from neo4j import GraphDatabase class MedicalQA: def __init__(self, uri, user, password): self.driver GraphDatabase.driver(uri, auth(user, password)) def query_symptom(self, disease_name): cypher MATCH (d:Disease {normalized_name: $disease_name})-[:has_symptom]-(s:Symptom) RETURN s.name AS symptom_name with self.driver.session() as session: result session.run(cypher, disease_namedisease_name) return [record[symptom_name] for record in result]这里把disease_name作为参数传入而不是直接拼接字符串是防止问句里的特殊字符破坏查询语法。同时LIMIT 10控制返回数量避免一个疾病关联几十个症状时把回复撑得太长。如果返回结果为空说明图谱里没有这个疾病节点可能的原因是实体归一化时没映射成功或者该疾病确实没有录入图谱。此时需要走兜底策略。4.3 答案生成与兜底策略图谱查询得到的是结构化记录比如症状列表、科室名称、药物列表还需要把它组织成自然语言答案。最简单的方式是模板拼接疾病“{name}”的常见症状包括{symptom1}、{symptom2}等。但如果查询结果为空就要区分是“用户输入了图谱里没有的实体”还是“图谱里有该实体但没有对应关系”。前者可以通过图谱的模糊搜索来做容错比如用CONTAINS或STARTS WITH匹配相似的节点名称。后者则要返回“暂未收录该关系”的提示同时引导用户换个问法。我在实际项目中还加了一层基于规则的回退如果用户问“头疼怎么办”但意图分类没有命中任何模板因为“怎么办”太泛就默认向用户推荐科室并把“头疼”作为症状实体去匹配has_symptom关系找到与它相关的疾病再通过疾病找到科室。这样即使意图分类失败也能借助图谱的多跳路径找到答案。问答系统的价值不在于回答所有问题而是把高频、确定性高的问题用极低的延迟和成本解决掉剩下难啃的再交给人工或大模型。5. 把问答结果映射到医生推荐并做一次完整验证5.1 推荐排序的权重计算知识问答系统返回了疾病、科室、症状等信息这些信息要映射到医生推荐。我的做法是给每个医生建立一个画像医生的擅长领域、所属科室、职称、简介文本。推荐打分主要由三部分构成科室匹配分、疾病匹配分、症状匹配分。用问答系统得到的实体分别与医生画像做匹配权重分配需要结合业务场景。比如用户问“高血压挂什么科”返回科室是心内科那么科室匹配分权重最高如果用户描述的是“我头晕有时心悸”问答系统解析出这些症状那么症状匹配分的权重就要提高。打分公式可以写成下面的伪代码def score_doctor(doctor, qa_result): # qa_result包含科室、疾病、症状实体列表 department_score 1.0 if qa_result.department in doctor.departments else 0.0 disease_score len(set(qa_result.diseases) set(doctor.diseases)) / len(qa_result.diseases) symptom_score len(set(qa_result.symptoms) set(doctor.symptoms)) / len(qa_result.symptoms) return 0.4 * department_score 0.4 * disease_score 0.2 * symptom_scoredepartment_score是硬条件科室不对后面再高也白搭。disease_score和symptom_score覆盖了用户描述中的疾病和症状信息这个权重比例可以根据线上点击数据调整。注意要加一个阈值比如总分低于0.4的医生不要展示否则会出现匹配很差的医生。5.2 一个最小可复现的验证用例为了确认整套链路没有断掉我建议用一条真实用户问题做全链路回归。假设用户输入“我最近总是口渴喝水很多尿也多查了血糖偏高应该看哪个科”流程如下用NER模型抽取实体得到“口渴”是症状“尿多”是症状“血糖偏高”是检查结果归一化为“血糖高”。实体归一化后查询知识图谱找到“血糖高”这个检查对应的疾病“糖尿病”通过exam-disease关系再找到糖尿病的科室“内分泌科”。生成答案您描述的症状和血糖检查结果可能与糖尿病相关建议前往内分泌科就诊。调用推荐排序模块在内分泌科医生里按疾病和症状匹配度排序返回前三名医生。把这个用例写成回归测试代码每次修改模型或图谱后跑一遍def test_recommendation_pipeline(): user_input 我最近总是口渴喝水很多尿也多查了血糖偏高应该看哪个科 ner_result load_model().predict(user_input) qa_result medical_qa.query(ner_result) recommended_doctors recommend_doctors(qa_result) assert 内分泌科 in qa_result.department assert len(recommended_doctors) 0验证的关键不是看推荐结果是否完美而是确认流程不抛异常、返回结果不为空、科室匹配正确。这套测试能帮你发现三类典型问题NER把“血糖偏高”识别成了疾病而不是检查、图谱里没有“血糖高”这个节点、科室映射缺失。每次升级模型或扩充图谱后跑一遍能快速定位是哪个模块出了问题。我建议把这类用例积累成十到二十条覆盖常见科室和常见疾病它们比跑一遍完整的测试集更能反馈链路稳定性。本文还有配套的精品资源点击获取