公司动态
基于Python+Neo4j的古诗词知识图谱问答系统实战指南
简介知识图谱作为组织实体与关系的核心技术正在自然语言处理领域发挥越来越重要的作用。与关系型数据库和全文检索引擎相比图数据库Neo4j将实体和关系作为一等公民能够以直观的路径遍历方式表达复杂关联特别适合构建问答系统。在实际工程中通过Python完成数据采集与清洗利用Cypher完成知识建模与查询再结合意图分类与实体识别技术即可搭建一个可运行的古诗词问答系统。这类系统不仅应用于文学知识检索还能延伸到文化遗产数字化、智能教育等场景。本文直接从零梳理系统架构、本体设计、数据导入、问答引擎及部署调优等关键环节为开发者提供一套可落地的实践方案。 前阵子有个读者发来一个“基于Pythonneo4j的知识图谱古诗词问答系统”的项目压缩包问能不能帮忙跑起来。我打开一看代码结构倒是标准的三层数据清洗、Neo4j导入、问答接口。可真正跑起来才发现这个系统只能回答固定的几类问题稍微换个问法就答不上来数据库里的诗词也就几百首诗人、意象这些实体压根没建全。这篇文章我不打算照着那个zip里的代码逐行讲解而是把自己从零搭一遍这类系统的完整思路和关键代码放出来顺便说说这种打包项目里最容易藏雷的地方。无论你是准备拿它做毕业设计还是单纯想搞一个能用的知识图谱古诗词问答系统demo这篇都能让你少走不少弯路。1. 古诗词问答系统为什么要用图数据库来搭先聊点实在的。古诗词问答系统这个词听起来高大上本质就一句话让用户用自然语言提问系统返回古诗词相关的答案。比如“李白写过哪些关于月亮的诗”“宋朝有哪些写梅花的诗人”“床前明月光是谁写的”。这类问题跟普通搜索引擎的差别在于它不只要做关键词匹配还要理解问句中的实体和关系再沿着这些关系去查找答案。1.1 关系型数据库和ES在这类问题上的天生短板很多人第一反应是用MySQL存数据三个表就能搞定诗人表、诗作表、朝代表。但真做起问答来就难受了。比如“李白写的包含月亮的五言绝句有哪些”这个查询在MySQL里要join诗人表、诗作表还要查诗作内容里是否包含“月”字再判断是不是五言、是不是绝句。表一多SQL就开始往长字符串的方向狂奔而且一旦问题变成“李白和杜甫共同写过的意象有哪些”这种环路查询写起来你就知道痛了。Elasticsearch的情况也类似。ES擅长的是全文检索你问“关于月亮的诗”它可以很快把包含“月”的诗句全部捞出来再加一个高亮。但它不擅长回答“李白写的诗中用了‘月’这个意象的诗有哪些这些诗里又用了哪些其他意象”这种带图结构关系的递进式问题。它没法在倒排索引上自然地做多跳遍历。1.2 图数据库把“关系”变成了查询的一等公民知识图谱选择Neo4j核心就是它把实体和关系作为基础存储单元。在Neo4j里“李白”是一个节点“月”是一个节点“李白创作了包含月的诗作”就体现为诗人节点通过一条关系指向诗作节点诗作再通过一条关系指向意象节点。查询时只需要用Cypher把路径描述出来就可以了不需要写一堆join和嵌套子查询。举个例子同样问“李白关于月的诗”Cypher是这样写的MATCH (p:Poet {name: 李白})-[:CREATED]-(poem:Poem)-[:HAS_IMAGE]-(i:Image {name: 月}) RETURN poem.title, poem.content这条查询的可读性非常高任何人看了都知道它在干什么。而且如果后面想加新的关系只需要在匹配模式里多扩一段路径不需要改表结构这是图数据库在为问答系统建模时最舒服的地方。1.3 我的技术选型结论整个系统从上到下是这么分工的数据层Neo4j Community Edition存实体和关系。采集与清洗层Python requests BeautifulSoup负责把古诗词数据从公开语料站点抓下来并清洗。导入与访问层优先推荐Neo4j官方Python驱动neo4j-driver如果只是快速验证逻辑也可以用py2neo但要注意版本兼容。问答逻辑层纯Python实现做问句分类、实体识别和Cypher生成。服务层Flask搭一个轻量HTTP接口前端给一个最简单的HTML搜索框。这套选型有个很现实的好处每个模块都可以独立替换。你觉得问句分类写规则太死板后面可以换大模型你觉得离线数据量大了导入方式可以从逐条插入换成LOAD CSV批量导入。整体框架不用动。2. 古诗词本体设计实体、关系与Cypher建模知识图谱问答系统最核心的不是代码而是本体设计。说白了就是你想让系统回答哪些问题就建哪些节点和关系。一开始贪多求全会把自己累死我的习惯是先定义一个最小可用集合跑通了再扩充。2.1 五类实体节点覆盖80%的问答场景我做古诗词问答系统时第一版只用了五类实体诗人、朝代、诗作、意象、名句。诗人姓名、字号、生卒年、简介。朝代名称、起始年份、结束年份。诗作标题、正文、体裁五绝、七绝、五律、七律、词等。意象名称、含义、常用情感色彩。这里的意象是个宽泛概念“月”“酒”“花”“柳”“雁”都算本质是诗词里反复出现的可被检索的意象词。名句诗句原文、出处、释义。单独拎出来建实体是因为很多问答场景是“这句话出自哪里”“这句诗的作者是谁”从名句节点出发去关联诗作查询会非常直接。我特意把“体裁”“字数”做成了诗作节点的属性而不是单独一个“体裁”节点。因为用户问“李白写的五言绝句”时“五言绝句”更多是过滤条件而不是查询起点把它建模成属性查询起来反而简单后面用索引也能覆盖到。2.2 关系设计的三个核心原则关系是本体设计的灵魂我给自己定的原则只有三条够用、不冗余、方向清晰。第一版建了这些关系(作者)-[:CREATED]-(作品)谁写的这首诗。(作品)-[:BELONGS_TO]-(朝代)作品属于哪个朝代。(作者)-[:LIVED_IN]-(朝代)诗人生活在哪个朝代。(作品)-[:HAS_IMAGE]-(意象)诗里用了什么意象。(作品)-[:HAS_LINE]-(名句)这首诗包含哪些名句。(名句)-[:FROM]-(作品)这句出自哪首诗方向与HAS_LINE相反便于从名句反查。关系方向的设计要刻意统一。比如“属于”我统一建模成(子)-[:BELONGS_TO]-(父)不管是作品属于朝代还是诗人属于朝代都用同一个关系类型避免查询时记一堆名字。2.3 在Neo4j里建约束防止脏数据实体建好之后第一件事不是导入数据而是给关键字段建唯一约束和索引。这一步很多人偷懒结果导完数据发现同一个诗人有十几个节点问答时一查出来一堆重复结果。CREATE CONSTRAINT poet_name_unique IF NOT EXISTS FOR (p:Poet) REQUIRE p.name IS UNIQUE; CREATE CONSTRAINT poem_title_unique IF NOT EXISTS FOR (p:Poem) REQUIRE p.title IS UNIQUE; CREATE INDEX image_name_index IF NOT EXISTS FOR (i:Image) ON (i.name); CREATE INDEX dynasty_name_index IF NOT EXISTS FOR (d:Dynasty) ON (d.name);用MERGE而不是CREATE来插入节点配合唯一约束可以很大程度上避免重复实体。这个经验在数据量几千到几万条时非常管用。3. 数据准备从公开语料到入库最花时间但最值得做扎实的一步很多做问答系统的人把精力全放在算法上结果数据一塌糊涂。古诗词这种领域数据本身就有大量异体字、繁体字、标点混乱的问题不做清洗就入库后面问答怎么跑都别扭。3.1 数据来源与采集方式我是从公开的诗词语料站采集的包括古诗文网这类允许非商业用途访问的站点以及GitHub上一些开源诗词数据集。采集时用了requests加BeautifulSoup控制请求频率设置1到2秒的间隔避免对服务器造成压力。采集字段我控制在导入Neo4j所需的最小集合诗名、朝代、作者、正文、类别。真正的清洗我放在采集之后而不是采集过程中因为清洗逻辑经常会调分开做省得反复请求数据源。3.2 清洗规则繁简、标点、异体字一个都不能少第一轮清洗是统一中文标点把全角英文标点、半角标点统一成标准中文标点把空白字符压缩掉。第二轮是繁简转换我用opencc-python这个库把从不同来源扒下来的繁体内容统一成简体。第三轮是处理异体字和通假字这个没法全自动解决我的做法是维护一个小词表遇到常见异体字批量替换。清洗完的示例数据格式大概是这样的title,author,dynasty,content,tags 静夜思,李白,唐,床前明月光 疑是地上霜 举头望明月 低头思故乡,思乡|月亮3.3 导入Neo4j的三种方式我推荐这样做Neo4j导入数据主要有三种方式逐条用Python写入、LOAD CSV批量导入、neo4j-admin import离线导入。逐条写入适合小规模验证数据量两三千条的时候可以用但每条都走Python网络请求导两万条能等到怀疑人生。LOAD CSV是最实用的折中方案数据量几万条以内都合适。neo4j-admin import适合十几万条以上的冷启动但要求数据库停止服务格式也更严格。我实际项目里用的是LOAD CSVCypher长这样LOAD CSV WITH HEADERS FROM file:///poems.csv AS row MERGE (d:Dynasty {name: row.dynasty}) MERGE (p:Poet {name: row.author}) MERGE (poem:Poem {title: row.title}) SET poem.content row.content MERGE (p)-[:CREATED]-(poem) MERGE (p)-[:LIVED_IN]-(d) MERGE (poem)-[:BELONGS_TO]-(d)需要注意file:///指向的是Neo4j服务器所在机器上的import目录不是Python所在机器。本地用Docker装Neo4j时要把csv文件挂载进容器的import目录这个细节折腾了我不少时间。3.4 意象和名句的抽取意象和名句关联我是半自动处理的。名句靠的是公开的《唐诗三百首》名句对照表手动整理成一个csv再根据诗句文本去匹配诗作。意象则是维护了一个意象词表比如“月、明月、月光”“柳、杨柳”“酒、杯中物”然后用正则从诗作正文里匹配匹配上了就在诗作和意象节点之间建一条HAS_IMAGE关系。这种方式看起来原始但胜在可控、可解释。后面如果引入大模型来做意象抽取也是在这个结构上替换一个模块而已不影响整体。4. 问答引擎把“你说的人话”翻译成“图能懂的Cypher”问答引擎是整个系统的灵魂。我的实现思路很传统但非常稳先对问句做分类再从问句里识别出实体最后根据“意图实体”拼装Cypher语句。4.1 问句分类先搞清用户到底在问什么我归纳了六种最常见的问法覆盖了我这个系统能回答的几乎所有问题问句类型典型问法意图标识诗人问诗李白写过哪些诗POET_TO_POEMS意象过滤诗李白关于月亮的诗POET_IMAGE_POEMS朝代问诗人唐朝有哪些著名诗人DYNASTY_POETS名句溯源床前明月光是谁写的LINE_TO_POET诗作详情静夜思的全文是什么POEM_DETAIL诗人信息杜甫是哪个朝代的POET_INFO实现上我用的是一个规则分类器每个规则是一组关键词和正则的组合。比如问句里同时出现人名和“写过”“哪些”“诗”时就分类为POET_TO_POEMS。这个方法看起来土但胜在透明、好调试而且古诗词问句的句式相对固定规则写好后准确率能到85%以上。后面想上模型也可以用BERT文本分类替换这一层接口不用变。4.2 实体识别词典匹配加正则兜底实体识别在这个场景下我用的是词典匹配法。提前把诗人名、朝代名、意象名、诗名都加载进一个Trie树然后把用户问句拿去做最长正向匹配。这里有个坑意象词经常有名和字的区分。“月”和“明月”都能匹配到同一个意象我选择在匹配时做归一化匹配到“明月”后查找意象词表时映射到“月”这个标准名。另外“长干行”里有个“干”是多音字词典匹配不涉及读音倒是不用担心但要小心“李白”和“白”这种包含关系——匹配“白”的时候先判断前面是不是“李”字避免把“李白”拆成“李”“白”。4.3 从意图到Cypher的生成逻辑分类和实体识别做完后就进入Cypher生成阶段。我维护了一个映射表每种意图对应一段Cypher模板模板里的实体参数用placeholder占位。TEMPLATES { POET_TO_POEMS: MATCH (p:Poet {name: $poet})-[:CREATED]-(poem:Poem) RETURN poem.title AS title, poem.content AS content LIMIT 20 , POET_IMAGE_POEMS: MATCH (p:Poet {name: $poet})-[:CREATED]-(poem:Poem)-[:HAS_IMAGE]-(i:Image {name: $image}) RETURN poem.title AS title, poem.content AS content LIMIT 20 , DYNASTY_POETS: MATCH (d:Dynasty {name: $dynasty})-[:LIVED_IN]-(p:Poet) RETURN p.name AS poet_name, p.bio AS bio LIMIT 30 , LINE_TO_POET: MATCH (line:FamousLine {content: $line})-[:FROM]-(poem:Poem)-[:CREATED]-(p:Poet) RETURN p.name AS poet_name, poem.title AS title, line.content AS line , POEM_DETAIL: MATCH (poem:Poem {title: $title}) RETURN poem.title AS title, poem.content AS content , POET_INFO: MATCH (p:Poet {name: $poet}) RETURN p.name AS name, p.bio AS bio, p.dynasty AS dynasty }这个模板化设计的最大好处是以后每新增一种问答能力只需要加一个模板外加对应的分类规则不需要动查询引擎其他部分。4.4 一个完整的问答流程示例我把整个answer函数走一遍拿“李白写过哪些关于月亮的诗”当例子。第一步问句分类。规则引擎发现问句同时包含人名“李白”和词“月亮”而且结构是“谁写过……的诗”于是分类为POET_IMAGE_POEMS。第二步实体识别。从问句里抽出两个实体诗人李白意象月月亮归一化后映射到“月”。第三步模板渲染。选择POET_IMAGE_POEMS模板把李白和月填进去得到这一条CypherMATCH (p:Poet {name: 李白})-[:CREATED]-(poem:Poem)-[:HAS_IMAGE]-(i:Image {name: 月}) RETURN poem.title AS title, poem.content AS content LIMIT 20第四步执行查询。用neo4j驱动把Cypher发到数据库拿到结果后转成JSON字典列表交给后端接口返回。整个过程看起来简单但工程量都在“规则怎么定得稳”和“实体识别怎么减少误召回”上。我建议你在自己的项目里也这么一步步拆别想着一步到位用大模型一股脑干完所有事。5. 把系统跑起来Python驱动选择与后端接口实现问答逻辑写好后要让它对外提供服务还需要接一个HTTP接口。我遇到过不少卡在这一步的读者所以把驱动选型和接口细节单独拿出来讲。5.1 py2neo还是官方驱动怎么选网上不少老教程用py2neo这个库用起来确实方便Node、Relationship这些对象模型非常直观。但需要注意py2neo在2020年之后更新频率明显降低对Neo4j 4.4以上版本的支持做得并不好到了Neo4j 5.x有些版本直接连不上。如果你的项目依赖包里有py2neo并且你的Neo4j是5.x建议尽快换掉。我推荐使用Neo4j官方驱动neo4j-driver。它虽然写起来更啰嗦一点但胜在稳定、跟版本走、性能好。安装方式pip install neo4j然后创建一个数据库连接类我把好用的连接写法放在这里from neo4j import GraphDatabase class Neo4jConnector: def __init__(self, uri, user, password): self.driver GraphDatabase.driver(uri, auth(user, password)) def close(self): self.driver.close() def run_query(self, cypher, paramsNone): with self.driver.session() as session: result session.run(cypher, params or {}) return [record.data() for record in result]5.2 Flask后端接口设计后端接口我用了Flask因为它足够轻写一个POST接口只要十几行代码。from flask import Flask, request, jsonify app Flask(__name__) qa_engine PoemQAEngine( connectorNeo4jConnector(bolt://localhost:7687, neo4j, yourpassword) ) app.route(/api/query, methods[POST]) def handle_query(): data request.get_json() question data.get(question, ).strip() if not question: return jsonify({error: question is empty}), 400 try: answer qa_engine.answer(question) return jsonify({question: question, answer: answer}) except Exception as e: return jsonify({error: str(e)}), 500 if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)前端就一个简单的HTML输入框加按钮点击后用fetch发请求把返回的诗名和正文渲染在页面上。这部分不用写复杂框架能演示即可。5.3 多跳查询与结果处理的细节问答系统里有一类问题比较难处理就是多跳查询。比如“唐朝的诗人写过哪些关于酒的诗”这个需要从朝代节点出发找到唐朝诗人再到诗作再到意象。对应的Cypher是MATCH (d:Dynasty {name: 唐})-[:LIVED_IN]-(p:Poet)-[:CREATED]-(poem:Poem)-[:HAS_IMAGE]-(i:Image {name: 酒}) RETURN p.name, poem.title, poem.content这种多跳查询在图数据库里完全不是问题但要注意返回结果的去重。因为一个诗人可能写了好几首关于酒的诗而一个意象可能在一首诗里出现多次图路径搜索时会出现多条路径对应同一首诗的情况。我在结果后处理时统一做了去重按诗作标题作为唯一键。还要注意返回的节点属性里可能带有Neo4j内部生成的elementId字段对外接口返回时要过滤掉避免把和用户无关的数据库内部信息暴露出去。6. 我在实际调试中踩过的坑与最后的性能调优这一节的内容是我最想跟你们分享的。上面所有章节的方案我都可以重新实现一遍但下面这些坑是真正跑过一次才能积累出来的。6.1 坑一Docker部署Neo4j时的CSV挂载路径我用Docker跑Neo4j时LOAD CSV一直报文件找不到。后来发现file:///路径是相对于容器内部来说的不是宿主机的。正确的做法是在docker run时挂载一个本地目录到容器内的import目录docker run -d \ --name neo4j \ -p 7474:7474 -p 7687:7687 \ -v /home/user/neo4j/import:/var/lib/neo4j/import \ -e NEO4J_AUTHneo4j/yourpassword \ neo4j:5.26我把poems.csv放到宿主机/home/user/neo4j/import下容器内路径就是file:///poems.csv。如果你的CSV文件包含中文还需要确认文件保存为UTF-8编码最好别带BOM否则第一列字段名会多一个不可见字符导致LOAD CSV匹配不上。6.2 坑二同名诗人和同名诗作的资料合并古诗词数据里同名同姓的诗人很少但同名诗作非常多《春思》《秋思》《感怀》这种标题一抓一把。我在导入数据时一开始用CREATE导致库里出现二十多个《春思》节点。后来改成MERGE加唯一约束但同名诗作还是会被合并成一个节点这就变成问题了。我的解法是在诗作唯一约束上不要只依赖标题而是用title_author组合字段CREATE CONSTRAINT poem_unique IF NOT EXISTS FOR (poem:Poem) REQUIRE (poem.title, poem.author) IS UNIQUE;导入时也把title和author两个字段拼成一个逻辑唯一键来处理。这个调整之后数据质量立刻提升了一个档次。6.3 坑三实体识别时的“李”和“白”被拆开实体识别里最烦人的就是短词误匹配。“李白”是诗人“李”也是姓氏“白”是个常见字。我用最朴素的字典匹配时问“李白写过什么”能正确识别但问“唐代诗人李白”时“李”和“白”会被拆成两个token再分别匹配导致整个问句解析失败。解决方法是做优先长词匹配。先匹配诗人词表里的“李白”命中后就不再对“李”和“白”做单词匹配。具体实现上我的Trie树节点只有词表内完整词才能命中匹配时每次都尝试尽可能多的字符匹配成功就跳过整个词。这个方法虽然简单但在我的场景里非常管用。6.4 性能调优索引和LIMIT一个都不能少数据量小的时候Neo4j查起来飞快但数据量到了几万条如果没建索引每次都全库扫描问答接口的响应时间会明显变慢。我的经验是给所有查询里的等值匹配字段建索引包括诗人name、朝代name、意象name、诗作title、名句content。建索引的Cypher在上文已经写过了直接用就行。另外每个查询模板后面都带一个LIMIT防止结果集过大把内存撑爆。特别是“唐朝有哪些诗人”这类问题如果数据全可能返回上千条记录前端展示也用不上那么多LIMIT 30非常合理。6.5 后续可以怎么扩展这套系统跑通之后扩展空间很大。最近我把问句分类从纯规则换成了基于BERT的文本分类模型效果提升很明显灵活问法也能处理了。同时也在实验把意象抽取换成大模型来做用LLM从诗作正文里抽意象实体和情感倾向再写回Neo4j这样比维护人工词表的覆盖面广很多。另外一个值得做的方向是给系统接入向量检索把诗作正文用embedding模型向量化后存进向量数据库形成“知识图谱向量库”的双路问答图谱负责精确关系查询向量库负责语义相似扩展。比如用户问“表达思念家乡的诗”图谱不一定能直接回答但向量检索可以根据语义把《静夜思》《九月九日忆山东兄弟》这类诗捞出来效果会自然很多。我在实际部署这个系统时最大的体会是把问题的分类结构设计得清晰一点后面所有模块都轻松。古诗词问答这种领域不要迷信复杂的算法先把数据质量做扎实把关系建模做清楚系统就已经能解决八成的问题了。剩下两成再慢慢用模型和向量检索去补齐每一步都能看到实实在在的提升。本文还有配套的精品资源点击获取