Neo4j构建肝病知识图谱问答系统:爬虫、图谱构建与规则匹配
简介基于 Neo4j 知识图谱和规则匹配的肝病问答系统完整项目面向医药领域自然语言处理与知识图谱初学者提供一套可复现的问答实现方案。项目以垂直医疗网站“寻医问药”的结构化数据为来源通过爬虫与 XPath 解析构建了包含 4.4 万实体、约 30 万关系的知识图谱覆盖 200 余种肝病相关疾病并采用 Neo4j 图数据库存储以 Cypher 完成规则匹配问答。压缩包共 28 个文件以 9 个 Python 脚本、8 个文本配置、3 个 JSON 数据文件和 5 个 XML 工程文件为主整体大小约 15.77MB涵盖数据采集、实体关系构建、问题分类、答案检索等模块代码与数据集齐全解压即可直接运行。目前已有 1047 人学习下载。借助该资源读者可完整走通从半结构化网页数据到知识图谱构建、再到规则匹配问答的链路并可根据自身需求扩展事件抽取或深度学习方法适合课程设计、毕业设计及知识图谱入门实战。1. 用 Neo4j 肝病问答系统先别急着跑看懂它比跑通更难很多人拿到知识图谱项目的第一反应就是敲neo4j start结果往往卡在数据导入或者问句解析上。这套基于 neo4j 知识图谱和规则匹配的肝病问答系统核心价值不是把数据塞进图数据库而是提供了一套从爬虫、schema 设计、图谱构建到规则问答的完整流水线。它包含 4.4 万实体、30 万关系覆盖 8000 多种疾病其中与肝病相关的有 200 多种数据来自垂直医疗平台的结构化页面。适合正在做知识图谱应用开发、医药领域问答系统、或者毕业设计想快速落地的从业者和学生。你能从里面看到知识图谱落地时最真实的取舍哪些靠规则能解决哪些必须靠模型补位。2. 图谱是怎么建出来的44k 实体、300k 关系的数据流水线2.1 为什么以疾病为核心的 schema 更适合医疗问答医疗知识图谱的 schema 设计没有统一标准常见的有两种以药物为核心、以症状为核心、以疾病为核心。这套项目选择的是「以疾病为中心」的辐射结构原因很直接用户在肝病问答里问得最多的是“这个病是什么”、“有什么症状”、“怎么治”、“要注意什么”这些问题都以疾病为起点。把疾病节点放在中心周围挂上症状、药物、检查、手术、科室、预防、病因等实体查询路径会非常短。schema 不是凭空设计的而是从爬取来的结构化数据反推生成。也就是说页面里展示哪些字段图里就建哪些节点和关系。这在工程上是划算的——避免为不存在的数据预设概念也让图谱和原始数据源保持同步。实体类型共 7 类关系类型共 11 类这个数字不大但足够覆盖常见 QA 场景。实体规模 4.4 万、关系规模 30 万对于 Neo4j 来说是小体量单机内存就能跑不需要分布式部署。2.2 爬虫脚本 data_spider.py从页面到 JSON 的 xpath 提取数据来源是垂直类医疗网站上的结构化页面常见做法是先用requests拿到 HTML再用lxml的 XPath 抽取字段。核心代码结构通常是这样import requests from lxml import etree import json HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9 } def parse_disease_page(url): resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 tree etree.HTML(resp.text) item {} # 疾病名称通常位于 h1 或特定 class 内 item[name] tree.xpath(//h1/text())[0].strip() # 症状列表常见于 ul/li 或 p 标签中 item[symptom] [x.strip() for x in tree.xpath( //div[contains(class,symptom)]//li/text())] # 风险因素/病因 item[cause] [x.strip() for x in tree.xpath( //div[contains(class,cause)]//p/text())] # 预防措施 item[prevent] [x.strip() for x in tree.xpath( //div[contains(class,prevent)]//p/text())] return item这段代码有两个关键点一是resp.encoding utf-8很多医疗网站页面实际是 gbk 编码但现代站点多数是 utf-8如果发现中文乱码就改成实际编码二是 XPath 后面的[0]要求至少有一个匹配所以爬虫在跑之前要先手动打开几个页面确认选择器。项目的data_spider.py里还对请求异常、字段缺失做了过滤避免半截数据写进 JSON。爬下来的数据会存成三个文件medical.json存放全部疾病数据hepatopathy.json存放肝病相关的子集test.json存放问答测试问句和预期答案。2.3 构建图脚本 build_medicalgraph.py批量写入 Neo4j 的写法数据清洗完成后下一步是把 JSON 写进 Neo4j。项目用 py2neo 操作图数据库这里最容易踩的坑是一条条graph.create()速度极慢。正确姿势是用事务批量提交每批次 5001000 个节点from py2neo import Graph, Node, Relationship, Transaction import json graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) def create_nodes(tx, label, items): batch [] for item in items: node Node(label, **item) batch.append(node) tx.create(batch) tx graph.begin() # 读取肝病相关数据 with open(hepatopathy.json, encodingutf-8) as f: diseases json.load(f) # 创建疾病节点每个节点只保留 name 和 desc 字段 for d in diseases: tx.create(Node(Disease, named[name], descd.get(desc, ))) # 每 500 个节点提交一次避免大事务内存溢出 graph.commit(tx)参数说明begin()开启事务commit()提交Node(label, **item)会把 item 里的键值对变成节点属性。实际项目里构建脚本不是这样简单两层循环而是先建实体节点、再根据关系 tuple 建立Relationship。项目中build_medicalgraph.py里常见的写法是维护一个(head_id, relation_type, tail_id)三元组列表然后一次事务里同时创建关系rels [] # rels.append((乙肝, belongs_to, 肝病)) tx graph.begin() for head, rel_type, tail in rels: h graph.nodes.match(Disease, namehead).first() t graph.nodes.match(Disease, nametail).first() tx.create(Relationship(h, rel_type, t)) graph.commit(tx)这种写法对 30 万关系来说会慢一些因为每次match都要查索引。更好的做法是先建立唯一性约束再在 Python 里把节点映射成name - node_id的字典避免反复查库。项目里建约束的 Cypher 是CREATE CONSTRAINT ON (d:Disease) ASSERT d.name IS UNIQUE有了唯一约束后用graph.run(MERGE (d:Disease {name:$name}), name...)可以防止重复实体。构建图的时候建议先建约束再导数据否则重启脚本后会出现大量重复节点。3. 规则问答引擎意图分类、词表匹配和 Cypher 查询模板3.1 问句分类正则字典怎么定义 7 类意图问答系统没有用深度学习模型而是规则匹配。原因是垂直领域问句相对固定规则能覆盖 80% 以上的场景且响应速度快、易调试。项目里的question_classifier.py维护了一个意图词典把常见问句映射到具体意图。比如# 意图 - 关键词列表 intent_dict { disease_symptom: [症状, 表现, 怎么知道, 有哪些表现], disease_cause: [原因, 诱因, 为什么得, 病因], disease_prevent: [预防, 怎么防, 如何避免], disease_treat: [治疗, 怎么治, 用什么药, 疗法], disease_cure: [能治好吗, 治愈率, 能否痊愈], disease_check: [检查, 查什么, 怎么确诊], disease_department: [挂什么科, 哪个科室, 去什么科], }分类时采用“最长匹配优先”或“命中累计得分”两种策略。项目里常见的是先遍历所有关键词统计每个意图命中的次数取最高分。这里有个隐蔽坑如果问句里同时出现“症状”和“原因”比如“乙肝症状和病因是什么”规则会把两个意图都打上高分实际返回结果就会只取第一个另一个意图被忽略。规避办法是在问句解析前先做分句或者允许一个问句携带多个意图标签。3.2 查询解析把问句映射成 Cypher 和参数意图识别出来还不够还得从问句里抽实体。question_parser.py做的事情就是两层先抽疾病名再根据意图生成 Cypher 模板。抽取疾病名最常见的手段是词典匹配项目自带dict/disease.txt和dict/symptom.txt等词表。import re disease_dict [d.strip() for d in open(dict/disease.txt, encodingutf-8)] def extract_disease(question): for name in disease_dict: if name in question: return name return None def build_query(intent, disease): if intent disease_symptom: return MATCH (d:Disease {name:$name})-[:HAS_SYMPTOM]-(s) RETURN s.name, {name: disease} elif intent disease_cause: return MATCH (d:Disease {name:$name})-[:HAS_CAUSE]-(c) RETURN c.name, {name: disease} # 其他意图类似这段代码的逻辑很直白先用extract_disease从词表里找疾病名再根据意图返回对应的 Cypher 查询语句和参数。项目里实际查询模板比这个复杂因为关系类型不是HAS_SYMPTOM这种驼峰式而是类似belongs_to、common_drug、do_eat这种下划线的命名。建议拿到代码后先打开build_medicalgraph.py看关系类型定义再去理解answer_search.py里的映射关系。3.3 答案搜索用 graph.run 执行 Cypher再组装自然语言答案生成在answer_search.py中完成。它负责执行 Cypher、接收结果、把结果拼成一句话。简单示例如下from py2neo import Graph graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) def search_answer(intent, disease): query, params build_query(intent, disease) data graph.run(query, params).data() if not data: return 未找到相关答案 names [d[s.name] for d in data] if intent disease_symptom: return f{disease}的常见症状有 、.join(names) 。 return 、.join(names)参数说明graph.run(query, params)返回一个 Records 对象调用.data()转成列表字典s.name是 Cypher 返回值的别名。实际项目里answer_search.py不是简单地拼接字符串而是先检查结果长度超过 5 个就用“等”省略避免答案过长。另外一个细节是如果查询结果是空列表往往不是没数据而是关系方向写反了。比如MATCH (d:Disease)-[:belongs_to]-(category)和(d)-[:belongs_to]-(c)看起来一样但belongs_to关系的方向在构建时可能存反了所以排查答案为空时先用 Neo4j Browser 手动跑一遍 Cypher确认关系方向。4. 常见问题与避坑Neo4j 导入、中文编码、查询超时4.1 实体建多了内存溢出可能是没用批量提交现象运行build_medicalgraph.py时输出几万个节点后程序报OutOfMemoryErrorNeo4j 服务也跟着卡死。原因脚本在循环里反复graph.create()单条写入每次都会开启隐式事务提交频率过高加上图数据本身有大量索引更新堆内存被快速占满。解决改成显式事务每 200500 条create提交一次。另外把 Neo4j 的dbms.memory.heap.initial_size调大到 1Gdbms.memory.heap.max_size调到 2G。如果数据量不大完全没必要一次导入所有节点可以先只导入肝病子集跑通流程后再全量导入。4.2 中文乱码JSON 文件编码和 py2neo 默认不一致现象导入 Neo4j 后用 Browser 查询中文正常但通过answer_search.py返回的中文变成乱码或者问句分词后出现\u6b67\u80a0这种转义字符。原因写入 JSON 时json.dump没有指定ensure_asciiFalse导致中文被转成 ASCII 转义序列或者读取文件时没有用encodingutf-8。py2neo 本身是支持 UTF-8 的问题几乎都出在读写两侧的编码设置。解决在提取数据的代码里固定写ensure_asciiFalsejson.dump(items, file, ensure_asciiFalse)。读取时统一open(path, encodingutf-8)。如果已经导入了乱码数据不要慌可以在 Cypher 里重新设置属性MATCH (n) WHERE n.name CONTAINS \\u SET n.name apoc.text.replace(n.name, \\\\u[0-9a-f]{4}, )但更简单的方式是删掉重建。4.3 关系重复导致图爆炸没有用 MERGE 而是 CREATE现象运行完构建脚本后用MATCH (d:Disease {name:乙肝})-[r]-() RETURN count(r)发现同一个症状出现了七八次图谱膨胀到 100 万关系查询变慢。原因构建脚本直接用了CREATE而节点数据源里同一个疾病可能在不同页面重复出现或者多个页面里写了同义症状。解决在创建节点前先建唯一约束关系写入时使用MERGE而不是CREATE。对于关系重复的已有数据可以用 Cypher 清洗MATCH (d:Disease)-[r]-(t) WITH d, t, type(r) AS rel_type, collect(r)[1..] AS duplicates FOREACH (x IN duplicates | DELETE x)这条语句会保留每个实体对之间每类关系的第一个实例删除其余重复关系。注意type(r)在 WITH 分成 rel_type 后后面 FOREACH 里已经无法使用更稳妥的做法是用apoc.refactor.mergeRelations插件但项目没引入所以最实际的办法是改构建脚本。4.4 规则匹配答非所问词表覆盖不足现象用户问“我爸爸有肝硬化平时吃饭要注意什么”系统返回的是肝硬化的定义而不是饮食建议。原因问句里 “注意” 和 “吃饭” 没有进disease_prevent或disease_notice的关键词表而规则匹配是精确词典匹配没有同义扩展和指代消解。解决扩充dict/下的词表把“饮食”“忌口”“吃什么好”“注意什么”补充进预防/饮食类意图。更进一步的方案是引入短文本相似度将问句和已有问题模板做向量匹配但这就超出了规则系统的边界。项目在设计时就承认了这块短板所以后来者可以在chatbot_graph.py里预留一个接口当规则匹配置信度低于阈值时转接给外部模型或人工兜底。5. 最后的技巧把肝病子图隔离出来用 test.json 验证问答准确率拿到系统后先不要急着全量导入 8000 多种病强烈建议只导入肝病相关的 200 多种也就是hepatopathy.json里的数据。这样能最快跑通问答也方便检查哪里出错。隔离子图的 Cypher 很简单用WHERE限定疾病范围MATCH (d:Disease) WHERE d.name IN $hepatopathy_names DETACH DELETE d但更好的办法是在构建时只加载hepatopathy.json这样整个图里只有肝病子图。然后运行项目自带的test.json它包含若干条问答测试样本例如“肝炎有什么症状”“肝硬化的病因是什么”。我会写一个简单脚本对每个问句调用chatbot_graph.py比对输出和预期答案中是否包含关键症状或药物名词统计准确率import json from chatbot_graph import ChatBot chatbot ChatBot() passed 0 total 0 with open(data/test.json, encodingutf-8) as f: tests json.load(f) for t in tests: answer chatbot.answer(t[question]) hit any(k in answer for k in t[keywords]) total 1 passed hit print(fAccuracy: {passed}/{total} {passed/total:.2%})这个循环能帮你快速定位是知识缺失还是规则漏匹配。有一次我发现准确率只有 60%一查原因是test.json里的问句包含“乙肝两对半”这种检查项而词表里只有“乙肝”抽取实体时匹配到了“乙肝”但意图识别把“两对半”当成了普通名词导致查询返回空。从那以后我每次修改词表或图谱都会强制跑一遍这个测试脚本把它当成回归测试用。希望帮到你。本文还有配套的精品资源点击获取