在这篇博文中,我们将深入探讨我们为简化 kNN 搜索入门体验所做的努力!
向量搜索概述
通过使用新的专用 knn
搜索类型,Elasticsearch 上的向量搜索已经可用了一段时间,同时我们在 8.12.0
版本中也引入了 kNN 作为查询(更多信息请查看我们最近发布的这篇精彩的博文!)。
虽然每种方法的执行流程和应用有一些区别,但执行基本 kNN 检索的语法非常相似。因此,一个典型的 knn
搜索请求如下所示
GET products/_search
{
"knn": {
"field": "my_vector",
"query_vector": [1, 2, 3],
"k": 5,
"num_candidates": 10
}
}
前几个参数非常直观:我们指定数据存储的位置 (field
) 以及我们要比较的目标 (query_vector
)。
另一方面,k
和 num_candidates
参数有点模糊,需要一些理解才能对其进行微调。它们特定于我们使用的算法和数据结构,即 HNSW,并且主要用于控制我们想要执行的图探索量。
Elasticsearch 文档是关于搜索的所有事物的宝贵资源,因此可以查看我们提供的knn 部分
k
:作为顶级命中返回的最近邻的数量。此值必须小于num_candidates
。
num_candidates
-> 每个分片要考虑的最近邻候选的数量。需要大于k
,或者如果省略k
则为size
,并且不能超过 10,000。Elasticsearch 从每个分片收集num_candidates
个结果,然后将它们合并以找到前k
个结果。增加num_candidates
往往可以提高最终k
个结果的准确性。
但是,当你第一次遇到这样的情况时,这些值的含义并不明显,正确配置它们可能会成为一项挑战。这些值越大,我们将探索的向量就越多,但这会带来性能成本。我们再次遇到了准确性和性能之间始终存在的权衡。
为了使 kNN 搜索更易于使用和更直观,我们决定使这些参数可选,以便你只需要提供搜索的位置和内容,并且只有在真正需要时才对其进行调整。虽然看起来这是一个很小的改动,但这使得事情变得更加清晰!因此,上面的查询现在可以简单地重写为
GET products/_search
{
"knn": {
"field": "my_vector",
"query_vector": [1, 2, 3]
}
}
通过使 k
和 num_candidates
可选来简化 kNN 搜索
所以我们想让 k
和 num_candidates
可选。这很棒!但是我们应该如何设置默认值呢?
此时有两个选择。选择一个看起来不错的选项,发布它,并希望一切顺利,或者进行艰苦的工作并进行广泛的评估,让数据来指导我们的决策。在 Elastic,我们喜欢这样的挑战,并希望确保做出的任何决策都是有充分理由的!
在上面,我们说过 kNN 搜索的 k
是我们从每个分片获得的结果数量,因此这里一个明显的默认值就是使用 size
。因此,每个分片将返回 size
个结果,我们将合并并对它们进行排序以找到全局前 size
个结果。这在没有 k
参数的 kNN 查询中也运行良好,而是基于请求的 size
进行操作(请记住,knn
查询的行为与任何其他查询(如 term
、prefix
等)类似)。因此,size
似乎是一个合理的默认值,可以涵盖大多数用例(或者至少在入门体验期间足够好!)。
另一方面,num_candidates
是一个完全不同的东西。此参数特定于 HNSW
算法,并控制我们将考虑的最近邻队列的大小(对于好奇的人:这等同于原始论文 中的 ef
参数)。
我们可以采用多种方法
- 我们可以考虑每个图的大小,并设计一个函数来计算
N
个索引向量的相应num_candidates
- 我们可以查看底层数据分布并尝试估算所需的探索(也许还可以考虑 HNSW 的入口点)
- 我们可以假设
num_candidates
与索引数据没有直接关系,而是与搜索请求相关,并确保我们将执行必要的探索以提供足够好的结果。
作为开始并为了简单起见,我们研究了相对于 k
(或 size
)设置 num_candidates
值。因此,你实际想要检索的结果越多,我们将在每个图上执行的探索就越多,以确保我们能够逃脱局部最小值。我们将主要关注以下候选值
num_candidates = k
num_candidates = 1.5 * k
num_candidates = 2 * k
num_candidates = 3 * k
num_candidates = 4 * k
num_candidates = Math.max(100, k)
这里值得注意的是,最初检查了更多备选方案,但较高的值提供的益处很少,因此在本文的其余部分,我们将主要关注上述内容。
有一组 num_candidates
候选值(双关语!),我们现在专注于 k
参数。我们选择考虑标准搜索以及非常大的 k
值(以查看我们执行的探索的实际影响)。因此,我们决定更多关注的值是
k = 10
(考虑未指定size
的请求)k = 20
k = 50
k = 100
k = 500
k = 1000
简化 kNN 搜索的基准测试和实验
数据
由于没有一刀切的解决方案,我们希望使用具有不同属性的不同数据集进行测试。因此,总向量数、维度以及来自不同模型的不同数据分布。
同时,我们有 rally
可用,这是一个用于对 Elasticsearch 进行基准测试的绝佳工具(https://github.com/elastic/rally),支持运行大量查询并提取许多向量数据集的指标。在 Elastic,我们广泛使用 rally 并依赖它进行所有夜间基准测试,你可以在此处查看!
在 rally 上运行基准测试就像运行以下命令一样简单
pip3 install esrally && esrally race --track=dense-vector
为此,我们稍微修改了轨道(即 rally 的测试场景)以包含其他指标配置,添加了一些新的指标,并最终得到以下轨道集
dense-vector
(200 万个文档,96 维):https://github.com/elastic/rally-tracks/tree/master/dense_vectorso-vector
(200 万个文档,768 维):https://github.com/elastic/rally-tracks/tree/master/so_vectorcohere-vector
(300 万个文档,768 维):https://github.com/elastic/rally-tracks/tree/master/cohere_vectoropenai-vector
(250 万个文档,1536 维):https://github.com/elastic/rally-tracks/tree/master/openai_vectorGlove 200d
(120 万,200 维)基于https://github.com/erikbern/ann-benchmarks 存储库创建的新轨道
还值得注意的是,对于前几个数据集,我们还想考虑具有一个或多个段的情况,因此我们包含了每个数据集的 2 个变体,
- 一个是在其中执行
force_merge
并具有单个段,以及 - 一个是在其中依赖底层
MergeScheduler
来发挥其作用,并最终获得它认为合适的段数。
指标
对于上述每个轨道,我们计算了标准的召回率和精确率指标、延迟,以及我们通过报告访问的节点在图上执行的实际探索。前几个指标相对于真实的最近邻进行评估,因为在我们的场景中,这是黄金标准数据集(请记住,我们正在评估近似搜索的质量,而不是向量本身的质量)。nodes_visited
属性最近已添加到 knn 的配置文件输出中(https://github.com/elastic/elasticsearch/pull/102032),因此,通过对轨道定义进行一些小的更改以提取所有需要的指标,我们应该可以开始了!
基准测试
现在我们知道要测试什么、要使用哪些数据集以及如何评估结果,是时候真正运行基准测试了!
为了获得标准化的环境,在每次测试中,我们都使用了一个干净的n2-standard-8 (8 个 vCPU,4 核,32 GB 内存)
云节点。Elasticsearch 配置,以及必要的映射和所有其他需要的东西,都是通过rally
配置和部署的,因此对于所有类似的测试来说都是一致的。
上面提到的每个数据集都执行了多次,收集了所有候选集定义的所有可用指标,确保没有结果是偶然的。
结果
每个指定数据集和参数组合的召回率-延迟图如下所示(越高越靠左越好,延迟以毫秒为单位测量)
缩小到dense_vector和openai_vector轨迹,查看延迟第50百分位数和召回率的绝对值,我们得到以下结果
类似地,每个场景下 HNSW 图节点访问的第99百分位数如下所示(越小越好)
越少越好*
*当然,并非所有情况下都如此,哈哈 :)
查看结果,有两件事很突出
- 使用一个片段与多个片段明显会以反比的方式影响召回率和延迟指标。使用较少的片段可以降低延迟(因为我们需要遍历的图较少,即需要执行的搜索次数也较少),这很好,但它也会以相反的方式影响召回率,因为很有可能一些好的候选者会被遗漏(由于
num_candidates
列表较少)。 - 即使进行很少的探索,我们几乎可以在所有情况下都获得足够好的召回率,这很棒!
我们一直在努力改进多片段搜索(这篇文章中有一个很好的例子),因此我们预计这种权衡在未来将不再是一个问题(此处报告的数字不包括这些改进)。
综合考虑所有因素,我们讨论的两个主要选项如下
num_candidates = 1.5 * k
- 这在几乎所有情况下都能获得足够好的召回率,并且具有非常好的延迟分数。num_candidates = Math.max(100, k)
- 这尤其是在较低的k
值下,可以获得略微更好的召回率,但代价是增加了图探索和延迟。
经过仔细考虑和(漫长!)讨论,我们选择前者作为默认值,即设置num_candidates = 1.5 * k
。我们只需要进行很少的探索,并且在几乎所有测试用例中召回率都超过90%(有些报告的数字也显著更高),这应该能提供足够好的入门体验。
结论
我们在 Elastic 中处理 kNN 搜索的方式一直在发展,并且我们不断引入新的功能和改进,因此这些参数和整体评估很可能很快就会过时!我们一直在关注,一旦发生这种情况,我们将确保跟进并相应地调整我们的配置!
需要记住的一件重要事情是,这些值仅作为简化入门体验和非常通用用例的合理默认值。用户可以轻松地在自己数据集上进行实验,并根据自己的需求进行相应调整(例如,在某些场景中,召回率可能比延迟更重要)。
祝您调整愉快 😃