ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

旅游推荐系统实战:SpringBoot+Vue协同过滤算法从原理到落地

旅游推荐系统实战:SpringBoot+Vue协同过滤算法从原理到落地

不做推荐系统的开发可能很难体会一件事:系统上线之后,用户点不点击、买不买单,跟页面做得多好看关系不算大,真正起作用的是你有没有在合适的位子,把用户心里想要的那个东西推到他面前。旅游行业尤其明显,用户面对的目的地选项太多了,同一个“想看海”的需求,有人想去三亚,有人想去青岛,有人预算两千,有人预算两万,靠人工分类和简单标签根本搞不定。

这个项目做的就是这件事——基于SpringBoot和Vue搭了一套前后端分离的旅游推荐系统,核心推荐引擎用AI协同过滤算法,通过分析用户的历史行为数据,把“猜你喜欢”真正落到具体的景点和线路上。整个项目我从数据建模、算法选型、后端接口设计,到前端推荐页面的交互呈现,全程自己折腾了一遍。这篇文章把我踩过的坑、验证过的方案、以及最终能稳定跑起来的实现细节都整理出来,给正在做旅游类推荐系统,或者想搞懂协同过滤怎么从论文变成真实代码的朋友参考。

1. 项目整体设计与技术选型思路

1.1 为什么锁定了SpringBoot+Vue这套组合

选技术栈这件事,很多新手容易犯一个毛病——看到什么新用什么,然后把自己坑进去。这个项目我一开始就锁定了SpringBoot和Vue,理由其实很现实。

后端用SpringBoot,核心优势是生态成熟、上手门槛低,尤其适合这种以业务CRUD加算法调度为主的系统。推荐系统本身不复杂,复杂的是周边一堆事:用户管理、景点管理、行为记录、订单流程、权限控制。SpringBoot加上MyBatis Plus,这些常规活儿基本半天就能搭完,不需要像微服务架构那样引入一堆分布式组件徒增维护成本。我服务器配置也就2核4G,SpringBoot单体应用在这个体量下完全扛得住。

前端用Vue,主要看中它的组件化开发效率和中文社区积累。推荐系统的前端重点不在炫技,而在于把推荐结果、相似度解释、兴趣标签这些信息清晰呈现给用户。Vue 3的Composition API配合Element Plus,卡片列表、评分组件、分页筛选这些UI模块很快就能拼出来。加上Pinia做状态管理,用户在页面上的每一次点击、评分、收藏行为,都能被干净地记录下来,反向喂给推荐算法。

还有一个很重要的现实因素:这套技术栈招人好招。我后来接手维护的同事,基本看一遍代码就能上手,不用花时间啃冷门框架。

1.2 协同过滤算法在旅游场景里的适配性分析

确定了技术栈之后,最核心的问题来了:推荐算法用什么。

现在市面上推荐算法五花八门,深度学习模型、图神经网络都在讲。但回到旅游这个垂直场景,我仔细对比之后,最终选了协同过滤,而且是UserCF和ItemCF两个都实现了,按场景切换。原因有这么几个。

首先,旅游推荐本质上还是"人以群分"的逻辑。用户A喜欢杭州、苏州这类江南水乡,用户B也喜欢,那么A还去过的乌镇,大概率B也会感兴趣。这种偏好关联用协同过滤来建模,数学上干净,效果上直接。

其次,旅游领域的行为数据天然适合构建"用户-景点"评分矩阵。用户会对景点评分、收藏、下单、浏览,这些行为都能转化成可量化的兴趣值。矩阵虽然稀疏,但协同过滤对稀疏矩阵的容忍度,比深度模型要高得多,初期数据量不大的时候反而更可靠。

第三点,也是我觉得最关键的一点:可解释性。协同过滤推荐一个景点,可以说“因为和你有相似兴趣的用户也喜欢这里”,或者“因为你喜欢过的景点和这里相似”。这种推荐理由是能讲给用户听的,能增加用户对推荐结果的信任感。换成深度模型,解释性就差了很多。

当然协同过滤不是银弹,后面我详细说冷启动、数据稀疏这些坑以及怎么填。

1.3 系统整体架构分层

整个系统的架构我分成了四层,每一层职责清晰,这也是后面排障比较顺畅的前提。

接入层就是Vue前端页面,包括首页推荐流、景点详情、个人中心、用户行为埋点。表现层往下走是SpringBoot提供的RESTful API,统一返回结构,前端通过axios调用。

再往下是推荐服务层,这部分是系统的核心。里面拆了三个模块:行为采集模块负责接收前端上报的浏览、评分、收藏等行为数据;离线计算模块定时跑协同过滤任务,计算用户相似度矩阵和景点相似度矩阵,生成推荐候选集;在线推荐模块接收实时请求,从缓存里捞取推荐列表,结合用户当前场景做排序过滤。

最底层是数据层,MySQL存业务数据和用户行为明细,Redis存推荐结果和热点数据,保证推荐接口的响应速度。算法计算产生的中间结果,比如相似度矩阵,也会落到Redis,避免每次实时请求都重新计算。

这套架构的好处是,离线把重的计算做完,线上只做轻量查询,接口实测下来平均响应时间在200毫秒以内。对于一个小型推荐系统来说,这个性能已经够用。

2. 协同过滤算法原理与工程落地

2.1 UserCF和ItemCF,到底怎么选

协同过滤两大流派,UserCF(基于用户的协同过滤)和ItemCF(基于物品的协同过滤),原理一句话都能说清,但选型上我纠结了很久。

UserCF的思想是:找到和目标用户兴趣最相似的一群用户,把这些人喜欢的、但目标用户还没见过的景点推荐出去。适合用户量相对不大、但用户兴趣相对稳定的场景,社交属性强,能推出来一些"惊喜"内容。

ItemCF的思想是:找和目标用户历史上喜欢过的景点最相似的景点推荐出去。适合物品量不大、用户行为相对丰富、追求精准度的场景。

旅游这个场景我实际对比下来,行为是这样的:用户在旅游平台上的兴趣通常是短期想要出行,目的地一旦确定,就比较集中地浏览相关景点。这个时候ItemCF的推荐精度更高,因为它是在用户已有明确偏好的基础上做延伸,比如用户刚搜索过杭州,系统推荐乌镇、千岛湖,转化率明显更好。

所以我把ItemCF作为主推荐算法,用在"相似推荐"和"猜你喜欢"栏目。UserCF放在辅助位置,主要用于新用户冷启动阶段,以及做"其他用户还去了这里"这种社交化推荐栏目。

2.2 相似度计算的数学原理与Java实现

相似度计算是协同过滤的基石,这里面的数学原理值得认真梳理一遍。最常用的相似度度量有三种:余弦相似度、皮尔逊相关系数、杰卡德相似系数。

余弦相似度计算方式是两个向量的夹角余弦值,公式是cos(a,b) = a·b / (|a|×|b|)。放在推荐系统里,就是把用户对各个景点的评分看成一个向量,计算用户之间的方向一致性。它的特点是只关注用户在哪些维度上有共同偏好,不受个体打分习惯影响。

皮尔逊相关系数在余弦相似度基础上做了去中心化处理,减去各自均值,能消除用户打分尺度不同带来的偏差。比如A习惯打高分,B习惯打低分,但他们的相对偏好是一致的,皮尔逊就能捕获这种一致。

杰卡德相似系数适合处理二元数据,比如用户有没有收藏过某景点,适合早期数据稀疏阶段。

我在项目里默认用的是皮尔逊相关系数,实现代码大概是这样的:

public double pearsonSimilarity(Map<Long, Double> userARatings, Map<Long, Double> userBRatings) { // 找出两个用户共同评分过的景点 List<Long> commonItems = userARatings.keySet().stream() .filter(userBRatings::containsKey) .collect(Collectors.toList()); if (commonItems.size() < 2) { return 0.0; } double meanA = commonItems.stream().mapToDouble(userARatings::get).average().orElse(0.0); double meanB = commonItems.stream().mapToDouble(userBRatings::get).average().orElse(0.0); double numerator = 0.0; double denominatorA = 0.0; double denominatorB = 0.0; for (Long itemId : commonItems) { double a = userARatings.get(itemId) - meanA; double b = userBRatings.get(itemId) - meanB; numerator += a * b; denominatorA += a * a; denominatorB += b * b; } if (denominatorA == 0.0 || denominatorB == 0.0) { return 0.0; } return numerator / (Math.sqrt(denominatorA) * Math.sqrt(denominatorB)); }

注意代码里有个细节:共同评分数量少于2就直接返回0。因为样本太少计算出来的相关系数没有统计意义,强行使用反而会引入噪声。这个阈值我在项目里调过,从3到5都试过,2是一个平衡点,既能保留足够的候选用户,又不至于让偶然的共同评分干扰结果。

2.3 评分矩阵的构建与稀疏性问题

协同过滤的输入是"用户-景点"评分矩阵,每一行是用户,每一列是景点,格子里的值是用户对景点的兴趣程度。矩阵怎么构建,直接决定了推荐质量。

旅游场景下用户不会给每个景点评分,直接拿显式评分当矩阵值,矩阵会稀疏得没法看。我的做法是融合多个维度的隐式行为,给不同行为赋予不同权重:

  • 用户主动评分:权重1.0,最可靠的信号
  • 收藏景点:权重0.8,代表明确兴趣
  • 下单预订:权重0.7,代表真实购买意图
  • 浏览详情页:权重0.4,浅层兴趣但量大
  • 搜索关键词命中:权重0.3

最终矩阵值取多个行为权重的加权和,再压到0到5的区间。这样矩阵的覆盖率比单纯用评分高了不少,推荐效果相应也好很多。

稀疏性问题在项目初期尤其严重。系统刚上线,用户行为数据量很小,大部分用户只有一两条行为记录,矩阵行向量几乎全是空的,这时候计算相似度结果非常不稳定。我的处理策略是,矩阵里的值使用归一化分数,同时对用户向量做L2范数归一化,降低行为数量差异带来的偏差。

还有一个工程上的处理是阈值过滤。计算用户相似度之前,先筛掉行为记录少于3条的用户,这些用户参与相似度计算只会拖累结果质量。他们走冷启动推荐策略,不参与协同过滤主链路。

2.4 冷启动问题的务实解法

冷启动是协同过滤的老大难,新用户没有行为数据,新景点没有用户行为,算法直接失效。我项目里用了一套组合策略来解决。

针对新用户,采用"地域优先+热门兜底+注册偏好"三合一策略。用户注册时我让他选择感兴趣的旅行偏好,比如自然风光、历史文化、美食探店这些标签。推荐时先把符合用户兴趣标签的景点捞出来,再按热门程度排序,热门的计算方式不是简单按浏览量,而是结合近期下单转化率和好评率的综合热度分。如果用户连偏好标签都没选,就直接推全局热门榜。

针对新景点,则采用"相似内容匹配"策略。新景点虽然没有用户行为记录,但它有自身的属性——所在城市、景点类别、建议游玩时长、票价区间。我把这些属性向量化,计算它与现有景点集合的属性相似度,找到最相似的几个老景点,然后把新景点推荐给喜欢这些老景点的用户。这个思路本质上是基于内容的推荐,用来做物品冷启动非常有效。

冷启动策略和协同过滤的切换逻辑是这样的:用户行为数据少于5条,走冷启动策略,同时系统积极引导用户产生行为;行为数据积累到一定规模后,自动切换到协同过滤主链路。这个阈值我压到了5条,因为从实测效果看,5条高质量行为记录已经足够让ItemCF产生像样的推荐结果。

3. 数据库设计与后端核心实现

3.1 核心表结构设计

推荐系统的数据库设计,跟普通业务系统的最大区别在于:不仅仅要存业务数据,还要把用户行为记录得很细,因为这是算法的养料。

用户行为表是这个系统的灵魂,设计如下:

CREATE TABLE user_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, scenic_id BIGINT NOT NULL, behavior_type TINYINT NOT NULL COMMENT '1浏览 2收藏 3下单 4评分', rating DECIMAL(2,1) DEFAULT NULL COMMENT '用户评分,行为类型为4时有效', scene VARCHAR(32) DEFAULT NULL COMMENT '行为发生的场景:首页推荐/搜索结果/详情页', create_time DATETIME NOT NULL, KEY idx_user_behavior (user_id, create_time), KEY idx_scenic_behavior (scenic_id) );

行为类型用TINYINT而不是字符串,节省空间同时查询快。scene字段看起来不起眼,实际作用很大,它记录了行为发生在哪个场景,后面做推荐效果评估时,可以区分出推荐位带来的行为和自然搜索带来的行为,方便评估推荐算法的真实贡献。

景点表除了常规的名称、描述、图片、价格这些字段,我还额外加了几个算法要用到的字段:类别、所在省份城市、建议游玩时长、热度分。热度分不是实时算的,而是定时任务每小时汇总一次行为表,按加权公式算好后写回这个字段,供冷启动排序用。

相似度结果表也很关键。离线任务计算完相似度矩阵后,把结果结构化存储,而不是每次都从内存算:

CREATE TABLE similarity_cache ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_a_id BIGINT NOT NULL, item_b_id BIGINT NOT NULL, similarity DOUBLE NOT NULL, calc_time DATETIME NOT NULL, KEY idx_similarity (item_a_id, similarity) );

这张表配合Redis使用,Redis存热点数据,MySQL做持久化兜底。Redis重启后可以从MySQL恢复,双保险。

3.2 推荐链路的完整时序

后端推荐接口的完整时序是这样的。

用户在前端打开推荐页,Vue发出请求到/api/recommend/list。后端先查Redis,key是recommend:itemcf:{userId}:{page},命中直接返回推荐列表,整个过程不到20毫秒。

缓存没命中的话,后端进入在线推荐逻辑。先从MySQL的行为表里加载用户最近90天的行为记录,构造用户评分向量。然后从相似度缓存表里查出与用户历史喜欢景点最相似的Top20景点,作为候选集。候选集生成后,做一轮规则过滤——把用户已经去过的、已下单的、近期刚推过的都过滤掉,避免推荐内容重复。过滤完的候选集,按预测分数从高到低排序,取前10个返回。

预测分数的计算用的是加权公式:

public double predictScore(Long userId, Long scenicId, List<UserSimilarity> similarUsers, int k) { double weightedSum = 0.0; double similaritySum = 0.0; for (UserSimilarity su : similarUsers) { // 取目标用户对景点的评分,没有评分就跳过 Double rating = getUserRating(su.getUserId(), scenicId); if (rating != null) { weightedSum += su.getSimilarity() * rating; similaritySum += Math.abs(su.getSimilarity()); } } if (similaritySum == 0.0) { return 0.0; } return weightedSum / similaritySum; }

这个公式本质上就是加权平均,但有个优化细节:similaritySum用的是相似度的绝对值之和,避免负相似度用户把结果拉偏。实践中负相似度的用户我直接不参与计算了,效果更干净。

离线计算任务我用Spring的@Scheduled注解跑定时任务,每天凌晨两点执行。任务流程是:从行为表捞近30天的数据,构建用户-景点矩阵,计算ItemCF相似度矩阵,计算每个用户TopN推荐结果,写入缓存和数据库。全部跑完大概需要15分钟,数据量在十万级用户和几千个景点时,性能完全不是问题。

3.3 缓存设计与异步化处理

推荐系统的性能瓶颈不在代码,而在缓存设计。我用Redis做了两级缓存策略。

第一级是推荐结果缓存。每个用户的TopN推荐列表序列化成JSON后直接存Redis,key的设计要带上版本号,比如recommend:itemcf:v3:{userId}。这个版本号很重要,因为算法参数调整、数据更新之后,需要让所有缓存整体失效,直接改版本号就能全部过期。

第二级是相似度矩阵缓存。景点之间的相似度计算结果,在在线推荐时需要频繁查询,这块数据量大但读取模式固定,非常适合放Redis。我用ZSet存储每个景点的相似景点Top100,key是similar:item:{scenicId},member是相似景点ID,score是相似度值。在线推荐时可以直接拿到有序的相似景点列表,不用再排序一次。

行为上报这块也做了异步化。用户在页面上产生浏览、收藏、评分等行为,如果每次都用同步请求写数据库,高峰期MySQL会被打爆。我的做法是前端行为埋点先把数据发给后端接口,后端只校验参数基本合法性,直接丢进线程池异步写入行为表。线程池用了Spring的@Async,配合自定义线程池参数,核心线程数10,最大线程数50,队列容量2000。实测单机扛住每秒几百次的埋点请求完全没问题,数据库写入压力也降下来了。

这里有个重要的取舍:行为数据的实时性要求没有那么高,晚几秒写入不影响推荐效果,因为协同过滤本来就是基于一段时间内行为数据的统计。尽早做异步化,比后续性能出问题再改架构要省事太多。

4. 前端Vue推荐页面的实现与优化

4.1 推荐流页面的组件设计与交互

前端这一层,推荐业务的核心是首页推荐流。我用Vue 3实现了三个推荐栏目:猜你喜欢、相似景点推荐、其他用户还去了。

猜你喜欢放在首页最核心位置,用的是ItemCF主推荐链路,展示一张景点卡片流。每一张卡片展示景点主图、名称、城市、价位、评分,以及最关键的一项——推荐理由。推荐理由是后端根据推荐链路生成的文本,比如“根据您对西湖的喜爱推荐给您”,这一个细节对用户信任度提升非常明显。

相似景点推荐放在景点详情页底部,当前景点详情加载完成后,前端调/api/recommend/similar/{scenicId}接口,展示与本景点属性最相似、且用户尚未去过的几个景点。这里的算法不必重新设计,直接把ItemCF算好的相似度缓存拿来用。

其他用户还去了放在行程规划页,走的是UserCF链路,展示与用户兴趣相似的其他用户去过的地方。这个栏目的差异化价值在于拓展性,能帮用户发现不在预设偏好里的新目的地。

卡片交互上做了一个细节:评分控件。用户可以在推荐卡片上直接点星星评分,不用跳转到详情页就能表达兴趣。这一步非常关键,它把打分成本降到了几乎为零,用户评分行为量比评论区打分模式高了3倍还多,直接改善了评分矩阵的密度。

4.2 埋点上报与行为数据闭环

前端埋点这个环节,是决定推荐算法能否持续变好的胜负手。埋点不完整,后期算法效果就无从谈起。

我把埋点分成两类。一类是轻量曝光埋点,推荐卡片进入用户视口超过1秒就上报一次曝光事件,记录卡片在页面中的位置、推荐类型、景点ID。另一类是交互埋点,用户点击卡片、收藏、评分、下单都会上报,每条数据带上当时的会话ID,方便后端追溯推荐效果。

埋点上报的实现,我没有用现成的第三方SDK,而是自己写了一个轻量的上报模块。核心思路是批量合并缩小请求次数:

export function track(event) { const payload = { userId: getUserId(), scenicId: event.scenicId, behaviorType: event.behaviorType, scene: event.scene, pageId: event.pageId, timestamp: Date.now() }; trackQueue.push(payload); if (trackQueue.length >= 10) { flushTrackQueue(); } } function flushTrackQueue() { navigator.sendBeacon('/api/track', JSON.stringify(trackQueue)); trackQueue = []; }

sendBeacon是浏览器原生API,页面卸载时也能可靠发送数据,比普通的XMLHttpRequest更适合埋点场景。批量合并到10条再发一次,极大减少了网络请求数量。

行为数据闭环的逻辑是:埋点上报后,数据进了行为表,定时任务每天汇总生成新的相似度矩阵,晚上更新推荐缓存,第二天用户打开页面看到的就是根据前一天行为优化的新推荐。用户昨天点了哪些景点、收藏了哪些城市,今天推荐结果里就会体现出来。这个闭环转起来之后,你会发现推荐准确率是肉眼可见地在涨。

4.3 推荐结果的视觉呈现与加载体验

推荐界面如果纯靠一个长列表往下滚,用户翻两屏就到头了。我在视觉呈现上做了几个优化,留人效果显著。

第一个是瀑布流布局。景点图片天然适合瀑布流,图片高度不一致,用等高的网格会很难看。我用CSS的columns属性做了三列瀑布流,配合懒加载,用户往下滑时新卡片无限填充,浏览体验流畅很多。懒加载用的vue-lazyload组件,图片进入视口前只加载占位图,真正进入视口才加载原图,首屏加载速度快了一倍。

第二个是推荐理由的视觉化。卡片底部一行小字,用灰色弱化处理,写着推荐理由,比如“相似兴趣用户去过”。这个不是为了凑数,而是给推荐结果一个解释锚点,用户会觉得“系统懂我”而不是“系统乱推”。

第三个是"不感兴趣"功能。每张卡片右上角一个叉号,用户点掉后这条推荐结果被打入"已过滤",不再出现。这个功能一方面改善用户体验,另一方面也为算法提供了负反馈信号——用户主动拒绝的内容,后续离线计算时会降低相关景点在这些用户推荐结果里的权重。

第四个是Skeleton骨架屏。推荐接口偶尔因为冷启动计算慢,响应时间会到1秒以上。我做了骨架屏加载状态,接口返回前页面展示灰色占位块,而不是白屏等待。用户体感上会觉得系统在认真计算,而不是卡死了。

5. 常见问题排查与性能优化实录

5.1 冷启动阶段推荐结果"千篇一律"的排查

项目测试阶段,我遇到的最尴尬的一个问题是:不同用户看到的推荐几乎一样,全是热门景点的排列组合。当时第一反应是算法代码有bug,排查后发现不是。

真正的根因有三个。第一个是冷启动策略里热门兜底权重太高,用户偏好标签的约束力不够,导致热门景点几乎霸榜。第二个是用户行为数据太少,还没积累到足够多的差异化数据,算法推不出来个性化内容。第三个是测试用户群体的行为相似度极高,大家都是同类用户,结果自然趋同。

解决方案分两步。短期做法是调整冷启动排序策略,提高用户偏好标签的权重,让偏好标签完全不同的用户,哪怕行为数据不足,也能看到差异化的推荐。长期做法是优化行为埋点,在冷启动阶段主动引导用户多产生行为——新用户注册后弹一个轻量引导,让用户评价三个看过的景点。这个引导的转化率做到了40%以上,直接让新用户最快速度进入正常推荐链路。

5.2 相似度计算慢导致的接口超时

上线一周后,运营反馈一个真实用户打开推荐页特别慢,接口超时概率非常高。后台日志一查,发现是相似度计算在实时请求链路里被触发了。

问题出在我设计在线推荐时的一个疏漏。用户行为数据积累到一定程度后,有些用户行为记录上几千条,实时构造评分向量加上实时遍历相似景点,计算量陡增。加上第一版实现里没加超时控制,请求量一上来,线程池被占满,接口雪崩。

整改方向是彻底斩断实时请求链路里的重计算。相似度矩阵和TopN推荐结果全部由离线任务算好,在线推荐只允许读缓存和数据库。同时给推荐接口加了Redisson分布式锁的缓存穿透保护,缓存不存在时只有一个线程去查数据库,其他线程直接返回空走兜底。优化后推荐接口P99延迟稳定在300毫秒以内。

还有一个细节值得说:在线推荐里遍历候选集,我一开始用Java的List,数据量大了之后改用ParallelStream,确实跑了快一些,但线程切换开销也不小。后来干脆把这个逻辑下沉到SQL,直接用IN查询一次捞回来,性能反而更好。能用数据库解决的问题,别在代码里自己折腾。

5.3 前后端联调的典型问题

这个项目前后端联调阶段,我踩了几个不算复杂但很磨人的坑。

第一个是时间格式化问题。后端返回的时间是LocalDateTime序列化后的数组格式,前端解析直接报错,整个推荐卡片上的时间字段全变成了"Invalid Date"。解决方案是后端统一配置Jackson的日期格式化,把时间转成yyyy-MM-dd HH:mm:ss字符串,前端不做任何解析直接展示。

第二个是跨域问题。前端跑在8080端口,后端跑在9090,浏览器直接拦截跨域请求。开发环境我用Vite的代理配置解决,/api开头的请求全部代理到后端地址,同时在后端配置了CORS跨域过滤器,生产环境用Nginx做反向代理统一转发,彻底规避跨域。

第三个是图片资源加载问题。景点图片存在本地目录,前端用<img src="/upload/xxx.jpg">访问,开发环境正常,部署之后图片全部404。排查半天发现是SpringBoot的静态资源映射配置没生效,加上一个资源映射配置类,把本地上传目录映射到/upload/**访问路径才解决。

这三个问题单拎出来都是小问题,但放在一起会耗费大量的联调时间。我的经验是:启动前后端联调前,先花半天时间把接口文档规范好,时间格式、返回结构、错误码都提前约定,联调阶段至少能少加一半班。

5.4 数据倾斜与推荐时效性的折中

项目后期面临一个数据层面的问题:热门景点被大量用户行为覆盖,计算相似度时这些景点的权重过大,导致推荐结果向热门景点倾斜;与此同时,长尾景点即使质量很好,也因为行为数据少而很难被推荐出来。这就是典型的数据倾斜问题。

我的处理方案是给相似度计算引入惩罚因子。具体做法是借鉴了物品相似度的热门惩罚公式,在计算景点相似度时,对热门景点做衰减:

public double itemSimilarityWithPopularityPenalty(long itemA, long itemB, int commonUsers, int usersA, int usersB) { double basicSimilarity = commonUsers / (Math.sqrt(usersA) * Math.sqrt(usersB)); // 热门惩罚:共同喜欢用户数越少,权重越高惩罚越重 double penalty = Math.log(1 + commonUsers) / Math.log(1 + 1000); return basicSimilarity * penalty; }

这个公式的直观意思是:景点A和景点B只在少数几个用户行为里共同出现,比在大量热门景点数据里共同出现更能代表真实的相似关系。加了惩罚因子之后,长尾景点的推荐曝光率提升了将近一倍,用户点击转化率反而更高了,因为推荐结果的差异化带来了新鲜感。

时效性问题上,我做了离线与近实时相结合的方案。每天的离线计算负责大而全的相似度矩阵更新,覆盖整个推荐主链路。用户实时行为则通过一个轻量近实时任务,每10分钟汇总一次新增行为数据,只增量更新受影响用户的推荐缓存。这样既能保证推荐不过期,又不用牺牲性能去全量重算。

5.5 推荐效果评估的方法与数据

项目上线后的效果评估,不能光凭感觉说"推荐变准了",得有量化指标。我用了一套比较完整的评估方案。

离线评估用经典的准确率和召回率。具体做法是,把用户行为数据按时间切分,前80%做训练集,后20%做测试集,用训练集计算推荐结果,检查推荐列表里有多少景点出现在测试集里。这个指标能快速验证算法参数调整是否有效。

在线评估则重点看业务指标。第一个是点击率,推荐位曝光到点击的转化比例,这个指标直接反映推荐结果是否有吸引力。第二个是曝光到下单的转化率,反映推荐结果的质量和商业价值。第三个是人均推荐位点击次数,反映推荐流是否留得住用户。

我自己项目里一个比较有意思的观察是,加了推荐理由文本之后,推荐位的点击率提升了大概百分之十几。这个提升不来自算法本身,而来自呈现方式。说明推荐系统是一个算法加产品设计结合的系统,算法再准,呈现方式不对,效果也要打折扣。

数据也暴露出一个反直觉的结论:推荐列表前三个位置的点击率最高,但这个位置的转化率不一定最好,用户可能只是对推荐卡片感兴趣而点进去看看,真正下单的反而是往下几个不那么显眼的位置。所以排序策略不能只按点击率调,要结合不同的效果目标去做位置加权。

我在实际开发这个项目时最深的体会是,别把推荐算法当黑盒,也别把它当成一个单独的后端模块。它其实是一套数据产品,算法、数据、前端呈现、用户交互四个环节咬合在一起,缺一个环节都会让推荐效果大打折扣。建议准备做类似系统的朋友,先花时间把行为埋点设计好,把数据闭环跑通,再在这个地基上去迭代算法,效果会快很多。最后一个小技巧:推荐列表里每一条结果都带上推荐原因,哪怕只是一个简单的标签,用户的接受度都会提升一大截,不信你试试。

RELATED READING

延伸阅读

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