尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

医疗KBQA实战:知识图谱构建、意图识别与实体抽取全流程解析

医疗KBQA实战:知识图谱构建、意图识别与实体抽取全流程解析 简介面向希望快速入门知识图谱问答KBQA的开发者与AI学习者这份资源以医疗领域为场景完整展示了从实体关系构建到意图识别、答案检索的落地流程。项目包含7类实体、约3.7万实体节点与21万实体关系并基于210条人工标注数据训练朴素贝叶斯意图分类模型测试F1值达96.68%同时附有与SVM的对比选型思路便于理解算法取舍。资源共15个文件压缩包仅3.73MB涵盖Python源码、文本词典、CSV数据表、模型文件及效果示意图代码结构与数据组织清晰读者可按数据准备、图谱构建、意图模型、问答测试的模块顺序逐步复现。已有787人学习浏览对于想用最小成本弄清KBQA工作流的入门者而言这是一份可运行、可扩展的参考实现既适合课程设计也适合作为进一步接入医疗领域业务问答的起点。1. 医疗问答的难点不在模型在把知识变成机器能查的图医疗领域做问答最尴尬的地方不是意图识别而是答案本身就没有结构化。用户问“高血压能吃香蕉吗”搜索引擎返回一堆页面你还要自己读问“头痛该挂什么科”不同医院说法还不一样。KBQA的思路是把知识从文本里抽出来变成实体和关系存成图再把用户的问句翻译成图查询直接返回答案。这个项目用7类实体、约3.7万实体、21万实体关系搭了一个医疗知识图谱配合朴素贝叶斯意图分类和词典式实体抽取走通了“问句→意图→实体→Cypher→答案”的完整链路。适合两类人想快速理解KBQA工作流的产品或算法工程师以及想复用医疗图谱schema做垂直问答的开发者。项目不大但五脏俱全210条标注数据就能把意图分类F1做到96.68%值得拆开看。2. 医疗知识图谱怎么建从disease.csv到21万条关系2.1 先设计图谱schema节点类型、属性、关系方向图谱不是把数据塞进Neo4j就完事先得定义节点和关系。本项目的7类实体基本覆盖了医疗问答的高频概念疾病disease、症状symptom、并发症complications、检查科目check、科室department、药物drug、食物food。实体类型主要属性典型的入图来源疾病 Diseasename, 病因, 预防, 治疗周期disease.csv 主表症状 Symptomnamesymptom_vocab.txt并发症 Complicationsnamecomplications_vocab.txt检查科目 Checkname, 检查部位disease.csv check 字段科室 Departmentnamedisease.csv department 字段药物 Drugname, 服用方法disease.csv drug 字段食物 Foodname, 宜/忌disease.csv food 字段我一般会在建图前先画一张草稿疾病节点是中心症状和并发症从它指出去指向方向决定了后续Cypher查询的写法。比如“高血压有哪些症状”在图上就是(d:Disease)-[:HAS_SYMPTOM]-(s:Symptom)方向从疾病到症状。如果建图时方向写反了后面所有查询都要加反向箭头排查起来非常磨人。所以第一步别急着写代码先把七类节点的关系方向定死。2.2 清洗CSV并抽三元组半角符号是第一个坑原始数据在disease.csv里通常是一行一个疾病症状、并发症、常用药物等多个字段用分隔符拼在一起。最常见的问题是全角逗号和分号混用直接split会漏掉一部分三元组。我在处理这类文件时第一步统一把所有全角符号转半角再按分隔符切割最后去空白、去重。import re import pandas as pd def normalize_text(text: str) - str: if not isinstance(text, str): return # 全角转半角兼容中文场景下的标点混用 text text.replace(, ,).replace(, ;) text text.replace(【, [).replace(】, ]) # 去掉实体内部的空白避免“高 血压”被匹配失败 text re.sub(r\s, , text) return text.strip() def extract_triples(csv_path: str, sep: str ;): df pd.read_csv(csv_path, encodingutf-8) triples [] for _, row in df.iterrows(): disease normalize_text(row.get(name, )) if not disease: continue for symptom in str(row.get(symptom, )).split(sep): symptom normalize_text(symptom) if symptom: triples.append((disease, HAS_SYMPTOM, symptom)) # 同理处理 complication / check / department return triples if __name__ __main__: triple_list extract_triples(data/disease.csv) print(f抽取三元组数量: {len(triple_list)})这段代码的意义不只是清洗它确定了后续写入Neo4j的最小数据单元是(head, relation, tail)三元组。row.get用字典式取值避免CSV缺列时整行报错text.replace把全角标点统一为半角是为了让split(sep)稳定工作。注意split(;)之后每个片段都要再过一遍normalize_text因为原始数据里常常出现“头痛 头晕”这种带空格的脏值。21万关系里如果有一半是重复三元组图的规模虚高查出来的答案也会有重复项。抽取完三元组还需要用vocab.txt和*_vocab.txt做一次反向过滤。这些词典文件本质上是实体的白名单不在词典里的tail节点要么是数据噪声要么是未登录实体。我一般把过滤分两级严格模式只保留词典命中节点宽松模式保留词频大于阈值的未知节点两种模式跑出来的图谱规模差不少。2.3 用py2neo写入Neo4jMERGE和CREATE的性能取舍写图我习惯用py2neo的graph.run()批量提交。这里有个原则性问题用CREATE还是MERGE。CREATE无视节点是否存在数据源有重复行就会产生重复节点MERGE会先查后写去重可靠但速度慢一个量级。本项目21万关系逐条MERGE可能要跑十几分钟性能上很难接受。from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, password)) def build_graph(triples: list, batch_size: int 1000): graph.delete_all() # 清空旧数据便于重复建图 nodes {} def get_node(label: str, name: str): key (label, name) if key not in nodes: node Node(label, namename) graph.merge(node, label, name) # 按name属性做去重合并 nodes[key] node return nodes[key] for i in range(0, len(triples), batch_size): batch triples[i:i batch_size] tx graph.begin() for head, rel, tail in batch: head_node get_node(Disease, head) tail_node get_node(Symptom, tail) tx.merge(Relationship(head_node, rel, tail_node)) tx.commit() print(f已写入 {i len(batch)} / {len(triples)})这版实现的核心技巧是graph.merge(node, label, name)。它会把name设为实体的唯一键属性配合后续的MATCH (n:Disease {name:高血压})查询才能走索引。很多项目建图时漏了这个唯一键约束导致图谱里出现两个“高血压”实体抽取和答案排序全乱。批量提交时要注意tx事务对象在commit()之后不能再复用必须重新graph.begin()长事务在Neo4j里容易OOM1000条一批是一个保守值。图建完后建议执行CREATE INDEX ON :Disease(name)否则实体链接阶段的点查会全表扫描。21万条关系看着不大但如果只用CREATE而不用MERGE重复边会迅速膨胀。实际项目中同一个疾病和同一个症状的关联可能在多行数据里重复出现先做个set(triples)去重再把三元组列表传入建图函数是最低成本的关系瘦身方案。3. 意图识别为什么选朴素贝叶斯210条训练样本下的取舍3.1 定义意图与标注规范先明确一点210条标注数据在深度学习时代是个极小的样本量。适合这个量级的方案优先考虑特征工程加上经典分类器而不是微调BERT。本项目的意图分类任务我按医疗问答的常见问题类型拆成几类每类写清楚判断标准再标注样本。意图名称问句示例后续查询动作disease_symptom高血压有什么症状查疾病→症状symptom_disease头痛可能是什么病查症状→疾病disease_complication糖尿病会引起什么并发症查疾病→并发症complication_disease哪些病会引起视网膜病变查并发症→疾病disease_department胃炎挂什么科查疾病→科室disease_check冠心病要做什么检查查疾病→检查标注规范比标注量更重要。同一语义的句子要写足变化口语说法“脑袋疼”、书面说法“头痛”、带否定词“头痛不伴发热”要分开处理。否定词在医疗问答里会直接翻转查询方向如果训练数据里没有覆盖之后的模型会把“没有头痛”识别成“有头痛”。3.2 TF-IDF向量化与NB/SVM对比特征层面用TF-IDF即可不需要词向量。原因很简单210条样本每条平均不到15个词词向量在这个数据量下学不到稳定的语义差异。TF-IDF的稀疏高维特征恰好匹配朴素贝叶斯对条件独立假设的拟合方式。下面是完整的训练和对比代码from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.svm import SVC from sklearn.model_selection import cross_val_score import joblib # train_texts: 标注好的问句列表, train_labels: 对应的意图标签 vectorizer TfidfVectorizer( token_patternr[\u4e00-\u9fa5], # 只保留中文词过滤标点和英文 ngram_range(1, 2), # 加入二元词组捕获“不伴发热”这类模式 min_df1, max_df0.8 # 去掉高频干扰词 ) X vectorizer.fit_transform(train_texts) models { MultinomialNB: MultinomialNB(alpha0.1), SVM_RBF: SVC(kernelrbf, C1.0, probabilityTrue), } for name, model in models.items(): scores cross_val_score(model, X, train_labels, cv5, scoringf1_macro) print(f{name} F1: {scores.mean():.4f} (/- {scores.std():.4f})) # 选出最优模型后重新训练并保存 best_model MultinomialNB(alpha0.1) best_model.fit(X, train_labels) joblib.dump(vectorizer, model/tfidf_model.m) joblib.dump(best_model, model/intent_reg_model.m)注意token_pattern只匹配中文字符因为医疗问句里的数字和英文对意图判断没有太大贡献还能减少特征维度。ngram_range(1, 2)是关键参数只用一个词时“不伴”和“伴”会被分到同一个词袋里加了二元组后“不伴发热”能作为整体被捕捉对否定表达的识别有明显帮助。alpha0.1是拉普拉斯平滑系数调小是为了让模型不过分平滑毕竟每个意图类别的训练样本只有三十几条。与SVM相比多项式朴素贝叶斯在小样本、高维稀疏特征下的优势在于它不需要调节核函数参数也不容易在小数据集上过拟合到支持向量的边界上。实际跑下来的F1值NB高出SVM大约2到3个百分点最终选定NB。3.3 推理阶段要串接词典文件名归一加载模型后有个容易忽略的细节模型训练时用的是原始问句文本推理时也要走完全一样的预处理流程包括全角转半角和分词。这一步如果漏掉线上效果会明显低于测试集F1。意图识别跑完下一步是把抽取到的实体信息跟意图组合成查询参数而实体名的归一化依赖于alias词典。import joblib from entity_extractor import extract_entity tfidf_model joblib.load(model/tfidf_model.m) intent_model joblib.load(model/intent_reg_model.m) def predict_intent(question: str) - str: # 推理和训练必须共用同一套文本预处理 question normalize_text(question) vec tfidf_model.transform([question]) intent intent_model.predict(vec)[0] return intent def parse_question(question: str): intent predict_intent(question) entities extract_entity(question, symptom_vocab.txt, disease_vocab.txt, alias_vocab.txt) return {intent: intent, entities: entities}这里的parse_question是KBQA入口的第一步它把自然语言问句转成结构化的{intent: ..., entities: ...}。后续查询模块只需要根据这两个字段去拼Cypher不需要再回看原始文本。实体抽取的细节在下一章展开但需要提前意识到意图分类再准实体抽错答案就是错的两个模块的准确率是相乘关系不是相加关系。4. 实体抽取与查询改写问句如何变成Cypher4.1 词典最大匹配alias_vocab的归一把实体抽取模块在KBQA里的作用是把“高血压吃什么药”里的“高血压”找出来并映射到图谱里存在的实体名。本项目采用词典最大匹配因为医疗实体名称相对固定也不太存在一词多义的问题。匹配方向是正向从句子开头向后扫描优先匹配长度更长的实体名避免“胃”和“胃炎”、“心脏病”和“风湿性心脏病”这类包含关系导致错误命中。def load_vocab(file_path: str) - set: vocab set() with open(file_path, r, encodingutf-8) as f: for line in f: word line.strip() if word: vocab.add(word) return vocab def max_forward_match(question: str, vocab: set, max_len: int 8): start 0 matched [] while start len(question): matched_flag False for end in range(min(start max_len, len(question)), start, -1): word question[start:end] if word in vocab: matched.append(word) start end matched_flag True break if not matched_flag: start 1 # 未命中则跳过当前字符 return matchedmax_len8是经验值中文疾病名最长也就七八个字超过这个长度基本不是标准实体名。这段代码只返回命中词不返回位置信息所以同一个词重复出现时会被记录多次。建议在返回前做一次去重并保留首次出现位置因为后续构造Cypher时只需要实体名不需要冗余项。4.2 未命中时的TF-IDF相似实体链接词典匹配覆盖不了所有问法。用户说“脑袋疼”词典里只有“头痛”这时就需要实体链接模块。把问句中的候选词与所有实体名做TF-IDF向量相似度计算取最接近的一个。本项目的tfidf_model.m在这一步也承担着实体向量化的任务一个模型两处用。from sklearn.metrics.pairwise import cosine_similarity import numpy as np def entity_link(candidate: str, entity_names: list, vectorizer, top_k1): cand_vec vectorizer.transform([candidate]) name_vecs vectorizer.transform(entity_names) sims cosine_similarity(cand_vec, name_vecs)[0] top_idx np.argsort(sims)[::-1][:top_k] results [(entity_names[i], sims[i]) for i in top_idx if sims[i] 0.6] return results阈值0.6是个经验起始值。调高会漏召回调低会把“胃痛”错误链接到“腹痛”。我通常先在开发集上跑一遍统计匹配成功样本的相似度分布再把阈值设在分布的最低点附近。4.3 意图实体→查询模板拿到意图和实体后开始拼Cypher。这里选择在Python层拼接查询模板而不是用Neo4j的全文索引因为意图已经限定了关系方向模板能精确控制查询路径。// 意图: disease_symptom实体是疾病名 MATCH (d:Disease {name: 高血压})-[:HAS_SYMPTOM]-(s:Symptom) RETURN s.name LIMIT 20INTENT_TEMPLATES { disease_symptom: MATCH (d:{disease_label} {{name: {entity}}})-[:HAS_SYMPTOM]-(s:Symptom) RETURN s.name LIMIT {limit}, symptom_disease: MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom {{name: {entity}}}) RETURN d.name LIMIT {limit}, } def search_answer(question: str): parsed parse_question(question) intent parsed[intent] entities parsed[entities] if not entities: return {code: 404, msg: 图谱中未找到相关实体, data: []} # 优先取第一个实体多个实体时取意图对应主体 entity_type, entity_name select_primary_entity(intent, entities) cypher INTENT_TEMPLATES[intent].format( entityentity_name, limit10 ) results graph.run(cypher).data() return {code: 200, data: [list(r.values())[0] for r in results]}select_primary_entity的作用很关键。当问句是“高血压伴头痛挂什么科”时实体抽取会得到“高血压”和“头痛”但意图是挂号科室主体实体是疾病“高血压”而不是症状“头痛”。按意图类型过滤实体类型是这层逻辑的核心。4.4 答案的排序与限制Cypher返回的答案直接展示有两个问题。一是排序不稳定Neo4j对无ORDER BY的查询返回顺序不保证二是答案过长比如某个疾病的并发症有几十条全量展示会淹没关键信息。我一般会对结果做两个处理LIMIT默认设为10同时按实体的出现频次排序。频次可以从图谱的关系度统计中得到关系多的实体往往是更常见的并发症或症状排在前面更符合用户预期。5. 评测与调优重复问到知识边界时日志看什么5.1 意图混淆的快速定位全部模块串起来后第一轮的评测重点不是端到端准确率而是定位错误发生在哪一层。我习惯在search_answer的返回结果里带上中间日志记录raw_text、predict_intent、extracted_entities、matched_similarity四个字段。之后人工抽查200条问答记录如果发现DB里的确认结果与预测意图不一致就按混淆矩阵归类错误类型划分意图分错、实体抽取失败、实体链接错误、查询模板写错。import json from collections import Counter def error_report(log_file: str): errors Counter() with open(log_file, r, encodingutf-8) as f: for line in f: record json.loads(line) if record[db_intent] ! record[predict_intent]: errors[意图识别错误] 1 if not record[extracted_entities]: errors[实体抽取漏召] 1 if record[matched_similarity] 0.6: errors[实体链接阈值过高] 1 return errors一次统计后错误分布会告诉你优先级。意图识别错误多回3.1节补充标注数据实体抽取漏召多说明vocab词表覆盖不够补充*_vocab.txt实体链接阈值过高导致的错误集中在相似度0.5到0.6区间才有理由调整阈值。5.2 重复未命中实体的回补端到端对话中经常出现同一个实体反复查不到的场景。标准的回补机制是记录所有未命中实体按问题次数排序。比如“偏头痛”在一周内出现15次未命中说明词表缺失人工确认后加入alias_vocab.txt并指向“偏头痛”的标准实体名“偏头痛病”同时要在图谱里补建或合并该实体。这比一次性收集完整词表高效得多也更贴合实际使用中用户的表达习惯。5.3 最容易翻车的两个配置细节stop_words.utf8文件的停用词排序会影响实体抽取结果。“痛”这个字如果进了停用词表“头痛”“胃痛”会被切碎匹配直接失效。我通常会排除所有长度为一且在医学实体中高频出现的字比如“痛”“热”“咳”。多义词实体的词典优先级也要人工确认。“乙肝”和“乙型肝炎”在alias词典中并存时若标准实体是“乙型肝炎”则必须先匹配长词“乙型肝炎”但词典是从文件中逐行加载的Python的set是无序的必须把alias到标准的映射关系显式维护成一个字典并规定匹配顺序为“标准名优先于别名长名优先于短名”。否则同一个“高血压”有时链接到标准实体有时链接到别名节点答案准确性会忽高忽低。评测脚本最后要能落成一个可重复执行的checklist意图准确率F1、实体链接的top1命中率、图谱中实体与词典的覆盖率三个指标。每次改完词表或模板重新跑一遍这三个指标的回归记录周维度观察趋势比临时抽查更能看出系统是否在真实地变好。本文还有配套的精品资源点击获取
返回列表