本文主要是介绍faiss 暴搜/ivf/ivfpq,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
一、总体
总揽
faiss提供了许多类型的近邻搜索算法实现,如下:
一级分类 | 二级分类 | 特点 |
IndexBinary | IndexBinaryFlat | Binary指以汉明距离作为度量 |
IndexBinaryFromFloat | ||
IndexBinaryHash | ||
IndexBinaryHNSW | ||
IndexBinaryIVF | ||
Index | IndexFlatCodes | 暴搜的基类 |
MultiIndexQuantizer | 作为量化器存在 | |
AdditiveCoarseQuantizer | 作为量化器存在 | |
ResidualCoarseQuantizer | 作为量化器存在 | |
LocalSearchCoarseQuantizer | 作为量化器存在 | |
IndexPreTransform | 升降维方法类 | |
IndexLattice | 一种特殊编码的索引 | |
IndexNNDescent | 一种特殊编码的索引 | |
IndexNSG | nsgd的faiss实现(很少用) | |
IndexPQFastScan | 使用另一种乘积量化编码存储方式,方便SIMD指令快速求解 | |
IndexRefine | 这是一个鸡肋功能 | |
IndexHNSW | hnsw的faiss实现(实现的不好,很少用) | |
Index2Layer | 没有倒排链的IVFPQ(无用) | |
IndexFlat | 暴搜的实现 | |
IndexLSH | hash近邻的faiss实现 | |
IndexPQ | 乘积量化实现 | |
IndexScalarQuantizer | 作为量化器存在 | |
IndexAdditiveQuantizer | 作为量化器存在 | |
IndexIVF | 倒排的基类 | |
IndexIVFFlat | 倒排暴搜实现 | |
IndexIVFPQ | 倒排乘积量化实现 |
本文主要涉及,faiss被使用时最被常用的索引,包括暴力搜索、一级量化搜索(粗聚类搜索,IVFFlat)、乘积量化搜索(IVFPQ及其优化变种)。下图是这些最常用索引及其组件类的UML关系图:
PS:没列在上面的不一定没用:
原因1:我还不了解
原因2:确实不常用
二、暴搜(IndexFlat)
原理
暴搜的原理很简单,它就是简单的让待查询向量,和每个备选向量都计算一遍相似度,选取topk,召回率100%。
代码结构分析
暴搜的代码结构非常简单:IndexFlat (继承) IndexFlatCodes (继承) Index,对外直接用IndexFlat
Index
Index基类定义了最基本的操作方法(个人称之为原语),派生类根据自身情况重写。Index也不完全是虚基类,也定义了一些最基本的方法(一些不会变的功能):
PS:下表未标明"外部api"的方法,并非外部不可调用,而是其功能更多是在内部用。
成员/方法 | 功能 |
d | 原始向量维度 |
ntotal | 原始向量个数 |
verbose | debug |
is_trained | 是否train完成 |
metric_type | 距离度量方式 |
train | 对原始向量,按自己的方式进行编码,外部api |
add | 增加n个向量 |
add_with_ids | 增加n个向量,含label,外部api |
search | topk,外部api |
range_search | topk并有距离约束,外部api |
assign | 查找n个向量最近邻的k个向量 |
reset | 清空索引数据 |
remove_ids | 删除索引中特定的数据,外部api |
reconstruct/reconstruct_n | 获取索引中,特定数据的编码 |
search_and_reconstruct | 在search基础上,还有获取原向量(如不可逆以近似结果代替),外部api |
compute_residual/compute_residual_n | 对1/n个原始向量做量化后计算残差 |
get_distance_computer | 返回对应索引的距离计算类 |
sa_code_size | 每个原始向量编码后字节数 |
sa_encode | 对n个原始向量进行编码 |
sa_decode | 对n个编码反推原始向量(如不可逆以近似结果代替) |
量化与编码
上面的表格有许多"量化"、"编码"的字眼:
1、所谓量化就是索引对每个向量用一套处理办法,把它转换为某种形式的存在,比如对于暴搜索引,并没有量化就是直接复制,那么还是以原本形式存在,再比如倒排暴搜索引和倒排乘积量化索引,每个向量根据它和哪个一级聚类中心近,落到对应的聚类簇,这就是索引对原始向量的量化
2、确定了量化方式后,每个原始向量,被量化后的结果,就是它的编码。比如暴搜索引和倒排暴搜索引,编码其实就是原始向量自己,再比如倒排乘积量化索引,编码是原始向量各分段的细聚类id拼接在一起
3、有的编码是可逆的,比如暴搜索引和倒排暴搜索引,通过索引可以找回来原始向量,有的则是不可逆的,比如倒排乘积量化索引。不可逆的好处是什么?索引体积下降 + 检索可以查表加速。不可逆的坏处是什么?检索精度下降 + 无法获取原向量(如用于video debug工具)
IndexFlatCodes
这个类并非对外直接用,是一个过渡类,不过暴搜索引实际定义在这里:
成员/方法 | 功能 |
code_size | 原始向量占用多少字节 = d * sizeof(float) |
codes | 原始向量编码。实际是连续内存直接存储原始向量 |
add | 增加原始向量,无label |
reset | 清除全部索引数据 |
sa_code_size | 返回code_size |
remove_ids | 在索引中清除指定的数据 |
reconstruct/reconstruct_n | 获取若干原始向量 |
1、暴搜索引内存布局
std::vector<uint8_t>型的IndexFlatCodes::codes存储了全部原始向量,个数Index::ntotal,每个原始向量长度IndexFlatCodes::code_size。
2、追加原始向量
增加n个原始向量x的时候,调用:memcpy(&codes[ntotal * code_size], x, sizeof(float) * d * n);
3、删除全部向量
调用IndexFlatCodes::reset()方法,codes.clear() && ntotal = 0
4、删除特定向量
暴搜索引没有label的概念,所以只能按照向量添加时,自己在codes的位置删除。具体的删除办法是o(n)双指针memmove。
值得一提的是faiss为id查找提供了3种方式,一种是数组(IDSelectorArray),靠遍历判断,一种比较巧妙不过很鸡肋(IDSelectorBatch),一种是范围型(IDSelectorRange)
所有增删方法都没有锁。
IndexFlat
faiss对向量浮点计算主要做了两方面的优化,一个是基于omp实现并行无关的计算流程实现在多核的多线程并发,一个是基于SIMD的指令的向量计算并发,以及两者的结合。前者更多在处理流程上,将可以并行互不干扰的计算任务,拆到多核上的线程去做,后者则贯穿整个faiss的全部浮点数加减乘除开方等数学计算,大幅压缩向量计算的耗时。
暴搜本身很容易理解,不论内积还是l2,其实都是浮点数乘法加法运算。faiss根据要查询的topk个数、待查询的请求数,包括topk算法本身都做了相关优化。下面是暴搜的流程:
topk算法
openmp
simd
三、含倒排暴搜(IVFFLAT)
原理
相比纯暴搜,ivfflat首先对数据做若干聚类(nlist指定),每个原始向量被归类到某一个聚类。查询时,遍历全部聚类中心向量,得到与待查询向量最近邻的若干个聚类中心(nprobe指定),然后在这些聚类下的原始向量中再通过暴搜,得到与待查询向量最近邻的topk个原始向量。
相比纯暴搜,ivfflat需要有train的过程,查询性能上比纯暴搜有提升(如nprobe = nlist则退化为暴搜),索引体积比纯暴搜略大一点点几乎一样大(多存了几个聚类中心向量和关联数据结构)。但总体上性能仍较低。
召回率方面,只要满足如下两个方面,召回率无限接近并且经常就是100%:
a、nlist设置不要太小,通常是sqrt(全量数据集)。当前数据量不是非常大,通常train全量的数据,这也是确保100%准确的一个原因。
b、nprobe只要别设置太小,确保topk < top(nprobe)倒排链的元素数量
代码结构分析
也非常简单,IndexIVFFlat (继承) IndexIVF (继承) Level1Quantizer, Index
Level1Quantizer
这个类专门用于通过聚类形成量化,level1充分描述了它的角色:一级量化器,或者叫粗量化器,也就是对原始数据集做第一轮粗糙的分布。
成员/方法 | 功能 |
quantizer | 通过它实现一级量化的索引(回指) |
nlist | 分类数 |
cp | 聚类器参数 |
clustering_index | 构造时传入,在其基础上继续聚类,一般都会重新聚类不会这样用。 |
train_q1() | 聚类 |
聚类结果也就是聚类中心向量集合,会返回给所属索引。
Clustering
IndexIVF
这个类是个过渡类,它规定了倒排系列索引的共有特性:
成员/方法 | 功能 |
构造 | 用IndexFlat作为一级量化索引,即存储全部聚类中心向量 |
invlists | 倒排链 |
code_size | 倒排中每个元素的编码的长度。对于ivfflat肯定是原始向量长度,但对于其他ivf系列索引就不一定了。 |
nprobe | nprobe的兜底参数。默认1 |
max_codes | topk查询时,控制最大的比较次数。控制效率用的,默认-1不开启 |
parallel_mode | 并行查询模式,见详细介绍 |
direct_map | 正排表,默认不开启 |
train | ivf系列索引的共同train入口,对ivfflat只调用Level1Quantizer::train_q1(),其他索引还有其他训练。 |
add_with_ids() & add_core() | 将n个原始向量及其label加入到索引。先通过search找到它最近邻的聚类中心向量,然后加入对应的倒排链。这里有一个omp典型极巧优秀运用 |
make_direct_map() & set_direct_map_type() | 设置正排表类型 |
search() & range_search | topk查询,是所有ivf系列索引的共同topk入口 |
search_preassigned() & range_search_preassigned() | 所有ivf系列索引在调用search后,分为两部分,一部分是首先跑出粗聚类,一部分是根据粗聚类结果继续查询,这个函数就是各个ivf系列索引的继续查询的部分。 |
search_and_reconstruct() | 不仅查topk,还要把向量编码拿出来。对于ivfflat,就是还要获取原始向量 |
ivf系列索引的相同点是:都是通过粗量化,实现一级分类,不同向量归属各自的倒排链
ivf系列索引的区别点是:原始向量在倒排链上,怎么存储怎么查询,就各自不一样了
IndexIVFFlat
相比IndexIVF,IndexIVFFlat重写了add_core、encode_vectors、reconstruct_from_offset、sa_decode四个函数方法:add_core(),巧妙实现了基于omp的无锁并行写倒排链。其余都是功能性函数。
倒排暴搜的train
IndexIVF::train(),对于IndexIVFFlat实际是调用Level1quantizer::train_q1(),就是对全量数据搞nlist个聚类。聚类完成后,全部聚类中心向量存在一级量化索引。
我们当前数据量不大,实际是把全部数据去train,这样的好处是准确度不打折。事实上即便真的很大,如果用ivfflat索引,实际也是这样去做。
倒排暴搜的add
ivf系列索引都支持label的加入,这是和普通暴搜很大的区别,也就是可以获取到原始feed_id、video_id信息。
对于IndexIVFFlat,add就是将原始向量及其label,加入到对应倒排链。ivf系列索引到的排链实际是包括两部分:原始向量编码链 和 原始向量的label链。
倒排链
倒排链的代码结构如下:
对于ivf系列索引全都使用ArrayInvertedLists作为倒排链,ArrayInvertedLists实际包括两条倒排链,一个存原始向量的编码,一个是对应的label信息,每个原始向量在其中的offset一致。
值得注意的是,faiss中对倒排链的操作,都是通过正排(direct_map)的操作顺带进行,并且此正排和文本正倒排索引中正排还是存在极大区别(甚至不能称之为正排)。所谓正排数据结构如下:
direct_map本身在faiss代码中存在感很低,只是invlist的curd函数都是以direct_map的接口函数为入口,faiss中完全没有用到direct_map自身功能的地方,原因是faiss对direct_map的设计有问题:比如构建索引时插入100个原始向量,direct_map不论是array型的vector数组,还是hashtable型的unordered_map,数组索引或映射的key,都是每个原始向量在插入时的顺序id,这样当需要remove_ids、update_codes或再次add时,根本查不到之前的原始向量,所以根本就没法用,默认也没有开启。
此外,faiss的向量倒排索引并没有像文本正倒排索引的"倒排链优先级",direct_map也没有存储每个entry的丰富的正排信息。对倒排链元素的具体选取哪个,是通过倒排链元素,依次的通过ivf索引的距离计算方式(如ivfflat的暴搜、ivfpq的查表)去计算,而所需的正排信息,是如ArrayInvertedLists这样存在了与原始向量编码(codes)的另外一个链中,通过offset取对应向量的label信息。
如果有这样的需求:需要像文本正倒排索引一样,正排存储着丰富的信息,需要怎么做呢?需要对现有的direct_map做相应更新,令其可通用化的curd各种格式的正排信息,这样倒排链也可以取消label这条链。目前feed/video都可能需要,目前做法是,上传正排信息(profile文件)到s3,然后再根据label和profile文件形成一个映射,topk查询后,根据label中的id信息,反查映射获取正排信息。事实上这是一种低效做法,索引构建和加载都较繁琐,并且大量正排信息通过unordered_map存储,构建、存储、查询都不高效,而且对实现增量构建形成极大麻烦,恰恰这部分是许多针对faiss ivf系列索引的开源代码的主要改进点之一。
下面是IVFFLAT的索引内存布局:
std::vector<std::vector<uint8_t>> codes // 存储原始向量
std::vector<std::vector<uint64_t>> ids // 存储对应的label
倒排暴搜的add
了解了倒排链及其相关上下文,理解IndexIVFFlat的add就非常容易了,实际就是在一级量化后,每个原始向量找到自己对应的聚类中心,然后插入到这个聚类中心对应的倒排链即可,细节上faiss通过omp实现了高效并行且不上锁的优秀榜样代码:
void IndexIVFFlat::add_core(idx_t n,const float* x,const int64_t* xids,const int64_t* coarse_idx){FAISS_THROW_IF_NOT(is_trained);FAISS_THROW_IF_NOT(coarse_idx);assert(invlists);direct_map.check_can_add(xids);int64_t n_add = 0;DirectMapAdd dm_adder(direct_map, n, xids);#pragma omp parallel reduction(+ : n_add){int nt = omp_get_num_threads();int rank = omp_get_thread_num();// each thread takes care of a subset of lists// 这是omp开发中一个常用规避出现竞争技巧,每个线程负责若干个倒排链的插入操作,每个倒排链只属于一个线程,所以不会出现任何问题for (size_t i = 0; i < n; i++) {idx_t list_no = coarse_idx[i];if (list_no >= 0 && list_no % nt == rank) {idx_t id = xids ? xids[i] : ntotal + i;const float* xi = x + i * d;size_t offset =invlists->add_entry(list_no, id, (const uint8_t*)xi);dm_adder.add(i, list_no, offset);n_add++;} else if (rank == 0 && list_no == -1) {dm_adder.add(i, -1, 0);}}}if (verbose) {printf("IndexIVFFlat::add_core: added %" PRId64 " / %" PRId64" vectors\n",n_add,n);}ntotal += n;
}
倒排暴搜的search
所有ivf系列索引的search即topk入口函数都是IndexIVF::search,这里包括两项重点:
1、不同的基于omp并发的模式
2、scanner数据结构的理解
search的整体流程是:
可见omp并发包括两个层次,针对待查询变量,首先就做了一次分拆并发,每个分片还会再做并发。所谓不同的omp的并发模式,指的是上图最底下的"根据不同并行方式查询",即分片内部的并发。
不同omp的并发模式
IndexIVF::parallel_mode指示了三种ivf系列索引的并发查询方式,围绕查询相关三操作去了解它们的异同:
a、待查询向量之间是否并发
b、每个待查询向量的nprobe个倒排链查询是否并发
c、各个倒排链查询结果的merge操作是怎样的
方式1、待查询向量的水平并发,倒排链查询串行
即比如,待查询向量是10个,则这10个待查询向量,是多核多线程并发查询的。它的流程是:
a、每个待查询向量,是并发查询的
b、每个向量的nprobe个一级分类的查询,是串行查询的
c、对每个待查询向量,最终结果的插入一次性的(len(nprobe个倒排链元素)*log(k))
这种模式是ivf系列索引的默认方式,适合待查询向量比较多的情况,而我们目前每次只查一个向量(最多feed尝试过每次查3个),实质上非常不适合这种方式,这部分需要修正。
if (pmode == 0 || pmode == 3) {
#pragma omp forfor (idx_t i = 0; i < n; i++) {if (interrupt) {continue;}// loop over queriesscanner->set_query(x + i * d);// 当前待查询向量的返回结果simi、idxifloat* simi = distances + i * k;idx_t* idxi = labels + i * k;init_result(simi, idxi);idx_t nscan = 0;// loop over probes// pmode = 0, 并行搜索x0,x1,x2等向量的近邻向量,但是对于每个向量搜索nprobe个倒排list时是串行的/*参数依次是: 粗聚类中心id与粗聚类中心的残差simi、idxi:返回*/for (size_t ik = 0; ik < nprobe; ik++) {nscan += scan_one_list(keys[i * nprobe + ik],coarse_dis[i * nprobe + ik],simi,idxi);if (max_codes && nscan >= max_codes) {break;}}ndis += nscan;// 重新堆排序reorder_result(simi, idxi);if (InterruptCallback::is_interrupted()) {interrupt = true;}} // parallel for}
方式2、待查询向量串行,倒排链查询并发
即比如,待查询向量是10个,则这10个待查询向量,是串行查询的。它的流程是:
a、每个待查询向量,是串行查询的
b、每个向量的nprobe个一级分类的查询,是并行查询的
c、对每个待查询向量,最终结果的插入nrpobe次的(平均复杂度近似:(len(当前倒排链元素)*log(k))*nprobe) (多个倒排链查询流程fork/join的运行,结果插入存在同步机制)
这个方式最为适合我们的场景。
else if (pmode == 1) {std::vector<idx_t> local_idx(k);std::vector<float> local_dis(k);for (size_t i = 0; i < n; i++) {scanner->set_query(x + i * d);init_result(local_dis.data(), local_idx.data());#pragma omp for schedule(dynamic)for (idx_t ik = 0; ik < nprobe; ik++) {ndis += scan_one_list(keys[i * nprobe + ik],coarse_dis[i * nprobe + ik],local_dis.data(),local_idx.data());// can't do the test on max_codes}// merge thread-local resultsfloat* simi = distances + i * k;idx_t* idxi = labels + i * k;
#pragma omp singleinit_result(simi, idxi);#pragma omp barrier
#pragma omp critical{add_local_results(local_dis.data(), local_idx.data(), simi, idxi);}
#pragma omp barrier
#pragma omp singlereorder_result(simi, idxi);}}
方式3、以每个倒排链查询为单位并行查询
即比如,待查询向量是10个,nrpobe=50,那么500并发的查询,它的流程是:
a、每个待查询向量的nprobe个一级分类的查询,都是并行查询的
c、对每个待查询向量,最终结果的插入nrpobe次的,需要nprobe*待查询向量个数的线程同步
这种模式适合待查询向量很多且nprobe非常大的情况。
scanner数据结构的理解
scanner是ivf系列索引查询时,构建的一个方便查询流程的封装工具类对象,粒度是每个待查询向量的每个待查询倒排链。faiss对它的封装层次很深。每个ivf系列索引的派生类必须实现get_InvertedListScanner方法和scanner派生类,生成自己的scanner对象。对于IndexIVFFlat如下:
对每个待查询向量都构建scanner对象,通过scanner::scan_codes方法实现每个待查询向量的当前待查询倒排链的查询。每个ivf系列索引须实现自己的scanner派生类,并包括上图中的方法。对于IndexIVFFlat,细节比较简单全在上图。
四、倒排乘积量化(IVFPQ)
原理
它和IVFFLAT的区别在于,倒排链存储的不再是原始向量本身,而是原始向量的一种编码,之所以叫乘积量化,叫乘积是因为这种编码不是一个编码,而是多个编码的拼接,叫量化是因为它不再存储原始向量,而是存储原始向量的一种近似描述。
IVFFLAT的缺点
IVFFLAT在设置足够大的nlist和线上查询的nprobe后,召回率很好,但问题是耗时较大,且维度越大耗时越大,因为需要的计算量更大。如下调nlist和nprobe,耗时有所下降,但召回率明显下跌。找到召回率和耗时的一个平衡比较困难甚至根本无法满足。
可能一种直观的想法时:在量化上再套量化,比如每个粗聚类簇,再在其内部元素做聚类,这其实解决不了问题的,因为一级量化已经产生误差,还是得提升nlist或nprobe。
高维度向量聚类的缺点
IVFFLAT对于维度越大的向量,误差耐受度越差。具体说就是,一级量化出现误差可能性越大。
简单举个小例子,比如聚类中心分别是[10]、[50],其中[10]有近邻[8],[50]有近邻[33],待查询变量[25],nprobe=1,会把[8]判为[25]的近邻,其实[33]更接近。只要有量化,就有可能带来误差。
随着维度越来越大,这样的误判会越来越多。这个其实可以直观想象:一维向量量化发生误差时的错误取值范围是N的话,二维向量发生误差,两个维度的错误取值范围肯定更大(一维向量可以按点思考,二维向量可以按线段思考,一维向量的误差由x产生,二维向量的误差可以由x、y产生),多维向量则误差范围更大。
PQ的核心思想
PQ的核心理论是,将高维向量的误差,拟合为,分段的低维向量的误差之和,以"分段的低维向量的误差之和"越低的,作为ann的计算结果。
请注意上面的"拟合",也就是说将"分段的低维向量的误差之和",当作实际误差。换句话说这其实是存在误差的。
PS:几乎100%的文章,完全没有写PQ究竟是出于什么思考,解决什么问题......
IVFPQ是怎么构建索引的
IVFPQ是倒排(ivf)和乘积量化(pq)的结合,它依然有一级量化,但一般不会像IVFFLAT聚类那么多(nlist),对于IVFPQ一级量化的目的是,尽可能将肯定不近邻的排除,然后对每个一级量化倒排链下的原始向量,对它们与一级量化聚类中心向量的残差,做分段聚类,这样每个原始向量,就被量化为,所属一级量化的倒排链中的,各分段的最近邻的聚类中心向量,的拼接:
举一个编造的例子:比如原始向量[1,2,3,4],它离一级量化聚类中心向量[1.1,1.9,3.1, 4.2]最近,这样原始向量[1,2,3,4]与这个一级量化聚类中心向量[1.1,1.9,3.1, 4.2]的残差等于[-0.1, 0.1, -0.1, -0.2],假定残差训练分2段,且其中[-0.1,0.1]与第0段的细聚类中心向量[-0.07,0.15]最近邻,[-0.1,-0.2]与第1段的细聚类中心向量[-0.13,-0.17]最近邻,那么原始向量[1,2,3,4]就被量化为:
a、它在一级量化聚类中心向量[1.1,1.9,3.1, 4.2]的倒排链中,假定倒排链id = 10
b、它的残差的乘积量化,分别是[-0.07,0.15]、[-0.13,-0.17]两个分段细聚类中心,假定两个细聚类中心id分别是15、25
c、那么原始向量[1,2,3,4],被归到"粗聚类[10][1525]"的向量集合中。IVFPQ索引不再存储[1,2,3,4]的原始向量,而是存:倒排链id(10)、所属的两个分段细聚类id(15、25)
三个收益和一个负面影响:
收益1、索引大幅变小
倒排链元素不存原始向量存分段id
收益2、查询耗时也变小
本身就由暴搜,变成只计算残差与分段细聚类中心近邻的距离,同时还有查表方式加速后边讲
收益3、很有限的若干id组合可以表达海量可能性
乘积量化先天能力而已。并不是因为这个才用乘积量化。99.99%网上文章这方面都写错了
负面、召回率相比IVFFLAT下降
IVFPQ将高维向量的distance,拟合以"分段低维残差聚类中心"代表,而IVFFLAT是老老实实的暴力计算,召回率必然下不如IVFFLAT。这是PQ的代价。
IVFPQ是怎么查询的
如果待查询向量如[0.9,2.1,3.05,3.95](假定它确实是原始向量[1,2,3,4]的近邻),它怎么查询ann呢?
a、它和全部一级量化聚类中心向量做暴搜比较,找到了最近邻的一级量化聚类中心向量[1.1,1.9,3.1, 4.2],残差 = [-0.2,0.2, -0.05,-0.25]
b、对残差分段,第0段是[-0.2,0.2],第1段是[-0.05,-0.25]
c、第0段残差[-0.2,0.2],和第0段各个细聚类中心暴搜比较,得出最近邻的是[-0.07,0.15]
d、第1段残差[-0.05,-0.25],和第1段各个细聚类中心暴搜比较,得出最近邻的是[-0.13,-0.17]
e、OK,那么待查询向量[0.9,2.1,3.05,3.95],与"以[1.1,1.9,3.1, 4.2]为一级量化结果,且第0分段残差细聚类中心是[-0.07,0.15],第1分段残差细聚类中心是[-0.13,-0.17]"代表的原始向量,distance = dist([0.9,2.1,3.05,3.95],[1.1,1.9,3.1, 4.2]) +dist([-0.2,0.2],[-0.07,0.15]) + dist([-0.05,-0.25],[-0.13,-0.17])
含义是:待查询向量与粗聚类中心距离 + sum各分段(dist(待查询向量与粗聚类中心残差分段,分段对应的最近邻细聚类中心向量)
如果是L2度量:
distance = || x - y_C || ^ 2 + sum各分段(|| x - y_C - y_R || ^ 2)
如果是IP度量:
distance = x|y_C + sum各分段((x - y_C)|y_R)
IVFPQ的针对L2的查表加速查询
针对L2度量,distance = || x - y_C || ^ 2 + sum各分段(|| x - y_C - y_R || ^ 2)
进一步,distance = || x - y_C || ^ 2 + sum各分段(|| x - y_C || ^ 2 + || y_R || ^ 2 + 2 * (y_C|y_R) - 2 * (x|y_R)),这里:
|| x - y_C || ^ 2:必须查询来时才能做
|| y_R || ^ 2:可以构建索引时提前算好
2 * (y_C|y_R):可以构建索引时提前算好
2 * (x|y_R):必须查询来时才能做
所以faiss的IVFPQ实现中,对L2度量的索引,默认开启查表加速。确切说,针对每个一级量化的聚类中心向量y_C,都提前算好了,它底下的各分段的y_R的|| y_R || ^ 2 、2 * (y_C|y_R)
IVFPQ的train
IVFPQ的每个原始向量,在IndexIVF::train中,完成Level1quantizer::train_q1()的一级量化得到粗聚类中心集合之后,调用IndexIVFPQ::train_residual_o做残差训练:
IVFPQ索引内存布局
IVFPQ的索引包括4部分:
1、一级量化索引IndexFLAT
IVFPQ和IVFFLAT一样,用一个IndexFlat索引存储自己的一级量化结果,即粗聚类中心向量集,它是一个单数组,存储全部的粗聚类中心向量
2、倒排表
与IVFFLAT唯一区别是,倒排链codes表存的不是原始向量,而是每个原始向量的乘积量化编码。倒排链ids依然存储原始向量的label。
隐含的就是,查询时,依然是遍历倒排链的每个元素,区别是,不再做向量直接距离计算,而是
3、细聚类中心向量存储
如彻底理解IVFPQ原理,则肯定知道,IVFPQ必须要存储每个y_C的各分段的全部y_R。faiss实现中负责这部分的是ProductQuantizer对象。ProductQuantizer如下图:
上图罗列了,作为IVFPQ使用时,且是通过查表优化查询时,ProductQuantizer中会被用到的成员/方法。对于"不做查表优化"、"为了更加快的计算和更差的精度而采用对称距离计算"、"PQ直接被用于索引查询"等场景,还有一些其他成员/方法,没有反映在上图。
细聚类中心存储在上面的centroids,这是个vector<float>,存储了M个分段,每分段ksub个dsub维的细聚类中心向量,对应IndexIVFPQ::pq::centroids。
4、速查表
对应IndexIVFPQ::precomputed_table,这是一个与基于SIIMD指令的加减乘除数学计算库配套的连续内存数据结构(AlignedTable),也就是它不仅是连续内存,而且方便被基于SIMD的数学计算函数操作,以实现并行浮点计算。
速查表存储每个粗聚类各个分段的:|| y_R || ^ 2 + 2 * (y_C|y_R)
下面代码片段是制表细节。可见速查表的内存布局是nlist * M * kusb的float数组,对应IndexIVFPQ::precomputed_table
// 第一步,计算|| y_R || ^ 2
std::vector<float> r_norms(pq.M * pq.ksub, NAN);
for (int m = 0; m < pq.M; m++)for (int j = 0; j < pq.ksub; j++)r_norms[m * pq.ksub + j] =fvec_norm_L2sqr(pq.get_centroids(m, j), pq.dsub);// 这个是制表,
if (use_precomputed_table == 1) {// 表元素长度 = nlist * pq.M * pq.ksubprecomputed_table.resize(nlist * pq.M * pq.ksub);// 这个centroid, 就是表std::vector<float> centroid(d);// 开始遍历粗聚类中心 即公式tem2中的y_Rfor (size_t i = 0; i < nlist; i++) {// 取出粗聚类中心点quantizer->reconstruct(i, centroid.data());// 计算粗聚类中心点和量化残差的內积,即公式中的term2中的y_C|y_R 细项,存储在tabfloat* tab = &precomputed_table[i * pq.M * pq.ksub];pq.compute_inner_prod_table(centroid.data(), tab);// 第二步,再加上|| y_R ||^2,让tab存储|| y_R ||^2 + 2*(y_C|y_R)fvec_madd(pq.M * pq.ksub, r_norms.data(), 2.0, tab, tab);}
}
IVFPQ的查询
作为ivf系列索引的IVFPQ,搜索流程和"倒排暴搜的search"相同,包括不同方式的omp并行和搜索框架代码。不同点只是scanner的实现,ivf系列索引通过scanner抽象了不同ivf系列索引的搜索动作,不同ivf索引的搜索差异就在各自scanner的实现。
IVFPQ的scanner
IVFPQ的查询流程
这篇关于faiss 暴搜/ivf/ivfpq的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!