十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于知识图谱的肝病问答系统构建:从数据建模到Cypher查询

基于知识图谱的肝病问答系统构建:从数据建模到Cypher查询 简介面向肝病知识图谱问答系统开发者的Python源码包整合知识图谱构建、问题分类、语义解析与自动回答全流程适合医疗NLP初学者或希望系统学习问答系统落地的开发者。压缩包共28个文件其中9个Python脚本是核心实现如医疗图谱构建、问答解析、答案搜索等8个txt多为依赖说明或数据字典5个xml与3个json用于项目配置与结构化数据存储另有README与license文档整体仅9.31MB小巧便于快速部署。已有112人学习浏览项目目录按数据准备、图谱构建、问答主流程分层代码结构清晰。通过学习可直接复用肝病实体关系抽取、意图识别与图谱查询模板并掌握将BERT类预训练模型融入知识图谱问答的工程思路为构建垂直领域智能问答系统提供可扩展参考。1. 肝病问答不是检索匹配QASystemOnHepatopathyKG 给了一条完整链路一个肝病科室的知识问答需求最常见的错误是直接拿 ES 或者 like 查询做全文检索。用户问“肝硬化早期有什么症状”全文检索会把“肝硬化”和“早期”拆开匹配返回一堆包含“早期”的干扰记录而且回答永远是一段原文摘录不是结构化的症状清单。QASystemOnHepatopathyKG 换了一条路先用 Python 把肝病相关的疾病、症状、检查、药物、科室等实体抽出来建成语义明确的图结构再通过问句分类、实体识别、Cypher 查询生成三个模块把自然语言问题转成图查询。这套工程设计思路对做医疗问诊、客服知识库、领域 FAQ 系统的人都有参考价值它不依赖大模型也能把问题回答得可控、可解释。2. 肝病知识图谱的数据建模与图谱构建2.1 从原始数据到属性表prepare_data 到底在准备什么拿到压缩包后先看prepare_data目录和data目录的区别。data通常是原始采集数据prepare_data里放的是清洗后的结构化表格常见命名是disease.txt、symptom.txt、examine.txt之类。每一行代表一个实体或一条关系记录列之间用分隔符切分。这个项目在构建图谱前主要做三件事去重、拆列、统一命名。去重不是简单的 set 操作比如“乙肝”和“乙型肝炎”在临床病历里可能同时出现这类描述同一实体的别名如果没有提前合并且建立映射关系导入 Neo4j 后会生成两个节点后续问答匹配就会漏答。拆列是另一个高频操作。原始表里某个字段经常是“头晕\n乏力\n食欲不振”这样的换行列表甚至是用中文逗号分隔的一长串。如果直接把这个字段塞进节点的 property查询“肝硬化有哪些症状”时会返回一整块字符串没法按单个症状做聚合和展示。正确做法是在 prepare 阶段把长文本拆成分词列表再以列表形式写入节点属性。统一命名则是把“肥胖症”“肥胖病”“单纯性肥胖”这类表达归一到一条标准名上后续实体识别才能稳定命中。2.2 实体与关系建模节点类型、关系命名和约束设计实体类型直接决定问答系统能回答哪几类问题。参考这类医学知识图谱项目的通用设计实体一般分为六类Disease疾病、Symptom症状、Check检查项目、Drug药物、Department科室、Food食物。关系类型围绕问题和属性展开比如疾病到症状用HAS_SYMPTOM疾病到检查用NEED_CHECK疾病到药物用RECOMMEND_DRUG疾病到科室用DEPARTMENT_IS。关系命名最忌讳用“属于”“包含”这种语义模糊的词比如“脂肪肝属于肝病”“属于”本身没有业务含义查询时无法判断查询方向。我一般会在建模阶段把关系名设计成“动词 宾语”风格让一条边能独立解释一个事实。实体和关系的 schema 确定后先建约束再导数据这是 Neo4j 工程里最容易漏的一步。不建约束时重复运行 build 脚本会在图上产生大量重复节点问答系统查询时返回多行重复结果答案还是一样的。建约束的目的一个是保证实体名称唯一另一个是给后续查询走索引而不是全图扫描。建约束和索引的 Cypher 如下CREATE CONSTRAINT disease_name_unique IF NOT EXISTS FOR (n:Disease) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT symptom_name_unique IF NOT EXISTS FOR (n:Symptom) REQUIRE n.name IS UNIQUE; CREATE INDEX disease_name_index IF NOT EXISTS FOR (n:Disease) ON (n.name); CREATE INDEX symptom_name_index IF NOT EXISTS FOR (n:Symptom) ON (n.name);第一段为 Disease 和 Symptom 节点建唯一约束防止同名实体被重复创建。第二段为 name 属性建普通索引加速后续按名称定位节点的速度。旧版 Neo4j 的CREATE CONSTRAINT ON (n:Disease) ASSERT n.name IS UNIQUE在 4.x 之后已废弃项目跑不起来先检查是不是语法版本问题。约束和索引建完后再用CALL db.awaitIndexes()或者SHOW INDEXES验证索引状态确保不是 pending 状态。2.3 批量写入用 py2neo 还是直接跑 Cypher数据量在一万节点以下时用 py2neo 逐条创建和直接跑 Cypher 性能差异不大但 py2neo 的对象模型在调试阶段更方便读。常见做法是先用 Cypher 做好约束再在build_medicalgraph.py里用graph.run()执行 MERGE 语句。MERGE 的语义是“有则匹配无则创建”适合幂等导入重复执行 build 不会产生重复数据。一个批量导入疾病节点的代码片段如下from py2neo import Graph graph Graph(bolt://localhost:7687, auth(neo4j, password)) def import_diseases(disease_rows): for row in disease_rows: name row[name].strip() if not name: continue graph.run( MERGE (d:Disease {name: $name}) SET d.desc $desc, d.prevent $prevent, namename, descrow.get(desc, ), preventrow.get(prevent, ) )每一次 MERGE 都会先按{name: $name}匹配已有节点匹配不到再创建SET 语句负责把描述、预防措施等属性覆盖写入。参数通过$name、$desc这种形式传进去而不是直接拼接字符串避免属性值里出现引号或特殊字符把 Cypher 弄坏。导入关系时要特别注意方向比如疾病到症状的关系写成MATCH (d:Disease {name: $disease}) MATCH (s:Symptom {name: $symptom}) MERGE (d)-[r:HAS_SYMPTOM]-(s)这里先分别定位两个端点再在它们之间建关系。如果怀疑某条关系的两个端点里有一个是空节点或者根本没匹配上可以提前在代码里判断graph.run(MATCH ... RETURN d).data()是否为空为空直接跳过防止建出悬空关系。整批导入跑完后在 Neo4j Browser 里执行MATCH (n:Disease) RETURN count(n)验证节点总量和 prepare_data 里的行数做比对差异较大就说明有重复实体或者过滤逻辑写错。3. 问句意图分类与肝病实体识别3.1 意图分类不是上 BERT而是先定义清楚几类问法问答系统的第一步是把用户问题归到一个有明确查询模板的类别里。这类字典驱动项目一般把意图定义为“疾病到症状”“疾病到检查”“疾病到药物”“疾病到科室”这几种组合再加上可能的“疾病到预防”和“疾病到治疗”。每个意图对应一组关键词分类器的实现就是遍历这些关键词表统计命中数量得分最高的意图胜出。这个设计看起来简单但它的边界很清楚覆盖不了不在词典范围内的问题因此后续迭代要围绕词典扩充来做。核心代码结构如下class QuestionClassifier: def __init__(self): self.word_dict { disease_symptom: [症状, 表现, 早期, 临床, 有什么感觉], disease_check: [检查, 检测, 化验, 查什么], disease_drug: [吃什么药, 用药, 药物, 治疗], disease_department: [挂什么科, 哪个科室, 就诊科室], } def classify(self, question: str): scores {intent: 0 for intent in self.word_dict} for intent, words in self.word_dict.items(): for w in words: if w in question: scores[intent] 1 best max(scores, keyscores.get) return best if scores[best] 0 else fallback逻辑是先遍历所有意图统计每个意图命中的关键词数量命中最多的获胜。fallback是兜底意图当问题完全匹配不到关键词时走默认回答。这种做法的优点是逻辑透明调试时一行日志就能看出分类依据缺点是关键词长度和特异性差异容易误判比如“治疗”既命中disease_drug又可能命中其他意图实际情况要看词典怎么设计。一个常用的修正策略是给关键词加权长关键词权重更高短词只做辅助。3.2 实体识别词典扫描加最长匹配而不是直接套 BERT实体识别在这个项目里用的是基于词典的扫描结合 jieba 分词做候选提取。把 prepare_data 里出现的全部疾病名、症状名、检查名、药物名收集起来生成一个自定义词典加载进 jieba。之后对问句分词凡是出现在词典里的词都标记为实体候选。这里有个绕不开的坑jieba 默认词典会把“肝硬化”切成“肝”和“硬化”或者把“乙肝”切成“乙”和“肝”。解决方法是在初始化时用jieba.add_word(肝硬化)把标准疾病名整体加入分词器并且提高词频。光是分词还不够问题里可能同时出现多个实体。比如“肝硬化需要做哪些检查”一句里“肝硬化”是疾病实体“检查”是意图关键词不是实体而“乙肝肝硬化患者查什么能确诊”里“乙肝肝硬化”和“肝硬化”可能同时命中。处理办法是先做最长匹配优先取更长的实体名如果长的和短的实体之间是包含关系只保留最长的那一个。一个简化版的实体提取实现import jieba entity_dict [肝硬化, 乙肝肝硬化, 肝癌, 脂肪肝, 转氨酶, 肝功能] def extract_entities(question: str): for w in entity_dict: jieba.add_word(w) tokens jieba.lcut(question) entities [t for t in tokens if t in entity_dict] entities.sort(keylen, reverseTrue) for e in entities: for other in entities: if e ! other and e in other: entities.remove(e) return entities这段代码分两层处理。第一层把实体词典注入 jieba保证分词时不会把疾病名切开。第二层把识别到的实体按长度排序后删除被更长实体包含的短实体比如“乙肝肝硬化”存在时去掉“肝硬化”。但这只是粗筛工程上更可靠的做法是把词典维护成按键长度排序的前缀树一次性扫描找到所有最长匹配而不是先分词再过滤。分词思路的局限在于病名包含标点或者罕见组合时容易失配前缀树方案在这些场景下更稳定。3.3 实体别名与歧义处理词典里要带一份同义词映射实体识别离不开同义词映射。临床问法里“肝部不适”“右上腹不舒服”可能都指向肝区症状但词典里只有“肝区不适”。这个映射关系一般维护在一个dict里key 是问法里的词value 是图谱中的标准实体名。分类器里命中“肝部不适”后替换成“肝区不适”再去图谱查询。别名映射表的典型形态alias_dict { 肝部不适: 肝区不适, 乙肝: 乙型肝炎, 脂肪肝: 脂肪性肝病, 甲胎蛋白: AFP, }这里还有一个实际问题实体识别出的词可能既不是疾病名也不是症状名而是某个节点 property 里的值比如“转氨酶”在数据表里既是检查名称又是某个描述字段里的词。处理方式是在实体识别阶段给出类型概率同一个词在疾病场景下优先被认为是检查实体在症状场景下优先被认为是症状实体。类型优先级的判断依据来自意图分类的结果这就是系统中意图分类和实体识别两个模块需要级联而不是独立运行的原因。4. 从意图到 Cypher查询构建与答案生成4.1 意图到查询模板的映射表意图分类和实体识别的结果要合并成结构化的查询请求。question_parser.py在这个阶段做的事情是把用户问题转成一个 dict{intent: disease_symptom, entities: {Disease: [肝硬化]}}。之后answer_search.py根据 intent 选择对应的 Cypher 模板把实体值填入查询参数。模板设计要点是每个模板只服务一个明确的问题类型查询返回的字段固定便于回答层做统一处理。常用映射关系如下意图Cypher 模板返回内容disease_symptomMATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom) WHERE d.name $name RETURN s.name症状列表disease_checkMATCH (d:Disease)-[:NEED_CHECK]-(c:Check) WHERE d.name $name RETURN c.name检查项目列表disease_drugMATCH (d:Disease)-[:RECOMMEND_DRUG]-(drug:Drug) WHERE d.name $name RETURN drug.name推荐药物列表disease_departmentMATCH (d:Disease)-[:DEPARTMENT_IS]-(dep:Department) WHERE d.name $name RETURN dep.name就诊科室模板里的$name全部走参数化查询一是防止用户输入的内容破坏 Cypher 语法二是让 Neo4j 对同一模板的查询缓存执行计划重复查询时性能更好。实际编码时我会在方法入口打日志把 intent、entities、最终生成的 Cypher 和参数打出来调试时能看到“问题被理解成了什么”而不是只看最后答案。4.2 查询结果的组装从图数据到自然语言查询结果本身是 list 类型的数据比如[乏力, 食欲不振, 黄疸]不能直接丢给用户。答案生成层要负责拼装完整回答拼装规则按意图区分。症状类问题的回答格式是“肝硬化的典型症状乏力、食欲不振、黄疸”检查类问题的回答格式是“肝硬化通常需要做以下检查肝功能、B超、肝穿刺”。实现上就是把实体名和查询出的列表分别放到模板槽位里def answer_symptom(disease_name, symptom_names): if symptom_names: return f{disease_name}的常见症状包括{, .join(symptom_names)} return f抱歉知识库中暂未收录{disease_name}的症状信息这段逻辑把查询结果列表用中文逗号接起来拼进预设的模板。空结果判断放在应答层而不是查询层因为查询空结果可能是知识库未收录也可能是意图分类错误导致查错了方向。区分这两种情况需要回看分类日志。这里的join操作要注意列表元素必须是字符串图谱查询返回的节点属性如果带 None 要提前过滤否则拼接时会报错。4.3 空回退与多实体消歧问答质量的最后一道关知识图谱问答常踩的坑是查询结果为空时直接回复“不知道”导致用户觉得系统很笨。更合理的设计是逐级回退先查最细粒度的问题比如“乙肝肝硬化早期症状”查不到再去掉“早期”这个修饰词查“乙肝肝硬化症状”再查不到就退回“肝硬化症状”。实现这层回退需要在查询层判断返回行数如果为空就逐步放宽 Cypher 模板中的条件而不是改参数。这里的关键是放宽条件的路径要事先定义好不能随意组合否则可能返回一个跨度太大的错误答案。多实体问题也要在生成答案前消歧。用户问“转氨酶高和脂肪肝有关系吗”这句话里“转氨酶”是检查实体“脂肪肝”是疾病实体意图是疾病和检查之间的关系。常见处理是优先选择疾病实体作为查询主体检查实体作为限定条件然后查询图谱中是否同时存在这条关系和对应的属性描述。只有实体类型消歧得足够干净答案才不会有歧义否则就是答非所问。5. QA 链路诊断与词典迭代的打磨技巧5.1 搭建一条调试管道每次回答都输出中间状态问答系统一旦部署实际使用用户问法五花八门光看正确回答无法定位问题。我会在chatbot_graph.py里加一个调试函数把整个流程的运行细节输出到日志作为排查问题的切入口def debug_qa(question: str): intent classifier.classify(question) entities parser.extract_entities(question) cypher, params search_engine.build_query(intent, entities) rows graph.run(cypher, **params).data() print(用户问题:, question) print(意图:, intent) print(实体:, entities) print(Cypher:, cypher, 参数:, params) print(返回行数:, len(rows))每次回答错误的case都能通过打印内容快速判断是分类错、实体识别错还是查询模板错。这三个环节里任何一个出错最终回答都是错的但修复位置完全不同。这比直接看最终答案盲调要高效得多。这个调试管道跑顺之后我会把一批真实问题存成文本批量回放验证回归效果。5.2 三大高频坑分词切碎、意图误判、图谱数据不一致第一是分词导致实体匹配失败。常见情况是“乙肝肝硬化”被 jieba 拆成“乙肝”和“肝硬化”或者“甲胎蛋白”变“甲胎”和“蛋白”实体识别返回多个短实体查询全部落空。兜底方案是把实体词典用jieba.suggest_freq调高词频同时再做一次全组合重扫。第二是意图误判比如“肝硬化吃什么药”同时命中“药”和“治疗”但词典设计里两者归属不同意图导致查询模板选错。修正方法是给更具体的词更高权重或者在代码里加意图互斥规则命中disease_drug的强关键词后不再参与其他意图投票。第三是数据和实体词典对不上prepare_data 里是“乙肝”实体词典里是“乙型肝炎”导入图谱的节点名是前者问答查询用后者永远查不到。这一般出在别名映射加载不完整要检查 alias_dict 是否在服务启动时就注入实体识别器。5.3 迭代节奏和升级路径维护这类问答系统的重心会慢慢从“写代码”变成“自主迭代”。每收一批问答失败案例标注出失败原因按意图和实体归类再扩充词典。图谱侧如果发现实体缺失就补建 MERGE 节点关系缺失就补插入新的关系。这套流程稳定之后如果有余力再评估升级到 BERT 类模型用微调后的模型替换词典分类器实体识别换成序列标注但查询模板和答案组装层可以完全复用这也是这套设计真正值钱的地方。验证升级效果时用上述 debug 函数把新旧两条链路输出对齐逐条比对 Cypher 生成结果确保升级过程可回滚。本文还有配套的精品资源点击获取
返回列表