Vector Sets are part of Redis

Salvatore Sanfilippo

Vector Sets 已成为 Redis 的一部分

原文由 Salvatore Sanfilippo 发布,订阅该博客

昨天,我们终于将 Vector Sets 合并进了 Redis,关于它的详细说明,你可以在 README 中找到:

https://github.com/redis/redis/blob/unstable/modules/vector-sets/README.md

简而言之,这个新数据结构的目标是创建一个类似 Set 的全新数据类型,与 Sorted Sets 相似,只不过分数不再是标量,而是一个向量。你可以像操作 Redis 其他数据结构一样添加和删除元素,无需关心其他任何事情,只需关注 Redis 所实现的抽象数据结构的特性,还可以查询与给定查询向量(或集合中已有元素所关联的向量)相似的元素,等等。不过这些稍后再细说,先讲点背景:

从 README 所在的路径就能看出,实现位于“modules”目录下,但实际上 Vector Sets 并不是一个模块,而是 Redis 核心的一部分。事情是这样的:我一开始是把它作为模块来开发的,后来我提议即便并入核心,实现上也依然使用模块 API,以此来推动 Redis 内部的模块化,从而兼具两方面的好处:从 Redis 8 开始,每个 Redis 实例都会将 Vector Sets 作为原生数据类型提供,同时核心与具体实现之间又有着清晰的边界。

时隔多时,Redis 的首个全新核心数据类型

我记得 Redis 上一个重大的数据结构是 Streams,也是我开发的。期间我离职又回归,中间还发生了分叉,而为 Redis 引入新数据类型的重担似乎还是落在了我身上 :D 不过我倒也乐在其中,因为我不仅喜欢编程,也非常喜欢设计,我总觉得向量和向量相似度在概念上非常简单,就应该配上一个极其简洁的 API。这正是我努力的方向。Vector Sets 目前仍处于 Beta 阶段,但有一点我可以保证:花 3 分钟就能学会它的 API。

我认为,实现向量相似度的一个基本前提,就是从零开始重新实现 HNSW(你可以在 hnsw.c 中看到我的实现),因为这将是我的核心数据结构,我不想随便从 GitHub 上抓一段代码就凑合用。不过,随着我开始阅读相关论文,我逐渐意识到还有一些环节是缺失的。

所以,就像当年实现 HyperLogLog 时我需要填补一些空白(见:https://antirez.com/news/75)一样,这次也遇到了一些新的算法挑战。尤其是我想要实现两点:

  1. 实现节点的真正删除。在 Vector Sets 中,你可以用 VADD 添加新元素,用 VREM 删除元素。我希望内存在删除后能尽快被回收。
  2. 确保在删除元素时,HNSW 图的连通性得以保持。

因此,相比其他 HNSW 实现,我的做法有几点不同。我没有采用墓碑删除,而是在删除发生的那一刻就真正将节点从图中解链,并将其与其它合适的候选邻居重新连接起来。为此,我的实现强制要求连接必须是双向的,这不再只是一个尽力而为的特性:这一点也反过来在很大程度上改变了插入时需要做的事情。

我对 HNSW 做的另一个改动,是支持通过谓词函数来扫描图,这样你就可以查询符合特定表达式的节点。这需要在一定程度上修改贪心的图扫描算法:既要收集待访问的候选节点,也要收集结果集,同时还要在查询选择性过高时具备提前终止的条件。当然,我们可不想触发全图扫描。

除了对 HNSW 的这些改动,我还想要一些更务实、更直观的特性:

  1. 对所有向量相似度请求进行多线程处理。是的,这在 Redis 领域算是新鲜事,但尽管我总体上认为单线程、无共享是一个优秀的设计,我还是觉得向量是个例外。它们很慢,比 Redis 中其他数据结构慢得多。额外的一点是,在实现多线程的 VSIM(执行向量相似度查询的命令)时,我还发现通过一些技巧,可以将写入操作拆分为读、写两半,让邻居候选的收集在后台进行,而真正的插入在前台执行。不过这种拆分并非默认行为,需要通过 VADD 的 CAS 选项来显式开启。
  2. 希望支持量化,甚至将其作为默认选项。因此 Vector Sets 同时提供了 8 位量化和二进制量化,还支持通过随机投影进行降维。不过,尽管我很喜欢随机投影和二进制量化,但对我而言真正的“杀手锏”是 int8 量化。它们速度极快,仅占用 FP32 25% 的内存,而对于大多数由嵌入模型生成的向量,其结果与使用完整向量的结果几乎一致。

顺便说一句,我认为最终的实现速度非常快。比如在我的机器上,对一个包含 300 万个条目、每个向量 300 维的向量集合,在我的笔记本上每秒可以执行 5 到 6 万次 VSIM(取前 10 条)查询。当然,我还是建议你自己跑跑基准测试。

另外需要注意的是,Vector Sets 在磁盘上是以图的形式序列化的,因此在 Redis 重启后重新加载到内存时,你无需再付出插入时的开销:每加载一百万个元素只需几秒钟,而不是像重新插入到内存 HNSW 中那样需要数分钟。

是数据结构,而非索引

前面说的都是底层的东西。但对我来说,Vector Sets 最有意思的部分是它的数据模型和与之配套的 API。许多数据库将向量相似度作为一种索引来提供,但在 Redis 这里,万物皆是数据结构,这次也不例外。你可以这样添加数据:

VADD mykey FP32 …blob of data… item1

诸如此类。所以如果你愿意,可以拥有许多小的向量集合,每个键对应一个。重要的一点是,如果你把向量分散到 N 个不同的键中(通过对要插入的条目做哈希等方式来选择键),那么你可以将针对不同键的多次 VSIM 调用结果合并为一次返回:

VSIM word_embeddings_int8 ele "banana" WITHSCORES COUNT 4
1) "banana"
2) "0.9997616112232208"
3) "bananas"
4) "0.8758847117424011"
5) "pineapple"
6) "0.8288004100322723"
7) "mango"
8) "0.8179697692394257"

如果我从不同的键和实例中拿到这样几份结果,只需按分数排序即可(分数为 1 表示完全相同,0 表示向量相反),就这么简单。

所以,我的感觉是 Vector Sets 可以组合出不同的使用模式,来处理大量向量(它们会占用不少内存)分散到不同实例等场景。还有一点很有意思:拆分后写入可以线性扩展,因为每个子集都会落到特定的键上,多个插入可以并行进行。

和往常一样,Redis 社区很可能会发掘出许多现在还不明显的使用模式。

过滤是如何工作的

说到过滤,如果说多线程在 Redis 中还不常见,那 JSON 就更不用想了!但这一次,我第一次找到了一个充分的理由,要在面向用户的 Redis API 中直接暴露 JSON:

VGETATTR word_embeddings_int8 banana
{"len": 6}

所以,基本上通过 VSETATTR / VGETATTR(以及在 VADD 添加条目时直接设置 JSON 属性的等效选项),你可以为指定的条目关联一个字符串。

然后你就可以做这样的事情:

VSIM word_embeddings_int8 ele "banana" FILTER ".len == 3"
 1) "yam"
 2) "pea"
 3) "fig"
 4) "rum"
 5) "ube"
 6) "oat"
 7) "nut"
 8) "gum"
 9) "soy"
10) "pua"

过滤表达式并不是一门编程语言,它就像你在高级编程语言的 if() 语句里写的那样,支持 &&、|| 以及所有常见的运算符等等(不过我敢说我们以后还会再加一些)。

好了,详情都在文档里,那里还有内存用量的示例、对特定功能的深入讨论等等。我希望很快会进一步完善文档。现在,我真的(真的!)希望你会喜欢 Vector Sets。如果你发现了 bug,请随时告诉我 :)

本文章由 muse-spark-1.2-contributor 进行翻译

评论