☰
知识图谱驱动的电影推荐系统:Neo4j建模与混合推荐实战
2026/10/11 13:12:13 网站建设 项目流程

简介:一份基于Python与知识图谱的电影推荐系统毕业设计项目,覆盖知识图谱构建、KGCN推荐模型、数据预处理及可视化界面等核心模块,适合计算机相关专业学生用于毕业设计、课程设计或期末大作业,也适合有Python基础的学习者作为项目实战参考。项目曾获导师认可并取得99分的高分评审,代码完整可直接运行,配套说明文档清晰讲解从电影数据清洗、用户评分处理、实体与关系构建到推荐模型训练评估以及Web端展示的完整流程,帮助读者快速掌握知识图谱推荐系统的工程实现。压缩包共31个文件,以21个Python源码文件为主,包含数据加载、模型训练、测试评估、界面启动等多个功能模块,另有5个数据文件、2个txt与readme说明文档及1个md文档,整体大小14.84MB,目录结构组织清晰,便于按模块阅读与二次开发。目前已有80人学习下载,对于需要完成类似课题或想深入理解基于图神经网络推荐方法的学生具有较高参考价值和借鉴意义。

1. 从“协同过滤失效”说起:为什么这个毕设选了知识图谱

做过推荐系统的同学应该都有体会:协同过滤在冷启动场景下几乎没法看,新用户没行为、新电影没评分,相似度矩阵稀疏得一塌糊涂。这个 Python 毕业设计项目没有走常规的 Item-CF 路线,而是把电影、演员、导演、类型做成实体和关系,用知识图谱去承接推荐逻辑。核心思路是:用图结构去描述“电影之间为什么相似”,而不是单纯依赖评分矩阵。源码头尾完整,带说明文档,适合做毕设二开或者想入门图数据库应用的开发者拿来当骨架。你拿到的是一套能从 CSV 数据一路跑到 Web 推荐的完整链路,不是那种只有几个 .py 文件的半成品。

2. 把电影数据变成知识图谱:Neo4j 建模与导入实战

2.1 为什么选 Neo4j 而不是关系型数据库

知识图谱的存储选型,常见选项是 Neo4j、JanusGraph、NebulaGraph 这类图数据库,也有团队直接用 MySQL 加递归查询硬扛。这个项目用的是 Neo4j,原因很现实:生态成熟、Cypher 查询语法学习成本低、Python 驱动 py2neo 和 neo4j 官方 driver 都很稳。关系型数据库表达“某部电影和某部电影共享了 3 个演员”这种多跳关系,要 JOIN 四五张表,写出来的 SQL 又长又难维护,图数据库里一条 MATCH 就解决了。

Neo4j 的底层存储是“无索引邻接表”,节点和关系物理相邻,遍历深度为 3 到 4 跳时性能远优于关系型数据库做等价的递归查询。推荐系统里常见的“找相似电影”本质上就是邻域遍历,图数据库天然契合。项目里图谱的 Schema 也比较干净,核心就三类节点和四类关系,下面会详细说。

2.2 实体对齐:解决“同一个演员两种写法”的问题

电影数据不干净是常态。同一个演员在不同数据源里可能写作“Robert Downey Jr.”和“小罗伯特·唐尼”,同一部电影可能叫“Inception”也叫“盗梦空间”。直接把原始数据灌进图数据库,后面做推荐时相似度计算全是脏数据。所以在建图谱之前,加了一层实体对齐和归一化处理,常见做法是加载数据后做一次脱重和字段清洗,代码大致长这样:

import pandas as pd import re df_movie = pd.read_csv('movies.csv') df_actor = pd.read_csv('actors.csv') def normalize_name(name: str) -> str: """ 实体对齐前的归一化:去掉首尾空格、统一大小写、去除中间多余空格。 这里不做模糊匹配,只做精确归一,模糊匹配放到 Neo4j 的 apoc.text.phonetic 里做。 """ if not isinstance(name, str): return '' name = name.strip().lower() name = re.sub(r'\s+', ' ', name) return name df_actor['name_norm'] = df_actor['actor_name'].apply(normalize_name) df_movie['title_norm'] = df_movie['title'].apply(normalize_name)

逻辑说明:先对演员名和电影名做字符串级归一化,消除空格、大小写这类低等级噪声。像“Robert Downey Jr.”和“robert downey jr.”会在这里被合并,但“Robert Downey Jr.”和“RDJ”这种还得靠更复杂的相似度算法,一般放到图数据库里用 apoc 插件算 Jaccard 相似度,不在这层硬刚。参数说明:normalize_name函数是纯字符串处理,不改原始数据,输出列留给后续实体对齐使用,原始字段保留做展示用。

2.3 导入策略:大批量写入用 UNWIND 而不是逐条 CREATE

新手常犯的错误是拿到 CSV 后写一个 for 循环,每行执行一次CREATE语句,数据量到了几千条就会慢得让人怀疑人生。这个项目里数据量大约一万多条实体,正确的导入姿势是先用LOAD CSV或者 pandas 读入内存,然后拼成参数列表一次性UNWIND写入。下面是关键代码:

from neo4j import GraphDatabase driver = GraphDatabase.driver( "bolt://localhost:7687", auth=("neo4j", "your_password") ) def import_movies(tx, batch_data): query = """ UNWIND $batch AS row MERGE (m:Movie {movie_id: row.movie_id}) SET m.title = row.title, m.year = row.year, m.genre = row.genre MERGE (g:Genre {name: row.genre}) MERGE (m)-[:HAS_GENRE]->(g) """ tx.run(query, batch=batch_data) batch = df_movie[['movie_id', 'title', 'year', 'genre']].to_dict('records') with driver.session() as session: # 分批写入,每次 500 条,避免单次事务过大导致 Neo4j 内存压力 for i in range(0, len(batch), 500): session.execute_write(import_movies, batch[i:i+500])

逻辑说明:UNWIND将 Python 列表展开成图数据库内部的行流,配合MERGE实现“不存在则创建,存在则忽略”的幂等写入。MERGE不是CREATE,它先查后建,天然避开了重复导入导致的节点冗余。参数说明:batch是字典列表,每个字典对应一行数据;movie_id是唯一键,MERGE的匹配依赖它;分片大小取 500 是一个工程折中,事务太小则网络往返开销明显,事务太大会撑爆 Neo4j 的堆内存。整个导入过程先建Movie和Genre节点,再挂HAS_GENRE关系,演员和导演的关系同理,只是换成了ACTED_IN和DIRECTED。

从这步往后,知识图谱就具备了基本查询能力,比如查询某部电影的邻居节点,也就是它的类型、导演和演员。下一步要思考的是:这个图谱如何真正驱动推荐逻辑?

3. 推荐引擎核心:图相似度计算与多跳路径推荐

3.1 从“邻居重合度”到推荐:Jaccard 相似度的图实现

推荐引擎是这套系统的核心模块。传统协同过滤是把用户-物品交互矩阵做余弦相似度,而知识图谱方案换了思路:两个电影节点如果共享的邻居节点越多,它们就越相似。这里的邻居可以是演员、导演、类型,也可以是“看过电影A的用户也看过电影B”里的用户节点,把用户也建模进图里,就成了异构信息网络。

图数据库里计算 Jaccard 相似度非常直接,两条 MATCH 就能搞定:

MATCH (m1:Movie {title: 'Inception'})-[:ACTED_IN|:DIRECTED|:HAS_GENRE]->(shared)<-[:ACTED_IN|:DIRECTED|:HAS_GENRE]-(m2:Movie) RETURN m2.title, COUNT(DISTINCT shared) AS shared_neighbors, m1.neighbor_count + m2.neighbor_count - COUNT(DISTINCT shared) AS union_count, 1.0 * COUNT(DISTINCT shared) / (m1.neighbor_count + m2.neighbor_count - COUNT(DISTINCT shared)) AS jaccard_sim ORDER BY jaccard_sim DESC LIMIT 20

逻辑说明:(shared)表示同时被m1和m2连接的中间节点,COUNT(DISTINCT shared)计算的是共同邻居数量,分母是两节点邻居数之和减去共同邻居数,即并集大小,最终相除得到 Jaccard 系数。这里必须用DISTINCT去重,因为同一部电影可能通过多个类型和同一个中间节点产生多条路径。参数说明:1.0 *是把整数除法强制转成浮点,避免落成 0;LIMIT 20控制返回候选集大小,实际项目里会把这个查询封装成一个存储过程,输入任意电影 ID,输出 Top-N 相似结果。这个查询跑在 1 万节点、5 万关系的图上,响应时间一般在几十毫秒量级,相比传统协同过滤要加载整个用户-物品矩阵再算相似度的做法,轻量很多。

3.2 Personalized PageRank:跳出“只看共同邻居”的局限

Jaccard 相似度有个明显短板:它只看直接邻居,两部电影如果没有直接共享的演员或类型,相似度直接归零。但实际的电影关联是可以通过多跳路径传递的,比如“A 和 B 共享了演员 X,B 和 C 共享了导演 Y,那 A 和 C 也可能有某种程度的潜在关联”。这个场景正是 PageRank 类算法的用武之地。

知识图谱推荐系统里常用 Personalized PageRank(个性化 PageRank),以用户看过的某部电影为起点,在图上做随机游走,游走过程中落在其他电影节点上的概率就是对用户的推荐评分。Neo4j 里用 GDS(Graph Data Science)库执行,核心调用如下:

from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) def personalized_pagerank(tx, start_movie_id: str, top_k: int = 20): query = """ MATCH (start:Movie {movie_id: $start_id}) CALL gds.pageRank.stream('movieGraph', { maxIterations: 20, dampingFactor: 0.85, sourceNodes: [start], tolerance: 0.0001 }) YIELD nodeId, score WHERE exists((start)-[:ACTED_IN|:DIRECTED|:HAS_GENRE]-(:Person)) AND NOT exists((start)-[:SIMILAR]-(nodeId)) // 排除已知相似,避免重复推荐 RETURN gds.util.asNode(nodeId).title AS title, score ORDER BY score DESC LIMIT $top_k """ result = tx.run(query, start_id=start_movie_id, top_k=top_k) return [record["title"] for record in result]

逻辑说明:movieGraph是在项目启动时通过 GDS 库把图谱投影成内存图,投影过程可以指定把哪些关系类型纳入计算。sourceNodes设置游走起点,maxIterations控制迭代上限,dampingFactor是随机游走中继续前进的概率,0.85 是 PageRank 论文里沿用下来的经典值,落在这个项目里意味着每一步有 15% 概率跳回起点电影。tolerance是收敛阈值,两次迭代之间分数变化小于它时提前停止,省算力。参数说明:top_k控制了最终返回条数,这个值会直接影响推荐列表的展示密度,毕设里设为 20 比较稳妥。注意这里的 WHERE 条件,把用户已看过的电影和已经产生过关系的节点排除掉,避免推荐结果全是用户已经消费过的内容。

3.3 混合推荐策略打分:把三种信号揉成一个分数

实际推荐效果不能只靠一种算法,项目里把多路召回的结果做了加权融合。具体做法是:对每部候选电影计算三个分数——Jaccard 相似度得分、Personalized PageRank 得分、以及基于评分的协同过滤得分(如果用户有历史评分),然后做加权求和。权重不是拍脑袋定的,说明文档里给了一组经过调参的经验值,我拆项目时验证过在 Cold Start 场景下这组权重明显优于纯协同过滤:

推荐算法权重适用场景
Jaccard 相似度0.3冷启动,无用户行为数据时兜底
Personalized PageRank0.5有少量种子电影,需要扩展兴趣
协同过滤(SVD)0.2用户历史评分较多时平滑过拟合

加权融合的公式可以写成代码里的一个简单函数,这也是毕设答辩时最容易讲清楚的一个点。

def hybrid_score(jaccard_score, ppr_score, svd_score, has_rating_history): # 无评分历史时,协同过滤权重置 0,重新归一化 if not has_rating_history: return 0.3 * jaccard_score + 0.7 * ppr_score return 0.3 * jaccard_score + 0.5 * ppr_score + 0.2 * svd_score

逻辑说明:这个函数把三种算法产出的分数做线性整合。has_rating_history是布尔值,没有历史评分时 SVD 分数没有意义,硬加进去只会引入噪声,所以降级成两路加权。归一化在上一层完成,保证三个分数都在 0 到 1 区间。参数说明:权重 0.3、0.5、0.2 是经验值,实际部署时可以在验证集上跑网格搜索,毕设里手动调这几组基本够用,重点是把融合逻辑讲清楚。

4. Web 展示层:Flask 把推荐结果变成可交互页面

4.1 Flask 路由设计与查询封装

推荐引擎跑通了,还得有个能演示的界面。项目用 Flask 做 Web 层,这不是性能最优的选型,但胜在轻量、生态熟、答辩演示的时候改起来快。后端只做两件事:接收前端传来的电影 ID 或者用户 ID,调推荐模块的函数拿结果,再把结果序列化成 JSON 返给前端。代码结构很简单:

from flask import Flask, request, jsonify, render_template from recommender import hybrid_recommend app = Flask(__name__) @app.route('/') def index(): return render_template('index.html') @app.route('/api/recommend', methods=['POST']) def recommend(): data = request.get_json() movie_id = data.get('movie_id') user_id = data.get('user_id', None) if not movie_id: return jsonify({'error': 'movie_id is required'}), 400 results = hybrid_recommend( movie_id=movie_id, user_id=user_id, top_k=20 ) return jsonify({'recommendations': results}), 200

逻辑说明:hybrid_recommend是封装在recommender.py里的主入口函数,内部依次调用推荐模块的多路召回和加权融合逻辑。Flask 路由/api/recommend接收 POST 请求,movie_id是必填参数,user_id可选,用于决定是否启用协同过滤那一路。参数说明:top_k这里写死为 20,生产环境会做成可配置项,毕设里写死问题不大。

4.2 前端展示:图谱可视化与推荐列表双栏

前端展示用的是 ECharts 的关系图组件,核心配置项是series类型为graph,把电影和演员的关系渲染成力导向图。这套方案的优点是零额外依赖、数据格式灵活,后端只需要返回节点和边的 JSON 数组。ECharts 的力导向图天然自带拖拽和缩放,答辩演示时观感不错。

前端必踩的坑是:后端返回的节点 ID 必须唯一,否则 ECharts 会把共享 ID 的节点合并成同一个,导致图结构错乱。如果电影和演员两个集合里都有 ID 为 1 的节点,展示时就会出现“电影节点被演员节点覆盖”的诡异现象。处理方法是给每个节点的 ID 加前缀,比如movie_1、person_2,或者直接用节点在数据库里的唯一 UUID。

4.3 API 返回格式约定与异常兜底

后端返回的数据格式一定要提前定下来,项目里统一用下面的结构:

{ "code": 0, "message": "success", "data": { "recommendations": [ {"movie_id": "123", "title": "Inception", "score": 0.87, "reason": "shared_actor: Leonardo DiCaprio"} ] } }

reason字段是这部推荐系统的一个亮点,它会说明“为什么给你推荐这部电影”,可能是共享了某个演员,也可能是共享了类型。这一设计在答辩时很加分,因为评分模型通常是黑匣子,能给出可解释的理由,说明你对推荐系统的理解不只是调包。异常兜底在recommend路由里加了一个try-except,捕获 Neo4j 连接异常和查询超时,返回code: 500的错误信息,保证 Web 层不会直接抛 500 裸异常,演示的时候不至于白屏。

5. 避坑:Neo4j 连接、py2neo 版本和中文乱码的五个实战记录

5.1py2neo版本差异导致查询接口不兼容

现象:按照网上教程写的graph.run("MATCH ...")在 py2neo 5.x 里报AttributeError: 'Graph' object has no attribute 'run',或者反过来在旧版本里没有graph.query()方法。
原因:py2neo 在 4.x 到 5.x 之间做了一次大版本升级,把很多 API 从 Graph 对象挪到了 Session 对象上,网上教程鱼龙混杂,抄到旧版代码直接跑新版环境必炸。
解决:统一使用官方neo4j驱动,不要用 py2neo。这个项目说明文档里也提到了这一点,代码里全部走GraphDatabase.driver()创建连接,语义清晰且长期维护。

5.2 中文数据导入 Neo4j 后变成乱码

现象:CSV 文件里明明是正确的 UTF-8 中文,LOAD CSV导入后查询出来全是???。
原因:CSV 文件编码不是 UTF-8,或者 Neo4j 导入时没有指定字符集。Windows 下用 Excel 另存的 CSV 大概率是 GBK 编码,直接用LOAD CSV读它就会乱。
解决:导入前用 Python 做一次编码探测和转换,统一转成 UTF-8 再落盘。这是数据层必须做的一道工序。建议写一行转换代码:

with open('raw_data.csv', 'r', encoding='gbk', errors='ignore') as f: content = f.read() with open('data_utf8.csv', 'w', encoding='utf-8') as f: f.write(content)

逻辑说明:第一段以 GBK 编码读取原始文件,errors='ignore'忽略无法解码的字节,避免读一半报编码异常;第二段将读入的字符串以 UTF-8 写回新文件,后续LOAD CSV或 pandas 读取就不会再乱码。参数说明:errors='ignore'是双刃剑,它会静默丢弃坏字节,可能造成部分数据缺失,所以转换后要做一次行数对比,确认数据量没缩水。

5.3 Neo4j 连接数占满导致定时任务阻塞

现象:系统跑一段时间后,推荐接口响应越来越慢,最后直接超时,日志里全是Max connection pool size reached。
原因:每次请求都新建一个GraphDatabase.driver()实例,并且不关闭,连接池被占满后新请求只能等待。
解决:driver 实例全局单例创建,程序退出时统一关闭。项目代码里把 driver 实例放在了模块导入阶段,这是标准做法,我也按这个思路改了。另外给 Session 加with上下文管理,确保每次操作完释放连接,宿主机上 Neo4j 的连接数上限是动态的,默认配置下创建 100 个连接就危险了。

5.4 Cypher 查询里拼接字符串导致注入风险

现象:查询条件里用了 f-string 直接拼接用户输入,构造出来的 Cypher 语句可能把传入的字符串当成了查询逻辑执行。
原因:Cypher 和 SQL 一样存在注入面,用户输入没做参数化就会给恶意输入留后门。
解决:所有用户输入必须走参数化查询,tx.run(query, movie_id=movie_id)这种写法安全可靠。毕设里不涉及安全攻防加分项,但答辩老师可能会追问这个问题,提前把参数化的写法写上,能少一个被质疑的点。

5.5 电影节点标签冲突:Movie标签被滥用

现象:图里出现大量孤立节点,查询MATCH (m:Movie)返回的数量远超预期。
原因:导入时把不同来源的数据都打了Movie标签,但没有唯一约束,同一部电影在多个批次里被重复CREATE。
解决:加唯一约束是 Neo4j 里保障数据完整性的核心手段,建节前先执行一条约束语句:

CREATE CONSTRAINT movie_id_unique IF NOT EXISTS FOR (m:Movie) REQUIRE m.movie_id IS UNIQUE

逻辑说明:CREATE CONSTRAINT是 Neo4j 的 Schema 约束语法,声明了movie_id属性在Movie节点上必须唯一。有了这个约束之后,再执行MERGE就会按movie_id匹配已有节点,而不是每次新建。参数说明:IF NOT EXISTS预防重复执行报错,查询已存在的约束时报错很常见,加这个关键词一劳永逸。导数据之前先跑这条约束,后面导入失败率能降一大半。

6. 评估推荐效果:离线指标与 A/B 对比验证

推荐系统做完,最直观的问题是“效果到底好不好”。这个项目带了一个离线评估脚本,核心思路是把用户的历史评分数据切分成训练集和测试集,用训练集调参,用测试集算指标。我拆项目时习惯先跑一遍这个评估,确认基线 OK 再往上加算法,避免改了一通代码最后连指标变化方向都说不清。

from sklearn.metrics import precision_score, recall_score, ndcg_score import random # 假设 test_set 是 (user_id, ground_truth_movie_ids) 的列表 # pred_set 是 (user_id, recommended_movie_ids) 的字典 def evaluate_recommendations(pred_set, test_set, k=10): precisions = [] recalls = [] ndcgs = [] for user_id, true_items in test_set: if user_id not in pred_set: continue pred_items = pred_set[user_id][:k] # 命中集合 hit = set(pred_items) & set(true_items) precision = len(hit) / len(pred_items) if pred_items else 0 recall = len(hit) / len(true_items) if true_items else 0 # 简单 ndcg 计算:排名越靠前的命中,权重越高 dcg = sum(1 / (idx + 1) for idx, item in enumerate(pred_items) if item in true_items) idcg = sum(1 / (idx + 1) for idx in range(min(len(true_items), k))) ndcg = dcg / idcg if idcg > 0 else 0 precisions.append(precision) recalls.append(recall) ndcgs.append(ndcg) return { 'precision@10': sum(precisions) / len(precisions), 'recall@10': sum(recalls) / len(recalls), 'ndcg@10': sum(ndcgs) / len(ndcgs), } def random_baseline(test_set, all_movie_ids, k=10): """ 随机推荐作为下限基线,用于对比验证知识图谱方案是否显著优于乱推。 """ pred_set = {} for user_id, _ in test_set: pred_set[user_id] = random.sample(all_movie_ids, min(k, len(all_movie_ids))) return pred_set

逻辑说明:评估函数计算了三个关键指标。precision@10衡量推荐列表里有多少是用户真正喜欢的,recall@10衡量用户喜欢的东西有多少被推荐出来了,ndcg@10衡量推荐结果的排序质量,排名靠前的命中项会获得更高分数。random_baseline生成了一个随机推荐结果,用于建立下限参照,知识图谱方案的指标必须显著优于这个基线才有说服力。参数说明:k=10是评估截断长度,如果产品端展示 20 条推荐,这里就改成 20。random.sample要求样本数不能超过列表长度,这里用min(k, len(...))做了安全截断。

对比实验的做法是:先跑随机基线,再跑协同过滤,最后跑知识图谱方案,三个结果放一起对比。我在验证数据集上跑过一版,知识图谱方案在precision@10上比随机基线高了 5 倍左右,在冷启动用户子集上优势更明显。这就是知识图谱推荐最值钱的地方——它不依赖用户行为历史,只依赖物品之间的结构关系,对没有评分记录的新用户照样能给出一份合理的推荐列表。

整套系统跑通之后,有几个值得延伸的方向:把用户节点也建进图谱,把“看过”“想看”“评分过”变成关系,这样 Personalized PageRank 可以直接把用户当起点,做真正的个性化推荐;或者引入时间维度,让关系带上时间戳,推荐时优先考虑近期的兴趣漂移。毕设项目做到这个粒度,无论从工作量还是技术深度上都已经足够撑起一次答辩了。从那以后我每次拿到推荐系统的活,都会先看一遍数据能不能建模成图,能的话优先走知识图谱这条路线,这套思路救过我不少次冷启动的场子,希望也能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询