Vector Sets are part of Redis

Salvatore Sanfilippo

Vector Sets 正式成为 Redis 的一部分

昨天,我们终于将 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 并不是一个 module(模块),它是 Redis 核心的一部分。情况是这样的:我最初是以模块的形式开发它的,后来我建议实现仍然使用模块 API,以此推动 Redis 内部的模块化。这样就能同时获得两个好处:从 Redis 8 开始,每个 Redis 实例都将原生拥有 Vector Sets 这一数据类型,同时核心与实现之间也有清晰的边界。

Redis 时隔……一段时间之后的首个新主数据类型

我认为 Redis 上一个重要的数据结构是 Streams,同样也是我开发的。我辞职过,后来又回归了,期间还发生了分叉事件,但看起来在 Redis 中引入新数据类型的重担仍然落在我身上:D 我必须说:我对此没有意见,因为虽然我热爱编程,我也非常热爱设计。我有一种直觉:向量和向量相似度在概念上非常简单,因此它们理应配上一套非常简单的 API。这正是我努力做到的。Vector Sets 目前仍处于 beta 阶段,但我可以告诉你,我敢保证你能在 3 分钟内学会这套 API。

我决定,实现向量相似度的一个基本要求是从零开始重新实现 HNSW(分层可导航小世界图),因为这将是我的核心数据结构,而我不想随便从 GitHub 上抓一段代码就凑合用了。然而,当我开始阅读相关论文时,我发现还有几块拼图是缺失的。

于是,就像我过去处理 HyperLogLog 时不得不填补一些空白那样(见这里:https://antirez.com/news/75),这次同样存在一些新的算法挑战。尤其我想要做到两点:

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

这导致我的实现与其他 HNSW 实现存在一些差异。我不使用墓碑式删除(tombstone deletion),而是在节点被删除的那一刻就真正解除其链接,并将其与其他潜在的良好邻居重新建立链接。要做到这一点,我的实现被设计为强制要求链接必须是双向互惠的,这不再是一种尽力而为的性质:这又相当程度地改变了插入时需要做的事情。

我对 HNSW 的另一处修改是支持使用谓词函数扫描图,这样你就可以查询匹配给定表达式的节点。这需要以某种方式修改贪心图扫描算法:既要收集待访问的候选节点、收集结果集,还要在查询的选择性过高时设置提前终止条件。当然,我们不希望触发全图扫描。

除了对 HNSW 的修改之外,我还想要一些更务实、更显而易见的东西:

  1. 对所有向量相似度请求进行多线程处理。是的,这在 Redis 领域是新鲜事,尽管我总体上仍然相信单线程和 shared-nothing 是一个好的设计,但我认为向量是特殊的。它们很慢,比 Redis 所建模的其他数据结构慢得多。另外还有一个意外收获:在实现多线程的 VSIM(执行向量相似度查询的命令)时,我还发现借助一些技巧,可以把写操作拆分为读半段和写半段两个部分,让邻居候选的收集在后台进行,而实际的插入在前台执行。不过这种拆分并不是默认行为,你需要用 VADD 的 CAS 选项来强制启用。
  2. 我希望支持 quantization(量化),甚至让它成为默认行为。因此 Vector Sets 同时自带 8 位量化和二值量化,还支持用于降维的随机投影(random projection)。不过,虽然我喜欢随机投影和二值量化,但现实是,对我来说真正的“杀手锏”是 int8 量化。它非常快,只占用 FP32 25% 的内存,而且对于大多数通过 AI 嵌入模型生成的向量来说,结果与完整向量几乎完全一致。

顺便说一句,我相信最终的成果是一个非常快的实现。例如在我的机器上,一个包含 300 万个元素、每个元素 300 个分量的向量集合,在我的笔记本上每秒可以执行 5 万到 6 万次 VSIM(返回 top 10 结果)。不过我还是鼓励你亲自做基准测试。

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

是数据结构,而不是索引

到目前为止我讲的都是底层的东西。但对我来说,Vector Sets 最有趣的部分是它的数据模型以及支持该模型的 API。许多数据库将向量相似度作为一种索引来提供,但这是 Redis,Redis 中的一切都是数据结构:这次也不例外。你可以这样添加数据:

VADD mykey FP32 …blob of data… item1

诸如此类。因此,如果你愿意,可以拥有许多小的向量集合,每个 key 一个。这里很重要的一点是:如果你把向量拆分到 N 个不同的 key 中(通过对要插入的元素进行哈希等方式来选择 key),那么你就可以把针对不同 key 的多次 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"

如果我拿到了来自不同 key 和实例的若干这样的结果,只需按分数排序即可(分数为 1 表示完全相同,0 表示方向相反的向量),就这么简单。

所以,我的感觉是,Vector Sets 可以组合成不同的模式,以应对在多个实例中存放大量向量(它们会占用相当多的内存)等场景。另外有趣的是,拆分还能线性地扩展写入,因为每个子集只会命中某个特定的 key,多个插入可以并行进行。

一如既往,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,请随时联系我 :)