基于知识图谱的电影推荐系统:Neo4j建模与Flask实战解析
发布时间:2026/10/2 20:59:28 锦皓数字建站

简介一套基于知识图谱的Python电影推荐系统毕业设计源码面向计算机相关专业学生及开发者适合用于课程设计、学期综合实践或毕业设计参考。项目采用模块化架构融合知识图谱技术和协同过滤算法旨在缓解传统推荐系统的冷启动问题。资源包共67个文件以43个Python脚本为核心另含SQL数据库文件、配置文件、Markdown文档及备份文件整体大小892KB结构清晰便于按模块学习。已有61人学习下载。源码涵盖知识图谱构建、用户行为分析、语义推荐等完整流程包含导演、演员、类型、题材等实体关系的电影知识网络并遵循PEP8编码规范配有详细注释和技术文档。数据集经多维度清洗提供环境配置指南和部署教程可直接运行或二次开发能帮助读者掌握推荐系统与知识图谱的工程实现方法。1. 基于知识图谱的电影推荐把“黑匣子打分”换成“能讲出理由的路径”电影推荐这个题目每年毕业设计都有几十个人在写但大多数人交出来的东西是“拿协同过滤算个 TopN 塞进 Web 页面”。你换成知识图谱来做推荐逻辑就从“黑匣子打分”变成了“能查得到路径的图谱关系”为什么给你推《无间道》因为你看过《英雄本色》而这两部电影的导演是同一个人。本项目的完整流程是先准备数据、再用 Neo4j 构建实体关系、用 Cypher 写推荐查询最后用 Flask 包成 Web 前端展示整套源码结构可以直接照着落地。它适合三类人还没定题目的学生、想给协同过滤补上可解释性的工程师、以及想入门图数据库的 Python 开发者。2. 先建模再写码电影知识图谱的实体与关系设计2.1 实体、关系与属性画一张能被 Neo4j 直接落地的图知识图谱项目最常见的翻车点不是代码写不出来而是没想清楚“图里要放什么、不放什么”就急着建节点。先明确一个结论推荐系统里的知识图谱不是把数据库里所有表都搬进图里而是只保留“能支撑推荐解释”的实体和关系。以电影推荐为例我一般只保留五类实体用户、电影、演员、导演、类型。属性也尽量克制每类实体只放推荐逻辑真正用得到的字段实体必要属性关系关系方向Useruser_id, 年龄段, 职业编码RATED看过并评分User - MovieMoviemovie_id, 标题, 年份, 简介BELONGS_TO属于某类型Movie - GenreActorperson_id, 姓名ACTED_IN出演Actor - MovieDirectorperson_id, 姓名DIRECTED执导Director - MovieGenregenre_id, 类型名无出边仅被引用为什么用户属性里不放“评分均值”因为图查询里用AVG()聚合即可存进去反而造成冗余更新。为什么不把“简介”拆成关键词节点因为毕设阶段关键词抽取会引入大量噪声简介保留为全文检索字段即可知识点要聚焦在图上。有几个建模原则是经验之谈第一Movie 与 Actor 之间只建 ACTED_IN不要建“主演”和“配角”两种关系。绝大多数公开数据集不区分番位强行区分会引入标注错误。第二类型不要做成 Movie 的属性要做成 Genre 节点。一旦做属性你就无法用MATCH (m:Movie)-[:BELONGS_TO]-(g:Genre {name:犯罪})这种路径查询而路径正是知识图谱推荐的解释依据。第三用户实体必须保留。有人说“推荐系统只需要电影和演员关系就够了用户单独存 MySQL”但这样就无法写用户-电影-演员-电影这种链式推荐查询而这是本项目最有区分度的亮点。2.2 从原始数据到 CSV用 pandas 清洗电影与演员字段数据源我用 MovieLens 作为评分主数据因为它的评分格式教科书般规范演员和导演信息用豆瓣或 TMDB 爬虫补充。这里不展开爬虫细节重点讲清洗环节——你爬回来的中文数据字段一定是脏的。豆瓣页面上“主演”字段的经典格式是张国荣 / 张丰毅 / 巩俐 / 葛优而 CSV 里你要的是每个主演一行。常见的错误做法是直接把整串塞进actors属性导致 Cypher 查询里不得不split()再加UNWIND性能差且索引失效。正确做法是清洗阶段就展开成关系表。import pandas as pd # 读取爬虫结果movie_id 为主键 df pd.read_csv(data/movies_douban.csv, encodingutf-8) # 主演字段按斜杠切分去除首尾空格 df[actors] df[actors].fillna().str.split(/) df[actors] df[actors].apply(lambda lst: [x.strip() for x in lst if x.strip()]) # 每部电影展开为多行得到 actor_movie 关系表 actor_movie df.explode(actors)[[movie_id, actors]].copy() actor_movie[person_id] actor_movie.groupby(actors).ngroup() actor_movie.rename(columns{actors: name}, inplaceTrue) # 同时生成演员节点表按 person_id 去重 person actor_movie[[person_id, name]].drop_duplicates(person_id) actor_movie.to_csv(data/actor_movie.csv, indexFalse, encodingutf-8) person.to_csv(data/person.csv, indexFalse, encodingutf-8)这段代码的关键点有三个。str.split(/)之前必须先fillna()否则空值在后续 explode 时会产生 NaN 行ngroup()是给演员分配稳定 ID 的捷径比enumerate更安全因为它在 groupby 分组时不会漏掉重复姓名rename成name是为了对齐 Neo4j 里的属性命名习惯。清洗阶段最容易遗漏的是“去重”。豆瓣同一个演员在不同电影里可能写作“张国荣”和“张国荣主演”你必须在清洗阶段做一次简单归一化。最常见做法是维护一个别名映射表把带括号的、带繁体变体的名字规整到标准名。这一步做不好后面图谱里会出现两个长得一样的演员节点推荐路径就断了。3. 用 Neo4j 落地图谱Cypher 导入与查询的工程细节3.1 LOAD CSV 加速导入约束、索引与字段映射数据清洗成 CSV 之后导入 Neo4j 的方式直接决定项目成败。新手最容易踩的坑是把 CSV 逐行读进 pandas 再通过 py2neo 逐条创建节点——两万条关系能跑十分钟。正确做法是先把 CSV 放进 Neo4j 的import目录然后写一条LOAD CSV语句两秒入库。先建立约束这是 MERGE 语句有效的前提CREATE CONSTRAINT movie_id_unique IF NOT EXISTS FOR (m:Movie) REQUIRE m.movie_id IS UNIQUE; CREATE CONSTRAINT person_id_unique IF NOT EXISTS FOR (p:Person) REQUIRE p.person_id IS UNIQUE;约束建立之后再执行节点导入LOAD CSV WITH HEADERS FROM file:///movies_douban.csv AS row MERGE (m:Movie {movie_id: toInteger(row.movie_id)}) ON CREATE SET m.title row.title, m.year toInteger(row.year), m.genres split(coalesce(row.genres, ), /);这段语句里MERGE而不是CREATE是幂等性的关键。重复执行同一脚本不会生成重复节点ON CREATE SET保证重复执行时不会覆盖已存在的属性toInteger是必须的CSV 里的数字会被当成字符串读取不转换的话后续按年份范围筛选会出错。等节点全部导入后再导关系。这里有一个顺序问题必须先导完所有节点再导关系否则MERGE会在关系导入时自动创建不存在的节点造成“半成品节点”污染图谱。LOAD CSV WITH HEADERS FROM file:///actor_movie.csv AS row MATCH (p:Person {person_id: toInteger(row.person_id)}) MATCH (m:Movie {movie_id: toInteger(row.movie_id)}) MERGE (p)-[:ACTED_IN]-(m);演员表里有些姓名可能存在于多部电影但MATCH之后找不到person_id对应节点时整行会被静默跳过。所以导入完成后一定要跑一次验证查询统计孤立节点数MATCH (p:Person) WHERE NOT (p)--() RETURN count(p);返回结果不是 0 的话说明源数据里的演员没对上电影节点回到清洗阶段补数据。3.2 把“看过 A 的人还爱看 B”翻译成 Cypher 路径图谱建好之后推荐系统的核心就变成了翻译问题。协同过滤里“看了 X 的人还看了 Y”是矩阵计算知识图谱里是“用户和候选电影之间是否存在一条语义路径”。路径长度和方向决定推荐理由。第一个实用的推荐查询是基于“用户看过的电影的演员”来挖掘候选MATCH (u:User {user_id: $userId})-[r:RATED]-(m:Movie) WITH u, m, r.rating AS rating MATCH (m)-[:ACTED_IN]-(actor:Person) MATCH (actor)-[:ACTED_IN]-(candidate:Movie) WHERE candidate.movie_id m.movie_id AND NOT EXISTS { MATCH (u)-[:RATED]-(candidate) } WITH candidate, count(DISTINCT actor) AS shared_actors, avg(rating) AS user_level RETURN candidate.title, shared_actors, user_level ORDER BY shared_actors DESC, user_level DESC LIMIT 20;这段查询有三个工程细节值得说明排除用户已经看过的原片避免推荐结果里混入“正在看的电影”NOT EXISTS子查询过滤掉用户已经评分过的电影这是做 TopN 推荐前必须做的去重count(DISTINCT actor)的语义是“这部电影和用户看过的电影共享了多少个演员”这个值在推荐解释里很有说服力。第二种常用的查询思路是基于类型的召回然后把图谱路径作为排序加分项。你可以对比一下效果没有图谱时你只能按评分均值排序有了图谱你可以在 ORDER BY 里加上“候选电影的类型覆盖用户偏好的数量”推荐结果就更贴近用户近期口味。这里还要提一个性能参数Neo4j 的查询默认给单个查询分配一定内存当图谱超过 30 万条关系时上述 Cypher 中的count(DISTINCT actor)可能触发内存不足。解决办法是在 Neo4j 配置文件里调大dbms.memory.transaction.max_size但更推荐的做法是给这个查询限定候选集范围先筛选年份和类型再做路径计算不把全图 20 万部电影全部拉进排序。3.3 图投影与统计先看数据分布再定推荐策略很多人建完图直接写推荐接口结果召回结果很差。问题不在算法在于没看过图的度分布。Neo4j 里跑一行统计就能发现真相MATCH (:Actor)-[r:ACTED_IN]-(:Movie) RETURN count(r) AS total_rels, count(DISTINCT r) AS unique_rels;如果 total_rels 远大于 unique_rels说明同一对演员和电影之间存在重复关系这是前面LOAD CSV里没有去重造成的需要先合并再去重。另一个要紧的统计是演员的度分布MATCH (p:Person)-[:ACTED_IN]-(:Movie) WITH p, count(*) AS degree RETURN avg(degree), percentileCont(degree, 0.9), max(degree);如果 90% 分位的度小于 5说明图谱太稀疏路径推荐大概率捞不到足够候选。遇到这种情况不要改算法先回数据源补演员信息。知识图谱推荐系统里数据密度比算法复杂程度重要得多。4. Python 侧打通推荐链路py2neo、Flask 与前端展示4.1 用 py2neo 封装查询版本差异是最大的隐藏坑Neo4j 数据准备好后Python 侧需要连接图数据库并对外提供接口。py2neo 是这个场景下最常用的库但它的接口每个大版本之间变化很大网上抄来的代码极易报错。我建议写代码前先确认你用的 py2neo 版本对应的 API 形式下面给出的是使用较广泛的 2021 系列版本写法。from py2neo import Graph # 连接默认端口 7687HTTP 端口一般用 7474 graph Graph(bolt://localhost:7687, auth(neo4j, your_password), nameneo4j) # 跑一段 Cypher返回 DataFrame 便于后处理 res graph.run( MATCH (u:User {user_id: $userId})-[r:RATED]-(m:Movie) RETURN m.title AS title, r.rating AS rating ORDER BY rating DESC LIMIT 10 , userId123).to_data_frame()连接参数里bolt://和http://容易被忽视。py2neo 默认走 Bolt 协议端口是 7687如果你填了http://localhost:7474部分版本也能连但查询性能会差不少。nameneo4j指定的是数据库名如果你的 Neo4j 版本默认数据库就叫 neo4j可以不写但多实例环境里这个参数能避免连错库。graph.run()里传参一定要用 Cypher 参数化方式即$userId不要用 f-string 拼进字符串。一方面防止 Cypher 注入另一方面参数化查询会复用执行计划多次调用性能明显更好。4.2 把推荐结果包成 Flask 接口每个接口只干一件事推荐逻辑不适合全部塞进一个接口。我一般拆三个/api/recommend/path返回基于路径的推荐/api/graph/query返回以某部电影为中心的局部图谱用于前端可视化/api/hot返回全站热门用于未登录用户。下面给出第二个接口的实现它直接决定前端能否画出知识图谱连线from flask import Flask, jsonify, request from py2neo import Graph app Flask(__name__) graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) app.route(/api/graph/query) def movie_graph(): movie_id request.args.get(movie_id, typeint) if not movie_id: return jsonify({error: movie_id is required}), 400 cypher MATCH (m:Movie {movie_id: $movie_id}) OPTIONAL MATCH (m)-[r1:ACTED_IN]-(actor:Person) OPTIONAL MATCH (m)-[r2:BELONGS_TO]-(genre:Genre) WITH m, collect(DISTINCT actor.name) AS actors, collect(DISTINCT genre.name) AS genres RETURN m.title AS title, m.year AS year, actors, genres result graph.run(cypher, movie_idmovie_id).data() if not result: return jsonify({error: movie not found}), 404 movie result[0] nodes [{id: movie_id, name: movie[title], category: movie}] links [] for actor in movie[actors]: nodes.append({id: fa_{actor}, name: actor, category: actor}) links.append({source: movie_id, target: fa_{actor}, relation: 出演}) for genre in movie[genres]: nodes.append({id: fg_{genre}, name: genre, category: genre}) links.append({source: movie_id, target: fg_{genre}, relation: 属于}) return jsonify({nodes: nodes, links: links})这个接口把图谱数据转成前端可渲染的{nodes, links}结构是知识图谱前端可视化最通用的数据格式。注意collect(DISTINCT ...)去重是因为一部电影的演员列表可能重复出现不去重前端连线会产生重复边。OPTIONAL MATCH保证某部电影没有演员时不丢节点数据这个在数据不全的测试环境里经常救场。前端我用 ECharts 的graph类型展示图谱连线。配置上只需要注意三个点layout: force启用力导向布局节点会自动散开categories按节点类型染色让演员、电影、类型一眼可辨edgeSymbol设成[circle, arrow]显示出边方向。这样一个图谱可视化页面就完成了整体工程量不大但展示效果很好。5. 避坑知识图谱推荐系统的 5 个高频翻车现场5.1 LOAD CSV 导入中文乱码文件编码成了第一道坎做知识图谱电影推荐数据基本绕不开中文。第一次用LOAD CSV加载豆瓣爬虫结果时最常见情况是在浏览器里看数据一切正常导入后全部变成乱码。原因基本是 Neo4j 的 import 目录读取文件时默认按 UTF-8 解码而 Windows 下 Excel 或部分爬虫脚本保存成了 GBK 编码。解决方法是重新转码做一层预处理不要试图在 Cypher 里解决编码问题iconv -f GBK -t UTF-8 movies_douban.csv movies_douban_utf8.csv注意转换后要用file命令查一下编码是否真的变成 UTF-8有些场景下源文件是 GB18030iconv目标编码要相应调整。转换完重新导入即可。这条没有技术含量但能省掉一晚上排查时间。5.2 py2neo 版本换接口旧代码在新库上直接不工作py2neo 从 4.x 升到 5.x 时Graph.run()返回的对象从Cursor变成了Result很多人的老代码在.data()后面拿不到结果。安装包时不做版本锁定大量时间会花在追接口变化上。pip install py2neo2021.2.3这个版本与多数 Neo4j 4.x 服务端兼容性较好能避开auth参数变更和name参数不识别的问题。如果你用的是 Neo4j 5.x则要确认你用的 py2neo 版本支持 Bolt 协议 5.0否则连接直接失败。写代码前先跑一行最小连接测试再写业务逻辑。5.3 MERGE 幂等性失效重复执行导入脚本生成重复节点MERGE只在匹配条件覆盖的属性上有约束作用。如果你MERGE (m:Movie {title: row.title})但两张 CSV 里同一部电影的标题不完全一致比如一个带年份一个不带就会生成两个节点。这就是为什么建模时一定要有稳定的业务主键movie_id。解决方式是建约束时给movie_id、person_id都加唯一性约束导入前用 Cypher 查一下是否已有半成品数据MATCH (m:Movie) WITH m.title AS title, count(*) AS cnt WHERE cnt 1 RETURN title, cnt LIMIT 20;这个查询能把标题重复的节点提前暴露出来。5.4 关系数据不完整导致推荐稀疏先补数据再调算法知识图谱偏好路径推荐非常依赖关系的覆盖率。如果你的图谱里只有 20% 的电影有完整演员关系推荐召回率会低得可怜。很多人在这种状态下强行加图算法模型实际上方向错了。血泪经验是先把覆盖度补到 80% 以上再回来看推荐效果。每轮迭代先跑一遍上文的度分布统计确认 Actor 节点平均度大于 5 再做算法调优。另外还要注意 CSV 里“演员名字重复但实际不是同一人”的问题。比如两个同名同姓但不同代的演员如果在清洗时没有加区分字段就会合并成一个人。处理方式是给 Person 节点加上出生年份或头像 URL 作为辅助判别属性不要只靠姓名做唯一键。5.5 ECharts 前端渲染卡死节点太多也一样要 limit图谱可视化最容易踩的坑是把全图的路径查询结果全部返回前端。一次MATCH (p:Person)-[:ACTED_IN]-(m:Movie)查询能返回几十万条浏览器直接崩溃。常见做法是在服务端对节点数量设硬上限比如取前 50 节点和前 100 条边再补一句“图谱仅展示部分关系”的文案。这样既守住接口性能也不会让前端体验崩掉。6. 验证与进阶从“能跑”到“能答辩”项目做到能跑只是第一步答辩时老师大概率会问“你的推荐效果凭什么比协同过滤好”。这个问题不能只答“因为知识图谱能解释”要有数字支撑。常见做法是拿 MovieLens 的评分数据做离线评测把用户的评分数据按 8:2 分成训练集和测试集在训练集上建用户偏好路径在测试集上计算推荐命中率。这里不需要复杂的评测框架按 TopK 命中率来算就可以。我一般会对比三组数字纯热门推荐、协同过滤推荐、图谱路径推荐的 Top10 命中率。多数情况下图谱路径推荐在冷启动用户上优势明显因为新用户只要看过一部电影就能通过演员、导演路径扩展出候选集而在行为丰富的用户上三者的命中率差异可能不大这时候要强调图谱推荐的可解释性价值——用户能清楚地看到“因为你有《霸王别姬》再看张国荣其他作品”的推荐理由。进阶方向上有两个点值得投入。第一个是给不同关系路径加权重比如“演员共现”的推荐权重设为 0.6“导演共现”设为 0.3“类型相似”设为 0.1这样排序结果会更贴近真实偏好第二个是引入元路径的设计思想把用户-电影-演员-电影和用户-电影-类型-电影定义成两条独立的元路径分别计算候选电影的得分再做加权融合这就形成了多视角推荐比单一路径稳定得多。最后一个忠告不要在调试阶段把所有功能一次性写完也别信“一套源码改改就能交”的省事逻辑。先跑通一条最小链路——导入 500 部电影、写一个路径查询、用 Flask 暴露一个接口、前端画出一个图谱再逐步扩数据。我做过太多项目都是卡在最后一步调试前端连线上后悔药只有一颗把数据层和接口层的边界切干净前端拿到的永远是标准化 JSON。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。