
简介这套 Python 毕业设计资源是一套基于知识图谱的电影问答系统源码与说明文档面向计算机相关专业正在准备毕设、课程设计或期末大作业的学生也适合需要实战练习的初学者。项目为作者大四经导师指导并通过的高分设计评审分 99 分代码完整、可运行能够帮助读者快速理解知识图谱构建、实体关系抽取与自然语言问答的完整流程。资源包共 116 个文件其中 py 文件提供系统核心逻辑txt 说明文档覆盖部署与配置要点html/css/js 构成前端问答界面csv/json 存放电影、人物及关系数据压缩包整体仅 6.3MB目录结构清晰便于按模块阅读和二次开发。目前已有 161 人学习/下载资源除核心问答功能外还配有人物关系数据表和交互页面读者可据此快速跑通系统并在此基础上扩展知识图谱或优化问答效果。1. 知识图谱与智能问答系统二合一电影问答毕设值不值得做知识图谱电影问答系统就是把「电影、演员、导演、类型」抽象成图里的节点和关系存进 Neo4j再用 Python 解析用户的自然语言问题转换成图查询语句并返回答案。作为高分毕设选题它一次覆盖知识图谱构建、爬虫采集、规则问答、后端接口和可视化五条主线工程量适中、演示效果好尤其适合有 Python 基础、想做后端方向的学生。它不是开箱即用的玩具需要你亲手把「知识建模→数据入库→意图识别→Cypher 生成→前后端联调」整条链路走通。下面按这条链路逐层拆解每一步都能直接抄作业。2. 知识图谱构建与数据入库从豆瓣爬虫到 Neo4j 的落地链路这一章解决「图里有什么」的问题。顺序是先把实体和关系定义好再抓数据清洗入库最后用 LOAD CSV 一次性灌进 Neo4j。三个环节相互依赖任何一个出错都会让后面的问答查不到东西。2.1 电影本体建模实体类型、关系方向与属性字段怎么定本体建模是知识图谱构建的第一步也直接决定后面 Cypher 好不好写。做问答系统不是做大而全的语义网实体和关系越少越好维护。我一般只用四种实体、四类关系Movie电影、Person演员/导演、Genre类型、Country制片地关系上分别是 ACTED_IN出演、DIRECTED执导、BELONGS_TO归属类型、PRODUCED_IN制片地。属性的取舍以「问答会问到什么」为准。Movie 上放 title、rating、release_year、runtimePerson 上放 name、aliasGenre 和 Country 只放 name。不要把电影简介、海报 URL 这类长文本一骨碌塞进图库它们既不能参与关系匹配又拖慢查询。答辩评委会问「为什么这么建模」标准答法是字段来源是问答模板的需求倒推而不是从数据倒推。提示导演和演员用同一个 Person 标签靠关系类型区分身份。这样「一个人既导又演」时不会建两个节点语义层上也符合现实。关系方向统一写成「人指向电影、电影指向类型」即 ACTED_IN / DIRECTED 从 Person 到 MovieBELONGS_TO 从 Movie 到 Genre。方向统一之后Cypher 模板只需要维护一种写法后面做问答生成时少踩一半的坑。2.2 用 python 爬虫采集电影数据字段设计、别名表与编码三件事数据源我习惯直接用豆瓣电影 Top250数量适中、字段齐全、中文场景贴合。爬虫只抓问答需要的那几个字段不要贪多。下面这段是典型的单页抓取逻辑。# spider/movie_spider.py import requests from bs4 import BeautifulSoup import time HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } def fetch_movie(subject_id): 抓取单个电影页字段名与图库属性一一对应 url fhttps://movie.douban.com/subject/{subject_id}/ resp requests.get(url, headersHEADERS, timeout10) soup BeautifulSoup(resp.text, html.parser) title soup.select_one(h1 span).text.strip() rating soup.select_one(strong.rating_num).text.strip() year soup.select_one(span.year).text.strip(()) info_text soup.select_one(#info).get_text(\n, stripTrue) director _get_field(info_text, 导演) actors _get_field(info_text, 主演)[:3] # 只取前三位主演 return { title: title, rating: float(rating), release_year: int(year), director: director, actors: actors, } def _get_field(text, key): 从 info 文本里按 key 取值兼容换行分割 lines text.split(\n) for idx, line in enumerate(lines): if line key: return lines[idx 1].strip( :) return def save_to_csv(items, path): if not items: return import csv with open(path, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnameslist(items[0].keys())) writer.writeheader() writer.writerows(items)这里有两个参数要注意。一是请求频率豆瓣对高频请求有限流连续抓 250 页建议每页间隔 1 秒被抓到 403 后先停 30 秒再重试二是encodingutf-8-sigPython 写中文 CSV 时用 utf-8-sig 会在文件头加 BOMNeo4j 和 Excel 都能正常识别比裸 utf-8 稳。_get_field里strip( :)是为了去掉「导演:」后面常见的空格和冒号变体这类脏数据在清洗阶段处理最便宜。2.3 用 LOAD CSV 批量导入 Neo4j唯一约束、MERGE 与索引把 CSV 放进 Neo4j 安装目录的 import 文件夹后用浏览器打开 http://localhost:7474 执行导入脚本。分三步走先建约束再导节点最后导关系到文件。// data_import/import.cypher CREATE CONSTRAINT movie_title_unique IF NOT EXISTS FOR (m:Movie) REQUIRE m.title IS UNIQUE; CREATE CONSTRAINT person_name_unique IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE; // 导入电影节点重复 title 自动跳过 LOAD CSV WITH HEADERS FROM file:///movies.csv AS row MERGE (m:Movie {title: row.title}) SET m.rating toFloat(row.rating), m.release_year toInteger(row.release_year), m.runtime toInteger(row.runtime); // 导入演员与导演节点 LOAD CSV WITH HEADERS FROM file:///persons.csv AS row MERGE (p:Person {name: row.name}); // 根据 relations.csv 逐行建关系 LOAD CSV WITH HEADERS FROM file:///relations.csv AS row MATCH (p:Person {name: row.person_name}) MATCH (m:Movie {title: row.movie_title}) MERGE (p)-[:ACTED_IN]-(m);关键要分清 CREATE 和 MERGE节点导入一律 MERGE重复行只会更新属性不会重建节点关系导入也 MERGE避免同一条关系在多次执行脚本时翻倍。唯一约束不只是防重还会为 title 和 name 自动建索引后面每次按名称查找都是索引命中而不是全图扫描。注意REQUIRE是 Neo4j 4.x 的写法如果你用的是 3.5 老版本要改成ASSERT m.title IS UNIQUE否则脚本直接报语法错误。关系导入写在节点导入之后因为它依赖两边节点已存在。如果 relations.csv 里出现了 persons.csv 里没有的人名MATCH 会匹配不到整行被跳过且不报错这是最隐蔽的丢数据方式。稳妥做法是先统计行数导完后用MATCH ()-[r]-() RETURN count(r)核对关系总量。3. 问答链路核心规则意图识别、实体抽取与 Cypher 模板生成问答逻辑是这个系统最核心的部分也是答辩时最容易展开讲的技术点。整体流程就一句话先判断用户想问什么再找出问题里提到的实体最后把两者拼成一条 Cypher 查询图库。下面按这三个子问题逐段拆。3.1 智能问答系统的意图识别规则分类为什么比训练模型更实用拿到自然语言问题后第一步是判断用户想问什么。常见做法是维护一张「意图 → 关键词」的规则表用包含匹配做分类。很多人纠结要不要上 BERT 或 TextCNN我的结论是毕业设计规模几十类问题、几百条语料下规则分类足够且更可控改规则不需要重新训练答辩时还能讲清楚每条规则的设计理由。# qa/intent.py INTENT_RULES [ (movie_rating, [评分, 几分, 多少分, 豆瓣分]), (movie_release, [上映时间, 什么时候上映, 哪一年]), (actor_movies, [演过, 出演, 拍过, 作品]), (director_movies, [导演, 执导, 导过, 拍的电影]), (movie_actors, [主演, 演员, 谁演的, 出演者]), (genre_recommend, [推荐, 好看的, 有哪些喜剧, 经典]), (actor_coop, [合作, 一起演, 同台]), ] def classify_intent(question): 返回 (intent, matched_keyword)先匹配先得 for intent, keywords in INTENT_RULES: for kw in keywords: if kw in question: return intent, kw return unknown, None这段代码逻辑很简单但有三个细节经常被忽略。第一关键词的顺序就是优先级像「周星驰演过的电影评分」这类混合问句会先命中 movie_rating规则完全不同要改善就把「演过」组放在「评分」组前面再做一层意图冲突检测。第二kw in question是子串匹配「经典」既能命中类型推荐也会误伤「经典电影有哪些」需要配合后面的实体类型校验兜底。第三返回 matched_keyword 是为了调试——答错时能看到是哪个规则触发的这在答辩排错时是救命信息。3.2 实体抽取与别名归一把「星爷」映射到「周星驰」的词典方案实体抽取的质量决定问答成败这是全系统最容易翻车的环节。直接用 jieba 分词抽电影名会大量碎词「大话西游之月光宝盒」被切成一堆无意义片段。我的做法是反向利用知识图谱本身先把图里所有 Movie 和 Person 的 name、alias 拉出来按字符长度降序排列做最大正向匹配。句子里的实体名和图库完全一致时命中率接近 100%。# qa/entity_extractor.py from py2neo import Graph graph Graph(bolt://localhost:7687, auth(neo4j, mypass)) def load_lexicon(): 从图库加载实体词典{类型: [名称列表]}去重并按长度降序 rows graph.run( MATCH (n) WHERE n:Movie OR n:Person RETURN labels(n) AS types, n.name AS name UNION MATCH (n) WHERE n.alias IS NOT NULL UNWIND n.alias AS a RETURN labels(n) AS types, a AS name ) lexicon {Movie: [], Person: []} for r in rows: for t in r[types]: if t in (Movie, Person): lexicon[t].append(r[name]) for k in lexicon: lexicon[k] sorted(set(lexicon[k]), keylen, reverseTrue) return lexicon def extract_entities(question, lexicon): 返回命中的 (类型, 实体名) 列表长词优先 matched [] for label, names in lexicon.items(): for name in names: if name in question and name not in matched: matched.append((label, name)) return matched注意两点。其一UNION 查询里n.alias是列表类型先 UNWIND 再输出别名否则返回的是一整列而不是单个字符串。其二词典按长度降序是必须的「肖申克的救赎」和「肖申克」同时存在时长的先命中否则会被短词截胡。这个方案的天花板在于别名表需要人工维护所以数据入库时就要把「星爷→周星驰」「发哥→周润发」这类映射写进 Person 的 alias 属性每维护一条问答范围就扩大一片。3.3 Cypher 模板生成覆盖 7 类高频电影问句的参数化写法实体抽到后把「意图 实体」拼成 Cypher。模板设计的核心原则是一个意图对应一条带槽位的模板槽位用参数化传参而不是字符串拼接既防注入又避免引号转义问题。# qa/cypher_gen.py def build_cypher(intent, entity): 把意图和实体映射成 Cypher 参数化查询 if intent movie_rating: return (MATCH (m:Movie {title: $entity}) RETURN m.title AS title, m.rating AS rating), {entity: entity} if intent movie_release: return (MATCH (m:Movie {title: $entity}) RETURN m.title AS title, m.release_year AS year), {entity: entity} if intent actor_movies: return (MATCH (p:Person {name: $entity})-[:ACTED_IN]-(m:Movie) RETURN m.title AS title, m.release_year AS year), {entity: entity} if intent director_movies: return (MATCH (p:Person {name: $entity})-[:DIRECTED]-(m:Movie) RETURN m.title AS title), {entity: entity} if intent movie_actors: return (MATCH (m:Movie {title: $entity})-[:ACTED_IN]-(p:Person) RETURN p.name AS name), {entity: entity} if intent genre_recommend: return (MATCH (m:Movie)-[:BELONGS_TO]-(g:Genre {name: $entity}) RETURN m.title AS title, m.rating AS rating ORDER BY m.rating DESC LIMIT 10), {entity: entity} if intent actor_coop: return (MATCH (p1:Person {name: $entity})-[:ACTED_IN]-(m:Movie) -[:ACTED_IN]-(p2:Person) RETURN DISTINCT p2.name AS name, m.title AS title), {entity: entity} return None, None关系方向是这段最容易错的地方。第 5 行(m:Movie)-[:ACTED_IN]-(p:Person)的箭头指向 Person是因为模板定义里 ACTED_IN 永远是「Person → Movie」反向查主演时要把箭头反过来写方向写反就查不到任何结果。genre_recommend 里的ORDER BY ... LIMIT 10限定返回条数是为了避免「喜剧」关联几百部电影时前端列表爆炸。所有查询统一用$entity参数占位py2neo 会把参数和查询语句分开传给服务器比f...{entity}...拼接安全得多也顺手解决了中文实体名里带引号或书名号的转义问题。4. 跑通源码前后端Flask 接口、问答页面与最小启动步骤4.1 源码项目结构分层放好爬虫、导入脚本、问答逻辑与 Web 层这类问答系统的源码目录我习惯按「数据 → 图谱 → 问答 → 展示」四层分。结构清晰不仅自己好改答辩演示时评委扫一眼目录就能理解你的工程组织能力。movie_qa/ ├── spider/ # 数据采集层 │ ├── movie_spider.py │ └── data/ # 生成的 CSV 原始数据 │ ├── movies.csv │ ├── persons.csv │ └── relations.csv ├── data_import/ │ └── import.cypher # 导入脚本Neo4j Browser 里执行 ├── qa/ # 问答核心逻辑层 │ ├── intent.py # 意图分类 │ ├── entity_extractor.py # 实体抽取 │ ├── cypher_gen.py # Cypher 模板 │ └── answer.py # 编排全流程 ├── app.py # Flask 入口 ├── templates/ │ └── index.html # 问答前端页 └── requirements.txt依赖我一般锁这四个flask、py2neo、requests、beautifulsoup4。py2neo 的版本必须和 Neo4j 服务端匹配Neo4j 4.x 用 py2neo 2021.xNeo4j 5.x 要换 neo4j-driver 5.x 且 API 从Graph.run变成driver.execute_query这一点在下一章避坑里详细说。requirements.txt 里把版本号写死避免过几个月重装环境时被大版本升级搞崩。# requirements.txt flask2.3.3 py2neo2021.2.4 requests2.31.0 beautifulsoup44.12.24.2 Flask 后端接口设计/api/chat 的请求响应契约与连接复用后端只需要两个路由一个渲染首页一个接收问答请求。问答接口的契约是 POST 一个 JSON返回一个 JSON字段名固定为 question 和 answer前端后端各守各的边界后续想接小程序或语音输入都不用改后端逻辑。# app.py from flask import Flask, render_template, request, jsonify from qa.answer import answer_question app Flask(__name__) app.route(/) def index(): return render_template(index.html) app.route(/api/chat, methods[POST]) def chat(): data request.get_json() or {} question data.get(question, ).strip() if not question: return jsonify({answer: 请先输入问题}), 400 result answer_question(question) return jsonify({answer: result}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)前端页面用原生 fetch 调接口不需要引 axios 或 jQuery代码更少、答辩更好解释。整个交互就一个输入框和一个回答区回车触发 sendQuestion。// templates/index.html 中的核心逻辑 async function sendQuestion() { const input document.getElementById(q); const question input.value.trim(); if (!question) return; const resp await fetch(/api/chat, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({question: question}) }); const data await resp.json(); document.getElementById(answer).textContent data.answer; }answer.py 里把三个模块串起来先 classify_intent再 extract_entities按意图类型决定用哪个实体movie_rating 用 Movie 实体actor_movies 用 Person 实体查库后把 py2neo 返回的记录控制器转换成中文句子。一个容易忽略的设计是抽不到实体时不要返回空串统一返回引导话术演示时至少不会冷场。# qa/answer.py from py2neo import Graph from qa.intent import classify_intent from qa.entity_extractor import load_lexicon, extract_entities from qa.cypher_gen import build_cypher graph Graph(bolt://localhost:7687, auth(neo4j, mypass)) lexicon load_lexicon() # 启动时加载一次词典 def answer_question(question): intent, kw classify_intent(question) entities extract_entities(question, lexicon) if not entities: return 这个问题我还不会试试问「某电影评分」或「某演员的作品」 # 问演员作品、导演作品、合作关系用 Person 实体其余走 Movie label Person if intent in (actor_movies, director_movies, actor_coop) else Movie entity next((e for t, e in entities if t label), None) if entity is None: return f图库里没有「{question}」对应的{label}实体 cypher, params build_cypher(intent, entity) rows graph.run(cypher, **params).data() if not rows: return f没有找到「{entity}」的相关数据 return format_answer(intent, rows) # 把多行结果拼成一句话或列表这里有一个性能上的关键决策py2neo 的 Graph 对象是线程安全的应放在模块级只初始化一次。我见过不少源码在每次请求里新建 Graph连接握手开销让问答延迟从 200ms 涨到 2 秒答辩现场转圈很难看。lexicon 放模块级的副作用是 Neo4j 没启动时 Flask 起不来这其实是好事——环境没配好启动即报错不用等演示时才翻车。4.3 最小启动流程从装 Neo4j 到浏览器拿到第一条回答把以上源码跑通的最短路径如下。第一次做不要跳步我按踩过的顺序排好了安装 Neo4j Community 4.4启动后用浏览器登录 7474初始账号 neo4j初始密码 neo4j首次登录强制改密码改成你自己的并记牢。把爬虫生成的三个 CSV 复制到 Neo4j 安装目录的 import 文件夹下确认是 UTF-8 编码。在 Neo4j Browser 里按顺序执行 import.cypher先跑约束再跑节点导入最后跑关系导入。在项目根目录执行python -m venv venv建虚拟环境激活后pip install -r requirements.txt。确认 entity_extractor.py 和 answer.py 里的连接串bolt://localhost:7687与密码一致执行python app.py。浏览器访问 http://localhost:5000 输入「周星驰演过哪些电影」第一条回答出现链路就通了。第 6 步如果返回空或报错不要急着改代码先用 Neo4j Browser 手敲一遍对应 Cypher确认数据本身没问题。把「数据问题」和「代码问题」分开排查是这一类全链路项目最高效的排错方式——八成以上的「系统跑不通」其实是图库里根本没数据。5. 源码复现避坑指南电影问答系统跑不通的五类典型问题把源码复现过程中最常见的五类问题按现象、原因、解决三个维度列出来。每一类我都踩过或者看过别人踩按这个清单排查可以省下大半天。5.1 Neo4j 连接失败Bolt 端口、驱动版本与首次改密现象启动 app.py 后第一次提问报Failed to establish connection或ServiceUnavailable浏览器里 Neo4j 却正常登录。原因大部分是端口或版本造成的。py2neo 默认走 Bolt 协议 7687 端口很多新手写成 http://localhost:7474那是给浏览器用的 HTTP 端口另外 Neo4j 5.x 与 py2neo 2021.x 的认证握手不兼容报错信息又不会明说版本问题。解决连接串统一写成bolt://localhost:7687在 neo4j.conf 里确认dbms.connector.bolt.enabledtrueNeo4j 4.4 搭配 py2neo 2021.2.4Neo4j 5.x 改用 neo4j-driver 5.x并把Graph(...)换成驱动类的execute_query写法。首次启动后密码没有改的话py2neo 会一直拿到 401去 7474 页面改一次即可。5.2 LOAD CSV 中文乱码Excel 另存为的编码陷阱现象导入成功后节点 title 变成鐢靛奖一类乱码但 CSV 用记事本打开是正常的。原因用 Excel 打开后直接另存为 CSV默认按 ANSI/GBK 编码写出而 Neo4j 的 LOAD CSV 按 UTF-8 读取两者对不上就全盘乱码。解决保存 CSV 时选择「CSV UTF-8」格式或者在 Python 里统一用encodingutf-8-sig写文件。判断编码有一个土办法把 CSV 拖进浏览器显示正常中文说明是 UTF-8乱码就用编辑器转一次编码再放回 import 目录。5.3 Cypher 查不到结果标签名、关系方向与属性值不一致现象Browser 里MATCH (m:Movie) RETURN m LIMIT 5有数据但问答接口返回「查不到」手敲模板里的 Cypher 却返回空。原因三类情况最多。一是标签名拼写不一致导入时:movie小写、查询时:Movie大写二是关系方向写反ACTED_IN 建成了 Person→Movie查询却写成(m:Movie)-[:ACTED_IN]-(p:Person)三是属性值带了多余空格或全角字符比如「周星驰 」末尾有个空格等于没匹配上。解决在 Browser 里先跑MATCH p()-[]-() RETURN p LIMIT 50看实际关系方向再对属性值做一次trim()。我一般会在导入脚本最后给所有 name/title 统一加一句SET m.title trim(m.title)从源头把空格问题清掉。5.4 实体抽取漏匹配别名、繁体与分词碎词现象问「星爷的代表作」返回空但图库里确实有周星驰问「大话西游之月光宝盒好看吗」抽出来的实体是「月光宝盒」。原因实体词典里只有节点 name没有 aliasjieba 分词把长电影名切碎部分用户输入繁体或半角符号与图库数据不一致。解决加载词典时把alias属性一起拉进来见 3.2 的 UNION 写法匹配时对输入先做一次归一化全角转半角、去空格、繁体转简体。词典命中失败时再降级用分词切出的连续名词片段去匹配作为最后的兜底方案。5.5 首次提问慢到怀疑人生连接池与预热现象系统启动后第一次问答要等 510 秒后面几次就快了或者并发访问时连接数暴涨。原因Neo4j 服务端首次被访问时 JVM 要做类加载和编译优化这是服务端预热问题客户端方面每个请求新建 Graph 实例会反复创建 Bolt 连接握手开销全叠在请求里。解决服务端预热没办法完全消除答辩前先手动访问一次即可客户端把 Graph 实例放在模块顶层全局变量py2neo 内部会复用连接。如果同时还要做图谱可视化页面建议把查询接口和图数据库访问都走同一个 Graph 实例避免多个连接池互相抢资源。6. 验收加分技巧测试集量化准确率、图谱可视化与演示护场准备验收阶段除了功能演示还要准备能被量化的评估结果和演示预案这两样比界面漂亮更能拿分。6.1 用短问句测试集量化问答准确率光演示几个成功案例不够评委大概率会问「准确率多少」。用一组固定测试问题来量化这个问题集本身也能反推你的覆盖范围# test/test_qa.py from qa.intent import classify_intent from qa.entity_extractor import extract_entities, load_lexicon TEST_CASES [ (周星驰演过哪些电影, actor_movies, 周星驰), (《肖申克的救赎》评分多少, movie_rating, 肖申克的救赎), (王家卫导演过哪些作品, director_movies, 王家卫), (《霸王别姬》的主演是谁, movie_actors, 霸王别姬), (推荐几部高分喜剧, genre_recommend, 喜剧), ] lexicon load_lexicon() def evaluate(): hit 0 for question, expected_intent, expected_entity in TEST_CASES: intent classify_intent(question)[0] entities extract_entities(question, lexicon) ok (intent expected_intent and any(e expected_entity for _, e in entities)) hit 1 if ok else 0 print(question, ✓ if ok else ✗) print(f准确率: {hit}/{len(TEST_CASES)})把这个测试集扩充到 50 条左右每条覆盖一个意图和一种问法变体评估结果写进论文的实验章节。这个数字比口头说「效果还不错」有说服力得多。6.2 图谱可视化与演示护场可视化直接用 Neo4j Browser 自带的图展示或者用 py2neo 把查询结果转成 JSON 返回前端用 vis.js 画节点关系。演示时有一个护场技巧把 6.1 的测试集跑一遍把回答截图按顺序放进 PPT现场演示如果 Neo4j 服务起不来或网络波动直接切截图讲不打断答辩节奏。我自己的习惯是答辩前一天清空 Neo4j 数据重新导入一遍确认从零启动的完整流程没问题而不是抱着跑了好几天的旧环境上场。现场演示最怕的不是功能弱而是环境崩。希望帮到你。本文还有配套的精品资源点击获取