简介:基于知识图谱与Neo4j图数据库的电影知识问答系统,是一套面向高校大作业、毕业设计及Python学习者的完整项目方案。系统涵盖数据采集、知识存储与问答交互等模块,后端采用Flask结合爬虫完成电影数据获取,前端通过小程序页面呈现,清晰演示了从实体关系建模到图数据库查询的落地流程。资源包共55个文件,以Python脚本、CSV数据文件、JSON配置和Markdown文档为主,同时包含SVG图、前端JS/WXML/WXSS页面及PNG图片,覆盖后端接口、数据导入、图查询和界面展示等环节,压缩包大小5.77MB,目录结构规整便于按序研读。已有424人学习,适合用作用业参考或毕设起步模板。借助该资源可快速理解知识图谱构建的关键步骤,掌握Neo4j在问答系统中的应用方式,并可从代码中延伸出推荐、检索等二次开发思路。
1. 从一个能跑通的知识图谱问答系统说起
做过知识图谱相关 Python 大作业或者毕业设计的朋友应该都有同感:最怕的不是算法难,而是“数据有了、图也建了,最后问答却做成关键词匹配”。如果有一套代码能打通“爬虫采集 → 数据清洗 → Neo4j 建图 → Flask 问答接口 → 微信小程序展示”的完整链路,那课设和毕设的进度条能瞬间拉满一大截。这套基于知识图谱和 Neo4j 图数据库的电影知识问答系统,正好补齐的就是这条链路。后端用 Flask 提供问答服务,图数据落在 Neo4j,前端是微信小程序,适合有 Python 基础、想快速复现一个完整项目的人。无论你是拿它当毕设底座,还是想理解知识图谱问答的真实工作流,下面这些内容都能帮你少走弯路。
2. 先把数据喂饱:爬虫清洗与实体关系建模
2.1 爬虫模块拆解:抓什么、存成什么样、为什么先落结构化表
这套代码包里的 spider 目录承担数据采集任务。我拆这类资源的第一步永远是先看它的数据流,因为知识图谱项目里,数据质量直接决定后续问答能答到什么程度。常见做法是抓取某个公开电影资料站点的列表页和详情页,字段至少覆盖:片名、导演、主演、类型、地区、语言、上映日期、评分、剧情简介。抓下来的数据不要直接怼进 Neo4j,我一般会先落到 MySQL 或者 CSV 文件里。
为什么先落结构化存储,而不是爬完就写图库?原因有三:首先是爬虫是批量的,断点续跑、增量更新在关系表里好实现;其次是去重和清洗需要在表结构里做,比如“片名+年份”唯一键;最后是 Neo4j 的导入对格式敏感,与其在 Cypher 里反复调格式,不如在清洗阶段就把数据收拾干净。
下面是我常用的爬虫落库片段,核心逻辑是请求详情页、解析字段、按唯一键去重后写入 MySQL:
import requests from bs4 import BeautifulSoup import pymysql conn = pymysql.connect(host='localhost', user='root', password='123456', database='movie_kg', charset='utf8mb4') cursor = conn.cursor() def parse_detail(url): resp = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10) soup = BeautifulSoup(resp.text, 'html.parser') title = soup.select_one('.movie-title').text.strip() director = soup.select_one('.director').text.strip() actors = [a.text.strip() for a in soup.select('.actor')] genre = [g.text.strip() for g in soup.select('.genre')] rating = float(soup.select_one('.rating').text.strip()) return title, director, ','.join(actors), ','.join(genre), rating # 已存在的片名集合,用于去重 existed = set() cursor.execute('SELECT name FROM movie') for row in cursor.fetchall(): existed.add(row[0]) for url in movie_urls: title, director, actors, genre, rating = parse_detail(url) if title in existed: continue cursor.execute( 'INSERT INTO movie(name, director, actors, genre, rating) VALUES (%s,%s,%s,%s,%s)', (title, director, actors, genre, rating) ) conn.commit()这段代码的逻辑比较好懂:先维护一个已存在片名集合,避免重复写入;然后逐条解析详情页,主演和类型字段用逗号拼接成字符串存进单列。参数层面有几个要注意的点:timeout=10是给请求设置超时,防止某个页面卡死拖垮整个爬虫;charset='utf8mb4'是为了兼容中文和特殊符号,如果用默认的utf8遇到生僻字会报错;existed集合减少了一次次查库的开销,但如果数据量到几十万条,这个方案就该换成INSERT IGNORE配合唯一索引。
2.2 实体与关系建模:节点、标签、属性与关系方向
实体和关系建模是知识图谱的核心,这个环节设计得好不好,直接决定 Cypher 查询好不好写。这套系统的建模方案很收敛,没有堆砌过多实体类型,而是围绕电影这个核心节点展开。
实体上只保留了三种:电影(Movie)、人物(Person)、类型(Genre)。人物节点通过 type 属性区分导演和演员,而不是拆成 Director 和 Actor 两类节点。这样设计的好处是,当你查“一个人既导过又演过哪些电影”时,不需要跨节点类型做两次查询,一个 Person 节点就涵盖了。
关系上对应三组:(:Movie)-[:DIRECTED_BY]->(:Person)、(:Movie)-[:ACTED_BY]->(:Person)、(:Movie)-[:GENRE]->(:Genre)。这里有个经验点:关系方向要全局统一,全部从电影指向人或者类型。如果一部分写成(Person)-[:DIRECT]->(Movie),另一部分写成(Movie)-[:DIRECTED_BY]->(Person),问答模板里就得维护两套方向逻辑,纯属给自己挖坑。
属性的设计也建议克制。Movie 节点上放 name、rating、release_date、area、language、summary 这些问答高频字段;Person 节点上只放 name 和 role 两个属性;Genre 节点就放一个 name。有些资源喜欢把“地区”也建成节点,比如(:Area {name:'中国'}),但这种做法在问答场景里价值不大,还会让图谱边数膨胀。地区用 Movie 的属性存就够,只有当你需要“查某个地区产出最多的导演”这类分析时才值得单独建节点。
2.3 数据清洗:同名电影、空值导演、去重的处理优先级
图数据库对脏数据的容忍度比关系型数据库低得多,因为一个空值关系会导致整条查询链路断掉。我拆这套包的时候,重点看了它的清洗逻辑,结合常见实现,一般要处理三个问题:同名电影、空值导演、主演列表展开。
同名电影很常见,不同年份可能有好几部同名作品。处理办法是给电影加year属性,用name + year作为唯一标识。比如“英雄”有 2002 年张艺谋版,也有其他年份的同名电影,清洗时保留年份字段,后续问答如果用户只说了片名,返回结果时把年份带上,歧义就消解了。
空值导演的处理要分成两种情况:如果一部电影确实查不到导演信息,那就只建 Movie 节点,不建关系;如果爬虫解析时字段为空但实际存在,就要回源补充。区分这两者的办法很朴素,看同页面其他字段是否完整。主演字段的处理则依赖分隔符展开,因为 CSV 里演员是逗号分隔的一长串,Neo4j 导入时不支持数组自动拆行,最好在清洗阶段就转成“片名, 演员名”的一对多行:
import pandas as pd df = pd.read_csv('movies_raw.csv', encoding='utf-8-sig') # 把主演字段按逗号拆分成多行 actor_rows = [] for _, row in df.iterrows(): if pd.isna(row['actors']): continue for actor in str(row['actors']).split(','): actor_rows.append({'movie': row['name'], 'actor': actor.strip()}) actor_df = pd.DataFrame(actor_rows) actor_df.to_csv('movie_actor.csv', index=False, encoding='utf-8-sig')这段清洗逻辑的关键在于pd.isna(row['actors'])跳过空值,避免把空字符串拆成空行写进图库。actor.strip()去掉演员名两端的空格,这个细节经常被忽略,爬虫抓下来的文本里经常混着\n和空格,不去掉的话,Neo4j 里会出现两个看起来一模一样、实际却不同的节点。最终落成两份 CSV,一份是电影基础信息,一份是电影-演员关系,后续导入图库时就可以按需加载。
3. 图谱入库:Neo4j 导入与 Cypher 查询的实战细节
3.1 为什么问答要选图数据库,而不是 MySQL 硬查
把电影知识图谱放进 Neo4j 而不是继续留在 MySQL 里,是因为问答场景天然是多跳关系查询。举例来说,用户问“某位演员演过哪些电影,这些电影的导演是谁”,在 MySQL 里要 join 三张表,写出来的 SQL 又长又绕;而在 Neo4j 里,从演员节点出发,沿着ACTED_BY方向走到电影节点,再沿着DIRECTED_BY走到导演节点,就是两跳遍历的事,Cypher 表达起来非常直观。
这个差异不是性能上的绝对值差异,而是查询表达能力上的差异。知识图谱问答的特点是关系路径不固定:今天问导演,明天问共演,后天问类型分布。关系型数据库每新增一种查询,可能都要调整 join 结构;图数据库只需要换一段 MATCH 语句。对毕设和课设场景来说,Cypher 的灵活性和可解释性,是选 Neo4j 最充分的理由。
3.2 LOAD CSV 导入:把清洗后的数据写进图库
CSV 是 Neo4j 导入最通用的中介格式。先把刚才生成的两份 CSV 放到 Neo4j 的 import 目录下,然后通过 Cypher 的LOAD CSV WITH HEADERS分步导入。先建约束,再导节点,最后建关系,这个顺序能避免大量重复扫描。
// 1. 创建唯一性约束,防止重复导入 CREATE CONSTRAINT movie_unique IF NOT EXISTS FOR (m:Movie) REQUIRE m.name IS UNIQUE; CREATE CONSTRAINT person_unique IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE; // 2. 导入电影节点 :auto USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM 'file:///movies.csv' AS row MERGE (m:Movie {name: row.name}) SET m.rating = toFloat(row.rating), m.release_date = row.release_date, m.area = row.area, m.language = row.language, m.summary = row.summary; // 3. 导入演员关系 :auto USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM 'file:///movie_actor.csv' AS row MERGE (m:Movie {name: row.movie}) MERGE (p:Person {name: row.actor}) SET p.role = 'actor' MERGE (m)-[:ACTED_BY]->(p);这段 Cypher 里两个细节值得展开。USING PERIODIC COMMIT 500是分批提交,500 条事务提交一次,能在 CSV 行数超过十万时避免事务内存膨胀。导入节点用MERGE而不是CREATE,是重复执行脚本的安全保障:第一次执行创建节点,第二次执行时相同名字的节点已经存在,MERGE会直接跳过。这里有个反直觉的点,SET p.role = 'actor'放在MERGE节点之后,是因为MERGE匹配到已存在节点时不会更新属性,SET恰好保证每次导入口径一致,哪怕重复执行也不会漏掉属性。
3.3 索引与约束:让中文查询不至于全库扫描
知识图谱问答的高频操作是根据电影名或者人名定位节点。如果没有索引,Neo4j 会对标签下的所有节点做全标签扫描,数据量到几千条时还能忍,到几万条的时候每次问答都像在翻一本没有目录的书。这套系统里至少需要两个索引:电影名索引和人名索引。上面的约束创建其实已经附带了一个唯一索引,因为REQUIRE ... IS UNIQUE会同时生成唯一约束和索引。如果想把查询扩展到模糊匹配,可以再给电影名建全文索引:
CREATE FULLTEXT INDEX movie_fulltext IF NOT EXISTS FOR (n:Movie) ON EACH [n.name];全文索引解决的是别名和模糊匹配问题。用户问“那部讲项羽的电影”时,系统没法准确命中“西楚霸王”,但全文索引支持CONTAINS查询,可以先把候选电影捞出来再做实体链接的相似度排序。要注意的是,Cypher 的字符串匹配默认区分大小写,中文场景虽然没有大小写问题,但标点符号和全半角差异会出现,所以实际查询时我习惯在业务层先把问题里的书名号、空格、引号统一清洗掉,再传给图查询。
4. 问答引擎:模板匹配、实体链接与 Flask 接口闭环
4.1 意图识别:常见问题类型与正则模板设计
问答系统的第一步是判断用户在问什么。这套项目采用模板匹配方案,而不是直接上一个意图分类模型,原因是电影知识问答的问题模式高度集中,常见的也就是评分、上映时间、导演、主演、类型、简介、演员作品、导演作品这八类。用正则在可控范围里做精确识别,比训练模型的成本低得多,而且每一条规则都能解释清楚,答辩时也更好讲。
我拆包时会重点看模板组织方式。常见做法是在一个字典里维护模板与意图的映射:
PATTERNS = [ {'intent': 'rating', 'pattern': r'(.+?)的?评分(是多少|多少分)?$'}, {'intent': 'director', 'pattern': r'(.+?)的(导演|谁导演的)$'}, {'intent': 'actors', 'pattern': r'(.+?)的(主演|演员)(有谁|有哪些)?$'}, {'intent': 'release_date', 'pattern': r'(.+?)的(上映时间|上映日期|什么时候上映)'}, ]设计模板时优先把“的”字和疑问词处理成可选组,因为用户的输入极其随意:“钢铁侠评分”“钢铁侠的评分是多少”都应该命中评分意图。.+?采用非贪婪匹配,用来捕获实体部分,避免把疑问词也吞进去。模板顺序也有讲究:把更具体的模板放前面,比如“导演是谁”先于“是谁”匹配,避免宽泛模板把问题错误归类。新增意图时,在这个字典里追加一条即可,不需要改动主流程代码,可维护性对课设项目来说足够友好。
4.2 实体链接:把问题里的电影名和人名准确抠出来
意图识别出来之后,下一步是把问题中的实体部分映射到图库里的节点。这里的坑在于,模板捕获到的字符串往往带着杂质,比如“《流浪地球》的导演是谁”捕获到的是“《流浪地球》”,如果直接拿去查库,必然查不到。所以实体链接环节要做两层处理:先用正则去掉书名号、引号、空格等噪声字符,再与图库中的电影名和人名做匹配。
常见做法是基于 jieba 分词加候选集过滤:
import jieba from py2neo import Graph graph = Graph("bolt://localhost:7687", auth=("neo4j", "123456")) def extract_entity(question, captured_text): cleaned = captured_text.strip('《》“”\' \t\n') # 精确匹配优先 movie = graph.run("MATCH (m:Movie) WHERE m.name = $name RETURN m.name", name=cleaned).data() if movie: return cleaned, 'Movie' # 分词后取最长匹配 words = jieba.lcut(cleaned) for w in sorted(words, key=len, reverse=True): if len(w) < 2: continue hit = graph.run("MATCH (p:Person) WHERE p.name CONTAINS $name RETURN p.name LIMIT 1", name=w).data() if hit: return hit[0]['p.name'], 'Person' return None, None这段代码的匹配策略是精确优先、包含次之。cleaned.strip('《》“”\' \t\n')是为了处理书名号和空白;sorted(words, key=len, reverse=True)让分词结果中最长的词优先去图库匹配,因为长词通常携带更多语义信息,比如“流浪地球”和“地球”同时出现在分词结果里时,长词命中概率更高。len(w) < 2过滤掉单字词,避免“球”“人”这类无意义实体造成误匹配。这个方案在数据量几千条时响应很快,但有个短板:如果用户提问时用了电影别名,比如“钢铁侠”用“铁人”代替,就会被判为未命中。要解决只能把别名做成属性加到节点上,但作为课设范围,精确匹配已经能覆盖大部分测试问题。
4.3 Cypher 生成与 Flask 接口:把意图、实体翻译成图查询
实体和意图都有了,下一步就是根据两者生成对应的 Cypher 查询,再通过 Flask 暴露成 HTTP 接口。为了让不同意图的查询逻辑不互相污染,可以维护一个意图到查询语句构造函数的映射表。下面是一个精简的实现:
from flask import Flask, request, jsonify app = Flask(__name__) CYPHER_TEMPLATE = { 'rating': "MATCH (m:Movie {name:$name}) RETURN m.rating AS answer", 'release_date': "MATCH (m:Movie {name:$name}) RETURN m.release_date AS answer", 'director': "MATCH (m:Movie {name:$name})-[:DIRECTED_BY]->(p:Person) RETURN p.name AS answer", 'actors': "MATCH (m:Movie {name:$name})-[:ACTED_BY]->(p:Person) RETURN p.name AS answer LIMIT 10", 'genres': "MATCH (m:Movie {name:$name})-[:GENRE]->(g:Genre) RETURN g.name AS answer", } def handle_question(question): intent, entity, entity_type = parse_question(question) if not entity: return '没找到相关电影或人物,换个问法试试' cypher = CYPHER_TEMPLATE.get(intent) if not cypher: return '这个问题我还在学习中' result = graph.run(cypher, name=entity).data() if not result: return f'我知道“{entity}”,但暂时没有该问题的答案' return result[0]['answer'] @app.route('/qa', methods=['POST']) def qa(): data = request.get_json() question = data.get('question', '') if not question: return jsonify({'code': 400, 'msg': 'question 不能为空'}) return jsonify({'code': 0, 'question': question, 'answer': handle_question(question)}) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)这个接口的核心逻辑是parse_question把问题解析成意图和实体,然后直接查CYPHER_TEMPLATE拿到对应的查询语句模板。LIMIT 10加在演员查询上很有必要,一部电影有二十个演员时,返回全部会造成前端渲染溢出。几个兜底分支值得注意:实体为空的提示、意图不匹配的提示、实体存在但答案为空时的提示,这三层兜底保证了接口不会返回空字符串让前端摸不着头脑。返回结构固定为code / question / answer三段式,前端拿到后只需判断code是否为 0 即可渲染。
5. 全链路避坑:从爬虫到小程序的高频故障排查记录
5.1 Neo4j 连接认证失败:驱动、密码、协议不匹配
现象:后端启动后调用问答接口,报AuthError或ServiceUnavailable,页面一直在转圈。原因:多半是 Neo4j 修改了初始密码后,后端代码里还是旧的neo4j / neo4j;或者bolt://localhost:7687的地址写成了http://localhost:7474,把 HTTP 协议端口当成了 Bolt 协议端口。解决:先确认 Neo4j 的 Bolt 端口是 7687,再确认密码和驱动连接串一致。我一般会在启动后端前用一段最小验证脚本确认连通性,避免把接口层的问题误判成中间件问题:
from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "你的密码")) with driver.session() as s: print(s.run("RETURN 1 AS ok").single()["ok"])5.2 小程序请求合法域名报错,接口返回 200 但页面报错
现象:在微信开发者工具里访问 Flask 接口,控制台提示url not in domain list或者request:fail。原因:微信小程序的wx.request默认要求请求地址是已经配置到后台的合法域名,开发时用的本地 IP 或者 localhost 不在白名单内。解决:开发阶段在project.config.json里关闭域名校验,把urlCheck设为false;等真正部署上线时,再把后端域名配置到小程序管理后台的 request 合法域名列表里。这一步卡住不少第一次写小程序的人,其实只是开发环境的一个开关问题。
5.3 实体链接把“导演”当成人名
现象:用户问“钢的琴导演是谁”,系统答成“导演”或者返回空。原因:实体链接阶段,模板捕获到的字符串是“钢的琴导演”,分词后产生了“导演”这个词,而“导演”本身也是 Person 节点的话,就会被误匹配。解决:在目标数据集的实体中,单独收集一份“意图词表”,比如导演、演员、主演、评分、上映时间,在实体匹配时先过滤掉这些词,再拿剩余文本去图库查询。这个坑很隐蔽,因为程序逻辑看着没错,但语义上被干扰词带偏了。
5.4 重复执行导入脚本,关系数量翻倍
现象:CSV 导入脚本执行了两遍,节点数量没变,但关系数量变成原来的两倍。原因:MERGE只保证节点不重复创建,但CREATE关系每次都会新建立一条,比如CREATE (m)-[:DIRECTED_BY]->(p)执行两次,同一对电影导演之间就有两条相同的关系。解决:建关系的语句同样用MERGE,MERGE (m)-[:DIRECTED_BY]->(p)能确保同方向同类型的关系只存在一条。如果是已经写进库里的重复关系,可以用下面的 Cypher 清理:
MATCH (m:Movie)-[r:DIRECTED_BY]->(p:Person) WITH m, p, collect(r) AS rels WHERE size(rels) > 1 FOREACH (r IN rels[1..] | DELETE r)5.5 CSV 导入后中文乱码和字段错位
现象:LOAD CSV 导入后,Neo4j 里的电影名变成了乱码,或者某列的值跑到另一列里去了。原因:CSV 文件不是 UTF-8 编码,常见的是用 Excel 打开再另存时保存成了带 BOM 的 GBK;另外电影简介里如果包含英文逗号,而导出时没有用双引号包裹整个字段,就会导致列数错位。解决:用 Python 导出 CSV 时设置encoding='utf-8-sig',这样 Excel 打开不乱码,Neo4j 导入也识别;对所有含逗号的字段手动加双引号包裹。最简单的办法是让 pandas 的to_csv自动加引号,不要用手写字符串拼接的方式生成 CSV。
6. 进阶验证:用测试集量化问答准确率,再做多跳查询扩展
系统能跑通只是第一步,真正要拿去答辩或者写进简历,必须知道它的问答准确率到底是多少。我验证这类系统时习惯准备一份 30 条左右的测试问题集,覆盖八种意图,每条问题都标注好标准答案,然后写个脚本循环调用/qa接口,人工比对返回结果,统计准确率、未命中率和错误类型分布。这一步看似繁琐,但它能帮你快速定位系统的短板:如果“演员作品类”问题准确率明显偏低,说明实体链接对 Person 节点的支持不足,而不是意图识别的问题。
对于毕设级别的项目,准确率能到 85% 以上已经足够说明问题。剩余的错误要具体分析,大部分集中在别名未命中、多义词歧义这些点上,这些都可以通过扩充别名表或者模板来修正。如果想让系统再往上走一步,可以把问题类型扩展到多跳查询,比如“章子怡主演的电影里,评分最高的是哪部”,这时 Cypher 就不再是简单的单跳模板,而是:
MATCH (p:Person {name:'章子怡'})-[:ACTED_BY]->(m:Movie) RETURN m.name AS movie_title, m.rating AS rating ORDER BY m.rating DESC LIMIT 1这种多跳查询的加入不需要改动问答主流程,只需要在模板映射表里加一条multi_hop_actor_best_rating,实体链接阶段仍然定位到 Person,Cypher 层去做排序和 LIMIT 即可。从那以后,我每次跑通一个知识图谱 Demo,都会强制自己先做一遍 30 条问题的回归测试,再决定要不要加新模板——因为问答系统真正的复杂度从来不在图数据本身,而在把自然语言稳定地翻译成图查询的这一步。希望这份拆解能帮你在复现时少踩几个坑。
本文还有配套的精品资源,点击获取