简介:一套基于 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, encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: props = ", ".join(f"{key}: ${key}" for key in fields) stmt = f"CREATE (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", encoding="utf-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 11,5.x 必须用 Java 17,版本不匹配,neo4j 服务根本起不来,日志里只报一句“Unsupported Java version”。
拿到项目先确认两件事:系统里java -version的输出版本,以及 neo4j 解压目录里conf/neo4j.conf是否存在。常见翻车点是:用户以为改了内存配置,实际改错了文件。neo4j 从 4.0 开始统一使用neo4j.conf,网上那些教改neo4j.properties的教程,针对的已经是爷爷辈版本。新版本内存相关参数是:
| 参数 | 作用 | 经验值 |
|---|---|---|
server.memory.heap.initial_size | JVM 堆初始大小 | 4G(数据量小就 1G) |
server.memory.heap.max_size | JVM 堆最大大小 | 4G |
server.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)的(complication | symptom |
| 判断查询 | (entity)能不能吃(food) | MATCH (d {name:$entity})-[r]->(f {name:$food}) RETURN type(r) |
实现时用正则提取槽位,再把槽位值拼进 Cypher。注意一定要用参数化查询,不能把槽位值直接拼进查询字符串,这是防止 Cypher 注入的基本要求,也能避免用户输入里的引号把语句弄坏。
RULES = [ { "intent": "definition", "pattern": r"什么是(?P<entity>.+?)[??]?$", "cypher": "MATCH (d {name:$entity}) RETURN d.definition AS answer", }, { "intent": "list", "pattern": r"(?P<entity>.+?)的(?P<relation>并发症|症状|饮食禁忌)有哪些", "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", encoding="utf-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_address=0.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 """, rows=rows, ) with driver.session() as session: for i in range(0, len(rows), 500): session.write_transaction(batch_create, rows[i:i+500])一次提交 500 条,比逐条CREATE快一个数量级,而且事务中途失败可以整体回滚。
端到端回归测试更重要。把问答用例做成表格,比如“肝硬化能不能吃鸡蛋 → 疾病-食物关系 → 期望答案类型”,每次改完代码跑一遍,统计命中率。我现在的习惯是任何规则模板改动后,都先跑一遍这几十条用例,不允许“改一处坏三处”的情况发生。这个测试脚本不复杂,但它是问答系统敢上线的前提。
最后给一条血泪经验:这类“完整代码+数据”的项目,最容易出问题的不是代码本身,而是环境。拿到项目先把 neo4j 版本、JDK 版本、Python 依赖版本记录下来,固定成一份环境清单,比追 README 里过时的启动命令靠谱得多。希望这些方案能帮你少踩几个坑,直接把精力花在把问答系统做好上,也希望帮到你。
本文还有配套的精品资源,点击获取