基于neo4j的知识图谱问答系统:不训练模型,靠规则跑通问答闭环

发布时间:2026/10/18 10:32:22
基于neo4j的知识图谱问答系统:不训练模型,靠规则跑通问答闭环 简介这套Python源码包以neo4j图数据库为核心完整实现了从医疗、犯罪等领域数据中构建知识图谱并基于该图谱进行问答检索的系统。资源面向希望学习知识图谱构建、Neo4j应用或问答系统开发的Python开发者涵盖数据预处理、实体关系抽取、图谱存储、问题解析与答案生成等关键环节。压缩包共40个文件以Python脚本.py为主配合txt数据字典、json图谱数据、pyc编译文件以及png运行截图、pptx讲解和markdown说明文档整体体积仅9.18MB便于下载与本地环境快速部署。目前已有798人学习下载说明其具有一定的参考价值。通过阅读源码和说明读者可以掌握基于neo4j搭建知识图谱的完整流程了解问句分类、意图识别、查询语句构造等问答系统核心组件的实现思路并借助截图和演示文档快速校验运行效果。1. 基于neo4j的Python问答系统不训练模型靠图谱和规则跑通问答闭环先用一个场景开场你在对话框里输入“我最近头晕乏力应该挂什么科”这套系统会在几百毫秒内完成实体识别、意图分类、图谱查询和答案拼接最终返回一句“头晕乏力可能与神经内科相关建议您前往神经内科就诊”。这套基于neo4j的知识图谱问答系统把医疗领域的疾病、症状、药品、检查、科室五类实体以及疾病和它们之间的各种关系全部存进图数据库再用词典和规则驱动问答。全程不需要训练深度学习模型却能跑出一个形态完整的问答闭环。它适合两类人一是想搞懂知识图谱从建图到应用完整链路的开发者二是想快速搭一版垂直领域问答系统的后端工程师。2. 项目结构与数据资产先看清每个文件在链路里的位置项目拿到手不要急着跑先把目录结构过一遍。这套源码的核心是“一条数据流水线”爬取原始数据 → 清洗成词表和关系对 → 灌入neo4j图谱 → 基于图谱做问答。只要认清每个文件在哪一环后面调参、改bug、二次开发都会顺手很多。2.1 目录结构一条链路上的脚本各自负责什么展开压缩包后我习惯先画一张文件映射图把脚本和它对应的职责挂上钩结构大致是这样的. ├── build_medicalgraph.py # 核心建图脚本读dict数据入neo4j ├── chatbot_graph.py # 问答入口串起分类、解析、检索 ├── question_classifier.py # 问句实体识别与意图分类 ├── question_parser.py # 把意图转成Cypher查询语句 ├── answer_search.py # 执行Cypher并组装答案文本 ├── del.py # 清空neo4j图谱数据便于重复建图 ├── prepare_data/ │ ├── data_spider.py # 从公开医疗词条抓取原始数据 │ ├── build_data.py # 清洗数据生成词表和关系对 │ └── max_cut.py # 最大匹配分词辅助问句实体识别 ├── dict/ # 预置实体词典问答和建图共用 │ ├── disease.txt # 疾病词表 │ ├── symptom.txt # 症状词表 │ ├── drug.txt # 药品词表 │ ├── check.txt # 检查项目词表 │ ├── department.txt # 科室词表 │ ├── food.txt # 食物词表 │ ├── producer.txt # 药品生产商词表 │ └── deny.txt # 否定/禁忌词表 ├── data/ # 数据备份目录 │ └── kg_crime.json ├── img/ # 作者跑出来的截图 │ ├── kg_route.png # 图谱整体概览 │ ├── qa_route.png # 问答链路图 │ └── graph_summary.png ├── document/ # 作者早期的架构讲解PPT └── README.md # 项目说明先读它注意document/里放了一份作者早期分享的架构讲解PPT里面有kg_route和qa_route的完整链路图对理解设计意图很有帮助。__pycache__下的.pyc是运行时自动生成的编译缓存可以直接忽略不影响项目运行。从这条目录结构能看出作者的分层意识数据准备、建图、问答三条链路各自独立build_medicalgraph.py只依赖dict/问答三个脚本也只依赖dict/和自己的类。这意味着你想换一个领域只需要替换数据源和词表问答层基本不用大动。2.2 dict词表给实体识别提供弹药dict/是整个项目的“弹药库”建图脚本和问答脚本都从这里读数据。每个txt文件一行一个词例如disease.txt里是“感冒”“高血压”“糖尿病”这类疾病名symptom.txt里是“发热”“咳嗽”“头晕”这类症状词。这里有两个容易被忽略的细节。第一个是deny.txt它存的是“不适宜”“忌口”“不能吃”这类否定表达。医学问答里经常出现“高血压患者不适宜吃什么”这种问法系统需要靠这个词表判断句子里的否定关系否则会把“不适宜”直接忽略给出相反的饮食建议。第二个是producer.txt生产商词表它支撑的是“这个药是谁生产的”一类问法属于补充性的实体维度。data/下的kg_crime.json和dict/里的crime.txt、crime_big.txt是作者切换实验主题时留下的另一份数据备份和医疗问答主线无关。跑建图脚本前建议直接忽略它们避免把无关实体混进图谱。2.3 数据准备脚本从爬虫、清洗到分词prepare_data/下三个脚本对应数据准备的三道工序。data_spider.py负责从公开的医疗词条页面抓取疾病词条包括疾病名称、症状、治疗方式、常用药品等半结构化的文本build_data.py把抓回来的原始文本做清洗抽取实体名和实体关系对输出成dict/下的词表文件max_cut.py实现的是一个最大正向匹配分词器留给问答阶段做问句实体匹配用。我当时跑build_data.py的感受是它的清洗逻辑并不复杂核心就是定好实体和关系的抽取规则。抓回来的原始文本如果是JSON清洗时大致长这样import json raw_data json.load(open(raw_medical.json, encodingutf-8)) disease_set set() symptom_disease_pairs [] for item in raw_data: disease item.get(name, ).strip() symptoms item.get(symptom, ).split(、) if not disease: continue # 疾病词表去重 disease_set.add(disease) # 疾病-症状关系对后续建图直接消费 for sym in symptoms: sym sym.strip() if sym: symptom_disease_pairs.append((sym, disease)) with open(dict/disease.txt, w, encodingutf-8) as f: for d in sorted(disease_set): f.write(d \n)逻辑说明这段代码先读入爬虫产出的原始JSONitem.get(name)取疾病名item.get(symptom)取症状字段并按顿号切分。疾病名和症状名都被汇总到集合里做去重最终写出的是dict/disease.txt这样的词表文件。symptom_disease_pairs这个关系对列表则是建图脚本的输入。参数说明encodeutf-8在Windows和Linux下都必须显式指定否则在Windows上会被默认的GBK编码坑到后面第5章会细说。split(、)是按中文顿号切分不同数据源的切分字符可能不一样处理其他领域数据时记得先确认分隔符是逗号、顿号还是空格。3. 图谱构建核心build_medicalgraph.py如何把数据灌进neo4j建图是整个项目最有价值的部分。build_medicalgraph.py做的事情可以概括为三件事定义实体类型、定义关系类型、把dict/里的词表数据和关系对灌进neo4j。图谱设计得好不好直接决定后面问答能回答什么、不能回答什么。3.1 实体与关系设计决定图谱长什么样实体就是图谱里的节点关系就是节点之间的边它们共同构成图谱的Schema。这个项目的Schema设计完全围绕医疗问答场景展开。节点类型有七类疾病、症状、药品、检查项目、科室、食物、生产商。关系类型则按“疾病和其他实体的联系”来设计核心关系可以整理成下面这张表关系类型起点实体终点实体语义含义支撑的问法has_symptom疾病症状该疾病有哪些症状“感冒有什么症状”recommend_drug疾病药品该疾病推荐用什么药“感冒吃什么药”need_check疾病检查项目该疾病需要做什么检查“高血压要做什么检查”belongs_to疾病科室该疾病归哪个科室“糖尿病挂什么科”good_food疾病食物该疾病宜吃什么食物“胃炎吃什么好”bad_food疾病食物该疾病忌吃什么食物“糖尿病患者不能吃什么”produce_by药品生产商该药品由哪家生产“布洛芬是谁生产的”关系命名是英文驼峰风格Cypher查询时直接使用比如(d:Disease)-[:has_symptom]-(s:Symptom)。这套设计把医疗问答里最高频的几类问题都覆盖到了没有过度设计。3.2 实体入库py2neo的Node、merge与去重实体入库的代码是整个建图脚本的骨架常见写法是遍历词表为每个词创建一个Node再用merge写入。from py2neo import Graph, Node g Graph(bolt://localhost:7687, auth(neo4j, 123456)) def create_disease_node(disease_name, desc): 创建一个疾病节点name是唯一键merge避免重复入库。 node Node(Disease, namedisease_name, descdesc) g.merge(node, Disease, name) return node逻辑说明Node(Disease, namedisease_name, descdesc)的第一个参数是节点标签后面是键值对属性。g.merge的作用是“有则更新、无则创建”第三个参数name指定了唯一键也就是说当图谱里已存在同名疾病节点时不会新建节点只会更新属性。如果不加这个唯一键每跑一次脚本就会多出一批重复节点图谱很快就废了。参数说明Graph(bolt://localhost:7687, auth(neo4j, 123456))里第一个参数是Bolt协议连接地址默认端口7687auth元组是用户名和密码。如果你本地的neo4j没有改过密码初始密码是neo4j第一次登录neo4j会强制要求修改。这里要特别提醒源码里写死的密码通常和你本地环境不一致照抄大概率会连不上。3.3 关系构建与批量提交Relationship与一次提交节点建好之后是关系。关系构建的经典写法是先match到起点和终点节点再创建关系。为了减少网络请求次数实际项目中通常会先把一批节点和关系放进一个Subgraph最后一次性merge。from py2neo import Graph, Node, Relationship def build_relationships(pairs, rel_type): pairs是[(起点实体名, 终点实体名), ...]的列表 rel_type是关系类型例如has_symptom nodes_to_merge [] rels [] for start_name, end_name in pairs: start_node Node(Disease, namestart_name) end_node Node(Symptom, nameend_name) nodes_to_merge.append(start_node) nodes_to_merge.append(end_node) rel Relationship(start_node, rel_type, end_node) rels.append(rel) subgraph Subgraph(nodes_to_merge, rels) g.merge(subgraph, Disease, name) g.merge(subgraph, Symptom, name)逻辑说明关系必须挂在节点上所以这里先为每个关系对创建两个临时Node再用Relationship(start_node, rel_type, end_node)建立关系。把节点和关系一起放进Subgraph最后仍然走merge。注意merge时分别指定了Disease和Symptom的name作为唯一键这样即使临时Node的属性不完整也能正确对齐到图谱里已有的节点上。参数说明Relationship第二个参数是关系类型名字符串里不能带空格或特殊符号Subgraph接收的是节点列表和关系列表它的merge逻辑是按标签和唯一键对齐不是按内存地址对齐。3.4 建图后的验证Cypher查询与数据核对建图脚本跑完后第一件事是打开neo4j浏览器用Cypher检查图谱规模。作者在img/里放了两张图kg_route.png是图谱整体概览graph_summary.png是节点数统计。你可以自己执行下面的Cypher验证MATCH (d:Disease) RETURN count(d) AS disease_cnt; MATCH (:Disease)-[r:has_symptom]-(:Symptom) RETURN count(r) AS relation_cnt; MATCH (n) RETURN labels(n) AS label, count(*) AS cnt ORDER BY cnt DESC;逻辑说明第一条统计疾病节点数第二条统计“疾病-症状”关系数第三条按标签分组统计各类节点的数量。核对数量时以dict/里的词表为基准如果disease.txt有200个词那图谱里Disease节点数应该也是200左右上下浮动不超过几个否则说明有重复节点或者有词没匹配上。这里有个血泪经验建图脚本如果连续跑两遍在merge没有生效的情况下节点数会翻倍。作者特意留了del.py它的作用就是先清空图谱再重建。所以每次重新建图前我都会先跑一遍del.py再跑build_medicalgraph.py保证从干净状态开始。4. 问答链路拆解从一句话到查询语句再到人话图谱只是基础设施用户实际接触的是问答入口。整个问答链路由三个脚本串联question_classifier.py理解用户问的是什么question_parser.py把理解结果翻译成Cypheranswer_search.py执行查询并把结果拼成自然语言。4.1 实体识别和意图分类question_classifier.py的核心逻辑意图分类是整个问答系统的灵魂。它要回答两个问题这句话里出现了哪些实体用户到底想问什么这个项目采用的是词典匹配加正则模板的方案没有用训练模型。class QuestionClassifier: def __init__(self): self.disease_words self.load_words(dict/disease.txt) self.symptom_words self.load_words(dict/symptom.txt) self.drug_words self.load_words(dict/drug.txt) self.intent_patterns { disease_symptom: re.compile(r(.?)有什么症状|症状是(.?)[吗呢]), disease_cureway: re.compile(r(.?)怎么治|怎么治疗|如何治疗), disease_drug: re.compile(r(.?)吃什么药|推荐什么药), disease_belongs_to: re.compile(r(.?)挂什么科|去什么科室), disease_food: re.compile(r(.?)吃什么好|不能吃什么), } def classify(self, question): entities self.find_entities(question) for intent, pattern in self.intent_patterns.items(): match pattern.search(question) if match: return intent, entities return unknown, entities逻辑说明classify方法先调用find_entities找出句子里的疾病、症状、药品等实体然后按顺序用正则模板匹配意图。(.?)是非贪婪分组用来捕获问句里的主体词。比如“高血压应该挂什么科”会命中disease_belongs_to模板同时find_entities会识别出“高血压”这个疾病实体。这里的执行顺序是有讲究的先匹配实体再匹配意图两个结果都保留。意图决定了走哪条查询路径实体决定了查询条件里的具体值。如果所有正则都没命中就返回unknown由下游做兜底处理。参数说明self.load_words是作者封装的一个读词表方法内部把txt文件的每行读取为列表元素re.compile预编译了每个意图对应的正则避免每次调用都重新编译这个习惯在高并发问答场景里能省不少开销。4.2 查询模板生成question_parser.py如何构造Cypher意图和实体都有了接下来要生成Cypher。question_parser.py的核心是一个字典工厂根据不同的question_type输出不同的Cypher模板并且要求每种类型都必须返回一个固定的结构。class QuestionPaser: def build_cypher(self, intent, entities): disease entities.get(disease, ) if intent disease_symptom: cypher ( fMATCH (d:Disease)-[:has_symptom]-(s:Symptom) fWHERE d.name {disease} fRETURN s.name ) elif intent disease_drug: cypher ( fMATCH (d:Disease)-[:recommend_drug]-(m:Drug) fWHERE d.name {disease} fRETURN m.name ) elif intent disease_belongs_to: cypher ( fMATCH (d:Disease)-[:belongs_to]-(dept:Department) fWHERE d.name {disease} fRETURN dept.name ) else: cypher return [{ question_type: intent, queries: [{query_type: intent, cypher: cypher}] }]逻辑说明build_cypher把Cypher语句包装成一个两层结构外层是question_type和queries列表。queries里每一项都有query_type和cypher两个字段。这样设计的价值在于一个问句可能对应多条查询链比如“胃炎有什么症状该挂什么科”需要查两个子问题queries数组正好可以放多条Cypher。参数说明Cypher里的{disease}是直接字符串拼接。这在演示项目里很常见但如果做成生产系统最好换成参数化查询否则用户输入的引号可能会破坏Cypher结构。我在二次开发时会用$disease参数替掉这段拼接逻辑。4.3 答案组装和对话入口answer_search.py与chatbot_graph.pyCypher生成后交给answer_search.py执行执行结果不能直接返回要拼装成正常人能读的话。答案组装规则按query_type区分不同的意图有不同的答案模板。class AnswerSearcher: def search_main(self, sqls): final_answers [] for sql in sqls[0][queries]: cypher sql[cypher] query_type sql[query_type] if not cypher: continue results g.run(cypher).data() if results: answer self.convert_answer(results, query_type) final_answers.append(answer) if not final_answers: return 这个问题我暂时没有查到答案换个问法试试 return \n.join(final_answers) def convert_answer(self, results, query_type): names [row[s.name] for row in results] if query_type disease_symptom: return f{names[0]}的常见症状包括{, .join(names[:5])} elif query_type disease_belongs_to: return f该疾病建议前往{names[0]}就诊逻辑说明g.run(cypher).data()返回的是列表每个元素是一个字典字典key是Cypher里RETURN的别名。convert_answer根据query_type决定答案模板names[:5]做了截断避免症状过多导致答案又臭又长。整个结构是纯函数的风格输入Cypher列表输出字符串方便单元测试。chatbot_graph.py是最后的主入口它把三个类串起来形成完整的处理流水线class ChatBotGraph: def __init__(self): self.classifier QuestionClassifier() self.parser QuestionPaser() self.searcher AnswerSearcher() def chat_main(self, sentence): intent, entities self.classifier.classify(sentence) if intent unknown: return 我不太明白你的意思换个问法试试 sqls self.parser.build_cypher(intent, entities) answers self.searcher.search_main(sqls) return answers逻辑说明chat_main接收用户原始句子第一步做分类第二步生成Cypher第三步搜索答案。unknown意图在这里被兜底拦截直接返回提示语不会往下走。这套分层设计把“理解、查询、表达”三个关注点拆开了任何一个环节出问题都能单独排查这在实际调试中非常省力。5. 踩坑与排查neo4j连接、版本兼容、问答答非所问这个项目我前前后后跑过三遍每一遍都在不同地方翻车。这里挑五个最典型的坑按“现象、原因、解决”写清楚帮后来的人省点时间。5.1 py2neo版本不一致导致API报错现象安装依赖后直接跑build_medicalgraph.py报错要么是ImportError: cannot import name authenticate要么是Graph()的username/password参数不被识别。原因py2neo的API在4.x和5.x之间变化很大。老版本用from py2neo import authenticate然后用Graph(http://localhost:7474, usernameneo4j, password123456)连接5.x版本移除了authenticate连接方式改成了Graph(bolt://localhost:7687, auth(neo4j, 123456))。源码很可能是基于旧版本写的你直接用pip install py2neo装的是最新版接口对不上。解决先看自己装的是哪个版本再来决定改代码还是锁版本。最省事的做法是统一改成py2neo 5.x的写法把建图的连接部分替换掉from py2neo import Graph # py2neo 5.x 推荐写法 g Graph(bolt://localhost:7687, auth(neo4j, 123456))如果项目里其他地方用了authenticate直接删除那行改上面这一个连接就够。血的教训不要升级py2neo到6.x再尝试跑老项目接口变动只会更大。5.2 中文乱码Windows下读词表全是乱码字符现象dict/disease.txt里的中文词读进Python后变成“鎰熷啋”一类的乱码建出来的图谱节点名全是问号。原因Windows下用记事本另存txt时默认编码可能是ANSI/GBK而Python的open()函数默认按系统编码读取。如果代码里没显式encodingutf-8在以UTF-8为默认编码的Linux或新版macOS上GBK文件读出来必然乱码。解决统一两件事。第一把所有txt文件转成UTF-8编码第二只要是读文本文件一律带上encodingutf-8参数def load_words(filepath): words [] with open(filepath, r, encodingutf-8) as f: for line in f: line line.strip() if line: words.append(line) return words5.3 图谱越建越大重复实体和重复关系现象跑了几遍build_medicalgraph.py之后用Cypher统计Disease节点数发现比词表行数多出好几倍。原因建图脚本用了create而不是merge或者merge时没有指定唯一键。每跑一次脚本所有节点都重新创建一遍旧的没清掉新的又进来。解决第一先给实体加唯一约束从数据库层面拦住重复。在neo4j浏览器里执行CREATE CONSTRAINT IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE;第二每次建图前先跑del.py清库。我现在的习惯是清库、建约束、建图、统计数量四步走完才敢进行问答测试。5.4 neo4j连接不上端口、认证、Driver不匹配现象Graph(bolt://localhost:7687, auth(neo4j, 123456))一直报ConnectionError或Unauthorized。原因常见三处。一是neo4j服务没启动二是默认密码没改初始密码是neo4j不是123456三是Bolt端口和HTTP端口搞混neo4j的默认端口是7474HTTP和7687Bolt代码里用的协议和端口不匹配就会连不上。解决先用浏览器打开http://localhost:7474确认neo4j进程正常再手动登录一次验证账号密码。如果密码没改过先用默认密码登录并按提示修改然后把代码里的auth改成你自己的密码。端口问题很简单用了bolt://就配7687用了http://就配7474别混。5.5 意图识别不准词典缺失与分词切碎实体现象输入“我最近老咳嗽吃什么药好的快”系统回答“我不太明白你的意思”或者把“咳嗽”识别成别的实体。原因两种可能。第一dict/symptom.txt里没有“咳嗽”这个词实体识别直接漏掉第二max_cut.py的最大匹配分词在切分时受语气词干扰把“咳嗽”从句子中错误切分出去。解决先在代码里单独调试实体识别这一步看find_entities输出了什么。如果词表缺词直接在dict/symptom.txt追加一行。如果分词被干扰可以先做一轮轻量的问句清洗把“我最近”“好的快”这类无关片段去掉再进入实体匹配。我一般会在分类器里加一个预处理步骤def clean_question(self, question): # 去掉口语语气词减少对实体匹配的干扰 question re.sub(r我最近|请问|一下|谢谢|好的, , question) return question.strip()参数说明re.sub的第二个参数替换成空字符串就等于删除匹配片段。这里列举的干扰词是按用户真实问法统计出来的跑问答测试时你会发现不同的问法干扰词不一样这份清单要长期维护。6. 二次开发把医疗图谱换成你自己的领域数据理解了这个项目的分层结构二次开发其实不难。核心思路是只要你的领域仍然可以用“实体关系问句模板”来表达这套框架就能复用。我拿一个模拟场景来演示改造过程比如要把医疗问答改成商品知识问答。第一步换数据。把dict/disease.txt换成商品类目词表symptom.txt换成商品属性词表department.txt换成服务渠道词表。建图脚本里的节点标签和关系类型也要跟着改比如Disease换成Categoryhas_symptom换成has_attribute。第二步改意图模板。商品问答最关心的是“这个商品有什么功能、适合什么人、价格区间是什么”所以question_classifier.py里的正则模板要重新写。这一步的核心是把意图枚举和你业务里的真实问法对齐。第三步改Cypher模板。question_parser.py里的build_cypher方法和convert_answer是强耦合的关系二者必须同步改。改完后用一批真实问句做回归验证确保每个意图都有产出test_cases [ (这款手机有什么功能, has_attribute), (这款手机适合什么人, target_user), (这款手机多少钱, price_range), ] for question, expected in test_cases: intent, entities classifier.classify(question) assert intent expected, f{question} 意图识别失败 sqls parser.build_cypher(intent, entities) answers searcher.search_main(sqls) assert answers, f{question} 查询结果为空这段验证脚本每改一次分类器就跑一遍成本很低能防住大多数改出来的低级错误。从那以后我每次拿到这类图谱问答项目都强制先跑一遍建图脚本确认关系数能和词表对得上再做问答测试最后才动意图模板——关系对不上后面所有模板都是白搭。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询