前阵子一直在折腾一个带推荐模块的Python二手交易网站,从数据库设计到协同过滤算法落地,磕磕绊绊踩了一堆坑。这个项目看起来很常规,但真正动手之后会发现:二手交易场景对推荐系统的要求跟普通电商完全不一样,且协同过滤在数据稀疏情况下的表现直接影响整个系统体验。这篇文章把整个项目从架构到算法细节都拆开来说,尤其聚焦协同过滤模块是怎么在真实业务里落地的。
这套系统没有停留在传统商城模版的增删改查上,而是围绕“用户需要什么”做个性化推荐。核心价值在于:二手商品标准化程度低,用户也很难用关键词准确描述自己想要的物品,靠搜索框很难完成需求匹配。通过用户的行为日志——浏览、收藏、联系卖家、发布物品,构建兴趣模型,再用协同过滤把“同样对这件商品感兴趣的人,也在看那件商品”这种隐含关系捞出来,才真正让系统的信息流活起来。
如果你正在做电商系统、以推荐算法为核心的应用,或者学习协同过滤怎么跟Web项目结合,这篇内容可以直接作为参考。文章从架构拆解开始,再到算法核心代码、数据模型、性能优化,最后是实际踩坑记录,覆盖一个完整项目的落地全过程。
1. 项目全貌与系统设计思路
1.1 二手交易场景下,推荐引擎要解决什么题
二手物品交易和全新商品电商最大的区别有两点。首先,商品是“非标准化”的。一件九成新的二手相机,它的成色、配件、使用频率都会影响买家判断,而普通电商商品的参数体系在这里基本失效。其次是商品生命周期极短,一台手机可能上架一周就卖出去了,不可能像书籍、数码产品那样长期沉淀稳定的“品类标签”。
这些特点决定了二手交易网站的核心矛盾:用户既不知道平台有什么,也不知道自己想要什么,只能不停地翻列表或者依赖搜索。纯靠搜索和分类导航,体验非常局限。推荐模块的价值就在这里——把用户的历史行为跟商品之间建立关联,主动把用户可能感兴趣的东西推到面前。
具体到这个系统,推荐模块要解决的第一个问题,是提高用户的浏览深度。没有推荐的时候,用户进来可能看两三个商品就离开了;有推荐之后,用户可以在“猜你喜欢”和“相似物品”里持续逛下去。第二个问题是提升交易匹配效率。在信息不标准的场景下,兴趣匹配比关键词搜索更容易连接买卖双方。
1.2 整体架构与Python技术栈选型
系统的技术栈以Python为主,Web层使用Django,数据存储用MySQL,缓存和推荐结果的短时存储用Redis。Django的ORM和Admin后台很适合快速搭建二手交易系统的业务骨架,商品发布、用户注册、订单管理这些模块都能快速落地。
推荐模块则没有和业务代码混在一起,而是独立成一个子模块。原因很实际:推荐逻辑是一次性操作大批量数据的计算型任务,与普通的Web请求在资源占用和运行节奏上完全不一样。如果像普通业务代码那样实时同步计算,用户每次打开首页都去跑一遍相似度计算,数据库和计算节点的压力根本兜不住。所以推荐模块只负责两件事,一是定时离线计算所有物品的相似度,把结果写入Redis和MySQL的相似物品表;二是提供查询接口,在Web请求进来时直接查已经算好的推荐结果。
这种“离线计算+在线读取”的方式虽然不算那么实时,但对于大多数二手交易场景来说足够使用了,而且工程上最稳定、最容易维护。
1.3 为什么选协同过滤,而不是内容推荐
刚做技术方案的时候,我也考虑过基于内容的推荐。比如给每个商品打上标签,然后按用户偏好标签去匹配。但二手商品的标题描述随意性高,标签体系的维护成本极大。一个卖家可能写“自用手机便宜出”,另一个卖家可能写“苹果手机几乎全新”,内容特征本身就不稳定,基于内容的推荐效果不会好。
协同过滤的思路恰恰不需要理解内容。它只关注用户和物品之间的交互行为,只要记录了大量买卖双方的浏览、收藏、联系记录,就能通过行为模式计算物品之间的相似度。这种推荐方式的优势在于完全跳过内容理解的困难,直接利用群体的行为一致性,发现人工规则难以发现的关联。比如“看过单反相机的人,往往也在看摄影背包”,这种关联靠商品标签很难挖出来,但协同过滤天然就能发现。
用户行为数据是这套系统的燃料。所以在项目初期,行为日志的埋点设计就不是可选项,而是一开始就要纳入核心架构的重点。
2. 协同过滤算法:原理拆解与落地细节
2.1 选UserCF还是ItemCF,二手场景要分清
协同过滤算法分UserCF(基于用户的协同过滤)和ItemCF(基于物品的协同过滤)两条路线。UserCF先找到与你兴趣相似的用户,再把这些用户浏览过但你没看过的物品推荐给你。ItemCF则反过来,先找到与你历史喜欢的物品相似的物品,再推荐给你。
| 对比项 | UserCF | ItemCF |
|---|---|---|
| 计算思路 | 找相似用户,推荐其喜欢的物品 | 找相似物品,推荐相似的物品 |
| 适用场景 | 用户数量相对少、物品变化快的场景 | 物品数量相对稳定、兴趣变化慢的场景 |
| 推荐理由 | 体现群体兴趣,偶然性更强 | 能清楚解释“因为你看了XX” |
| 实时性 | 用户状态变化频繁,更新成本高 | 物品关系相对稳定,更适合离线更新 |
| 二手交易适配 | 用户数量大、用户偏好瞬变,不适合 | 物品虽流动性高,但同类物品有稳定关联,较合适 |
在二手交易网站这种用户规模远大于物品规模的场景下,UserCF每来一个新行为都可能影响相似用户集合,计算量爆炸,而且二手交易的兴趣变化极快。反观ItemCF,物品之间的相似关系相对稳定,而且“因为你看过XX,所以推荐相似的XX”这种推荐逻辑更容易让用户理解。所以系统最终选的是ItemCF为主体,再用热门物品做兜底。
2.2 没有评分的时候,怎么构建兴趣矩阵
协同过滤的经典算法依赖用户对物品的评分矩阵。但二手交易网站基本没有“评分”这个动作,用户对物品的反馈只能从行为日志里获取。这就需要一个评分映射策略,把不同权重、不同发生时间的行为折算成一个可比较的分数。
我用一个加权行为评分策略:浏览记录计1分,收藏计2分,发起联系(比如点击“我想要”或查看卖家联系方式)计3分,最终完成交易计4分。评分代表的是“用户对这件物品的潜在兴趣强度”。还需要对分数做时间衰减处理,因为一周前的收藏和昨天的收藏,代表的兴趣强烈程度完全不同。这里对每个行为乘一个时间衰减系数。
时间衰减采取指数衰减方式。假设当前时间为current_time,行为发生时间为action_time,衰减系数alpha设为0.5/天,权重计算如:
score = base_weight * exp(-alpha * (current_time - action_time).days)这样两天前的一次收藏(基础权重2)按衰减后大约是2 * exp(-1),也就是0.73分;而昨天的收藏还有1.2分左右。这个衰减逻辑,让近期的行为在推荐中占据更主导的位置,旧行为逐渐退出影响,贴合二手物品流转快的特性。
2.3 相似度计算:余弦相似度的落地细节
ItemCF的核心是计算物品之间的相似度,这里用的是余弦相似度。有两个物品A和B,把同时对它们产生过行为的用户集合取出来,构成两个向量,然后计算向量间的余弦距离。公式表达如下:
sim(A, B) = (向量A · 向量B) / (|向量A| × |向量B|)在代码实现时,我并不直接构造一个完整的用户-物品矩阵,那样内存消耗太大,尤其是用户量上来以后,这个矩阵稀疏得可怕。真正的主流的做法是:用字典结构存储每个物品对应的“感兴趣的用户及其分数集合”,然后通过共现用户数量计算相似度。
用实际代码表示就是:
import math from collections import defaultdict def compute_item_similarity(action_data): # action_data: {item_id: {user_id: score}} user_items = defaultdict(dict) # user -> {item: score} item_users = defaultdict(dict) # item -> {user: score} for item_id, users in action_data.items(): for user_id, score in users.items(): item_users[item_id][user_id] = score user_items[user_id][item_id] = score # 共现矩阵 co_occur = defaultdict(lambda: defaultdict(float)) item_norm = defaultdict(float) for item_id, users in item_users.items(): user_score_list = [(u, s) for u, s in users.items()] item_norm[item_id] = math.sqrt(sum(s * s for _, s in user_score_list)) for i in range(len(user_score_list)): u1, s1 = user_score_list[i] for j in range(i + 1, len(user_score_list)): u2, s2 = user_score_list[j] co_occur[item_id][u1] += s1 * s2 co_occur[item_id][u2] += s1 * s2 sim_matrix = defaultdict(dict) for item_id, user_weights in co_occur.items(): for user_id, weight in user_weights.items(): denom = item_norm[item_id] * item_norm[user_id] if denom > 0: sim_matrix[item_id][user_id] = weight / denom return sim_matrix这里有个简化处理需要注意:co_occur里的键既是物品又是用户,所以计算分母时需要根据当前循环对象确定是物品还是用户。实际项目中建议拆成两个矩阵分别存物品与物品、用户与用户,避免混淆。这个代码是为了讲清逻辑的高度简化版,真正的工程项目里建议直接用物品-用户倒排表来生成物品共现矩阵,再合并归一化。
相似度算完之后,对每个物品只保留TopN(一般取10到20)个最相似的物品,存入Redis和MySQL,作为后续推荐查询的基础。
2.4 推荐列表的生成逻辑
有了物品相似度矩阵后,为某个用户生成推荐列表就非常简单了。把该用户最近有过正向行为的物品作为“种子物品”,把它们最相似的物品汇总,再排除掉用户已经交互过的物品,其余按分数降序排列就是推荐结果。
推荐分数公式为:
prediction_score = sum(seed_item的活跃分数 × 该seed_item与候选物品的相似度)在代码中一个可能的实现如下:
def generate_recommendations(user_seed_items, sim_matrix, exclude_items, topk=20): scores = defaultdict(float) for seed_item, seed_score in user_seed_items.items(): if seed_item not in sim_matrix: continue for candidate, sim_score in sim_matrix[seed_item].items(): if candidate in exclude_items: continue scores[candidate] += seed_score * sim_score ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [item_id for item_id, score in ranked[:topk]]这个流程看起来简单,但有两个坑会在实战中反复出现。第一个坑是,如果用户最近只产生过一两次行为,种子物品太少,候选推荐结果会非常有限。这时候需要用“热门商品补位”机制:当推荐结果不足TopN时,用全局热门商品补足,避免首页冷场。第二个坑是商品可能已经被卖家下架或售出,推荐列表生成时要把这些不可售状态过滤掉,否则用户点进去是个失效链接,体验很差。
3. 系统核心模块实现与关键代码
3.1 数据库模型设计与相似度表存储
推荐引擎落地的第一步是设计好数据模型。系统的核心表有这几张:用户表、商品表、行为日志表、物品相似度表。
商品表主要保存发布者的用户ID、标题、描述、价格、成色、图片地址、状态(在售、下架、已售)。行为日志表直接决定推荐质量,设计成自增主键加用户ID、物品ID、行为类型(view/favorite/contact/deal)、行为时间四个核心字段。
相似度表的设计则有讲究。不能在查询推荐结果时现场算相似度,而是把离线计算好的结果存起来。我用的物品相似度表包含主键、物品ID、相似物品ID、相似度分数、更新时间几个字段,并对物品ID加索引。查询一个物品的相似物品时,直接按物品ID查这张表就可以了。
这里有个实际工程经验:相似度表没必要存全部两两相似关系,只保留每个物品最相似的TopN就行。因为推荐时根本用不到第21个之后的相似物品,存得越多只是白白增加存储压力。
3.2 推荐引擎的定时更新与增量策略
全量更新在数据量不大时很简单,每月甚至每周跑一次都行。但二手物品交易系统的数据会随着时间增长,而且行为日志增加得很快,固定时间全量更新会导致推荐结果跟不上近期用户行为的变化。所以最终选了折中方案:每天凌晨运行一次全量更新,全量更新结束后,再实时叠加当天的增量行为。
增量更新的思路不复杂。每天记录当天的行为日志,把这些日志转换成类似全量数据的格式,只计算这些新行为涉及的物品的相似度,然后在Redis里把对应的物品相似度列表做合并。这里不需要完全精确,先把推荐效果做出来,收敛速度的问题以后再优化。
代码上我用了一个简单的定时任务调度:
def update_recommend(): logger.info("start full update at %s", datetime.now()) behaviors = load_all_behaviors() item_sim_matrix = compute_item_similarity(behaviors) save_to_mysql(item_sim_matrix) save_to_redis_cached(item_sim_matrix) logger.info("full update finished, item count: %s", len(item_sim_matrix))增量部分则是获取当天的行为数据,只对行为涉及到的物品重新计算,并和Redis中已有的相似度列表进行加权合并。注意合并时相似度要按item_id维度分别处理,不然数据会越来越乱。
3.3 业务模块如何对接推荐结果
推荐结果不能只停留在计算阶段,需要真正出现在Web页面上。在这个系统里,推荐模块以两种方式暴露给前端:首页的“猜你喜欢”接口和商品详情页的“相似物品推荐”接口。
首页推荐接口的大致工作流是:从Redis缓存中查询当前用户的推荐物品ID列表;不足时用热门物品补位;再联查商品表,过滤掉已下架、已售出的物品;返回最终的商品信息列表。
详情页的“相似物品”则更简单直接,用当前查看的物品ID去查相似度表,取出TopN相似物品,过滤后展示。这个模块是独立于用户行为的,任何用户看到的相似物品都是一样的,但正是这种推荐,让用户在浏览单个商品时有机会不断发现新的相关信息,从而延长停留。
3.4 推荐结果缓存如何设计
Web请求最怕的是每次都去MySQL里翻相似度表和商品表,尤其首页这种热点页面,用户同时进入时很可能把数据库连接打满。推荐结果一定要加缓存。
系统用了两级缓存。第一级是Redis,key设计成按用户维度区分,比如rec:guess:{user_id},value直接存物品ID列表JSON,TTL设置为一小时。第二级是热门推荐列表,比如rec:hot,用全局商品ID列表,TTL设置成十分钟。详情页相似物品也有独立的缓存,key设为rec:similar:{item_id},TTL设置成三小时。
在用户行为发生变化时(比如收藏了一个新物品),可以直接删除该用户的推荐缓存,让用户下次访问时生成新推荐。这样既保证了数据新鲜度,又避免了每次请求都去做复杂计算。
4. 实战中的常见问题与排查方法
4.1 数据稀疏,推荐质量差怎么办
协同过滤最怕的就是数据稀疏。新平台刚上线那会儿,行为日志里的用户和物品交互非常少,很多物品只有一两次浏览,算出来的相似度矩阵异常稀疏。相似度表里能查到的相似物品很少,推荐列表经常补位补成热门榜,等于没有个性化。
这种情况下不要硬上算法,先做规则兜底。我给系统加了三层推荐策略:第一层是个性化协同过滤;第二层是同分类下的热门商品;第三层是全局热门商品。前一层推荐数不足时,再用下一层补齐。等行为数据积累到一定量级后,个性化推荐的占比自然会慢慢变大。
数据采集阶段也可以做一些主动引导。比如在新手用户注册后,引导用户选择感兴趣的物品分类,用这个“人工标注兴趣”补充冷启动数据和行为矩阵。虽然这种数据和真实行为相比有偏差,但至少缓解了前期的稀疏问题。
4.2 冷启动:新用户与新商品怎么推
新用户的推荐本身就是无从下手的,因为没有任何行为记录。这里采用的思路是:在前端第一次加载推荐接口时,如果发现该用户没有任何行为种子,就直接返回热门商品推荐。同时在商品详情页的相似物品模块里,如果遇到一个几乎没有行为的新商品,相似度表查不到任何记录,就用它所在分类下的热门商品作为替代推荐。
热门商品怎么定义也需要设计好。这里用的是“近7天浏览次数 + 收藏次数加权”的方式,计算出一个热力值,然后定期更新。有了热门池,冷启动问题就算不能完全解决,也不至于让新用户进来面对一个空荡荡的首页。
4.3 相似度矩阵与内存性能问题
物品数量上来后,相似度矩阵的维度和内存消耗会快速上升。如果商品有5万条,两两相似度矩阵的存储规模是5万乘5万,光存浮点数就能耗掉几个GB内存。这个量级在开发环境还能跑,但生产环境就不行了。
实际项目里用了一招很管用的手段:先做商品过滤。只保留近90天内产生过至少3次行为的商品进入相似度计算,把长期无行为、无曝光的僵尸商品从候选集合里剔除。这样做有两个好处,一是大幅压缩矩阵维度,二是推荐模块真正关注的是活跃商品,算出来的相似度也更可靠。
另外,物品共现矩阵的计算过程本身也需要优化。在线计算时不要去遍历整个用户物品矩阵,而是先把用户行为按天聚合,然后用“以用户为维度构建物品序列”的思路做共现统计。这里有一个更好的实践是:先在SQL层面把行为日志按用户分组、按时间排序,得到每个用户的“行为物品序列”,再用Python处理这个序列来统计物品之间的共现次数。这样比直接从全量矩阵硬算快一个量级。
4.4 推荐结果的可解释性怎么做
协同过滤推荐的一个问题就是“吃的黑箱”感。用户不知道为什么系统给他推荐了这些商品,而可解释推荐能显著提高信任感。这个系统里做了一件简单但有效的事:在推荐结果旁边标注推荐理由,比如“因为你看过【某商品】,所以为你推荐相似物品”。
实现上就是在生成推荐列表时,把每个候选物品命中的种子物品一起保存下来,接口返回时带上推荐理由字段。比如一个候选物品命中了用户上次收藏的“九成新山地车”,推荐理由就是“和你收藏过的九成新山地车相似”。这个细节能有效提升用户对推荐模块的接受度。
4.5 容易忽视的细节坑
有几个细节问题光靠读算法论文发现不了,只有在实际跑系统的时候才会被反复折磨。
第一个坑是物品状态过滤。推荐结果如果不对商品状态做判断,用户点进推荐物品后发现已经下架或已售,跳出率会很高。生成推荐列表时,一定要把不可售状态商品过滤掉,并且要在缓存失效前同步处理,否则同一件商品可能在多个用户首页显示失效链接。
第二个坑是行为日志的埋点不一致。详情页浏览、列表页浏览、搜索结果点击、收藏、联系卖家,这些行为如果埋点字段不统一,后端解析会非常痛苦。所以行为日志的字段在设计当初就要统一成一个标准格式,线上排查问题才能高效。
第三个坑是时区问题。行为日志的时间和推荐计算的时间如果跨时区,时间衰减计算就会出错。最简单的方式是所有日志统一记录成UTC时间,展示层再转本地时区。千万别存在什么“本地时间字符串”之类的,后端一旦跨服务器部署就会乱掉。
第四个坑是热门补位导致的重复问题。如果个性化推荐结果本身已经包含了热门商品,补位时可能把同一件商品重复推到推荐列表里,前端展示会有重复卡片。处理办法是推荐结果合并后统一做一次去重,以物品ID为主键,后合并的优先保留,逻辑不复杂但非常影响观感。
还有一个容易被忽视的点:用户自己发布的商品,绝对不应该出现在自己的推荐列表里。这种错误在开发环境里很难发现,直到真实用户反馈“我自己卖的东西又推荐给我了”才意识到。所以在最终组装推荐列表的时候,要把用户ID对应的已发布商品集合拿出来,统一排除掉。
5. 离线评估与后续优化思路
推荐模块上线后,自然要回答“推荐效果怎么样”的问题。没有评估的推荐就是自娱自乐。系统里做了两个层面的评估。
离线评估用的是准确率和召回率。把行为日志按时间切分,前70%作为训练集,后30%作为测试集。用训练集计算相似度矩阵,再预测测试集用户的潜在物品,看推荐物品中有多少是用户真实发生行为的物品。另一个指标是覆盖率,计算推荐结果覆盖的商品数占总可推荐商品数的比例。覆盖率太低说明推荐结果集中在那几个头部商品上,挖掘长尾信息的能力不够。
在线层面做的是简单地版本对比:一个页面带推荐模块,另一个页面不带推荐模块,观察用户平均浏览深度、停留时长和转化率。开发环境只能单机自测,生产环境上线后做灰度切量即可依赖真实数据对比。
后续迭代可以考虑引入矩阵分解的协同过滤,比如基于隐语义模型的算法。它能部分解决物品相似度矩阵在大规模数据下的稀疏和扩展问题,把用户和物品映射到低维空间进行兴趣匹配。但要注意的是,如果当前的物品相似度表和热门补位策略已经能够支撑业务,就不要盲目上复杂模型,沿用现有架构先把数据积累起来,效果评估模型之后再做替换。
在做项目总结的时候,可以反复提醒自己:推荐系统跑通只是第一步,关键在于整个链路是否都能稳定服务真实用户需求。协同过滤的坑非常隐蔽,冷启动、数据稀疏、性能膨胀、业务规则冲突,每一个实际跑起来都可能让开发陷入泥潭。个人体会是,先把最简单的协同过滤做到极致,比直接上一堆复杂算法靠谱得多。一个能解释逻辑、能快速定位问题、能稳定支撑接口的推荐模块,已经能超过许多停留在演示层面的系统了。