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

正排表,默认不开启
trainivf系列索引的共同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 for
            for (idx_t i = 0; i < n; i++) {
                if (interrupt) {
                    continue;
                }

                // loop over queries
                scanner->set_query(x + i * d);
                // 当前待查询向量的返回结果simi、idxi
                float* 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 results

                float* simi = distances + i * k;
                idx_t* idxi = labels + i * k;
#pragma omp single
                init_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 single
                reorder_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.ksub
    precomputed_table.resize(nlist * pq.M * pq.ksub);
    // 这个centroid, 就是表
    std::vector<float> centroid(d);

    // 开始遍历粗聚类中心 即公式tem2中的y_R
    for (size_t i = 0; i < nlist; i++) {
        // 取出粗聚类中心点
        quantizer->reconstruct(i, centroid.data());

        // 计算粗聚类中心点和量化残差的內积,即公式中的term2中的y_C|y_R 细项,存储在tab
        float* 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的查询流程

       

        

  • 9
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
faiss是一个用于高效相似性索和聚类的库,它是由Facebook AI研究院开发的。它提供了一种快速的索引技术,可以在大规模数据集上进行快速的相似性索。在中文语义索方面,我们可以通过以下步骤在Python中使用faiss进行中文语义索: 1. 准备数据: 首先,我们需要准备中文文本数据。这些数据可以是一系列的中文句子、段落或文档。可以从文件中读取数据,也可以从数据库或API获取。 2. 中文文本处理: 在将中文文本传递给faiss之前,我们需要进行文本处理。这包括分词、去除停用词、词干提取等。常用的中文分词工具有jieba、pkuseg等。你可以选择适合你项目需求的分词工具,并根据自己的需求进行预处理。 3. 特征提取: 接下来,我们需要将文本转化成特征向量。在中文语义索中,常用的方法是使用词嵌入技术,如Word2Vec、FastText、BERT等。这些词嵌入模型可以将文本转化为稠密向量表示,捕捉到词语之间的语义关系。 4. 索引构建: 使用faiss中提供的索引方法(如Flat、IVF等),将特征向量构建成索引结构。这样可以加速相似性索过程。 5. 相似性索: 一旦索引构建完成,我们可以使用faiss提供的查询方法在中文语义空间中进行索。我们可以将查询文本的特征向量传递给faiss,它将返回与查询文本最相似的文本。 总之,faiss在中文语义索中提供了一种高效的解决方案。通过合理的数据准备、文本处理、特征提取和索引构建,我们可以利用faiss快速找到与查询文本相关的中文文本。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值