Elasticsearch:探索 k-nearest neighbor (kNN) 搜索

由于新一代机器学习模型可以将各种内容表示为矢量,包括文本、图像、事件等,人们对矢量搜索的兴趣激增。 通常称为 “嵌入模型(embedding models)”,这些强大的表示可以以超越其表面特征的方式捕获两段内容之间的相似性。

k 最近邻 (kNN) 搜索算法在数据集中查找与查询向量最相似的向量。 与这些矢量表示相结合,kNN 搜索为检索开辟了令人兴奋的可能性:

  • 查找可能包含问题答案的段落
  • 在大型数据集中检测近似重复的图像
  • 查找听起来与给定歌曲相似的歌曲

矢量搜索有望成为搜索工具箱的重要组成部分,与基于术语的评分等传统技术并驾齐驱。

选择 ANN 算法

设计 ANN 算法是学术研究的一个活跃领域,有许多有前途的算法可供选择。 他们经常在搜索速度、实施复杂性和索引成本方面做出不同的权衡。 值得庆幸的是,有一个名为 ann-benchmarks 的伟大开源项目可以针对多个数据集测试领先的算法并发布比较结果。

Elasticsearch 8.0 使用称为分层导航小世界图 (Hierachical Navigable Small World graph - HNSW) 的 ANN 算法,该算法根据向量彼此的相似性将它们组织成图形。 HNSW 在各种 ann-benchmarks 数据集上显示出强大的搜索性能,并且在我们自己的测试中也表现出色。 HNSW 的另一个好处是它在工业中得到广泛应用,已在多个不同的系统中实现。 除了原始学术论文外,还有许多有助于了解算法细节的有用资源。 虽然 Elasticsearch ANN 目前是基于 HNSW 的,但该功能以灵活的方式设计,让我们在未来融合不同的方法。

最近邻搜索是数据科学和机器学习中的一个基本问题。 给定高维空间中的一组点,目标是有效地找到给定查询点的最近邻居。 Elasticsearch 是一种流行的搜索引擎和分析平台,它通过使用 Hierarchical Navigable Small World (HNSW) 算法为这个问题提供了强大的解决方案。 在这篇博文中,我们将深入探讨 HNSW 如何在 Elasticsearch 中实现高效的最近邻搜索。

HNSW 是一种近似最近邻 (ANN) 算法,这意味着它提供了真实最近邻的近似值,而不是保证。 HNSW 背后的关键思想是建立一个互连节点的层次结构,形成一个小世界图。 图中的每个节点代表高维空间中的一个点,节点之间的边代表点与点之间的相似度。 该图的构建方式可以平衡探索(寻找新的、可能更近的点)和利用(利用可能接近的已发现点)之间的权衡。

HNSW 算法分为两个主要阶段:构造搜索

在构造阶段,该算法通过以贪婪、迭代的方式向图中添加节点和边来构建小世界图。 在每次迭代中,该算法都会选择一个候选节点添加到图中,并将其连接到可能是其最近邻居的其他节点。 该算法使用一组启发式方法来平衡探索和开发,例如维护一组具有高出度的节点(即,与其他节点有许多边的节点)以启用探索,同时还向具有低出度的节点添加边以启用开拓。

该算法的搜索阶段涉及在小世界图中找到给定查询点的 k 个最近邻居。 该算法从图中的一个随机节点开始,迭代探索该图,移动到可能更接近查询点的相邻节点。 当找到 k 个最近的邻居或当搜索已经探索了预定数量的节点时,搜索终止。 搜索算法还使用启发式方法来平衡探索和开发,例如维护一组已访问节点并使用这些节点将搜索引导至图中未探索的区域。

HNSW 与其他 ANN 算法相比有几个优点。 首先,它可以处理具有数百万个点的高维空间,使其非常适合在 Elasticsearch 中使用。 其次,它对内存的要求很低,因为小世界图(small world graph)可以存储在内存高效的数据结构中,例如数组和哈希表。 第三,它在构造和搜索方面的计算成本都很低,使其快速且可扩展。

要可视化 HNSW 算法的运行情况,请查看原论文作者 Yu. A. Markov 的交互式演示。

Dense vector 字段类型

dense_vector 字段类型存储数值的密集向量。 密集向量场主要用于 k 最近邻 (kNN) 搜索。dense_vector 类型不支持聚合或排序。

你可以将 dense_vector 字段添加为基于 element_type 的数值数组,默认情况下为 float:

PUT my-index
{
  "mappings": {
    "properties": {
      "my_vector": {
        "type": "dense_vector",
        "dims": 3
      },
      "my_text" : {
        "type" : "keyword"
      }
    }
  }
}

我们可以使用如下的命令来写入一些文档:

PUT my-index/_doc/1
{
  "my_text" : "text1",
  "my_vector" : [0.5, 10, 6]
}

PUT my-index/_doc/2
{
  "my_text" : "text2",
  "my_vector" : [-0.5, 10, 10]
}

注意:与大多数其他数据类型不同,密集向量始终是单值的。 不可能在一个 dense_vector 字段中存储多个值。

为了完成搜索,你使用的账号必须具有如下的 index 权限

  • create_index 或 manage 创建一个带有 dense_vector 字段的索引
  • create、index 或 write 以将数据添加到你创建的索引
  • read 以搜索索引

更多关如何创建这些用户权限,请参阅之前的文章 “Elasticsearch:用户安全设置”。

k 最近邻 (kNN) 搜索

k 最近邻 (kNN) 搜索找到与查询向量最近的 k 个向量,由相似性度量来衡量。kNN 的常见用例包括:

  • 基于自然语言处理 (NLP) 算法的相关性排名
  • 产品推荐和推荐引擎
  • 图像或视频的相似性搜索

有关 NLP 及图像相似性的实例,请详细阅读我之前的文章 “Elastic:开发者上手指南” 中的 “NLP - 自然语言处理” 部分。

kNN 方法

Elasticsearch 支持两种 kNN 搜索方法:

  • 使用带有向量函数的 script_score 查询的 Exact,brute-force kNN
  • 使用 knn 搜索选项近似 kNN(Approximate kNN)

在大多数情况下,你会希望使用近似 kNN。 近似 kNN 以较慢的索引和不完美的准确性为代价提供较低的延迟。

Exact, brute-force kNN 保证了准确的结果,但不能很好地扩展到大型数据集。 使用这种方法,script_score 查询必须扫描每个匹配的文档来计算向量函数,这会导致搜索速度变慢。 但是,你可以通过使用 query 来限制传递给函数的匹配文档的数量来改善延迟。 如果将数据过滤为一小部分文档,则可以使用此方法获得良好的搜索性能。

Exact kNN

要运行精确的 kNN 搜索,请使用带有向量函数的 script_score 查询。

1)显式映射一个或多个 dense_vector 字段。 如果你不打算将该字段用于近似 kNN(approximate kNN),请省略索引映射选项或将其设置为 false。 这可以显着提高索引速度。

首先,我们需要将我们的数据索引到 Elasticsearch 中,并指定我们要用于最近邻搜索的字段。 这是一个例子:

PUT product-index
{
  "mappings": {
    "properties": {
      "product-vector": {
        "type": "dense_vector",
        "dims": 5,
        "index": false
      },
      "price": {
        "type": "long"
      }
    }
  }
}

2)我们写入一下数据:

POST product-index/_bulk?refresh=true
{ "index": { "_id": "1" } }
{ "product-vector": [230.0, 300.33, -34.8988, 15.555, -200.0], "price": 1599 }
{ "index": { "_id": "2" } }
{ "product-vector": [-0.5, 100.0, -13.0, 14.8, -156.0], "price": 799 }
{ "index": { "_id": "3" } }
{ "product-vector": [0.5, 111.3, -13.0, 14.8, -156.0], "price": 1099 }
...

3)使用 search API 运行包含向量函数的 script_score 查询。

提示:为了限制传递给向量函数的匹配文档的数量,我们建议您在 script_score.query 参数中指定一个过滤器查询。 如果需要,你可以在此参数中使用 match_all 查询来匹配所有文档,但是,匹配所有文档会显着增加搜索延迟。

POST product-index/_search
{
  "query": {
    "script_score": {
      "query" : {
        "bool" : {
          "filter" : {
            "range" : {
              "price" : {
                "gte": 1000
              }
            }
          }
        }
      },
      "script": {
        "source": "cosineSimilarity(params.queryVector, 'product-vector') + 1.0",
        "params": {
          "queryVector": [-0.5, 90.0, -10, 14.8, -156.0]
        }
      }
    }
  }
}

一个完整的例子, 可以详细阅读文章 “Elasticsearch:基于 Vector 的打分”。

近似 kNN - Approximate kNN

警告:与其他类型的搜索相比,近似 kNN 搜索具有特定的资源要求。 特别是,所有矢量数据必须适合节点的页面缓存才能有效。 有关配置和大小调整的重要说明,请参阅近似 kNN 搜索调整指南

要运行近似 kNN 搜索,请使用 knn 选项搜索启用索引的 dense_vector 字段。

1)显式映射一个或多个 dense_vector 字段。 近似 kNN 搜索需要以下映射选项:

  • index 值为 true。
  • similarity。 该值确定用于根据查询和文档向量之间的相似性对文档进行评分的相似性度量。 有关可用指标的列表,请参阅 similarity 参数文档。
PUT image-index
{
  "mappings": {
    "properties": {
      "image-vector": {
        "type": "dense_vector",
        "dims": 3,
        "index": true,
        "similarity": "l2_norm"
      },
      "title": {
        "type": "text"
      },
      "file-type": {
        "type": "keyword"
      }
    }
  }
}

2)写入一些数据

POST image-index/_bulk?refresh=true
{ "index": { "_id": "1" } }
{ "image-vector": [1, 5, -20], "title": "moose family", "file-type": "jpg" }
{ "index": { "_id": "2" } }
{ "image-vector": [42, 8, -15], "title": "alpine lake", "file-type": "png" }
{ "index": { "_id": "3" } }
{ "image-vector": [15, 11, 23], "title": "full moon", "file-type": "jpg" }
...

3)使用 knn 选项运行搜索

POST image-index/_search
{
  "knn": {
    "field": "image-vector",
    "query_vector": [-5, 9, -12],
    "k": 10,
    "num_candidates": 100
  },
  "fields": [ "title", "file-type" ]
}

在返回的文档中,_score 由查询和文档向量之间的相似性决定。 有关如何计算 kNN 搜索分数的更多信息,请参阅 similarity

注意:8.0 版中添加了对近似 kNN 搜索的支持。 在此之前,dense_vector 字段不支持在映射中启用索引。 如果你在 8.0 之前创建了包含 dense_vector 字段的索引,则为了支持近似 kNN 搜索,必须使用设置索引的新字段映射 reindex 数据并设置 index: true。

调整近似 kNN 以获得速度或准确性

为了收集结果,kNN 搜索 API 在每个分片上找到 num_candidates 个近似最近邻候选者。 搜索计算这些候选向量与查询向量的相似性,从每个分片中选择 k 个最相似的结果。 然后搜索合并来自每个分片的结果以返回全局前 k 个最近的邻居。

你可以增加 num_candidates 以降低搜索速度为代价获得更准确的结果。 具有高 num_candidates 值的搜索会考虑来自每个分片的更多候选人。 这需要更多的时间,但是搜索有更高的概率找到真正的 k top nearest neighbors。

同样,你可以减少 num_candidates 以加快搜索速度,但结果可能不太准确。

使用字节向量的近似 kNN

除了浮点值向量外,近似 kNN 搜索 API 还支持字节值向量。 使用 knn 选项搜索 dense_vector 字段,其中 element_type 设置为 byte 并启用索引。

1)显式映射一个或多个 dense_vector 字段,将 element_type 设置为 byte 并启用索引。

PUT byte-image-index
{
  "mappings": {
    "properties": {
      "byte-image-vector": {
        "type": "dense_vector",
        "element_type": "byte",
        "dims": 2,
        "index": true,
        "similarity": "cosine"
      },
      "title": {
        "type": "text"
      }
    }
  }
}

2)索引你的数据,确保所有向量值都是 [-128, 127] 范围内的整数。

POST byte-image-index/_bulk?refresh=true
{ "index": { "_id": "1" } }
{ "byte-image-vector": [5, -20], "title": "moose family" }
{ "index": { "_id": "2" } }
{ "byte-image-vector": [8, -15], "title": "alpine lake" }
{ "index": { "_id": "3" } }
{ "byte-image-vector": [11, 23], "title": "full moon" }

3)使用 knn 选项运行搜索,确保 query_vector 值是 [-128, 127] 范围内的整数。

POST byte-image-index/_search
{
  "knn": {
    "field": "byte-image-vector",
    "query_vector": [-5, 9],
    "k": 10,
    "num_candidates": 100
  },
  "fields": [ "title" ]
}

过滤的 kNN 搜索

kNN 搜索 API 支持使用过滤器限制搜索。 搜索将返回也与过滤器查询匹配的前 k 个文档。

以下请求执行由 file-type 字段过滤的近似 kNN 搜索:

POST image-index/_search
{
  "knn": {
    "field": "image-vector",
    "query_vector": [54, 10, -2],
    "k": 5,
    "num_candidates": 50,
    "filter": {
      "term": {
        "file-type": "png"
      }
    }
  },
  "fields": ["title"],
  "_source": false
}

注意:在近似 kNN 搜索期间应用过滤器以确保返回 k 个匹配文档。 这与后过滤方法形成对比,后者在近似 kNN 搜索完成后应用过滤器。 后过滤的缺点是它有时会返回少于 k 个结果,即使有足够的匹配文档也是如此。

将近似 kNN 与其他特征相结合

你可以通过提供 knn 选项和查询来执行混合检索:

POST image-index/_search
{
  "query": {
    "match": {
      "title": {
        "query": "mountain lake",
        "boost": 0.9
      }
    }
  },
  "knn": {
    "field": "image-vector",
    "query_vector": [54, 10, -2],
    "k": 5,
    "num_candidates": 50,
    "boost": 0.1
  },
  "size": 10
}

该搜索找到全局前 k = 5 个向量匹配,将它们与匹配查询中的匹配相结合,最后返回 10 个得分最高的结果。 knn 和查询匹配通过 disjunction 组合在一起,就好像你在它们之间取了一个布尔值或它们中间的值。 前 k 个向量结果表示所有索引分片中的全局最近邻。

每次命中的分数是 knn 和查询分数的总和。 你可以指定一个提升值来为总和中的每个分数赋予权重。 在上面的示例中,分数将计算为

score = 0.9 * match_score + 0.1 * knn_score

knn 选项也可以与聚合一起使用。 通常,Elasticsearch 计算所有匹配搜索的文档的聚合。 因此,对于近似 kNN 搜索,聚合是在前 k 个最近的文档上计算的。 如果搜索还包括一个查询,则聚合是根据 knn 和查询匹配的组合集计算的。

索引注意事项

对于近似 kNN 搜索,Elasticsearch 将每个段的密集向量值存储为 HNSW 图。 用于近似 kNN 搜索的索引向量可能需要大量时间,因为构建这些图的成本非常高。 你可能需要增加索引和批量请求的客户端请求的 timeout 值。 近似 kNN 调优指南包含有关索引性能以及索引配置如何影响搜索性能的重要指南。

除了其搜索时间调整参数外,HNSW 算法还有索引时间参数,这些参数在构建图形的成本、搜索速度和准确性之间进行权衡。 设置 dense_vector 映射时,可以使用 index_options 参数来调整这些参数

PUT image-index
{
  "mappings": {
    "properties": {
      "image-vector": {
        "type": "dense_vector",
        "dims": 3,
        "index": true,
        "similarity": "l2_norm",
        "index_options": {
          "type": "hnsw",
          "m": 32,
          "ef_construction": 100
        }
      }
    }
  }
}

近似 kNN 搜索的限制

  • 你不能在 nested 映射中的 dense_vector 字段上运行近似 kNN 搜索。
  • 跨集群搜索中使用 kNN 搜索时,不支持 ccs_minimize_roundtrips 选项。
  • Elasticsearch 使用 HNSW 算法来支持高效的 kNN 搜索。 与大多数 kNN 算法一样,HNSW 是一种近似方法,它牺牲结果准确性来提高搜索速度。 这意味着返回的结果并不总是真正的 k 最近邻。

注意:近似 kNN 搜索始终使用 dfs_query_then_fetch 搜索类型,以便跨分片收集全局前 k 个匹配项。 运行 kNN 搜索时不能明确设置 search_type。

猜你喜欢

转载自blog.csdn.net/UbuntuTouch/article/details/129816427