在Elasticsearch中进行相似向量搜索/相似图像搜索
(目录在这里)
首先
在之前的文章中,我们准备了大约一百万个1280维的图像特征向量,并将其投入到Amazon Elasticsearch Service中,但是我们得到了远离实用的结果,查询的响应时间达到了15秒。非常感谢Amazon ES团队给予我们的建议,因此我们按照他们的建议进行了重新验证。
如果对Elasticsearch非常熟悉的人来说,这个步骤可能是微不足道的,但对于我这个没有在实际服务中操作过Elasticsearch的人来说,这是非常有用的。
分割
查看上一次从Elasticsearch返回的响应,我们可以发现hits为1,203。此时,由于我们正在搜索最近的10个向量,所以k=10,并且我们可以知道结果至少来自于120个Elasticsearch Index。
{
"took": 15471,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1203,
"relation": "eq"
...
Elasticsearch 索引以 Shard 为单位进行分割,每个 Shard 都是 Lucene 索引。Lucene 索引在内部被分割成多个文件,称为 Segment。由于 Segment 是按顺序进行搜索的,所以 Segment 的数量越少,搜索效率就越高。
为了考虑搜索效率,Amazon ES的默认情况下,分片数为5,因此希望段数也为5。
设定
为了提高搜索效率,下面的设置建议被提出。
-
- index.refresh_interval = -1 (default: 1 sec)
-
- index.translog.flush_threshold_size = ‘10gb’ (default: 512mb)
- index.number_of_replicas = 0 (default: 1)
此外,也有建议针对索引时的线程数量进行改进,以提高索引效率。
-
- Tune for indexing speed
-
- Improving Indexing Performance in Amazon Elasticsearch Service
- Open Distro for Elasticsearch KNN
以下是用于插入改进后数据的代码。
import time
import math
import numpy as np
import json
import certifi
from elasticsearch import Elasticsearch, helpers
from sklearn.preprocessing import normalize
dim = 1280
fvecs = np.memmap('fvecs.bin', dtype='float32', mode='r').view('float32').reshape(-1, dim)
idx_name = 'imsearch'
es = Elasticsearch(hosts=['https://vpc-xxxxxxxxxxx.us-west-2.es.amazonaws.com'],
ca_certs=certifi.where())
res = es.cluster.put_settings({'persistent': {'knn.algo_param.index_thread_qty': 2}})
print(res)
mapping = {
'settings' : {
'index' : {
'knn': True,
'knn.algo_param' : {
'ef_search' : 256,
'ef_construction' : 128,
'm' : 48
},
'refresh_interval': -1,
'translog.flush_threshold_size': '10gb',
'number_of_replicas': 0
},
},
'mappings': {
'properties': {
'fvec': {
'type': 'knn_vector',
'dimension': dim
}
}
}
}
res = es.indices.create(index=idx_name, body=mapping, ignore=400)
print(res)
bs = 200
nloop = math.ceil(fvecs.shape[0] / bs)
for k in range(nloop):
rows = [{'_index': idx_name, '_id': f'{i}',
'_source': {'fvec': normalize(fvecs[i:i+1])[0].tolist()}}
for i in range(k * bs, min((k + 1) * bs, fvecs.shape[0]))]
s = time.time()
helpers.bulk(es, rows, request_timeout=30)
print(k, time.time() - s)
合并片段
即使是以上的代码,段落数也不会成为5,所以需要调用以下终点,主动进行合并。
POST /imsearch/_forcemerge?max_num_segments=1
由于这个操作需要一些时间,所以需要每隔几分钟调用一次,直到收到状态码200为止,以确认是否完成。当然,也可以暂时不定期地轮询一段时间。最终应该有5个片段。
刷新
由于设置 index.refresh_interval = -1,刷新操作未被执行,因此需要调用以下端点。(也可以将其恢复为默认的1秒)
POST /imsearch/_refresh
当刷新完成后,就可以进行已插入向量的搜索。
所以,当我实际执行并测试的时候,结果是…7秒…虽然花了相当长的时间,但这真是令人沮丧。当然,与上一次的15秒相比,这已经有了显著的改善,但7秒并不实用。另外,预热也没有特别的效果。此外,在除了搜索时间以外的方面,每个分片有50个命中结果,这实际上是理想的情况,因为每个分片有一个段。
{
"took": 7269,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 50,
"relation": "eq"
},
...
记忆
在本次实验中使用的是r5.large实例,拥有2核CPU和16GB内存。在Amazon ES的默认设置中,服务器内存的50%被分配给Elasticsearch,上限为32GB,并且剩余内存的一部分可以用于kNN。将所有剩余内存都分配给kNN是危险的,因此引入了断路器,限制了内存使用量。这可以通过knn.memory.circuit_breaker.limit进行设置,默认值为60%。因此,16GB * 50% * 60% = 4.8GB 是kNN的内存分配上限。
在HNSW中保持图/索引需要4*d + 8*M字节。我们在实验中使用了一百万个1280维的向量,而根据Amazon ES团队的建议,考虑到图的构建等问题,我们需要将需要量增加1.5倍。由于本次实验中M=48,所以需要(4*1280 + 8*48)*1.5*1M = 7.7GB的存储空间。
由于r5.large不足够,因此我重新创建了一个r5.xlarge(4个核心CPU,32GB内存)的集群并进行了新的尝试。在这种实例类型下,可以分配 32GB * 50% * 60% = 9.6GB的内存,因此满足了7.7GB的需求。由于CPU核心数量增加了,我也顺便将knn.algo_param.index_thread_qty参数调整为4,然后按照上述步骤生成了索引。
然后,发送了1000个搜索请求,计算搜索时间的平均值得到了14毫秒的结果,达到了实用水平的改善。
具有nmslib的ANN模型
作为ANN库,通常我们使用Faiss,所以在上一次实验中我们也用Faiss来处理HNSW。然而,Elasticsearch使用的是nmslib,所以为了确认,我们也做了比较。实例类型是r5.xlarge,用于集群。
考虑到它不是一个向量搜索引擎,而是一个全文搜索引擎,与使用Elasticsearch kNN相比,速度大约快了两倍。因此,可以说14毫秒的结果已经足够实用水平。
总结
多亏了亚马逊西班牙团队的支持,我们能够在Elasticsearch中实现类似搜索并达到实际可用的搜索时间。具体而言,通过适当设置段数和内存分配,我们看到了不可同日而语的改进与上一次相比。尽管HNSW是一种出色的ANN算法,但在内存效率方面,与采用倒排文件和产品量化(IVFPQ)等方法比较,它并不好。对于处理高维向量的Elasticsearch情况,我得到的印象是,最好在可能的范围内进行维度压缩。可以说,HNSW是一种适用于百万级数据的优秀算法,通过将其与Elasticsearch进行集群化,我们可以处理十亿级的数据,并实现可扩展性。