基于gRPC的Faiss server实践

首发于公众号MXPlayer技术团队,欢迎关注。

Faiss简介

Faiss是Facebook AI团队开源的针对聚类和相似性搜索的开源库,为稠密向量提供高效相似度搜索服务,支持十亿级别向量的搜索,是目前最为成熟的近似近邻搜索库之一。Faiss提供了多种索引类型如L2距离,向量內积等,详细介绍可参考Faiss Indexes,我们可以针对不同大小的向量集和聚类算法选择合适的索引类型。

在mx推荐服务中,目前主要借助Faiss做相似性召回和打分。我们将不同的算法产出item和user的embedding数据,加载到Faiss的索引中,便可实现诸如item-based/user-based等算法。同时,也可以根据业务场景需要,利用Faiss给相应的结果进行打分排序。

服务选型

最初,MX的Faiss server是根据一个开源的用flask框架开发的web server(faiss-web-service)改进而来的,为了尽快推上线做实验,并没有进行详细地设计和优化,因此线上表现不算太好,平均响应时间大概为6ms左右,另外扩展性较差,在添加或删除索引时在很多方面受到框架限制。经充分调研和技术选型,最终决定用RPC框架进行重构。

目前开源的RPC框架有很多,例如Thrift,Dubbo,gRPC,rpcx等,由于mx推荐服务是用java语言开发的,而Faiss目前只支持C++和Python两种语言,考虑到多语言支持的需求,只有gRPC和Thrift满足要求。在调研中发现(参考《流行的rpc框架benchmark》),在处理10ms级业务时,在吞吐和延迟方面,gRPC较Thrift有一定的优势,故最终选择了gRPC框架。

需求分析

1、多类型向量加载

不同算法产出的向量以算法名+item类型+产出的时间戳(版本号)的命名方式存储在s3上,例如S3上键为deepwalk/movie/index.20190821_043002_64的文件是用deepwalk算法产出的movie类型的item的向量。Faiss server需要能加载各个类型的向量构建索引,并根据请求中的算法类型和item类型从对应的索引中进行相似性搜索。

2、多类型索引试验

Faiss提供了丰富的索引类型,我们可以将同一类型的向量加载成不同类型的索引来进行小流量试验,由此来找出效果最好的一组向量类型与索引类型的映射,这里的效果包括推荐结果的指标,Faiss server内存占用率,CPU使用率以及响应时间等。

3、索引方便配置

因为每个索引的线上表现并不相同,随着试验的进行,有些索引需要被下掉,新的算法产出的向量也需要及时做试验,所以Faiss server需要能够灵活地配置索引。

4、索引版本控制

我们在产出item向量的同时也会产出user的向量,推荐系统可以用user向量去召回同一向量空间下的item,但是user向量存储在pika里,产出也会滞后于item向量,也有可能因为某些原因user向量的的写入出现问题,导致推荐系统取出的是旧向量,而Faiss server已经更新成新的索引,因此Faiss server需要能加载同一类型多个版本的向量,这样推荐系统就可以通过指定版本来获取同一向量空间里与user向量近邻的item。

5、索引热更新

不同算法产出的向量每天会不定时的进行更新,Faiss server除了在服务启动时加载最新向量构建索引外,还需要及时用最新的向量更新索引,同时也要能够正常响应请求。

设计与实现

1. Faiss Server

1.1  组织结构


上图为Faiss server的组织结构图。为了满足需求1,2,4,我们将Faiss索引封装在FaissHandler中。FaissHandler包含了5个字段,algorithm_type, category和index_type唯一指定了一个index,由此可以方便支持多向量类型和多索引类型,而index_dict保存了真正的Faiss索引,以向量(索引)版本为key,Faiss索引为value,由此可以方便地支持指定版本的最近邻搜索服务,另外还有一个latest_version字段,用来保存index_dict中最新的版本号,当不指定向量版本进行搜索时将会使用最新版本的索引搜索。

我们可以在HandlerCollection里配置当前需要使用的FaissHandler,索引的更新程序可以通过遍历collection逐个更新索引。另外也满足了需求3,添加、更改或删除都只需要修改一行代码即可,非常方便。而gRPC服务只需要将请求中的参数进行组装,然后路由到对应的handler就行,剩下的操作都由handler完成。

1.2 接口设计

Faiss server需要提供一个搜索最近邻item的RPC接口search,search接口的请求中需要指明具体使用哪个索引,以及目标item的id或者向量,请求体的主要字段如下:

message SearcRequest {
    string algorithmType = 1;
    string category = 2;
    int32 num = 3;
    string indexType = 4;
    repeated string itemId = 5;
    repeated FloatArray vector = 6;
}复制代码

SearchRequest中前四个字段是必须的,因为依赖这四个字段来查找handler,而itemId和vector至少需要一个存在。itemId和vector都用repeated修饰,是为了支持多item,多vector搜索。

message SearchResponse {
    message Str2FloatMap {    
        map<string, float> innerMap = 1;
    }
    map<string, Str2FloatMap> similarItems = 1;
}  复制代码

response很简单,只有一个map,key为目标item或vector(SearchRequest中的),value则为最近邻结果和对一个的分数。

1.3 search流程


2. 索引更新


上图为索引更新的架构图,celery beat每隔2分钟创建一个更新索引的task,要求更新HandlerCollection中的所有handler对应的索引,worker接收到task之后从AWS S3上下载对应的向量文件,加载向量构建索引,然后将索引序列化到文件,Faiss server提供gRPC接口来接收celery的通知(调用),接到通知后直接加载索引文件更新索引。向量的下载和索引的构建都是非常耗时的操作,交由celery完成能够节省server的资源,保证server稳定高效地运行。详细的索引更新流程如下图:


上图讲述了详细的更新流程,但是为了流程的流畅性和可读性隐藏了一些细节。更新操作并非是单线程一路走下来的,因为我们有很多索引,为了尽快更新索引提供服务,在遍历handler collection时,会为每个handler创建一个检查version的celery task,然后celery worker会并发地去执行这些task,针对每一个task,如果真的需要更新,同样创建一个download vector的task并发执行,但是这里有一个问题,就是celery beat每隔2分钟创建一条更新索引的task,但是下载向量是非常耗时的操作,因为向量文件可能很大,如果上一次的更新操作还处于download vector执行中,而这一次也走到了download vector这一步,这样就造成了同时有多个worker在下载相同的向量,这样不仅造成了worker资源的浪费,还造成了网络IO的浪费,所以,在真正下载向量之前,会先尝试获取一个针对具体向量的锁,如果无法获得锁,说明有worker正在执行download vector task,当前worker直接return,结束任务,等待接收其他task。下载完向量之后也是并发的加载索引和通知server更新。

部署

Faiss server是用python语言开发的,由于python具有全局解释器锁无法有效地利用多核CPU,所以我们采用了单机多服务实例的模式进行部署。


线上表现

在上线之前进行了充分的压力测试,结果表明单机QPS比之前高了2倍以上,响应时间降低了大约67%。新服务上线前后在newrelic上的响应时间对比如下图:


总结

基于gRPC的Faiss server是针对业务需求高度定制化的服务,有效地解决了原有服务所存在的各种问题,具有高效率,高可扩展性等特点。


猜你喜欢

转载自juejin.im/post/5d5b8e7be51d4561f777e1b6