简介一套基于 Neo4j 知识图谱与规则匹配的肝病问答系统完整项目面向自然语言处理、知识图谱方向的开发者与研究者。资源以 8000 余种疾病数据为基础聚焦 200 多种肝病构建了涵盖 4.4 万实体、30 万关系的医疗知识图谱原始数据来自垂直医疗网站寻医问药采用爬虫与 XPath 解析完成结构化采集。项目代码与数据齐全可直接运行体验从数据抓取、知识抽取、图谱构建到规则问答的完整流程。压缩包共 28 个文件、约 15.77MB包含 9 个 Python 源码、8 个词典/配置文本、3 个 JSON 数据、5 个工程配置文件以及 README 说明与 LICENSE 许可源码覆盖爬虫、数据预处理、图谱构建、问题分类、答案检索等关键模块疾病、症状、药品、食物等词典文件便于理解知识体系。已有 1046 人学习下载适合用来入门医疗知识图谱问答也可作为毕业设计或课程项目的可扩展底座。1. 肝病问答系统为什么选 neo4j 知识图谱加规则匹配大部分人拿到这套“基于 neo4j 知识图谱和规则匹配的肝病问答系统”代码第一反应是找入口文件、装依赖、把问句输进去看它答不答。但真正决定这套系统好不好用的不是启动命令而是背后的图谱长什么样、规则怎么组织。肝病问答和通用闲聊最大的区别在于用户问的是事实型问题需要答案可追溯、可解释答错一句可能让患者白跑一趟医院。这类场景里大模型反而未必比“知识图谱 规则匹配”可靠因为后者至少能明确告诉你答案是从哪条关系上来的。这套方案适合三类人一是要用完整代码和数据快速交付毕业设计的学生二是做医疗信息化产品、想验证问答闭环是否可行的工程师三是已经在学 neo4j 知识图谱、想找一套能落地的业务样例的从业者。下面我就按“数据建模 → 图谱导入 → 问答引擎 → 避坑 → 加固”的顺序把这个系统从零到能跑的完整思路讲清楚。2. 肝病知识图谱建模先别写代码把实体、关系和方向理清2.1 为什么是 neo4j而不是 RDF 或 MySQL肝病问答的核心查询模式是“给定一个实体找它的一度或二度邻居”。比如“肝硬化的并发症有哪些”本质是从“肝硬化”节点出发沿“并发症”这类边找到目标节点。“乙肝患者不能吃什么”是从“乙肝”出发找到“饮食禁忌”节点。这种查询如果用 MySQL得设计多张中间表再写两三层 JOIN数据一多查询语句就开始失控如果用 RDF 三元组和 SPARQL做语义推理确实更强但为了一个问答 demo 引入整套推理机运维成本明显偏高。neo4j 的属性图模型恰好卡在中间节点上可以挂属性关系上也可以挂属性Cypher 查询语句直观索引和遍历性能在百万级节点以内都很轻松。工业场景下做知识图谱选型常见做法也是先用属性图做应用层把推理需求拆成应用代码里的规则逻辑。这套肝病问答系统能“可直接运行”很大程度上正是因为选型选得轻。2.2 最小本体设计六类实体、八类关系拿到肝病数据第一件事不是写导入脚本而是把本体表定下来。我习惯先建一张最小实体清单实体类型关键属性例子疾病名称、别名、定义、传染性慢性乙型肝炎、肝硬化、肝癌症状名称、描述黄疸、腹水、乏力检查指标名称、正常范围、单位ALT、AST、总胆红素药物名称、用法、注意事项恩替卡韦、干扰素食物名称、性质鸡蛋、动物肝脏、酒精人群名称、特征孕妇、肝功能不全者关系不要建模得太多否则规则匹配时会疯掉。这套系统实际只需要八类关系就可以覆盖大部分问句疾病-症状临床表现疾病-并发症指向另一种疾病疾病-检查指标需要做什么检查疾病-药物治疗用药疾病-人群高发人群药物-食物相互作用比如“服用恩替卡韦时不宜饮酒”疾病-食物适宜或不适宜吃检查指标-正常范围挂在属性里还是节点上这里有个设计原则属性放节点类型放关系。肝病里有一个特殊情况“正常范围”这类数据看起来是关系实际放进检查指标节点的属性里更合理。比如“ALT 正常范围是 9-50 U/L”如果做成单独节点问答时还得多一跳查询做成属性一个查询就能同时返回指标值和参考范围规则拼接也简单。肝病还有一个特殊的建模难点疾病之间的进展链路。“肝炎 → 肝硬化 → 肝癌”是一条很长的演变链在图上就是三个疾病节点之间的“并发症”关系。如果建模时把方向设反了比如把“肝癌的并发症是肝衰竭”和“肝硬化的并发症是肝癌”混在一起路径查询就会得到错误结果。所以导入数据前必须先统一关系方向从“因”指向“果”从“上游疾病”指向“下游疾病”。2.3 数据预处理标准词加别名先解决同义词肝病数据公开来源不少但质量参差不齐最大的问题是同义词。同一个“乙肝”数据里可能出现“慢性乙型肝炎”“乙肝病毒携带”“CHB”同一个指标可能叫“ALT”也可能叫“谷丙转氨酶”。如果不同步归一问答引擎识别出“谷丙转氨酶”却去图谱里查“ALT”必然答不上来。我处理这类问题的方式是建一张标准的实体词表每行包含“标准名”和“别名列表”。用 Python 做归一化时先把所有文本统一转成小写、去掉全角空格再做别名替换import pandas as pd alias_map { 乙肝: [慢性乙型肝炎, 乙肝病毒携带, hepatitis b, chb], alt: [谷丙转氨酶, 丙氨酸氨基转移酶], 肝硬化: [肝硬变, liver cirrhosis], } def normalize_entity(text: str) - str: text text.strip().lower() for standard, aliases in alias_map.items(): if text in aliases or text standard: return standard return text df pd.read_csv(hepatitis_raw.csv) df[entity_std] df[entity_raw].apply(normalize_entity)这段代码里normalize_entity不只做字符串替换它把数据统一映射到标准名。问答引擎处理用户输入时也要调用同一个函数保证“入库”和“查询”走同一套归一化逻辑这是这类系统不答错的基础。需要注意数值型属性不要在这里清洗。比如“总胆红素正常范围 3.4-17.1 μmol/L”单位不统一时常见做法是统一换算成国际单位后再入库浓度和单位分开成两个字段方便问答时做数值比较。2.4 用 Python 批量生成 Cypher比手写几千条靠谱数据量少的时候直接在 neo4j 浏览器里录入几条没有问题但肝病数据到几百上千条以后逐条手写纯属自虐。常见做法是把清洗后的 CSV 转成批量 Cypher 脚本再用 cypher-shell 执行。我在项目里一般写个生成器import csv def generate_node_cypher(csv_path, label, fields): statements [] with open(csv_path, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: props , .join(f{key}: ${key} for key in fields) stmt fCREATE (n:{label} {{{props}}}); statements.append((stmt, {key: row[key] for key in fields})) return statements statements generate_node_cypher(disease.csv, Disease, [name, definition]) with open(import_disease.cypher, w, encodingutf-8) as f: for stmt, params in statements: f.write(stmt \n)这里用的是参数化模板而不是把值直接拼进字符串目的是防止名称里的引号和特殊字符把语句拆坏。生成的.cypher文件可以直接用cypher-shell -f import_disease.cypher执行比在浏览器里复制粘贴整段脚本稳定得多。3. 用 LOAD CSV 把肝病数据导入 neo4j从配置到 Cypher 查询3.1 neo4j 版本、JDK 与内存参数的硬边界neo4j 社区版是免费可商用的但版本升级换代时顺手淘汰了一批旧配置网上很多教程已经过时。要看清楚自己下的是什么版本4.4.x 用 Java 115.x 必须用 Java 17版本不匹配neo4j 服务根本起不来日志里只报一句“Unsupported Java version”。拿到项目先确认两件事系统里java -version的输出版本以及 neo4j 解压目录里conf/neo4j.conf是否存在。常见翻车点是用户以为改了内存配置实际改错了文件。neo4j 从 4.0 开始统一使用neo4j.conf网上那些教改neo4j.properties的教程针对的已经是爷爷辈版本。新版本内存相关参数是参数作用经验值server.memory.heap.initial_sizeJVM 堆初始大小4G数据量小就 1Gserver.memory.heap.max_sizeJVM 堆最大大小4Gserver.memory.pagecache.size页面缓存走内存的文件缓存2G 或物理内存一半server.default_listen_address监听地址默认 localhost部署到服务器改成 0.0.0.0改完配置必须重启 neo4j而且不是重启浏览器页面是重启 neo4j 服务进程。验证配置有没有生效不要靠猜直接执行SHOW SETTINGS。3.2 LOAD CSV 导入节点和关系MERGE 比 CREATE 安全neo4j 社区版导入数据最常见的方式是 LOAD CSV不需要额外装插件。导入前把xxx.csv放进 neo4j 安装目录的import文件夹否则文件路径会解析不到。节点导入代码如下逻辑不复杂但有两个细节必须说明。LOAD CSV WITH HEADERS FROM file:///disease.csv AS row MERGE (d:Disease {name: row.name}) ON CREATE SET d.definition row.definition; LOAD CSV WITH HEADERS FROM file:///symptom.csv AS row MERGE (s:Symptom {name: row.name});第一为什么用MERGE而不用CREATE。肝病数据里同一个疾病可能出现在多行如果直接CREATE会生成重复节点后续查询返回一堆重复结果。MERGE的本质是先查后建图中没有同名节点才创建有就把属性补上。第二LOAD CSV读进来的所有值都是字符串如果后面要做数值比较比如“ALT 大于 40”必须在 Cypher 里用toFloat(row.normal_high)或toInteger()先转类型否则比较结果全错。关系导入同样用LOAD CSV但要注意关系的方向。比如“肝硬化-不宜吃-动物肝脏”这条边CSV 里应该有两列分别是源实体和目标实体导入时写成LOAD CSV WITH HEADERS FROM file:///disease_food.csv AS row MATCH (d:Disease {name: row.disease}) MATCH (f:Food {name: row.food}) MERGE (d)-[:NOT_SUITABLE]-(f);两条MATCH先把两端的节点查出来再建立关系。这里如果节点不存在MATCH会失败并跳过这一行所以导入完成后一定要做数量校验能对上的边数往往比 CSV 行数少缺的就是节点名对不上的脏数据。3.3 从一个节点出发查多条关系三条 Cypher 技巧热搜里“neo4j 查询从一个节点出发如何查询多条”是很典型的需求肝病问答里正好用上。用户问“肝硬化患者需要注意什么”如果只写一条MATCH返回单一关系类型那答案会非常窄。正确做法是同时查多个关系类型让结果汇集到一张表里。MATCH (d:Disease {name: 肝硬化})-[r]-(n) RETURN type(r) AS relation, n.name AS target UNION MATCH (d:Disease {name: 肝硬化})-[r]-(n) RETURN type(r) AS relation, n.name AS target;这是最朴素的双向查询把出边和入边都拿回来。但实际问答系统里我更推荐用OPTIONAL MATCH它能把多个关系类型横向展开MATCH (d:Disease {name: $name}) OPTIONAL MATCH (d)-[:HAS_COMPLICATION]-(c:Disease) OPTIONAL MATCH (d)-[:NOT_SUITABLE]-(f:Food) OPTIONAL MATCH (d)-[:USING]-(m:Medicine) RETURN collect(c.name) AS complications, collect(f.name) AS forbidden_foods, collect(m.name) AS medicines;注意OPTIONAL MATCH的意义它不是“可选”而是“找不到也返回空列表”避免整条查询因为某类邻居不存在而整体返回空。在同一个MATCH分支后面挂多个OPTIONAL MATCH用collect()把多条邻居聚合成列表结果就是一行三列非常方便拼接答案。如果查询链路超过两层比如“肝硬化患者的并发症会引起哪些症状”需要限定路径深度写成(d)-[:HAS_COMPLICATION*1..2]-(n)路径深度不加限制会在数据量大时把性能拖垮后面避坑章还会专门讲。3.4 验证图谱数据量别等 bug 找上门数据导入完不要急着接问答引擎先跑三条统计语句确认数据完整性MATCH (n) RETURN count(n) AS total_nodes; MATCH ()-[r]-() RETURN count(r) AS total_relations; MATCH (d:Disease) RETURN d.name, size((d)--()) AS degree ORDER BY degree DESC LIMIT 10;第一条看节点总数是不是和 CSV 去重后的行数一致第二条看关系总数第三条很有价值它能排查“孤立节点”因为度数为 0 的疾病节点不会出现在任何查询结果里但用户一旦问到它系统就永远答不上来。我习惯把孤立节点导出后单独补数据这是让问答系统“看起来智能”的最快手段。4. 规则匹配问答引擎把问句拆成意图、实体和条件4.1 问答的本质意图模板加实体槽位有人看到“规则匹配”四个字就觉得技术含量低但真正做过垂直领域问答的人会明白规则匹配最大的优势是可控。用户问“肝硬化可以吃鸡蛋吗”规则引擎能够明确告诉你实体是“肝硬化”另一个实体是“鸡蛋”意图是“饮食适宜性”然后去图谱里查“肝硬化-鸡蛋”这条边存不存在。整个过程每一步都可解释答案错误时能立即定位是图谱数据问题还是规则模板缺失。大模型做不到这一点。所以问答引擎的设计核心不是算法而是把问句拆解成三层意图用户想了解什么、实体问题涉及哪个疾病/指标/食物、条件有没有“能/不能”“正常/偏高”这类限定词。意图决定查什么类型的边实体决定从哪里出发条件决定答案模板。4.2 问句预处理分词、归一化和槽位抽取用户输入不会规规矩矩先做清洗去掉全角空格、统一小写、去停用词再用 jieba 分词。关键一步是加载自定义词典否则“肝硬化患者”会被切分成“肝/硬化/患者”实体识别直接失败。词典文件放一行一个词肝硬化、乙肝携带者、谷丙转氨酶、恩替卡韦……import jieba import re jieba.load_userdict(liver_dict.txt) def extract_entities(question: str) - dict: question question.replace(, ,).replace(, ?).lower() segments jieba.lcut(question) entities [] for word in segments: std normalize_entity(word) if std in disease_set or std in food_set or std in indicator_set: entities.append(std) return {question: question, segments: segments, entities: entities}这段代码把分词结果逐个过归一化函数再和实体集合比对。注意normalize_entity和第 2 章数据清洗用同一个函数这样才能保证“谷丙转氨酶”进库时变成了“ALT”查询时也变成“ALT”。实体抽取完成后还要做槽位分类哪些词是疾病名哪些是食物名哪些是指标名。我一般维护三个集合disease_set、food_set、indicator_set从 CSV 数据里直接构建避免手写词表漏词。4.3 规则模板引擎从问句模板到 Cypher 模板规则匹配的核心是一张模板映射表每一条包含“问句正则”和“Cypher 模板”。模板不要设计太细否则维护成本爆炸按三类意图覆盖常见问法即可。意图类型问句正则示例Cypher 模板定义查询什么是(defect)?(entity)MATCH (d:Disease {name:$entity}) RETURN d.definition列表查询(entity)的(complicationsymptom判断查询(entity)能不能吃(food)MATCH (d {name:$entity})-[r]-(f {name:$food}) RETURN type(r)实现时用正则提取槽位再把槽位值拼进 Cypher。注意一定要用参数化查询不能把槽位值直接拼进查询字符串这是防止 Cypher 注入的基本要求也能避免用户输入里的引号把语句弄坏。RULES [ { intent: definition, pattern: r什么是(?Pentity.?)[?]?$, cypher: MATCH (d {name:$entity}) RETURN d.definition AS answer, }, { intent: list, pattern: r(?Pentity.?)的(?Prelation并发症|症状|饮食禁忌)有哪些, cypher: ( MATCH (d {name:$entity})-[r]-(n) WHERE type(r) $relation RETURN collect(n.name) AS answer ), }, ] def build_query(intent: str, slots: dict) - tuple: rule next(r for r in RULES if r[intent] intent) return rule[cypher], slots正则和 Cypher 模板分离的好处是规则数量增长到几十条时仍然可以维护。每个模板只负责一种问法命中后槽位自然填好查询结果也统一以answer字段返回。4.4 答案组装列表怎么变成人能读的话图谱查询返回的是列表但用户要的是句子。比如查“肝硬化的并发症”返回[腹水, 上消化道出血, 肝性脑病]直接输出 JSON 数组显然不合格。组装逻辑很简单列表答案用顿号连接单值答案直接套模板。def render_answer(intent: str, result: dict) - str: if result.get(answer) is None: return 这个问题我暂时无法回答建议咨询临床医生。 if isinstance(result[answer], list): return 、.join(result[answer]) return str(result[answer])这一步虽然不起眼却是用户体验的分水岭。很多问答项目“答非所问”的观感并不是图谱查错了而是没有把 Cypher 返回的结构化数据翻译成自然语言。5. 肝病问答系统常见问题与避坑5 个真实现场5.1 中文乱码导入数据变成问号现象CSV 里中文正常LOAD CSV 导入后 neo4j 浏览器里全是乱码和问号。原因Excel 导出的 CSV 默认可能是 ANSI 编码neo4j 按 UTF-8 读取时全部错位。另外文件开头如果有 BOM 头neo4j 也可能把第一列字段名解析出问题。解决用 Python 统一转码再放入 import 目录。with open(disease_gbk.csv, rb) as f: data f.read() with open(disease_utf8.csv, w, encodingutf-8) as f: f.write(data.decode(gbk))如果文件是 UTF-8 带 BOM直接另存为 UTF-8 无 BOM。经验是所有 CSV 入库前先跑一次file命令看编码不确认就不要导入。5.2 改了内存配置却不生效现象在neo4j.conf里把 heap 改成 8G重启后SHOW SETTINGS依然显示 4G项目数据一多就查不动。原因neo4j 有多个配置文件改错文件或者改了没保存成功。还有一个隐蔽坑有些版本里server.memory.heap.max_size需要和initial_size一起配置只改 max 会被默认初始值覆盖。解决在neo4j.conf里同时写这两个参数然后重启服务。重启后直接执行SHOW SETTINGS验证SHOW SETTINGS YIELD name, value WHERE name CONTAINS memory RETURN name, value;5.3 浏览器访问不到 neo4j只能本机连外网不通现象neo4j 在服务器上启动成功本机能打开 7474 页面换一台机器就访问不了。原因默认配置只监听 localhost不监听外部 IP 地址。解决修改conf/neo4j.conf里的server.default_listen_address0.0.0.0重启服务。这里注意如果是在云服务器上还需要确认安全组放行 7474 和 7687 两个端口。7687 是 Bolt 协议端口Python 代码连接 neo4j 时走的是这个口只开 7474 会一直报“连接被拒绝”。5.4 规则匹配漏答把“患者”切出了病灶现象用户问“肝硬化患者能不能吃月饼”引擎直接答不上来但图谱里明明有“肝硬化-不宜吃-月饼”这条边。调试后发现 jieba 把“肝硬化患者”切成了“肝硬化/患者/能不能/吃/月饼”实体只抽到了“患者”。原因jieba 默认词典不含医学实体切词结果把复合词拆碎了叠加“正常范围”这类条件词模板匹配失效。解决一是加载自定义词典把“肝硬化患者”“乙肝携带者”“肝功能不全”这类词组加入liver_dict.txt二是在实体抽取后加一步“实体对齐”如果分词结果里没有抽到疾病实体就用最长匹配在原始问句里再扫一遍实体集合。两道防线下来漏答率会显著降低。5.5 路径查询把性能拖垮现象查询“乙肝的最后发展结果是什么”写了一条不限深度的路径查询(d)-[:COMPLICATION*]-(n)一开始数据量小跑得很快数据涨到几千条后单次查询耗时超过 5 秒系统整体卡死。原因没限制路径深度Cypher 在图上做了全深度遍历。肝病进展链路“肝炎 → 肝硬化 → 肝癌”最多三步就到头了不限深度等于放任引擎遍历整个图。解决所有路径查询必须明确深度上界。根据业务语义限定*1..3并为疾病节点建立索引CREATE INDEX disease_name IF NOT EXISTS FOR (d:Disease) ON (d.name);索引对实体点查的提升非常明显尤其是图谱关系超过一万条后没有索引的MATCH (d {name:$name})会退化成全表扫描。6. 从直接运行到稳定可用批量写入、端到端验证与可靠性项目能跑通只是第一步。把它从“demo 能答几句话”变成“可以交付的系统”我建议至少补三块批量写入效率、端到端回归测试、冷启动脚本。当 CSV 有几万行时LOAD CSV 逐行执行会很慢更好的方式是 Python 驱动里用UNWIND批量提交from neo4j import GraphDatabase driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, password)) def batch_create(tx, rows): tx.run( UNWIND $rows AS row MERGE (d:Disease {name: row.name}) ON CREATE SET d.definition row.definition , rowsrows, ) with driver.session() as session: for i in range(0, len(rows), 500): session.write_transaction(batch_create, rows[i:i500])一次提交 500 条比逐条CREATE快一个数量级而且事务中途失败可以整体回滚。端到端回归测试更重要。把问答用例做成表格比如“肝硬化能不能吃鸡蛋 → 疾病-食物关系 → 期望答案类型”每次改完代码跑一遍统计命中率。我现在的习惯是任何规则模板改动后都先跑一遍这几十条用例不允许“改一处坏三处”的情况发生。这个测试脚本不复杂但它是问答系统敢上线的前提。最后给一条血泪经验这类“完整代码数据”的项目最容易出问题的不是代码本身而是环境。拿到项目先把 neo4j 版本、JDK 版本、Python 依赖版本记录下来固定成一份环境清单比追 README 里过时的启动命令靠谱得多。希望这些方案能帮你少踩几个坑直接把精力花在把问答系统做好上也希望帮到你。本文还有配套的精品资源点击获取