SSM电影推荐系统源码解析:架构、UserCF协同过滤与部署指南
简介:这是一份基于Java SSM框架的电影推荐系统完整源码包,面向高校计算机专业学生及初入行的Java开发者,可用于毕业设计、课程设计或SSM整合实战练习。系统覆盖用户管理、图片与视频素材维护、推荐展示等核心模块,并配有MySQL 5.7数据库脚本和Maven工程配置,便于快速搭建运行环境。资源共845个文件,包含128个Java后端类、49个Vue组件、167个JavaScript脚本,以及HTML、CSS、XML、SQL等辅助文件,压缩包大小17.62MB,目录结构清晰。此外还附带项目设计文档(docx)和安装运行批处理脚本,可对照源码理解B/S架构下Spring、SpringMVC、MyBatisPlus与前端Vue的整合思路。目前已有282人下载学习,适合需要完整参考实现与动手调试的中级开发者。
1. 选电影推荐系统做 Java Web 毕业设计:SSM 源码项目,一次讲清架构、算法和部署
选电影推荐系统做 Java Web 方向的毕业设计或课程项目,是风险很低的组合。这套基于 SSM(Spring + SpringMVC + MyBatis)的电影推荐系统源码,把前台电影展示、用户注册登录、评分管理、后台管理,以及基于用户协同过滤的推荐模块全部揉在一个能直接运行的项目里——不是只给你几个 CRUD 例子,而是从登录页到推荐列表都完整打通。
它能解决的核心问题很明确:一是快速出一套「数据库设计 + 算法实现 + Web 工程」齐全的毕业设计成果;二是给想学 SSM 和推荐算法落地的 Java 开发者一份有业务逻辑的真实代码。适合三类人:需要交付毕设的学生、想练手完整工程的 Java 新人、以及想搞懂协同过滤怎么从公式变成代码的开发者。下面按架构选型、数据表、推荐引擎、部署排错的顺序拆。
2. 推荐算法选型:为什么这套源码选 UserCF 而不是 ItemCF
2.1 电影推荐场景选 UserCF 的三个理由
先说结论:这套源码用的是基于用户的协同过滤(UserCF)。算法思路一句话——找到和你口味最像的一批用户,把他们看过且评分高的电影推荐给你。相比之下,基于物品的协同过滤(ItemCF)是找“和你已经喜欢电影相似”的其他电影,拿购物网站举例更直观:买过《算法导论》的人大概率会买《编程珠玑》,这就是 ItemCF。
| 对比维度 | UserCF | ItemCF |
|---|---|---|
| 适用场景 | 用户规模小于物品规模,偏冷门内容挖掘 | 物品数量稳定,用户兴趣相对固定 |
| 实时性 | 用户行为变化感知慢 | 物品相似度可离线算,响应快 |
| 可解释性 | “和你口味相似的人还喜欢” | “因为你看过 XXX,所以推荐 XXX” |
电影推荐系统为什么要选 UserCF?三个现实理由。第一,电影数量在毕设项目里通常是几百到一两千条,用户量级也完全可控,UserCF 的训练和实时计算成本扛得住;第二,电影是重内容消费,用户口味跨度大,从用户相似度出发更容易把冷门电影挖出来,这对演示效果很重要——如果推荐结果总是热门 TOP10,答辩老师会觉得算法没有存在感;第三,UserCF 的推荐解释贴近直觉,“和你口味相似的人还喜欢这部电影”比“因为你看过这部电影所以推荐那部”更容易在答辩时讲清楚。
还有一种方案会拿来对比:基于内容的推荐,每部电影按分类、导演、主演做成特征向量,推荐与你之前打过分电影相似的其他电影。这套源码没有选它,原因不复杂——电影特征要靠人工维护,而且内容相似不等于口味相似。一个人同时喜欢《教父》和《疯狂动物城》,从内容特征上怎么也找不出关联,但协同过滤可以从其他相似用户的评分里发现这条隐蔽的偏好。毕设场景下讲清楚这个选型理由,比堆公式更能体现设计思考。
2.2 相似度计算:余弦相似度如何落到代码里
UserCF 的第一步是构建 user-item 评分矩阵。行是用户,列是电影,单元格是评分(默认 1 到 5 分,0 表示未评分)。有了这个矩阵,就能计算两个用户之间的相似度。最常用的度量是余弦相似度:把每个用户的评分看成一个多维向量,夹角越小代表两个用户的口味方向越一致。
两个用户 u 和 v 的余弦相似度,分子是它们共同评分电影的分数乘积之和,分母是两个用户评分向量模长的乘积。写成公式就是 similarity(u,v) = Σ(rᵤᵢ × rᵥᵢ) / (sqrt(Σ rᵤᵢ²) × sqrt(Σ rᵥᵢ²)),其中 i 遍历两个用户的共同评分电影集合。这套源码实用时做了一个简化:分母遍历的是用户所有已评分项,未评分位置按 0 处理,0 的平方仍然是 0,所以两种理解算出来的结果一致。
除余弦相似度外,还有皮尔逊相关系数:先减去用户自己的平均分,再做相关性计算,能消除不同用户打分尺度不一样的影响。用户 A 习惯打 2 到 4 分,用户 B 习惯打 4 到 5 分,不代表 B 的口味比 A 高一倍。这套源码里的实现优先用皮尔逊,但当两个用户只有一条共同评分记录时,退化用余弦相似度,避免除零。这个细节很重要,因为冷门电影的评分很稀疏,很多用户对之间只有一两条重合记录。
邻居数量 K 的选择直接影响推荐质量。K 太小,只有极少数用户的偏好被纳入计算,结果噪声大;K 太大,把相似度很低的用户也拉进来了,推荐结果会趋向热门电影。常见做法是先从 K=10 起步,看推荐列表里是否出现用户没看过的冷门电影,再调整到 20 或 30 观察变化。下文的推荐引擎实现里把 K 做成一个常量,方便调整。
2.3 推荐引擎在 SSM 分层里的职责边界
把算法放进 SSM 项目看,职责很清晰。Controller 层负责暴露 HTTP 接口,比如 GET /recommend/{userId};Service 层放推荐核心逻辑,包括评分矩阵构建、相似度计算、最近邻选取、预测评分;DAO 层由 MyBatis 的 RatingMapper 提供原始评分记录。后台管理模块是另一套独立流程:管理员登录后维护电影、用户、分类信息,这些操作会更新数据库,进而影响下一次推荐的评分矩阵。
这里有个常见误用要提醒:不要把推荐逻辑写在 Controller 里。我一般会这样做——推荐引擎实现成一个独立的 RecommendService,内部只依赖 RatingMapper 和 MovieMapper 两个接口。如果以后想换成 ItemCF,只需要替换 RecommendService 的实现类,Controller 和前端完全不用动。源码工程里就是这么拆的,接口边界很清晰,也是一般答辩老师喜欢看到的层次。
接口设计上,RecommendService 对外只暴露一个方法:recommendForUser(Integer userId, int topN),内部实现细节全部隐藏。这样 SpringMVC 层不需要知道协同过滤怎么算相似度,前端更不用关心。想要扩展成 ItemCF 时,新增一个 ItemCFRecommendServiceImpl,两个实现类并存,用 @Qualifier 按名字注入,几行配置就能切换推荐策略,这个能力在毕设创新点里很加分。
3. 数据库设计:五张表和一张评分表撑起整个推荐
3.1 五张核心表与管理后台的关系
这套系统的表设计不复杂,核心是五张表:用户表 user、电影表 movie、电影分类表 category、评分表 rating、管理员表 admin。用户表保存账号密码等身份信息,电影表保存标题、简介、封面、上映年份、分类 ID,评分表记录每个用户对每部电影的打分和时间,管理员表只面向后台登录。
设计上要注意两个点。第一,movie 表的 category_id 关联 category 表,但不要真建外键约束,MyBatis 环境里外键约束容易把插入顺序和服务层事务搞复杂,保留普通索引就行;第二,rating 表要加联合唯一索引,约束同一用户对同一电影只能评一次分,这是业务层面的强需求,也是评分数据质量的第一道防线。
3.2 建表 SQL 全文与字段选型说明
CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '登录密码,建议MD5后存储', nickname VARCHAR(50) DEFAULT NULL COMMENT '昵称,用于前台展示', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '分类ID', name VARCHAR(50) NOT NULL COMMENT '分类名称,如动作/科幻' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影分类表'; CREATE TABLE movie ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '电影ID', title VARCHAR(200) NOT NULL COMMENT '电影标题', category_id INT NOT NULL COMMENT '关联分类ID', cover_url VARCHAR(255) DEFAULT NULL COMMENT '封面图地址', description TEXT COMMENT '剧情简介', release_year INT DEFAULT NULL COMMENT '上映年份', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '录入时间', INDEX idx_category (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影表'; CREATE TABLE rating ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '评分ID', user_id INT NOT NULL COMMENT '评分用户ID', movie_id INT NOT NULL COMMENT '被评分电影ID', score DECIMAL(2,1) NOT NULL COMMENT '评分值,1.0-5.0,允许0.5分', rating_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '评分时间', UNIQUE KEY uk_user_movie (user_id, movie_id), INDEX idx_movie (movie_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评分表'; CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '管理员ID', username VARCHAR(50) NOT NULL COMMENT '管理员账号', password VARCHAR(64) NOT NULL COMMENT '管理员密码' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表';字段上注意几个细节。user.password 在演示工程里通常存 MD5 密文,注册时先加密再入库,登录比对时也加密后比对,避免数据库里出现明文密码。rating.score 用 DECIMAL(2,1) 而不是 INT,因为 4.5 分、3.5 分是很常见的评分粒度,DECIMAL(2,1) 的整数部分最多 1 位,小数部分 1 位,足够覆盖 0.0 到 9.9。movie.description 用 TEXT 而不是 VARCHAR,因为剧情简介可能超过 255 字符。
rating 表的 uk_user_movie 联合唯一键是评分唯一性的兜底。前端重复提交评分时,数据库层面会直接拒绝,不需要在 Service 里写复杂的防重逻辑。idx_movie 索引容易被忽略:推荐算法做电影维度聚合时,例如统计某部电影被多少人评分,这个索引能让按 movie_id 的查询走索引而不是全表扫描。
3.3 评分数据从页面到推荐引擎的完整链路
评分数据从哪来?前台页面上每部电影有一个评分组件,用户选择分数后,前端用 AJAX POST 到 /movie/score 接口,参数是 movieId 和 score。Controller 层接收后调用 RatingService.insertOrUpdate 方法,先查一下 user_id + movie_id 是否已有记录,有就 UPDATE,没有就 INSERT。
这段逻辑很多人会写漏“先查后更新”,导致重复点击评分时被 uk_user_movie 联合唯一键拦截,抛 DuplicateKeyException,前端表现为评分保存失败。为了避免这种问题,建议在 insertOrUpdate 方法上加 @Transactional 事务注解,把“查 + 写 + 更新推荐缓存(如果有)”放在同一个事务里,传播行为用默认的 REQUIRED 即可。插入成功后,推荐引擎下次运行时就会把这条新评分纳入相似度计算。
4. 推荐引擎代码拆解:评分矩阵、KNN 和热门兜底
4.1 MyBatis 层只做两件事:查全量评分、查用户评分
推荐引擎的入口是 RecommendService.recommendForUser(Integer userId, int topN),先通过 RatingMapper.selectAll() 把全量评分记录取出来。数据量小时直接全量查没问题,几千条评分在内存里构建矩阵,性能基本无感;如果数据量到了十万以上,再考虑按用户维度分页或降采样。
<select id="selectAll" resultType="map"> SELECT user_id AS userId, movie_id AS movieId, score FROM rating </select> <select id="selectByUser" parameterType="int" resultType="map"> SELECT movie_id AS movieId, score FROM rating WHERE user_id = #{value} </select>这两条 SQL 返回 Map 而不是实体类,因为推荐引擎只需要 userId、movieId、score 三个字段,Map 更轻。selectAll 用于构建全量评分矩阵,selectByUser 用于获取目标用户的已评分列表,两个方法的结果在代码里转成 Map<Integer, Map<Integer, Double>> 的嵌套结构,外层键是用户 ID,内层键是电影 ID。
4.2 余弦相似度的 Java 实现与四个边界细节
拿到列表后,先构建评分矩阵。实际代码里我用 computeIfAbsent 简化嵌套 Map 的初始化逻辑:
private Map<Integer, Map<Integer, Double>> buildRatingMatrix() { List<Map<String, Object>> list = ratingMapper.selectAll(); Map<Integer, Map<Integer, Double>> matrix = new HashMap<>(); for (Map<String, Object> row : list) { Integer userId = (Integer) row.get("userId"); Integer movieId = (Integer) row.get("movieId"); // DECIMAL 列会被 MyBatis 映射成 BigDecimal 或 Double,统一转 double Double score = ((Number) row.get("score")).doubleValue(); matrix.computeIfAbsent(userId, k -> new HashMap<>()) .put(movieId, score); } return matrix; }这里有个容易翻车的点:rating.score 在 MySQL 里是 DECIMAL,MyBatis 返回的可能是 BigDecimal,直接用 (Double) 强转会报 ClassCastException。用 Number 类型做中转再调 doubleValue(),一层转换解决所有情况。computeIfAbsent 的意思是:外层 Map 里没有这个 userId 时,用 lambda 创建一个新 HashMap 放进去,然后往里 put 电影 ID 和评分。
相似度计算的代码要写清楚,它决定整个推荐质量的上限:
public double cosineSimilarity(Map<Integer, Double> user1, Map<Integer, Double> user2) { double dotProduct = 0.0, norm1 = 0.0, norm2 = 0.0; for (Map.Entry<Integer, Double> entry : user1.entrySet()) { Integer movieId = entry.getKey(); double score1 = entry.getValue(); norm1 += score1 * score1; // 只累加两个用户共同评分过的电影 Double score2 = user2.get(movieId); if (score2 != null) { dotProduct += score1 * score2; } } for (double score : user2.values()) { norm2 += score * score; } if (norm1 == 0 || norm2 == 0) { return 0.0; } return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); }这段代码有四个关键点。第一,dotProduct 只在 user2 能找到同一部电影时累加,这是协同过滤的核心意义——两个用户完全没看过相同的电影,相似度就是 0。第二,norm1 和 norm2 是各自评分向量的模长平方,最后开方做归一化。第三,两个用户各自的评分数量会影响相似度,评分多但共同项少的用户对,会自然得到较低相似度,这符合直觉。第四,除零保护返回 0.0,避免某个用户没有任何评分时出现 NaN。
拿到所有用户对目标用户的相似度之后,按值排序取前 K 个。K 在代码里定义常量 DEFAULT_NEIGHBOR_SIZE = 10。实际更合适的做法是动态取:相似度大于 0 的用户数如果少于 K,就全部当邻居;如果远远多于 K,则只取前 K 个,避免评分数据稀疏时推荐列表为空。
4.3 Top-K 邻居加权预测,组装推荐列表
预测评分是推荐引擎的主干逻辑,按四步走:构建矩阵、算相似度、找邻居、加权预测。
public List<MovieVO> recommendForUser(Integer userId, int topN) { Map<Integer, Map<Integer, Double>> ratingMatrix = buildRatingMatrix(); Map<Integer, Double> targetUserRatings = ratingMatrix.get(userId); if (targetUserRatings == null || targetUserRatings.isEmpty()) { return hotMoviesFallback(topN); // 冷启动兜底:按热门榜推荐 } // 计算目标用户与所有其他用户的相似度 Map<Integer, Double> simMap = new HashMap<>(); for (Map.Entry<Integer, Map<Integer, Double>> entry : ratingMatrix.entrySet()) { Integer otherUserId = entry.getKey(); if (otherUserId.equals(userId)) { continue; // 跳过自己 } double sim = cosineSimilarity(targetUserRatings, entry.getValue()); if (sim > 0) { simMap.put(otherUserId, sim); } } // 相似度降序,取前 K 个邻居 List<Map.Entry<Integer, Double>> neighbors = new ArrayList<>(simMap.entrySet()); neighbors.sort((e1, e2) -> Double.compare(e2.getValue(), e1.getValue())); int k = Math.min(DEFAULT_NEIGHBOR_SIZE, neighbors.size()); List<Map.Entry<Integer, Double>> topK = neighbors.subList(0, k); // 邻居评分加权累加,跳过用户已经看过的电影 Map<Integer, Double> predictScore = new HashMap<>(); Map<Integer, Double> weightSum = new HashMap<>(); for (Map.Entry<Integer, Double> neighborEntry : topK) { Integer neighborId = neighborEntry.getKey(); double sim = neighborEntry.getValue(); Map<Integer, Double> neighborRatings = ratingMatrix.get(neighborId); for (Map.Entry<Integer, Double> movieEntry : neighborRatings.entrySet()) { Integer movieId = movieEntry.getKey(); if (targetUserRatings.containsKey(movieId)) { continue; } predictScore.merge(movieId, sim * movieEntry.getValue(), Double::sum); weightSum.merge(movieId, sim, Double::sum); } } // 按加权总分降序,取 topN,再回表查电影详情 List<Map.Entry<Integer, Double>> result = new ArrayList<>(predictScore.entrySet()); result.sort((e1, e2) -> Double.compare(e2.getValue(), e1.getValue())); List<MovieVO> recommendList = new ArrayList<>(); for (int i = 0; i < Math.min(topN, result.size()); i++) { Map.Entry<Integer, Double> item = result.get(i); MovieVO vo = movieMapper.findById(item.getKey()); double avgScore = item.getValue() / weightSum.get(item.getKey()); vo.setPredictScore(Math.round(avgScore * 100) / 100.0); recommendList.add(vo); } return recommendList; }两个参数需要说清楚。DEFAULT_NEIGHBOR_SIZE(K)控制邻居窗口,K 越大结果越平滑但越趋近热门,K 越小越个性化但噪声越大,常见做法是 10 到 30 之间先跑一轮看分布。topN 是最终返回的电影数量,前端展示通常 8 到 12 比较合适。如果结果不足 topN,不需要强行补齐——补热门电影会稀释算法的推荐比例,宁愿让列表短一点,答辩时也更好解释。
兜底分支 hotMoviesFallback 是必须写的。当目标用户没有任何评分记录时,协同过滤完全失效,常见做法是直接返回按评分人数排序的热门电影。hotMoviesFallback 内部走 movieMapper.selectHotMovies(10),SQL 按 rating 表聚合评分人数倒序。这个分支如果不单独写,新注册用户一打开推荐页就报空指针,是非常典型的翻车现场。
4.4 Controller 层薄封装与 JSON 响应
Service 写完之后,Controller 层只需要一个很薄的封装。下面是接口定义:
@Controller @RequestMapping("/recommend") public class RecommendController { @Autowired private RecommendService recommendService; @RequestMapping(value = "/{userId}", method = RequestMethod.GET) @ResponseBody public Result recommend(@PathVariable("userId") Integer userId, @RequestParam(value = "topN", defaultValue = "8") Integer topN) { List<MovieVO> list = recommendService.recommendForUser(userId, topN); return Result.success(list); } }前端拿到 JSON 后渲染封面图和预测分。更灵活一点的是把 topN 做成请求参数,前端传 ?topN=12 就能控制列表长度,现场调数字比改代码重启更有说服力。如果项目里用了 SpringMVC 的消息转换器,返回 JSON 时 Jackson 会自动处理对象序列化,不要自己拼 JSON 字符串——手动拼接一旦遇到中文就会乱码,答辩时基本被追问到卡壳。
5. 部署常见问题与避坑:404、连接失败、乱码、冷启动
5.1 三步把源码跑起来:建库、打包、启动
先把部署顺序说清楚,再讲坑。拿到这份 SSM 源码后,按三步走。第一步,本地装好 JDK 1.8、MySQL 5.7+、Maven 3.6+、Tomcat 8.5+ 四个基础环境;第二步,在 MySQL 里执行工程自带的 movie_recommend.sql 脚本,建库建表并灌入演示数据;第三步,用 IDEA 打开源码包,修改 jdbc.properties 里的数据库账号密码,把项目打成 war 包丢进 Tomcat 的 webapps,启动后访问 http://localhost:8080/movie-recommend/。
mysql -uroot -p < movie_recommend.sql mvn clean package -DskipTests cp target/movie-recommend.war $CATALINA_HOME/webapps/ $CATALINA_HOME/bin/startup.sh第三步看起来简单但有两个隐藏细节。war 包名称就是访问路径,如果不想带版本号,在 pom.xml 里把 设成 movie-recommend;startup.sh 启动后不要急着访问页面,先看 logs/catalina.out 里有没有 Exception,确认启动成功再开浏览器。Windows 下也可以进入 mysql 命令行后执行 source C:/movie_recommend.sql,效果一样。
5.2 Tomcat 404:先把部署和 artifact 配置对
现象:Tomcat 正常启动,访问首页返回 404,但 catalina.out 里没有任何异常。原因:工程没被打进 webapps。这种情况常见于直接用 IDEA 的 Run 按钮跑 Tomcat 插件,但 artifact 配置里没有把项目 modules 加进 deployment,相当于 Tomcat 起来了但项目根本没部署。解决:在 IDEA 的 Deployment 面板确认有带 war exploded 字样的条目;或者手动 mvn clean package,把 war 放进 Tomcat 的 webapps 用 startup.sh 启动,后者更贴近部署环境,也更好排查。
5.3 MySQL 连接失败:驱动类名与连接串参数
现象:项目启动时日志出现 Cannot create PoolableConnectionFactory,底层是 Communications link failure。原因:最常见的是 MySQL 驱动版本和数据库版本不匹配,以及连接串少参数。解决:用 mysql-connector-java 8.0.x 时,jdbc.url 必须带 serverTimezone=Asia/Shanghai 和 useSSL=false,否则高版本 MySQL 会因时区不明直接拒绝建连。下面是一份可用的 jdbc.properties:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/movie_recommend?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=123456jdbc.driver 要留意:MySQL 8.x 驱动类名是 com.mysql.cj.jdbc.Driver,老项目里常写 com.mysql.jdbc.Driver,虽然 8.x 驱动仍兼容老类名,但控制台会打 WARN,建议直接换成新类名。password 如果含有 @、# 特殊字符,在 properties 文件里直接写,不要加双引号。
5.4 中文乱码:三个环节统一 UTF-8
现象:电影标题显示成 æ 或 î½,管理后台明明看到数据库里是中文。原因:链路里字符集不一致。MySQL 建库用 utf8mb4,但 Java 侧的 jdbc.url 没带 characterEncoding=utf8,或者 Tomcat 的 URIEncoding 没设置。解决:三层统一。数据库连接串加 characterEncoding=utf8;Tomcat 的 server.xml 里 Connector 增加 URIEncoding="UTF-8";工程加一个 SpringMVC 字符编码过滤器 CharacterEncodingFilter,强制 request 和 response 都走 UTF-8,三处配齐后基本不会再出乱码。
另外要区分一种情况:接口返回的 JSON 里中文全部变成 \u5f71\u8bc4 这类转义序列,这不是乱码,是 Jackson 默认的 ASCII 输出行为。想要可读的中文,在 spring-mvc.xml 里配置消息转换器,将 ObjectMapper 的 WRITE_NON_ASCII 关闭即可。不影响功能但排查时很唬人。
5.5 新用户推荐为空:用热门兜底替代报错
现象:刚注册的用户进入首页,推荐位一片空白,控制台也没有报错。原因:recommendForUser 里 targetUserRatings 为空时直接返回了空列表,没有走冷启动兜底。解决:在入口处判断用户无评分时调 hotMoviesFallback,把热门电影当作推荐结果。这不是偷懒,是协同过滤本身解决不了新用户问题,业界通用方案就是热度兜底。
这里有个进阶技巧:与其让前端等到接口返回空数组再拉热门电影,不如在后端把兜底结果和推荐结果合并返回,响应体里加一个 source 字段,值是 collaborative 或 fallback。前端拿到 fallback 时展示“根据热门榜单推荐”,拿到 collaborative 时展示“根据你的口味推荐”,演示时系统看起来聪明很多。
6. 验证推荐算法:5 个用户的评分矩阵,手算一遍相似度
拿到源码的第一件事,不是急着加功能,而是先验证算法到底算得对不对。我的习惯是先构造一套 5 个用户、10 部电影的评分数据,手工算一遍余弦相似度,再用程序跑一遍,两个结果逐项对比,不一致就去查代码。
| 用户 | 电影1 | 电影2 | 电影3 |
|---|---|---|---|
| 用户A | 5 | 3 | 0 |
| 用户B | 5 | 3 | 4 |
| 用户C | 0 | 5 | 1 |
对用户 A 和 B,共同评分电影是电影1、电影2。分子是 5×5 + 3×3 = 34;A 的模长是 sqrt(25+9) ≈ 5.83,B 的模长是 sqrt(25+9+16) ≈ 7.07;相似度就是 34 / (5.83 × 7.07) ≈ 0.825。程序打印出来如果差太多,先检查是不是把未评分项当成 0 参与相似度计算了——把未评分当 0 是新手最常见的错,它会让两个用户的相似度虚高。再验证一组:用户 C 和 A 只有一个共同评分项,相似度按公式算约等于 0.505,和 0.825 放在一起看,能明显区分出“关系远”和“关系近”。
验证完相似度,再调两个参数。DEFAULT_NEIGHBOR_SIZE 从 10 改成 20,看推荐列表里是不是多了评分人数更多的热门片;topN 从 8 改成 12,看预测分最低的几部电影有没有明显离群值。正常表现是 K 翻倍后推荐结果的重合度在五成到七成,如果完全不变,多半是 simMap 里所有用户相似度都一样,回去查评分矩阵的 key 是否在构建时被覆盖了。我已经不止一次遇到这种情况:评分矩阵 Map 的 key 写错,把所有用户的评分塞到同一个键下面,相似度全是 1.0,推荐列表直接变成“全班平均分排行榜”。
从那以后我每次拿到推荐类源码,都强制走一遍“造数据 → 手算 → 跑程序 → 对比 → 调参”这个流程,再轻量的 demo 也不跳步,能挡掉九成“玄学推荐”的坑。希望帮到你。
本文还有配套的精品资源,点击获取