如何在 Elasticsearch 中设置向量搜索 - 第二部分
作者:来自 Elastic Valentin Crettaz
了解如何在 Elasticsearch 中设置向量搜索并执行 k-NN 搜索。
本文是三篇系列文章中的第二篇,深入探讨了向量搜索(也称为语义搜索)的复杂性以及它在 Elasticsearch 中的实现方式。
第一部分重点介绍了嵌入(又称向量)的基础知识以及向量搜索的底层工作原理。
凭借第一篇文章中学到的所有向量搜索知识,第二部分将指导你了解如何在 Elasticsearch 中设置向量搜索和执行 k-NN 搜索。
在第三部分中,我们将利用前两部分中学到的知识,并在此基础上深入研究如何在 Elasticsearch 中制作强大的混合搜索查询。
首先介绍一些背景
尽管 Elasticsearch 直到 8.0 版(带有 _knn_search API 端点的技术预览)才支持向量搜索,但自 7.0 版发布以来,已经可以使用 dense_vector 字段类型存储向量。那时,向量只是作为二进制文档值存储,但没有使用我们在第一篇文章中介绍的任何算法进行索引。这些密集向量构成了 Elasticsearch 中即将推出的向量搜索功能的前提。
如果你有兴趣深入了解导致 Elasticsearch 中当前实现向量搜索的讨论,你可以参考此问题,其中详细介绍了 Elastic 为将此功能推向市场而必须克服的所有障碍。简而言之,由于 Elasticsearch 已经大量使用 Lucene 作为其底层搜索引擎,我们还决定使用与我们的向量引擎相同的技术,并以非常透明的方式解释了该决定背后的理由。
历史问题已经解决,现在让我们开始工作。
如何设置 k-NN
Elasticsearch 本身就提供向量搜索,无需安装任何特定程序。我们只需要创建一个索引,该索引定义至少一个类型为 density_vector 的字段,向量数据将存储在该字段中并/或被索引。
下面的映射显示了一个名为 title_vector 的 3 维 dense_vector 字段。在此字段中存储和索引的密集向量将使用我们在本系列第一篇文章中介绍的 dot_product 相似度函数。值得注意的是,在 8.11 之前,hnsw(即 Hierarchical Navigable Small Worlds)是 Apache Lucene 支持的唯一用于索引密集向量的算法。从那时起,其他算法被添加,未来,Elasticsearch 可能会提供用于索引和搜索密集向量的其他方法,但由于它完全依赖于 Apache Lucene,因此这将取决于这方面的进展。
"title_vector": {
"type": "dense_vector",
"dims": 3,
"index": true,
"similarity": "dot_product",
"index_options": {
"type": "hnsw",
"ef_construction": 128,
"m": 24
}
}
下表总结了 Elasticsearch 提供的 dense_vector 字段类型的所有可用配置参数:
参数 | 必需 | 描述 |
---|---|---|
dims | Yes (<8.11) No (8.11+) | 向量维数,在 8.9.2 之前不能超过 1024,在 8.10.0 之后不能超过 2048,在 8.11.0 之后不能超过 4096。此外,从 8.11 开始,此参数不再需要,将默认为第一个索引向量的维数。 |
element_type | No | 向量元素值的数据类型,若不指定,则默认为 `float`(4 个字节),也可以使用 `byte`(1 个字节)和 `bit`。 |
index | No | 指示是否在专用且优化的数据结构中索引向量(如果为 “true”)或仅将其存储为二进制文档值(如果为“false”)。在 8.10 之前,如果未指定,则默认值为“false”。从 8.11 开始,如果未指定,则默认值为 “true”。 |
similarity | Yes (<8.11) No (8.11+) | 直到 8.10 版本,如果 `index` 为 `true`,则此参数是必需的,并定义用于 k-NN 搜索的向量相似度度量。可用的度量包括:a) `l2_norm`:L2 距离 b) `dot_product`:点积相似度 c) `cosine`:余弦相似度 d) `max_inner_product`:最大内积相似度。还请注意,只有当你的向量已经归一化(即,它们是幅度为 1 的单位向量)时,才应使用 `dot_product`,否则使用 `cosine` 或 `max_inner_product`。从 8.11 版本开始,如果未指定,则如果 element_type 为 `bit`,则此参数默认为 `l2_norm`,否则为 `cosine`。 |
index_options | No | 以下是 `type` 参数可能的值(具体取决于版本):a) 直到 8.11,仅支持 `hnsw`。b) 在 8.12 中,标量量化启用了 `int8_hnsw` c) 在 8.13 中,添加了 `flat` 及其标量量化的 `int8_flat` 兄弟 d) 在 8.15 中,添加了 `int4_hnsw` 和 `int4_flat` e) 在 8.18 中,二进制量化启用了 `bbq_hnsw` 和 `bbq_flat`。你可以查看官方文档以了解它们的详细描述(https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html#dense-vector-params)以及如何配置每个算法。 |
从上表可以看出,自 8.11 版本以来,想量场的定义已大大简化:
关于 8.12 中添加的标量量化支持,请记住,我们在本系列的第一部分中讨论过这种压缩技术。我们不会在本文中深入探讨,但你可以在另一篇 Elastic Search Labs 文章中了解有关如何在 Lucene 中实现这一点的更多信息。同样,我们不会深入研究 8.18 中添加的更好的二进制量化 (BBQ),我们邀请你在本文中了解有关该新突破性算法的更多信息。
就这些了!只需定义和配置一个 dense_vector 字段,我们现在就可以索引向量数据,以便使用 knn 搜索选项或 knn DSL 查询(在 8.12 中引入)在 Elasticsearch 中运行向量搜索查询。Elasticsearch 支持两种不同的向量搜索模式:1) 使用 script_score 查询进行精确搜索和 2) 使用 knn 搜索选项或 knn 查询(8.12+)进行近似最近邻搜索。接下来我们将描述这两种模式。
精确搜索
如果你回想一下本系列的第一篇文章,我们回顾了向量搜索概况,那么精确向量搜索可以归结为在整个向量空间中执行线性搜索或强力搜索(brute-force search)。基本上,查询向量将根据每个存储的向量进行测量,以找到最近的邻居。在此模式下,向量不需要在 HNSW 图中编入索引,而只需存储为二进制文档值,相似度计算由自定义 Painless 脚本运行。
首先,我们需要以不对向量进行索引的方式定义向量场映射,这可以通过在映射中指定 index: false 和 no 相似度度量来实现:
# 1. Create a simple index with a dense_vector field of dimension 3
PUT /my-index
{
"mappings": {
"properties": {
"price": {
"type": "integer"
},
"title_vector": {
"type": "dense_vector",
"dims": 3,
"index": false
}
}
}
}
# 2. Load that index with some data
POST my-index/_bulk
{ "index": { "_id": "1" } }
{ "title_vector": [2.2, 4.3, 1.8], "price": 23}
{ "index": { "_id": "2" } }
{ "title_vector": [3.1, 0.7, 8.2], "price": 9}
{ "index": { "_id": "3" } }
{ "title_vector": [1.4, 5.6, 3.9], "price": 124}
{ "index": { "_id": "4" } }
{ "title_vector": [1.1, 4.4, 2.9], "price": 1457}
这种方法的优点是向量不需要索引,这大大降低了提取时间,因为不需要构建底层 HNSW 图。但是,根据数据集的大小和硬件,随着数据量的增加,搜索查询可能会很快变慢,因为添加的向量越多,访问每个向量所需的时间就越长(即线性搜索的复杂度为 O(n))。
创建索引并加载数据后,我们现在可以使用以下 script_score 查询运行精确搜索:
POST my-index/_search
{
"_source": false,
"fields": [ "price" ],
"query": {
"script_score": {
"query" : {
"bool" : {
"filter" : {
"range" : {
"price" : {
"gte": 100
}
}
}
}
},
"script": {
"source": "cosineSimilarity(params.queryVector, 'title_vector') + 1.0",
"params": {
"queryVector": [0.1, 3.2, 2.1]
}
}
}
}
}
如你所见,script_score 查询由两个主要元素组成,即查询和脚本。在上面的示例中,查询部分指定了一个过滤器(即 price >= 100),该过滤器限制了将针对其执行脚本的文档集。如果未指定查询,则相当于使用 match_all 查询,在这种情况下,脚本将针对索引中存储的所有向量执行。根据向量的数量,搜索延迟可能会大幅增加。
由于向量未编入索引,因此没有内置算法可以测量查询向量与存储向量的相似性,这必须通过脚本来完成,幸运的是,Painless 提供了我们迄今为止学到的大多数相似性函数,例如:
l1norm(vector, field)
: L1 distance (Manhattan distance)l2norm(vector, field)
: L2 distance (Euclidean distance)hamming(vector, field)
: Hamming distancecosineSimilarity(vector, field)
: Cosine similaritydotProduct(vector, field):
Dot product similarity
由于我们正在编写脚本,因此也可以构建我们自己的相似性算法。 Painless 通过提供对 doc[<field>].vectorValue(允许迭代向量数组)和 doc[<field>].magnitude(返回向量的长度)的访问来实现这一点。
总而言之,尽管精确搜索的扩展性不佳,但它可能仍然适用于某些非常小的用例,但如果你知道数据量会随着时间的推移而增长,则需要考虑改用 k-NN 搜索。 这就是我们接下来要介绍的内容。
近似 k-NN 搜索
大多数情况下,如果你有大量数据并且需要使用 Elasticsearch 实现向量搜索,则你将选择此模式。索引延迟稍高一些,因为 Lucene 需要构建底层 HNSW 图来存储和索引所有向量。它在搜索时的内存要求方面也要求更高一些,之所以称为 “近似”,是因为其准确度永远不可能像精确搜索那样达到 100%。尽管如此,近似 k-NN 的搜索延迟要低得多,并且允许我们扩展到数百万甚至数十亿个向量,前提是你的集群大小合适。
让我们看看它是如何工作的。首先,让我们创建一个具有足够向量场映射的示例索引来索引向量数据(即 index: true + 特定 similarity 定义)并用一些数据加载它:
# 1. Create a simple index with a dense_vector field of dimension 3
PUT /my-index
{
"mappings": {
"properties": {
"price": {
"type": "integer"
},
"title_vector": {
"type": "dense_vector",
"dims": 3,
"index": true, # default since 8.11
"similarity": "cosine", # default since 8.11
"index_options": {
"type": "int8_hnsw", # default value since 8.12
"ef_construction": 128,
"m": 24
}
}
}
}
}
# 2. Load that index with some data
POST my-index/_bulk
{ "index": { "_id": "1" } }
{ "title_vector": [2.2, 4.3, 1.8], "price": 23}
{ "index": { "_id": "2" } }
{ "title_vector": [3.1, 0.7, 8.2], "price": 9}
{ "index": { "_id": "3" } }
{ "title_vector": [1.4, 5.6, 3.9], "price": 124}
{ "index": { "_id": "4" } }
{ "title_vector": [1.1, 4.4, 2.9], "price": 1457}
简单的 k-NN 搜索
运行这两个命令后,我们的想量数据现在已在标量量化的 HNSW 图中正确索引,可供搜索。直到 8.11 版,运行简单 k-NN 搜索的唯一方法是使用 knn 搜索选项,该选项与你习惯的 query 部分位于同一级别,如以下查询所示:
POST my-index/_search
{
"_source": false,
"fields": [ "price" ],
"knn": {
"field": "title_vector",
"query_vector": [0.1, 3.2, 2.1],
"k": 2,
"num_candidates": 100
}
}
在上面的搜索负载中,我们可以看到没有像词汇搜索那样的 query 部分,而是 knn 部分。我们正在搜索与指定查询向量最接近的两个(k:2)相邻向量。
从 8.12 开始,引入了新的 knn 搜索查询(knn search query),以允许更高级的混合搜索用例,这是我们将在下一篇文章中讨论的主题。除非你具备将 k-NN 查询与其他查询相结合所需的专业知识,否则 Elastic 建议坚持使用更易于使用的 knn 搜索选项。首先要注意的是,新的 knn 搜索查询没有 k 参数,而是像任何其他查询一样使用 size 参数。第二件值得注意的是,新的 knn 查询通过利用将一个或多个过滤器与 knn 搜索查询相结合的 bool 查询来实现对 k-NN 搜索结果的后过滤,如下面的代码所示:
POST my-index/_search
{
"size": 3,
"_source": false,
"fields": [ "price" ],
"query" : {
"bool" : {
"must" : {
"knn": {
"field": "title_vector",
"query_vector": [0.1, 3.2, 2.1],
"num_candidates": 100
}
},
"filter" : {
"range" : {
"price" : {
"gte": 100
}
}
}
}
}
}
上述查询首先检索具有最近邻向量的前 3 个文档,然后过滤掉 price 小于 100 的文档。值得注意的是,使用这种后过滤,如果过滤器过于激进,你可能最终得不到任何结果。还请注意,此行为不同于通常的布尔全文查询,其中首先执行过滤部分以减少需要评分的文档集。如果你有兴趣了解有关 knn 顶级搜索选项和新 knn 搜索查询之间的差异的更多信息,你可以前往另一篇很棒的搜索实验室文章了解更多详细信息。
现在让我们继续了解有关 num_candidates 参数的更多信息。num_candidates 的作用是增加或减少找到真正最近邻候选的可能性。该数字越高,搜索速度越慢,但找到真正最近邻的可能性也越大。每个分片上将考虑 num_candidates 个向量,并将前 k 个向量返回到协调器节点,协调器节点将合并所有分片本地结果并返回全局结果中的前 k 个向量,如下图 1 所示:
![](https://i-blog.csdnimg.cn/direct/0083cf90ef19475fad1defd7f2e1ee4d.webp)
向量 id4 和 id2 分别是第一个分片上的 k 个局部最近邻,id5 和 id7 分别是第二个分片上的 k 个局部最近邻。在对它们进行合并和重新排序后,协调器节点将返回 id4 和 id5 作为搜索查询的两个全局最近邻。
多个 k-NN 搜索
如果你的索引中有多个向量字段,则可以发送多个 k-NN 搜索,因为 knn 部分还接受查询数组,如下所示:
POST my-index/_search
{
"_source": false,
"fields": [ "price" ],
"knn": [
{
"field": "title_vector",
"query_vector": [0.1, 3.2, 2.1],
"k": 2,
"num_candidates": 100,
"boost": 0.4
},
{
"field": "content_vector",
"query_vector": [0.1, 3.2, 2.1],
"k": 5,
"num_candidates": 100,
"boost": 0.6
}
]
}
我们可以看到,每个查询可以采用不同的 k 值以及不同的提升因子。提升因子相当于权重,总得分将是两个得分的加权平均值。
过滤的 k-NN 搜索
与我们之前在 script_score 查询中看到的类似,knn 部分也接受过滤器的规范,以减少近似搜索应运行的向量空间。例如,在下面的 k-NN 搜索中,我们将搜索限制为仅搜索价格大于或等于 100 的文档。
POST my-index/_search
{
"_source": false,
"fields": [ "price" ],
"knn": {
"field": "title_vector",
"query_vector": [0.1, 3.2, 2.1],
"k": 2,
"num_candidates": 100,
"filter" : {
"range" : {
"price" : {
"gte": 100
}
}
}
}
}
现在,你可能想知道数据集是否先按 price 过滤,然后对过滤后的数据集运行 k-NN 搜索(预过滤),还是反过来,即先检索最近邻居,然后按 price 过滤(后过滤)。实际上,两者都有一点。如果过滤器过于激进,预过滤的问题在于 k-NN 搜索必须在非常小且可能稀疏的向量空间上运行,并且不会返回非常准确的结果。而后过滤可能会剔除大量高质量的最近邻居。
因此,即使 knn 部分 filter 被视为预过滤器,它也会在 k-NN 搜索期间工作,以确保至少可以返回 k 个邻居。如果你对其工作原理感兴趣,可以查看以下处理此事的 Lucene 问题。
具有预期相似度的过滤 k-NN 搜索
在上一节中,我们了解到,在指定过滤器时,我们可以减少搜索延迟,但我们也冒着将向量空间大幅减少为与查询向量部分或大部分不相似的向量的风险。为了缓解这个问题,k-NN 搜索还可以指定所有返回向量预计具有的最小相似度值(minimum similarity)。重用上一个查询,它看起来会像这样:
POST my-index/_search
{
"_source": false,
"fields": [ "price" ],
"knn": {
"field": "title_vector",
"query_vector": [0.1, 3.2, 2.1],
"k": 2,
"num_candidates": 100,
"similarity": 0.975,
"filter" : {
"range" : {
"price" : {
"gte": 100
}
}
}
}
}
基本上,它的工作方式是,通过跳过任何与提供的过滤器不匹配或相似度低于指定过滤器的向量来探索向量空间,直到找到 k 个最近邻居。如果算法不能接受至少 k 个结果(由于过滤器过于严格或预期相似度太低),则将尝试进行强力搜索(brute-force),以便至少返回 k 个最近邻居。
关于如何确定最小预期相似度的简短说明。这取决于你在向量场映射中选择了哪种相似度度量。如果你选择了 l2_norm,它是一个距离函数(即相似度随着距离的增加而减小),你将需要在 k-NN 查询中设置最大预期距离,即你认为可接受的最大距离。换句话说,与查询向量的距离介于 0 和最大预期距离之间的向量将被视为足够 “接近” 以相似。
如果你选择了 dot_product 或 cosine,它们是相似度函数(即,相似度随着向量角度变宽而降低),你将需要设置最小预期相似度。与查询向量具有最小预期相似度和 1 之间的相似度的向量将被视为足够 “接近” 以致相似。
应用于上面的示例过滤查询和我们之前已索引的示例数据集,下面的表 1 总结了查询向量和每个索引向量之间的余弦相似度。我们可以看到,向量 3 和 4 被过滤器选中(price >= 100),但只有向量 3 具有最小预期相似度(即 0.975)才能被选中。
Vector | Cosine similarity | Price |
---|---|---|
1 | 0.8473 | 23 |
2 | 0.5193 | 9 |
3 | 0.9844 | 124 |
4 | 0.9683 | 1457 |
k-NN 的局限性
现在我们已经回顾了 Elasticsearch 中 k-NN 搜索的所有功能,让我们看看你需要注意的几个限制:
- 直到 8.11,k-NN 搜索都无法在嵌套文档内的向量字段上运行。从 8.12 开始,此限制已被取消。但是,这种嵌套的 knn 查询不支持指定过滤器。
- search_type 始终设置为 dfs_query_then_fetch,并且无法动态更改它。
- 在使用跨集群搜索跨不同集群进行搜索时,不支持 ccs_minimize_roundtrips 选项。
- 这已经提到过几次了,但由于 Lucene 使用的 HNSW 算法的性质(以及任何其他近似最近邻搜索算法),“近似” 实际上意味着返回的 k 个最近邻并不总是真实的。
调整 k-NN
你可以想象,你可以使用很多选项来优化 k-NN 搜索的索引和搜索性能。我们不会在本文中介绍它们,但如果你真的想在 Elasticsearch 集群中实施 k-NN 搜索,我们强烈建议你在官方文档中查看它们。
超越 k-NN
到目前为止,我们看到的一切都利用了密集(dense)向量模型(因此是致密向量字段类型),其中向量通常包含非零值。Elasticsearch 还提供了使用稀疏(sparse)向量模型执行语义搜索的另一种方法。
Elastic 创建了一个稀疏 NLP 向量模型,称为 Elastic Learned Sparse EncodeR,简称 ELSER,这是一个域外(即未在特定域上训练)稀疏向量模型,不需要任何微调。它是在约 30000 个术语的词汇表上进行预训练的,并且作为一个稀疏模型,这意味着向量具有相同数量的值,其中大多数为零。
它的工作方式非常简单。在索引时,使用推理摄取处理器生成稀疏向量(术语/权重对),并将其存储在 sparse_vector 类型的字段中,这是 dense_vector 向量字段类型的稀疏对应项。在查询时,特定的 DSL 查询(也称为 sparse_vector)将用 ELSER 模型词汇表中的可用术语替换原始查询术语,这些术语已知与它们的权重最相似。
我们不会在本文中深入探讨 ELSER,但如果你渴望了解它的工作原理,你可以查看这篇开创性的文章以及官方文档,其中详细解释了该主题。
快速浏览一些即将推出的相关主题
Elasticsearch 还支持结合词汇搜索和向量搜索,这将是本系列下一篇也是最后一篇文章的主题。
到目前为止,我们必须在 Elasticsearch 之外生成嵌入向量,并将它们明确传递到我们所有的查询中。是否可以只提供查询文本,然后模型就会动态生成嵌入?好消息是,这可以通过 Elasticsearch 来实现,方法是利用名为 query_vector_builder 的构造(用于密集向量)或使用新的 semantic_text 字段类型和 semantic DSL 查询(用于稀疏向量),你可以在这篇文章中了解有关这些技术的更多信息。
让我们总结一下
在本文中,我们深入研究了 Elasticsearch 向量搜索支持。我们首先分享了 Elastic 寻求提供准确向量搜索的一些背景,以及我们决定使用 Apache Lucene 作为向量索引和搜索引擎的原因。
然后,我们介绍了在 Elasticsearch 中执行向量搜索的两种主要方法,即利用 script_score 查询来运行精确的强力搜索,或者通过 knn 搜索选项或 8.12 中引入的 knn 搜索查询使用近似最近邻搜索。
我们展示了如何运行简单的 k-NN 搜索,然后,我们回顾了使用过滤器和预期相似性配置 knn 搜索选项和查询的所有可能方法,以及如何同时运行多个 k-NN 搜索。
总结一下,我们列出了 k-NN 搜索的一些当前限制以及需要注意的事项。我们还邀请你查看可用于优化 k-NN 搜索的所有可能选项。
如果你喜欢你正在阅读的内容,请务必查看本系列的其他部分:
- 第 1 部分:向量搜索快速入门
- 第 3 部分:使用 Elasticsearch 进行混合搜索(敬请期待!)
想要获得 Elastic 认证?了解下一次 Elasticsearch 工程师培训何时开始!
Elasticsearch 包含许多新功能,可帮助你为你的用例构建最佳搜索解决方案。深入了解我们的示例笔记本以了解更多信息,开始免费云试用,或立即在你的本地机器上试用 Elastic。
原文:How to set up vector search in Elasticsearch - Elasticsearch Labs