简介:一份面向计算机专业毕业设计的高分项目资源,基于Python和Neo4j构建知识图谱电影问答系统。项目源自作者大四期间完成的毕业设计,经导师指导并认可,评审得分为99分,代码完整、可直接运行,既适合正在准备毕设的学生,也适合需要项目实战练习的学习者,还可作为课程设计或期末大作业。资源共59个文件,涵盖Python后端、爬虫脚本、小程序前端、JSON数据、SVG流程图、PNG运行截图以及Markdown说明文档等,压缩包大小6.28MB,目录结构清晰便于定位。已有77人学习下载。随包提供系统源码、配套文档、运行效果截图与工作流程示意图,覆盖从数据采集、知识图谱构建到问答交互的完整链路。借助该项目可深入理解Neo4j图数据库在知识图谱问答中的应用方法,也可直接作为毕业设计答辩的功能演示与项目参考。
1. 毕业设计选知识图谱电影问答:为什么这个组合容易出高分
同样是电影信息查询类毕设,用关系型数据库做出来的,答辩时最多演示几条 SQL;换成「知识图谱 + Neo4j + 问答系统」这个组合,演示逻辑就从「查表」变成了「问答」,背后是数据建模、图存储、自然语言处理和系统集成一整条链路。这个标题的核心是知识图谱电影问答系统:Python 负责逻辑和接口,Neo4j 负责存储实体与关系,最终交付一个输入「周星驰演过哪些喜剧片」就能直接给结构化答案的网页系统。它适合三类人:正在选毕设题目的学生,想把普通电影管理网站改出技术亮点的学生,以及想零基础入门知识图谱落地的开发者。不过先泼盆冷水:这套系统的难点不在 Neo4j,在问答链路的数据清洗和模板设计——后面几章我会把每个环节的代码、参数和翻车点一次讲清。
2. 图谱构建先过数据关:电影本体、清洗与 Neo4j 批量导入
很多同学拿到源码第一件事就是启动 Neo4j 导入数据,结果运行起来发现「导演是谁」「合作过哪些演员」这类问题答不了,原因不是代码有 bug,而是本体设计阶段把关系建少了。我在模拟项目X里见过一份数据集,只有 movie 和 person 两类节点,导演和演员全部用同一个 person 节点表示,关系里也没有区分「执导」和「主演」——最后调了两天模板,发现问题根本出在数据模型。所以这一章从本体设计开始讲,再给数据清洗和导入的具体代码。
2.1 先画本体:电影、演员、导演三类实体和四种关系
本体设计就是回答三个问题:有哪些实体类型、每个实体有哪些属性、实体之间有哪些关系。电影问答系统里最常见的实体是电影、演员、导演,再往上加可以扩充类型(编剧、制片公司)和属性(评分、片长、制片国家、上映年份),但是毕设规模不建议一开始就超过五类实体。
| 实体类型 | 关键属性 | 关系(出度) |
|---|---|---|
| Movie | 片名、年份、评分、片长、简介 | 主演、导演、类型、发行 |
| Actor | 姓名、出生日期、国籍 | 主演 |
| Director | 姓名、出生日期、国籍 | 导演 |
属性要不要做索引,取决于问答场景。比如「2018 年上映的科幻片有哪些」,如果没有给上映年份建索引,这个查询就是在全图谱里扫一遍;数据量小时无所谓,跑到一万部电影以上就得建索引,否则每次问答从秒级响应变成几秒。实体之间的关系在 Cypher 里是有方向的,建关系时统一按「电影指向人」还是「人指向电影」要一致,模板写起来才不会晕。
2.2 数据清洗:公开数据集里最容易出问题的三处
本体定好后最繁琐的是数据准备。公开影视数据集往往来自爬虫或社区整理,格式五花八门,常见做法是用 pandas 先洗一遍再入库。最容易出问题的三处:演员名和导演名不统一,同一人名有的带空格、有的带繁体写法;同一部电影有多个条目,尤其翻拍和同名片,需要用年份做区分;评分字段里混着「7.8」「暂无评分」这种字符串,入库时要么转 float 要么置空。
清洗代码通常长这样:
import pandas as pd df = pd.read_csv('movies.csv', encoding='utf-8-sig') # 去重:片名+年份 重复的只保留第一条 df = df.drop_duplicates(subset=['name', 'year']) # 评分统一格式,无法转换的置为 None df['rating'] = pd.to_numeric(df['rating'], errors='coerce') # 演员字段切割,兼容逗号和中文顿号 df['actors'] = df['actors'].str.split(r'[,,、]') df.head()to_numeric 的 errors='coerce' 参数是最常用的处理手法,遇到「暂无评分」这类值会转成 NaN,而不是报错中断。split 时用正则同时切逗号和顿号,能避开「张某某,李某某」和「张某某、李某某」两种格式混在一个文件里的问题。切完记得检查一下每个 list 的长度分布,有的电影演员列几十个人,有的只有一个人,后面导入关系时不影响,但会影响前端展示。
2.3 批量导入 Neo4j:UNWIND 批量写入的正确姿势
清洗完怎么入图?常见有两种:一种是用 Neo4j 自带的 LOAD CSV 语法,在浏览器里执行 Cypher;另一种是用 py2neo 在 Python 里连接 Neo4j 写。毕设项目用 py2neo 更多,原因是问答逻辑本身就在 Python 里,后续维护只写一套连接代码。
批量写入不要用循环一条条 create,那种写法数据量稍大就会非常慢,正确姿势是用 UNWIND 批量创建。下面这段代码可以当作通用模板:
from py2neo import Graph, Node, Relationship graph = Graph('bolt://localhost:7687', auth=('neo4j', 'password')) batch_size = 500 nodes = [] # movies_df 是清洗后的 DataFrame for _, row in movies_df.iterrows(): node = Node('Movie', name=row['name'], year=row['year'], rating=row['rating']) nodes.append(node) if len(nodes) >= batch_size: graph.create(*nodes) # 批量提交 nodes.clear() if nodes: graph.create(*nodes)Graph 的三个参数分别对应连接协议、地址、认证信息。batch_size 一般取 200 到 500,太大会让单次事务内存压力上升,太小又体现不出批量优势。graph.create(*nodes) 的重点在于一次性提交一个列表,而不是在循环里每次写 Neo4j,这是最常见的性能优化点。写入过程中如果出现重复节点报错,说明数据里仍有重复键,回头检查去重逻辑,别直接在导入代码里 try-except 吞掉,否则会留下大量僵尸节点。
关系导入要等节点全部写完后进行,原因很简单:创建关系时必须引用到两端节点,节点还没入库就建关系会直接失败。关系写入同样用批量思路,先给 Movie 和 Actor 的 name 建唯一约束,再用 match 找节点引用:
CREATE CONSTRAINT movie_name IF NOT EXISTS FOR (m:Movie) REQUIRE m.name IS UNIQUE; CREATE CONSTRAINT actor_name IF NOT EXISTS FOR (a:Actor) REQUIRE a.name IS UNIQUE;# 假设 relation_df 是 (actor_name, movie_name) 的映射表 tx = graph.begin() for _, row in relation_df.iterrows(): a = graph.nodes.match('Actor', name=row['actor']).first() m = graph.nodes.match('Movie', name=row['movie']).first() if a and m: tx.create(Relationship(a, '主演', m)) tx.commit()有了唯一约束,match 走索引,关系导入速度能快一个数量级,还能顺带防止重复节点。这段代码里最花时间的是 match 查找,数据量过万以后,每次 match 全库扫会非常慢,所以索引必须在导入前建好。到这里图谱侧就绪,可以开始写问答逻辑。
3. 问答核心逻辑:意图分类、实体抽取与 Cypher 模板生成
问答系统的难点从来不在「查数据库」,而在「把一句中文问句变成一条 Cypher」。问「周星驰演过的电影有哪些」和「周星驰的出生日期」,前者要把实体定位到电影,后者要取属性,两者生成的查询路径完全不同。大部分毕设项目的做法是模板匹配:先判断用户问的是什么类型的问题,再用规则抽取问句里的实体,最后套用写好的查询模板。这三步走完,系统基本就能覆盖日常问答。
3.1 意图分类:五类问题覆盖大多数「电影问答」场景
意图分类就是先判定「用户在问什么」。常见的分类方案是五类:演员作品类、电影信息类、导演信息类、合作关系类、属性类。每一类对应一组触发关键词,比如「演过、主演、出演」触发演员作品,「导演、执导、谁拍的」触发导演信息。
实现上用关键词规则就够,毕设阶段不需要上意图识别模型,因为电影问答的句式非常固定,规则写好了准确率能到九成。分类函数可以这样组织:
INTENT_RULES = { 'actor_movies': ['演过', '出演', '主演', '作品', '电影'], 'movie_info': ['评分', '简介', '片长', '上映', '信息'], 'director_movies': ['执导', '导演的', '谁拍的'], 'cooperation': ['合作', '同台'], 'attr_query': ['出生', '生日', '国籍', '年龄', '哪里人'], } def classify_intent(question): # 按规则表逐个匹配,返回第一个命中的意图 for intent, keywords in INTENT_RULES.items(): for kw in keywords: if kw in question: return intent return 'unknown'这里有一个容易被忽略的细节:意图分类要在实体抽取之前做。因为「周星驰演过的电影」和「周星驰的出生日期」抽出来的实体都是周星驰,但反问句意图不同,后续查询路径完全不同。所以分类的目的不是检索,而是决定走哪套模板。规则表的顺序也有讲究,越具体的意图放前面,比如「谁拍的」和「导演的」都涉及导演,但「导演的」可能指导演的电影作品,顺序反了会误判。上面代码里我把更具体的「导演的」放在前面,就是这个原因。
3.2 实体抽取:给 jieba 加载自定义词典,别让电影名被切碎
意图定完,下一步是从问句里找出具体的电影名、演员名。常见做法是用 jieba 分词,但是直接分词会把「大话西游之大圣娶亲」这种长片名切成好几段,所以必须加载自定义词典。
import jieba jieba.load_userdict('movie_dict.txt') # movie_dict.txt 每行格式:词语 词频 词性 # 大话西游之大圣娶亲 10 nz # 周星驰 10 nr def extract_entity(question): segs = [w for w in jieba.cut(question) if w.strip()] # 从切分结果中挑出在词典里出现过的词,作为候选实体 return [w for w in segs if w in ENTITY_DICT]词典文件格式是「词语 词频 词性」,词频越大越容易成词。这里有个小坑:词性标 nr(人名)还是 nz(作品名)会间接影响模板匹配结果,因为后面会拿词性做类型判断。实体抽取完不要直接拿第一个候选当答案,同名情况在电影领域特别多,后面的避坑部分会专门讲消歧。
3.3 模板匹配:从标准化问句到 Cypher 查询的映射函数
抽取到意图和实体后,最后一步是生成 Cypher。做法是写一个映射函数,输入意图和实体,输出一条带参数的查询:
def build_cypher(intent, entity, entity_type): if intent == 'actor_movies': # 查询某演员演过的电影,返回片名和评分 return ("MATCH (a:Actor {name: $name})-[:主演]->(m:Movie) " "RETURN m.name AS title, m.rating AS rating " "ORDER BY m.year DESC"), {'name': entity} elif intent == 'movie_info': # 查询某一部电影的信息 return ("MATCH (m:Movie {name: $name}) " "RETURN m.name, m.year, m.rating, m.duration"), {'name': entity} elif intent == 'cooperation': # 查询两位演员是否合作过 return ("MATCH (a1:Actor {name: $name1})-[:主演]->(m:Movie)" "<-[:主演]-(a2:Actor {name: $name2}) " "RETURN m.name AS movie"), {'name1': entity[0], 'name2': entity[1]} return None, Nonebuild_cypher 的三个参数里,entity 可能是单个词也可能是多个候选,entity_type 是从分词词性带过来的类型标记。Cypher 查询参数用 $name 而不是字符串拼接,一是防止注入,二是 Neo4j 会缓存执行计划,性能更稳定。返回的记录列表在组装答案时拼成一句自然语言。如果 build_cypher 返回 None,通常说明意图和实体对不上,比如问「某演员的导演是谁」,意图是导演信息但实体类型是演员,要么改模板,要么提示用户换个问法。
模板匹配有个常见翻车点:实体的词性类型和意图不一致时不要硬匹配。比如查电影《风之谷》的导演,意图是导演信息,但抽取出的实体是电影名,模板里需要把「风之谷」当 Movie 处理。所以我一般会在 build_cypher 里加一个校验分支,entity_type 与模板期望不一致时,尝试用「先定位电影节点,再沿导演关系跳转」的兜底查询,而不是直接返回空答案。
模板的维护顺序也有讲究。我在毕设阶段习惯把所有模板集中在一个列表里,按「具体到通用」排序,而不是散落的 if-else。具体模板比如带年份范围的查询,通用模板比如只查片名,前面命中了就不再往后走。这样后续加新问题类型时,只需要往列表里插一个模板,不用改动主流程。
4. Python 后端与前端对接:让问答从命令行走进页面
问答核心逻辑跑通后,剩下的是把它包成一个系统。毕设项目的常见架构是 Flask 做后端,一个极简 HTML 页面做前端,用 fetch 调接口。Neo4j 的查询不能在浏览器里直接暴露给客户端,所有 Cypher 执行必须放在服务端,前端只发送问句文本、接收 JSON 答案。
4.1 Flask 后端:一条 /qa 接口串起整条问答管线
Flask 项目里只需要一个路由就能撑起整个问答功能:
from flask import Flask, request, jsonify from qa_pipeline import answer_question app = Flask(__name__) @app.route('/qa', methods=['POST']) def qa(): data = request.get_json() question = data.get('question', '').strip() if not question: return jsonify({'code': 400, 'msg': '问题不能为空'}) result = answer_question(question) return jsonify({'code': 200, 'data': result}) if __name__ == '__main__': app.run(host='0.0.0.0', port=8000, debug=False)answer_question 是前面几章的封装:先分类意图,再抽取实体,然后 build_cypher 拿到查询和参数,最后执行查询并组装答案。把这条链路收进一个函数而不是散在路由里,后续加日志、加缓存都方便。host 设为 0.0.0.0 是为了方便局域网里演示,毕设答辩时评委可能用另一台电脑访问。debug=False 是运行习惯,开发阶段开 True 能直接在页面看到报错堆栈。
封装函数里最好加上异常捕获,防止 Neo4j 查询报错时把堆栈直接返回给前端:
def answer_question(question): try: intent = classify_intent(question) entities = extract_entity(question) cypher, params = build_cypher(intent, entities) if not cypher: return {'answer': '暂时没理解这个问题,换一种问法试试'} records = graph.run(cypher, **params).data() return {'answer': assemble_answer(intent, records)} except Exception as e: # 记录日志到文件,前端显示兜底文案 logging.exception('QA failed') return {'answer': '查询出错,请稍后再试'}graph.run 返回的是一个游标,调用 .data() 才会真正把记录加载成 Python 字典列表。这里有个性能细节:返回条数不确定时,最好在 Cypher 里加 LIMIT 限制,比如演员作品最多返回 20 部,否则遇到作品特别多的演员,JSON 会很大,前端渲染也会卡。
4.2 前端页面:一个输入框、一个对话列表的极简实现
前端不需要复杂框架,一个输入框加一个消息列表足够演示。核心代码就两段:一段渲染对话气泡,一段发请求。
<div id="chat-list"></div> <input id="q-input" placeholder="输入问题,例如:周星驰演过哪些喜剧片" /> <script> const list = document.getElementById('chat-list'); const input = document.getElementById('q-input'); async function ask(question) { // 把用户问题和系统答案按顺序追加到对话列表 const res = await fetch('/qa', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({question}) }); const json = await res.json(); const item = document.createElement('div'); item.textContent = json.data.answer; list.appendChild(item); } input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) { const q = input.value.trim(); const item = document.createElement('div'); item.textContent = 'Q: ' + q; list.appendChild(item); input.value = ''; ask(q); } }); </script>fetch 的 Content-Type 必须写成 application/json,Flask 的 request.get_json() 才能正确解析,写成别的类型或漏掉这个头会拿不到 body。调试时打开浏览器开发者工具的网络面板,如果 /qa 返回 415 或 400,先看请求头是不是少了 Content-Type。消息列表的样式可以用 CSS 简单区分左右气泡,这块不影响功能,放在最后做。
4.3 项目结构先搞清楚:文档和代码怎么对应着看
拿到这个项目后先别急着运行,花 10 分钟把目录结构和文档对应起来,能少走很多弯路。毕设项目通常包含两个核心文档:一个是环境搭建说明,另一个是设计文档。环境文档会讲 Python 版本、Neo4j 版本、第三方库清单;设计文档一般按「需求分析 → 本体设计 → 系统实现 → 测试」的顺序组织,直接对应代码里的数据导入、问答管线、后端接口这几个模块。
我的阅读顺序是:先看环境文档确认版本组合,再打开数据导入脚本看字段名是否和 CSV 表头一致,然后打开问答管线的入口文件确认三个核心函数的位置,最后才启动服务。前端页面放在最后看,因为它只依赖 /qa 接口的返回格式,接口的数据结构一旦稳定,页面代码基本不用动。按这个顺序排查有个好处:出问题时你能快速判断是数据层、逻辑层还是接口层的问题,而不是在整份源码里乱翻。
5. 避坑!运行毕设项目时最常翻车的 4 个地方
这部分内容来自我帮 A 同学排查问答系统时攒下的经验。每个坑都按「现象 → 原因 → 解决」来写,可以直接对照检查。
5.1 py2neo 和 Neo4j 版本不匹配导致连不上库
现象:运行导入脚本报 ConnectionError 或 AuthError,提示无法连接 bolt://localhost:7687,但浏览器能打开 Neo4j 的网页界面。
原因:py2neo 的版本和 Neo4j 服务端版本不匹配。py2neo 对服务端的协议支持有明显版本兼容差异,新版服务端配旧驱动,握手直接失败。另一个常见诱因是 Neo4j 修改了默认密码后,脚本里的 auth 参数还是初始密码。
解决:先在浏览器里用 Neo4j 自带的查询界面执行 RETURN 1 确认服务端正常,然后核对 py2neo 版本与服务端大版本是否匹配。如果项目文档明确写了依赖版本,优先按文档组合装,别用最新的。改完密码记得重启 Neo4j 服务,只改密码不重启是新手最容易忽略的一步。
5.2 中文数据导入乱码:编码问题比你想的常见
现象:CSV 导入后,Neo4j 页面里中文全是乱码,或者 Python 读取 CSV 时抛 UnicodeDecodeError。
原因:CSV 文件的实际编码是 GBK 或带 BOM 的 UTF-8,而读取代码写的是普通 UTF-8。Windows 上用 Excel 编辑过的 CSV 尤其容易变成 GBK,直接 pd.read_csv 默认读 UTF-8 就翻车。
解决:读取时指定 encoding='utf-8-sig' 或 'gbk',先读一个小文件测试,确认中文正常后再处理全量数据。还有一种隐蔽情况:文件整体是 UTF-8 但个别字段混了脏字符,这种情况用 errors='ignore' 丢掉少数脏字符,比中断任务好接受。检查是否乱码最直接的办法是导入后执行一条 Cypher 查一条记录,看浏览器渲染结果,不要只看 Python 控制台里的打印。
5.3 同名实体问答答错:图谱里有「两个周星驰」怎么办
现象:问「周星驰演过哪些电影」,系统返回的是一串完全不对的电影名。
原因:知识图谱里有重名的实体,可能是两个同名演员,也可能是演员和电影同名。抽取实体时只按名称匹配,没有做类型或年份约束,匹配到了错误节点。
解决:在实体抽取后增加消歧步骤:如果候选实体存在多个同名的,先按意图类型过滤。比如意图是 actor_movies,优先匹配 Actor 类型而不是 Movie 类型;如果同名 Actor 还有多个,就结合问句里出现的年份词(比如「九十年代的周星驰」)缩小范围。在 Cypher 里也可以加属性约束,比如 MATCH (a:Actor {name: $name, birth_year: $year}),但前提是数据里真的维护了这些属性。
5.4 模板没匹配上:问题答不出时先打印生成的 Cypher
现象:图谱里有数据,Python 也没报错,但前端永远返回「暂时没理解这个问题」。
原因:意图分类返回了 unknown,或者 build_cypher 的某个分支没覆盖到当前问法。这类问题隐蔽在它不是崩溃性错误,而是静默返回空答案,新手最容易在页面和接口之间来回找原因。
解决:在 answer_question 里把 intent、entities、cypher 三个变量打到日志,或者临时加 print 输出。然后拿一条失败问句走一遍链路:问句切分对不对?词典里有没有这个词?意图命中没命中?最后拿打印出来的 Cypher 去 Neo4j 浏览器手动执行,看有没有数据返回。按这个顺序排查,绝大多数「答不出」能在十分钟内定位到具体环节。
6. 验证与进阶:让问答系统从 70 分走到 90 分
系统跑通不等于能拿高分。答辩评委最常问的问题是「你怎么证明系统是有效的」。空口说「我测了没问题」没有说服力,得有一份结构化的测试记录。
6.1 构造 30 条测试集,按意图分类统计准确率
我建议准备一份固定测试集:每个意图至少 5 条问句,共 30 条,覆盖正常问法、颠倒语序、带年份修饰、带别名等变体。跑完统计两个指标:意图分类准确率和最终答案准确率。前者判断规则表有没有误分类,后者判断 Cypher 模板和数据有没有配合失误。这两组数字写进毕设文档的测试章节,比十页截图都有说服力。
6.2 答案排序:评分、热度、年份的加权规则
模板查询经常返回多条记录,比如「周星驰演过的电影」有几十部,返回顺序需要设计。最简单的做法是让 Cypher 按年份或评分排序,但这只是单因子。我在模拟项目X里用的是加权评分:按「年份 × 0.4 + 评分 × 0.4 + 热度 × 0.2」,热度可以用数据库里的评论数或收藏数代替,没有就统一给默认值。权重不要写死在 Cypher 里,在 Python 里组装答案时算,方便答辩时现场调参数演示效果。
6.3 换个更抗打的思路:从小模板到大模型
如果时间有余,可以在模板匹配之外加一层词向量召回:把问句向量化后和已有问答模板做相似度匹配,相似度低于阈值再走规则表。这样做的好处是,即使规则里没覆盖新问法,只要语义相近就能落到正确模板。不过这一步是加分项不是必选项,先把规则表的准确率做到九成,再考虑向量化,顺序反了会把项目拖进调参泥潭。
做完这些,这个毕设项目的技术含量不止在「实现了问答」,更在「能说清楚每条问句从输入到答案经历了哪几步、每步失败怎么排查」。我现在的习惯是保留一张问题排查表,把上面 4 个坑按现象列成清单,每次改代码后逐条回归一遍,30 条测试集全过了才算完成一次迭代。这套「先定测试集、再改代码、最后回归」的顺序帮我省掉了大量改完 A 坏 B 的时间,希望帮到你。
本文还有配套的精品资源,点击获取