☰
基于Spark与ALS的电影推荐系统全链路设计与实现
2026/10/31 7:13:20 网站建设 项目流程

简介:该资源面向计算机专业学生与大数据入门开发者,是一份围绕 Spark 技术栈完成电影推荐系统的完整毕业设计资料,内容涵盖论文文档与配套源码,可用于课程设计、毕业设计参考或推荐算法入门实践。压缩包共 1 个 docx 文件,约 7.46MB,文档按标准论文结构组织,含摘要与英文摘要、绪论、开发技术介绍、系统分析与设计、系统实现、系统测试及结论、参考文献与致谢等章节,便于读者快速把握整体写作框架与论证逻辑。技术层面系统梳理了 Spark、MongoDB 与 Web 相关技术,并分别讲解基于人口统计学、基于内容和基于协同过滤三类推荐算法的原理与设计思路,同时给出用户登录注册、个性化推荐、电影搜索、用户评分等功能模块的实现方案及功能与性能测试方法。目前已有 207 人学习,适合需要参考选题结构、算法对比思路与系统实现流程的读者对照学习,也可作为撰写同类推荐系统论文时的框架模板。

1. 需求先立住:一个电影推荐系统要回答哪三个问题

做过推荐类毕设的人都清楚,最难的阶段通常在跑通协同过滤之后才真正开始:这时你要回答“用户打开首页那一刻,凭什么给他看这几部片”。基于 Spark 的电影推荐系统之所以值得单独做一套完整方案,是因为它同时压着三个彼此冲突的诉求:大数据量下的离线统计要跑得动,新用户进来不能没有内容可推,老用户点开详情页之后还要能顺着继续看。这套方案面向的是要在有限机器上交付完整链路的同学和初级工程师——几台虚拟机、一套 Spark、一个 MongoDB,把离线统计、协同过滤个性化、实时推荐、详情页相似推荐四条链路全部串起来,前端能点、后台能查、算法能复现。后面按需求、架构、算法、实现、避坑、验收的顺序拆开讲,能直接照着改。

2. 技术栈与总体架构:Spark 计算层、MongoDB 存储层与前后端的职责边界

2.1 把计算压在 Spark 上,而不是写个单机脚本

很多课设会先用 pandas 或单机 Python 脚本把推荐逻辑跑通,数据量几千条时没什么问题,一旦把评分表放大到百万级,内存先承受不住,迭代训练更是没有任何进展。协同过滤里的 ALS 属于典型的迭代式算法,每轮都要重复读同一份评分矩阵,MapReduce 那种“中间结果全部落盘”的模型在这种场景下磁盘 I/O 开销极大,而 Spark 把中间数据放在内存里,迭代几十轮的开销才是可接受的水平。这是选型的第一层理由。

第二层理由在于组件覆盖的完整度。这套系统需要的不止是训练一个模型:历史热门、近期热门、平均分、分类别 Top10 这些统计类结果依赖 SQL 式的聚合能力,对应 Spark SQL;个性化召回依赖矩阵分解,对应 MLlib 的 ALS;实时推荐依赖流式处理,对应 Spark Streaming。三者共用同一套 RDD/DataFrame 抽象和同一份集群资源,不需要为每个环节单独搭一套环境,这对只有一台物理机的毕设场景来说是必须考虑的现实约束。

第三层理由是语言。计算层用 Scala 写,语法简洁、和 Java 完全互操作;业务层用 Spring 写 Java,两边共用 JVM 生态,实体类可以复用同一套字段定义,省掉一层数据转换的样板代码。Maven 负责依赖管理和打包,前端交给 AngularJS2 做响应式页面。整套东西没有一个是需要商业授权的,可行性分析这一节基本可以照着这个思路写。

提示:伪分布式集群的意义不在于性能,而在于让“提交到集群”“资源调度”“Web UI 查看 Worker 状态”这些动作真实发生。答辩时能打开集群页面,比讲十句架构图更有说服力。

2.2 分层结构:view 层、business 层、计算层与存储层怎么切

系统按四层切开。view 层是 AngularJS2 写的单页应用,负责登录注册、首页推荐位、电影详情、评分打标、模糊搜索这些交互,所有数据都从 business 层的 REST 接口取,不直接连数据库。business 层是 Spring 撑起来的业务服务,做参数校验、权限判断、结果组装,同时承担“把用户行为写进存储”的职责——用户打一次分,这一层要同时更新 MongoDB 的评分表和 Redis 的近期行为队列。

计算层是 Spark,分两条独立的链路。离线链路是按天或手动触发的批处理任务,读 MongoDB 里的原始评分与电影元数据,算出统计类结果和 ALS 推荐结果,再写回 MongoDB 的结果集合。实时链路是常驻的 Spark Streaming 作业,消费评分行为流,结合 Redis 里缓存的用户近期评分做快速召回,结果写进实时推荐集合,前端下次刷新就能拿到。

存储层做了职责拆分,避免一个库承担所有压力:

存储承担的角色典型数据
MongoDB业务主库 + 推荐结果库用户、电影、评分、各类推荐结果集合
Redis实时链路的行为缓存每个用户最近若干条评分记录
Elasticsearch电影检索片名、简介、演员、导演等可搜索字段

四条推荐链路的分工也要在这一层说清楚:离线统计负责“历史热门、近期热门、平均分、分类别 Top10”;离线个性化负责“猜你喜欢”,走 ALS;实时推荐负责“刚看完这部,接下来推什么”;详情页推荐负责“和当前这部相似的还有哪些”。四条链路各自产出结果集合,互不阻塞,任何一条挂了首页都还有内容可展示,这一点在答辩里是加分项。

2.3 数据模型:九个集合撑起四条推荐链路

集合设计的原则很简单——原始数据和推荐结果分开存,一类结果一个集合,别塞进同一个文档里。原始数据只有电影表、评分表、用户表三张;推荐结果按链路拆成平均分表、分类别 Top10 表、历史热门表、近期热门表、离线个性化推荐表、实时推荐表,详情页的相似度结果通常也会单独落一个集合。

字段类型要在建模阶段就统一,这是后面所有坑的源头。评分分值统一用 Double,时间戳统一用毫秒 Long,用户和电影主键统一用整型,别出现一张表里 score 是 Int、另一张表里是 Double 的情况,Spark 读进来推断成两种 schema,join 的时候报错信息能让人找半天。

// 实体类与 MongoDB 集合一一对应,字段名和类型在这里定死 case class Movie(mid: Int, name: String, descri: String, timelong: String, issue: String, shoot: String, language: String, genres: String, actors: String, directors: String) // score 必须是 Double,timestamp 必须是毫秒 Long,全项目不许出现第二种写法 case class Rating(uid: Int, mid: Int, score: Double, timestamp: Long) // 推荐结果统一结构:一个用户对一串 (电影, 分数),方便前端直接渲染 case class Recommendation(mid: Int, score: Double) case class UserRecs(uid: Int, recs: Seq[Recommendation])

这段定义看着平淡,实际是整份方案的基础。Movie里的genres用竖线分隔多个标签(比如“动作|惊悚”),所以后面基于内容的推荐不用做中文分词,直接按分隔符切就行,这一点省掉了引入分词器的麻烦。Rating里的timestamp在近期热门统计和实时推荐里都要用,单位不统一会导致“近一个月”这个窗口算出来差三个数量级。UserRecs把推荐列表作为一个数组字段存进单个文档,读的时候一次查询就能拿到某个用户的全部推荐,比一行一条记录省掉一次聚合。

Recommendation的score在不同链路里含义不同:ALS 输出的是预测评分,内容推荐输出的是余弦相似度,实时推荐输出的是加权打分。如果直接混在一起展示,排序就会变得难以解释。我一般在写入时就把分数归一化到 0~1,并在集合里额外记一个来源字段,前端按来源分栏展示,出问题时也能快速定位是哪条链路的结果。

3. 三路推荐算法的分工:人口统计学保底、内容相似度补位、协同过滤主攻

3.1 冷启动的先手:人口统计学与统计类离线推荐

新用户注册完,历史行为是零,ALS 训不出任何东西,协同过滤在这里完全无能为力。这套系统把“无行为数据”的场景交给统计类和人口统计学方法,效果谈不上精准,但能保证页面不空。原方案里这一节写得有点名不副实——标题叫基于人口统计学,实际做的是四类统计:历史热门影片、近一个月热门影片、电影平均评分、各分类 Top10。从工程角度看这个折中是对的,统计类结果可解释性极强,用户看到“本周热榜”不会觉得莫名其妙。

四类统计的实现路径基本一致:Spark SQL 从 MongoDB 读原始表,聚合成结果 DataFrame,再写回对应的结果集合。历史热门按评分次数倒序,近期热门在评分表上加时间窗口过滤,平均评分直接 groupBy 求均值,分类别 Top10 稍微麻烦一点,因为电影的类别字段是竖线分隔的多值字符串,得先炸开再聚合。

// 把 "动作|惊悚" 这种多值字段炸成多行,一行一个类别 val splitGenres = udf((genres: String) => genres.split("\\|").toSeq) val movieWithGenre = movies .withColumn("genre", explode(splitGenres($"genres"))) .select($"mid", $"genre") // 先算每部电影的平均分 val avgMovies = ratings.groupBy("mid").agg(avg("score").as("avgScore")) // 关联后按类别分组,组内按平均分降序取前 10 val top10 = movieWithGenre .join(avgMovies, Seq("mid")) .groupBy("genre") .agg(collect_list(struct($"mid", $"avgScore")).as("candidates")) .withColumn("recs", topN($"candidates", lit(10))) // 自定义 UDF:排序后截断 .select($"genre", $"recs")

explode是这里的核心,它把一行多标签记录变成多行单标签记录,后续 groupBy 才有意义。topN这个 UDF 接收一个结构体数组和截断长度,内部按分数降序排完取前 N 条返回,之所以不直接用窗口函数,是因为分组数不多、每组数据量也有限,UDF 写起来更直观。lit(10)把常量包成 Column,否则 UDF 调用时类型对不上。

这套统计任务有一个容易被忽略的细节:分类别 Top10 在类别数量多、电影数量大的时候会产生笛卡尔积式的膨胀,先过滤再聚合比先聚合再过滤快一个数量级。另外平均分要设一个最小评分数阈值,只被一个人打了 10 分的片子不该出现在榜单里,这属于统计类推荐的常见保底处理。

3.2 内容推荐:标签向量、相似度计算与详情页推荐

详情页推荐和实时推荐的候选生成都依赖基于内容的算法,它最大的好处是不需要用户评分数据,只要电影本身有标签就能算,冷启动问题在这里被绕开了。做法是把每部电影的类型、演员、导演拼成一段文本,做 TF-IDF 向量化,然后两两算余弦相似度,取 TopN 存进相似度集合。

// 三个标签字段拼成一篇“文档”,竖线是天然的分隔符 val docs = movies.select($"mid", concat_ws(" ", $"genres", $"actors", $"directors").as("doc")) val tokenizer = new RegexTokenizer() .setInputCol("doc").setOutputCol("words") .setPattern("\\|") // 直接按竖线切,不用中文分词器 val hashingTF = new HashingTF() .setNumFeatures(4096) // 特征维度,标签总量不大,4096 足够 .setInputCol("words").setOutputCol("raw") val idf = new IDF().setInputCol("raw").setOutputCol("features") val pipeline = idf.fit(hashingTF.transform(tokenizer.transform(docs)))

RegexTokenizer的 pattern 设成竖线,是因为标签本身已经结构化,硬上中文分词反而会把“悬疑”切成两个无意义的字。HashingTF用哈希把词映射到固定维度,好处是不用维护词表、维度可控,代价是存在哈希冲突,4096 维对几千部电影的标签量来说冲突概率很低。IDF的作用是压低“剧情”“喜剧”这种高频通用标签的权重,让相似度更聚焦在区分度高的标签上。

相似度算完之后不保存全量矩阵,只保留每部电影的前 20 个近邻。理由很实在:电影数量上万时,全量相似度矩阵是上亿条记录,存下来既占空间又没意义,用户不可能翻到第 200 个相似影片。

3.3 协同过滤:ALS 隐语义模型怎么调、怎么评估

个性化推荐的主力是 ALS。它的思路是把用户-电影评分矩阵分解成两个低维矩阵,用户矩阵和电影矩阵的乘积去逼近真实评分,训练目标是最小化预测误差加正则项。相比基于邻域的方法,ALS 在稀疏矩阵上表现更稳,也更容易并行化,这也是它成为 Spark MLlib 里唯一现成推荐算法的原因。

val als = new ALS() .setMaxIter(10) // 迭代轮数,10 轮后误差下降已经很平 .setRegParam(0.01) // 正则系数,数据越稀疏越要往大调 .setRank(50) // 隐因子维度,20~100 之间网格搜 .setUserCol("uid").setItemCol("mid").setRatingCol("score") .setColdStartStrategy("drop") // 训练集里没出现过的用户/电影直接丢 val Array(train, test) = ratings.randomSplit(Array(0.8, 0.2), seed = 42L) val model = als.fit(train) // 用 RMSE 评估,别只看 loss 曲线 val predictions = model.transform(test) val evaluator = new RegressionEvaluator() .setMetricName("rmse").setLabelCol("score").setPredictionCol("prediction") val rmse = evaluator.evaluate(predictions) // 给所有用户生成 20 条推荐,写入离线推荐集合 val userRecs = model.recommendForAllUsers(20)

setColdStartStrategy("drop")这行必须加,否则预测阶段遇到训练集里没出现过的用户会产生 NaN,写进 MongoDB 之后前端渲染直接崩。rank和regParam是主要调参对象:rank 太小模型欠拟合,推荐全是热门;rank 太大在稀疏数据上容易过拟合,测试集 RMSE 反而上升。maxIter一般 10~20 轮就够,再往上收益递减明显,伪分布式环境下纯属浪费时间。

评估口径这件事在毕设里特别容易出问题。RMSE 低不代表推荐好,因为模型完全可以把所有电影都预测成 3.5 分,RMSE 依然很小。更靠谱的做法是补一组 TopN 指标,比如命中率:对每个用户在测试集里的高分电影,看推荐列表里命中了几个。两个指标一起报,答辩时被问到“怎么证明推荐有效”才有话说。

3.4 融合与保底:四路结果怎么排序不冲突

四条链路各出各的结果,如果前端随便拼在一起,用户会看到同一部电影出现两次、刚打过高分的片子又被推荐一遍。融合层要做三件事。第一是去重,按 mid 合并,同一条目保留分数最高的来源。第二是过滤,把用户已经评过分、已经看过的电影从候选里剔掉。第三是归一化,ALS 输出的是 0.5~5 的预测分,内容推荐输出的是 0~1 的余弦相似度,直接相加没有意义,得先用 min-max 拉到同一量纲再加权。

权重没有标准答案,我一般的做法是离线个性化给 0.5、实时推荐给 0.3、热门榜单给 0.2,新用户没有行为数据时把前两项权重置零,全量走热门和分类别榜单。这套规则写在一份配置里,改权重不用重新跑任务,答辩演示时想强调哪条链路都能现场调。

当只有一路返回结果时,直接用它;四路都空的情况必须有一个保底,通常是随机抽若干高分电影填充。这个分支看着不起眼,但演示时如果首页白了,前面的架构讲得再好也没用。

4. 环境搭建与模块实现:四节点伪分布式集群到推荐接口的完整链路

4.1 集群与依赖:一台物理机上模拟四个节点

受硬件限制,实际开发只有一台物理机,用虚拟机模拟出四个节点:一个 Master、三个 Worker。三台 Linux 机器装 JDK 并配好环境变量,主机名分别设为 hadoop102、hadoop103、hadoop104,配置 SSH 免密登录,让主节点能直接拉起从节点上的进程。这一步如果跳过,start-all.sh会在启动 Worker 时反复要密码,最后只起来一半节点。

# 三台机器统一执行:改主机名、生成密钥、把公钥发给三台 hostnamectl set-hostname hadoop102 ssh-keygen -t rsa -P "" -f ~/.ssh/id_rsa ssh-copy-id hadoop102 && ssh-copy-id hadoop103 && ssh-copy-id hadoop104 # 主节点上配置 slaves 文件,写入三个 Worker 主机名后启动 $SPARK_HOME/sbin/start-all.sh

-P ""表示密钥不设密码,自动化脚本才能非交互执行;ssh-copy-id要把公钥分发到包括自己在内的三台机器,否则主节点连自己都要密码。start-all.sh会先起 Master 再逐个起 Worker,起完之后打开 Master 的 Web UI,Worker 列表里三个节点状态都是 ALIVE 才算成功——这个页面建议截图放进论文,比文字描述直观得多。

配套服务也要在这一步装好:MongoDB 装在业务侧机器上,建好库和集合;Redis 装在 Linux 上做行为缓存;Elasticsearch 承担检索。三者的连接信息统一写进配置文件,别散落在代码里。

4.2 离线统计任务:Spark SQL 与 UDF 的落地写法

离线任务的骨架是统一的:建 SparkSession、连 MongoDB 读数据、用 SQL 或 DataFrame API 算、把结果写回。以近期热门为例,需要统计近一个月内被评分次数最多的电影。

val spark = SparkSession.builder() .appName("RateMoreRecentlyMovies") .config("spark.mongodb.input.uri", "mongodb://127.0.0.1:27017/movie.Rating") .config("spark.mongodb.output.uri", "mongodb://127.0.0.1:27017/movie.RateMoreRecentlyMovies") .getOrCreate() // 一个月窗口的毫秒数,用固定值而不是动态算,保证多次运行结果可复现 val oneMonthAgo = System.currentTimeMillis() - 30L * 24 * 3600 * 1000 val recent = spark.read.format("com.mongodb.spark.sql.DefaultSource").load() .filter($"timestamp" > oneMonthAgo) .groupBy("mid") .agg(count("uid").as("count")) .orderBy($"count".desc) .limit(100) recent.write.format("com.mongodb.spark.sql.DefaultSource").mode("overwrite").save()

时间窗口用固定毫秒数算而不依赖数据库函数,是因为不同机器的时区设置可能不一致,动态计算会让同一份数据在两台机器上跑出不同结果,复现时很难排查。mode("overwrite")表示每次全量覆盖结果集合,统计类任务本身是幂等的,覆盖比增量更新省心。

写回时要注意 DataFrame 的列名必须和 MongoDB 集合里的字段名对得上,schema 不匹配不会报错,只会写进去一堆空文档,这是很隐蔽的问题。我一般在写之前先printSchema()看一眼字段类型,尤其是 count 这类聚合出来的列,默认是 Long,如果集合里定义成 Int,读回来会变成另一种类型。

4.3 实时推荐:Redis 缓存加 Spark Streaming 的最小闭环

实时推荐要在用户评分后几秒内更新推荐列表,这个延迟要求下不可能重新训练 ALS,只能做近邻式的快速召回:拿到用户最近评过的几部电影,找它们的相似电影,加权合并后输出。用户近期行为存在 Redis 里,用 List 结构按时间倒序存,每次评分往头部推一条,同时截断长度。

// 取用户最近 K 条评分,K 取 20:太少召回不准,太多实时算不动 def getRecentRatings(pool: JedisPool, uid: Int, k: Int): List[Rating] = { val conn = pool.getResource try { conn.lrange(s"uid:$uid", 0, k - 1).asScala.toList.map { line => val arr = line.split("\\|") // 存储格式:mid|score|timestamp Rating(uid, arr(0).toInt, arr(1).toDouble, arr(2).toLong) } } finally conn.close() // 必须归还连接,否则连接池很快被打满 }

用 List 而不是 Hash,是因为这里只关心“最近 N 条”这个顺序语义,lpush加ltrim两个命令就能维护窗口,比在 Hash 里自己维护时间戳排序简单得多。finally里归还连接这件事看着基础,实际是连接池耗尽类故障的头号原因,流式任务里漏一次就是持续泄漏。

流处理端的逻辑是:每个批次从行为流里取出用户和电影,查相似度集合拿到候选,按相似度乘以评分权重累加,取 Top20 写入实时推荐集合。批处理间隔设成秒级,间隔太短会导致每个批次数据量不足、频繁启停开销大,太长又失去了“实时”的意义。

4.4 业务侧实现:登录、评分、搜索与推荐接口

业务层的接口不多,但每个都牵着一条链路。用户登录注册操作 User 集合;电影搜索走 Elasticsearch;推荐列表按场景分别读不同的结果集合;评分接口是最关键的一个,它要同时写 MongoDB 和 Redis。

@RestController @RequestMapping("/api/movie") public class MovieController { @Autowired private MongoTemplate mongoTemplate; @Autowired private JedisPool jedisPool; // 提交评分:先落库保证持久化,再推缓存供实时链路消费 @PostMapping("/rate") public Result rate(@RequestBody RateRequest req) { Query q = Query.query(Criteria.where("uid").is(req.getUid()) .and("mid").is(req.getMid())); mongoTemplate.upsert(q, new Update() .set("score", req.getScore()) .set("timestamp", System.currentTimeMillis()), "Rating"); try (Jedis jedis = jedisPool.getResource()) { String key = "uid:" + req.getUid(); jedis.lpush(key, req.getMid() + "|" + req.getScore() + "|" + System.currentTimeMillis()); jedis.ltrim(key, 0, 19); // 只保留最近 20 条,防止无限增长 } return Result.ok(); } }

用upsert而不是insert,是为了处理“同一个用户重复给同一部电影打分”的场景:查询条件按 uid 加 mid 定位,存在就更新分值和时​​间戳,不存在才插入。这样评分表里不会出现重复记录,ALS 训练时也不会因为同一对用户-电影有多条记录而把权重放大。写库在前、写缓存在后,是因为缓存只服务实时链路,丢了最多影响一路推荐,而评分数据丢了就是数据事故。

搜索接口交给 Elasticsearch,模糊匹配片名、演员、导演几个字段,比在 MongoDB 里用正则全表扫快得多。前端页面访问本地 8088 端口,登录后进入首页,四个推荐区块分别对应四条链路的结果。

5. 避坑与排查:集群、稀疏数据与推荐效果的四类高发问题

5.1 集群起来了,任务却全挤在一个节点上跑

现象是 Web UI 里三个 Worker 都在,但提交任务后只有一个节点 CPU 打满,另外两个闲着,任务耗时和单机跑没区别。

原因通常有两个。一是提交时没指定 master,代码里写死了local[*],任务压根没进集群;二是数据分区数太少,比如从 MongoDB 读出来只有两个分区,三个 Executor 里必然有一个分不到活。

解决办法是提交脚本里显式指定--master spark://hadoop102:7077,并在读数据后调一次repartition(12),分区数取 Executor 核数的 2~3 倍。改完再跑,Web UI 的 Stage 页面能看到任务均匀分布,耗时通常能降一半以上。这个坑我在第一次搭集群时踩了整整两天,血泪经验是:先看 UI 里的 Task 分布,再怀疑代码。

5.2 离线 RMSE 很漂亮,线上推荐全是热门片

现象是测试集 RMSE 只有零点几,但打开首页,每个用户看到的推荐列表几乎一样,翻来覆去都是那几部高分电影。

原因在于评分数据的分布极度不均:少数电影被几千人评过分,绝大多数只有个位数。ALS 在这种长尾分布上会倾向于给热门电影更高的预测分,因为它们在训练中出现的次数多、拟合得更充分。RMSE 这个指标对这种现象完全不敏感,它衡量的是整体误差,不衡量推荐的多样性。

解决办法分三步。先给训练数据做重采样,对热门电影的评分做降权或下采样;再在候选生成后加一层多样性约束,同一个主演或同一类别的电影最多出现两条;最后在评估里补上覆盖率指标,统计推荐结果涉及的电影占全部电影的比例,这个数字一目了然。改完之后 RMSE 可能略微变差,但推荐列表明显更像“给人看的”。

5.3 实时推荐延迟高,刷新几次结果还会丢

现象是用户打完分要等十几秒首页才变,有时候刷新一下推荐又回到旧列表。

原因一般出在缓存和批处理两头。缓存侧可能是每次评分都新开一个 Jedis 连接没归还,连接池被打满后请求排队;批处理侧可能是间隔设得太长,或者写实时结果集合时用了覆盖模式,把上一批还没被前端读到的新结果冲掉了。

解决办法是把连接获取统一收进 try-with-resources,确保归还;把批处理间隔调到秒级并观察每批的实际处理耗时,如果单批耗时接近间隔时间,说明候选计算量太大,要减少每个用户取的历史条数;写结果时按 uid 做 upsert 而不是整表覆盖。排查这类问题的顺序建议是:先看连接池监控,再看每批耗时,最后看写入模式,别一上来就怀疑算法。

5.4 片名搜不到,MongoDB 里还多出一堆空文档

现象有两个,往往同时出现:搜索框输入完整片名返回空结果;某个统计任务跑完后,结果集合里的文档字段全是 null。

搜索问题的原因是 Elasticsearch 建索引时没配中文分析器,默认分词器把中文片名当成一个整词,用户输一半自然匹配不上。解决办法是在 mapping 里指定中文分词器,或者退一步用 match_phrase 加 slop 做短语匹配,先保证可用。

空文档问题的原因是 DataFrame 的 schema 和集合里已有文档的字段类型不一致,Spark 写入时不会报错,只会静默写成 null。排查方式是写之前printSchema()核对一遍,尤其是聚合出来的 count、avg 这类列,默认类型往往是 Long 或 Double,和集合里定义的 Int 对不上。从那以后我养成了一个习惯:任何写库操作前先打印 schema,宁可多花十秒,也别花两小时找空字段。

6. 验收与答辩:功能用例、性能指标和演示脚本怎么准备

验收这一步最容易被草草带过,写两句“系统运行正常”就结束了,答辩时被追问就答不上来。功能测试要按用例走,每条用例写清前置条件、操作步骤、预期结果,至少覆盖四条推荐链路各一条、搜索一条、评分一条、登录注册一条。重点验证的是“刚评完分的电影不会立刻出现在推荐里”这类边界,而不是只截个首页图。

用例操作预期结果
新用户首页注册后直接进入首页展示热门榜与分类别榜单,个性化区块为空或走保底
老用户个性化评分 10 部以上后刷新推荐列表与热门榜重合度明显下降
详情页相似打开任一电影详情推荐 5~20 部同类型或同主创影片
实时推荐给某部电影打高分后等待一个批次实时区块出现该片相似影片,且不包含已评分项
模糊搜索输入片名片段返回匹配影片,响应在可接受范围内

性能测试不用做得多复杂,两组数字就够:离线 ALS 任务在给定数据量下的总耗时,以及推荐接口的响应时间。测试时把数据量、分区数、迭代轮数一起写进表格,否则数字没有参照意义。我见过只写“训练耗时 3 分钟”的报告,被问“多少数据、几轮迭代”时当场答不上来,很可惜。

答辩演示建议提前排好脚本:先用新账号登录,指出首页走的是热门保底;然后连续给三部同类型电影打高分,等待十几秒刷新,指出个性化区块的变化;接着点开其中一部,讲详情页的相似度来源;最后打开集群 Web UI,说明任务确实提交到了集群而不是本地跑。整条路径控制在三分钟内,中间不要现场敲命令,任何一步卡住都会让后面全乱。

从那以后我每次做这类项目,都会在答辩前一天晚上把演示路径从头到尾走三遍,把可能卡住的环节提前截图备好,因为现场的环境永远比不上自己机器听话。希望帮到你。

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

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

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

立即咨询