ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Java宠物用品推荐系统实战:协同过滤与内容推荐融合

Java宠物用品推荐系统实战:协同过滤与内容推荐融合 简介面向具备Java与Spring Boot基础的中初级研发人员这份基于Java的宠物用品智能推荐系统项目实例完整呈现了从用户数据采集与画像构建、商品标签体系搭建到行为日志分析与兴趣挖掘再到协同过滤、内容推荐与混合推荐算法落地的全过程并覆盖基于Spring Boot、MySQL、Redis及Vue/React的高并发、高可用服务架构。资源打包为1个docx文档整体仅77KB目录按项目背景、目标意义、挑战与解决方案、模型架构、算法流程、数据库设计、前后端功能模块、代码实现与部署方案等章节展开同时附有数据库脚本、API接口规范、GUI界面设计说明可对照阅读逐步复现也方便按需检索知识模块。目前已有61人学习下载适合电商平台开发者、推荐系统入门者及计算机相关专业学生用于毕业设计或项目实训也可为宠物电商、线下门店、健康管理等场景提供落地参考。借助其中的代码片段与部署思路可快速搭建本地调试环境重点掌握用户兴趣建模、召回排序、结果过滤与高并发推荐服务的设计要点理解推荐系统从离线训练到在线服务的完整链路。文档还讨论了数据获取清洗、算法实时性、推荐可解释性及用户隐私保护等工程化问题帮助读者从整体架构、核心算法到前后端交互建立完整认知避免只停留在理论层面整体内容组织清晰、实践指向强。1. 宠物用品推荐系统为什么单靠协同过滤或内容推荐都不够宠物用品这个品类在电商里很特殊猫粮狗粮有明确的年龄段、体型适配、功能属性用户复购周期又短但你让用户直接搜“猫粮”返回的是几百个同质化结果。标题里这套基于 Java 的宠物用品智能推荐系统核心不是堆一个推荐算法而是把协同过滤与内容推荐算法同时落地用 MySQL 存评分与行为数据再用 Java 完成相似度计算、融合打分和 GUI 展示。整个方案适合三类人正在做 Java 课设或毕业设计的学生、想给内部商城快速搭推荐 MVP 的开发者、面试时需要一个完整业务闭环来讲解的工程师。我按自己做这类项目的习惯把整条链路拆成五个部分数据模型、协同过滤模块、内容推荐模块、Swing GUI 展示、以及最容易被忽略的评估与避坑。你会看到一个推荐系统在真实落地时百分之六十的工作量不在算法而在数据表怎么拆、评分数据稀疏时怎么办、以及 GUI 层怎么把推荐结果合理呈现。2. 数据模型设计先把 MySQL 的五张表拆明白推荐系统的地基是数据表。很多人在这个标题下翻车不是因为算法写不出来而是因为评分表、物品表、用户表之间的关系没设计好后面代码越写越别扭。我一般会先建五张表用户表、宠物信息表、物品表、标签表、评分行为表。其中评分表是整个推荐系统的中枢协同过滤和内容推荐都从它取数据。2.1 用户、宠物、物品三张主表用户表不需要太多字段只要 user_id、username、password、created_at因为推荐系统面向的是匿名偏好不需要详细档案。宠物信息表反而是这个项目的特色——宠物用品和普通电商品类不同一只猫和一只狗需要的用品差异很大宠物类型、年龄、体重区间直接决定推荐效果。物品表则要存基础信息和冗余字段比如 category 归类用物品还是药品、适用宠物类型这样后面做内容推荐时不需要反复联表。我建表时会遵循一个原则能存冗余就存冗余查询时不轻易 join 第三张表。比如物品表里直接放pet_type和suitable_age,不要通过关联表去查否则在 GUI 界面点一次“刷新推荐”可能要触发几十条 SQL。下面是标准建表脚本直接跑在 MySQL 8 上。CREATE TABLE pet_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE pet_profile ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, pet_type ENUM(cat,dog,other) NOT NULL, pet_age_month INT DEFAULT 12, pet_weight_kg DECIMAL(5,2) DEFAULT 5.00, INDEX idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE pet_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_name VARCHAR(128) NOT NULL, category VARCHAR(32) NOT NULL, pet_type VARCHAR(16) NOT NULL, suitable_age VARCHAR(32) DEFAULT all, price DECIMAL(8,2) DEFAULT 0.00, image_path VARCHAR(255) DEFAULT ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表脚本的细节在于pet_profile用ENUM限制宠物类型避免脏数据suitable_age使用字符串而不是数值因为它要表达“幼猫”“成猫”“全年龄段”这种区间描述不适合用数值连续区间。image_path字段是给 GUI 展示留的口子推荐结果面板上可以直接渲染图片路径这一步虽然小但很影响界面完整度。日常开发中我建议用 MyBatis-Plus 根据 Java 实体类自动生成建表 SQL但生产库表结构以本文这份手工脚本为准手工脚本为准。原因很简单自动生成工具生成的表缺少合理的索引和枚举约束。如果你坚持用 MyBatis-Plus 的ddl-auto也务必在生成后手动补上INDEX。2.2 评分表与行为日志表推荐系统的数据命脉评分表是协同过滤的输入。字段上除了user_id和item_id还要加rating1-5分)、rating_time时间戳。有一条值得注意的经验不要把浏览行为也写进这张表。浏览和收藏的语义权重完全不同混在一张表里会让评分失真。CREATE TABLE pet_rating ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, item_id BIGINT NOT NULL, rating TINYINT NOT NULL DEFAULT 3, rating_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_item (user_id, item_id), INDEX idx_item_id (item_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个唯一键uk_user_item是关键它防止同一个用户对同一件商品重复打分。实际项目里用户重复点击“喜欢”按钮很常见没有唯一键约束评分数据里会出现同一条记录不同分值协同过滤的相似度矩阵直接乱掉。我还建议加一张行为日志表记录浏览、收藏、加购行为。这张表不入算法但用户冷启动阶段只能靠它兜底。冷启动问题后面专门讲这里先说设计原则评分表是算法主输入日志表是冷启动补偿输入。3. 协同过滤模块基于物品的相似度计算与 TopN 推荐协同过滤是这套系统的主算法。选型时很多初学者会纠结 UserCF 和 ItemCF我直接给结论宠物用品项目用 ItemCF 更合适。理由在于电商场景下物品数量相对稳定用户数量和兴趣变化快用物品相似度矩阵可以离线计算、定期更新线上推荐时延迟更低。3.1 为什么选 ItemCF 而不是 UserCF假设系统有 1000 个用户和 300 件商品UserCF 需要实时计算用户之间的相似度当用户量增长到万级时复杂度陡增而 ItemCF 线下计算 300×300 的物品相似度矩阵耗时基本可以忽略。更重要的是宠物用品的用户兴趣迁移快——用户可能这个月买幼猫粮下个月换成成猫粮UserCF 会把“曾经相似用户”的老兴趣带进来推荐结果滞后。ItemCF 的核心逻辑只有两步先算物品间的余弦相似度再根据用户历史评分加权求和取 TopN。3.2 Java 实现物品相似度矩阵我习惯把相似度计算单独封装成ItemSimilarityCalculator输入是评分记录列表输出是MapInteger, MapInteger, Double。整个过程用纯 JDK 完成不引第三方数学库方便后续扩展。public class ItemSimilarityCalculator { /** * 计算物品间的余弦相似度 * param ratings 评分记录列表元素为 int[]{userId, itemId, rating} * return 物品相似度矩阵 itemId - (itemId - similarity) */ public MapInteger, MapInteger, Double calcSimilarity(Listint[] ratings) { // 统计每个物品被哪些用户评过分 MapInteger, SetInteger itemUsers new HashMap(); // 统计每个用户对某物品的评分 MapString, Double userItemRating new HashMap(); for (int[] r : ratings) { int userId r[0], itemId r[1]; double score r[2]; itemUsers.computeIfAbsent(itemId, k - new HashSet()).add(userId); userItemRating.put(userId : itemId, score); } ListInteger items new ArrayList(itemUsers.keySet()); MapInteger, MapInteger, Double similarityMatrix new HashMap(); for (int i 0; i items.size(); i) { int itemA items.get(i); for (int j i 1; j items.size(); j) { int itemB items.get(j); double sim cosine(itemA, itemB, itemUsers, userItemRating); if (sim 0.01) { similarityMatrix.computeIfAbsent(itemA, k - new HashMap()).put(itemB, sim); similarityMatrix.computeIfAbsent(itemB, k - new HashMap()).put(itemA, sim); } } } return similarityMatrix; } private double cosine(int itemA, int itemB, MapInteger, SetInteger itemUsers, MapString, Double userItemRating) { SetInteger commonUsers new HashSet(itemUsers.get(itemA)); commonUsers.retainAll(itemUsers.get(itemB)); if (commonUsers.size() 2) { return 0.0; // 共同评分人数太少相似度无统计意义 } double dot 0, normA 0, normB 0; for (int userId : commonUsers) { double rA userItemRating.get(userId : itemA); double rB userItemRating.get(userId : itemB); dot rA * rB; normA rA * rA; normB rB * rB; } if (normA 0 || normB 0) return 0.0; return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } }这段代码有四个设计点。第一共同评分人数少于 2 直接返回 0避免“只被同一个用户评分”就判定两件商品高度相似。第二使用对称矩阵存储A 对 B 和 B 对 A 的相似度一起写入后续查询免去判断方向。第三用MapInteger, SetInteger构建倒排索引比双层循环遍历所有评分记录快一个数量级。第四相似度阈值设为 0.01过滤掉无意义的弱关联降低矩阵体积。这里有个性能边界要提醒物品数超过 5000 时双重循环计算量是 2500 万次单机仍可承受但在calcSimilarity实时计算就不合适了。常见做法是每晚离线跑一次把结果序列化到本地缓存或 Redis。3.3 生成 TopN 推荐列表相似度矩阵算完后给定一个用户只需找到他评过分或收藏过的物品去矩阵里取相似物品按加权得分排序取前 N。public ListInteger recommend(int userId, int topN, MapInteger, MapInteger, Double similarityMatrix, Listint[] userRatings) { // 该用户已经交互过的物品推荐时要排除 SetInteger interacted userRatings.stream() .filter(r - r[0] userId) .map(r - r[1]) .collect(Collectors.toSet()); MapInteger, Double scoreMap new HashMap(); for (int[] r : userRatings) { if (r[0] ! userId) continue; int itemId r[1]; double rating r[2]; MapInteger, Double similarItems similarityMatrix.get(itemId); if (similarItems null) continue; for (Map.EntryInteger, Double entry : similarItems.entrySet()) { int candidateId entry.getKey(); if (interacted.contains(candidateId)) continue; // 候选物品得分 用户对当前物品评分 × 两物品相似度 scoreMap.merge(candidateId, rating * entry.getValue(), Double::sum); } } return scoreMap.entrySet().stream() .sorted((a, b) - Double.compare(b.getValue(), a.getValue())) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); }关于排序这里多说一句用Comparator从大到小排是 Java 面试常考的冒泡排序、快排之外的工程正确姿势——直接调Double.compare(b.getValue(), a.getValue())表示降序。权重rating是用户对历史物品的打分值这里直接用原始评分不做过拟合的归一化。协同过滤的推荐列表已经能覆盖“买过猫罐头的人也会买同品牌猫条”这类规律但对于新上架、无人评分的商品ItemCF 是盲区这就是下一章要接内容的推荐的原因。4. 内容推荐模块标签画像打分与双算法融合协同过滤解决的是“相似的人/物”问题内容推荐解决的是“物品本身属性”问题。宠物用品的标签体系非常适合内容推荐——每件商品天然具备品类、适用宠物、适用年龄、功能标签这些信息在物品表里都已经有冗余存储。4.1 物品画像与用户偏好向量内容推荐的输入有两个物品画像和用户偏好画像。物品画像很好构建直接把pet_item表的 category、pet_type、suitable_age 组合成一个标签集合。用户偏画像则从评分历史里来用户给哪些标签的商品打了高分这些标签就是他的偏好词。我用一个非常轻量的实现不引入向量数据库或词向量模型纯靠标签频次统计。原因很现实项目规模有限引入高维向量反而让代码复杂度失控。计算时按评分加权累计标签频次评分越高该物品的标签权重越大。public class ContentProfileBuilder { /** * 构建用户偏好向量 * param itemTags 物品ID - 标签集合 * param ratings 用户评分列表 * return 用户ID - 标签实力 */ public MapInteger, MapString, Double buildUserProfile( MapInteger, SetString itemTags, Listint[] ratings) { MapInteger, MapString, Double userProfiles new HashMap(); for (int[] r : ratings) { int userId r[0], itemId r[1]; double rating r[2]; SetString tags itemTags.get(itemId); if (tags null) continue; MapString, Double profile userProfiles.computeIfAbsent(userId, k - new HashMap()); for (String tag : tags) { // 按评分加权,避免冷门商品被概率淹没 profile.merge(tag, rating, Double::sum); } } // 归一化,防止高频标签主导后召回 for (MapString, Double profile : userProfiles.values()) { double sum profile.values().stream().mapToDouble(Double::doubleValue).sum(); profile.replaceAll((k, v) - sum 0 ? 0 : v / sum); } return userProfiles; } }归一化这一步不能省。如果不归一化“幼猫”这个词出现 50 次权重可能远超实际偏好归一化后每个标签权重表示相对偏好程度和协同过滤的得分相乘时维度就对齐了。构建完用户画像后候选物品得分等于用户对物品标签的偏好总分。4.2 融合得分计算权重怎么调双算法融合是标题里的核心考察点。最简单的做法是线性加权但权重系数不要拍脑袋。我早期在另一个项目里直接把协同过滤设 0.7、内容设 0.3结果冷门新物品永远没有曝光因为协同过滤压根没它的分。后来改成动态策略对每一个候选物品它的最终得分是0.6 × 协同过滤分 0.4 × 内容推荐分其中协同过滤分先做 min-max 归一化。public class HybridRecommender { private final double cfWeight; private final double cbWeight; public HybridRecommender(double cfWeight, double cbWeight) { this.cfWeight cfWeight; this.cbWeight cbWeight; } public ListInteger recommend(int userId, int topN, ListInteger cfCandidates, MapInteger, Double cfScores, MapInteger, Double cbScores) { SetInteger merged new HashSet(cfCandidates); merged.addAll(cbScores.keySet()); // 分别归一化 cf 和 cb 得分到[0,1] MapInteger, Double cfNormalized minMaxNormalize(cfScores); MapInteger, Double cbNormalized minMaxNormalize(cbScores); ListInteger ranked new ArrayList(merged); ranked.sort((a, b) - { double scoreA cfWeight * cfNormalized.getOrDefault(a, 0.0) cbWeight * cbNormalized.getOrDefault(a, 0.0); double scoreB cfWeight * cfNormalized.getOrDefault(b, 0.0) cbWeight * cbNormalized.getOrDefault(b, 0.0); return Double.compare(scoreB, scoreA); }); return ranked.stream().limit(topN).collect(Collectors.toList()); } private MapInteger, Double minMaxNormalize(MapInteger, Double scores) { if (scores.isEmpty()) return Collections.emptyMap(); double min scores.values().stream().mapToDouble(Double::doubleValue).min().orElse(0); double max scores.values().stream().mapToDouble(Double::doubleValue).max().orElse(1); double range max - min; if (range 0) range 1; MapInteger, Double normalized new HashMap(); scores.forEach((k, v) - normalized.put(k, (v - min) / range)); return normalized; } }权重让谁来定如果评分数足够多超过 1 万条协同过滤权重大一些设 0.6 到 0.7如果新用户新商品很多内容推荐权重要提到 0.5 以上否则全是老物品重复推荐。这套参数设计看似简单但是纯经验数据。网上很多帖子说调权重靠玄学其实没那么玄核心是观察推荐列表有多样性——如果连续 10 个结果都是同一个品类说明内容推荐权重太低。5. Swing GUI 设计推荐结果怎么落到界面GUI 是这个项目和普通推荐算法 Demo 最大的区别。标题里明确写了“含完整的 GUI 设计”很多人以为只是摆几个按钮其实推荐系统的 GUI 要解决三个问题用户输入入口、推荐结果展示、以及用户反馈采集反馈采集直接回写评分表。5.1 主界面与结果面板用 JTable 展示 TopN我选用 Java Swing 作为 GUI 框架。不选 JavaFX 的理由很朴素课设和多数内部项目跑在老版本 JDK 上Swing 零依赖、启动快。主界面用JFrame 左侧用户列表 右侧推荐结果表格布局顶部放一个“刷新推荐”按钮。核心代码是结果面板的构建。public class RecommendPanel extends JPanel { private JTable table; private DefaultTableModel model; private HybridRecommender recommender; public RecommendPanel() { model new DefaultTableModel(new String[]{物品ID, 物品名称, 品类, 推荐得分}, 0); table new JTable(model); add(new JScrollPane(table), BorderLayout.CENTER); } public void showRecommendations(int userId, int topN) { // 从数据库加载评分数据 Listint[] ratings RatingDao.loadAllRatings(); ItemSimilarityCalculator simCalc new ItemSimilarityCalculator(); MapInteger, MapInteger, Double simMatrix simCalc.calcSimilarity(ratings); ListInteger cfList new ItemCFBasedRecommender().recommend(userId, topN * 2, simMatrix, ratings); MapInteger, Double cfScores computeCfScores(cfList, simMatrix, ratings, userId); MapInteger, Double cbScores new ContentBasedRecommender().recommendWithScores(userId); HybridRecommender hybrid new HybridRecommender(0.6, 0.4); ListInteger results hybrid.recommend(userId, topN, cfList, cfScores, cbScores); model.setRowCount(0); for (int itemId : results) { PetItem item ItemDao.findById(itemId); model.addRow(new Object[]{item.getId(), item.getItemName(), item.getCategory(), String.format(%.4f, cbScores.getOrDefault(itemId, 0.0))}); } } }这里有一个关键设计每次点击刷新都重新加载评分数据并计算相似度矩阵。功能正确但性能很差。正确做法是把相似度矩阵做成全局单例缓存只在第一次加载或后台定时刷新时更新。RatingDao.loadAllRatings()一次性全量加载只适合数据量在万条以内的系统再大就必须走分页或增量拉取。5.2 三个常见坑冷启动、数据稀疏、刷新卡顿冷启动是推荐系统最大的坑之一。新用户没有任何评分协同过滤和内容推荐都失效。我的兜底方案是当用户评分行为少于 3 条时直接返回热门榜——按浏览次数倒序取 TopN。这不算算法但责任感和商业落地要求系统在“没数据”时也有像样的输出。数据稀疏问题比冷启动更隐蔽。假设一个用户只给 2 件商品评过分且这两件商品又小有人评计算出的相似度极不可靠。常见的缓解方法有两个将共同评分人数作为置信度乘以相似度比如finalSim rawSim * Math.log10(commonUsers 1)或者对相似度设最低门槛比如共同评分人数少于 5 就不进入矩阵。JTable 刷新卡顿是另一个容易翻车的地方。每次刷新推荐后台全量重算再密集调用model.addRow界面直接假死。解决办法是把计算放到 SwingWorker 的后台线程里计算期间按钮释放显示“计算中”。class RefreshWorker extends SwingWorkerVoid, Void { Override protected Void doInBackground() { refreshButton.setEnabled(false); showRecommendations(selectedUserId, 10); return null; } Override protected void done() { refreshButton.setEnabled(true); statusLabel.setText(推荐更新完成); } }SwingWorker 里调showRecommendations时必须确认它内部没有阻塞的主线程调用。另有一条经验GUI 层不要直接拼 SQL所有数据访问走 DAO 层这样后面换成 MyBatis 或者 Spring Data JPA 时不需要动界面代码。6. 避坑与验证推荐系统上线前必须做的检查落地推荐系统算法本身不是最难的难的是上线前验证和排错。这一章我把最容易导致翻车的三条经验写透每一条都是真实项目里踩过的。6.1 避坑一评分数据全是 4 分和 5 分相似度计算失真现象推荐结果几乎等于热门榜用户画像没有区分度。原因构建模拟评分数据时所有用户只打 4 到 5 分负样本缺失导致正反馈不可分。解决设计评分数据时必须有“负反馈”概念。真实用户不会对不喜欢的东西打分但你的测试数据里必须模拟给 1 到 2 分的用户甚至可以直接从浏览行为里提取“看了不买”的行为降权插入评分表。6.2 避坑二MySQL 中文乱码导致 GUI 显示异常现象JTable 里物品名称全是问号“???”。原因数据库连接串没有指定字符集或者建表时用了utf8mb4但 JDBC 连接串用了旧utf8。解决连接串必加?useUnicodetruecharacterEncodingutf8且建表语句统一DEFAULT CHARSETutf8mb4。这个问题的隐蔽性在于 MySQL 8 默认字符集可能是 utf8mb4但 JDBC 驱动版本过旧时会覆盖。6.3 避坑三重建上次推荐结果时没有考虑“已下架商品”现象推荐列表频繁出现已删除商品用户点击报错。原因推荐算法计算时只筛选评分表的数据没有 join 物品表检查状态。解决每次推荐前必须过滤掉item_status0的物品。而且这个过滤要前置到相似度矩阵构建阶段否则下架商品留在矩阵里会拖动其他商品的相似度。6.4 离线评估准确率、召回率与 F1推荐效果不能只看列表顺不顺眼。我习惯把评分数据按 82 划分训练集和测试集。训练集上构建相似度矩阵测试集里隐藏用户 20% 的评分行为让系统预测被隐藏的物品。public class Evaluation { public static void main(String[] args) { Listint[] hiddenRatings loadTestData(); MapString, Double metrics evaluate(hiddenRatings); System.out.println(Precision: metrics.get(precision)); System.out.println(Recall: metrics.get(recall)); System.out.println(F1: metrics.get(f1)); } private static MapString, Double evaluate(Listint[] testRatings) { // hit 记录 Top10 推荐里有多少个测试物品被命中 int hit 0, total 0; for (int[] r : testRatings) { ListInteger recList getTopN(r[0], 10); if (recList.contains(r[1])) hit; total; } double precision (double) hit / (testRatings.size() * 10); double recall (double) hit / testRatings.size(); double f1 precision recall 0 ? 0 : 2 * precision * recall / (precision recall); MapString, Double result new HashMap(); result.put(precision, precision); result.put(recall, recall); result.put(f1, f1); return result; } }离线评估结果不要只用来写报告。当你调整协同过滤权重时F1 上升不一定代表线上更好因为测试集本身存在采样偏差。我一般还会加一步人工检查把推荐结果里连续出现的同类商品数量打印出来如果 10 个结果里有 7 个是猫粮说明多样性出了问题单纯调高内容推荐权重到 0.5 就能明显改善。6.5 实时刷新与缓存让新评分立刻影响推荐评分数据更新后相似度矩阵不应该立刻全量重算而是增量更新。最简单的做法是设置一个定时任务每 5 分钟从评分表取出最近的新评分只重算受影响的物品相似度。我一般还会给前端接口加一个“静默刷新”阈值用户新增评分累计超过 20 条时触发一次后台矩阵重算。这套系统走到这一步才算真正具备工程意义上的可用性离线评估给出量化结论缓存保证响应速度推荐结果排除了异常商品。我自己现在做这类项目固定流程是先建一个只有 500 条数据的玩具集把全链路跑通后再放大数据量。放大过程中最容易暴露问题的是 SQL 查询性能而不是算法所以数据量超过 10 万条时我会把相似度矩阵序列化成二进制文件启动时直接加载不让数据库背这个锅。希望这些经验能帮你在宠物用品推荐系统这个方向上少走几步弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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