ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

医疗知识图谱问答系统实战:从数据导入到意图识别

医疗知识图谱问答系统实战:从数据导入到意图识别 简介这是一份基于Python实现医疗领域知识图谱问答系统的完整项目面向正在学习知识图谱、自然语言处理的高校学生以及需要完成期末大作业或毕业设计的开发者帮助大家从零搭建一个可交互的医学问答原型。项目提供可直接运行的源代码与配套数据所有源码均经过本地编译验证下载后按照文档配置好Python环境即可顺利启动省去调试环境的大量麻烦。压缩包整体约19.04MB轻量便携适合快速部署和反复实验目前已有448人学习/下载资源难度适中内容经过助教老师审定能够满足课程设计、个人项目或知识图谱入门进阶的使用需求。内容覆盖医疗知识图谱构建的关键流程包括数据清洗、实体关系抽取、图谱存储与问答匹配并给出可扩展的代码框架读者可以借此理解知识图谱在垂直医疗领域的实际落地方法。这套资源既适合作为期末作业的完整参考也适合初学者对照源码逐段研读掌握从数据处理到问题回答的完整链路。1. 医疗问答系统对知识图谱的依赖远比想象中务实一个医生问“高血压患者能不能吃布洛芬”搜索引擎给的是十个广告页面一个患者问“阿莫西林和头孢有什么区别”通用对话模型给的是含混的口头解释。这两个场景恰恰是医疗领域问答系统最值得做的位置问题短、实体明确、答案有标准依据不需要长篇生成只需要把“疾病—药物—症状—检查”之间的结构化关系命中出来。知识图谱问答系统在这个领域的优势不是“比ChatGPT聪明”而是答案可溯源、逻辑可检查、关系可扩展。这个标题指向的是一个典型的中等规模工程用Python处理数据把医疗实体和关系导入图数据库再通过问句解析和查询构建把自然语言转成图查询。对从业者的价值不在于“医疗”本身而在于它把知识图谱构建、规则解析、图查询、结果排序串成了一条完整链路。三四个月Python经验就能跟下来但有五年以上后端或算法经验的人也能在参数设计、边界处理和验证方法上找到值得推敲的细节。2. 医疗知识图谱构建先定schema再写导入数据2.1 为什么医疗图谱适合用Neo4j而不是关系型数据库医疗问答应试实体跳转典型路径是“高血压 → 禁忌药物 → 替代药物 → 说明书”。这类多跳查询在关系型数据库里要写四五个JOIN而且JOIN次数随路径深度线性增长。Neo4j用索引邻居遍历查询时间与图的大小弱相关而与路径长度强相关。当你未来把药品、疾病、症状、科室、检查全部塞进图里、节点数过百万时这个差异会非常明显。另外一个现实原因是标题场景里要跑通的最小实现Neo4j社区版从数据导入到查询调优都有大量可复制资料Py2neo和neo4j官方驱动对Python支持得也很好。图谱布在Neo4j上后期接入BERT向量检索或大模型生成都只是增加插件而不是推翻重来。2.2 实体关系和CSV数据模板先行医疗领域的数据不会一开始就是三元组常见来源是药品说明书、诊疗指南和结构化病历。我一般会先设计一个最小但完整的schema然后按schema去清洗数据避免边导边改。实体类型关键属性示例Diseasename, department, alias高血压、原发性高血压Drugname, usage, dosage, side_effect硝苯地平、美托洛尔Symptomname, description头晕、头痛、心悸Examname, reference_value心电图、动态血压监测关系类型要少而实用后面写规则解析时不会失控。关系起点终点含义TREATSDrugDisease药物治疗疾病INDUCESDrugSymptom药物引发症状FORBIDSDrugDisease药物对疾病禁忌HAS_SYMPTOMDiseaseSymptom疾病表现症状DIAGNOSED_BYDiseaseExam疾病通过检查确诊2.2.1 用LOAD CSV做首轮导入如果数据已经整理成CSV最常见的做法是用Neo4j内置的LOAD CSV它比Python逐条写Cypher快一个数量级。LOAD CSV WITH HEADERS FROM file:///drug_disease.csv AS row MERGE (d:Disease {code: row.disease_code}) ON CREATE SET d.name row.disease_name MERGE (p:Drug {code: row.drug_code}) ON CREATE SET p.name row.drug_name, p.usage row.usage MERGE (p)-[:TREATS]-(d);这段代码有三个值得注意的参数行为。MERGE保证节点和关系都幂等重复执行不会产生重复数据这在csv文件需要二次修正时很省事。ON CREATE SET只在节点首次创建时写入属性后续重跑不会覆盖已经手工补充的字段。code字段承担唯一键的作用因为不同CSV文件可能用“高血压”和“原发性高血压”两种写法直接按name做唯一键会导致一个真实实体拆成多个节点。LOAD CSV执行完务必在Neo4j Browser里检查行数是否与源文件一致。常见问题是CSV编码为GBK或带BOMNeo4j只认UTF-8无BOM否则中文名称变成乱码节点数对不上。2.2.2 用Py2neo做增量维护数据清洗是个持续过程CSV重跑不方便时我用Py2neo做增量。from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, password)) disease Node(Disease, codeH001, name高血压, department心内科) drug Node(Drug, codeM001, name硝苯地平, usage口服每次10mg) graph.merge(disease, Disease, code) graph.merge(drug, Drug, code) graph.merge(Relationship(drug, TREATS, disease))graph.merge的第一个参数是节点第二个是标签第三个是唯一键和Cypher的MERGE语义一致。调用两次merge分别处理两个节点再建关系是为了避免直接创建关系时因为节点不存在而报错。这段代码的逻辑是有则命中无则创建关系不重复。增量修正数据时只需要改节点的属性再merge一次副作用最小。2.3 导入之后必须做的三个一致性检查数据导完不代表建模成功。第一在Neo4j Browser执行MATCH (d:Disease) RETURN count(d)确认实体数在预期区间。第二随机抽一个疾病比如“高血压”用MATCH (d:Disease {name:高血压})-[r]-(n) RETURN distinct type(r), labels(n)查看它关联的实体类型是否覆盖了药物和症状。第三查孤立节点节点没有任何关系通常是实体抽取时关系字段缺失这类数据在问答阶段只会让召回变空。命名一致性是另一个坑。“硝苯地平”可能在CSV里写作“硝苯地平片”它们是同一款药的两个写法。医疗数据里这个问题尤其严重我的习惯是在schema里增加alias数组属性实体解析阶段用词典做别名归一而不是在CSV阶段强行合并。宁可多留一条别名关系也不要丢掉一个真实实体。3. 医疗问答系统的核心问句解析与意图识别3.1 解析器要先解决分词问题再谈语法医疗问句的歧义集中在实体切分上。“高血压患者能喝咖啡吗”如果按通用分词大概率切出“高血/压患/者”因为“压患”不是词典词。处理方式有两种实践中往往搭配使用。第一种是加载自定义词典到分词器。HanLP和jieba都支持这个方式我以jieba为例。import jieba jieba.load_userdict(medical_dict.txt) # medical_dict.txt 每行一个词格式高血压 1000 nz # 全科词典里补充高血压、原发性高血压、硝苯地平、美托洛尔、体位性低血压 seg_list jieba.lcut(高血压患者能不能吃硝苯地平) print(seg_list) # 预期输出[高血压, 患者, 能不能, 吃, 硝苯地平]load_userdict加载的词典优先级高于默认词典词频设置到1000以上可以有效防止细粒度切分。nz表示专有名词词性供后续词性过滤用。第二种依赖规则本身在实体识别阶段用“最长匹配优先”。如果图谱里已经有实体词典直接从问句里枚举所有词典词并选长度最长且位置重叠的命中比分词更可控。凡是医疗规则问答我强烈建议实体识别和分词解耦分词结果只做参考实体命中以词典枚举为准。3.2 意图识别用规则模板起步不做机器学习问句解析的目的是得到四元组实体、意图、属性、限定条件。意图用一组正则但不能只用一条要按优先级顺序匹配否则“治疗高血压的药有什么副作用”会被先命中“治疗”而忽略“副作用”。import re INTENT_RULES [ (side_effect, re.compile(r副作用|不良反应|吃了.*会.*(?:头晕|恶心|呕吐))), (treatment, re.compile(r治疗|吃什么药|用药|怎么治|吃啥)), (forbid, re.compile(r禁忌|不能用|不能吃|忌|禁用)), (symptom, re.compile(r症状|表现|会.*(?:头晕|头痛|心悸))), (exam, re.compile(r检查|确诊|诊断)), ]匹配顺序是有讲究的。“副作用”规则必须放在“治疗”规则之前因为“治疗高血压的药有什么副作用”同时满足两个模式先命中副作用才更贴近真实问题。匹配时从问句文本中提取不依赖分词结果。INTENT_RULES里的每个正则都尽量用具体症状词替代宽泛的“怎么样”减少误匹配。3.2.1 属性与限定条件的提取门槛属性提取常见的是“用法用量”和“禁忌人群”。问句里出现“一天几次”“多大剂量”时提取粒度到药物节点属性出现“孕妇”“老人”“儿童”时这些限定条件不直接改查询而是要传到答案过滤阶段。attr_patterns { dosage: re.compile(r一天几次|用量|剂量|吃多少), frequency: re.compile(r一天.*次), population: re.compile(r孕妇|老人|儿童|哺乳期|肝功能不全), }这部分的输出是字典{entity: 硝苯地平, intent: treatment, attr: {population: 孕妇}}。注意不要让属性参与图查询本身因为图谱里可能没有“孕妇”这个节点强查会直接空结果。正确的做法是先查出药物再在后处理里过滤说明书禁忌信息。3.3 大模型辅助意图识别能用但别依赖近两年的热门话题是基于DeepSeek这类模型做问答系统我院子里也尝试在规则识别后加一个LLM纠偏层。做法很简单规则结果拿不准时把实体和问句交给大模型做意图分类限定输出为几个固定枚举值。这不改变整体架构只是把规则的召回结果做二次确认。如果你也想这么接注意两点。第一大模型的输出要做枚举映射不能让它自由发挥否则下游模板匹配会崩。第二延迟是硬伤一个规则查询在50毫秒内完成加一次大模型调用直接到秒级所以LLM只做兜底纠偏不要做全量解析。4. 查询构建与答案生成从意图映射到Cypher的正确姿势4.1 意图到Cypher模板的显式映射规则解析完下一步是把解析结果翻译成图查询。很多入门项目在这里用字符串拼接这是最危险的写法。因为实体名称来自外部输入拼接Cypher存在注入风险也容易因引号或中文标点报错。一律使用参数化查询。def build_query(entity: str, intent: str): if intent treatment: return MATCH (d:Disease {name: $name})-[:TREATS]-(p:Drug) RETURN p.name AS drug_name, p.usage AS usage LIMIT 8 , {name: entity} if intent forbid: return MATCH (d:Disease {name: $name})-[:FORBIDS]-(p:Drug) RETURN p.name AS drug_name, p.contraindication AS reason , {name: entity}$name是参数占位符执行时由驱动单独传参与字符串拼接的区别在于参数值不会被当作Cypher语法解析。LIMIT 8是为了防止某个疾病关联了太多药品导致响应超时。返回字段里usage直接作为答案素材省去二次查询。同一意图下还可能存在关系方向的坑。治疗是外界药物指向疾病所以查询写成(d:Disease)-[:TREATS]-(p:Drug)禁忌同样是外界药物指向疾病方向一致。但症状查询反过来了(d:Disease)-[:HAS_SYMPTOM]-(s:Symptom)。方向搞反一次你的问答系统会稳定返回空结果而且这种空结果极难在测试时被察觉。4.2 答案生成的模板策略与无结果兜底图查询返回的是记录列表不是人能直接读的语言。需要模板把记录拼成自然句。治疗类答案拼成“针对高血压常见治疗药物有硝苯地平口服每次10mg、美托洛尔口服每次25mg”先列药名再括注用法。禁忌类答案拼成“高血压患者禁用以下药物XXX。原因XXX”。属性缺失时留空不要让程序抛出KeyError。无结果处理是这个项目的分水岭。一个真实可用的问答系统50%的查询都拿不到答案或拿不全答案没有兜底策略的系统会表现为“问什么都是对不起”。我常用的兜底顺序是改名实体再查一次把“高血压”换成“原发性高血压”尝试命中检查是否存在同义词节点用alias属性做二次匹配返回相关实体列表例如查“高血压怎么治”没结果就返回“高血压”关联的所有症状实体提示用户换个问法以上都不行才返回“暂未收录该问题答案”。4.3 查询性能的验证点与索引设计Neo4j节点数在10万级别以下图谱问答通常不需要太多优化但有两个性能点值得Verify。第一实体名称查询必须走索引否则全库扫描会让问答响应时间从毫秒级变成秒级。CREATE INDEX disease_name_index FOR (n:Disease) ON (n.name); CREATE INDEX drug_name_index FOR (n:Drug) ON (n.name);索引是覆盖全标签的匹配时字段名必须和创建时完全一致。第二个性能瓶颈是ORM层。Py2neo的graph.run每次调用都会建立事务和网络往返。问答场景下一次回答可能触发两三个Cypher查询如果把每次查询都拆成单独事务连接开销可能比查询本身还大。实测下来把多个查询合并进一个事务里小数据量差距不明显但到了生产规模响应时间差一个量级。合并方式是用事务函数一次性执行多个Cypher。5. 拿到一个可直接运行的压缩包后先验证这三个点5.1 环境适配和目录结构检查标题里说“完整代码数据可直接运行”但“可直接运行”在不同机器上完全是两码事。拿到压缩包第一件事不是启动脚本而是先看依赖文件。有requirements.txt先核对Python版本py2neo的8.x版本不支持Python 3.12这是个高频踩坑点。如果代码里用的是from py2neo import Graph建议在Python 3.8到3.11之间运行。数据文件的位置也要检查。医疗图谱项目常见的数据目录是data/里面有diseases.csv、drugs.csv、relations.csv。用文本编辑器打开CSV确认第一行是列名确认文件编码是UTF-8。我遇到过太多案例症状识别全部失败最后定位是CSV里有BOM导致第一个字段名变成\ufeffnameCypher里匹配不到。5.2 用金标准问题集做回归验证到底答得好不好不能靠感觉。从图谱的实际内容里抽20个覆盖“治疗、禁忌、症状、检查、副作用”五类意图的问题写进验证集构建一个代码函数自动比对预期与返回。test_questions [ (高血压患者能喝酒吗, forbid), (高血压吃什么药, treatment), (美托洛尔有什么副作用, side_effect), (高血压需要做什么检查, exam), (高血压有什么症状, symptom), ] def evaluate(qa_pipeline, test_questions): hit 0 for question, expected_intent in test_questions: parsed qa_pipeline.parse(question) if parsed[intent] expected_intent: hit 1 print(fintent accuracy: {hit / len(test_questions):.2%})evaluate函数只评估意图识别准确率这是问答系统的地基。实体识别和答案生成的评估需要人工看结果不适合自动断言。跑完这一步你至少能分清系统短板在三段流程的哪一段而不是笼统地说“效果不好”。5.3 继续扩展从手工标注到LLM辅助的Cypher生成规则模板最怕新中问法比如“血压高到180了要怎么办”里的“血压高”你的图谱里没有“血压高”这个节点。两个扩展方向是低成本高收益的。第一个方向是扩展alias。把“血压高”“血压偏高”统一映射到“高血压”实体这是医疗问答系统里性价比最高的一步。第二个方向是让大模型做实体归一和Cypher生成。既然已经用DeepSeek做意图纠偏可以把整个问句交给它生成Cypher但要用带模板的few-shot示例约束输出接着做两步自检第一步检查生成的Cypher里是否存在图谱中不存在的属性名第二步在Neo4j里EXPLAIN验证语法后执行。这两步自检能拦截掉大部分幻觉查询。这样一个hybrid策略跑起来后规则模板负责确定性大模型负责泛化你既保留了传统图谱问答的可解释性又拿到了LLM对口语化表达的容忍度。用阶段式的方式演进不要一开始就把全部解析交给大模型因为图数据库查询不可控会把整个系统的可用性带到沟里去。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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