知识图谱驱动的电影推荐系统:Neo4j建模与混合推荐实战
简介一份基于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, batchbatch_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:i500])逻辑说明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 *是把整数除法强制转成浮点避免落成 0LIMIT 20控制返回候选集大小实际项目里会把这个查询封装成一个存储过程输入任意电影 ID输出 Top-N 相似结果。这个查询跑在 1 万节点、5 万关系的图上响应时间一般在几十毫秒量级相比传统协同过滤要加载整个用户-物品矩阵再算相似度的做法轻量很多。3.2 Personalized PageRank跳出“只看共同邻居”的局限Jaccard 相似度有个明显短板它只看直接邻居两部电影如果没有直接共享的演员或类型相似度直接归零。但实际的电影关联是可以通过多跳路径传递的比如“A 和 B 共享了演员 XB 和 C 共享了导演 Y那 A 和 C 也可能有某种程度的潜在关联”。这个场景正是 PageRank 类算法的用武之地。知识图谱推荐系统里常用 Personalized PageRank个性化 PageRank以用户看过的某部电影为起点在图上做随机游走游走过程中落在其他电影节点上的概率就是对用户的推荐评分。Neo4j 里用 GDSGraph 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_idstart_movie_id, top_ktop_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有少量种子电影需要扩展兴趣协同过滤SVD0.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_idmovie_id, user_iduser_id, top_k20 ) 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, encodinggbk, errorsignore) as f: content f.read() with open(data_utf8.csv, w, encodingutf-8) as f: f.write(content)逻辑说明第一段以 GBK 编码读取原始文件errorsignore忽略无法解码的字节避免读一半报编码异常第二段将读入的字符串以 UTF-8 写回新文件后续LOAD CSV或 pandas 读取就不会再乱码。参数说明errorsignore是双刃剑它会静默丢弃坏字节可能造成部分数据缺失所以转换后要做一次行数对比确认数据量没缩水。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_idmovie_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, k10): 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 { precision10: sum(precisions) / len(precisions), recall10: sum(recalls) / len(recalls), ndcg10: sum(ndcgs) / len(ndcgs), } def random_baseline(test_set, all_movie_ids, k10): 随机推荐作为下限基线用于对比验证知识图谱方案是否显著优于乱推。 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逻辑说明评估函数计算了三个关键指标。precision10衡量推荐列表里有多少是用户真正喜欢的recall10衡量用户喜欢的东西有多少被推荐出来了ndcg10衡量推荐结果的排序质量排名靠前的命中项会获得更高分数。random_baseline生成了一个随机推荐结果用于建立下限参照知识图谱方案的指标必须显著优于这个基线才有说服力。参数说明k10是评估截断长度如果产品端展示 20 条推荐这里就改成 20。random.sample要求样本数不能超过列表长度这里用min(k, len(...))做了安全截断。对比实验的做法是先跑随机基线再跑协同过滤最后跑知识图谱方案三个结果放一起对比。我在验证数据集上跑过一版知识图谱方案在precision10上比随机基线高了 5 倍左右在冷启动用户子集上优势更明显。这就是知识图谱推荐最值钱的地方——它不依赖用户行为历史只依赖物品之间的结构关系对没有评分记录的新用户照样能给出一份合理的推荐列表。整套系统跑通之后有几个值得延伸的方向把用户节点也建进图谱把“看过”“想看”“评分过”变成关系这样 Personalized PageRank 可以直接把用户当起点做真正的个性化推荐或者引入时间维度让关系带上时间戳推荐时优先考虑近期的兴趣漂移。毕设项目做到这个粒度无论从工作量还是技术深度上都已经足够撑起一次答辩了。从那以后我每次拿到推荐系统的活都会先看一遍数据能不能建模成图能的话优先走知识图谱这条路线这套思路救过我不少次冷启动的场子希望也能帮到你。本文还有配套的精品资源点击获取