faiss入门+使用的索引原理

faiss入门+使用的索引原理

已经在项目中在离线时,用faiss算过50w 视频的相似度。
但感觉还是对faiss有些陌生,想对faiss理解更多一些。
有幸看到别人分享的这个帖子

Faiss 在项目中的使用
Faiss Indexs 的进一步了解

这里跟着上面两篇文章的思路,对faiss理解更多一些。

重新审视

1.再问faiss 是什么?

撇开具体定义不管,Faiss 就可以类比为一个可以设置索引数据库
faiss这个“数据库”里存的什么?对于Faiss来讲就是巨多的向量。
只是在 Faiss 中没有数据库存储介质这一层的概念,全部都是 Index,索引是干什么的? 更快的读取。

Faiss搜索的基本单位是单个向量
Faiss 默认输入一个向量 x,返回和 x 最相似的 k 个向量。

2. 构建Index

简单起见,我使用最基础的 Index 类型: “IDMap,Flat”, 它是暴力搜索。
马上我们讲大致哪些index,大概怎么选择
在自己的项目中,你当然需要选择自己需要的索引类型。

3. 查询Index

如果你选择需要训练的Index类型,请先训练它再添加向量。
查询 Index,函数:scores, neighbors = index.search(siftfeature, k=topN)

4. 构建 API 服务

参考
利用faiss和flask提供矢量搜索服务AP这篇文章中也是利用到了一个外国人git上分享的代码,faiss-web-service.

5.环境

直接用 docker。
Faiss docker 选择:https://hub.docker.com/r/waltyou/faiss-api-service/
里面 Faiss 、Flask、OpenCv 都已经装好了。

--------------------华丽的分割线-----------------------------

上面看完是不是还是挺懵,或者说感觉很神奇,为什么索引构建一下,训练一下,查询一下,就能出相似向量的结果了,还那么快。这就要说到faiss的核心了,各种索引。

faiss的几大特点

1. 如何更快

理论基础

为了加快搜索速度,我们可以按照一定规则或者顺序将数据集分段。 我们可以在 d 维空间中定义 Voronoi (沃诺瑞)单元,并且数据库每个向量都会落在其中一个单元。
在搜索时,查询向量 x, 可以经过计算,算出它会落在哪个单元格中。 然后我们只需要在这个单元格以及与它相邻的一些单元格中,进行与查询向量 x 的比较工作就可以了。
(这里可以类比 HashMap 的实现原理。训练就是生成 hashmap 的过程,查询就是 getByKey 的过程)。
上述工作已经通过 IndexIVFFlat 实现了。 这种类型的索引需要一个训练阶段,可以对与数据库矢量具有相同分布的任何矢量集合执行。 在这种情况下,我们只使用数据库向量本身。

但IndexIVFFlat 同时需要另外一个 Index: quantizer,来给 Voronoi 单元格分配向量。
每个单元格由质心(centroid)定义。 找某个向量落在哪个 Voronoi 单元格的任务,就是一个在质心集合中找这个向量最近邻居的任务。 这是另一个索引的任务,通常是 IndexFlatL2(IndexFlatL2: 一个蛮力L2距离搜索的索引)。

这里搜索方法有两个参数:
nlist(单元格的总数量),
nprobe (一次搜索可以访问的单元格数量,默认为1)。
搜索时间大致随着 nprobe 的值加上一些由于量化产生的常数,进行线性增长。
还是不好理解,用已有但知识类比一下,就是
nlist类似于kmeans中的k的数量,即k个类,
nprobe类似于每一次查询我只查kmeans中的n个类,n<k.

代码

import numpy as np
d = 64                           # dimension
nb = 100000                      # database size
nq = 10000                       # nb of queries
np.random.seed(1234)             # make reproducible
xb = np.random.random((nb, d)).astype('float32')
xb[:, 0] += np.arange(nb) / 1000.
xq = np.random.random((nq, d)).astype('float32')
xq[:, 0] += np.arange(nq) / 1000.

import faiss
nlist = 100
k = 4
quantizer = faiss.IndexFlatL2(d)  # 另外一个 Index
index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_L2)
       # 这里我们指定了 METRIC_L2, 默认它执行 inner-product 搜索。
assert not index.is_trained
index.train(xb)
assert index.is_trained

index.add(xb)                  # add may be a bit slower as well
D, I = index.search(xq, k)     # actual search
#不设置index.nprobe的时候,默认index.nprobe = 1,快但准确性不保证
print(I[-5:])                  # neighbors of the 5 last queries

index.nprobe = 10              # default nprobe is 1, try a few more
D, I = index.search(xq, k)
print(I[-5:])                  # neighbors of the 5 last queries


为了对比,我们用一个暴力但索引得出精确结果,和上面对比,

index = faiss.IndexFlatL2(d)   # build the index
print(index.is_trained)
index.add(xb)                  # add vectors to the index
print(index.ntotal)
D, I = index.search(xb[:5], k) # sanity check
print(I)
print(D)

D, I = index.search(xq, k)     # actual search
print(I[:5])                   # neighbors of the 5 first queries

得到的结果是:

index = faiss.IndexFlatL2(d) 暴力的结果:
[[ 9900 10500  9309  9831]
 [11055 10895 10812 11321]
 [11353 11103 10164  9787]
 [10571 10664 10632  9638]
 [ 9628  9554 10036  9582]]
index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_L2)
index.nprobe = 1的结果:
[[ 9900 10500  9831 10808]
 [11055 10812 11321 10260]
 [11353 10164 10719 11013]
 [10571 10203 10793 10952]
 [ 9582 10304  9622  9229]]
index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_L2)
index.nprobe = 10的结果:
[[ 9900 10500  9309  9831]
 [11055 10895 10812 11321]
 [11353 11103 10164  9787]
 [10571 10664 10632  9638]
 [ 9628  9554 10036  9582]]

结论

可以发现,
当 nprobe=1 的输出结果与蛮力搜索类似,但不完全相同(见上文)。 这是因为一些结果不在完全相同的 Voronoi 单元格中。 因此,访问更多的单元格可能会有用。

把 nprobe 上升到10的结果就是正确的结果(与蛮力搜索相同)。 请注意,在这种情况下获得完美结果仅仅是数据分布的工件,因为它在x轴上具有强大的组件,这使得它更容易处理。

nprobe 参数始终是调整结果的速度和准确度之间权衡的一种方法。 设置nprobe = nlist会产生与暴力搜索相同的结果(但速度较慢)。

2. 如何减少内存占用

理论基础

IndexFlatL2 和 IndexIVFFlat 都会存储所有的向量(准确说是向量的所有维)。
为了扩展到非常大的数据集(维度特别高),Faiss提供了一些变体,
他们提供产品量化器(product quantizer)来压缩,对存储的向量进行有损压缩。

矢量仍然存储在Voronoi单元中,但是它们的大小减小到可配置的字节数m(d必须是m的倍数,d:原维度)。
压缩基于Product Quantizer(论文地址), 其可以被视为额外的量化水平,其应用于要编码的矢量的子矢量。
在这种情况下,由于矢量未精确存储,因此搜索方法返回的距离也是近似值

d: 向量维数
quantizer: 粗量化器,存放了所有的粗聚类中心点
nlist: 粗聚类中心个数
m: 向量段数
n_bits:一般都取8,是指分段后的每段的聚类中心点的个数(或者说码)占用的bit数,8意味256个聚类中心点(每段)(c++源码中变量名是nbits_per_idx)
n_bits必须等于8,12或者16.nbits是存储向量的bits数目,一般都取8

参考Faiss基于PQ的倒排索引实现

代码

nlist = 100
m = 8                             # number of bytes per vector
quantizer = faiss.IndexFlatL2(d)  # this remains the same
index = faiss.IndexIVFPQ(quantizer, d, nlist, m, 8)
                                    # 8 specifies that each sub-vector is encoded as 8 bits
index.train(xb)
index.add(xb)
k = 4
D, I = index.search(xb[:5], k) # sanity check
print(I)
print(D)
index.nprobe = 10              # make comparable with experiment above
D, I = index.search(xq, k)     # search
print(I[-5:])

同样我们用暴力索引IndexFlatL2作为对比
import faiss                   # make faiss available
index = faiss.IndexFlatL2(d)   # build the index
print(index.is_trained)
index.add(xb)                  # add vectors to the index
print(index.ntotal)
k = 4                          # we want to see 4 nearest neighbors
D, I = index.search(xb[:5], k) # sanity check
print(I)
print(D)

D, I = index.search(xq, k)     # actual search
print(I[:5])                   # neighbors of the 5 first queries
print(I[-5:])                  # neighbors of the 5 last queries

实验结果1:

暴力索引IndexFlatL2的结果:
I 显示了与搜索向量最相近的 4 个邻居的 ID。 
[[  0 393 363  78]
 [  1 555 277 364]
 [  2 304 101  13]
 [  3 173  18 182]
 [  4 288 370 531]]
D 显示了与搜索向量之间的距离。
[[ 0.          7.17517328  7.2076292   7.25116253]
 [ 0.          6.32356453  6.6845808   6.79994535]
 [ 0.          5.79640865  6.39173603  7.28151226]
 [ 0.          7.27790546  7.52798653  7.66284657]
 [ 0.          6.76380348  7.29512024  7.36881447]]

而PQ索引的结果:
I 显示此时搜索到的与搜索向量最相近的 4 个邻居的 ID。 
[[   0  608  220  228]
 [   1 1063  277  617]
 [   2   46  114  304]
 [   3  791  527  316]
 [   4  159  288  393]]
D 显示此时搜索到的与搜索向量之间的距离。
[[ 1.40704751  6.19361687  6.34912491  6.35771513]
 [ 1.49901485  5.66632462  5.94188499  6.29570007]
 [ 1.63260388  6.04126883  6.18447495  6.26815748]
 [ 1.5356375   6.33165455  6.64519501  6.86594009]
 [ 1.46203303  6.5022912   6.62621975  6.63154221]]
 

结论1

我们可以观察到我们正确找到了最近邻居(它是矢量ID本身),但是矢量与其自身的估计距离不是0,尽管它明显低于到其他邻居的距离。 这是由于有损压缩造成的。

这里我们将64个(d=64)32位浮点数压缩为8个字节(m=8),因此压缩因子为32。

实验结果2:

搜索真实查询时,结果如下所示:
[[ 9432  9649  9900 10287]
 [10229 10403  9829  9740]
 [10847 10824  9787 10089]
 [11268 10935 10260 10571]
 [ 9582 10304  9616  9850]]

结论2

它们可以与上面的IVFFlat结果进行比较。 对于这种情况,大多数结果都是错误的,但它们位于空间的正确区域,如10000左右的ID所示。 实际数据的情况更好,因为:

  • 统一数据(这里的输入是random的)很难索引,因为没有可用于聚类或降低维度的规律性
  • 对于自然数据,语义最近邻居通常比不相关的结果更接近。

简化索引构建

由于构建索引可能变得复杂,因此有一个工厂函数在给定字符串的情况下构造它们。 上述索引可以通过以下简写获得:

index = faiss.index_factory(d, "IVF100,PQ8")
把”PQ8”替换为“Flat”,就可以得到一个 IndexFlat。

当需要预处理(PCA)应用于输入向量时,工厂就特别有用。 例如,要使用PCA投影将矢量减少到32D的预处理时,工厂字符串应该时:”PCA32,IVF100,Flat”。

3. 使用 GPU

获取单个 GPU 资源

res = faiss.StandardGpuResources()  # use a single GPU

使用GPU资源构建GPU索引

# build a flat (CPU) index
index_flat = faiss.IndexFlatL2(d)
# make it into a gpu index
gpu_index_flat = faiss.index_cpu_to_gpu(res, 0, index_flat)

多个索引可以使用单个GPU资源对象,只要它们不发出并发查询即可。

使用

获得的GPU索引可以与CPU索引完全相同的方式使用:

gpu_index_flat.add(xb)         # add vectors to the index
print(gpu_index_flat.ntotal)

k = 4                          # we want to see 4 nearest neighbors
D, I = gpu_index_flat.search(xq, k)  # actual search
print(I[:5])                   # neighbors of the 5 first queries
print(I[-5:])                  # neighbors of the 5 last queries

使用多个 GPU

使用多个GPU主要是声明几个GPU资源。 在python中,这可以使用index_cpu_to_all_gpus帮助程序隐式完成。

ngpus = faiss.get_num_gpus()

print("number of GPUs:", ngpus)

cpu_index = faiss.IndexFlatL2(d)

gpu_index = faiss.index_cpu_to_all_gpus(  # build the index
    cpu_index
)

gpu_index.add(xb)              # add vectors to the index
print(gpu_index.ntotal)

k = 4                          # we want to see 4 nearest neighbors
D, I = gpu_index.search(xq, k) # actual search
print(I[:5])                   # neighbors of the 5 first queries
print(I[-5:])                  # neighbors of the 5 last queries

--------------------华丽的分割线-----------------------------

上面看完是不是感觉索引真的很好用,而且原理大概也能看懂。但faiss是出了名的索引多,也就是说可选范围多,那我们大概要如何选了,这里有一点指引,其实也可以多试试,最后结果都是准确性和速度的折中权衡。

挑一个合适的 Index

如何根据实际情况选择 Index 呢?
可以以下根据几个必要的问题,来找到自己合适的 Index 类型。

需要注意:

  • 以下都通过 index_factory 字符串来表示不同 Index
  • 如果需要参数,使用相应的 ParameterSpace 参数

1. 是否需要精确的结果?

那就使用 “Flat”

可以保证精确结果的唯一索引是 IndexFlatL2。

它为其他索引的结果提供基线。

它不压缩向量,但不会在它们之上增加开销。
它不支持添加id(add_with_ids),只支持顺序添加.
因此如果需要 add_with_ids,请使用“IDMap,Flat”。

是否支持 GPU: yes

2. 是否关心内存?

请记住,所有Faiss索引都存储在RAM中。 以下的这些考虑是,如果我们不需要精确的结果而 RAM 又是限制因素,那么,我们就需要在内存的限制下,来优化精确-速度比(precision-speed tradeoff)。

如果不需要关心内存:“HNSWx”

如果你有大量的RAM或数据集很小,HNSW 是最好的选择,它是一个非常快速和准确的索引。 x 的范围是[4, 64],它表示了每个向量的链接数量,越大越精确,但是会使用越多的内存。

速度-精确比(speed-accuracy tradeoff)可以通过 efSearch 参数来设置。 每个向量的内存使用是情况是(d * 4 + x * 2 * 4 )。

HNSW 只支持顺序添加(不是add_with_ids),所以在这里再次使用 IDMap 作为前缀(如果需要)。 HNSW 不需要训练,也不支持从索引中删除矢量。

是否支持 GPU: no
如果有些担心内存:“…,Flat”

“…” 表示必须事先执行数据集的聚类(如下所示)。 在聚类之后,“Flat”只是将向量组织到不同桶中,因此它不会压缩它们,存储大小与原始数据集的大小相同。 速度和精度之间的权衡是通过 nprobe 参数设置的。

是否支持 GPU: yes(但是聚类方法也需要支持GPU)
如果相当关心内存:“PCARx,…,SQ8”

如果存储整个向量太昂贵,则执行两个操作:

使用尺寸为x的PCA以减小尺寸
每个矢量分量的标量量化为1个字节。
因此,总存储量是每个向量 x 个字节。

是否支持 GPU: no
如果非常关心内存:“OPQx_y,…,PQx”

PQx 代表了通过一个product quantizer压缩向量为 x 字节。 x 一般 <= 64,对于较大的值,SQ 通常是准确和快速的。

OPQ 是向量的线性变换,使其更容易压缩。 y是一个维度:

y是x的倍数(必需)
y <= d,d为输入向量的维度(最好)
y <= 4*x(最好)

是否支持 GPU: yes(注意:OPQ转换是在软件中完成的,但它不是性能关键)

3. 数据集有多大

这个问题用于选择聚类选项(就是上面的那些”…“)。 数据集聚集到存储桶中,在搜索时,只访问了一小部分存储桶(nprobe 个存储桶)。 聚类是在数据集矢量的代表性样本上执行的,通常是数据集的样本。 我们指出该样本的最佳大小。

如果向量数量低于1百万:”…,IVFx,…”

当数据集的数量为 N 时,那么 x 应该处于 4 * sqrt(N) 和 16 * sqrt(N) 之间。 这只是用k-means聚类向量。 你需要 30 * x 到 256 * x 的矢量进行训练(越多越好)。

是否支持 GPU: yes

如果向量数量位于1百万-1千万之间:”…,IMI2x10,…”

(这里x是文字x,而不是数字)

IMI在训练向量上执行具有2 ^ 10 个质心的 k-means,但它在向量的前半部分和后半部分独立地执行。 这将簇的数量增加到 2^(2 * 10)。您将需要大约64 * 2 ^ 10个向量进行训练。

是否支持 GPU: no

如果向量数量位于1千万-1亿之间:”…,IMI2x12,…”

与上面相同,将10替换为12。
是否支持 GPU: no

如果向量数量位于1亿-10亿之间:”…,IMI2x14,…”

与上面相同,将10替换为14。
是否支持 GPU: no

1 单元-探测(Cell-probe) 方法

以失去保证以找到最近邻居为代价来加速该过程的典型方法是采用诸如k均值的分区技术。 相应的算法有时被称为 cell-probe 方法:

我们使用基于多探测的基于分区的方法(可以联想到best-bin KD-tree的一种变体)。

  • 特征空间被划分为 ncells 个单元格。
  • 由于散列函数(在k均值的情况下,对最靠近查询的质心的分配),数据库向量被分配给这些单元中的一个,并且存储在由ncells反向列表形成的反向文件结构中。
  • 在查询时,会选择一组 nprobe 个的反向列表
  • 将查询与分配给这些列表的每个数据库向量进行比较

这样做,只有一小部分数据库与查询进行比较:作为第一个近似值,这个比例是 nprobe / ncells,但请注意,这个近似值通常被低估,因为反向列表的长度不相等。 当未选择给定查询的最近邻居的单元格时,将显示失败案例。

在C++中,相应的索引是索引IndexIVFFlat。

构造函数将索引作为参数,用于对反转列表进行赋值。 在该索引中搜索查询,并且返回的向量id(s)是应该被访问的反向列表。

2. 具有平坦索引作为粗量化器的单元探测方法

一般的,我们使用一个 Flat index 作为粗糙量化。 IndexIVF 的训练方法给 flat index 添加了质心。 nprobe 的值在搜索时设置(对调节速度-准确比很管用)。

注意: 根据经验,n 表示要被索引的点的数量, 一般确定合适质心数量的方法是在“分配向量到质心的开销(如果是纯粹的kmeans:ncentroids * d)” 和 “解析反转列表时执行的精确距离计算的数量(按照 kprobe / ncells * n * C 的顺序,其中常量 C 考虑了列表的不均匀分布, 以及当使用质心批处理时单个矢量比较更有效的事实,比如 C = 10)”之间找平衡。

这导致了许多质心的数量都遵循 ncentroids = C * sqrt(n)。

注意: 在引擎盖下,IndexIVFKmeans 和 IndexIVFSphericalKmeans 不是对象,而是返回正确设置的 IndexIVFFlat 对象的函数。

警告: 分区方法容易受到维数的诅咒。对于真正高维数据,实现良好的召回需要具有非常多的probe。

3. 基于量化的方法

基于产品量化的索引由关键字PQ标识。 例如,基于产品量化的最常见索引声明如下:

m = 16                                   # number of subquantizers
n_bits = 8                               # bits allocated per subquantizer
pq = faiss.IndexPQ (d, m, n_bits)        # Create the index
pq.train (x_train)                       # Training
pq.add (x_base)                          # Populate the index
D, I = pq.search (x_query, k)            # Perform a search

位数n_bits必须等于8,12或16. 维度d应为m的倍数.

4. 具有PQ细化的反转文件

IndexIVFPQ 可能是大规模搜索最有用的索引结构。

coarse_quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFPQ (coarse_quantizer, d,
                          ncentroids, code_size, 8)
index.nprobe = 5

有关ncentroids的设置,请参阅有关IndexIVFFlat的章节。 code_size 通常是4到64之间的2的幂。 对于 IndexPQ,d 应该是 m 的倍数。

5. 示例:应用PCA以减少尺寸数

使用 IndexPreTransform
例如,如果输入向量是2048D,并且必须减少到16个字节,那么使用PCA减少它们是有意义的。

# the IndexIVFPQ will be in 256D not 2048
coarse_quantizer = faiss.IndexFlatL2(256)
sub_index = faiss.IndexIVFPQ (coarse_quantizer, 256, ncoarse, 16, 8)
# PCA 2048->256
# also does a random rotation after the reduction (the 4th argument)
pca_matrix = faiss.PCAMatrix (2048, 256, 0, True) 

#- the wrapping index
index = faiss.IndexPreTransform (pca_matrix, sub_index)

# will also train the PCA
index.train(...)
# PCA will be applied prior to addition
index.add(...)

6. IndexRefineFlat:重新排名搜索结果

在查询向量时,使用实际距离计算对搜索结果进行重新排序可能是有用的。 以下示例使用 IndexPQ 搜索索引,然后通过计算实际距离重新排列第一个结果:

q = faiss.IndexPQ(d, M, nbits_per_index)
rq = faiss.IndexRefineFlat(q)
rq.train (xt)
rq.add (xb)
rq.k_factor = 4
D, I = rq.search(xq, 10)

搜索功能将从 IndexPQ 中获取4 * 10个最近邻居,然后计算每个结果的实际距离,并保留10个最佳结果。 请注意,IndexRefineFlat 必须存储完整的向量,因此它不具有内存效率。

7. IndexShards:组合来自多个索引的结果

当数据集分布在多个索引上时,可以通过它们调度查询,并将结果与 ​​IndexShards 结合使用。 如果索引分布在多个GPU上并且查询可以并行完成,这也很有用. 请参阅在 GpuClonerOptions中 将 shards 设置为 true 的 index_cpu_to_gpus 。

发布了93 篇原创文章 · 获赞 8 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/zlb872551601/article/details/103704874