简介:基于Neo4j图数据库的医疗知识图谱智能问答机器人项目,面向知识图谱课程大作业及Python问答应用学习者,可用于掌握医疗领域知识建模、图谱查询与自动问答的完整实现链路。压缩包共36个文件,体积15.33MB,以Python源码为主(8个.py),覆盖图谱构建、问句分析、CQL生成、答案检索等核心模块;另有9个txt数据/说明文件、5个pyc缓存、JSON配置、JS/CSS/HTML前端静态资源、PNG/JPG图片及Markdown文档,分别对应数据素材、界面展示和项目说明。项目中包含图谱构建、问句分析、答案检索等模块,清晰展示从医疗数据清洗入库、问句意图分析到生成Cypher查询并返回答案的完整流程。资源已有478人学习,适合需要完成知识图谱大作业、或想用Python操作Neo4j实现智能问答的开发者参考,可从中获得一套完整可改造的工程示例。
1. 医疗知识图谱问答机器人:一份能跑的 Neo4j+Python 落地参考
拿到这个压缩包的第一反应是:它不像课程设计里那种只交报告不交代码的“空气项目”,而是一套完整能跑的医疗知识图谱智能问答工程。里面有建图脚本、问答脚本、模板字典、数据目录,连清库脚本都备好了,典型的知识图谱大作业配置,但链路完整度超出多数课件示例——从问句输入到 Neo4j 返回答案,中间每一步都有一份独立的 Python 文件负责。对正在做知识图谱课程设计、毕业设计,或者想快速理解“知识图谱问答系统到底怎么转起来”的从业者来说,这份资源最大的价值不是答案多准,而是把黑匣子拆成了六个能看懂的脚本。本文按我拆项目的习惯,从架构、建图、问答链路、坑点到改进依次过一遍,重点标出哪些地方改参数就能换场景。
2. 系统架构先拆清楚:六个 Python 脚本的角色与依赖
2.1 文件职责与运行主线
解压后目录里最核心的是六个 Python 文件和两个数据目录,我用表格把职责先列清楚。这一步不是凑字数,而是后面所有排查的基础——这个项目的坑,很多出在“文件职责边界没守住”上。
| 文件 | 职责 | 依赖 |
|---|---|---|
main.py | 程序入口,启动控制台对话循环 | 其余全部模块 |
chat_robot.py | 对话管理,接收用户输入并组织回复 | question_analysis.py、get_answer.py |
question_analysis.py | 问句解析,识别意图和实体,产出结构化槽位 | keyword_template.py、dict目录 |
get_cql.py | 把槽位映射成 Cypher 查询语句返回 | 无,纯字符串组装 |
get_answer.py | 执行 Cypher,连接 Neo4j,格式化答案 | py2neo或neo4j驱动 |
build_medicalgraph.py | 读取data目录数据,构建知识图谱节点与关系 | data目录 |
clear_graph.py | 清空图谱,用于重建或调试 | Neo4j 连接 |
运行主线的理解很简单:python main.py启动后进入chat_robot.py的循环,每一轮用户输入先送进question_analysis.py做意图识别和实体抽取,产出的是一个包含“问句类型编号 + 实体词”的中间结构;这个结构接着传给get_cql.py,替换成真正的 Cypher 语句;最后get_answer.py去 Neo4j 执行查询,把节点属性拼成自然语言回复返回给用户。整条链路里,只有get_answer.py直接碰数据库,前面的模块全是纯内存字符串处理,这个分层是这类项目最值得抄的部分——它让调试可以逐段进行,不至于一个问题查半天不知道是解析挂了还是查询挂了。
2.2 数据流向:一问一答在内存里是怎么转的
把“一问一答”的过程落成代码时序,比文字描述直观得多。常见做法是在chat_robot.py的循环里串起这几个函数,我习惯用一个结构体在中间传递,避免函数参数越滚越长:
# chat_robot.py 核心循环的逻辑示意 def handle_question(user_input): # step 1: 问句解析 -> 得到意图编号与实体槽位 parsed = question_analysis.analyze(user_input) if not parsed["intent"]: return "我还没学会回答这类问题,换个问法试试" # step 2: 槽位 -> Cypher slot = parsed["entities"] # 例如 {"disease": "感冒"} cql, params = get_cql.build_cql(parsed["intent"], slot) if cql is None: return "这句我拆不出可查询的条件" # step 3: 执行查询并整理答案 answer = get_answer.run(cql, params) return answer if answer else "数据库里没有匹配到这个查询条件"这段逻辑说明三点。第一,question_analysis.py返回的parsed字典是整条链路的接口契约,它的字段名尽量不要改,改了后面get_cql.py的参数名会跟着乱。第二,get_cql.build_cql接收意图编号和实体槽位,返回的是 Cypher 字符串和参数字典,参数单独传而不是直接拼进字符串,这是为了防止问句里出现引号或特殊字符时 Cypher 语法报错。第三,get_answer.run里拿到的params最终会作为查询参数传给 Neo4j 驱动。
参数层面需要注意的是,analyze函数内部一般会先做关键词匹配,从keyword_template.py里定义好的模板库逐条比对。模板里的实体词来自dict目录下的自定义词典,词典覆盖能力直接决定问答命中率。动手改这个项目时,建议先跑通默认数据,再替换dict和data目录的内容——文件名和字段格式保持一致就能无缝切换到自己的领域。
3. 图谱构建这一步:数据模型、建图脚本与 Cypher 细节
3.1 医疗图谱的节点与关系设计
知识图谱问答的效果上限在数据模型层就定死了,查询写得再花哨,节点类型和关系方向不对也白搭。这类医疗问答项目常见的设计是六类实体节点:疾病(ILLNESS)、症状(SYMPTOM)、科室(DEPARTMENT)、药品(DRUG)、检查项目(CHECK)、常见用语(COMMON)。其中 COMMON 节点用来承载疾病别名或口语化说法,它是问句解析命中率的关键——用户说“流感”而图谱里存的是“流行性感冒”,就需要 COMMON 节点做桥梁。
关系方向我习惯统一用“从主体指向客体”的规则,例如疾病拥有症状就是(ILLNESS)-[:HAS_SYMPTOM]->(SYMPTOM),疾病对应的科室是(ILLNESS)-[:DEPARTMENT]->(DEPARTMENT),药品治疗疾病是(DRUG)-[:DRUG_FOR]->(ILLNESS),疾病需要做的检查是(ILLNESS)-[:NEED_CHECK]->(CHECK)。这样一个疾病节点就能辐射出症状、科室、药品、检查四条边,问答系统查询时只需要沿着某类关系走出一步,Cypher 语句天然短而快。
属性设计方面,节点上至少要保留两个字段:name和description。name是实体的标准名,description存介绍文本,后面get_answer.py拼答案时直接取description就能输出完整的一句话。额外加alias列表字段是更好的做法,别名查询可以直接用UNWIND展开匹配,不用每次在词典层做映射。
3.2 build_medicalgraph.py:建图流程与重复数据问题
看build_medicalgraph.py的代码,核心逻辑其实就是遍历data目录下的结构化数据文件,逐条创建节点和关系。我拆过很多份类似的建图脚本,写法大同小异,骨架通常是下面这个样子:
from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) # 读取疾病数据,典型格式是每行包含疾病名、症状列表、科室、药品、检查项 # 常见做法是 CSV 或用 Python 列表直接构造,这里以列表为例 diseases = load_disease_data() # 自定义读取函数 for d in diseases: # 1. 创建疾病节点,用 MERGE 防止重复 illness_node = graph.nodes.match("ILLNESS", name=d["name"]).first() if illness_node is None: illness_node = Node("ILLNESS", name=d["name"], description=d.get("desc", "")) graph.create(illness_node) # 2. 处理症状关系 for symptom in d["symptoms"]: symptom_node = graph.nodes.match("SYMPTOM", name=symptom).first() if symptom_node is None: symptom_node = Node("SYMPTOM", name=symptom) graph.create(symptom_node) rel = Relationship(illness_node, "HAS_SYMPTOM", symptom_node) graph.create(rel)这段代码里有三个参数值得细看。第一,Graph("bolt://localhost:7687", auth=(...))里的端口和账号密码必须和本地 Neo4j 实例一致,默认端口是 7687,这是 Bolt 协议端口,跟浏览器访问用的 7474 不是一回事。第二,graph.nodes.match("ILLNESS", name=d["name"])是按名称查询是否存在该节点,存在就复用,不存在才创建,这是控制重复节点的关键。第三,关系创建用graph.create(rel),如果重复执行建图脚本且前面没用 MERGE 逻辑,同一个HAS_SYMPTOM关系会被创建多条,后面查询时count(*)的结果会虚高,去重逻辑名不虚传地坑人。
更稳妥的写法是把查询和创建合成MERGE一条语句执行,MERGE (n:ILLNESS {name: $name})能做到“存在则返回、不存在则创建”。但要注意,MERGE的匹配逻辑是基于整个模式的所有属性,如果你把description也写进MERGE的子句里,每次更新数据后跑建图脚本会产生新节点,这是最容易踩的隐性坑。我的习惯是:节点的唯一标识只保留name,其他属性一律用SET更新。
3.3 清库与重建:clear_graph.py 的正确姿势
clear_graph.py的存在说明作者一开始就被重复数据坑过。清库不能用MATCH (n) DELETE n,因为删节点时如果它还有关系,Neo4j 会直接报错——必须先删关系再删节点,或者用DETACH DELETE一步搞定。常见做法是写成这样:
# clear_graph.py from py2neo import Graph graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) graph.run("MATCH (n) DETACH DELETE n") print("图谱已清空")跑完这条命令再执行build_medicalgraph.py,就能保证图谱里只有本轮导入的数据。调试期间我一般固定一个顺序:清库 → 建图 → 用MATCH (n) RETURN count(n)验证节点数,三步走完再进问答调试。这个顺序建议直接写进你的实验记录,能省掉大量“数据是不是重复了”的无效排查。
4. 问答管线:从问句解析到 Cypher 生成的完整链路
4.1 意图识别与实体抽取:模板匹配在医疗问答里够用
question_analysis.py是这个项目的“大脑”,但它的实现通常朴素到让人意外——基于关键词模板的意图分类加实体抽取,没有机器学习模型。这种做法在限定领域的医疗问答里完全够用,因为医疗问句的句式高度套路化:“感冒有什么症状”“头痛挂什么科”“发烧吃什么药”,翻来覆去就那几类问法。
这个文件内部的处理逻辑,我拆解后大致是这样的结构:
# question_analysis.py 意图识别逻辑示意 from keyword_template import INTENT_TEMPLATES, ENTITY_DICT def analyze(user_input): intent = None entities = {} # 1. 意图判定:遍历模板,第一个命中的作为当前意图 for intent_id, keywords in INTENT_TEMPLATES.items(): # keywords 例如 ["症状", "表现", "有什么反应"] if any(kw in user_input for kw in keywords): intent = intent_id break if intent is None: return {"intent": None, "entities": {}} # 2. 实体抽取:用自定义词典做最大正向匹配 for entity_type, word_list in ENTITY_DICT.items(): for word in word_list: if word in user_input: entities[entity_type] = word break # 每种实体只取第一个命中 return {"intent": intent, "entities": entities}这段代码有两个设计点值得学习。第一,意图模板INTENT_TEMPLATES的值是关键词列表,any()判断用户输入是否包含其中任意一个词,这样“感冒有什么症状”和“感冒的临床表现是什么”能命中同一个意图,靠的就是关键词覆盖面。第二,实体抽取用的是词典正向匹配,ENTITY_DICT里按实体类型维护词表,比如ILLNESS类下面放感冒、头痛、胃炎等词,SYMPTOM类下面放发烧、咳嗽、恶心等词。这种方法的局限也很明显:词典没有收录的问法会直接漏掉,但作为课程设计和原型演示,它是性价比最高的方案。
实体抽取后得到的entities字典是有序的,这里有个很多新手会忽略的细节——同一种实体类型在问句里可能多次出现,比如“头痛伴随发烧挂什么科”,SYMPTOM类会同时命中“头痛”和“发烧”,但代码里的break只取第一个。要不要保留多个实体,取决于查询模板是否需要。我一般会改成收集全部命中实体放到列表里,get_cql.py生成 Cypher 时用IN条件匹配,能提升不少召回率。
4.2 get_cql.py:把槽位填进 Cypher 模板
get_cql.py是整个链路里最“机械”但最关键的一环,它把意图编号和实体槽位填进预先写好的 Cypher 模板。很多项目的问答效果差,不是图谱数据有问题,而是这一步的模板写得不严谨,查出来的结果张冠李戴。典型的模板映射逻辑如下:
# get_cql.py 意图到Cypher模板的映射 CQL_TEMPLATES = { "symptom_of_disease": # 问“某病有什么症状” "MATCH (d:ILLNESS {name: $disease})-[:HAS_SYMPTOM]->(s:SYMPTOM) " "RETURN s.name AS symptom", "department_of_disease": # 问“某病挂什么科” "MATCH (d:ILLNESS {name: $disease})-[:DEPARTMENT]->(dep:DEPARTMENT) " "RETURN dep.name AS department", "drug_for_disease": # 问“某病吃什么药” "MATCH (d:ILLNESS {name: $disease})<-[:DRUG_FOR]-(drug:DRUG) " "RETURN drug.name AS drug", "disease_by_symptom": # 问“某症状可能是啥病” "MATCH (d:ILLNESS)-[:HAS_SYMPTOM]->(s:SYMPTOM {name: $symptom}) " "RETURN d.name AS disease", } def build_cql(intent, entities): template = CQL_TEMPLATES.get(intent) if template is None: return None, None # 把实体槽位映射成Cypher参数,键名与模板里的$占位符保持一致 params = {} if "disease" in entities: params["disease"] = entities["disease"] if "symptom" in entities: params["symptom"] = entities["symptom"] return template, params这里有一个特别值得强调的细节:symptom_of_disease与disease_by_symptom的 Cypher 方向是相反的,一个是疾病出发查症状,一个是症状出发查疾病。这要求意图编号的命名必须能一眼看出方向,否则模板写多了容易混。参数名$disease和$symptom必须与模板里的占位符严格一致,我见过太多因为参数名拼写不一致导致查询返回空结果的案例——报错倒不至于,因为py2neo把未使用的参数当成多余参数,静默忽略,结果就是用户辛辛苦苦问了一句,系统回一句“查不到”。
查询结构上,$disease的精确匹配意味着用户说“感冒”就必须图谱里有name: "感冒"的节点。如果问句里出现的是“流行性感冒”而图谱里只有“流感”,就会查不到。这个问题的解法是尽量在图谱构建阶段用别名节点或alias属性把常见叫法都收进来,而不是指望问答阶段做繁复的同义词归一。
4.3 get_answer.py:执行查询与答案兜底
get_answer.py负责把 Cypher 抛给 Neo4j 并处理结果。执行本身不复杂,容易翻车的是结果为空时的返回逻辑和多个返回值时的拼接格式:
# get_answer.py 执行查询并组织答案 from py2neo import Graph graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) def run(cql, params): if cql is None: return "" try: result = graph.run(cql, **params).data() except Exception as e: # 查询语法错误时把异常吞掉,避免控制台直接崩 print(f"[Cypher执行失败] {e}") return "" if not result: return "" # 取第一个结果字段,多个结果用顿号拼接 # 这一步看似简单,但字段名取错是空答案的最大来源 first_key = list(result[0].keys())[0] values = [r[first_key] for r in result if r[first_key] is not None] return "、".join(values)这个函数有两个边界情况值得注意。第一,result[0].keys()取第一个结果的字段名,它要求所有返回行都有这个字段——如果RETURN子句用了AS别名,这里拿到的是别名,不是原始属性名,所以前面get_cql.py模板里的AS symptom就是为了让这里能稳定取到键名。第二,结果去重问题:如果同一个症状被多个HAS_SYMPTOM关系关联,data()会返回重复行,用set(values)包一层去重是必要操作,否则答案里会出现两遍“发烧”。
兜底策略上,“查不到”和“没听懂”要区分对待。run返回空字符串,chat_robot.py层会提示用户换个问法;而如果question_analysis.py根本没识别出意图,用户看到的是另一句提示。这两类反馈混在一起会严重影响体验,调试时看到“我还没学会”和看到“数据库里没有匹配”代表的是两个完全不同的故障方向。
5. 部署与运行避坑:版本、编码与空答案的排查记录
5.1 Neo4j 连接不上:Bolt 端口与版本不匹配
现象:运行main.py后控制台报py2neo.errors.ServiceUnavailable或ConnectionError,提示连不上bolt://localhost:7687。
原因:最常见的是 Neo4j 没有启动、端口配错,其次是项目用的py2neo版本和本地 Neo4j 版本不兼容。这个项目依赖的py2neo是 4.x 时代风格的写法,如果本地装的是 Neo4j 5.x,py2neo4.x 的连库方式会直接失效,因为 Neo4j 5 移除了部分旧协议支持。
解决:先访问http://localhost:7474看 Neo4j 浏览器是否能打开,确认服务在跑;再确认build_medicalgraph.py里Graph()的地址端口和账号密码与本地实例完全一致。版本兼容问题上,最省事的做法是装 Neo4j 3.5 或 4.4 的社区版配py2neo 4.x,这套组合经过大量课程项目验证,踩坑最少。如果坚持用 Neo4j 5.x,就得把py2neo升级到 5.x 并适配新的驱动写法,改动量不大但没必要在课程设计阶段折腾。
5.2 重复节点暴涨:CREATE vs MERGE 的代价
现象:图谱里同一种疾病出现几十个节点,关系数量翻倍,问答答案里同一结果重复出现。
原因:build_medicalgraph.py里创建节点用的是graph.create(),没有先查询是否已存在同名节点;或者建图脚本被执行了多次,每次都往里插一条完整数据。
解决:所有创建节点的位置统一改成“先匹配后创建”,或者直接用MERGE。我习惯用graph.nodes.match("ILLNESS", name=d["name"]).first()做存在性判断,因为MERGE写在一个长 Cypher 里时,属性变化可能导致新节点产生,排查起来更隐蔽。另外,每轮重建前先跑clear_graph.py,这是最简单粗暴但最有效的防重手段。
5.3 答案总为空:模板没匹配上还是 Cypher 没查对
现象:问“感冒有什么症状”,系统回复“数据库里没有匹配到这个查询条件”;但 Neo4j 浏览器里手动执行同样的 Cypher 却能查出数据。
原因:三个环节都可能出问题。第一,question_analysis.py的词典里没有“感冒”这个词,实体抽取失败,entities为空;第二,get_cql.py的意图编号和模板映射错位,比如问症状却匹配到了科室模板;第三,get_answer.py返回结果时字段名取错,查到了数据但没取出来。
解决:我在排查这类问题时用分步打印法,在chat_robot.py的每一段处理结束后打印中间结果——先打印parsed字典看意图和实体有没有出来,再打印cql和params看生成的查询语句对不对,最后单独执行get_answer.run看返回内容。哪一段输出异常就修哪一段,不用猜。这个习惯帮我省了一半以上的调试时间,强烈建议照着做。
5.4 建图卡死或 OOM:Neo4j 堆内存与事务提交
现象:执行build_medicalgraph.py时,Neo4j 服务无响应,控制台报OutOfMemoryError或者建图过程极慢。
原因:Neo4j 默认 JVM 堆内存只有几百 MB,当data目录数据量达到数万节点时,脚本在一个大事务里批量写入会撑爆内存。
解决:改 Neo4j 配置文件neo4j.conf里的dbms.memory.heap.initial_size和dbms.memory.heap.max_size,建议设为1024到2048。代码层面,建图循环里每处理一定数量节点就提交一次事务,而不是全部攒到最后——py2neo里可以通过graph.begin()配合tx.commit()控制事务边界,也可以简化成每 100 条数据就重新连接一次。医疗类课程项目的数据量一般不大,这个坑遇到的人少,但一旦遇到就是灾难性的体验。
5.5 中文乱码与文件名编码问题
现象:导入的数据在 Neo4j 浏览器里显示正常,但 Python 打印出来的实体词是乱码,或者question_analysis.py匹配中文词时总是失败。
原因:Windows 终端默认编码是 GBK,而 Python 3 源文件若是 UTF-8 编码,打印中文时终端解码错误;更隐蔽的是dict目录下的词典文件如果不是 UTF-8 编码,读进来的词条本身就是错的。
解决:所有数据文件和 Python 源文件统一用 UTF-8 编码保存,question_analysis.py读取词典时显式指定encoding="utf-8",同时把chat_robot.py的输出流重定向为 UTF-8——在main.py开头加sys.stdout.reconfigure(encoding="utf-8")就能解决 Windows 下的中文输出乱码。这个问题看似小,却是新手第一个遇到的坎,因为报错信息里完全没有“编码”字样,只会表现为匹配失败。
6. 把意图模板改成配置化:新增问法只改一处,不用碰主流程
keyword_template.py里的意图模板是写死在 Python 字典里的,每加一种问法就要改代码再重启。我在复现这个项目后做的第一个改动,是把意图模板和 Cypher 映射抽成独立的配置文件,用嵌套字典组织,新增问法时只往配置文件里加一条记录。具体做法是把INTENT_TEMPLATES和CQL_TEMPLATES合并成一个配置项:
# 配置化的意图-查询模板示例,可直接替换 keyword_template.py 里的硬编码字典 INTENT_CONFIG = [ { "intent": "symptom_of_disease", "keywords": ["症状", "表现", "反应", "临床"], "entity_types": ["disease"], "cql": "MATCH (d:ILLNESS {{name: $disease}})-[:HAS_SYMPTOM]->(s:SYMPTOM) RETURN s.name AS symptom", }, { "intent": "department_of_disease", "keywords": ["挂什么科", "去哪个科室", "看什么科"], "entity_types": ["disease"], "cql": "MATCH (d:ILLNESS {{name: $disease}})-[:DEPARTMENT]->(dep:DEPARTMENT) RETURN dep.name AS department", }, ]这段配置里两个细节要解释。第一,cql字段里用了双花括号{{name: $disease}},这是为了让字符串格式化时保留字面量花括号,因为后面要用.format()把真参数填进去。实际执行时get_cql.py读取配置,先.format()再传给 Neo4j 驱动做参数化查询。第二,entity_types告诉question_analysis.py这个意图需要抽取哪些实体,没有列出的实体类型即使命中了也不进入槽位,避免无关词干扰查询。
加一种“某症状需要做什么检查”的问法,现在只需要在配置里加一条记录,把keywords设为["做什么检查", "检查项目", "怎么查"],entity_types设为["symptom"],cql写成MATCH (s:SYMPTOM {name: $symptom})<-[:NEED_CHECK]-(d:ILLNESS) RETURN d.name AS disease即可。主流程的analyze和build_cql函数完全不用动,因为它们本来就只按配置结构去遍历。配置化改造的价值在维护期才真正显现——交完大作业三个月后回头改代码,你会感谢自己当初没把模板散落在 if-else 里。
我在每次换数据源时都强制走一遍完整流程:清库 → 改配置文件 → 跑建图 → 手动问三个不同类型的问题验证。这个习惯是从被“答案为空”反复折磨之后养成的,从那以后接手任何知识图谱问答项目,第一件事都是确认配置和数据字典的生命周期是否清晰,而不是直接跑main.py碰运气。希望这份拆解能让你少走我走过的弯路,项目里的代码骨架是好的,把它跑熟吃透,比换个方向重写一套更有价值。
本文还有配套的精品资源,点击获取