协同过滤算法实战:个性化美食推荐小程序从数据建模到部署

发布时间:2026/10/1 12:25:34
协同过滤算法实战:个性化美食推荐小程序从数据建模到部署 1. 这个项目缘起从选择困难症到个性化美食推荐系统1.1 点开外卖列表的那一刻我就知道我需要它我特别讨厌选餐厅。真的这个毛病跟了我很多年——每次朋友聚餐打开点评软件满屏的排行榜和广告位让我头皮发麻外卖平台上筛选条件十几个但选来选去最后还是点开了常吃的那家。有一回我们五个人站在商场四楼手机翻了三遍七家店的等位号都拿了一遍谁都没法说服谁最后饿着肚子进了一家味道很一般的面馆。那天晚上回去之后我想了很久问题出在哪出在推荐链路完全不对——所有平台都在推商家投放的广告、推销量最高的大众款而不是推我真正会喜欢的那一口。市面上的美食 App 很多但真正围绕用户兴趣做个性化推荐的太少大多数只是按热度排序。于是我想自己动手做一个一个基于协同过滤算法的美食推荐小程序把那些被大数据遗忘的小店用算法捞出来推给真正会喜欢它们的人。当时我手里能用的技术栈正好是三样Node.js做算法服务和数据采集PHP做业务接口Vue做管理后台。加上微信小程序做 C 端入口四者拼起来刚好覆盖一个完整闭环。这篇博文就是对这个项目的完整复盘——从技术选型、数据建模到算法落地、联调部署以及我在过程中踩过的坑和取舍逻辑。适合正在做推荐系统相关项目的开发者或者准备以个性化推荐 小程序作为毕业设计/创业原型的朋友参考。1.2 为什么是 Node.js PHP Vue 小程序这个四件套先回答一个很多人会问的问题这组合也太杂了吧为什么不干脆用 Spring Boot 全家桶或者 Python 写推荐算法这个选型有非常实际的原因。那段时间我在维护一套老的 PHP 业务系统用户账号、商家资料、订单数据都在里面完全重写成本太高而且 PHP 在快速迭代管理类接口上效率并不低。但 PHP 不太适合做两件事一是长连接/异步抓取二是跑需要常驻内存的算法进程。后者用 Node.js 非常合适——事件驱动、单线程非阻塞 IO写爬虫和计算密集型但可拆分的任务都很顺手。Vue 的角色很单纯管理后台。我需要一个界面去管理菜品库、查看推荐效果、手动置顶某些餐厅Vue 在这类中后台场景积累深厚生态成熟没什么可犹豫的。小程序则没有替代选项——微信生态下用户从看到到使用的最小路径就是小程序不需要下载安装转发到群聊也方便。所以这套四件套看起来混搭其实是老系统 新能力组合下成本最低、各端生产力最高的方案。技术选型不是非 A 即 B而是要让每个工具去干它最擅长的事。我的原则是原有系统能复用的绝对不推倒重来新引入的技术必须要解决一个旧技术解决不了的问题。2. 系统架构与数据流推荐引擎和业务层各自安放的位置2.1 各端职责与整体数据流转很多新手做推荐系统容易陷入一个误区把所有逻辑写在一个项目里推荐算法、用户接口、管理后台全揉在一起。我一开始也这么干过结果是每次改算法都要重新部署业务服务而且算法一旦跑得慢用户接口跟着卡顿。这次我干脆彻底拆分四条链路互不干扰小程序端用户交互入口负责展示推荐结果、处理收藏/下单/评分行为把行为数据回传给 PHP 接口。PHP 业务层统一对外提供 REST API处理用户注册登录、菜品数据 CRUD、行为记录写入、推荐结果查询。它是整个系统的门面所有端都只跟它对话。Node.js 推荐引擎独立服务干两类脏活累活——定时抓取/清洗美食数据源以及跑协同过滤算法生成推荐结果。结果写入 Redis 缓存不直接暴露给用户请求。Vue 管理后台运营和管理视角给管理员一个界面去维护菜品、查看推荐效果、手动调整推荐位。数据流转大概是这样的小程序里用户产生一个收藏动作请求 PHP 接口PHP 将行为写入 MySQL 的行为记录表Node.js 侧有一个定时任务每天凌晨读取当天的增量行为数据重新计算相似度矩阵和推荐列表把结果写进 Redis用户刷新小程序首页请求 PHP 的今日推荐接口PHP 先查 Redis命中直接返回未命中则降级为热门榜兜底。简单画个链路就是小程序行为上报 - PHP API - MySQL 行为表Node.js 定时任务 - 读取 MySQL - 协同过滤计算 - Redis 推荐结果小程序请求推荐 - PHP API - Redis 命中返回 - 无缓存则热门榜降级每条链路的职责边界非常清楚谁挂了都不至于让整个系统瘫痪。PHP 服务挂了Node.js 算法照跑Node.js 挂了用户还能拿到热门榜。2.2 数据模型设计用户、餐厅、菜品、行为记录推荐系统的地基是数据表设计。我建的表不多核心就五张表名核心字段作用usersid, nickname, openid, flavor_tags用户基本信息flavor_tags 存口味标签 JSON 数组dishesid, restaurant_id, name, price, cuisine, spicy_level, tags菜品基本信息tags 存食材/做法标签restaurantsid, name, address, avg_price, rating, geo餐厅信息方便按距离和价格筛选user_behaviorid, user_id, dish_id, behavior_type, score, created_at用户行为流水行为类型有点击/收藏/下单/分享等recommend_cacheid, user_id, dish_list, expire_time推荐结果缓存dish_list 存按推荐分排序的菜品 ID 数组user_behavior是推荐算法的原材料我要特别说说这张表。大多数小程序用户不会真的给菜品打分我能拿到的都是隐式反馈——他看了什么、收藏了什么、下单了什么。所以在设计表时我就留了一个score字段用来保存行为折算后的评分值折算规则后面算法部分再细说。另外created_at必须建索引因为 Node.js 定时任务每次都是查增量数据没有索引会全表扫描数据量一大就要出事。2.3 为什么把推荐引擎独立成 Node.js 服务我在项目文档里管 Node.js 这个服务叫recommend-server。它不跟 PHP 抢进程完全独立部署。核心原因有三个。第一算法计算会阻塞进程。虽然协同过滤不像深度学习那样吃 GPU但当用户量到几万、菜品到几千时用户相似度矩阵的计算量是 O(N²) 级别的内存开销也随之暴涨。放在 PHP-FPM 里跑根本不现实一个请求就能把进程卡死十个。第二生命周期不对齐。PHP 传统部署模式下每个请求结束后进程就释放了但算法服务需要长时间驻留内存加载评分矩阵、相似度矩阵做缓存。Node.js 可以轻松维持几十个常驻进程。第三语言生态。Node.js 的node-cron、mysql2、ioredis这三个库配合起来写定时任务和缓存读写非常顺手网络爬虫也有axios和cheerio这样的成熟方案。不过独立服务也有代价PHP 要读取 Node.js 算出来的结果两者之间需要约定好数据格式。我的处理方式是 Node.js 只管把结果写 RedisPHP 只管读 Redis两者之间通过 Redis 解耦谁也不依赖谁。3. 协同过滤算法在美食场景的具体落地3.1 用户-菜品评分矩阵怎么拿隐式行为造出可计算的分数协同过滤算法的最基本输入是用户-物品评分矩阵行是用户列是菜品交叉点是分数。但美食小程序里几乎没有用户会主动打分所以我需要一套行为折算规则。我最终定的规则是行为类型折算评分点击进入详情1 分收藏3 分分享给好友4 分下单购买5 分取消收藏-3 分这样就拿到了一个稀疏矩阵。实际情况里矩阵稀疏度超过 95%这是正常的——用户只会跟极少数菜品发生互动。矩阵稀疏不可怕可怕的是你不做任何平滑处理直接算相似度结果会被少数热门菜品带偏。所以我在建矩阵时还做了两件事一是把用户对菜品的评分做均值中心化减去该用户所有评分的均值缓解某些用户天生评分偏高或偏低的影响二是给行为加上时间衰减因子三个月前的收藏打 6 折一个月前的打 8 折让近期行为权重更大。我用一个还算经典的公式来表达时间衰减final_score score * exp(-0.03 * days_since_behavior)。这个衰减系数不是拍脑袋定的我试过 0.01、0.03、0.05 三档0.03 在离线评估下的推荐命中率最高。3.2 相似度计算核心代码与公式选择矩阵造好之后接下来就是计算用户之间的相似度。协同过滤有两套主流路线基于用户UserCF找跟你口味相似的人推荐他们吃过而你没吃过的菜和基于物品ItemCF跟你收藏过的菜相似的菜。我在项目里两种都用到了先给用户相似度计算的核心代码// recommend-server/src/similarity.js const mysql require(mysql2/promise); async function loadUserItemMatrix() { const conn await mysql.createConnection({ host: process.env.MYSQL_HOST, user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, }); const [rows] await conn.query( SELECT user_id, dish_id, score FROM user_behavior_point WHERE created_at DATE_SUB(NOW(), INTERVAL 90 DAY) ); const matrix new Map(); for (const row of rows) { if (!matrix.has(row.user_id)) matrix.set(row.user_id, new Map()); matrix.get(row.user_id).set(row.dish_id, row.score); } await conn.end(); return matrix; } function cosineSimilarity(vecA, vecB) { let dot 0, normA 0, normB 0; for (const [dishId, scoreA] of vecA) { if (vecB.has(dishId)) dot scoreA * vecB.get(dishId); normA scoreA * scoreA; } for (const scoreB of vecB.values()) normB scoreB * scoreB; if (normA 0 || normB 0) return 0; return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } async function computeUserSimilarity() { const matrix await loadUserItemMatrix(); const userIds [...matrix.keys()]; const similarityMap new Map(); for (let i 0; i userIds.length; i) { for (let j i 1; j userIds.length; j) { const sim cosineSimilarity(matrix.get(userIds[i]), matrix.get(userIds[j])); if (sim 0.4) continue; // 相似度阈值太低就忽略减少计算量 const key ${userIds[i]}|${userIds[j]}; similarityMap.set(key, sim); } } return similarityMap; }cosineSimilarity里的余弦公式用生活话翻译就是两个用户对同一批菜品的态度方向越一致夹角越小余弦值越接近 1。所谓态度方向就是他爱吃的你也爱吃他不爱的你也不碰。这个指标对数值幅度的敏感度低于欧氏距离非常适合评分稀疏、还带有正负分取消收藏的数据。皮尔逊相关系数比余弦更精细一些因为它会把每个用户的评分均值抠掉能纠正有的人习惯性给高分、有的人手紧低分的问题。我在项目里实测美食场景中绝大多数用户只跟十来个菜品发生过互动均值差异其实不大所以最终还是选了余弦加均值中心化的组合——实现简单效果不比皮尔逊差。3.3 基于用户、基于物品、混合推荐怎么选纯 UserCF 有个问题随着用户量增长用户间相似度矩阵的规模是平方级膨胀的。1 万用户就是 1 亿对待算虽然定时任务可以半夜跑但响应不了用户新行为的即时变化。而美食场景有一个非常好的特性菜品总量有限且相对稳定一家城市里的活跃菜品也就几千个算菜品之间的相似度矩阵成本低得多而且可以预计算、频繁更新都行。所以我的最终方案是离线任务用 ItemCF 为主每天夜里全量算一次菜品相似度矩阵对每个用户从他最近收藏/下单的菜品出发找相似菜品生成推荐列表。这个部分大概占推荐结果的 60%。UserCF 做补充单独计算相似用户集合把跟你口味相似的人最近爱吃但你还没尝过的菜品也混进来占 30%。剩余 10% 留给热门兜底和运营置顶防止推荐列表全是冷门菜品用户打开后一个认识的都没有。混合公式是final_score 0.6 * item_score 0.3 * user_score 0.1 * hot_score。这里的权重是我用一个月真实行为日志反复调出来的不一定适合所有场景但给了你一个合理起点。在代码里生成最终推荐列表时我对每个用户取混合分 Top 20 的菜品写入 Redis小程序端实际展示时再按运营规则和距离过滤。4. 冷启动这个绕不过去的坎4.1 新用户冷启动标签问卷与热门兜底做推荐系统的都知道一句话系统给新用户推荐的第一个列表直接决定他是留下来还是卸载。如果新用户打开小程序看到的内容和打开普通点评软件没区别他凭什么继续用我试过最简单的热门榜兜底结果留存率很一般——热门菜每个人都知道没有任何懂我的感觉。后来我加了一个口味偏好初始化的步骤用户授权登录后第一次进入不做强制引导但在首页顶部放一条可滑动的标签栏让用户随手选 3 到 5 个口味标签比如重辣粤菜日料素食人均 100 以下。用户每选一个标签我就在他的虚拟评分矩阵里给对应菜品打一个基础分 2.5 分。菜品标签匹配度越高分数越高最多不超过 3.5 分。这样哪怕他一条真实行为都没有推荐系统也能基于标签生成一个还过得去的初始列表。这套方案其实是在懂用户和不打扰用户之间找平衡。我不强制他必须先选标签才能用但如果他愿意选后续推荐质量能明显上一个台阶。实测下来做完标签初始化的新用户次日留存比纯热门兜底高了大约 12 个百分点。4.2 新菜品冷启动内容特征向量的折中方案新菜品的问题正好反过来它没有任何用户行为数据协同过滤无从下手。我用的方案是基于内容特征的相似度匹配。每道菜入库时后台运营人员会填一组标签比如川菜麻辣兔肉干锅做法。那我就可以用杰卡德相似系数算新菜和现有菜之间的相似度J(A,B) |A ∩ B| / |A ∪ B|即两个菜品标签集合的交集大小除以并集大小。两道菜共享 4 个标签、总共有 8 个独特标签相似度就是 0.5。算完新菜与所有老菜的相似度后取 Top 10 的老菜把新菜伪装成这些老菜的相似品跟着它们一起出现在用户的推荐列表里。一旦有真实用户对新菜产生了点击或收藏行为它就逐渐脱离内容相似度逻辑切换到正常的行为驱动协同过滤。这套内容预热、行为接管的打法在美食领域特别好用因为菜品的标签体系非常成熟不像新闻文章那样难以提取主题。5. 四个端协作从数据采集到接口联调5.1 Node.js 定时任务清洗数据与预计算推荐结果Node.js 服务里有三个定时任务我用node-cron管理全部挂在同一个入口文件下// recommend-server/src/index.js const cron require(node-cron); const { syncDishesFromSource } require(./jobs/syncDishes); const { recomputeItemSimilarity } require(./jobs/itemSimilarity); const { recomputeUserRecommendation } require(./jobs/userRecommendation); cron.schedule(0 2 * * *, syncDishesFromSource, { timezone: Asia/Shanghai }); cron.schedule(30 2 * * *, recomputeItemSimilarity, { timezone: Asia/Shanghai }); cron.schedule(0 3 * * *, recomputeUserRecommendation, { timezone: Asia/Shanghai });第一个任务syncDishesFromSource是从公开美食数据源增量同步菜品用axios抓取后用cheerio解析 HTML清洗掉无图、无营业状态的脏数据再入库。第二个任务算菜品相似度矩阵。第三个任务基于最新相似度矩阵给每个用户生成 Top 20 推荐列表写入 Redis。这里有个非常容易踩的坑定时任务必须做并发保护。我第一次上线后的第三天凌晨任务重叠执行Node.js 进程内存直接飙到 2GB推荐列表还出现了一半是空数据的诡异情况。原因是某次菜品同步很慢把相似度计算任务挤到了同一时刻两个任务同时读写了同一批 Redis key。后来我在任务入口加了一个分布式锁const redis require(ioredis); async function withJobLock(jobName, fn) { const lockKey job:lock:${jobName}; const ok await redis.set(lockKey, 1, EX, 3600, NX); if (!ok) return; // 另一个进程已经在跑跳过 try { await fn(); } finally { await redis.del(lockKey); } }这跟数据库乐观锁一个道理谁先抢到锁谁执行抢不到的默默退出等下一个周期。5.2 PHP 业务接口鉴权、缓存与结果返回PHP 侧我确保只做一件事快速稳定地把数据返回给小程序。以首页推荐接口为例// /api/v1/recommend/feed.php public function feed(Request $request) { // 1. 简单的 JWT 鉴权 $userId $this-auth-parseToken($request-header(Authorization)); // 2. 先读 Redis 缓存命中直接返回 $cached $this-redis-get(rec:user:$userId:feed); if ($cached ! false) { return $this-json([code 0, data json_decode($cached, true)]); } // 3. 未命中则取热门榜兜底同时触发异步重建缓存 $hotDishes Dish::where(status, 1) -orderByDesc(popularity) -limit(20) -get(); return $this-json([ code 0, data $this-decorate($hotDishes), source hot-fallback, ]); }这段代码的关键在第三步。Redis 里没有推荐结果时我不选择现场等 Node.js 重新算——那可能要等好几秒甚至更久。正确做法是先返回热门榜用户不会看到空白页面同时 PHP 通过消息队列给 Node.js 发一个用户 X 需要重建推荐的信号异步补充缓存。等用户下次刷新时个性化的推荐列表就准备好了。PHP 还统一处理了跨域问题在响应里带上Access-Control-Allow-Origin: *方便 Vue 后台调试时跨域访问。5.3 Vue 管理后台运营干预与效果观测Vue 管理后台是这个项目里最不性感但最不能缺的部分。它主要干三件事菜品/餐厅管理对同步进来的菜品做人工审核确认图片和描述没问题才置为上线状态相当于一道质量门禁。运营位管理管理员可以直接把某家餐厅的某个菜拖到本周主推位置这类运营干预的菜在推荐结果里有最高优先级。推荐效果观测从 MySQL 里聚合统计每个菜品的曝光量、点击率、下单转化率以表格展示让运营能看到算法的效果。后台界面上我用 Vue Router 做了三个页面菜品列表用了虚拟滚动来处理几千行的数据表单部分用了动态表单校验。说实话这部分开发速度很快Vue 的响应式系统和 Element Plus 组件库配合起来跟写 PHP 的业务接口一样顺滑。5.4 小程序端分页加载、请求封装与体验优化小程序端最核心的就是请求封装。我一开始直接在每个页面里调wx.request代码散得到处都是后来统一封装了一个request.js// miniprogram/utils/request.js const BASE_URL https://api.example.com/api/v1; function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: ${BASE_URL}${path}, method, data, header: { Authorization: wx.getStorageSync(token) || , }, success(res) { if (res.data.code 0) { resolve(res.data.data); } else if (res.statusCode 401) { // token 失效跳登录页 wx.navigateTo({ url: /pages/login/login }); } else { reject(res.data); } }, fail: reject, }); }); } module.exports { request };首页推荐流用的是scroll-view加onReachBottom触底加载更多。每次加载下一页时把上次返回的最后一条记录的 ID 传给后端避免重复。这里有个小细节推荐列表的图片我使用了小程序的image懒加载模式并结合 CDN 进行裁剪首屏打开速度能快不少。6. 部署、性能优化和真正上线前的检查6.1 预计算产物与 Redis 缓存策略推荐系统的黄金法则是能离线算的绝不在线算。我在这个项目里所有的相似度矩阵、用户推荐 TopN 都是预计算产物线上只做读取。Redis 里主要存了四类数据Key 前缀数据类型内容失效时间rec:user:{id}:feedString用户推荐列表 JSON24 小时item:sim:{dishId}ZSet与该菜最相似的菜品及分数12 小时hot:dishesZSet全局热门菜品6 小时job:lock:*String定时任务分布式锁1 小时失效时间都设得比较短因为餐饮是有时效性的夏天推火锅、冬天推冰饮都不合适。用户最近一次行为会触发 PHP 侧删除rec:user:{id}:feed缓存这样他下一次刷新时就能拿到新结果而不需要等整个 24 小时周期走完。6.2 接口响应时间优化首版上线后我压过一次接口PHP 推荐接口平均 200ms 左右还算能接受但有几个点明显能优化MySQL 连接池PHP-FPM 每个请求都新建连接开销很大我启用了 Swoole 连接池把数据库连接复用起来。JSON 序列化推荐结果返回时带了菜品详情我在 PHP 里做了一层精简去掉无关字段响应体积从 30KB 降到 8KB。Redis pipelineNode.js 在写推荐列表时用pipeline批量写入减少网络往返。Nginx gzip开启gzip on并且给静态资源设了expires 7d。优化完推荐接口稳定在 100ms 以内加上小程序的缓存策略用户体感上秒开基本达标。6.3 上线前容易被忽略的几件事这个项目我前后折腾了大概两个月真正上架审核的时候才发现有一堆小事没做。给后来者列个清单每一条都是我用时间换回来的教训小程序必须配置合法域名开发模式下可以关掉校验上线后所有请求域名必须备案且在公众平台配置白名单不然接口全部报url not in domain list。用户隐私协议现在微信审核会检查隐私协议弹窗涉及用户 openid、位置信息的采集必须明确告知。HTTPS 全覆盖不仅是接口域名图片 CDN 也要上 HTTPS否则小程序在 iOS 上会阻止加载。埋点要提前做别等上线后才补。我在用户行为表里加了utm_source字段标记推荐位来源这样能统计从推荐流带来的点击和下单占比后续调算法权重看得清清楚楚。7. 实际做完后的心得与踩坑结论项目跑起来到现在大约三个月用户量不大但给了我一个很扎实的样本。算法部分最有价值的不是公式本身而是对业务数据的理解——隐式行为如何折算、时间衰减系数怎么调、混合权重怎么试这些离开真实场景都是空谈。踩坑方面我印象最深的是两个问题。一个是 Windows 下跑 Node.js 时遇到的脚本执行权限错误提示无法加载文件 nodejs\npm.ps1因为在此系统上禁止运行脚本管理员模式下执行Set-ExecutionPolicy RemoteSigned解决。另一个是 Vue 开发环境下跨域直接配了devServer.proxy把所有/api请求转发到 PHP 服务省掉了开发环境跨域的糟心事。最后再分享一个我觉得很值得推广的小技巧在管理后台里给每条推荐加一个为什么推荐按钮点击能看到因为你常点川菜、且你收藏了 XX哪怕只是展示简单的规则解释用户的信任感和点击意愿都会明显提升。推荐系统说白了不是玄学它要做的就是让用户觉得这个 App 真的懂他的胃。