肝病知识图谱问答系统实战:从数据导入到Cypher查询全链路
简介这份资源是肝病知识图谱问答系统的完整源码包面向具备Python基础、希望入门知识图谱与智能问答开发的开发者与学习者。项目以肝病领域为场景将疾病、症状、治疗、药物等实体及其关联组织为结构化图谱并在此基础上实现自然语言提问到答案检索的完整链路适合作为医疗知识图谱与NLP方向的实战练手项目。压缩包共28个文件约9.31MB以9个Python脚本为核心配合8个txt词典与数据文件、5个xml配置、3个json数据及说明文档覆盖图谱构建、问句分类、问句解析、答案搜索与对话入口等模块目录结构清晰便于按流程阅读与二次开发。目前已有115人学习下载。通过该资源可掌握知识图谱的构建与查询思路、自然语言问句到图谱查询的映射方法以及问答系统的整体工程组织方式对提升Python开发与NLP应用能力有实际帮助。1. 拿到 QASystemOnHepatopathyKG-master.zip 之后肝病知识图谱问答到底怎么跑起来很多人第一次看到 QASystemOnHepatopathyKG-master.zip 这个包名第一反应是「又一个知识图谱 demo」解压完发现里面既有 Neo4j 的导入脚本又有前端页面还有一堆没见过的实体词典直接卡在第一步。这个项目解决的是一个很具体的问题把肝病领域的实体、关系、属性组织成图结构再让用户用自然语言提问系统把问题解析成图查询语句从 Neo4j 里捞出答案返回。它适合两类人一类是想找一个完整 KGQA 链路练手的中级开发者另一类是做医疗信息化、想把科室里的诊疗知识做成可查询系统的工程师。整条链路的核心不是模型多强而是「实体识别准不准、意图映射对不对、Cypher 拼得对不对」这三件事。跑通它不需要 GPU一台能装 JDK 和 Neo4j 的机器就够但坑基本都藏在数据清洗和问句模板的边界上。2. 肝病知识图谱的数据从哪来、怎么进 Neo4j2.1 先看清数据文件的结构再动手解压之后不要急着启动服务先把数据目录翻一遍。这类 KGQA 项目的数据通常分三块实体文件disease、drug、symptom、food 等、关系文件三元组、以及问答模板文件。实体文件一般是「实体名 实体类型」两列关系文件是「头实体 关系 尾实体」三列分隔符可能是逗号、制表符或者竖线这一步必须先确认否则后面导入全是脏数据。我一般会先写一个探查脚本把每个文件的编码、分隔符、行数、字段数打出来避免直接导入后才发现某列错位。# inspect_data.py import csv, os DATA_DIR ./data # 按实际解压路径改 for fname in os.listdir(DATA_DIR): path os.path.join(DATA_DIR, fname) if not os.path.isfile(path): continue with open(path, r, encodingutf-8) as f: # 先读前 5 行看结构不假设分隔符 head [next(f).rstrip(\n) for _ in range(5)] print( * 40) print(file:, fname) for line in head: print(repr(line))逻辑说明这段脚本不做任何解析假设只把原始行 repr 出来让你肉眼判断分隔符到底是\t还是,以及有没有表头。参数上DATA_DIR指向解压后的数据目录range(5)控制预览行数数据量大时不要一次全读进内存。确认结构后把实体和关系整理成 Neo4j 能吃的 CSV。常见做法是统一成entity.csvname, type和relation.csvhead, relation, tail编码统一 UTF-8 无 BOM。2.2 Neo4j 导入LOAD CSV 的写法与索引Neo4j 导入有两种路子LOAD CSV适合中小规模、可反复调试neo4j-admin import适合千万级、要求停机。肝病知识图谱这种体量LOAD CSV完全够用而且改起来快。// 建唯一约束避免重复导入产生重复节点 CREATE CONSTRAINT entity_name IF NOT EXISTS FOR (n:Entity) REQUIRE n.name IS UNIQUE; // 导入实体 LOAD CSV WITH HEADERS FROM file:///entity.csv AS row MERGE (n:Entity {name: row.name}) SET n.type row.type; // 导入关系先匹配两端节点再建边 LOAD CSV WITH HEADERS FROM file:///relation.csv AS row MATCH (h:Entity {name: row.head}) MATCH (t:Entity {name: row.tail}) MERGE (h)-[r:REL {type: row.relation}]-(t);逻辑说明MERGE而不是CREATE是为了幂等——重复执行不会产生重复节点。CREATE CONSTRAINT那行是性能关键没有唯一约束时MERGE会全表扫描几万行数据就能让你等到怀疑人生。参数上CSV 文件要放到 Neo4j 的import目录下file:///后面跟文件名路径写错会直接报找不到文件。导入完做一次校验确认节点数和关系数对得上MATCH (n:Entity) RETURN count(n) AS node_count; MATCH ()-[r:REL]-() RETURN count(r) AS rel_count;如果 node_count 明显小于源文件行数八成是实体名里有空格或特殊字符导致 MERGE 合并了回去查数据清洗环节。2.3 实体类型和关系的设计取舍很多新手会纠结「症状」到底算实体还是算疾病的属性。我的经验是只要它需要被单独查询、或者会和其他实体产生关系就建成节点如果只是描述性文本、永远不参与匹配就放属性。肝病场景里症状、药品、检查项、科室都值得建节点而「疾病简介」这种长文本放属性更合适。关系命名也要统一别一会儿has_symptom一会儿症状后面拼 Cypher 时大小写和命名风格不一致是排查起来最烦的一类问题。建议全部小写下划线关系类型在代码里用常量管理。3. 问句怎么变成 Cypher意图识别与模板映射3.1 意图分类的最小可用方案KGQA 的核心难点是「用户问法千变万化但图查询只有那么几种」。这个项目里常见的意图有查疾病的症状、查症状对应的疾病、查疾病的用药、查药品的适应症、查疾病的检查项。最省事的做法不是上大模型而是「关键词 模板」先跑通。# intent.py INTENT_RULES [ ([症状, 表现], disease_to_symptom), ([吃什么药, 用药, 药物], disease_to_drug), ([什么病, 哪些病, 会导致], symptom_to_disease), ([检查, 化验], disease_to_check), ] def detect_intent(question: str): for keywords, intent in INTENT_RULES: if any(kw in question for kw in keywords): return intent return unknown逻辑说明按关键词优先级从上到下匹配先命中先返回。参数上INTENT_RULES的顺序很重要比如「症状」和「什么病」可能同时出现谁先谁后决定结果。这套方案召回率高但精确率一般适合先跑通链路后面再换成分类模型。3.2 实体抽取词典匹配够不够用意图定了还得知道用户问的是哪个病、哪个药。最直接的办法是拿实体词典做匹配把问句里出现的实体名捞出来。# ner.py def extract_entities(question: str, entity_dict: dict): hits [] for name, etype in entity_dict.items(): if name in question: hits.append((name, etype)) # 长实体优先避免「肝炎」把「乙型肝炎」切碎 hits.sort(keylambda x: len(x[0]), reverseTrue) return hits逻辑说明entity_dict是「实体名 - 类型」的映射从 Neo4j 或本地文件加载。排序那行是关键——如果词典里同时有「肝炎」和「乙型肝炎」不按长度排序就会先匹配到短的导致实体识别错误。参数上词典越大匹配越慢实体过万时建议换成 Aho-Corasick 或前缀树。3.3 模板填充与 Cypher 生成意图和实体都有了接下来就是拼查询。每种意图对应一个 Cypher 模板把实体名填进去。# cypher_builder.py TEMPLATES { disease_to_symptom: ( MATCH (d:Entity {name: $name})-[:REL {type: has_symptom}]-(s) RETURN s.name AS answer ), disease_to_drug: ( MATCH (d:Entity {name: $name})-[:REL {type: has_drug}]-(s) RETURN s.name AS answer ), } def build_cypher(intent: str, entity_name: str): tpl TEMPLATES.get(intent) if not tpl: return None, None return tpl, {name: entity_name}逻辑说明用参数化查询$name而不是字符串拼接一是防注入二是 Neo4j 能缓存执行计划。参数上entity_name必须和 Neo4j 里存的完全一致差一个空格都查不到所以前面实体抽取时最好做一次去空格和全半角归一化。3.4 把链路串起来跑一次# qa_pipeline.py from intent import detect_intent from ner import extract_entities from cypher_builder import build_cypher def answer(question, entity_dict, run_cypher): intent detect_intent(question) ents extract_entities(question, entity_dict) if not ents: return 没识别到实体 name, _ ents[0] cypher, params build_cypher(intent, name) if not cypher: return 没匹配到意图 return run_cypher(cypher, params)逻辑说明run_cypher是外部传入的执行函数方便替换成真实 Neo4j 驱动或测试桩。参数上ents[0]只取第一个实体多实体问句需要额外处理这是这套最小方案的边界。4. 避坑与排查跑不通时先看这几处4.1 导入后查不到数据现象Cypher 明明写对了返回空。原因CSV 里有 BOM 或首行表头被当成数据导致实体名带了不可见字符。解决用LOAD CSV WITH HEADERS并确认文件是 UTF-8 无 BOM必要时在导入前用脚本 strip 掉\ufeff。4.2 实体匹配总是匹配到短的现象问「乙型肝炎的症状」结果查的是「肝炎」。原因词典遍历顺序不确定短实体先命中。解决按实体名长度降序排序后再匹配或者用最大正向匹配算法。4.3 关系类型大小写不一致现象模板里写has_symptom数据里存的是Has_Symptom查询永远为空。原因导入时没统一命名规范。解决导入前统一转小写代码里关系类型用常量别手写字符串。4.4 Neo4j 内存不够导致导入中断现象导入到一半报堆内存溢出。原因LOAD CSV默认批量提交大文件会撑爆堆。解决在neo4j.conf里调大dbms.memory.heap.max_size或者用CALL {} IN TRANSACTIONS分批提交。4.5 问句里实体带别名查不到现象用户问「乙肝」词典里只有「乙型肝炎」。原因没有别名映射。解决建一张别名表实体抽取前先把别名归一化成标准名这一步在医疗场景里几乎是必须的。5. 让问答更稳的两个进阶技巧第一个技巧是给意图识别加一层兜底。关键词匹配在遇到「这个病平时要注意啥」这种没有明显意图词的问句时会返回 unknown我的做法是维护一个「高频未知问句」日志每周把新出现的问法补进规则或模板跑上一个月覆盖率能明显上去。第二个技巧是给 Cypher 查询加超时和结果上限避免某个实体关系特别多时把前端卡死。# 给查询加超时和上限 def safe_query(driver, cypher, params, timeout3.0, limit50): with driver.session() as session: result session.run(cypher f LIMIT {limit}, params, timeouttimeout) return [record[answer] for record in result]逻辑说明timeout是服务端执行超时limit防止返回过多结果。参数上超时别设太短Neo4j 冷启动第一次查询会慢3 秒是个比较稳的起点。验证这套系统好不好用别只看它答对了多少要看它答错时的表现——是返回空、返回无关答案还是直接报错。返回空说明实体或意图没匹配上返回无关答案说明模板映射错了报错说明 Cypher 拼错了三种情况对应三处不同的排查方向。我自己踩过的最大坑是早期没做实体归一化用户换个说法就查不到后来加了别名表才稳定下来。做这类项目数据清洗和边界处理的时间永远比写查询多别指望一次跑通留出调试的耐心。希望帮到你。本文还有配套的精品资源点击获取