☰
Java图书推荐系统源码:协同过滤+内容召回解决评分稀疏
2026/11/5 7:08:22 网站建设 项目流程

简介:这是一套面向高校课程设计与Java初学者实践的机器学习图书推荐系统源码,聚焦个性化图书推荐场景,可用于毕业设计参考、协同过滤算法学习及Web项目二次开发。系统覆盖图书管理、读书会管理以及科技、人文、历史、中小学等图书分类推荐,后端采用SpringMVC框架,前端由HTML、CSS构建,并以协同过滤算法依据用户点击行为刻画偏好,通过计算图书特征距离完成近邻推荐。压缩包共172个文件,约17MB,包含25个java源文件、32个jsp页面、58个class编译文件,以及jar依赖、xml配置、sql脚本和界面图片、说明文档等,源码、数据库与运行依赖相对齐备,便于梳理项目结构与调试流程。目前已有259人学习。读者可据此理解推荐系统从数据表设计、用户行为记录到相似度计算与结果展示的完整链路,也能参考现有模块改造分类推荐与读书会功能,适合需要一套可运行、可拆解的机器学习入门项目作为学习样本。

1. 给图书站做推荐位,先要解决的问题不是“准”

给一个图书类站点做推荐位的时候,运营的原话是“先别管多准,别一打开全是畅销榜那二十本就行”。这句话把图书推荐系统的难点说透了:书目多、单用户评分少、长尾书几乎没有曝光机会,纯热门兜底会把推荐位做成第二个销售榜。这套基于机器学习的图书推荐系统源码,走的是 Java 后端 + 推荐算法模块内置的路线,用协同过滤做主体召回,再叠一层基于标签的内容召回,解决的就是“评分稀疏的时候还得推得动”这件事。它适合两类人:一类是要交课程设计或者毕业设计,需要一套能跑起来、能改算法的完整工程;另一类是手上已经有图书业务,想找个能塞进现有 Java 服务的推荐模块做二次开发。它不适合用来处理千万级用户,但在把推荐链路每一环拆开看清楚这件事上,够用。

2. 分层架构:一次图书推荐请求在 Java 端是怎么走完的

2.1 技术栈清单与选型理由

这类项目最容易出问题的地方在于算法模块和业务模块相互牵扯,而不是算法本身写不出来。我见过的写法里,比较稳妥的是按职责切成四层,算法层只认“用户 ID、图书 ID、评分”这三种数据,不碰 HTTP 也不碰数据库连接。具体选型大致是这样:

层次选型在项目里负责什么
接口层Spring Boot + Spring MVC暴露推荐接口、参数校验、统一返回体
业务层原生 Java Service图书查询、用户行为写入、推荐结果拼装
算法层纯 Java 实现评分矩阵、相似度、召回、融合排序
持久层MyBatis-Plus + MySQL图书表、用户表、评分表、推荐结果表
缓存Redis推荐结果缓存、热门榜、相似度矩阵快照

为什么算法不直接用现成的机器学习库?因为图书推荐这个场景里,协同过滤的数学部分并不复杂,一个余弦相似度加一个加权求和就够跑出效果。相比之下,调用外部库就要处理版本冲突、模型文件序列化、Python 与 Java 之间的进程通信,这些额外成本往往比算法本身还大。我一般建议:数据量在十万级评分以内,用 Java 手写 UserCF、ItemCF 完全够;只有当你要上矩阵分解、深度召回这类模型时,才值得引入独立的算法服务。

数据库选 MySQL 也很自然,图书、用户、评分这三类数据本质上是关系型的,评分表加一个(user_id, book_id)的唯一索引就能挡住重复提交。真正的性能瓶颈不在存储,而在“每次推荐都去扫全量评分表”,这一点后面讲缓存的时候会展开。

2.2 离线算矩阵、在线拿结果的链路拆解

推荐系统跟普通 CRUD 最大的区别是它有两套时间尺度。用户点一次“为你推荐”,期望 200 毫秒内出结果;但相似度矩阵的计算可能要跑几分钟。把这两件事混在一起做,接口必然超时。所以链路上要明确分成两段:

第一段是离线。定时任务在凌晨低峰期跑,把评分表全量拉出来,构建用户-图书的评分矩阵,两两计算用户相似度,把每个用户最相似的 Top-K 邻居存进user_sim表或者直接序列化进 Redis。这一步慢一点没关系,关键是结果要落盘,第二天在线服务直接读。

第二段是在线。接口收到请求后,先查缓存;缓存没命中,就读该用户的邻居列表,把邻居评过高分、而当前用户没碰过的书加权汇总,排序取 TopN,回写缓存。整条在线链路只涉及“一次邻居查询 + 一次候选图书批量查询”,不做任何两两比较,响应时间能压到几十毫秒。

提示:离线任务一定要做幂等。我习惯在user_sim表上加一个batch_date字段,新批次先写临时表,算完再原子替换,出问题能一键回滚到上一批。

这条链路还有个容易被忽略的细节:离线算出来的邻居是“昨天的相似”,用户今天刚评了一本书,邻居不会立刻变。对图书这种低频消费场景来说完全可以接受,一本书读完到再买下一本中间隔好几天,延迟一天没有任何体感。但如果你的场景是短视频或者新闻,就必须上实时特征,这套架构得换。

2.3 算法模块为什么要和业务模块隔离

把算法单独放在一个包里,好处在二次开发的时候特别明显。你想把 UserCF 换成 ItemCF,只需要新增一个实现类,改一下注入的 Bean,业务代码一行不用动。反过来,如果相似度计算直接写在 Controller 里,换算法就等于重写接口。

隔离还有个隐性收益:能单测。算法层的输入是Map<Long, Map<Long, Double>>,输出是List<RecommendItem>,不依赖数据库也不依赖 Spring 容器,用 JUnit 造两组假数据就能验证“邻居算得对不对”“已读的书有没有被过滤掉”。这种测试写起来五分钟,能省掉后面调 bug 的两小时。

3. 目录结构与数据模型:包名和数据表里藏着这套系统的骨架

3.1 工程目录拆解

拿到源码包后,先别急着点运行,先把目录看一遍。这类图书推荐工程典型的多模块结构是这样:

book-recommend/ ├── book-rec-common/ 公共模块:统一返回体 Result、业务异常、日期工具 ├── book-rec-dao/ 持久层:Entity、Mapper 接口、mapper/*.xml │ └── src/main/resources/mapper/ │ ├── BookMapper.xml │ ├── RatingMapper.xml │ └── UserSimMapper.xml ├── book-rec-service/ 业务层 │ └── src/main/java/.../rec/ │ ├── cf/ UserCfRecommender、ItemCfRecommender、SimilarityUtils │ ├── content/ 基于标签的内容召回 │ ├── rank/ 多路融合、去重、多样性打散 │ └── job/ 离线批处理:矩阵构建、相似度刷新 ├── book-rec-web/ 接口层:Controller、全局异常处理、启动类 ├── sql/ 建表脚本 + 演示数据 init.sql └── pom.xml 父 POM,统一版本管理

看目录基本能判断工作量分布:cf/目录下的文件数就是算法主体,job/下有几个类就说明离线任务拆了几步。如果content/是空的,那这套源码大概率只做了协同过滤,冷启动部分需要你自己补。sql/目录一定要先看,里面通常包含建表语句和几十条演示评分数据,没有演示数据的话你跑起来页面上是空的,会误以为程序坏了。

3.2 三张核心表和各字段的作用

图书推荐系统的表不多,但字段设计直接决定了算法能不能跑通。最少要有三张:

-- 图书表:内容召回的原料都在这 CREATE TABLE `book` ( `id` bigint NOT NULL AUTO_INCREMENT, `isbn` varchar(20) DEFAULT NULL, `title` varchar(200) NOT NULL, `author` varchar(100) DEFAULT NULL, `category_id` int DEFAULT NULL COMMENT '分类,用于同类兜底', `tags` varchar(255) DEFAULT NULL COMMENT '逗号分隔标签,内容召回靠它', `avg_score` decimal(3,2) DEFAULT '0.00', `rating_count` int DEFAULT '0' COMMENT '评分数,热门兜底排序用', PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 评分表:协同过滤的唯一数据源,唯一索引防重复提交 CREATE TABLE `rating` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `book_id` bigint NOT NULL, `score` decimal(2,1) NOT NULL COMMENT '1~5 分,允许 0.5 粒度', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_book` (`user_id`, `book_id`), KEY `idx_book` (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

rating表的唯一索引是重点。用户重复点“喜欢”会产生多条记录,一旦进到矩阵里,这本书的权重就被放大了,推荐结果会明显偏向某一类书,这种 bug 排查起来很费时间。book.tags字段看起来随意,其实是冷启动阶段唯一的依靠,新用户没评分的时候,只能靠他浏览过的书的标签去做内容召回。

如果源码里还有user_sim表和recommend_result表,那就是做了离线预计算和结果落库。user_sim一般长这样:(user_id, sim_user_id, score, batch_date),四个字段加一个联合索引,够用了。

3.3 环境准备与启动顺序

跑这套东西的顺序不能乱,乱了就会遇到空指针。我一般按这个流程走:

第一步,装 JDK 8 或 11、Maven 3.6+、MySQL 5.7/8.0、Redis。JDK 版本不要用 17,很多老项目里的 MyBatis-Plus 版本和 Lombok 版本在 17 上会因为模块系统报反射错误。

第二步,建库导数据。执行sql/init.sql,它会建表并插入演示数据。导完检查一下rating表有没有数据,select count(*) from rating;如果返回 0,后面的推荐必然是空的。

第三步,改配置。配置文件里的数据源地址、账号密码、Redis 地址必须和你本机一致:

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/book_rec?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 database: 0 rec: cf: neighbor-size: 30 # 邻居数量,20~50 之间比较稳 min-common-items: 3 # 共同评分少于 3 本不算相似,防止相关系数失真 cf-weight: 0.7 # 协同过滤在融合结果里的权重 content-weight: 0.3 # 内容召回权重

serverTimezone这个参数别省,MySQL 8 驱动不带它会直接抛时区异常。min-common-items是最容易被忽略的一个,设成 1 的时候,两个用户只共同评过一本书就会被判为高度相似,推荐结果会变得特别离谱,看起来像随机推荐。

第四步,启动类跑起来。日志里出现Tomcat started on port(s): 8080就说明容器起来了,但这时候别急着调接口,先确认离线任务有没有跑过一遍。很多工程在启动类里挂了一个CommandLineRunner做初始化,第一次启动会阻塞十几秒算矩阵,日志会停在那不动,不是卡死。

4. 推荐核心模块拆解:评分矩阵、相似度与 TopN 召回

4.1 评分矩阵的构建与稀疏存储

协同过滤的第一步是把数据库里的扁平行转成矩阵。这里有个新手常踩的坑:用二维数组double[userCount][bookCount]存。一万个用户乘十万本书就是十亿个格子,八成是 0,内存直接爆掉。

正确做法是稀疏存储,用Map<Long, Map<Long, Double>>,外层 key 是用户 ID,内层 key 是图书 ID。只有真正评过分的格子才存在,内存占用跟评分条数成正比。

/** * 把评分表转成稀疏矩阵 * 外层 key = userId,内层 key = bookId,value = 评分 */ public Map<Long, Map<Long, Double>> buildMatrix(List<Rating> ratings) { Map<Long, Map<Long, Double>> matrix = new HashMap<>(ratings.size() / 4); for (Rating r : ratings) { // 过滤脏数据:评分越界、ID 为空的直接跳过,别让它们污染相似度 if (r.getUserId() == null || r.getBookId() == null || r.getScore() == null) { continue; } double score = r.getScore().doubleValue(); if (score < 1d || score > 5d) { continue; } matrix.computeIfAbsent(r.getUserId(), k -> new HashMap<>(16)) .put(r.getBookId(), score); } return matrix; }

这段代码里computeIfAbsent的初始容量给了 16,是因为单个用户评分数通常在 5 到 50 本之间,一次分配够用,避免反复扩容。脏数据过滤看着多余,但实际项目里评分字段被前端传成 0 或者 99 的情况很常见,一个 99 分的异常值能把余弦相似度整体拉偏。

稀疏矩阵的代价是查询变慢,matrix.get(userId)是 O(1),但遍历所有用户找邻居是 O(用户数)。十万用户量级下这一步要几百毫秒,所以必须离线跑,不能放在接口里。

4.2 相似度计算:余弦与皮尔逊怎么选

相似度是整个推荐的心脏,写得不对,后面全白搭。最常用的是余弦和皮尔逊两个版本,代码里一般都会实现,用配置决定走哪个。

public final class SimilarityUtils { private SimilarityUtils() {} /** * 余弦相似度:把两个用户的评分向量当成空间里的两个点,算夹角 * 没什么共同评分的书时返回 0,不做强行推测 */ public static double cosine(Map<Long, Double> a, Map<Long, Double> b) { if (a == null || b == null || a.isEmpty() || b.isEmpty()) { return 0d; } // 遍历较小的那个 map,能少循环很多次 Map<Long, Double> small = a.size() <= b.size() ? a : b; Map<Long, Double> large = (small == a) ? b : a; double dot = 0d; for (Map.Entry<Long, Double> e : small.entrySet()) { Double other = large.get(e.getKey()); if (other != null) { dot += e.getValue() * other; } } if (dot == 0d) { return 0d; } double normA = 0d, normB = 0d; for (double v : a.values()) normA += v * v; for (double v : b.values()) normB += v * v; return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } /** * 皮尔逊相似度:先减去各自平均分再算余弦 * minCommon 控制共同评分下限,低于这个数直接返回 0 */ public static double pearson(Map<Long, Double> a, Map<Long, Double> b, int minCommon) { List<Long> common = new ArrayList<>(); for (Long bookId : a.keySet()) { if (b.containsKey(bookId)) common.add(bookId); } if (common.size() < minCommon) { return 0d; } double avgA = a.values().stream().mapToDouble(Double::doubleValue).average().orElse(0d); double avgB = b.values().stream().mapToDouble(Double::doubleValue).average().orElse(0d); double num = 0d, denA = 0d, denB = 0d; for (Long bookId : common) { double da = a.get(bookId) - avgA; double db = b.get(bookId) - avgB; num += da * db; denA += da * da; denB += db * db; } if (denA == 0d || denB == 0d) { return 0d; } return num / (Math.sqrt(denA) * Math.sqrt(denB)); } }

两个方法的差别在实际效果上很直观。余弦不减去均值,一个“给什么都打 5 分”的用户,会和所有人相似度都很高,因为他是个方向恒定的高分向量。皮尔逊做了中心化,这种用户被识别成方差极小,denA接近 0,结果反而被压到低分区。所以图书场景我更倾向皮尔逊,因为打分习惯差异太明显,有人最低 3 分,有人最高 3 分。

minCommon参数建议设 3。设 1 的时候,任意两个评过同一本书的人都是满分相似,邻居噪声极大;设 5 的时候,大部分用户对之间都没有 5 本共同书目,邻居数量凑不够,推荐覆盖度掉下来。3 是本场景下的折中值,可以按你自己的数据分布微调。

4.3 UserCF 召回主流程逐段讲

有了矩阵和相似度,召回就是“找邻居、加权、排序”三步。完整实现大概长这样:

@Service public class UserCfRecommender { @Resource private RatingMapper ratingMapper; /** 邻居数量:太多会引入噪声,太少覆盖不住长尾 */ private static final int NEIGHBOR_SIZE = 30; /** 候选集上限,防止内存被打爆 */ private static final int CANDIDATE_LIMIT = 200; public List<RecommendItem> recommend(Long userId, int topN) { // 1. 取全量评分构矩阵(生产环境应改成读离线快照) List<Rating> all = ratingMapper.selectAll(); Map<Long, Map<Long, Double>> matrix = buildMatrix(all); Map<Long, Double> me = matrix.get(userId); if (me == null || me.size() < 3) { // 冷启动:自家评分太少,直接走热门兜底 return hotFallback(topN); } // 2. 算相似度,保留 Top-K 邻居 List<Map.Entry<Long, Double>> sims = new ArrayList<>(); for (Map.Entry<Long, Map<Long, Double>> e : matrix.entrySet()) { if (e.getKey().equals(userId)) continue; double sim = SimilarityUtils.pearson(me, e.getValue(), 3); if (sim > 0) { // 负相似度不要,会把推荐带偏 sims.add(new AbstractMap.SimpleEntry<>(e.getKey(), sim)); } } sims.sort((x, y) -> Double.compare(y.getValue(), x.getValue())); int k = Math.min(NEIGHBOR_SIZE, sims.size()); List<Map.Entry<Long, Double>> neighbors = sims.subList(0, k); // 3. 加权汇总邻居评过、我没评过的书 Map<Long, Double> scores = new HashMap<>(); for (Map.Entry<Long, Double> n : neighbors) { double sim = n.getValue(); Map<Long, Double> his = matrix.get(n.getKey()); for (Map.Entry<Long, Double> item : his.entrySet()) { if (me.containsKey(item.getKey())) continue; // 读过的跳过 scores.merge(item.getKey(), sim * item.getValue(), Double::sum); } } // 4. 取 TopN return scores.entrySet().stream() .sorted((x, y) -> Double.compare(y.getValue(), x.getValue())) .limit(Math.min(CANDIDATE_LIMIT, topN)) .map(e -> new RecommendItem(e.getKey(), e.getValue())) .collect(Collectors.toList()); } }

逐段说一下。第一步拉全量评分是最粗暴的做法,演示够用但上生产必须改,正确姿势是读离线任务算好的邻居表,接口里只做第二步的加权。第二步里sim > 0这个判断很关键,皮尔逊算出来是负值意味着“口味相反”,这种用户评高分的书对你大概率没用,保留下来会让推荐结果变差。第三步的me.containsKey过滤已读,这是最基本的去重,漏掉的话用户会看到自己刚评完的书又被推一遍,体验很差。第四步的CANDIDATE_LIMIT是给内存兜底的,邻居有 30 个、每个评过 50 本,候选集就有 1500 条,排序还行,但如果邻居数设成 300 就危险了。

NEIGHBOR_SIZE取 30 是我的经验值。取 10 的时候,邻居质量高但覆盖度不足,冷门书永远推不出来;取 100 的时候,长尾书进来了,但相似度只有 0.1 的“远亲”也在贡献分数,结果明显杂乱。

4.4 冷启动兜底与多路结果融合

纯协同过滤有个绕不开的问题:新书没人评过,永远不会被推荐;新用户没评过分,永远算不出邻居。所以工程上一定要有两路兜底。

一路是热门兜底,按avg_score * log(rating_count + 1)排序,这个公式比单纯按评分排序好,能压住“三个人打了 5 分”的刷分书。另一路是内容召回,拿用户最近评过分的 3 本书,取出它们的标签,去找标签重合度高的书,用 Jaccard 系数算相似度。

两路结果要融合,融合时最大的问题是分数量纲不一致。协同过滤出来的分数可能是 12.8,内容召回出来的可能是 0.65,直接相加会被前者完全主导。所以要先归一化:

public List<RecommendItem> merge(List<RecommendItem> cf, List<RecommendItem> content, int topN) { Map<Long, Double> scoreMap = new HashMap<>(); // 各自归一化到 0~1 区间,再按权重相加 for (RecommendItem item : normalize(cf)) { scoreMap.merge(item.getBookId(), item.getScore() * 0.7, Double::sum); } for (RecommendItem item : normalize(content)) { scoreMap.merge(item.getBookId(), item.getScore() * 0.3, Double::sum); } return scoreMap.entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .limit(topN) .map(e -> new RecommendItem(e.getKey(), e.getValue())) .collect(Collectors.toList()); } /** min-max 归一化,最大值映射到 1,最小值映射到 0 */ private List<RecommendItem> normalize(List<RecommendItem> list) { if (list == null || list.isEmpty()) return Collections.emptyList(); double max = list.stream().mapToDouble(RecommendItem::getScore).max().orElse(0d); double min = list.stream().mapToDouble(RecommendItem::getScore).min().orElse(0d); double span = max - min; if (span <= 0d) { // 全部分数一样,避免除零 return list.stream().map(i -> new RecommendItem(i.getBookId(), 0.5d)).collect(Collectors.toList()); } return list.stream() .map(i -> new RecommendItem(i.getBookId(), (i.getScore() - min) / span)) .collect(Collectors.toList()); }

span <= 0那个分支不要觉得多余,单路召回只返回一条结果的时候max == min,不处理就是 NaN 然后排序全乱。权重 0.7 / 0.3 是起步值,实际调的时候可以拿一批人工标注的样本做小流量验证,看两路各自的点击率再定。

5. 常见问题排查:五个让图书推荐跑不出效果的坑

5.1 推荐结果全是热门书,个性化像没生效

现象:不同用户调同一个接口,返回的前十条一模一样,全是评分人数最多的那几本。

原因有两个方向。一是协同过滤那一步算出来的邻居为空,代码走了hotFallback,但日志没打,看起来像正常返回。二是相似度算出来了但普遍很小,加权之后不如兜底分数高。我遇到过最隐蔽的一次是min-common-items配成了 5,演示数据里每个用户只评了三四本书,邻居全被过滤光了。

解决:先在recommend方法里打一行日志,输出me.size()和sims.size()。如果sims.size()是 0,那就是阈值问题;如果邻居有几十个但结果还是热门,去检查融合权重,大概率是内容召回那一路静默失败了,权重全给了兜底。

5.2 相似度全是 0 或者 NaN

现象:pearson返回 0.0 的比例超过九成,或者直接吐出 NaN,排序后结果随机。

原因:NaN 只有一个来源,就是denA或denB为 0 的时候没拦住。这种情况出现在用户所有评分都一样的场景,比如演示数据批量插入时全写了 5 分,减完均值全是 0,方差为 0,除零就出 NaN。返回 0 的比例高则是minCommon设得太大。

解决:在方法里加if (denA == 0d || denB == 0d) return 0d;这一行拦截。同时把演示数据检查一遍,select user_id, count(distinct score) from rating group by user_id having count(distinct score) <= 1;,把这些用户找出来,初始化脚本里给他们补几本不同分数的书。

5.3 推荐接口第一次慢到超时

现象:服务刚启动,第一个请求要等十几秒,后面就正常了。压测时重启一次就复现一次。

原因:离线矩阵没预热,第一次请求触发了全量评分加载和两两相似度计算,这活儿本来就该离线做。另一种可能是 Redis 缓存冷启动,所有用户都穿透到数据库。

解决:在CommandLineRunner里做一次预热,服务启动时把矩阵构建好放进内存或 Redis,接口只读不建。如果数据量大到预热也慢,就彻底改成读user_sim表。缓存那边加个空值缓存,避免不存在的用户 ID 每次都打库。

5.4 中文书名和标签乱码、搜不到

现象:页面上书名显示成问号,或者按标签召回的时候明明有匹配的书却找不到。

原因:建库时字符集用了utf8而不是utf8mb4,连接串里没带characterEncoding=utf8,或者 MyBatis 的mapperXML 里写了jdbcType=VARCHAR但实际字段是nvarchar。还有一种情况是标签用逗号分隔存储,但代码里用中文逗号做了分隔符去匹配。

解决:库、表、字段、连接串四处字符集统一成 utf8mb4,连接串加上useUnicode=true&characterEncoding=utf8。标签匹配那段代码检查一下split(",")里的逗号是不是半角,这个低级错误我见过不止一次。

5.5 离线任务跑完了线上却没变化

现象:手动触发了相似度刷新任务,日志显示成功,但接口返回的还是昨天的推荐。

原因:新批次写进了user_sim表,但接口读的是 Redis 里的旧快照,两边没同步。或者任务写的是user_sim_tmp临时表,替换逻辑因为主键冲突回滚了,日志里只有一行 warn 被淹掉。

解决:任务里明确三件事的顺序——写临时表、校验行数、原子替换或删缓存,缺一步就会脱节。替换完主动del掉相关用户的缓存 key,别指望过期时间。我在任务结尾固定加一行行数对比日志,tmp和正式表差超过 10% 就告警,能挡住大部分静默失败。

6. 二次开发:换算法、加特征、上 A/B 的改造路线

源码跑通只是起点,真正有价值的是知道往哪改。按改动成本从低到高排,我一般这么走。

第一层是换召回策略,成本最低。在cf/包下照着UserCfRecommender的结构新增一个ItemCfRecommender,输入输出签名保持一致,都是recommend(userId, topN),然后在配置里加一个开关决定注入哪个实现。ItemCF 的好处是相似度矩阵按图书维度算,书比用户稳定得多,一本书的相似书不会因为今天几个新用户评分就剧烈变化,离线更新频率可以降到一周一次。改造的时候注意一点:ItemCF 的相似度矩阵是bookCount × bookCount,十万本书就是一万亿个格子,必须用倒排索引加速,只算共同被评过分的书对。

第二层是加特征。现在的打分只用了评分值,实际上用户的浏览、收藏、加购这些隐式行为信息量更大。可以给rating表加一个behavior_type字段,浏览记 1 分、收藏记 3 分、购买记 5 分,和显式评分加权合并成最终偏好分。这一步改完要重新调min-common-items,因为行为数据比评分数密集,阈值可以适当提高。

第三层是排序阶段。召回出来的 200 条候选不应该直接截断,中间加一个排序模型效果会明显不同。最轻量的做法是线性加权:finalScore = 0.5 * cfScore + 0.2 * contentScore + 0.2 * hotScore + 0.1 * freshness,freshness按上架天数衰减,给新书一点曝光机会。再重一点可以上 LR 或者 GBDT,但那就要引入特征工程和样本标注,工作量翻几倍,建议先看线性加权能不能满足。

第四层是 A/B。接口上加一个abGroup参数或者按 userId 哈希分桶,bucket = userId % 100,0 到 49 走老策略,50 到 99 走新策略,推荐结果里带上strategy字段方便埋点。评估指标别只看点击率,图书场景还要看“推荐后加购率”和“推荐结果里长尾书的占比”,只看点击率会让推荐越来越向热门集中。

最后提一个改动时容易忽略的点:所有改了算法的地方,都要留一个能回滚的开关。算法效果这东西有点玄学,离线指标涨了线上不一定涨,我吃过一次亏,把协同过滤的邻居数从 30 调到 80,离线召回率涨了 6 个点,上线后推荐位的点击率反而掉了。从那以后我每次动算法参数,都强制走一遍“配置开关 + 灰度 10% + 观察 48 小时”的流程,再急的需求也不省这一步。希望帮到你。

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

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

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

立即咨询