随着互联网数据规模的爆炸式增长,当前主流电商平台的商品品类及数量越来越多,用户却越来越难以便捷地找到自己需要的产品。
电商搜索推荐系统的核心作用是根据用户的搜索意图及偏好,从海量商品中检索出合适的商品并展示给用户。在这个过程中,系统需要计算商品与用户的搜索意图及偏好之间的相似性,从而将相似度最高的 TopK 个商品推荐给用户。
商品数据、用户搜索意图、用户偏好等数据都属于非结构化数据。我们尝试使用搜索引擎 Elasticsearch(ES)的 CosineSimilarity (7.x) 计算此类数据的相似度,但这种方式存在以下缺点:
- 计算响应时间较长——检索百万商品并召回 TopK 结果的平均延时在 300 ms 左右。
- ES 索引维护成本较高——商品向量数据和其他相关信息数据都使用同一套索引,不仅不便于索引构建,还导致数据规模变得过于庞大。
我们曾尝试自研局部敏感哈希插件以加速 ES 的 CosineSimilarity 计算,尽管加速后的性能与吞吐量较之前有显著提升,但 100 ms 的延时还是难以满足实际的线上商品检索需求。
经过调研比较,我们决定采用开源向量数据库 Milvus。相较于业界使用的单机版 Faiss,Milvus 的优势在于:支持分布式、多语言 SDK 支持、读写分离等等。
我们通过各种深度学习模型将海量非结构化数据转化成特征向量导入 Milvus。凭借 Milvus 的出色性能,我们搭建的电商搜索推荐系统能够高效地查询出与目标向量相似的 TopK 个向量。
整体架构
如图所示,我们的整体架构主要分为两部分:
- 写入流程:将深度学习模型产生的 item 向量归一化后写入到 MySQL 中,数据同步工具(ETL)读取 MySQL 中的 item 向量并导入向量数据库 Milvus。
- 读取流程:搜索服务根据用户查询关键词和用户画像获取 user 向量,在 Milvus 中查询相似向量并召回 TopK 个 item 向量。
Milvus 支持增量更新和全量更新两种方式。每次增量更新都要删除已有的 item 向量再插入新的 item 向量,这意味着每次更新一个 collection 都必须重新构建索引,更适合读多写少的场景。因此,我们选择全量更新的方式。而且,分批多 partition 写入百万级全量数据只需几分钟,等同于近实时更新。
Milvus 写节点负责所有写操作,包括创建数据集合、构建索引、插入向量等,以写域名对外提供服务。Milvus 读节点负责所有读操作,以只读域名对外提供服务。
由于 Milvus 目前暂不支持 collection 的别名切换,我们通过引入 Redis 在多个全量数据 collection 之间实现别名的无缝切换。
读节点只需要从 MySQL 和 Milvus 数据库以及 GlusterFS 分布式文件系统中读取现有的元数据信息与向量数据或索引,因此可通过部署多个实例来横向扩展读取能力。
方案细节
数据更新
数据更新服务不仅包括写入向量数据,还包括向量的数据量检测、索引构建、查询预热(将索引文件加载到内存)、别名控制等。整体流程如下:
- 假设构建全量数据前,由集合 CollectionA 对外提供数据服务,正在使用的全量数据指向 CollectionA(redis key1 = CollectionA)。构建全量数据的目的是创建一个新的集合 CollectionB。
- 商品数据校验——检验数据库表内商品数据的条数,对比现有 CollectionA 的数据,可基于数量、百分比设置告警。如未达到设定数量(百分比),则不构建全量数据,视为本次构建失败,告警提醒;一旦达到设定数量(百分比),则启动全量构建步骤。
- 开始构建全量——初始化正在构建的全量数据的别名,更新 Redis(更新后,正在构建的全量数据的别名指向 CollectionB:redis key2 = CollectionB)。
- 创建新的全量 collection——判断 CollectionB 是否存在。假如存在,先删除再创建。
- 批量写入——对商品数据的 ID 取模,算出其所在分区的 partitionId,分批将多个分区数据写入新创建的 collection。
- 构建索引和预热——为新 collection 创建索引 createIndex(),索引文件存放在分布式存储服务器 GlusterFS。自动模拟请求查询新 collection,将索引内容加载到内存,实现索引预热。
- Collection 数据检验——检验新 collection 的数据,对比现有 collection 的数据,可基于数量、百分比设置告警。如未达到设定数量(百分比),则不切换 collection,视为本次构建失败,告警提醒。
- 切换 collection——别名控制。更新 Redis 后,正在使用的全量别名指向 CollectionB(redis key1 = CollectionB),同时删除 Redis key2,构建完成。
数据召回
根据用户查询关键词和用户画像获取 user 向量,多次调用 Milvus partition 的数据并计算 user 向量和 item 向量的相似度,汇总后返回 TopK 个 item 向量。整体示意图如下:
下表列出了这一流程涉及到的几个主要服务。可以看出,召回 TopK 个向量的平均延时约 30 ms。
实现过程:
- 根据用户查询关键词和用户画像信息,通过深度学习模型计算得到 user 向量。
- 从 Redis currentInUseKeyRef 获取正在使用的全量数据的 collection 别名,得到 Milvus CollectionName(数据同步服务,做完全量更新数据后,切换别名写入 Redis)。
- 用 user 向量并发异步调用 Milvus,从同一个 collection 的不同 partition 获取数据,Milvus 计算 user 向量与 item 向量的相似度,返回每个 partition 中相似的 TopK 个商品。
- 汇总每个 partition 返回的 TopK 个商品,再将汇总结果按照相似距离的倒序排序(采用 IP 内积计算,距离越大越相似),返回最终的 TopK 个商品。
未来展望
目前,基于 Milvus 的向量召回在推荐场景的搜索中已经能够稳定使用,其高性能使我们在模型的维度和算法选择上有了更大的发挥空间。
后续会有更多场景用到向量的相似性计算,包括主站搜索的召回和全场景推荐等,Milvus 将在其中扮演至关重要的中间件角色。
未来 Milvus 最备受期待的三大功能如下:
- Collection 别名切换的逻辑——不用通过外部组件来协调多个 collection 的切换。
- 过滤机制—— Milvus v0.11.0 仅在单机版支持 ES 的 DSL 过滤机制,希望尽快推出支持读写分离的过滤机制用于向量相关性检查。
- 存储支持 Hadoop Distributed File System(HDFS)——我们使用的 0.10.6 版本只支持 POSIX 文件接口,我们部署了支持 FUSE 的 GlusterFS 作为存储后端,但是 HDFS 无论从性能和扩容便利性上来说都是更优选择。
经验教训与最佳实践
- 对于读操作为主的应用,读写分离部署可大幅增加机器的处理能力,提高性能。
- Milvus Java 客户端没有重连机制,因为召回服务使用的 Milvus 客户端是常驻内存,需要自行建立连接池,通过心跳测试保证 Java 客户端与服务端的连接可用性。
- Milvus 偶尔出现慢查询的情况。经排查,这是由于新 collection 预热不充分。通过模拟请求参数查询新 collection,将索引内容加载到 cache 里以达到索引预热的效果。
- nlist 是建索引参数,nprobe 是查询参数。需要根据自己的业务场景通过压测实验得到合理的阈值,平衡检索系统性能和检索准确率。
- 对于静态数据的场景,先将所有数据导入 collection、后构建索引的做法更高效。
Github @Milvus-io|CSDN @Zilliz Planet|Bilibili @Zilliz-Planet
Zilliz 以重新定义数据科学为愿景,致力于打造一家全球领先的开源技术创新公司,并通过开源和云原生解决方案为企业解锁非结构化数据的隐藏价值。
Zilliz 构建了 Milvus 向量相似度搜索引擎,以加快下一代数据平台的发展。Milvus 目前是 LF AI & Data 基金会的孵化阶段项目,能够管理大量非结构化数据集。我们的技术在新药发现、计算机视觉、推荐引擎、聊天机器人等方面具有广泛的应用。