简化 KNN 检索
#转载 #大数据/ES #翻译
这篇文章是关于如何简化 k 最近邻(k-Nearest Neighbors,简称 kNN)搜索的深入探讨。以下是对全文的翻译(借助 kimi AI):
在这篇博客文章中,我们将深入探讨我们为使 kNN 搜索的入门体验更加轻松所做的努力!
向量搜索
向量搜索在 Elasticsearch 中已经可用了一段时间,通过使用新的专用 knn
搜索类型,同时我们也在 8.12.0
版本中将 kNN 作为查询引入(有关这个惊人的博客文章,我们最近发表了更多内容!)。
尽管每种方法的执行流程和应用有一些差异,但进行基本 kNN 检索的语法非常相似。因此,一个典型的 knn
搜索请求看起来像这样:
json
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 搜索更加简单和直观,我们决定使这些参数成为可选的,这样您只需要提供您想要搜索的位置和内容,如果确实需要,您可以调整它们。虽然看似一个相当小的变化,但它使事情变得更加清晰!因此,上述查询现在可以简单地重写为:
json
GET products/_search
{
"knn": {
"field": "my_vector",
"query_vector": [1, 2, 3]
}
}
使 k
和 num_candidates
成为可选的
所以我们希望使 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
与索引数据没有直接关系,而是与搜索请求有关,并确保我们进行所需的探索以提供足够好的结果。
作为开始,并保持事情简单,我们研究了将 num_candidates
值相对于 k
(或 size
)进行设置。因此,您实际想要检索的结果越多,我们在每个图上执行的探索就越多,以确保我们从局部最小值中逃脱。我们将重点关注的候选选项如下:
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
数据
由于没有一种解决方案适用于所有情况,我们希望测试具有不同属性的不同数据集。因此,不同的总向量数量、维度,以及由不同模型生成,因此具有不同的数据分布。
同时,我们有 rally
,这是一个用于基准测试 Elasticsearch 的很棒的工具(https://github.com/elastic/rally),它支持运行一堆查询并提取多个向量数据集的指标。在 Elastic,我们广泛使用 rally,并依赖它进行我们所有的夜间基准测试,您可以在这里查看!
在 rally 上运行基准测试就像运行以下命令一样简单:
shell
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 repo 创建的新赛道
同样值得注意的是,对于前几个数据集,我们还想考虑有一个与多个段的情况,因此我们包含了每种的两个变体,
- 一个我们执行
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 th 百分位值和召回率值如下:
类似地,每个场景下 HNSW 图形节点访问的 99 th 百分位数可以在下面找到(越小越好):
少即是多*
*嗯,在所有情况下都不是这样,但嘿 😃
从结果来看,有两点比较突出:
一个分段与多个分段明显成反比地影响召回率和延迟指标。分段越少,延迟就越短(因为我们需要遍历的图更少,也就是需要运行的搜索更少),这固然很好,但它也会以相反的方式影响召回率,因为很有可能一些好的候选者会被遗漏(因为候选者列表数量更少)。
即使探索很少,我们也能在几乎所有情况下获得足够好的召回率,这非常好!
我们一直在努力改进多分段搜索(本文章中就有一个很好的例子),因此我们希望在今后的工作中,这种取舍将不再是一个问题(此处报告的数据不包括这些改进)。
考虑到所有因素,我们讨论的两个主要选项如下:
Num_candidates = 1.5 * k - 这在几乎所有情况下都能达到足够好的召回率,同时获得非常好的延迟分数。
Num_candidates = Math.Max (100, k)--这种方法的召回率稍高,尤其是在 k 值较低的情况下,但代价是增加了图形探索和延迟。
经过仔细考虑和(漫长的!)讨论,我们选择了前者作为默认设置,即设置 num_candidates = 1.5 * k。在我们测试的几乎所有情况下,我们需要做的探索工作要少得多,召回率也都超过了 90%(有些报告的数字还明显更高),这应该能带来足够好的上机体验。
结论
Elastic 处理 kNN 搜索的方式一直在不断发展,我们也在不断引入新功能并进行改进,因此这些参数和整体评估很可能很快就会过时!我们一直在关注这一变化,无论何时发生,我们都会确保跟进并适当调整我们的配置!
需要记住的重要一点是,这些值只是简化上架体验和非常通用的使用案例的合理默认值。人们可以在自己的数据集上轻松地进行实验,并根据自己的需求进行相应的调整(例如,在某些情况下,召回可能比延迟更重要)。
调优愉快 😃