基于Neo4j知识图谱与规则匹配的肝病问答系统构建实战 简介面向知识图谱、自然语言处理及医疗信息化方向的开发者这份资源提供了基于Neo4j和规则匹配的肝病问答系统完整代码与数据集可直接运行省去环境搭建与数据清洗的繁琐工作。项目以垂直医疗网站为数据源通过爬虫脚本采集结构化信息构建了包含7类4.4万实体、11类约30万关系的医药知识图谱覆盖8000多种疾病其中与肝病相关的多达200余种并利用Neo4j图数据库存储与Cypher查询实现规则式问答。压缩包内共28个文件主要包含9个Python脚本用于数据爬取、实体构建、问题分类与答案检索、8个TXT词典与规则文件、5个XML配置、3个JSON数据及说明文档总大小15.77MB目录层级简洁、模块划分清晰便于对照学习。目前已有1047人学习下载适合希望快速上手知识图谱构建、Neo4j图数据库应用以及规则匹配问答系统开发的中高级学习者也可作为相关课程设计与毕业设计的参考蓝本。1. 肝病问答系统为什么选“知识图谱规则匹配”而不是直接上大模型问答基于 Neo4j 知识图谱和规则匹配的肝病问答系统乍一看像课程设计里的 Demo但真正接触过医疗信息化预研后你会发现它恰好是当下最能落地的形态。很多团队一开始都想直接上大模型问答最后被可解释性、离线部署和标注成本劝退——医生或患者问的不是“漂亮话”而是“这个答案是从哪条路径查出来的”。这个系统解决的是让用户用自然语言问“肝硬化吃什么药”“乙肝要做哪些检查”系统从预先构建的肝病知识图谱中把关联实体查出来再用规则模板拼成可读回答。代码和数据可以放在同一个工程目录里直接跑通不需要训练模型也不需要 GPU一台普通笔记本就能运行。适合谁想入门知识图谱问答的中级工程师、做医学信息课程设计的学生以及想在项目里用一句话交互替代菜单点选的小团队。下面的章节会按“图谱怎么建 → 问句怎么解析 → 接口怎么封装 → 踩了哪些坑 → 怎么验证”的顺序展开中间会给完整可复现的代码片段。2. 从肝病医疗数据到 Neo4j 知识图谱实体、关系与 Cypher 导入首先要决定图谱里放什么。肝病问答系统不是把病历文本全量塞进图数据库而是抽出医生问诊中最高频的实体。常见做法是维护五类核心节点疾病、症状、检查、药物、食物如果涉及并发症就把并发症建模成疾病与疾病之间的关系而不是单独一类节点。这样后面写规则匹配时查询方向会非常统一。2.1 实体与关系设计肝病领域的图模型长什么样我一般会把实体属性控制在“够用就好”。疾病节点需要 name、category、icd_code症状节点需要 name 和描述检查节点需要 name 和参考范围药物节点需要 name 和适应症食物节点需要 name 和 attribute宜吃或忌吃。不需要把医学知识库的全部字段搬进来否则导入数据时会先被字段清洗累死。关系集建议收敛成六个方向全部沿“疾病 → 关联内容”方向关系类型起点终点对应问句HAS_SYMPTOM疾病症状“肝硬化有哪些症状”NEED_CHECK疾病检查“乙肝需要查什么”USE_DRUG疾病药物“脂肪肝吃什么药”GOOD_FOR疾病食物“乙肝宜吃什么”AVOID_FOOD疾病食物“肝硬化忌口什么”HAS_COMPLICATION疾病疾病“肝硬化会并发什么病”这个方向设计很关键。如果并发症关系弄反写成(腹水)-[:COMPLICATION_OF]-(肝硬化)那么回答“肝硬化有哪些并发症”时就要写反向查询-[:COMPLICATION_OF]-每个意图的方向都不一样后面的 Cypher 模板会越写越乱。统一成“疾病 → 关系 → 目标”后所有模板都是同一种形态MATCH (d:疾病 {name: $disease})-[:关系类型]-(target:目标类型) RETURN target.name AS answer这样设计还有一个好处以后要加新意图只需要加一组关系和一句模板不需要改查询框架。2.2 用 Cypher 把结构化数据灌进 Neo4j三条常用语句很多人一上来就用CREATE逐条建节点几百条数据没问题几千条就失去耐心。正确姿势是把数据整理成 CSV用LOAD CSV批量导入配合MERGE做幂等写入。首先是建约束和索引我习惯把约束写在导入脚本最前面CREATE CONSTRAINT FOR (d:疾病) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT FOR (s:症状) REQUIRE s.name IS UNIQUE; CREATE CONSTRAINT FOR (m:药物) REQUIRE m.name IS UNIQUE;逻辑说明REQUIRE ... IS UNIQUE是 Neo4j 5.x 的约束语法等价于老版本的CREATE CONSTRAINT ON ... ASSERT ... IS UNIQUE。约束存在时重复MERGE不会产生重复节点。这里给每个带name的实体类型建唯一约束也能让后续按名字查询时直接走索引。接着导入疾病节点LOAD CSV WITH HEADERS FROM file:///disease.csv AS row MERGE (d:疾病 {name: trim(row.name)}) SET d.icd_code row.icd_code, d.category row.category;参数说明file:///disease.csv是相对于 Neo4j 安装目录下import文件夹的路径row.name对应 CSV 的表头。trim函数用来去首尾空格中文数据里经常有肉眼看不见的空格不去掉会把“乙型肝炎”和“乙型肝炎 ”当成两个节点。CSV 必须存成 UTF-8 编码否则会出现乱码节点。导入关系时先MATCH两端点再MERGE关系LOAD CSV WITH HEADERS FROM file:///relations.csv AS row MATCH (a:疾病 {name: row.source}) MATCH (b:药物 {name: row.target}) MERGE (a)-[:USE_DRUG {evidence: row.evidence}]-(b);这段代码的逻辑是先用MATCH定位两个实体节点然后MERGE关系。row.source是关系起点名称row.target是终点名称evidence属性用来记录答案来源比如“XX指南第X章”。如果某个MATCH找不到对应节点整条MERGE不会执行所以必须先完成实体导入再导关系。关系表在整理时建议直接用权威名不要在这里做同义词映射把映射放到查询层。2.3 索引与约束没有索引的图谱查询等于全库扫约束已经给name建了唯一索引但问答模板里还会用到category这类属性过滤比如“肝硬化在哪个科室看”。如果科室是疾病节点的属性就需要额外建索引CREATE INDEX FOR (d:疾病) ON (d.category);建完索引后可以用PROFILE验证是不是真的走了索引PROFILE MATCH (d:疾病 {name: 肝硬化})-[:USE_DRUG]-(m:药物) RETURN m.name;逻辑说明如果执行计划里出现NodeIndexSeek而不是NodeByLabelScan说明索引生效。NodeByLabelScan等于把该类型所有节点扫一遍数据量到十万以上时每次问答都会有明显卡顿。这里的关键是索引不要贪多只给查询模板里频繁出现的过滤条件建属性索引在写入时会增加开销小数据量下反而没必要。到这一步图谱已经能查了。但用户不会写 Cypher所以下一步要把自然语言问句转换成查询计划。3. 规则匹配的问法解析从自然语言问句到查询计划问答系统里最复杂的不是 Cypher而是把口语变成意图和槽位。肝病场景下用户会问“乙肝吃什么药”“慢性乙型肝炎的治疗药物有哪些”“得了肝硬化要注意什么饮食”句式变化很大。如果完全依赖训练好的命名实体识别模型标注成本会很高而规则匹配恰好适合领域窄、句式相对固定、对可解释性要求高的场景。3.1 意图识别与槽位抽取不用 NER 也能做到的方案先把意图收敛成六类drug_query、symptom_query、check_query、food_query、complication_query、general_query。意图识别我习惯用“正则 意图表”而不是机器学习因为每一条规则都能被审计、被测试。下面的 Python 代码是一组核心意图模式import re intent_patterns [ (r(吃|用|服用|用些什么|治疗).*(药|药物|药品), drug_query), (r(症状|表现|出现什么感觉), symptom_query), (r(检查|化验|检测项目|需要查什么), check_query), (r(饮食|什么能吃|不能吃|注意|忌口), food_query), (r(并发|引起|导致|诱使).*(什么病|并发症|疾病), complication_query), (r(是什么|什么是|啥是|定义), general_query), ] def match_intent(question): for pattern, intent in intent_patterns: if re.search(pattern, question): return intent return None逻辑说明按列表顺序从上到下匹配命中即返回。顺序很重要“吃什么药”虽然也含“什么”但drug_query排在比较靠前所以先命中。如果你的词表更细建议按“更长更具体模式优先”排序。参数说明.*默认是贪婪匹配问句里出现多个“什么”时容易匹配到后半句可以把.*改成.*?变成懒匹配但在这个场景下影响不大。槽位抽取是从问句里抠出疾病名。最稳妥的方法是维护一张肝病名词表做最长词优先匹配disease_table [慢性乙型肝炎, 乙型肝炎, 乙肝, 肝硬化, 脂肪肝, 酒精性肝病, 药物性肝损伤, 原发性肝癌] def extract_disease(sentence): for name in disease_table: if name in sentence: return name return None注意这里最长词优先顺序不能把“乙肝”放在“乙型肝炎”前面否则“乙型肝炎患者能喝牛奶吗”会先抽出“乙肝”把病名抽错。这个低级错误会导致后续所有模板查不到节点。3.2 从规则模板到 Cypher 模板覆盖高频问句拿到意图和疾病名后下一步是查表生成 Cypher。这里可以用 Python 字典维护模板cypher_templates { drug_query: MATCH (d:疾病 {name: $disease})-[:USE_DRUG]-(m:药物) RETURN m.name AS answer, symptom_query: MATCH (d:疾病 {name: $disease})-[:HAS_SYMPTOM]-(s:症状) RETURN s.name AS answer, check_query: MATCH (d:疾病 {name: $disease})-[:NEED_CHECK]-(c:检查) RETURN c.name AS answer, food_query: MATCH (d:疾病 {name: $disease})-[:GOOD_FOR]-(f:食物) RETURN f.name AS answer, 宜吃 AS kind UNION MATCH (d:疾病 {name: $disease})-[:AVOID_FOOD]-(f:食物) RETURN f.name AS answer, 忌吃 AS kind , complication_query: MATCH (d:疾病 {name: $disease})-[:HAS_COMPLICATION]-(c:疾病) RETURN c.name AS answer, }逻辑说明这里的$disease是参数占位符执行查询时通过 Neo4j 驱动传参而不是用字符串拼接这样既能避免特殊字符问题也能让 Cypher 执行计划复用。为什么不用.format把疾病名拼进查询串因为就医问句里可能出现引号或括号一旦拼到 Cypher 里就会语法错误甚至注入风险。常见做法是让模板统一采用参数化到了第 4 章的接口代码里你会看到session.run(cypher, diseasedisease)。还需要处理未命中意图或疾病名的情况。兜底思路是对节点做包含匹配MATCH (n) WHERE toLower(coalesce(n.name, )) CONTAINS toLower($keyword) RETURN labels(n)[0] AS type, n.name AS name LIMIT 5这段查询可以在规则没覆盖时帮用户找到包含关键词的实体。但它只是兜底不能替代规则返回的labels(n)[0]在多标签节点上不一定可靠但在常见单标签模型里够用。3.3 同义词与换序容错让规则不那么脆肝病领域同义词非常多“乙肝”和“乙型肝炎”、“脂肪肝”和“脂肪性肝病”、“肝硬化腹水”和“腹水”在不同语境下含义不同。对于问答系统我建议做一个轻量级同义词归一表在意图识别前先把用户问句里的别名替换成权威名synonym_map { 乙肝: 乙型肝炎, 乙肝病毒携带者: 乙型肝炎, 乙型病毒性肝炎: 乙型肝炎, 脂肪肝: 脂肪性肝病, 肝腹水: 肝硬化腹水, } def normalize_synonym(sentence): for alias, standard in synonym_map.items(): sentence sentence.replace(alias, standard) return sentence这段代码简单直接但要注意顺序长别名要先替换比如“乙型病毒性肝炎”要在“乙肝”之前否则“乙肝”先替换会把“乙型病毒性肝炎”拆坏。同义词表放在单独文件里维护后续新增别名不需要改代码。另一个问题是语序“肝硬化吃什么药”和“吃什么药能治肝硬化”意思相同但规则模板如果要求病名出现在“药”前面第二种问法就会匹配失败。我常用一个占位技巧先把疾病名抽出来替换成D再对占位后的句子做意图匹配def normalize_order(question): disease extract_disease(question) if disease: return question.replace(disease, D ), disease return question, None normalized, disease normalize_order(吃什么药能治肝硬化) # normalized 吃什么药能治 D 这样drug_query的正则依然能命中而疾病名从原句单独取出既不影响意图识别也不丢失槽位。这个十几行的技巧能覆盖相当一部分倒装问法。到这里问句解析已经可以串起来工作下一步要把它封装成 HTTP 服务。4. 工程化问答服务用 FastAPI 把 Neo4j 查询变成能直接调用的接口规则解析和图谱查询都写好后需要提供一个对外接口。常见做法是用 FastAPI 封装因为异步支持好、文档自动生成、依赖少。驱动选择上我优先使用官方 neo4j Python Driver而不是 py2neo。新版 py2neo 对 Neo4j 5.x 的支持不够及时官方驱动在连接池和参数化查询上更稳定。4.1 FastAPI 接口封装最小可运行代码先安装依赖pip install fastapi uvicorn neo4j。然后写一个最小接口文件from fastapi import FastAPI, Query from neo4j import GraphDatabase app FastAPI() driver GraphDatabase.driver( bolt://localhost:7687, auth(neo4j, your_password), connection_timeout15 ) def query_graph(cypher, **params): with driver.session() as session: result session.run(cypher, **params) return [record[answer] for record in result] app.get(/qa) def qa(q: str Query(..., description用户问句)): question normalize_synonym(q) intent match_intent(normalize_order(question)[0]) disease extract_disease(q) if not disease or intent not in cypher_templates: return {answer: 我还没学会回答这个问题你可以试试问肝硬化吃什么药, intent: intent, disease: disease} cypher cypher_templates[intent] answers query_graph(cypher, diseasedisease) if not answers: return {answer: f图谱里暂时没有找到“{disease}”的相关记录, intent: intent, disease: disease} return {answer: format_answer(intent, disease, answers), intent: intent, disease: disease}逻辑说明driver在整个进程生命周期内只创建一次后续请求复用连接池不要在每个请求里重复创建。query_graph把 Cypher 和参数一起传给session.run这里cypher来自模板params里放疾病名。record[answer]要求 Cypher 返回的列名是answer否则取不到值。启动命令uvicorn main:app --host 0.0.0.0 --port 8000这里的main:app指main.py文件里的app对象--host 0.0.0.0允许局域网其它机器访问如果只在本地调试可以改成127.0.0.1。另外一个实践经验是Neo4j 的用户名密码不要硬编码在代码里用环境变量读取import os driver GraphDatabase.driver( os.getenv(NEO4J_URI, bolt://localhost:7687), auth(os.getenv(NEO4J_USER, neo4j), os.getenv(NEO4J_PASSWORD, your_password)) )参数说明connection_timeout15指建立连接的超时时间单位秒。网络抖动时15 秒内没有建立连接就直接报错不会让服务无限等待。4.2 回答生成与兜底策略拼人话而不是裸列表查询结果是药物、症状名称列表直接返回列表不仅生硬还容易让用户误以为系统在推荐治疗方案。我一般会根据意图拼回答模板def format_answer(intent, disease, answers): if intent drug_query: return f{disease}常用的药物有{、.join(answers)}。具体用药请遵医嘱。 if intent symptom_query: return f{disease}可能出现的症状包括{、.join(answers)}。 if intent check_query: return f{disease}常见的检查项目有{、.join(answers)}。 if intent food_query: good [a for a, kind in answers if kind 宜吃] bad [a for a, kind in answers if kind 忌吃] return f{disease}宜吃{、.join(good)}忌吃{、.join(bad)}。 if intent complication_query: return f{disease}可能并发的疾病有{、.join(answers)}。 return 、.join(answers)在医疗问答里回答末尾加“具体用药请遵医嘱”不是套话而是红线。就算只是一个 Demo也不能让输出看起来像诊断建议。回答生成还要配两级兜底图谱没查到是一种意图没识别是另一种。图谱没查到返回“图谱里暂时没有找到……”意图没识别返回引导示例。两级兜底都要在日志里记录下来方便统计哪些问法没有覆盖到。4.3 与前端/调用方约定的响应结构接口响应建议固定成{answer, intent, disease}三个字段前端只渲染answer其余字段用于调试和统计。一个实际响应示例{ answer: 乙型肝炎常用的药物有恩替卡韦、替诺福韦。具体用药请遵医嘱。, intent: drug_query, disease: 乙型肝炎 }把intent和disease返回给前端的好处是页面可以做“猜你想问”或追问后端也可以用同一套日志来做规则覆盖率分析。不要一开始就设计复杂的嵌套结构问答场景返回越平越好。如果调用方是浏览器页面需要在 FastAPI 里加 CORS 中间件。常见做法是from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins[*], allow_methods[GET], allow_headers[*], )这里的allow_origins[*]只适合本地开发生产环境必须换成具体域名。接口封装完成后真正的问题才会显现出来运行时的坑往往比写代码时多。5. 运行与调优中的避坑Neo4j连接、中文分词、模板冲突这一章写的是我在类似系统上实际踩过的坑每一条都用“现象 → 原因 → 解决”的顺序讲清楚。如果你完整跑通了上面的代码再看这些坑会有一种“原来翻车在这”的感觉。5.1 现象Neo4j连接失败或超时现象调用/qa接口时返回 500服务日志里出现ServiceUnavailable或AuthenticationError但 Neo4j 浏览器里明明能登录。原因最常见的是 Bolt 端口和 HTTP 端口搞混。Neo4j 浏览器访问的是7474端口Bolt 驱动访问的是7687端口两者不是一个东西。其次是连接池被占满长事务没关闭导致后续请求拿不到连接。还有可能是密码写错Neo4j 5.x 安装时设定的密码会被浏览器记住但代码里用的还是旧密码。解决先用浏览器打开http://localhost:7474确认 Neo4j 进程正常再从命令行用cypher-shell -u neo4j -p 密码验证认证信息。代码里增加连接参数driver GraphDatabase.driver( os.getenv(NEO4J_URI, bolt://localhost:7687), auth(os.getenv(NEO4J_USER, neo4j), os.getenv(NEO4J_PASSWORD, your_password)), connection_timeout15, max_connection_lifetime3600 )max_connection_lifetime设置连接最大存活时间避免网络设备自动断开长期空闲连接后驱动还在复用已被对端关闭的 socket。这个参数在容器化部署里尤其重要。5.2 现象中文问句里的“吃什么药”匹配不到现象在 Python 脚本里直接测match_intent(肝硬化吃什么药)能返回drug_query但通过 HTTP 接口一问就走到兜底文案。原因这类问题通常是编码和全角半角造成的。Windows 命令行粘贴的问句可能是全角括号或全角空格另外 FastAPI 收到的字符串虽然统一是 Unicode但如果调用方用 GBK 编码发请求问句到后端可能已经是乱码。还有一种情况是病名词表里没有“肝硬化”的别名导致extract_disease返回空。解决在解析入口先做 Unicode 标准化和全角转半角import unicodedata def normalize_question(q): return unicodedata.normalize(NFKC, q).strip()NFKC会把全角英数、全角标点转成半角再把“”这类符号整理干净。正则匹配时加上re.IGNORECASE也能避免英文大小写问题。病名词表里则要维护一个尽量完整的别名清单并在第 3.3 节里做同义词归一。5.3 现象规则模板之间互相冲突现象用户问“肝硬化会导致什么病”系统返回“肝硬化是什么”的定义而不是并发症列表。原因意图正则里general_query的(是什么|什么是)模式放在了complication_query的(导致).*(什么病)前面而“会导致什么病”中恰好包含“什么”于是general_query先命中。这是规则匹配系统的通病正则模式之间没有互斥。解决调整意图表的顺序把更长、更具体的模式放在前面。更靠谱的做法是给每个意图正则增加“正向关键词 负向排除”intent_patterns [ (r(吃什么药|用药|服用|治疗药物), drug_query), (r(并发|引起|导致|诱发).*(什么病|并发症), complication_query), (r(是什么|什么是|啥是|定义), general_query), ]这里把complication_query提到general_query之前并给drug_query增加更具体的短语减少歧义。如果模板继续增多可以改成“先算每个意图的命中得分取最高分”的方案而不是简单顺序匹配。5.4 现象查询同义词节点时返回空结果现象图谱里节点名是“乙型肝炎”用户问“乙肝吃什么药”接口返回“图谱里暂时没有找到……”。但直接在图谱浏览器里查“乙型肝炎”有数据。原因图谱节点只维护了权威名而用户问的是“乙肝”这个别名模板生成的 Cypher 使用{name: 乙肝}去匹配自然匹配不到。这说明同义词归一只在问句解析层做了查询层没有兼容别名。解决除了做同义词归一建议给实体节点增加alias属性在导入时把别名写进去MERGE (d:疾病 {name: 乙型肝炎}) SET d.alias [乙肝, 乙型病毒性肝炎]查询时用WHERE d.name $disease OR $disease IN d.aliasMATCH (d:疾病) WHERE $disease IN coalesce(d.alias, [d.name]) MATCH (d)-[:USE_DRUG]-(m:药物) RETURN m.name AS answer这里的coalesce防止没有alias属性的节点报错。注意这个写法在d.name和d.alias同时存在时走name索引的效率会下降因为IN查询不一定能用上索引如果问题比较严重可以把别名也建成独立节点并加ALIAS_OF关系但这属于大实体的调整小数据量下用coalesce就够。6. 验证回答质量的三个技巧从接口日志到图谱路径回看系统能跑通不意味着回答正确。我每次搭建完都会用三个技巧做验证能快速找到规则盲区和图谱数据缺失。第一个技巧是准备一张标准问题集至少覆盖六个意图每个意图写三条不同说法共十八条以上。把这些问题批量调用接口统计意图识别正确率和图谱返回空结果的比例。这比随机点几个问题靠谱得多。标准问题集里的每一条都记录预期意图接口返回的intent字段会和预期比对错一条就立刻能定位是正则的问题还是模板的问题。第二个技巧是查看接口返回的 Cypher。在开发阶段保留响应里的cypher字段然后拿到 Neo4j Browser 里手动执行。如果回答错了先看 Cypher 有没有查错关系方向如果 Cypher 正确但结果不对再去查图数据。这一步能快速区分“规则写错”和“数据缺失”。我经常在浏览器里用一条链式查询核对回答路径例如回答是“肝硬化常用的药物有A、B”就执行MATCH (d:疾病 {name: 肝硬化})-[:USE_DRUG]-(m:药物) RETURN d.name, m.name看返回的路径是否真实存在于图谱中有没有把关系方向搞反。第三个技巧是看日志里的兜底触发次数。把“我还没学会回答这个问题”和“图谱里暂时没有找到”统计出来排在前面的问法就是下一轮要补充的规则。这个习惯帮我避免了很多“看着能答实际一问就哑火”的问题。最后说一个教训规则匹配问答系统本质上不是模型项目而是配置管理项目。同义词表、意图正则、Cypher 模板都要版本化每加一条新问法就把这条问法加进标准问题集里做回归。这样做看似很笨却是让系统长期可维护最快的路。这个方向不需要大算力和大规模标注值得投入希望帮到你。本文还有配套的精品资源点击获取