杨磊(易禛)
前言
推荐系统通常可以分为召回、粗排、精排、重排四个阶段,召回主要是根据不同策略或模型从海量的物品库中快速筛选出一小部分用户可能感兴趣的物品,交由排序模型来精准地完成个性化排序,本文主要阐述召回在算法侧的工作原理以及召回系统工程侧落地实践。
召回算法
目前海拍客算法团队在使用传统算法如ItemCF、Swing的基础上,也在不断探索深度模型在召回中的应用,下面章节将介绍几种常用召回算法以及算法侧工作流程。
协同过滤
作为在推荐系统召回中最基本的一种算法,系统过滤主要分为两大类
- 基于用户的协同过滤算法(UserCF)
- 基于物品的协同过滤算法(ItemCF)
基于用户的协同过滤UserCF
简单的说基于用户的协同过滤就是找到与你相似的用户,将相似用户交互过的商品推荐给你。基于用户的协同过滤核心在于计算用户之间的相似度,以一个简单的例子演示该算法。在首页推荐场景中,用户A点击的商品集合为M,用户B点击的商品集合为N,那么用户A和用户B的相似度可以通过如下公式计算
如用户A点击过的商品集合M={a,b,c,d} , 用户B点击过的商品集合N={a,b,c,d,e,f} ,所以用户A和用户B的相似度为2/3,用户C点击过的商品集合为{a,b,c} ,用户D点击过的商品集合为{a,b},此处我们人为定义相似度阈值为0.5,那么与用户A相似的用户为用户B和用户C,那么可以给用户A推荐商品{e,f},给用户C推荐{d,e,f}。 这里只是展示了一种比较简单粗暴的方法,想要得到更好的推荐效果可以改进相似度的计算方法,这边不作深究。
基于物品的协同过滤
简单来说基于物品的协同过滤就是根据用户对商品的交互历史,推荐该商品相似的其他商品。基于物品的协同过滤同样需要计算物品的相似度,以一个简单的例子演示该算法。假设喜欢物品a的用户数量为N(a),喜欢物品b的用户数量为N(b) ,那么物品a和物品b的相似度可以使用如下公式表示
假设用户对商品的喜好如下表所示
那么根据上述的相似度计算公式可以得到ab相似度为 0.71 ,ac相似度为0.58,bc相似度为0.82,cd相似度为0.82。我们人为假定某个用户对商品a有购买行为兴趣度为10,对b商品有点击行为兴趣度为5,那么我们可以得到该用户对商品c的兴趣度为 10 * 0.58 + 5 * 0.82 = 9.9,对d的兴趣度为0 ,因此可以给该用户推荐商品c
双塔模型
双塔模型是一种在推荐领域召回、粗排阶段被广泛使用的深度学习模型,其结构非常简单如下图所示
左侧是User塔,右侧是Item塔,可将特征拆分为两大类:用户相关特征(用户基本信息、群体统计属性以及行为过的Item序列等)与Item相关特征(Item基本信息、属性信息等),原则上,Context上下文特征可以放入用户侧塔。对于这两个塔本身,则是经典的DNN模型,从特征OneHot到特征Embedding,再经过几层MLP隐层,两个塔分别输出用户Embedding和Item Embedding编码。
离线训练阶段,User Embedding和Item Embedding做内积或者Cosine相似度计算,使得用户和正例Item在Embedding空间更接近,和负例Item在Embedding空间距离拉远,损失函数则可用标准交叉熵损失。
在线服务阶段,对于海量的Item集合,可以通过Item侧塔,离线将所有Item转化成Embedding,并存储进ANN检索系统如Faiss以供查询。当一个用户进行请求时,将用户相关特征作为User塔的输入计算出User Embedding,从Faiss中获取相似性得分Top K的Item,做为个性化召回结果,这种方式可以实时地体现用户兴趣的变化,实践起来也相对简单。
本章小结
目前算法侧通过离线的方式训练召回数据,存储到对应的数据源中,工程侧需要通过召回配置完成多路召回-去重-过滤-融合的操作,将召回结果送到下一阶段进行个性化排序,对整个推荐链路而言,召回要求快,排序要求准,所以对召回系统的要求是稳定低延迟。
召回工程
海拍客推荐目前服务触达、首页、搜索激活页、支付成功页、 我的页面等多个场景,涵盖了购前、购中、购后等多个不同阶段。如上面所述,商品召回作为推荐的第一步在整个推荐流程中起到了举足轻重的作用,直接影响了返回物料的质量,而合理的召回工程架构也一定程度上影响业务和算法迭代的速度与质量。
召回初代架构
由于业务的特殊性以及诸多历史遗留问题,在之前海拍客的推荐架构中各业务系统需要各自完成商品召回,再按需调用精排服务完成商品排序等后续操作,召回数据源又各有不同,如触达业务使用了MySQL作为召回数据源,推荐业务使用了Redis作为主要的召回数据源,所涉及到的架构大致如下图所示
架构局限性
这种架构在业务的迭代中也逐渐出现各种弊端
- 职责不清晰, 从整个推荐域看,这种架构增加了业务系统复杂度使得业务系统过重,系统和系统之间架构职责不清晰
- 扩展性差,依赖各业务系统各自完成召回动作,而商品召回本身并不是一个简单动作,需要完成召回-过滤-融合等一系列动作。除此以外,数据源的新增变更、数据结构的变化带来的适配工作也是一个让人头疼的问题
- 召回配置难,这一点是对算法同学而言的,原有架构缺少一个统一的召回配置平台,AB实验的进行和验证受到影响,妨碍算法迭代效率提升
- 稳定性难保障,对平台开发同学而言,日常需要监控依赖数据源、召回的各个阶段,如数据源平均rt,各阶段平均rt、超时率,召回整体的兜底率等指标,业务系统越多越不利于监控,系统的稳定性也会大打折扣
召回系统设计
基于如上所述的诸多缺点,我们着手完成了推荐召回的服务切分,使得触达、首页推荐、购后等多个场景的推荐业务召回部分能够得到统一
主体设计
一般来说,整个召回阶段主要完成多路召回-过滤-去重-融合几个步骤,大致的作用如下
- 多路召回,根据配置采用不同的召回策略从不同的数据源中获取物料,原则上多路召回尽可能多的返回用户可能有兴趣的物料,通常会根据每路召回的后验表现来设置配比。
- 去重,这一步主要是针对每一路召回而言,通常只是简单的根据商品id或者一些简单属性去重,避免因为数据源清洗问题导致的重复曝光。
- 过滤,通常会存在一些不同的过滤规则,如用户维度的曝光过滤、点击过滤、购买过滤等,也可能是基于风控规则的过滤,如卖家作弊等处罚、黄图恶心图等过滤。
- 融合,多路召回的物料根据需要进行合并,截断选取若干物料进入下一阶段,可以按照召回策略优先级融合,也可以是多路召回投票融合,也可以是通过物料的指标权重融合。
针对上述召回系统的几个步骤和特性,我们设计了如下的系统架构,整个召回系统大体上可以分为三层,召回配置层、召回引擎层、数据依赖层。
- 召回配置层
召回配置层主要面向算法同学,旨在让算法同学方便快捷地进行召回层配置进行AB实验和后续的结果验证,在实现上,主要借助Disconf作为配置中心,在引擎层做AB分流、召回配置解析等前置操作。
- 召回引擎层
召回引擎层采用模块化设计,通过召回配置实现模块和任务节点的动态化编排,同时通过插件化思想提供了异构数据源的支持,大大降低了新增数据源的成本。在稳定性保障上,由于采用了模块-任务节点的设计,能够很好地实现多维度的监控,如场景-模块维度的rt监控、失败率监控,场景-任务节点维度的超时率监控,场景维度的兜底率、无结果率监控等,同时配合钉钉告警实现问题的早发现早止血。
- 召回存储层
召回存储层主要面向异构数据源设计,前面提到因为历史原因现有的召回数据源结构和存储介质都存在差异以满足不同业务系统的诉求,因此在改造召回系统的过程中需要充分考虑数据存储的问题,如首页推荐等场景召回数据结构相对简单,需要满足低延迟诉求所以一直以来优先考虑Redis, 又如触达算法召回数据结构复杂,包含属性多,数据量大用MySQL或者MongoDB更合适些,又如后续业务可能存在向量召回的场景,使用ElasticSearch或者调用faiss服务更合适,因此必须要考虑异构数据源的接入便捷性。
实现细节
这一节主要介绍下召回引擎部分的一些实现细节以及踩过的一些坑,整个召回召回引擎调度如下图所示
- 模块化设计
如上述,整个召回过程中大体经过了多路召回-去重-过滤-融合等步骤。在设计上可以将各个步骤封装成独立的模块(module),AB分流获取门店对应的召回配置后,根据配置编排所需要的模块完成调度。而模块和模块之间又可能存在依赖关系,如在召回系统中各模块之间是串行的,而在一些系统中存在模块并发执行的需求,所以在设计之初设计了如下结构来做兼容,简单来说同一个列表内多个模块并发执行,不同列表的模块串行执行。
在模块内,抽象出任务节点(TaskNode),如在多路召回模块中,每一路召回相当于一个任务节点,彼此并发执行,又比如在过滤模块中,每一种过滤策略可以当做一个任务节点获取待过滤数据。通过任务节点并发的方式可以有效降低RT,相较于之前串行召回的方式平均减少RT约16%。如下代码大致演示了模块内任务节点执行的实现。
/**
* 模块执行只需要执行任务列表中的任务即可
* 任务列表中的任务先暂时都并发执行 后面可以支持并发和顺序两种模式
* 即 [[A,B],[C]] A、B并发执行完成后再执行C
*
* @param requestContext 召回请求上下文
* @return recallModuleResultDto 召回模块结果对象
*/
@Override
public ModuleResult invoke(RequestContext requestContext) {
RecallStrategyConfig recallStrategyConfig = requestContext.getRecallStrategyConfig();
if (recallStrategyConfig == null) {
return null;
}
ModuleResult moduleResult = new ModuleResult();
moduleResult.setTaskResultList(new ArrayList<>());
if (CollectionUtils.isEmpty(this.taskList)) {
return moduleResult;
}
// 并发执行任务列表中的任务 使用arrayList保留任务原始的顺序
List<Tuple<TaskNode, CompletableFuture<TaskResult>>> tupleList = new ArrayList<>();
for (TaskNode taskNode : taskList) {
CompletableFuture<TaskResult> future = CompletableFuture.supplyAsync(() ->
taskNode.invoke(requestContext), ThreadUtil.executor);
tupleList.add(new Tuple<>(taskNode, future));
}
// 结果获取 省略...
return moduleResult;
}
模块内多个任务节点并发执行有一些需要关注的点,在实现中可能需要特别关注。
a.任务节点超时处理, 首先任务节点必须配置超时时间,避免因为某一个任务节点引起的服务雪崩,其次不同的任务节点其超时时间可以根据经验做配置化,如多路召回中MongoDB和Redis的超时时间可以根据监控做动态化调整,再比如对于无执行先后顺序要求的任务节点可以适当调整获取结果顺序以避免空结果等。
b.召回结果在不同阶段的传递,在目前的召回引擎实现中采用了上下文的方式传递召回中间结果,上下文中会保存不同阶段的模块结果(其中包含各阶段的召回结果),保存不同阶段召回结果即每个阶段召回结果均为深拷贝以避免对上一个模块结果的破坏。
c.空结果处理, 在实际运行中可能存在各种情况导致召回结果为空,此时需要进行服务端兜底召回,除此以外当常规召回数量不够也需要使用兜底数据补召回,兜底召回数据请求实际也需要权衡,目前的实现中把兜底召回作为一路召回在多路并发召回阶段执行,通过多一次IO来降低服务的总RT(兜底召回不一定被使用)
- 异构数据源支持
召回引擎提供了对多种数据源的支持,目前已支持如Redis、MySQL、MongoDB、RPC等多种数据源。以触达算法召回为例,数据量大且字段属性多,非常适合以MongoDB作为存储数据源,而首页推荐、购后推荐等场景的召回数据大多结构简单且要求召回速度快,因此以Redis作为存储数据源更合适。
常规的单路召回需要经过获取数据-去重-截断-类型转换等步骤,其中获取数据和类型转换需要根据数据源类型做适配,因此在设计之初采用插件的方式支持异构数据源,只要实现几个简单方法就可以完成数据源的新增,如下代码大致展示了数据源插件的抽象定义
public abstract class AbstractRecaller<OriginType, ResultType> implements Recaller<OriginType, ResultType> {
//部分代码省略
@Override
public ResultType recall(RequestContext requestContext, RecallSourceConfig recallSourceConfig) {
// 获取数据源数据
OriginType originData = fetch(requestContext, recallSourceConfig);
// 去重
originData = distinct(originData);
// 截断
originData = cut(originData, recallSourceConfig.getLen());
// 转换
ResultType result = convert(originData, recallSourceConfig);
return result;
}
}
以一个实际的单路召回为例,召回策略配置如下,表示该路召回策略存储数据源为MongoDB,召回类型为s2i,召回的主键标识为act_new_sign_,同时需要返回对应文档的指定字段
mongo_act_new_sign:
dataSource: mongo
fields: itemId,shopId,feature,rankNum,orderedDays
key: act_new_sign_
type: recall_s2i
在运行的过程中召回引擎先会根据召回类型、绑定的数据源元信息等匹配对应的召回器Recaller, 召回器的注册使用自定义注解@AiRecaller 完成,如下代码即为以MongoDB作为数据源的接入方式。需要说明的是目前召回引擎多以离线方式计算落库,实时召回也多以用户实时Query等作为trigger,但现有的召回系统架构可以快速支持如基于向量的实时召回等方式,这也是后续召回引擎一个迭代的方向。
@AiRecaller(
name = "touchMongoRecaller",
dataSourceType = RecallDataSourceType.mongo, recallTypes = {RecallType.recall_s2i},
dbName = "ai_recall", tableName = "ai_touch"
)
public class TouchMongoRecaller extends AbstractRecaller<List<TouchRecallDo>, TriggerResult> {
@Resource
private MongoTemplate mongoTemplate;
@Override
public List<TouchRecallDo> fetch(RequestContext requestContext, RecallSourceConfig recallSourceConfig) {
String sceneId = requestContext.getRecallReq().getSceneId();
String recallKey = recallSourceConfig.getKey();
String shopId = requestContext.getRecallReq().getShopId();
// 查询
List<TouchRecallDo> touchRecallDos = null;
Query query = new Query();
// TODO 这边可以结合管理后台实现查询条件的配置化
query.addCriteria(Criteria.where("shop_id").is(shopId))
.addCriteria(Criteria.where("recall_key").is(recallKey))
.with(Sort.by(Sort.Order.asc("rank_num")));
try {
touchRecallDos = mongoTemplate.find(query, TouchRecallDo.class);
} catch (Exception e) {
logger.error("[TouchMongoRecaller-fetch] sceneId:{} recallKey:{} shopId:{} exception cause:",
sceneId, recallKey, shopId, e);
}
return touchRecallDos;
}
总结和展望
如上文提及,目前算法侧的召回数据均以离线方式产出落库,因此对实时特征的利用相对不足。后续召回引擎可以结合特征平台建设,尝试基于ElasticSearch或者Faiss的在线检索召回服务。