ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

个性化电影推荐系统设计:从协同过滤到Java Web工程落地

个性化电影推荐系统设计:从协同过滤到Java Web工程落地 简介一套基于SpringBoot和Vue的个性化电影推荐系统毕业设计源码适合Java方向毕设、课程设计以及前后端开发学习者使用。工程覆盖用户信息管理、图片与视频素材组织、电影推荐核心模块还包含选题动因、背景意义和相关技术介绍等文档内容便于撰写论文和进行系统演示。压缩包共563个文件大小19.56MB包含115个Java后端源码文件、88个Vue前端页面、161个SVG图标以及JS脚本、CSS样式、XML与JSON配置文件并提供启动与打包脚本可快速搭建运行环境。已有271人学习/下载适合作为完整参考项目。资源价值在于提供一整套可运行的SpringBoot与Vue结合MyBatisPlus、Maven、AJAX技术的落地代码涵盖前后端交互、数据库设计、资源配置等实操细节目录结构清晰源码与论文目录配套能帮助理解B/S架构开发流程节省自建系统时间。1. 个性化电影推荐系统到底是什么先看清毕设的真实工作量一个电影推荐系统的毕设最容易翻车的地方不是算法而是把“推荐”做成了“按评分排序的排行榜”。答辩时老师问一句“你这个系统跟豆瓣热门榜有什么区别”很多人当场愣住。个性化电影推荐系统的核心任务只有一件根据指定用户的历史评分和浏览行为从他没看过的电影里挑出一批最可能喜欢的Top N结果。它是用户和电影之间的桥梁不是电影评分排行榜也不是全网热播榜单。本文从算法选型、数据预处理、Java实现到部署验证把一套基于Web的个性化电影推荐系统源码的完整落地链路拆开讲清楚。适合正在做Java毕设、需要设计说明和代码实现思路的同学也适合想弄清楚推荐链路怎么在Web工程里落地的从业者。2. 推荐算法选型协同过滤的两种路线和三个必调参数打开B站首页看它的web推荐算法那是多级召回、粗排精排、在线实时机器学习的大工程。毕设和大多数Web推荐场景不要模仿这种黑匣子因为你要在答辩现场把每一步讲清楚。常见做法是选协同过滤它只依赖用户的历史行为数据不需要电影的内容特征不需要训练深度学习模型纯Java就能实现。下面的问题就变成用基于用户的还是基于物品的。2.1 基于用户的协同过滤公式、直觉和适用边界直觉很简单你和小A都看过《星际穿越》《盗梦空间》《蝙蝠侠黑暗骑士》并且都打了高分那么小A打过高分的《致命魔术》你也大概率喜欢。实现思路分三步先根据用户对电影的评分向量计算用户之间的相似度再找出与目标用户最相似的K个用户把这K个用户喜欢但目标用户没看过的电影加权汇总取Top N输出。相似度公式常用余弦可以写成similarity(u, v) Σ(r_ui * r_vi) / ( sqrt(Σr_ui²) * sqrt(Σr_vi²) )其中r_ui表示用户u对电影i的打分分母是两个向量模长的乘积。打分越接近方向越一致余弦值越接近1。预测评分公式则是对相似用户的评分做加权平均p(u, i) Σ( v ∈ 最相似K个用户 ) similarity(u, v) * r_vi / Σ | similarity(u, v) |这个方案有两个毕设同学容易忽视的代价。第一用户两两计算是O(N²)N是用户数6000个用户就是约1800万对组合如果做成实时接口Tomcat直接卡死。所以UserCF往往需要离线批处理或分桶索引。第二用户口味会漂移今天看科幻明天看文艺片用户相似度矩阵容易失真。电影不一样一部电影的类型和风格基本固定电影之间的相似度稳定得多。2.2 基于物品的协同过滤为什么电影场景优先选它一线做电影推荐我一般会优先选ItemCF。直觉是喜欢《盗梦空间》的人大概率喜欢《星际穿越》。要算的是电影与电影之间的关联关系而不是用户与用户之间的关系。在线流程变成先拿到用户已经评过分的电影集合对每个候选电影累加“用户评过的电影与候选电影的相似度×用户评分”算出加权得分再排序。公式如下score(u, i) Σ( j ∈ 用户u已经评分的电影集合 ) similarity(i, j) * r_uj和UserCF相比它对Web项目有三个友好特性。第一电影数量远小于用户数量。MovieLens 1M数据集里大约6000用户、4000部电影相似度矩阵规模从“用户对用户”的3600万降到“电影对电影”的1600万内存压力明显更小。第二物品相似度可以离线算好在线接口只做查表和加权求和。Web工程最怕在线做重计算把重计算放在启动阶段或定时任务里接口延迟就能压下来。第三推荐结果可解释性强“因为你喜欢《盗梦空间》所以推荐《星际穿越》”这句话在答辩和产品展示里都站得住。对比维度基于用户基于物品计算对象用户间相似度O(N²)物品间相似度O(M²)矩阵规模用户量上涨就膨胀电影数量相对稳定在线计算量需实时找相似用户群离线算相似度在线查表结果可解释性依赖“相似用户”概念较绕可直接展示“因为看过X所以推荐Y”毕设推荐度用户量小可用电影场景更推荐2.3 相似度计算的三个参数余弦、皮尔逊和评分归一化选定了ItemCF下一个问题就是相似度怎么算。最常用的是余弦相似度但直接套在电影评分上有坑它没考虑评分尺度的差异。有人习惯打分3到5从不给1分2分有人喜欢用满5分表达一切。这不代表两个用户或两部电影的口味不一致。皮尔逊相关系数解决这个问题先对每个评分向量做中心化减去该向量均值再算余弦pearson(a, b) Σ( (r_ai - mean_a) * (r_bi - mean_b) ) / ( sqrt(Σ(r_ai - mean_a)²) * sqrt(Σ(r_bi - mean_b)²) )中心化之后评分值变成“相对自身平均水平的偏高或偏低”。在评分数据上皮尔逊一般比余弦稳定代价是多一遍均值扫描。第三个参数是评分归一化常见做法是把1到5分映射到0到1区间(r - min) / (max - min)。也有映射到[-1, 1]的做法保留“这个用户比平时打得低”的含义。两种都行关键是要让参与计算的评分处于同一尺度。相似度方案原理优点缺点毕设建议余弦向量夹角简单直接没消除打分尺度偏差数据清洗后可用皮尔逊先中心化再算余弦消除个人打分尺度多一遍均值计算优先推荐归一化余弦统一量纲后算余弦稳定、好解释对异常值敏感配合皮尔逊或单独用都行还有一个必调参数是K近邻数也就是“每个电影保留多少个最相似的邻居”。K太小推荐结果偏狭窄K太大推荐结果向热门榜漂移。毕设里K取10到30是合理起点K20是我常用的默认值。很多人问的参数设置八成问的就是K和Top N这两个值也适合作为答辩时的改进点。提示不要把算法当黑匣子。无论选哪种相似度都要保证每一步计算能在日志里打印中间结果否则线上结果不对很难定位。3. 从MovieLens到数据库数据预处理与建表的四个决定算法选型定下来后先不要写任何推荐代码。工程里80%的坑都在数据这一层。常见做法是先用MovieLens数据集做原型验证再替换成自己爬到的或业务里导出的真实评分数据。这里以MovieLens 1M版本为例说明从原始CSV到MySQL四张表的完整预处理链路。3.1 字段裁剪别把整份CSV直接扔进MySQLMovieLens的评分文件一般包含userId、movieId、rating、timestamp四列。很多同学直接建一张大宽表把全部字段扔进去导致后续查询和相似度计算都变慢。现实做法是先做字段裁剪timestamp在离线ItemCF里用不到除非你要做时间衰减电影的原始标题里有逗号、引号和年月日信息直接LOAD DATA容易出现错位。我一般会用Python脚本做第一次清洗而不是Java。清洗阶段用脚本效率高属于工程里的常见做法不是偷懒。Python处理CSV的转义和类型转换比手写Java导入快得多数据量也就百万行级别pandas完全吃得下。import pandas as pd ratings pd.read_csv(ratings.csv, headerNone, names[user_id, movie_id, rating, timestamp]) # 只保留1到5分的有效评分过滤异常行 ratings ratings[(ratings[rating] 1) (ratings[rating] 5)] # timestamp在纯ItemCF里用不到先丢掉减小导入体积 ratings ratings.drop(columns[timestamp]) # 评分数量太少用户留着只会让相似度矩阵更稀疏取最近1000个用户做演示集 ratings ratings[ratings[user_id].isin(ratings[user_id].value_counts().head(1000).index)] ratings.to_csv(ratings_clean.csv, indexFalse, headerTrue)这段逻辑分四步第一步读取原始评分文件第二步过滤掉范围外的脏数据第三步裁剪掉时间戳字段因为纯ItemCF不需要时间维度第四步只保留评分条数最多的1000个用户避免演示阶段矩阵膨胀。里面两个参数值得说明。“1000个用户”的阈值按自己机器内存调整毕设演示阶段1000用户加几千部电影足够把效果跑出来全量6000用户会让相似度矩阵大好几倍效果差别不大。想全量导入删掉最后一行过滤直接to_csv即可。评分范围1到5的过滤条件要保持否则脏数据会把相似度计算带偏。电影表也一样处理只要movieId、标题、类型字段。类型字段在纯ItemCF里不是必需但Web页面上要展示所以保留。3.2 建表SQL用户表、电影表、评分表和推荐结果表怎么写数据清洗完成后MySQL里的表结构我建议按四张表来建。用户表存用户元信息电影表存电影元信息评分表只做一件事记录userId和movieId之间的评分关系。推荐结果表用来存离线算好的每个用户Top NWeb接口直接查不需要在线重新算一遍。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE movie ( id BIGINT PRIMARY KEY, title VARCHAR(255) NOT NULL, genres VARCHAR(255) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE rating ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, movie_id BIGINT NOT NULL, rating DECIMAL(2,1) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_movie (user_id, movie_id), KEY idx_movie_id (movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE recommendation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, movie_id BIGINT NOT NULL, score DOUBLE NOT NULL, rank INT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;四张表各自独立评分表和推荐结果表都只存关系不存冗余文本。recommendation表里的score字段存加权求和值rank是排序后的序号接口查询时ORDER BY rank直接返回。这里有两个容易忽略的细节rating字段用DECIMAL(2,1)而不是INT因为评分可能带小数比如4.5UNIQUE KEY uk_user_movie能防止重复导入同一份CSV造成数据翻倍这个约束建表时就要有否则后面跑出来的相似度矩阵错得离谱。字段长度也值得说一句。title用VARCHAR(255)足够绝大多数电影标题genres字段用VARCHAR(255)存逗号分隔的类型字符串不建关联表。毕设阶段这样设计清清爽爽等数据量大到需要规范类型维度时再拆表。推荐结果表里的score用DOUBLE因为相似度和评分的乘积会出现大量小数DECIMAL存起来不划算。提示不要为了省一次联表把电影标题冗余到recommendation表里。毕设数据量不大联表开销可以接受保持表结构清晰比过早优化更重要。建完表记得验证导入结果。导入后跑一条简单SQL确认条数对得上和CSV行数比较不一致就去查是不是有空行或未知字符。SELECT COUNT(*) AS rating_cnt, COUNT(DISTINCT user_id) AS user_cnt, COUNT(DISTINCT movie_id) AS movie_cnt FROM rating;这条SQL拿到总评分条数、用户数、电影数三个值。用rating_cnt除以user_cnt乘以movie_cnt的结果就能估算评分矩阵的稀疏度。MovieLens 1M大约只有4%左右的格子有评分也就是说96%是空的。这个数字在后面会直接影响代码里的相似度阈值设置。3.3 评分稀疏性冷启动前大家都会遇到的第一道坎稀疏对ItemCF影响很直接很多电影没有任何用户共同评过分相似度直接是0。在这样的矩阵上推荐热门电影容易被反复推出来个性化程度很低。常见做法是对参与计算的用户和电影分别设置最低行为门槛。比如评分少于10条的用户、被打分少于10次的电影先剔除出相似度计算。用SQL执行清理DELETE FROM rating WHERE user_id IN ( SELECT user_id FROM ( SELECT user_id, COUNT(*) AS cnt FROM rating GROUP BY user_id HAVING cnt 10 ) t );这里“10”就是行为门槛。门槛太低过滤不掉噪声太高比如50数据量会少一半影响召回。毕设里10到20是比较稳的区间。等替换成真实数据后再看实际稀疏度决定要不要把阈值提到30或50。清理完重新跑一遍计数SQL确认数据量在可接受范围再往下走。4. 核心代码实现Java推荐引擎与Web接口的分层拆解数据准备妥当剩下就是标题里的核心源码部分了。这一章直接给一套能跑的Spring Boot工程核心代码按“数据访问层→算法层→Web接口层”三层拆开。开发环境可以按自己习惯来用IDEA 2024创建Web项目时会自动生成Spring Initializr结构勾选Spring Web和MyBatis Framework依赖即可。下面直接看代码。4.1 数据访问层Rating实体与MyBatis映射先定义一个最简的Rating实体字段对应rating表的三列不需要把自增id也带进来算法层用不到。public class Rating { private Long userId; private Long movieId; private Double rating; // 省略 getter/setter }对应的Mapper接口Mapper public interface RatingMapper { Select(SELECT user_id, movie_id, rating FROM rating) Results({ Result(column user_id, property userId), Result(column movie_id, property movieId) }) ListRating findAll(); }findAll一次性把评分表全量读入内存这是离线ItemCF的标准做法。推荐算法要遍历所有用户及其评分记录不能一行一行查数据库否则性能完全没法看。启动阶段加载一次之后所有计算都在内存里完成。这里用注解式MyBatis而不是XML是因为SQL足够简单不必两个文件来回跳。Spring Boot MyBatis的组合在这个项目里就干这一件事保持轻量。4.2 算法层物品相似度矩阵与TopN推荐核心类下面这个类是全文核心。它做了三件事加载评分映射表、计算电影相似度并只保留TopK、根据相似度给指定用户生成推荐。Service public class RecommendService { private final RatingMapper ratingMapper; private MapLong, MapLong, Double itemSimMap new HashMap(); public RecommendService(RatingMapper ratingMapper) { this.ratingMapper ratingMapper; } // 启动时调用一次避免请求时现算 PostConstruct public void init() { MapLong, ListRating userRatings ratingMapper.findAll().stream() .collect(Collectors.groupingBy(Rating::getUserId)); // 第一步统计每部电影被哪些用户评分过构建倒排表 MapLong, MapLong, Double itemUserScore new HashMap(); for (ListRating ratings : userRatings.values()) { for (Rating r : ratings) { itemUserScore.computeIfAbsent(r.getMovieId(), k - new HashMap()) .put(r.getUserId(), r.getRating()); } } // 第二步对电影两两计算余弦相似度只保留TopK int K 20; ListLong movieIds new ArrayList(itemUserScore.keySet()); for (int i 0; i movieIds.size(); i) { PriorityQueueMap.EntryLong, Double topK new PriorityQueue(Comparator.comparingDouble(Map.Entry::getValue)); for (int j i 1; j movieIds.size(); j) { double sim cosine(itemUserScore.get(movieIds.get(i)), itemUserScore.get(movieIds.get(j))); if (sim 0) continue; topK.offer(Map.entry(movieIds.get(j), sim)); if (topK.size() K) topK.poll(); } itemSimMap.put(movieIds.get(i), toMap(topK)); } } // 根据用户已评分的电影加权汇总得到候选电影得分 public ListLong recommend(Long userId, int topN) { MapLong, Double scoreMap new HashMap(); for (Rating r : ratingMapper.findAll()) { if (!r.getUserId().equals(userId)) continue; MapLong, Double sims itemSimMap.getOrDefault(r.getMovieId(), Collections.emptyMap()); for (Map.EntryLong, Double e : sims.entrySet()) { scoreMap.merge(e.getKey(), e.getValue() * r.getRating(), Double::sum); } } return scoreMap.entrySet().stream() .sorted((a, b) - Double.compare(b.getValue(), a.getValue())) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } private double cosine(MapLong, Double a, MapLong, Double b) { double dot 0, normA 0, normB 0; for (Double v : a.values()) normA v * v; for (Double v : b.values()) normB v * v; for (Map.EntryLong, Double e : a.entrySet()) { if (b.containsKey(e.getKey())) { dot e.getValue() * b.get(e.getKey()); } } if (normA 0 || normB 0) return 0; return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } private MapLong, Double toMap(PriorityQueueMap.EntryLong, Double topK) { MapLong, Double map new HashMap(); topK.forEach(e - map.put(e.getKey(), e.getValue())); return map; } }init方法的逻辑分三步走。先把全量评分按用户分组再转换成“电影→该电影所有用户评分”的倒排表。倒排表是算余弦的关键两部电影的余弦相似度只需要在它们共同评分的用户集合上算有了这张倒排表就不用每次全表扫描。最后用优先队列边算边裁只保留每部电影最相似的20个邻居。为什么只保留Top 20这是内存和质量的折中。如果存全部电影两两相似度4000部电影接近1600万条记录Double对象加Map桶开销轻松超过500MBTomcat直接撑不住。只保留K个邻居后矩阵变得稀疏内存占用降一个数量级。代码里K20是经验起点你可以改成10或30对比效果。recommend方法做的事情是遍历该用户所有评分对每个评过分的电影取出它最相似的20个邻居用“邻居相似度×用户评分”累加到候选得分里。比如用户给《盗梦空间》打了5分《盗梦空间》和《星际穿越》相似度0.8那么《星际穿越》就累积4分。所有候选电影排序后取Top N返回。代码里用的是余弦而不是皮尔逊是为了让核心逻辑更短。想换成皮尔逊只需要在cosine里先把两部电影的评分分别减去各自的平均分再算余弦其余部分不用动。注意代码里用了Map.entry方法Java 9可用。如果本机还是JDK 8换成new AbstractMap.SimpleEntry(movieId, sim)即可。还有一个细节init用PostConstruct在容器启动后执行。不要在构造函数里做这件事因为RatingMapper还没注入完成。也不要等第一个请求进来再懒加载这会让第一次请求慢到无法接受具体踩坑在第5章展开。4.3 Web接口层Spring Boot暴露推荐结果推荐结果不能只存在内存里要让Web页面能拿到。Controller如下RestController RequestMapping(/api/recommend) public class RecommendController { private final RecommendService recommendService; private final MovieMapper movieMapper; public RecommendController(RecommendService recommendService, MovieMapper movieMapper) { this.recommendService recommendService; this.movieMapper movieMapper; } GetMapping(/{userId}) public ListMovieVO recommend(PathVariable Long userId) { return recommendService.recommend(userId, 10).stream() .map(movieMapper::findById) .map(m - new MovieVO(m.getId(), m.getTitle(), m.getGenres())) .collect(Collectors.toList()); } }这个接口接收userId调用RecommendService拿到Top 10电影ID再逐个查电影详情拼装成MovieVO返回前端。这里有个容易被忽略的点MovieVO不能直接复用Movie实体VO里只放前端要展示的字段避免把数据库结构直接暴露出去。MovieVO是典型的三字段结构id、title、genres。/api/recommend/{userId}路径上的userId用来模拟当前登录用户。真实系统里要从会话或token里取毕设阶段传路径参数即可。如果遇到跨域问题加一个CorsConfiguration把/api/**放开就行。前端页面如果是单独的Vue或HTML工程这一步早晚要处理。前端不用写得复杂一个纯HTML加JavaScript示例就能跑div idrec-list/div script fetch(/api/recommend/1) .then(res res.json()) .then(data { const list document.getElementById(rec-list); data.forEach(item { const div document.createElement(div); div.textContent item.title - item.genres; list.appendChild(div); }); }); /script这里特意用textContent而不是innerHTML是为了避免电影标题里的特殊字符破坏页面也顺手避开了XSS隐患。电影标题里带引号、尖括号很常见拼字符串早晚翻车。这个细节很小但在答辩和代码review里很加分。5. 避坑指南推荐结果不准、运行慢、并发崩的5个真实问题有了能跑的代码后真正折腾人的是各种运行期问题。下面五条是我在这个方向上看到的最高频踩坑记录每条按“现象→原因→解决”写排查时直接对号入座。5.1 推荐结果毫无个性全像热播榜现象切换三个用户返回的Top 10几乎一样全是《肖申克的救赎》《霸王别姬》这种高分热门电影。原因本质是评分矩阵太稀疏大部分电影之间相似度为0候选集极小。与此同时很多人会在代码里写“如果候选集为空就返回热门榜”的兜底逻辑这个兜底直接让算法变成了排行榜。另一个常见遗漏是没有过滤用户已经看过的电影导致用户自己打过高分的电影反复出现在结果里。解决第一步删掉兜底热门榜的逻辑。冷启动用户的推荐应该走独立分支叫“新人推荐”或“热门榜”不要混在协同过滤流程里。第二步在recommend方法里加一行过滤把用户已经评过分的电影直接从结果中剔除。第三步相似度阈值设为0负相关的电影不要进入候选池。这三步做完推荐结果的个性通常立竿见影。5.2 内存溢出与首请求卡顿矩阵初始化方式的两个教训现象应用启动几秒后日志打印OutOfMemoryError: Java heap space有时直接卡在init阶段。还有一种情况是启动正常但第一个推荐请求转圈十几秒刷新后才恢复正常。原因内存溢出的最常见写法是用MapLong, MapLong, Double存了所有电影的两两相似度4000部电影约1600万条记录Double对象和Map桶的开销轻松超过500MB。如果先算全量再裁剪峰值内存更高。首请求卡顿则是初始化的时机不对相似度矩阵变成懒加载第一个请求进来时才现算相当于把全部离线计算压力压到在线路径上。解决内存问题靠“边算边裁”。用优先队列只保留每部电影Top 20邻居而不是算完再裁。开发机JVM参数-Xmx512m起步如果加载全量MovieLens 1M建议-Xmx1g。初始化时机用ApplicationRunner或PostConstruct在启动阶段预计算并在init结束后打印一行“相似度矩阵加载完成电影数xxx邻居数xxx”的日志方便复盘。先排除本机残留的旧Java进程用jps看一眼再谈优化有时候卡顿和溢出只是上一个没退干净的进程在抢资源。5.3 连接池打满与页面白屏并发和渲染的稳定问题现象JMeter或同窗口打开多个页面时日志里大量出现Connection is not available接口报500。另一种情况是接口返回正常JSON也没问题但页面渲染后部分区域白屏控制台报语法错误。原因连接池打满通常是在推荐路径里反复查数据库。每个请求都要去查询评分一个请求可能触发几十次SQL如果每次请求都新建Connection而不是用连接池线程一多连接就耗尽。页面白屏则是前端用innerHTML拼字符串电影标题里的单引号、双引号、撇号把HTML结构截断了像“Doraemon: Nobitas New Great Demon”这类标题必踩。解决第一评分数据已经在启动阶段全部读进内存推荐路径里不要再查rating表。第二数据库连接交给Spring Boot默认的HikariCP管理Controller依赖注入Mapper绝不手动建连接。第三前端用textContent或模板引擎的转义输出不要手工拼HTML字符串。这个坑顺手堵住了一个XSS隐患如果电影标题来自外部抓取问题会更严重。修完这三处50并发下P99稳定在500毫秒内就足够答辩演示了。6. 顺着答辩往下走离线指标、缓存和一次像样的验证6.1 别等老师问先把召回率算出来离线评估在毕设里最容易被跳过但“你的系统到底准不准”这个问题几乎必问。常见做法是留出法把每个用户的评分按时间或随机分成训练集80%和测试集20%只用训练集生成推荐再统计测试集里有多少电影被推荐命中。命中率通常落在10%到25%之间具体数值和数据集有关。给一段极简校验逻辑ListLong rec recommendService.recommend(userId, 10); ListLong testMovies testRatings.get(userId); long hit rec.stream().filter(testMovies::contains).count(); double recall hit / (double) testMovies.size();recall就是召回率也就是测试集里被推荐系统捞回来的比例。代码量很小但说明你懂推荐系统评价。如果答辩老师顺着问准确率把分母换成rec.size()即可。这个点也经常出现在java面试题里提前跑一遍数据比背概念有用得多。6.2 用户再看一眼页面推荐结果也要做缓存虽然相似度矩阵常驻内存但每个用户的Top 10每次都重新计算还是会消耗几百毫秒。毕设阶段可以用一个ConcurrentHashMap做简单结果缓存private final MapLong, ListLong recCache new ConcurrentHashMap(); public ListLong recommendCached(Long userId, int topN) { return recCache.computeIfAbsent(userId, id - recommend(id, topN)); }这个缓存没有过期时间适合演示。真实项目中要加时间戳或TTL比如用户产生新评分后清空该用户缓存否则推荐永远不会更新。6.3 接口冒烟与并发验证证明它不是玩具部署时不需要单独买web服务器Spring Boot内嵌的Tomcat就是最省事的web服务器本地演示跑起来即可。用一行命令确认接口可用curl http://localhost:8080/api/recommend/1返回带着电影标题的JSON数组说明整条链路是通的。并发验证用JMeter建一个线程组50个线程循环10次看聚合报告里的错误率和P99响应时间。这一步做完你的系统就不再只是一个能跑的源码工程而是一个能说性能、能讲指标的Web项目。我做这类项目最大的教训是把大量时间花在调K值和相似度参数上却忽略了“已看过的电影没过滤”这种低级问题。后来每改一轮算法就先问自己一句换一个真实用户这个结果合理吗这个习惯帮我避开了很多无效调优。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进