简介:这份资源是面向计算机相关专业毕业设计与项目实战学习者的知识图谱电影推荐系统完整源码,基于Python实现,难度适中,适合作为毕设、课程设计或期末大作业的参考方案。项目围绕知识图谱构建电影推荐逻辑,涵盖数据爬取、电影信息整理与评分处理等模块,能帮助读者理解推荐系统与图谱结合的实现思路。压缩包共55个文件,以43个py源码文件为核心,另含4个txt数据说明、4个cfg配置、3个md文档和1个sql建表脚本,整体约890KB,结构清晰便于按模块查阅。目前已有155人学习下载。源码经本地编译调试,可运行性有保障,读者可据此快速搭建环境、梳理推荐流程与数据组织方式,并在此基础上完成二次开发或论文实验。
1. 知识图谱电影推荐:从冷启动到可解释推荐的一条落地路径
做电影推荐最头疼的不是模型不够深,而是新用户进来没有任何行为数据,协同过滤直接哑火。基于知识图谱的电影推荐系统,核心思路是把电影、导演、演员、类型、制片公司这些实体以及它们之间的关系抽出来,构建一张语义网络,再通过图上的路径和邻居信息给用户做推荐。它解决的是数据稀疏和冷启动问题,同时因为推荐结果能追溯到具体的关系路径,可解释性天然比矩阵分解强。这套方案适合正在做计算机毕业设计、想找一个有技术深度又能完整跑通的项目的人,也适合想入门知识图谱构建与图推荐算法的 Python 开发者。下面从数据准备、图谱构建、推荐算法到排错,把整条链路拆开讲清楚。
2. 数据从哪来、图谱怎么建:电影知识图谱的构建流程
2.1 数据源选择与字段设计
做知识图谱电影推荐,第一步不是写代码,而是确定数据从哪来。常见做法有三种:一是用 MovieLens 数据集,它自带用户评分和电影元数据,适合做推荐算法的验证;二是从 TMDB 或豆瓣抓取电影详情,能拿到导演、演员、制片国家、时长等更丰富的实体属性;三是直接用 Neo4j 官方提供的电影示例图谱,适合快速验证图查询逻辑。我一般会推荐 MovieLens + TMDB 补充元数据的组合,因为 MovieLens 的评分数据质量高,TMDB 的实体关系全。
字段设计上,至少要覆盖以下几类实体和关系:
| 实体类型 | 关键属性 | 关系类型 | 关系方向 |
|---|---|---|---|
| 电影 | 电影ID、标题、上映年份、评分 | 属于类型 | 电影 → 类型 |
| 用户 | 用户ID、年龄、职业 | 评分 | 用户 → 电影 |
| 导演 | 导演ID、姓名 | 执导 | 导演 → 电影 |
| 演员 | 演员ID、姓名 | 出演 | 演员 → 电影 |
| 类型 | 类型名 | — | — |
这个表看起来简单,但实际建图时最容易翻车的地方是实体对齐。比如同一部电影在 MovieLens 里叫 “Toy Story”,在 TMDB 里叫 “Toy Story (1995)”,如果不做标题归一化,图谱里就会出现两个节点,后续推荐直接错乱。
2.2 用 Python 构建图谱并导入 Neo4j
数据准备好之后,用 Python 把 CSV 转成 Neo4j 能吃的 Cypher 语句。下面是一个最小可运行的构建脚本:
import pandas as pd from neo4j import GraphDatabase # 连接 Neo4j,默认 bolt 端口 7687 driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) # 读取 MovieLens 的 movies.csv 和 ratings.csv movies = pd.read_csv("movies.csv") ratings = pd.read_csv("ratings.csv") # 提取类型字段,按 | 拆分 movies["genres"] = movies["genres"].str.split("|") def create_movie_node(tx, movie_id, title, year): # 使用 MERGE 避免重复创建节点 tx.run("MERGE (m:Movie {movieId: $movieId, title: $title, year: $year})", movieId=movie_id, title=title, year=year) def create_genre_relation(tx, movie_id, genre): tx.run(""" MATCH (m:Movie {movieId: $movieId}) MERGE (g:Genre {name: $genre}) MERGE (m)-[:BELONGS_TO]->(g) """, movieId=movie_id, genre=genre) with driver.session() as session: for _, row in movies.iterrows(): # 从标题中提取年份,如 "Toy Story (1995)" title = row["title"] year = None if "(" in title and ")" in title: year = title[title.rfind("(")+1:title.rfind(")")] title = title[:title.rfind("(")].strip() session.execute_write(create_movie_node, row["movieId"], title, year) for genre in row["genres"]: session.execute_write(create_genre_relation, row["movieId"], genre) driver.close()这段代码的逻辑是:先读 CSV,把电影标题里的年份拆出来单独存,然后逐条写入 Neo4j。MERGE而不是CREATE是关键,因为同一部电影可能出现在多行数据里,用MERGE能保证节点唯一。参数方面,bolt://localhost:7687是 Neo4j 默认的 Bolt 协议地址,如果你改了端口要同步改。auth里的密码是你安装 Neo4j 时设置的,不是默认的 “password”,这里只是占位。
导入完成后,可以在 Neo4j Browser 里跑一条验证查询:
MATCH (m:Movie)-[:BELONGS_TO]->(g:Genre) RETURN m.title, g.name LIMIT 10如果能看到电影和类型的对应关系,说明图谱骨架已经建好了。接下来要做的就是把用户评分也写进去,形成用户-电影-类型的完整三元组。
2.3 用户评分关系的批量导入与索引优化
用户评分数据量通常比电影元数据大得多,MovieLens 的 ratings.csv 动辄几十万行,逐条MERGE会非常慢。我一般会先用LOAD CSV命令在 Neo4j 里直接导入,比 Python 循环快一个数量级:
// 先给 User 和 Movie 建唯一约束,加速后续匹配 CREATE CONSTRAINT user_id IF NOT EXISTS FOR (u:User) REQUIRE u.userId IS UNIQUE; CREATE CONSTRAINT movie_id IF NOT EXISTS FOR (m:Movie) REQUIRE m.movieId IS UNIQUE; // 批量导入评分关系 LOAD CSV WITH HEADERS FROM 'file:///ratings.csv' AS row MATCH (u:User {userId: toInteger(row.userId)}) MATCH (m:Movie {movieId: toInteger(row.movieId)}) MERGE (u)-[r:RATED {score: toFloat(row.rating)}]->(m);这里有两个坑要注意:第一,LOAD CSV的文件必须放在 Neo4j 安装目录的import文件夹下,否则会报找不到文件;第二,toInteger和toFloat不能省,CSV 读进来默认是字符串,直接拿去匹配会全部失败。建完索引之后,查询用户看过的电影从秒级降到毫秒级,这个优化在后续推荐查询里非常关键。
3. 推荐算法怎么选:基于图路径与图嵌入的两条路线
3.1 基于规则路径的推荐:可解释性最强
图谱建好之后,最直接的推荐方式就是找路径。比如给用户推荐电影,可以走这样一条逻辑:用户看过某部电影 → 这部电影属于某个类型 → 找同类型的其他电影 → 排除用户已经看过的 → 按评分排序返回。用 Cypher 写出来就是:
MATCH (u:User {userId: 1})-[:RATED]->(m:Movie)-[:BELONGS_TO]->(g:Genre) MATCH (rec:Movie)-[:BELONGS_TO]->(g) WHERE NOT (u)-[:RATED]->(rec) RETURN rec.title, g.name, count(*) AS score ORDER BY score DESC LIMIT 10这条查询的逻辑是:先找到用户 1 看过的电影及其类型,再找同类型的其他电影,排除已看过的,按共同类型数量打分。优点是结果完全可解释——你可以告诉用户“因为你看过《玩具总动员》,所以推荐同类型的《怪物公司》”。缺点是推荐多样性差,容易陷入同一类型的循环。
实际项目中,我会在这条路径上再加一层导演或演员的关系,比如:
MATCH (u:User {userId: 1})-[:RATED]->(m:Movie)<-[:DIRECTED]-(d:Director)-[:DIRECTED]->(rec:Movie) WHERE NOT (u)-[:RATED]->(rec) RETURN rec.title, d.name AS director, count(*) AS score ORDER BY score DESC LIMIT 10这样推荐结果就变成了“因为你喜欢某导演的作品,所以推荐他执导的其他电影”,解释性更强,也更容易让用户信服。
3.2 基于图嵌入的推荐:用 Node2Vec 生成向量再算相似度
规则路径的推荐在数据量大时查询会变慢,而且无法捕捉高阶的语义相似性。这时候可以用图嵌入方法,把节点映射成低维向量,再用向量相似度做推荐。Node2Vec 是常用的一种,它通过随机游走生成节点序列,再用 Word2Vec 训练出每个节点的向量表示。
import networkx as nx from node2vec import Node2Vec from sklearn.metrics.pairwise import cosine_similarity import numpy as np # 从 Neo4j 导出边列表,构建 NetworkX 图 # 这里假设已经导出了 user_movie_edges.csv 和 movie_genre_edges.csv G = nx.Graph() edges = pd.read_csv("user_movie_edges.csv") for _, row in edges.iterrows(): G.add_edge(f"u{row['userId']}", f"m{row['movieId']}", weight=row['rating']) # Node2Vec 参数:dimensions 是向量维度,walk_length 是游走长度 node2vec = Node2Vec(G, dimensions=64, walk_length=30, num_walks=200, workers=4) model = node2vec.fit(window=10, min_count=1, batch_words=4) # 获取所有电影节点的向量 movie_nodes = [n for n in G.nodes() if n.startswith("m")] movie_vectors = np.array([model.wv[n] for n in movie_nodes]) # 计算电影之间的余弦相似度 sim_matrix = cosine_similarity(movie_vectors) # 给用户推荐:找用户看过电影的最相似电影 def recommend_for_user(user_id, top_k=10): rated_movies = [n for n in G.neighbors(f"u{user_id}") if n.startswith("m")] scores = {} for m in rated_movies: idx = movie_nodes.index(m) sim_scores = sim_matrix[idx] for i, s in enumerate(sim_scores): if movie_nodes[i] not in rated_movies: scores[movie_nodes[i]] = scores.get(movie_nodes[i], 0) + s return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]参数说明:dimensions=64是向量维度,一般 64 到 128 之间效果比较均衡;walk_length=30是每次随机游走的步数,太长会引入噪声,太短捕捉不到高阶关系;num_walks=200是每个节点游走的次数,数据量大时可以适当减少。window=10是 Word2Vec 的上下文窗口,控制游走序列中多远的关系会被视为共现。
这条路线的好处是推荐结果更丰富,能发现“虽然类型不同但用户群体重叠”的电影。代价是可解释性下降,你很难说清楚为什么这两部电影相似。实际项目中,我一般会把两种方法的结果做加权融合,规则路径占 60%,图嵌入占 40%,既能保证解释性,又能提升多样性。
4. 避坑与排查:知识图谱电影推荐最常见的 5 个翻车点
4.1 实体对齐没做,图谱里全是重复节点
现象:导入数据后发现同一个导演出现了多个节点,比如 “Christopher Nolan” 和 “Christopher Nolan (1967)”,导致推荐时路径断裂。
原因:不同数据源的命名规范不一致,有的带年份,有的带中间名,有的用全角括号。
解决:在导入前统一做字符串归一化,去掉括号内容、转小写、去除首尾空格。可以用 Python 的re.sub(r'\(.*?\)', '', name).strip().lower()做预处理,再写入 Neo4j。
4.2 评分关系导入后查询超时
现象:MATCH (u:User)-[:RATED]->(m:Movie)这种查询跑了几十秒还没返回。
原因:没有给User.userId和Movie.movieId建唯一约束,Neo4j 在做节点匹配时走了全表扫描。
解决:导入前先执行CREATE CONSTRAINT语句建唯一约束,Neo4j 会自动为约束字段创建索引。如果已经导入了数据,可以补建索引:CREATE INDEX FOR (u:User) ON (u.userId)。
4.3 Node2Vec 训练出来的向量推荐结果全是同一部电影
现象:用余弦相似度算出来的 top10 推荐里,有 8 部是同一部电影的续集或翻拍。
原因:随机游走时walk_length设得太长,导致同一部电影的邻居被反复采样,向量过度聚集。
解决:把walk_length从 30 降到 15 到 20 之间,同时增加num_walks来保证采样充分。另外可以在构建图时给不同类型的边设置不同权重,比如用户评分边的权重设为评分值,类型边的权重设为 0.5,让游走更倾向于沿着评分关系走。
4.4 Cypher 查询返回空结果但数据明明存在
现象:MATCH (m:Movie {title: 'Toy Story'}) RETURN m返回空,但 Neo4j Browser 里明明能看到这个节点。
原因:标题里可能有不可见字符,比如从 CSV 读入时带了 BOM 头或者尾部空格。
解决:用MATCH (m:Movie) WHERE trim(m.title) = 'Toy Story' RETURN m先验证,如果这样能查到,说明是空格问题。批量修复可以用MATCH (m:Movie) SET m.title = trim(m.title)。
4.5 推荐结果里出现了用户已经看过的电影
现象:推荐列表里混进了用户评分过的电影,体验很差。
原因:Cypher 查询里用了NOT (u)-[:RATED]->(rec)但rec变量在多个MATCH之间没有正确绑定,导致排除逻辑失效。
解决:把排除条件写在WHERE子句里,并且确保rec是在同一个MATCH链里绑定的。如果查询复杂,可以先用WITH收集用户已看的电影 ID 列表,再用WHERE NOT rec.movieId IN watchedIds来排除。
5. 进阶技巧:用图谱路径做可解释推荐与冷启动兜底
5.1 把推荐理由做成可展示的路径
可解释推荐不是嘴上说说,而是要把图谱里的路径直接返回给前端。比如下面这条查询,不仅返回推荐电影,还返回推荐依据:
MATCH path = (u:User {userId: 1})-[:RATED]->(m:Movie)-[:BELONGS_TO]->(g:Genre)<-[:BELONGS_TO]-(rec:Movie) WHERE NOT (u)-[:RATED]->(rec) RETURN rec.title AS recommendation, g.name AS genre, m.title AS based_on, length(path) AS path_length ORDER BY path_length ASC LIMIT 5返回结果里based_on就是推荐理由的锚点,前端可以展示成“因为你喜欢《玩具总动员》(类型:动画),所以推荐《怪物公司》”。path_length越短说明关系越直接,推荐置信度越高。这个技巧在毕业设计答辩时特别有用,评委一眼就能看懂推荐逻辑。
5.2 冷启动用户的兜底推荐策略
新用户没有任何评分数据,图谱路径走不通。这时候可以用图谱的全局统计信息做兜底:
// 找评分最高且被最多用户看过的电影 MATCH (m:Movie)<-[r:RATED]-() RETURN m.title, avg(r.score) AS avg_score, count(r) AS rating_count ORDER BY rating_count DESC, avg_score DESC LIMIT 10这条查询不依赖任何用户行为,纯粹基于图谱的全局热度。虽然简单,但在冷启动场景下比随机推荐强得多。更进一步,可以结合用户注册时选择的偏好类型,在全局热门里做类型过滤:
MATCH (m:Movie)-[:BELONGS_TO]->(g:Genre {name: 'Animation'}) MATCH (m)<-[r:RATED]-() RETURN m.title, avg(r.score) AS avg_score, count(r) AS rating_count ORDER BY rating_count DESC LIMIT 10这样新用户一进来就能看到符合他偏好类型的热门电影,体验上不会太差。
5.3 用 PageRank 找图谱里的“枢纽电影”
除了路径和嵌入,还可以用图算法找枢纽节点。Neo4j 的 Graph Data Science 库自带 PageRank,可以直接在电影节点上跑:
CALL gds.pageRank.stream('movie-graph') YIELD nodeId, score RETURN gds.util.asNode(nodeId).title AS movie, score ORDER BY score DESC LIMIT 10PageRank 分数高的电影,通常是被大量用户评分且被其他高评分电影连接的电影,可以理解为图谱里的“必看经典”。把这些电影作为推荐列表的兜底项,能有效提升新用户的首次体验。
这套方案我从数据导入到推荐查询完整跑过一遍,最大的血泪经验是:图谱构建阶段多花一小时做实体对齐和索引优化,后面推荐查询能省一整天调优时间。另外,不要一上来就追求复杂模型,先用 Cypher 路径把推荐跑通,再逐步引入图嵌入做融合,这样每一步都有可验证的结果,不会陷入“模型跑了但不知道哪里错了”的黑匣子。希望帮到你。
本文还有配套的精品资源,点击获取