在这篇博文中,我们将深入探讨我们为简化 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
个结果。这在 kNN 查询中也非常有效,在 kNN 查询中我们根本没有k
参数,而是基于请求的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搜索的方式一直在发展,我们不断引入新的功能和改进,因此这些参数和整体评估很快就会过时!我们一直在关注,每当发生这种情况时,我们都会确保跟进并适当地调整我们的配置!
要记住的一件重要事情是,这些值仅作为简化入门体验和非常通用的用例的合理默认值。用户可以轻松地在自己数据集上进行实验,并根据自己的需求进行相应的调整(例如,在某些情况下,召回率可能比延迟更重要)。
调整愉快 😃