简介:面向知识图谱课程大作业与自然语言处理入门学习者,基于Neo4j的古诗词问答系统源码包,完整覆盖从数据采集、知识抽取到图谱构建与问答实现的开发流程。资源共43个文件,含11个Python脚本(负责爬虫、数据清洗、实体识别、图谱写入等)、11个CSV数据文件(训练与语料数据)、13个TXT文本及JSON模型文件(分类模型与词表),压缩包仅828KB,结构紧凑便于快速部署。目前已有298人学习下载。通过该包可掌握Neo4j图数据库建模、基于模板的古诗词知识问答、机器学习文本分类等关键技能;附带训练好的模型与词典,下载后修改路径即可运行,适合作为课程设计参考或深入理解知识图谱落地的实践样例。
1. 基于知识图谱的古诗词问答系统:为什么用 Neo4j 而不是全文检索
当用户问出“李白写过哪些带‘剑’的诗句”时,传统系统要在作者表、作品表、诗句表之间多次关联,再用 LIKE 去扫“剑”字,勉强查到结果却回答不了下一个问题“他当时是在哪里写的”。基于知识图谱的古诗词问答系统把诗人、作品、意象、典故、地名都建模成节点与关系,多跳问题变成一条 Cypher,Neo4j 正是这类图查询最趁手的存储。这篇笔记从古诗词本体设计讲到 Cypher 建库,再到把用户问题翻译成图查询的 Python 管道,最后梳理这类项目里最容易翻车的地方。适合做 NLP 课程设计、知识图谱入门实践,以及想把手头诗词数据做成 AI 问答 Demo 的开发者。
2. 先把诗词变成图:古诗词本体建模与关系设计
2.1 六个节点类型:一首诗不再是一行宽表
图建模的第一步不是写 Cypher,而是决定哪些东西是节点。一次常见的失误是把诗句、诗作、作者揉进一张大宽表,等用户问到“哪句诗用了哪个典故”时,SQL 查询会迅速变得不可维护。基础做法是拆出六个核心标签:
| 标签 | 关键属性 | 示例数据 |
|---|---|---|
| Poet | name, aliases, era, birth_year | 李白(太白、青莲居士,唐) |
| Poem | pid, title, dynasty, genre, content | 《将进酒》 |
| Verse | vid, text, order_index, is_famous | 君不见黄河之水天上来 |
| Imagery | name, category, description | 剑(兵器)、明月(自然) |
| Place | name, modern_city, note | 庐山(今九江) |
| Allusion | name, source_text, note | 庄生晓梦 |
Poem 与 Verse 分开是最容易被忽略的一步。只保留 Poem 的情况下,“带‘剑’的诗句”只能对整首做子串搜索,无法回答“这句诗出自哪首诗”,也无法统计某个意象在一首诗里出现几次。把每一联或每一句拆成 Verse 节点,诗句层面的问答才有可靠抓手。
Place 和 Allusion 属于加分项。数据量小、急着跑通问答时,先只建 Poet、Poem、Verse、Imagery 四个标签,地名和典故后续再补;数据量允许的话,建议一次建全六个,因为补一个标签意味着把整张图重新遍历一遍,后期迁移成本不低。另一个纠结是朝代做成属性还是节点。我的判断标准很简单:如果只做“查出唐诗”这种过滤,朝代属性就够;如果要做“初唐到盛唐有哪些有影响力的诗人”这类跨朝代统计,朝代提成节点更合适。课程设计阶段从属性做起,不丢人。
2.2 关系设计:关系名就是你要回答的那类问题
关系建模时我会反向检验:每定义一个关系,都要想清楚它能让哪条问答成立。以四条核心关系为例:
| 关系 | 起点→终点 | 语义 | 建议属性 |
|---|---|---|---|
| WROTE | Poet→Poem | 作者写了这首诗 | confidence(多作者争议时用) |
| CONTAINS | Poem→Verse | 诗句从属于诗 | order_index |
| HAS_IMAGERY | Verse→Imagery | 诗句出现某个意象 | evidence(完整原句) |
| MENTIONS | Poem→Place | 诗中出现某地名 | frequency |
这里有一个容易想歪的点:意象关系挂在 Verse 还是 Poem 上。“这首诗借酒抒情”是整首诗层面的判断,可以把 HAS_IMAGERY 挂在 Poem;但“这句诗里有剑”是句子层面的判断,必须挂在 Verse。两者混用会导致用户搜“带剑的诗句”时,返回一堆“整首提到剑”的诗,精度明显下降。
我个人的取舍是:严格区分证据粒度。Verse 上的证据精确到联句,Poem 上的证据只是整首的主题标签。问答系统优先回答证据粒度更细的结果,粒度细的查不到,才退回 Poem 的粗粒度关系。图模型比关系模型强的地方也在这里:加一跳就是一次新查询,不需要改表结构。比如从“李白写了哪些诗”到“这些诗里哪些提到过酒”,只在中间加一个 Imagery 节点就能实现。
2.3 属性取舍:哪些进字段、哪些进关系
属性分配上没有万能公式,但我习惯遵守三条原则。
第一,只有“经常被拿来过滤或排序”的字段才作为独立属性。比如 genre、dynasty 值得作为 Poem 属性,因为用户常问“有哪些五言绝句”;而作者的详细生平小传属于低频展示字段,放进 bio 字符串即可,不需要为它单独建索引。
第二,长文本和检索文本要分开。poem.content 保留原文排版,用于展示;同时生成一个 verse_text 字段,去掉全角空格和标点,只负责匹配。清洁字段在导入时算好,不要在查询时再用函数现场清洗,否则每次查询都白付一遍 CPU。
第三,关系到哪层、证据到哪层,就在那层放属性。WROTE 关系的 confidence 只在多作者存疑时出现,不要在 Poem 节点上也存一个“作者可信度”。属性重复放在两个地方,早晚会不一致,这是图谱项目里很典型的“玄学 bug”。别名同理:如果别名只是别名,放进 Poet 的 aliases 数组;如果别名本身还有生平或关系数据,才考虑提成独立 Alias 节点。
提示:关系唯一性在 Neo4j 里没有内置约束,脚本幂等性要靠导入方式保证,这一点第 3 章会专门展开。
3. Neo4j 建库实战:约束、批量导入与索引对齐
3.1 先建唯一约束:给实体去重兜底
拿到 neo4j.zip 这类项目包时,我一般先找初始化脚本,而不是直接跑 Python 端。把项目打包分发给别人时,初始化顺序决定了对方能不能五分钟跑通。我的组织方式固定为:约束脚本在前、CSV 数据其次、问答代码最后。
Neo4j 的节点去重没有天然主键,MERGE 依赖你指定唯一的属性。所以建库第一条语句应该是约束,而不是 LOAD CSV。约束能同时带来性能收益,WHERE name = $name 的查询会直接走索引。
CREATE CONSTRAINT poet_name IF NOT EXISTS FOR (p:Poet) REQUIRE p.name IS UNIQUE; CREATE CONSTRAINT poem_pid IF NOT EXISTS FOR (poem:Poem) REQUIRE poem.pid IS UNIQUE; CREATE CONSTRAINT verse_vid IF NOT EXISTS FOR (v:Verse) REQUIRE v.vid IS UNIQUE; CREATE TEXT INDEX poem_title_index IF NOT EXISTS FOR (poem:Poem) ON (poem.title); CREATE TEXT INDEX verse_text_index IF NOT EXISTS FOR (v:Verse) ON (v.text);注意 REQUIRE 是 Neo4j 5.x 的语法,如果用 4.x,要换成ASSERT p.name IS UNIQUE。IF NOT EXISTS让脚本可以重复执行,适合放到项目初始化的文件里。TEXT INDEX 是给 poem.title 和 verse.text 这类支持子串匹配的字段用的;普通 B-tree 索引对CONTAINS帮不上忙。
3.2 LOAD CSV 两段式导入:先节点后关系
导入最大的坑在于关系必须引用已存在的节点。常见做法是分两遍跑:第一遍只建节点,第二遍建关系。诗人的 CSV 大致长这样:
name,aliases,courtesy_name,birth_year 李白,太白|青莲居士,,701 杜甫,子美|少陵野老,字子美,712节点导入语句用 MERGE 去重,再用 ON CREATE 写初始值:
LOAD CSV WITH HEADERS FROM 'file:///poets.csv' AS row MERGE (p:Poet {name: trim(row.name)}) ON CREATE SET p.aliases = split(coalesce(row.aliases, ''), '|'), p.birth_year = toIntegerOrNull(row.birth_year);split 把“太白|青莲居士”拆成数组,后续别名匹配可以直接用;coalesce 处理空值,避免 null 字段进图。诗作数据类似,pid 尽量保持稳定,就算作者名改成繁体,pid 不变,约束就不会制造重复诗作。
pid,title,poet,dynasty,genre,content p0001,静夜思,李白,唐,五绝,床前明月光 疑是地上霜... p0002,登高,杜甫,唐,七律,风急天高猿啸哀...关系导入一进来就要面对重复执行的问题:
LOAD CSV WITH HEADERS FROM 'file:///poems.csv' AS row MATCH (p:Poet {name: trim(row.poet)}) MERGE (poem:Poem {pid: row.pid}) ON CREATE SET poem.title = trim(row.title) MERGE (p)-[:WROTE]->(poem);最后一行是完整路径的 MERGE,不是先 MATCH 再 CREATE。如果写成MATCH (p), (poem) CREATE (p)-[:WROTE]->(poem),脚本跑第二遍时 WROTE 关系就会直接翻倍,而 MERGE 路径不会。数据量大时,可以在 LOAD CSV 前面加USING PERIODIC COMMIT 500控制事务大小,避免一次事务写太多内存爆炸。
3.3 索引对齐查询模式:不是每个字段都值得建索引
索引不是多多益善。古诗词问答里重复度高的查询模式只有几类,我按查询模式决定建什么索引:
| 查询模式 | 应该用的索引 |
|---|---|
| MATCH (p:Poet {name: '李白'}) | 唯一约束自动建索引 |
| WHERE poem.title CONTAINS '静夜' | TEXT INDEX(poem.title) |
| WHERE v.text CONTAINS '明月' | TEXT INDEX(verse.text) |
| MATCH (poem:Poem {genre: '五绝'}) | B-tree Index(genre) |
如果用户经常问“某朝代的诗”,可以把 dynasty 加进 Poem 的 B-tree 复合索引;但如果只是偶尔过滤,复合索引带来的写入开销就不划算。导入完成后,用EXPLAIN看一条查询是否命中索引:EXPLAIN MATCH (v:Verse) WHERE v.text CONTAINS '明月' RETURN v.vid。执行计划里出现 NodeIndexSeek 或 NodeIndexScan 说明索引生效,出现 NodeByLabelScan 则说明在扫全量节点,需要检查字段名和索引名是否对齐。中小型 Demo 保持上述三四个索引即可,中文场景里 TEXT INDEX 对子串查找的帮助有限,数据量到几十万行以后,我一般把诗句全文挪进检索系统或向量索引,让 Neo4j 专心做关系多跳。
4. 问答核心:用 Python 把用户问题翻译成 Cypher
4.1 实体识别:词典优先于分词模型
问答的第一步是把用户问题里的“李白”“明月”抓出来。很多人一上来就上 BERT 序列标注,其实古诗词人名、意象是封闭集合,词典匹配更快、更可控,也更容易解释给其他人听。
import re POET_NAMES = ["李白", "杜甫", "苏轼", "李清照", "辛弃疾"] IMAGERY_WORDS = ["明月", "剑", "酒", "雪", "梅花", "孤帆"] ALIAS = {"太白": "李白", "子美": "杜甫", "东坡": "苏轼", "稼轩": "辛弃疾"} def recognize_entities(text): text = re.sub(r"[,。!?、\s]+", "", text) found = {"poet": None, "imagery": None} for alias, name in ALIAS.items(): if alias in text: found["poet"] = name for name in POET_NAMES: if name in text: found["poet"] = name for word in IMAGERY_WORDS: if word in text: found["imagery"] = word return found这段代码刻意没用 jieba,原因在于“床前明月光”这类句子很容易被切成“床前”“明月”“光”,而“明月”作为意象词必须整体命中,直接子串匹配在小规模场景反而更鲁棒。如果你是做课程设计,可以把 POET_NAMES 改成从数据库动态加载:启动时查一次全量诗人名,内存里维护一份名单,这样新增诗人就不用改代码。
4.2 意图识别:几组关键词就能覆盖七成问题
实体识别解决“提到谁”,意图识别解决“要问它什么”。基于规则的意图分类只要关键词设计得清楚,在小规模问答里比模型更实用:
def classify_intent(question, entities): if entities["poet"] and any(k in question for k in ("诗", "作品", "写")): return "poet_poems" if entities["imagery"] and any(k in question for k in ("诗", "句", "含", "提到")): return "poem_by_imagery" if entities["poet"] and any(k in question for k in ("介绍", "生平", "谁")): return "poet_info" return "fallback"注意意图判断的顺序:先判断带实体的意图,把“李白写过哪些诗”和“哪些诗含明月”分开;拿不准的走 fallback,明确告诉用户答不上来,而不是硬套一个模板返回空结果。这套方法能覆盖课程设计和 Demo 的七成常见问法,剩下的交给后续同义词扩展和模板扩充。
4.3 模板映射到 Cypher:用参数而不是拼字符串
意图确定以后,把槽位填进预写好的 Cypher 模板。这里最关键的安全习惯是使用参数($poet、$imagery),不要直接把用户输入拼进查询串:
TEMPLATES = { "poet_poems": ( "MATCH (p:Poet) WHERE p.name = $poet " "MATCH (p)-[:WROTE]->(poem:Poem) " "RETURN poem.title AS title ORDER BY poem.pid LIMIT $limit" ), "poem_by_imagery": ( "MATCH (v:Verse)-[:HAS_IMAGERY]->(i:Imagery) WHERE i.name = $imagery " "MATCH (v)<-[:CONTAINS]-(poem:Poem) " "RETURN poem.title AS title, v.text AS line LIMIT $limit" ), "poet_info": ( "MATCH (p:Poet) WHERE p.name = $poet " "RETURN p.name AS name, p.era AS era, p.birth_year AS birth_year" ), }LIMIT $limit 是防手滑的重要手段。图查询如果忘写 LIMIT,遇上“李清照写了多少首词”这类大结果集,会把几百行一次性拉进内存。默认限制 10 条足够,展示时再提示总数。模板里只写 MATCH 和 RETURN,天然不会产生写操作,这是基于模板方案对比大模型生成方案的一大优势。
4.4 问答主流程与答案格式化
把上面的函数串起来,就是一个最小可运行的问答类:
from neo4j import GraphDatabase class PoemQA: def __init__(self, uri, user, password, database="neo4j"): self.driver = GraphDatabase.driver(uri, auth=(user, password)) self.database = database def answer(self, question, limit=10): entities = recognize_entities(question) intent = classify_intent(question, entities) if intent == "fallback": return "没听懂,试试问我「李白写过哪些诗」或「带明月意象的诗句」" cypher = TEMPLATES[intent] with self.driver.session(database=self.database) as session: records = session.run(cypher, **entities, limit=limit).data() if not records: return "没有查到,换个实体再试试" return self._format(intent, records) def _format(self, intent, records): if intent == "poet_poems": return "找到 %d 首:%s" % (len(records), ";".join(r["title"] for r in records)) if intent == "poem_by_imagery": return "例如:「%s」出自《%s》" % (records[0]["line"], records[0]["title"]) if intent == "poet_info": r = records[0] return "%s,%s,生年约 %s" % (r["name"], r["era"], r["birth_year"])session.run 的后续参数会把同名变量传给 $poet、$imagery、$limit,天然避免拼接注入。服务退出时记得调用 self.driver.close(),否则容器环境里连接数会缓慢堆积。如果你之后接入大模型生成 Cypher,这条只读白名单和参数化习惯依然适用,只是把模板换成动态生成后再做一遍校验。
5. 避坑:古诗词问答系统最容易翻车的 5 个环节
5.1 字号没进图:问“子美的诗”直接落空
现象:用户输入“子美写了哪些诗”,实体识别把“子美”映射成杜甫,但数据库里只有 name="杜甫",WHERE p.name = $poet 查不到。
原因:建模时只给 Poet 设置了 name,字、号、别称全部漏掉。古诗词领域特别明显,读者习惯用“太白”“东坡”“幼安”称呼诗人,而数据源里往往只给了本名。
解决:给 Poet 增加 aliases 数组属性,导入时把字、号、别称全部写入;识别阶段先查别名字典再查正名,识别成功后统一转成规范名。后续再遇到新别称,只需要扩充词典,不需要改图结构。这里还有一个隐藏细节:用户说“李白字太白,给我讲讲他”,子串会同时命中“李白”和“太白”,我的做法是别名映射只做一次,后续统一用规范名,不允许一个字段里同时出现两个候选值。
5.2 导入脚本跑两遍,WROTE 关系翻倍
现象:初始化脚本执行第二次,诗作节点数量不变,WROTE 关系数量变成两倍。这种问题肉眼很难发现,因为查询结果看起来是一样的,只有统计关系数时才会吓一跳。
原因:节点用了 MERGE 去重,关系却用了 CREATE 或者先 MATCH 再 CREATE。
解决:关系必须用完整路径 MERGE,例如MERGE (p)-[:WROTE]->(poem),并确保两端已经通过唯一属性匹配。想把当前数据彻底重置,就显式删除关系再重导:MATCH (:Poet)-[r:WROTE]->(:Poem) DELETE r。我建议在每次导入后跑一条固定检查语句:MATCH (:Poet)-[r:WROTE]->(:Poem) RETURN count(r),然后和 CSV 行数比对,把这句话写进测试脚本,关系翻倍的问题会在第一次跑数据时就暴露。
5.3 “床前明月光”把“床”识别成意象
现象:用户问“哪些诗写到床”,系统返回《静夜思》,还把“床”当成核心意象。单看结果好像没错,但问“床”明显不是用户真正关心的诗歌主题。
原因:意象词典把常用字“床”收进去了,而“床”在这句里更多是生活物件,并非稳定情感意象;子串匹配又让它命中。
解决:把“床”这类歧义高频词移出意象词典,加入停用词表;对意象至少要求两个字,单字词除非人工确认否则不进词典。更稳妥的是维护一份负例清单,每次问答抽测时把误报记进去,迭代几轮后准确率会明显上升。不要指望自动扩展的意象词典能一步到位,古诗词意象“酒、月、柳、雁”看着简单,落到具体句子时边界非常模糊。
5.4 版本异文导致诗句匹配不上
现象:用户背出“床前明月光”,库里的古本却写作“床前看月光”,CONTAINS 匹配失败;同样的情况还有“唯见长江天际流”和“惟见长江天际流”。
原因:同一首诗流传过程中存在异文,教材通行本和古籍版本不一致,用户背的版本往往和入库版本不同。
解决:入库时额外生成一个 search_text 字段,统一做繁体转简体、去标点、异体字映射,并把常见异文归一到同一个规范形式;查询时也使用同样的规范化函数。字段在导入阶段算好,查询阶段不再做清洗,性能更可控。展示字段保留原文,检索字段负责匹配,两者各司其职就不会出现“为了兼容异文把界面也改成错别字”的尴尬。
5.5 生成式 Cypher 不受控:白名单和只读权限
现象:接入大模型自动写 Cypher 后,某次返回了 DELETE 语句,差点清空整张图。
原因:让模型生成的 Cypher 直接跑在管理员账号上,模型“幻觉”出了非查询语句。
解决:为问答服务单独建账号,并在应用层加白名单校验。如果用的是 Neo4j Enterprise,可以建只读角色:
CREATE ROLE qa_reader; GRANT TRAVERSE ON GRAPH * TO qa_reader; GRANT READ {name} ON GRAPH * TO qa_reader; CREATE USER qa_robot SET PASSWORD 'only-read-password' SET PASSWORD CHANGE NOT REQUIRED; GRANT ROLE qa_reader TO qa_robot;注意角色管理在 Neo4j Community 版不可用,社区版主要靠应用层白名单和参数化兜底。同时应用层拦截第一关键词:
import re def assert_readonly(cypher): if not re.match(r"^(MATCH|CALL)\b", cypher.strip().upper()): raise ValueError("only read query is allowed")参数化是第一道防线,白名单是第二道,只读角色是第三道。三层都上,不要嫌多。
6. 进阶:让系统经得起追问,而不是只答预设问法
6.1 问法归一化:把“诗”和“作品”看成同一个意图
规则问答最常见的瓶颈是同义替换。可以把近义词在入口处归一化:
SYNONYMS = {"诗": "作品", "诗句": "句子", "诗篇": "作品", "提到": "含"} def normalize_question(question): for src, dst in SYNONYMS.items(): question = question.replace(src, dst) return question在 recognize_entities 之前调用 normalize_question,原本“杜甫的诗篇有哪些”会转成“杜甫的作品有哪些”,直接命中已有意图。每加一个同义词,就等于扩展一批问题覆盖,比训练意图分类模型更快见效。
6.2 用 PROFILE 复查每次新增查询
新增一个查询模板后,不要只跑一遍看结果对不对,还要看执行计划。比如:
PROFILE MATCH (v:Verse)-[:HAS_IMAGERY]->(i:Imagery {name:"明月"}) RETURN v.text LIMIT 10;如果 db.hits 接近图谱里 Verse 节点总数,说明索引导航没有生效,查询在扫全量节点。此时检查 Imagery 的 name 上有没有唯一约束或索引,以及 Cypher 里是否写成了WHERE i.name = $name而不是WHERE i.name CONTAINS $name。前者才走索引,后者只能子串匹配。这个习惯能帮你发现很多“结果对但性能差”的隐患。
6.3 把词库和导入脚本纳入版本管理
最后给一个建议:诗人的别名表、意象词典、停用词表、CSV 转 Cypher 的脚本,全部放进 Git。新数据进来先跑一遍LOAD CSV ... RETURN count看行数是否符合预期,再跑一遍导入脚本;新增意图时在同一批回归问题上跑一遍,确认“诗”和“诗句”不会答混。
我现在的做法是每次扩充前先把 CSV 里的全角空格和空行洗掉,再跑约束脚本;生成式 Cypher 只允许在带只读账号的测试环境里试。这套流程是从几次翻车里换来的血泪经验,希望帮到你。
本文还有配套的精品资源,点击获取