首页 / 资讯中心 / 文章详情

推荐系统架构与算法流程:召回、排序、重排与工程落地

推荐系统架构与算法流程:召回、排序、重排与工程落地 ★ FEATURED ARTICLE
做推荐系统这行最容易被误解的一点是很多人以为它的核心是那个模型。刚入行的时候我也这么想觉得把排序模型从逻辑回归换成深度网络指标就该往上走。真做过几个完整项目之后才发现模型只是整条流水线上的一颗螺丝决定效果上限的往往是架构分层、样本构造、特征口径这些看起来不性感的活。推荐系统架构与算法流程说白了就是把用户可能喜欢什么这个问题拆成从海量候选到最终几条结果的逐级收敛过程每一级都有自己的成本约束和优化目标。这篇内容我想从一个从业者的角度把整条链路的架构设计、召回排序的算法细节、工程落地和踩坑排查完整讲一遍适合刚接触推荐系统的同学建立全局认知也适合已经在一线做特征和策略的同学对照检查自己的实现有没有埋雷。1. 推荐系统的整体架构设计与分层思路1.1 为什么推荐系统必须做分层收敛先算一笔账。一个中等规模的内容或商品平台物品池按百万级算日活按百万级算如果对每个用户在每次请求时都对全量物品打分单次请求就是百万次模型推理即使单次推理只要0.1毫秒也要100秒这显然不现实。推荐系统的分层本质上是一个漏斗式的计算资源分配方案用极低成本从百万级候选里粗筛出千级再用中等成本从千级里筛出百级最后用高成本模型从百级里精排出几十条重排层再做业务层面的调整。这个漏斗的每一层成本差通常在数量级上。召回层常用向量近邻检索或倒排索引单次请求的耗时控制在几十毫秒排序层跑复杂模型可能要用到上百个特征耗时几十毫秒重排层逻辑简单但规则密集耗时通常个位数毫秒。把贵的计算放在候选集已经很小的地方是整套架构能跑起来的前提。我见过一些团队在早期为了省事跳过召回直接对全量池子做规则过滤加排序物品池几千个的时候没问题等池子涨到十万级接口延迟直接崩掉回头重构的代价比一开始就分层大得多。所以哪怕是最小可行的版本我也建议把召回和排序的边界划清楚后面的演进会顺畅很多。1.2 数据层、召回层、排序层、重排层的职责边界分层的另一个价值是职责清晰。每层只解决一个问题出了问题容易定位迭代时也不会互相干扰。我把常见四层的职责整理成下面这张表实际项目里可以按这个骨架去对层级输入规模输出规模核心职责典型耗时预算数据层全量行为与内容特征与索引提供样本、特征、向量索引离线/近线不占在线召回层百万级物品池数百到数千保证相关候选不遗漏30~60ms排序层数百到数千数十到数百精确预估点击/转化概率50~100ms重排层数十到数百十条到几十条多样性、打散、业务规则5~20ms职责边界划清楚之后迭代就有了抓手。召回层关注的是召回率也就是用户真正感兴趣的东西有没有被捞进来排序层关注的是排序质量通常用AUC、GAUC或者NDCG来衡量重排层关注的是最终呈现效果指标更偏业务比如人均点击次数、停留时长、多样性指标。如果这几层的指标混在一起看很容易出现排序模型AUC涨了但线上点击没动的困惑——大概率是召回的候选池里本来就没有用户想要的东西排序再怎么排也变不出来。1.3 离线、近线、在线三条链路的取舍真正落地的时候架构还要按时间维度切成三条链路。离线链路负责全量样本生成、模型训练、向量索引构建通常按天调度近线链路负责准实时的特征更新比如用户最近半小时的点击序列延迟在分钟级在线链路负责单次请求的实时计算延迟在百毫秒级。这三条链路的分工不是拍脑袋定的而是由特征时效性价值决定的。用户的历史偏好类特征比如长期兴趣标签天级更新完全够用物品的统计类特征比如近一小时点击率如果天级更新遇到突发热点就会严重滞后必须走近线而用户当前会话的行为比如刚点过的三个物品必须在线拼接。我一般会按下面的原则划分能用离线算的绝不放近线能用近线算的绝不放在线因为在线链路的每一次额外计算都要从延迟预算里扣。一个具体的经验数字在线服务里特征拼接环节的耗时往往比模型推理还高。如果一次请求要取50个特征每个特征一次远程调用就算每次只要2毫秒加起来也100毫秒了。所以特征平台通常会做批量取数接口一次请求拿回所有特征这个优化做过之后延迟能降一半以上。2. 召回层从海量候选里捞出可能感兴趣的2.1 多路召回的组合逻辑与配比单路召回几乎不可能覆盖用户的所有兴趣面。协同过滤擅长挖掘相似人群的偏好但对新物品无能为力向量召回擅长语义相似但对长尾品类覆盖不足热门召回保证基础体验但千人一面标签召回可控性强但粒度粗。所以工业界普遍采用多路召回并联每路负责一种召回视角最后合并去重。合并之后的候选池规模要控制住不能简单地把所有路的TopN拼起来。我的做法是给每路设置配额再根据线上的召回覆盖情况动态调整。下面是一份典型的配置可以直接拿去改召回路召回数量主要覆盖场景更新频率协同过滤200有历史行为的活跃用户天级双塔向量300语义相似与跨品类天级/近线热门与趋势100新用户、长尾用户小时级标签与规则150运营可控场景天级序列召回200兴趣迁移明显的用户近线配额不是固定不变的。我通常会在离线评估阶段统计每路的独立贡献率——也就是去掉这一路之后整体召回率下降多少。下降幅度小的路配额就往下压独立贡献率高的路配额往上加。这个动作每两周做一次能让候选池的构成一直保持在比较健康的状态。注意多路召回合并时一定要去重而且去重要在合并前做。我见过先合并再按物品ID去重的实现结果同一物品在不同路的打分尺度完全不同去重时保留谁的分都不合适最后排序层拿到的分数不可比。2.2 协同过滤与向量召回的落地细节协同过滤是绕不开的基础方案尤其是物品协同过滤实现简单、可解释性强。核心就是构建用户-物品交互矩阵算物品之间的相似度。实操里有几个细节值得说相似度计算前要做热门物品降权否则爆款会和所有物品都相似交互矩阵要过滤掉噪声行为比如停留时间低于1秒的点击相似度矩阵要截断TopK不然存储和检索成本会爆炸。向量召回这几年成了主力。典型的双塔结构用户塔和物品塔各自编码成定长向量线上用近似最近邻检索找TopK。这里的关键决策是向量维度我一般选64或128维。维度太低表达能力不够太高则检索成本上升明显——以百万级物品池为例128维的向量库内存占用大约是64维的两倍而检索耗时也会跟着涨。训练双塔时负采样策略直接决定效果。常见做法是batch内负采样把同一批次里其他样本的物品当作负例这样不用额外构造负样本训练效率高。但这么做会引入热门物品被过度惩罚的偏差因为热门物品更容易在batch内被抽到。修正办法是加一个采样概率的校正项按物品被抽中的概率做logit修正代码大致长这样# batch 内负采样后的 logit 校正缓解热门物品被过度惩罚 import torch def corrected_logits(user_emb, item_emb, sampling_prob, temperature0.05): logits user_emb item_emb.t() / temperature # 减去采样概率的对数抵消热门物品的高频出现 logits logits - torch.log(sampling_prob 1e-8) return logits序列召回是另一条值得投入的路。把用户最近点击的物品序列过一遍编码器得到兴趣向量再检索对那些兴趣迁移快的场景提升明显。我做过一次对比在内容类场景下单独加一路序列召回整体召回率提升了大概8个百分点但代价是近线链路多了一套序列特征的生产任务运维复杂度上去了。2.3 召回评估指标与常见陷阱召回层最核心的指标是召回率但它的计算方式经常被搞错。正确的做法是取用户在评估窗口内真实交互过的物品作为应召回集合然后看召回路返回的TopK里覆盖了多少。这里有两个坑。第一个坑是用训练数据评估。如果评估窗口和训练窗口重叠召回率会虚高得离谱因为它只是把记忆过的东西又背了一遍。评估集必须严格来自训练数据之后的时间段。第二个坑是只看整体召回率不看分层。整体召回率60%听起来还行但拆开看活跃用户可能到80%新用户只有20%问题就暴露出来了。我一般会按用户活跃度分桶按物品热度分桶各桶分别算召回率哪一桶塌了就去补哪一路召回。还有个容易被忽略的指标是候选池的多样性。如果某一路召回返回的全是同一品类的物品排序层再怎么努力也做不出多样性。可以在召回阶段就引入品类打散比如每个一级品类最多贡献30个候选这个简单的约束在后期能省掉很多重排层的麻烦。3. 排序层精排模型的样本构造与特征工程3.1 样本构造中的负采样与偏差修正排序模型的效果七成看样本和特征三成看模型结构。这话有点绝对但在我经历的项目里基本成立。先说样本。正样本通常好定义点击、购买、收藏这类明确行为就是正例。麻烦的是负样本。曝光未点击算不算负例严格说曝光未点击里混着大量没看到和不感兴趣两种情况直接当负例会把模型带偏。我的做法是分场景处理首屏曝光按较短的停留时长设阈值过滤非首屏曝光则适当放宽因为用户压根没滑到那里。负采样比例也需要认真对待。正负样本严重不平衡时常见做法是下采样负例比如1:4。但下采样会改变样本分布导致模型输出的概率不再是真实点击率而是一个被抬高的值。如果下游要用这个概率做排序问题不大因为排序只关心相对顺序但如果要用绝对概率做阈值判断或出价计算就必须做校准。校准公式不复杂# 负样本下采样后的概率校准 # 设原始正负比为 pos:neg采样后负例保留比例为 r def calibrate(p_sampled, neg_keep_ratio): odds p_sampled / (1 - p_sampled 1e-12) true_odds odds / neg_keep_ratio return true_odds / (1 true_odds)除了下采样偏差还有位置偏差。用户在首屏看到的物品点击率天然高于后面的位置如果模型把位置信息当成普通特征学进去就会高估首屏物品的价值。解决办法一般是在训练时把位置作为一个偏置项单独建模预测时置零或者干脆在训练样本里做位置随机化让物品出现在不同位置消除位置和物品的耦合。3.2 特征体系的分层设计与实时性权衡特征体系我习惯分成四类这样便于管理也便于排查用户侧特征、物品侧特征、交叉特征、上下文特征。每类的更新频率和存储方式都不一样。用户侧包含长期兴趣标签、历史统计量、人口属性等更新频率天级为主。物品侧包含内容属性、统计点击率、转化率等其中统计类特征建议做多时间窗口1小时、1天、7天、30天各算一份让模型自己去学哪个窗口更有用。交叉特征是最能拉开差距的部分比如用户对某品类的历史点击率、用户与物品在标签上的重合度。上下文特征包含时间、网络环境、页面位置等这类特征时效性最强必须在线取。特征类型示例更新频率存储位置用户长期兴趣标签、活跃度分天级离线特征库用户短期近30分钟点击序列分钟级近线存储物品统计1h/1d/7d点击率小时级近线存储交叉特征用户-品类偏好分天级离线特征库上下文时段、位置、设备实时请求上下文这里有个不得不提的硬约束线上线下特征一致性。离线训练用的特征和在线推理时取的特征必须来自同一套计算逻辑。我踩过最疼的坑是一次把离线统计口径改成了去重后点击而在线服务还在用原始点击结果模型线上线下表现差了将近一倍排查了三天才发现是口径问题。后来我们的做法是所有特征的计算逻辑必须沉淀成统一的UDF或者特征平台配置离线和在线都调同一个实现宁可多一层抽象也不要两边各写一份。3.3 模型选型的现实考量模型结构的选择很多时候不是哪个先进用哪个而是哪个能在延迟预算内稳定跑起来。逻辑回归是永远的baseline训练快、可解释、部署简单特征工程做得好时效果并不差。我一般会先跑一版LR拿到一个基准AUC后面所有模型都以它为参照。双塔模型适合做粗排用户塔和物品塔分离物品向量可以离线算好线上只算用户塔再做向量检索延迟极低。深度交叉网络类模型适合精排能自动学特征交叉但推理成本高。上线前必须算清楚单次推理耗时乘以候选数量再乘以峰值QPS看是否在预算内。举个例子精排候选500个单次推理0.2毫秒串行就是100毫秒已经超预算了这时候要么降候选数要么做批量推理要么换更轻的模型。我个人的选型顺序是先上LR确认特征有效再上双塔做粗排把候选从千级压到百级最后在百级候选上跑深度模型。这样每一步的收益都能单独验证出问题也好回滚。4. 重排与策略层让结果真正可交付4.1 多样性、打散与去重的实现方式排序层输出的是单点最优但用户看到的是一个列表列表整体的体验和单点最优是两回事。十个一模一样的爆款堆在一起用户会觉得这个产品很无聊。所以重排层的第一个任务就是多样性控制。打散的实现方式通常是滑动窗口。设定一个窗口大小N和约束条件比如任意连续3个位置内不能出现同品类物品然后从头遍历列表遇到违反约束的就往后找合适的替换。这个算法简单但要小心死循环——如果候选池里某个品类占比过高可能怎么找都找不到合规的必须设置兜底逻辑比如遍历完一轮后放宽约束。除了品类打散还有作者打散、内容形式打散、价格带打散。具体用哪个取决于业务。电商场景里同店铺商品连出容易引起反感内容场景里同作者连出会让用户觉得内容池很浅。我一般会先上线品类打散观察一周的用户反馈和多样性指标再决定要不要加更多维度。加太多约束反而会让排序效果大打折扣因为合规的排列空间被压得很小。去重这块有个细节容易被忽略跨请求去重。同一个用户短时间内多次刷新如果不做跨请求去重会看到大量重复内容。实现上可以用一个短期缓存记录用户最近看过的物品ID重排时降权或过滤。缓存过期时间设多长要看内容池的大小和用户的刷新频率我通常设成10到30分钟。4.2 业务规则注入的正确姿势业务侧总会有各种诉求新品类要扶持、某类内容要控量、特定商品要置顶。这些规则不能直接塞进排序模型模型学不会也学不准正确的位置是在重排层做规则干预。我的做法是给每个物品算一个规则调整分然后和模型分做加权。加权系数要可控且可观测不能让规则把模型彻底压住。下面这套结构我用过几次比较好维护# 重排层打分融合规则分与模型分加权 def final_score(model_score, rule_score, weights): # weights 形如 {model: 0.8, rule: 0.2} return weights[model] * model_score weights[rule] * rule_score关键在于权重的可观测性。每次规则调整都要能回答这条规则影响了多少曝光、带来了多少额外点击。如果答不上来规则就会越加越多最后整个列表变成人工编排模型完全失效。我见过一个项目重排层堆了二十多条规则后来做归因发现其中十五条的净收益是负的全部下线后整体指标反而涨了。注意规则干预要有熔断。比如扶持新内容时设置曝光上限达到上限就自动退出扶持池否则新内容会长期占用优质位置老内容拿不到流量生态会失衡。4.3 冷启动与流量调控的实操冷启动分两种新用户和新物品。两者的处理方式完全不同。新用户的问题是没有任何行为数据召回和排序都无从下手。常见做法是先用热门和高质内容兜底同时通过引导页让用户选择兴趣标签快速积累第一批画像。引导页的选项不要太多8到12个就够太多用户会直接跳过。另外新用户的前几次交互权重应该放大因为这几步最能反映真实意图。我一般会给新用户的行为做时间衰减加权最近行为的权重是历史行为的3到5倍。新物品的问题是曝光不足模型没有数据可学。行业里通用的做法是给新物品分配一个探索流量池比如总流量的5%到10%在池内做随机或半随机曝光积累初始反馈。探索流量的分配策略可以用汤普森采样这类方法让表现好的新物品快速获得更多曝光表现差的及时止损。这里有个必须警惕的陷阱探索流量会拉低短期指标。如果只看当天点击率探索一定是亏的。所以评估体系里必须有长期指标比如新物品的七日存活率、内容池的丰富度。否则探索流量永远第一个被砍掉系统会逐渐僵化成一个只推爆款的机器。5. 线上工程实现从离线训练到在线服务5.1 训练与推理的特征一致性保障前面提过特征口径问题这里展开讲怎么系统性解决。核心思路是特征定义唯一化所有特征在特征平台上注册包含计算逻辑、数据类型、更新频率、默认值离线和在线都通过平台取数不允许业务代码自己拼SQL或自己写计算逻辑。这个约束听起来很重但收益很大。第一口径天然一致不会出现两边算法不同的问题。第二特征可复用新模型接入时直接选特征就行不用重写。第三可追溯某个特征出问题能快速定位影响范围。落地时的难点在于在线取数的性能。特征平台的在线服务必须支持批量取一次请求拿回所有特征并且要有本地缓存和降级策略。降级策略很关键如果特征服务超时是返回默认值继续算还是直接走兜底链路我的选择是核心特征超时走兜底链路非核心特征超时用默认值。核心和非核心的划分要在接入时就标好不能等出事再判断。另外训练样本的生成时间点也要严格对齐。特征必须用预测时刻之前的数据任何用到未来信息的特征都叫特征穿越是线上事故的头号来源。举个典型例子用物品的最终总点击量作为特征这个值在训练时是完整的但在预测时刻根本拿不到。这类特征在离线评估时表现得极其漂亮上线后直接崩盘。排查方法是看特征的重要度排名如果某个特征重要度高得离谱先怀疑它穿越了。5.2 在线服务链路与延迟预算分配在线服务的延迟预算要提前分配好并且留足余量。以一个200毫秒的总预算为例我通常这么切环节预算说明请求解析与参数校验5ms固定开销用户画像与上下文特征15ms走缓存多路召回50ms各路并行召回结果合并去重10ms内存操作排序特征拼接40ms批量取数排序模型推理60ms批量推理重排与规则15ms纯计算序列化与返回5ms固定开销加起来200毫秒实际留出的余量靠并行化来挤。召回路必须并行用线程池或异步IO串行执行的话五路召回就是250毫秒直接爆预算。排序特征拼接也要并行用户侧特征和物品侧特征可以同时取。还有一点超时控制必须是链路级的。每一层都要设置自己的超时时间超时后走降级逻辑而不是一直等。我见过没做超时控制的实现下游某个服务抖动整个推荐接口挂住前端转圈用户体验直接崩掉。降级逻辑一般是召回超时就少一路排序超时就退化成按热门分排序重排超时就跳过规则直接返回。5.3 AB实验与效果评估的正确方法模型上线前必须做AB实验这一点没什么争议但实验设计里的坑很多。第一是分流要正交。召回实验和排序实验如果共用一套分流会互相干扰结论不可信。正确的做法是用不同的分流层各自独立随机。第二是样本量要算够。假设基线点击率是5%想检测出0.2个百分点的提升按常规的显著性水平每组需要的样本量大概在几十万曝光量级。如果实验只跑了一天样本量不够波动就能盖过真实效果。我一般会要求实验至少跑满七天覆盖完整的工作日和周末周期。第三是核心指标要提前定好不能事后挑。实验跑完发现点击率没涨就去看人均时长涨了没这属于事后诸葛亮容易得出错误结论。我的习惯是每个实验开跑前就写清楚主指标是什么护栏指标是什么达到什么程度算成功。护栏指标尤其重要比如推荐实验里内容多样性、用户负反馈率、举报率都必须监控避免为了点击率牺牲体验。第四别只看均值。实验组和对照组的差异可能只集中在某个用户分桶里整体看没差异拆开看新用户涨了20%、老用户跌了5%这种情况如果不拆桶分析就完全错过了。所以我每次看实验结果都会按新老用户、活跃度、品类偏好拆一遍。6. 常见问题与排查技巧实录6.1 效果不涨反跌的排查顺序模型迭代后指标下跌是最常见也最让人焦虑的场景。我的排查顺序是从数据往模型倒着查因为数据问题的概率远高于模型问题。第一步查样本。样本量有没有异常波动正负样本比例是否和之前一致样本的时间分布有没有断层。有一次我们发现线上效果突然掉了最后查出来是样本生成任务在某个时间段失败导致那几天的训练样本缺了一大块。第二步查特征。特征的缺失率、均值、分布有没有变化。特征缺失率突然从1%涨到20%模型效果必然受影响。这类问题在特征平台上有监控就能秒级发现没有监控就只能靠猜。第三步查线上线下一致性。拿一批相同的请求分别走离线和在线链路对比特征值和模型输出差异超过阈值就是有问题。第四步才查模型。模型结构改动、超参数调整、训练轮次变化这些因素放在最后是因为它们的排查成本最高但出问题的概率最低。排查步骤检查项常见异常样本量级、正负比、时间分布任务失败导致断层特征缺失率、均值、分位数上游任务延迟或口径变更一致性离线在线特征值对比计算逻辑不一致模型结构、超参、训练轮次过拟合或欠拟合6.2 特征穿越与样本泄漏的识别方法特征穿越的识别有个简单有效的办法把训练集按时间切成前后两段用前段训练、后段验证。如果某个特征在这个划分下表现异常好好到不合常理基本可以判定是穿越。比如一个特征单独用就能把AUC做到0.9那它一定用了未来信息。另一个办法是看特征在预测时刻的可获取性。具体做法是把特征按生成时间分类凡是生成时间晚于预测时刻的特征一律剔除。这个检查最好做成自动化的在特征注册时就要求填写最早可用时间模型训练时自动过滤不满足时序约束的特征。样本泄漏还有种隐蔽形式同一个用户在训练集和验证集里都出现模型实际上记住了这个用户。对于用户量不大的场景这个泄漏会严重影响评估的可靠性。解决办法是按用户划分数据集同一个用户的所有样本只出现在一个集合里。6.3 线上延迟抖动与超时的定位手段延迟问题最麻烦的地方是它往往是间歇性的排查时它又不出现了。我的经验是提前埋好链路级的耗时打点每一层的耗时都上报这样出问题时能直接看是哪一层抖了。定位时的顺序是先看是不是全链路都慢还是某一层慢。全链路慢通常是流量突增或者下游依赖故障单层慢就是这一层自己的问题。召回层慢先看检索服务的QPS和响应时间排序层慢先看特征取数的耗时和模型推理的批量大小。有个容易被忽略的点是慢查询的长尾。平均延迟100毫秒看着很正常但P99可能是800毫秒这部分用户的体验极差。所以监控必须看分位数不能只看均值。我一般会同时盯P50、P95、P99三个数P99超过预算的两倍就要告警。还有个实操技巧在压测环境里人为放大流量观察系统在极限下的表现。很多问题只有在高负载时才暴露比如线程池打满、连接数耗尽、缓存击穿。这些事情在低峰期永远测不出来等大促当天才爆发就晚了。最后分享一个我在实际项目里反复验证过的做法把每一次线上问题都沉淀成一条监控规则或者一个自动化检查项。推荐系统的链路太长靠人盯是盯不住的只有把踩过的坑变成系统的一部分才能保证下次不再踩。这个习惯坚持两三个季度之后你会发现团队的排查效率会有质的变化因为大部分问题在用户感知到之前就已经被拦下来了。
阅读完成 · 觉得有帮助?
咨询建站