目录
一、概述
最近公司有个任务是向opengauss移植pgvector扩展,opengauss的可能是基于PG内核9.1,内核中许多基础设施,与最新的PG并不相同,而pgvector是基于至少PG12以上的版本。
这个过程我学到很多,编写PG扩展,到底可以多大程度的改变PG的运行?都有哪些API(基础设施/机制)可用?加载到内核中的扩展,是否可以为所欲为?
答案是,差不多可以为所欲为吧。
加载到Linux内核中的驱动程序运行在内核态,也可以为所欲为,修改内核数据结构、直接改硬件寄存器,也会使用Linux内核提供的基础设施。
加载到PG内核中的扩展和Linux驱动程序不同,虽然也可以修改PG内核数据结构,破坏PG内核的运行机制,但是只能运行在用户态。
虽然通过自己实现 _PG_init() 可以在so加载时,调用so中的函数调用,可以在里面为所欲为,但是,一般我们还是按照PG内部基础设施提供的API,按照安全的编程规范实现扩展功能,关于这些编程规范和API,有些在PG文档和代码里的README有详细描述,有些缺乏文档,只能看其他人写的,使用这些API的代码作为例子。
以自定义索引为例(围绕自定义索引所需要的知识,正是pgvector实现的核心),PG支持不同程度的自定义索引,例如,文档中所描述的,先自定义complex类型,然后再自定义基于它的(btree)索引
(自定义基础类型complex见文档PostgreSQL: Documentation: 9.1: User-defined Types)
PostgreSQL: Documentation: 9.1: Interfacing Extensions To Indexes
你可以自定义一种类型,如果还希望在此类型上建立( btree) 索引,可以定义operator class:
CREATE OPERATOR CLASS complex_abs_ops DEFAULT FOR TYPE complex USING btree AS OPERATOR 1 < , OPERATOR 2 <= , OPERATOR 3 = , OPERATOR 4 >= , OPERATOR 5 > , FUNCTION 1 complex_abs_cmp(complex, complex);
它告诉PG内核,对complex类型创建索引时,使用btree的索引访问方法,为此,必须实现一组operator(>, <, = 等等),每个operator对应一个编号(strategy number),每个编号代表指定的语义,与之对应的operator必须实现其语义,btree的代码中以编号调用operator,操作自定义的complex数据。
具体到我们的向量类型和索引,如果只是二维向量,直接就可以用PG内置的索引和类型,但是这里我们要定义可变长的向量类型,涉及到自定义TOAST基础类型,这部分知识倒也不难,参考 PostgreSQL: Documentation: 9.1: User-defined Types 就够了。
然后定义向量的各种操作符,有了这些,PG就支持变长变量的存储、查询和距离计算了,只是要想加速,还需要自定义索引算法。
我们可以基于btree索引,定义一个operator class,就可以在自定义向量上建立btree索引了。
但是btree索引,并不能计算在多维向量数据库中求最近n个向量的问题,为此必须自定义索引访问方法(Access Method),这就是pgvector的价值所在,pgvector-0.6.0 中定义了两种索引访问方法,分别是ivfflat和hnsw,它们功能是一样的,只是用了不同的索引算法。
PG提供了一套自定义索引的编程机制和API(Index Access Method Interface),它给用户提供了足够的发挥空间,又帮助用户完成了许多工作。想要使用这套机制和API,用户需要编写一个extension,在其中调用API或实现回调函数(接口)来实现自己的索引算法。
PostgreSQL: Documentation: 12: Chapter 61. Index Access Method Interface Definition简单说一下PG extension的知识,一般来说一个编译好的extension包括control文件、一个sql文件,一个so文件,在PG命令行输入create extension xxx 时,数据会找相应的control文件,加载so文件,然后执行sql文件,可能细节上这一描述有点误差,但这是我理解pgvector插件的一个最简单的模型,而且是有效的,这里我不想深究extension的其它机制。
总之,当执行 create extension vector 时,vector.sql会被执行,可以看到里面创建了自定义的类型和各种函数、操作符,其中许多函数都是C语言实现的,因此这些逻辑应该预先存在于PG内核中,也就是应该先加载 vector.so,我猜测只要sql文件中不涉及C语言实现的自定义函数或者基础类型,extension也可以没有C代码写的so。
下面就来解读一下pgvector-0.6.0的代码,代码来自,请对比阅读pgvector/pgvector: Open-source vector similarity search for Postgres (github.com)
前面说过,pgvector-0.6.0实现了两种Index access method,分别是 ivfflat 和 hnsw,在编程上这两者可以互相独立存在,没有互相依赖,所有可以用SQL编写的逻辑都在 vector.sql 中,包括ivfflat 和 hnsw的访问方法的定义、各种函数和运算符的定义、operator class的定义等等。
src目录下的.c文件是自定义索引的逻辑代码,也包括一些在vector.sql中定义的函数的C语言实现,它们最终都会编译到so中。
二、IVFFLAT
vector.sql
定义了 vector 基础类型,定义基础类型需要定义in、out、send、receive函数和C语言结构体,细节可以看 PostgreSQL: Documentation: 9.1: User-defined Types 不难理解。
然后是对于这种类型的函数和操作符,这些对于 ivfflat 和 hnsw 是共用的。
其中,operator 是通过函数实现的,这些函数在 sql 文件中也有定义,实现则是C语言代码,RESTRICT 和 JOIN 参数则是指定一些方法,跟优化器有关,这里用的是 PG 内核内建的函数,没有必要深入研究。
然后是 ACCESS METHOD,注意 OpenGauss 原来是不支持创建ACCESS METHOD,这部分内核代码由 向量数据库功能内核相关(pgvector插件依赖) · Pull Request !4845 · openGauss/openGauss-server - Gitee.com 修改后可以支持。
对于 ivfflat 和 hnsw,分别创建两套 ACCESS METHOD ,注意两个信息:
1、ACCESS METHOD 的名称(紫色部分)
2、handler函数(黄色部分)
CREATE ACCESS METHOD ivfflat TYPE INDEX HANDLER ivfflathandler;
ACCESS METHOD 的名称是创建索引时需要指定的,例如:
CREATE INDEX ON items USING ivfflat (embedding vector_l2_ops) WITH (lists = 100);
(红色部分是operator class)
handler函数是执行CREATE ACCESS METHOD时PG内核调用的函数,这个函数也要自己定义,在这个函数里,要填充实现函数的指针,填充到 IndexAmRoutine 结构体里,PG内核会根据这个结构体创建一条 pg_am 的记录。一个访问方法本质上是一条 pg_am 记录,之所以这里要按照这个规则填写,因为自定义索引,要按照PG内核提供框架的来编写的,不是用户完全自由发挥。
然后是operator class的定义,一套访问方法,可能对应多于一个operator class,例如,对于ivfflat索引,计算向量距离有L2、IP、CONSINE多种方法,当我们创建ivfflat索引时,指定不同的距离计算方法,就是选择了不同的operator class,下面图中包含 DEFAULT FOR TYPE 的operator class,是不指定operator class时,创建ivfflat索引选择的默认operator class:
1、如果创建ivfflat索引时指定 vector_l2_ops,则索引代码使用vector_l2_ops operator class提供的函数,按欧氏计算距离。注意,欧氏距离越小两个向量越相似!
欧氏距离的计算公式:
2、如果创建ivfflat索引时指定 vector_ip_ops,则索引代码使用vector_ip_ops operator class提供的函数,按内积计算两个向量的相似度。注意,内积越大两个向量越相似!
内积计算公式:
把两个n维向量想象成二维空间中两个都以(0,0)为起点的向量,内积的中蕴含的信息包括了两个n维向量在长度和夹角上的差别。
对于IP距离,可以在计算IP距离前对两个向量进行normalize,把它们的模都变成1,然后在计算IP距离,如果这样的话,算出的IP距离等价于cosine距离。
3、如果创建ivffat索引时指定 vector_consine_ops,索引代码使用vector_consine_ops operator class提供的函数。注意,余弦相似度越大两个向量越相似!
把两个n维向量想象成二维空间中两个都以(0,0)为起点的向量,但是与内积不同的是,cos需要把两个向量的模长度都normalize为1,然后计算内积,cos距离只关注两个向量“夹角”属性的差异,并不关心“模长度”属性的差异,而内积既关心“夹角”的差异也关心“模长度”的差异。
关于向量距离的选择,见这篇文章,除了解释了三种距离的计算方法,作者提出,比较向量相似度时选择哪种距离算法,与模型训练时选择哪种距离有关:
Vector Similarity Explained | Pinecone
vector.c
在vector.sql中定义的存储过程的实现,基本上都在这个文件里。
还定义了这个extension的初始化函数:_PG_init
定义了vector基础类型所必须的vector_in/out 等函数,注意这些函数在分配内存时使用的时palloc,那么用的是哪个上下文呢?
这个文件还定义了各种距离计算函数,这些函数不仅仅是C函数,而是定义为PG的存储过程,这样是为了在operator class里引用和在index method代码里调用:
vector_l2_squared_distance -- 计算两个向量的欧氏距离,但是不取平方根,对于比较两个向量的距离大小足以。
l2_distance -- 计算两个向量的欧氏距离,取平方根。
inner_product -- 计算两个向量的内积。
vector_negative_inner_product -- 计算两个向量的内积,再取负数,因为ip越大越相似,与目标向量最近的前k个向量,其实与之计算ip最大的k个向量,如果用order by <ip相似度> 把它选出来们,需要对结果做降序排序,order by降序排序在PG中不能用索引,若想用索引,只有order by x按x升序,如果内积取负,ip最大的k个向量,取负以后就是最小的k个向量,order by -<ip形似度>就既可以是升序,又可以选出ip最大的前k个向量,这样也可以用索引了。
vector_norm -- 计算一个向量的模长度,即各分量平方的和的开方。
vector_spherical_distance -- 计算两个向量的球体距离,其实就是圆的弧长,但是圆的弧长和圆的半径有关,那这个半径是多少呢?这个函数假设两个向量都是单位向量(模为1的向量),如果两个向量不是单位向量,就需要先对两个向量进行normalize,再由这个函数计算弧长,这样弧长其实是单位圆的弧长。
上面几个函数都是 operator class 的支持函数,索引代码里,计算距离时会引用到这些函数。
对于向量的其它操作符(函数)和转换函数,作为C实现的存储过程,也在 vector.c 中定义。
ivfflat.c
这里定义整个 ivfflat 索引公用的一些函数和数据结构,定义了handler函数的实现 ivfflathandler,当执行CREATE ACCESS METHOD 语句时,ivfflathandler 函数被调用,填充索引相关的实现函数,在pg_am中加入一条记录。
对于OpenGauss,由于没有这种机制,需要将每个实现函数创建为存储过程,然后在pg_am中记录存储过程名,然后通过存储过程名来调用实现函数,OG对这些函数进行了封装,例如,存储过程 ivfflatbuild 内部调用了 ivfflatbuild_internal 等等。
这些实现函数中,有一些对于我们自定索引非常重要,且必须自己定义,有一些不那么重要,可以使用内建的标准定义,例如:
这些函数对于自定义索引非常重要,必须按照自定义索引的逻辑自己实现:
ivfflatbuildivfflatbuildempty
ivfflatinsert
ivfflatbulkdelete
ivfflatvacuumcleanup
ivfflatbeginscan
ivfflatrescan
ivfflatgettuple
ivfflatendscan
这些函数可以照搬PG的标准实现,在pgvector的代码中,这几个函数我没有去详细理解,这里不打算解读:
ivfflatcostestimate
ivfflatoptions
ivfflatvalidate
参考 PostgreSQL: Documentation: 16: 64.2. Index Access Method Functions
IvfflatInit 函数是 _PG_init 调用的,这里定义了创建和使用 ivfflat 索引时,可以使用的参数,例如,建立索引时,把所有的向量分成几个“区域”,即lists:
CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
还定义了 extension 全局参数 ivfflat.probes 表示搜索索引时,从前几个区域里搜索最邻近向量:
SET ivfflat.probes = 10;
表示从所有100个区域里,选择center最近的前10个区域,然后在这10个区域里,搜索最邻近向量。
这里有个问题,在opengauss的中,这个全局变量是被所有并发连接的线程所共享的,这样查询时,有的会话希望ivfflat.probes是10,有的希望是1,会不会互相影响?还需要研究一下。
ivfbuild.c
再看建立索引的代码,建索引是 pgvector 非常重要的模块,入口函数是 ivfflatbuild,在OG里是ivfflatbuild_internal。
这个函数的输入输出数据结构,是按照PG框架的标准。其中主要调用 BuildIndex 实现建索引,BuildIndex 里先初始化IvfflatBuildState,这个数据结构记录了索引构建过程中需要用到的一些信息,在后面构建步骤的函数中,都要传入和传出这个数据结构,贯穿于一次索引构建的整个过程,它里面有哪些重要信息呢?
它的定义如下:
typedef struct IvfflatBuildState
{
/* Info */
Relation heap; // 被定义索引的基表对象
Relation index; // 索引表对象,PG中索引也是一个表,与表是一样的
/* this struct holds the information needed to construct new index
* entries for a particular index. Used for both index_build and
* retail creation of index entries.*/
IndexInfo *indexInfo; // 感觉没什么用
/* Settings */
int dimensions; // 被索引的向量的维度
int lists; // 分区区域数/* Statistics */
// 这两个变量会通过IndexBuildResult传给PG框架
double indtuples; /* # of tuples inserted into index */
double reltuples; /* # of tuples seen in parent table *//* Support functions */
// 对应支持函数1,根据operator class不同,可能是:vector_l2_squared_distance 或 vector_negative_inner_product
FmgrInfo *procinfo;// 对应支持函数2,根据operator class不同,可能是:vector_norm 或者 NULL
FmgrInfo *normprocinfo;// 对应支持函数4,根据operator class不同,可能是:vector_norm 或者 NULL
FmgrInfo *kmeansnormprocinfo;// 字符排序相关
Oid collation;/* Variables */
// ivfflat 建索引时,从所有向量中采样若干向量,根据区域的个数,从样本计算出每个区域的中心向量
// 计算中心向量的算法是 kmeans,这里先不详细研究。
// 从所有向量中采样的样本,以数组形式存储在这里
VectorArray samples;
// 区域中心向量数组VectorArray centers;
// 指向 ListInfo 数组的首地址,每个区域信息IvfflatListData对应一个ListInfo,
// 它记录了对应的 IvfflatListData 在索引表中的页号和页内偏移
// 这个结构和ivfflat索引的存储有关,ivfflat索引也是一个表,其数据也是分成一个个page
// 但是ivfflat索引表有3种page,分别存储了不同格式的记录:
// meta page - 存储了一个ivfflat索引的元数据,一般一个索引只有一个metapage,是第一个page
// list page - 存的是区域中心点的信息,其中的每条tuple,保存的是一个区域中心点的信息,一个索引可能会有多个list page。
// entry page - 其中的每条tuple,存储的是向量的信息。
// ListInfo 是存储一个索引的区域中心和它在list page的页号和页内偏移,在list page中存储的记录格式就是IvfflatListData
ListInfo *listInfo;// 指向一个Vector对象
Vector *normvec;/* Sampling */
// 采样相关,对表中所有向量采样,遍历表的每条记录,从中选择出一些tuple,作为样本。
// 选择的算法是,对表进行顺序扫描,把每个向量加入到sample数组中,如果sample数组满了,
// 但是表还每扫描完,则接着扫描,用新tuple随机的替换sample数组中的tuple,
// 直到扫描完表的所有tuple。
BlockSamplerData bs;
ReservoirStateData rstate;
int rowstoskip;/* Sorting */
// 建立索引的过程中,所有向量需按距离分配给每个区域中心,
// 算法就是,遍历表的所有tuple中的向量,计算距自己最近的区域中心的分区号,
// 然后将分区号、向量和向量所在tuple的tid,这三个信息,组成一条记录,插入临时表,
// 再将临时表按照分区号排序,这样就实现了所有向量按照分区号聚合。
// 在PG代码中创建这样可以排序的临时表用 tuplesort_begin_heap
// 这个函数返回一个Tuplesortstate,它接受 TupleDesc 参数,
// 这里 sortstate,tupdesc 就是定义和使用可排序临时表用的,
// 临时表都有哪些列,列类型、列名称都由 TupleDesc 描述,
// 按照哪一列排序,调用 tuplesort_begin_heap 时设置,
// 向表中插入数据时,要用到 TupleTableSlot。
// 临时表的 tuple 数据会先缓存在内存,当超过某个阈值,就存入磁盘,
// 这个阈值是在调用 tuplesort_begin_heap 时设置的,
// 参考 work_mem 或 maintanence_work_mem 。
Tuplesortstate *sortstate;
TupleDesc tupdesc;
TupleTableSlot *slot;
/* Memory */
// 建索引过程中用的内存就在这个MemoryContext里分配
MemoryContext tmpCtx;/* Parallel builds */
IvfflatLeader *ivfleader; // 在OG移植中不用,因为OG不支持parallel worker
} IvfflatBuildState;
然后调 ComputeCenters:
这个函数的目的在于,根据输入的区域数(lists),对基表里的向量进行采样,然后计算出每个区域的中心向量(centers),所以这个函数调用结束,IvfflatBuildState::centers 向量数组应该填充了有效的值,当然 IvfflatBuildState::samples 也填充了有效的值,不过当这个函数结束时,IvfflatBuildState::samples 也没用了。
这里还有个问题,有了样本,知道要分多少个区域(lists),但是怎么分呢?这就需要 kmeans 算法了,这个算法目前还不想深究。总之在 ComputeCenters 函数中,先调用 SampleRows 对表中所有向量进行采样,再调用IvfflatKmeans根据前一步得到的样本和分区数,对样本进行分区,最终得到每个分区的中心点。
关于 SampleRows 函数,很有意思,这个函数在 PG 中的实现,是随机选择表的一个 page,然后遍历 page 中所有的tuple,每个tuple调用一次 SampleCallback,在 SampleCallback 里将向量加入 samples 数组,在加入数组以前,可能还会对向量做 normalize。遍历表tuple的方法用 table_index_build_range_scan ,这个函数指定表、起始页和页数,函数内部会遍历这些页里的tuple,对于每个tuple调用一次回调函数,回调函数是要自己定义的,这里是SampleCallback,tuple作为参数传入回调函数,在回调函数里用:
Datum value = PointerGetDatum(PG_DETOAST_DATUM(values[0]))
提取出来,注意,PG SQL中的值(字段值、存储过程参数),在代码中都是Datum,这个Datum是一个8字节数据(64bit系统),可能是一个指针,也可能是想传递的int、long、double值。
看来,自定义索引代码逻辑中,遍历表tuple建立索引的方法都是调用类似 table_index_build_range_scan 的函数,类似的函数还有:
table_index_build_scan -- 遍历整个表
table_index_build_range_scan -- 遍历表的某个页
OG中的函数稍有不同,但是使用模式是一样的:
tableam_index_build_scan -- 遍历整个表
IndexBuildHeapScan -- 遍历整个表
(搞不清OG里这两个有啥区别)
移植到OG后,在选取page时,并不是随机选取,因为OG中没有table_index_build_range_scan ,只能遍历所有page和其中的所有tuple。
然后调 CreateMetaPage:
这个函数,为索引表新建一个page,储索引元数据信息,是索引表的第一个page。
索引元数据结构为:
typedef struct IvfflatMetaPageData
{
uint32 magicNumber;
uint32 version;
uint16 dimensions;
uint16 lists;
} IvfflatMetaPageData;
这里要给一些背景知识,自定义索引的page结构也是可以自定义的,不过一般都是使用PG标准格式,这样的好处是可以使用许多PG的基础设施,如GenericXLog,这里的索引元数据页,就使用标准页格式,标准页格式,只是一种容器,除了页头,数据如何存储,数据格式怎样,完全自由。
在代码中,写meta数据时,不是通过PageAddItem,而是直接(通过结构体映射)向页内存赋值,这样就需要自己更新pd_lower(而PageAddItem会内部自动更新pd_lower和pd_upper)。
ivfflat meta page 的页内容如下:
注意,这个索引页结构,使用了special area,这个special area一般在页尾部,通常用来指定下一个页的页号(表的页号,不是buffer id),相当于链表的指针,只不过这个链表对于磁盘是有效的。
还要注意的是,这里写meta page内容时,不是直接写到shared buffer pool的page中,而是写到GenericXLog给的page,再由GenericXLog写到shared buffer pool的对应page。
也就是,写meta page的操作,经过了wal保护,代码里其它索引项写page的操作,也都是通过GenericXLog的。
然后调 CreateListPages :
这个函数向shared buffer pool的页(通过GenericXLog)写区域相关数据,每个区域相关的数据是一个IvfflatListData,它的定义为(索引表meta page之后紧接着就是list page):
typedef struct IvfflatListData
{
BlockNumber startPage;
BlockNumber insertPage;
Vector center;
} IvfflatListData;
ivfflat索引一共有三种page:(前面讲过这里再啰嗦一遍)
1、meta page(前面已经解释过)
2、list page -- 存储每个区域list信息的页,也是标准页结构,但是内容数据的结构与meta不同,list page里存储的是一个个IvfflatListData结构体(item),它的长度根据Vector的维度而变化。
3、entry page -- ivfflat索引的每个区域,要包含属于这个区域的所有向量信息(指基表中的向量,不是样本,也不是center),这些向量信息(每个向量一个IndexTuple,还包括其它字段)集中存储在entry page中,entry page也是标准page,它的一个item是一个IndexTuple。
startPage 是属于这个区域的第一个entry page号,insertPage是属于这个区域的最后一个entry page号,也是新向量插入的page号。
最后一个成员center,是这个区域的中心点向量,IvfflatListData 实际占用空间,根据center的维度而变化。
CreateListPages 遍历 buildstate->centers 里的每个 center,填充 IvfflatListData,然后调用 PageAddItem,将一个个 IvfflatListData 追加到 list page 中,注意代码中申请新增的函数:
ReadBufferExtended(index, forkNum, P_NEW, RBM_NORMAL, NULL)
还有新增页时(IvfflatAppendPage),前一个页special area里的 IvfflatPageOpaque,要指向新页(通过页号,这里的页号指的是索引表内的页号,不是指 shared buffer pool里的buffer id)。
追加完一个区域信息(IvfflatListData )到 page 后,还要将这个区域信息的页号和页内偏移,保存到 buildstate->listInfo,这是为啥呢?
有没有注意到 CreateListPage 中填充 IvfflatListData 时,startPage 和 insertPage 并没有赋给有效值,因为在构建 list page 时,每个区域里向量数据所在页(entry page)还没有创建,这两个值还不知道该填什么,所以先填InvalidBlockNumber,等entry page构建时,再用 IvfflatUpdateList 更新list page里的startPage 和 insertPage。知道每个区域信息(IvfflatListData )在索引表中的页号和偏移,可以快速访问并修改。
然后调 CreateEntryPages :
1、AssignTuples -- 这个函数会遍历基表的所有tuple,然后对于每个tuple,计算其中的向量与所有区域的center(buildstate->centers)的距离(欧氏、内积、cos由class operator 决定,可能会先对向量normalize,再计算距离,如果是这样,center应该已经被normalize过了,center由sample计算,sample已经事先normalized),然后将 最近区域中心的id + 这个tuple的tid + 向量数据,组成一个tuple,插入到临时排序表中。然后对临时表按照区域中心id排序,这样,属于相同区域的向量就聚集在一起了。
AssignTuples() 里遍历表tuple用到了table_index_build_scan和callback,这个和之前采样时用的API差不多。
还用到了parallel worker,为的是并行扫描表tuple,和并行调用callback,因为OG不支持,我改造成了只支持单线程,所以parallel worker的使用机制我也没仔细研究,这里打算略过。
2、这个排序的动作,其实不是在AssignTuples函数里做,而是在AssignTuples之后做:
3、InsertTuples -- 将(按照区域中心id排序后)临时表里的向量数据,写入对应区域的entry page中,这里会不断新增entry page,用PageAddItem写入标准索引的tuple结构:以IndexTupleData为tuple头,后面加上向量数据。IndexTupleData中包含了对应基表tuple的tid。
这里注意,entry page的tuple不再是某种自定义的数据结构(例如list page的IvfflatListData),而是PG预定义的tuple结构(IndexTuple)。
这个函数对于每个center,从临时表取出相应id的记录,构建出标准索引tuple(以IndexTuple为头),然后将这个标准索引tuple写入entry page。从临时表中取出记录、绑定、形成IndexTuple的过程在GetNextTuple函数中。
entry page是PG预定义的标准page,但是多了一块special area,用于指向下一个page,由于以后可能的插入和删除操作,属于相同区域(list)的enty page,页号可能不连续,这里通过页号链表,将相同区域的enty page连起来。
当构造完一个区域的所有entry page后,调用 IvfflatUpdateList,更新对应IvfflatListData中的startPage和insertPage信息。
至于 IvfflatCommitBuffer() ,不是真的commit,而是通知PG内核,我生成了一条wal记录,并在shared buffer pool中修改了相应page,至于落盘时机,你看着办吧。
还有些细节上的疑惑,要澄清一下:
1、meta page的spacial area会不会有页号指针指向第一个list page?不会,metapage的页号是0,即索引表的第一个页,第一个list page的页号是1。
2、list page 的spacial area的页号指针会不会指向第一个entry page?不会,list page的special area里的页号指针,只会指向另一个list page,list page里的每条记录IvfflatListData里的startPage和insertPage才会指向enty page的页号。
3、entry page的special area的页号指针,只会指向另一个entry page,同一个list的entry page之间是连起来的。
ivfinsert.c
每当基表有一个tuple被插入时,对应的索引也要插入一项,ivfflatinsert 函数就被调用,这个函数向entry page中插入条IndexTuple(包含了基表新tuple的tid和向量数据,当然要计算一下向哪个区域的entry page中插入)。
注意一点,无论基表新插入的向量,与数据库中既有的向量有多大的差异,也无论多大规模的插入,都不会再更新区域中心点,但是list page里的startPage或insertPage会改变。虽然这样会不太准确,如果想更新区域中心点,就选择重建索引吧。
InsertTuple -- 这函数先计算新向量与所有区域中心点的距离,选择距离最短的区域,插入到里面,插入点就是它的startPage,如果startPage空闲空间不够用,就申请新页,更新startPage和前一页的spacial area。
插入新IndexTuple的过程中,用了GenericXLog,IndexTuple中的向量可能做normalize,注意,向量数据在基表是以TOAST方式存储的,但是在IndexTuple中不是以TOAST方式存储。
ivfscan.c
scan算法是,对于一个目标向量,例如:
SELECT * FROM bigitems ORDER BY embedding <-> '[3,1,2]' LIMIT 5;
其中 '[3,1,2]' 就是目标向量,这个SQL是选出离他最近的前5个向量所在记录。
先把目标向量与所有区域的中心点比较,选出离它最近的前n个中心点所在区域,这个n是可以设置的,就是前面说过的ivfflat.probes,例如:
SET ivfflat.probes = 10;
然后,把目标向量和这n个区域内的所有向量比较,选出前m个,这个m就是上面SQL中的5,这个m一般是在SQL查询时设置的。
算法不难,在PG中为实现scan操作,需要实现 ivfflatbeginscan,ivfflatrescan,ivfflatgettuple,ivfflatendscan四个函数。
在这些函数中有个公共的 IndexScanDesc 对象,作为参数,在这几个函数中传来传去,我们有一个自定义的对象,也需要在这几个接口间传递,就是IvfflatScanOpaque,我们在MemroyContext中申请IvfflatScanOpaque对象,在这些接口函数中用IndexScanDesc::opaque传递IvfflatScanOpaque对象的指针。
注意,这些接口中用的是同一个MemroyContext。
Index scan 的实现逻辑是,PG内核发出开始 index scan 的命令,调用 ivfflatbeginscan,然后会调用 ivfflatrescan,再调用 ivfflatgettuple,PG内核调用 ivfflatgettuple 是为了得到基表的 tuple,至于哪些 tuple 应该返回,返回的顺序如何,正是自定义索引应该实现的。
一般PG内核还会传入筛选条件(例如where),对于向量索引,就是目标向量,ivfflatgettuple将向量索引中的向量,按照与目标向量的距离从近到远排序,返回给PG内核,PG内核每调用一次ivfflatgettuple得到一个基表tuple,作为ivfflatgettuple的实现者,实现代码要返回一个基表tuple的tid,这个tid所指的tuple就是PG内核返回给用户的tuple,至于调用多少次ivfflatgettuple,是由查询语句(如limit)和索引实现逻辑(有没有符合条件的tuple了?)共同决定的。
ivfflatbeginscan -- 做准备工作,分配IvfflatScanOpaque对象,收集索引相关的信息填充IvfflatScanOpaque对象,这个对象中的信息在整个scan过程中都要用到,其中包括一个可排序临时表,有距离和tid两个字段,还有一个pairing_heap。
ivfflatrescan -- 这个函数传入了目标向量,这是最重要的,当然还有其它初始化工作。
ivfflatgettuple -- 所有重要的逻辑都在这里,理应调用一次这个函数,PG内核就得到一个基表tuple的tid,然后PG内核自己再从基表中取tuple,给用户。
但是对于ivfflat索引实现逻辑,第一次调用ivfflatgettuple是一个非常费时的过程,它要先遍历list page找出ivfflat.probe个离目标向量最近的list,然后遍历这些list中的所有向量,按照与目标向量的距离,从小到大排序,选出最近的n个,再返回第一个tid,之后的调用再返回下一个。
如果是第一次调用 ivfflatgettuple:
先调用 GetScanLists:
从所有list里选出center与目标向量最近的ivfflat.probe个,放在IvfflatScanOpaque::lists数组里,这个数组的大小就是ivfflat.probe,它的算法很有意思,用到了pairing_heap,pairing_heap是一种树结构的优先队列,在根部总是最大或最小的值,但它的子树不保证有序。如果算法将所有list与目标向量的按距离从小到大排序,然后从中取前ivfflat.probe个,相对于使用pairing_heap的算法,它的效率并不高,其实在这个场景里,我们并不要求按距离全局有序。
不断读取 list page 里的 IvfflatListData,计算 center 与目标向量的距离,然后加入到pairing_heap和IvfflatScanOpaque::lists数组,pairing_heap的root代表与目标向量距离最大的list,当pairing_heap和IvfflatScanOpaque::lists数组里的list满了以后,如果还有list加入,要比较其center与目标向量的距离和pairing_heap的root里的距离,如果root中的距离更小,则新list被抛弃掉,在查看下一个list,如果root中的距离更大,则将root移除pairing_heap,root节点的数据更新为新list里的数据,再加入到pairing_heap,这就相当于用距离更小的一个list挤出了一个距离更大的list,同时IvfflatScanOpaque::lists里list值也更新了,如此循环,直到所有的list都被如此处理过了,最后IvfflatScanOpaque::lists中的就是其center距离目标向量最近的ivfflat.probe个区域了。
之后是 GetScanItems:
GetScanLists执行完后,IvfflatScanOpaque::lists就是中心点与目标向量最近的前m个区域了,把这些区域中的所有向量,与目标向量计算距离,将距离与原向量所在基表tuple的tid组成一个记录,插入到临时排序表中,然后对临时表以距离排序。
这样,PG内核所要的数据就准备好了,也就是说,ivfflatgettuple 要返回的所有数据,在第一次调用时已经准备好了,接下来,PG内核每调用一次ivfflatgettuple,从临时表里取一条记录,提取tid返回即可,至于什么时候结束,可以由PG内核的limit决定,也可以在临时表遍历完时结束。
如果有多个进程并发的用ivfflat索引进行 index scan呢?
从上面的过程可见,ivfflat索引的meta page和list page是都要被读到shared buffer pool里,如果shared buffer pool足够大,则meta page和list page都缓存在内存中,并且对于所有并发的进程(线程)是共享的。但是每个不同的并发,由于目标向量不同,所选取的list也不同,调入shared buffer pool的entry page就不同,每个并发只会调入自己用到的那些entry page,不会调用所有的entry page,而且这些并发调入的entry page还可以共用。
pgvector使用索引时,不会一次将整个索引表所有的page到调到内存,而是用哪些就调入哪些,这和milvus的工作方式还不一样。
在代码中,有些时候要对向量normalize,有些时候不要normalize,到底啥时候做normalize呢?这里总结一下,下面讲的是代码中的逻辑,至于是否正确和为什么要这么做,尚未深入研究:
1、vector_l2_ops -- 建索引时采样和生成center都不做normalize,list page 中的center向量和 entry page 中的向量都不做 normalize,计算与center距离前,基表中的向量不做normalize。
index scan 时不对目标向量normalize。
2、vector_ip_ops -- 建索引时采样和生成center做normalize,list page中的center向量做normalize,enry page中的向量不做normalize,计算与center距离前,基表中的向量不做normalize。
index scan 时不对目标向量normalize。
3、vector_consine_ops -- 建索引时采样和生成center做normalize,list page 中的center向量和 entry page 中的向量做 normalize,计算与center距离前,基表中的向量做normalize。
index scan 时对目标向量normalize。
三、HNSW
概述
HNSW索引的基本思想是(下图):
a) 把数据库中的多维向量,想象成二维空间的点,将所有向量连成一个图(Graph0),向量是图中的点,与直接相连点之间称为“友点”,图中任意两点之间都可以联通,从任意一点开始,遍历其友点,计算这一点和其友点到目标向量的距离,选择距离最短的点,再遍历其友点,重复上面的过程,每次选择,都要加入与目标向量更近的点,淘汰出相对更远的点,直到不能找到更近的点。
b) 在Graph0的基础上,选择部分节点再连成一个图(Graph1),相对于Graph0,Graph1的节点更少,但友点之间跨度大,在Graph1中查找目标向量,会更快达到目标向量附近的点。
c) 在Graph1的基础上,再选择部分节点再连成一个图(Graph2),相对于Graph1,Graph2的节点更少,友点之间跨度更大,在Graph2中查找目标向量,会更快达到目标向量附近的点。
d) 如此,我们可以建立多个图,HNSW中称为不同的层(layer),上一层的图的节点是下一层图的子集,越往上层的图,节点越稀疏,节点间距离跨度越大。
查找时(下图),从最上层(Graph2)开始搜索,找到这一层距目标向量最近的一个或多个点后,以这些点(或一个点)为起点,进入下一层继续搜索,重复前面的步骤,直到最底层(Graph0),找到的距目标向量最近的K个点就是最终结果。
注意,距目标向量最近的K个点,一定要在最底层确定,之上的层只是为了加速查找。
图的构建过程,与目标向量的查找是相同的,即一个一个地插入向量,把新向量当作要查找的目标向量,在各层图中查找与它最邻近的n个点,然后将这n个点与新向量相连。
pgvector中HNSW的构建过程
在pgvector的HNSW代码中,新加入的向量有一个 HnswElement->level 属性,初始化时给它随机设置一个整数值(在最大值范围内),它决定这个向量存在于哪些层,例如HnswElement->level = 5,那么这个向量将存在于0、1、2、3、4、5层的图中。
在插入最底层layer0的过程中,并不是直接在最底层图上操作,而是经历了一遍从上到下查找的过程,从HnswElement->level层开始查找,逐层向下到layer0,查找经过每一层时,会保存这一层与新向量最邻近的n个向量,完成查找后,将新加入的向量,与各层中n个最邻近向量连接,也就完成了在layer0以上的各层的插入,当然如果某些向量节点的友点个数超过限制,要做适当的更替。
注意,每一层的友点个数要求不同,最小友点个数和最大友点个数不同。
pgvector中HNSW的查询过程
目标向量用HnswElementData对象表示,起始向量也是一个HnswElementData对象,可以有多个起始向量,用一个HnswElementData链表表示,这个链表在代码中称为entrypoint。
查询先从最上层图的开始,找到(目标向量的)若干个最邻近点后,以这些点作为下一层查找的起始点,再找出若干个最邻近点,再作为下一层查找的起始点,如此循环,这个过程不断加入更近的点,并淘汰出最远的点,直到在layer0,最后剩下的n个点,就是与目标向量最邻近的n个点。
hnsw.c
这个文件定义了初始化函数 HnswInit(),里面初始化了三个参数,分别是:
m -- 每个节点的最大连接数
ef_construction -- 构建索引时的 “dynamic candidate list”
ef_search -- 查询索引时的 “dynamic candidate list”
这些全局变量在OG的线程模型中,可能存在不同会话间相互干扰的情况!需仔细研究。
这个 “dynamic candidate list” 稍后解释,其中 m 和 ef_construction是在CREATE INDEX语句里指定的,例如:
CREATE INDEX ON items USING hnsw (embedding vector_l2_ops) WITH (m = 16, ef_construction = 64);
这个函数里还调用了HnswInitLockTranche(),它在共享内存中分配了内存,这块内存用来存放锁变量,这些锁是并行创建索引或并行查询索引时用的,但是,由于OG不支持parallel worker机制,在OG上移植,我删去了所有并行执行相关的代码,这里暂时不分析并行执行的逻辑。
这个文件里还有 index method 接口函数的定义,如 hnswcostestimate()(默认实现)、hnswoptions()、hnswvalidate()和hnswhandler()(将接口和access method机制联系起来)。
hnswbuild.c
包含构建HNSW索引的函数,入口函数是 hnswbuild()->BuildIndex(),其中InitBuildState()初始化 HnswBuildState 结构体,这个结构体贯穿HNSW索引构建的过程,非常重要,其中包括许多构建索引时用到的信息和中间结果。
typedef struct HnswBuildState
{
/* Info */
Relation heap; // 基表
Relation index; // 索引表
IndexInfo *indexInfo;
ForkNumber forkNum;/* Settings */
int dimensions; // 向量维度
int m; // 向量节点最大连接数(degree)
int efConstruction; // 于目标向量,最邻近点的个数,// 也是一层进入下一层entrypoint的个数
/* Statistics */
double indtuples;
double reltuples;/* Support functions */
FmgrInfo *procinfo;
FmgrInfo *normprocinfo;
Oid collation;/* Variables */
HnswGraph graphData;
HnswGraph *graph; // 内存中的多层图由HnswGraph 对象代表,// 其中包含了整个多层图的全部信息,
// 图的每个节点由HnswElementData结构代表,
// 节点之间除了图的neighbour连接,还用next连成一个链表,
// 这样是为了便于遍历。
double ml;
int maxLevel;
Vector *normvec;/* Memory */
MemoryContext graphCtx; // 分配图节点内存所在的memory context
MemoryContext tmpCtx;
HnswAllocator allocator;/* Parallel builds */
HnswLeader *hnswleader;
HnswShared *hnswshared;
char *hnswarea;
} HnswBuildState;
然后调用 BuildGraph() 在内存中构建图,然后将图保存到page中(写到shared buffer pool,并被保存到磁盘),HNSW索引就是这个图(包含多层),但是前面所描述的图都是内存中的结构,要写到page中,需要对多层图的存储做一下设计,这是FlushPages()做的事情。
比较有意思的是,HNSW索引的保存方法,先是直接写page(写到shared buffer pool),并不写wal,最后集中的把这些page读出来,为它们写wal,因为这些page都是新建页,所以这么做是安全的,写wal记录的方式也是full page write(XLOG_FPI),这个操作在 log_newpage_range() 函数里做,这个函数是PG源码里有的。
上面 BuildGraph() 构建图的过程,是通过一条条遍历基表中的记录,然后一个个向图中插入向量完成的,调用 tableam_index_build_scan() 驱动遍历基表,对每个tuple调用BuildCallback函数,在BuildCallback中将这个tuple中的向量插入到图,这个插入包括对多层图的插入,这个插入还包括InsertTupleInMemory 和 HnswInsertTupleOnDisk 两种情况。
如果内存够大,当我们遍历完基表的每个tuple,可以在内存中构建基表所有向量组成的图,这就是InsertTupleInMemory,然后调用FlushPages()把这个图写到page里(进而被buffer manager写到磁盘)。如果之后又有新的向量加入,则走INSERT流程。
我们没有遍历完基表的所有tuple,但是内存不够用了,此时在内存中也构建了一个图,我们也可以调用FlushPages()把这个图写到page里,剩下的向量插入,则走INSERT流程,这个流程并不会在内存中建立完整表的图,只会为了给新向量找到合适的插入位置,加载部分索引page。这就是HnswInsertTupleOnDisk。
InsertTupleInMemory 和 HnswInsertTupleOnDisk 的区别就是 HnswInsertTupleOnDisk 提前(基表的tuple还没遍历完)调用FlushPages()(因为maintenance_work_mem不够用了),之后每个tuple 走INSERT流程,而InsertTupleInMemory一直是向内存中的图插入,能够遍历完所有tuple,最后再调用FlushPages(),在索引构建的整个过程中不需要走INSERT流程。
先来看 InsertTupleInMemory:
这个函数最重要的就是调用 HnswFindElementNeighbors(),它的输入为目标向量,entryPoint(每一层的入口点)列表,还有m、efConstruction等参数,它的工作是,在每一层查找距离目标向量最近的efConstruction个点,再选出m或2m个点,存储在目标向量的HnswElementData::neighbors 内,HnswElementData::neighbors 是一个 HnswNeighborArray的指针数组,每个成员是一个指向HnswNeighborArray对象的指针,代表着这个节点在某一层的友点。
调用完 HnswFindElementNeighbors() 后,目标向量的HnswElementData对象就存储了它所在的每个层中,与它最近的m个友点。
这样也就是有了目标向量到最近友点的连接,但是每两个点的连接是双向的,两个点的HnswElementData都要有指向对方的指针,因此,还要在其友点上设置指向目标向量的指针,这可能要挤出其它指针。
我们先看一下 HnswFindElementNeighbors 内部逻辑,它分为两个阶段(phase),这是考虑到目标向量出现的层,可能低于或高于entrypoint所在的层。如果目标向量的level,低于entrypoint的level,则从entrypoint所在层向下查找,直到目标向量所在层,这个过程称为phase1,这个过程上一层只找到距离目标向量最近的一个点,然后作为entrypoint进入下一层,并且不会在目标向量的HnswElementData中保存最近点(因为目标向量不会出现在这些层)。
然后进入phase2,如果目标向量的level高于entrypoint的level,则跳过phase1,直接进入phase2。以phase1的结束时的entrypoint和layer为起点,在目标向量出现的层中搜索(调用HnswSearchLayer),HnswSearchLayer返回距离目标向量最近的efConstruction个点,作为entrypoint,再进入下一层查询(“dynamic candidate list”),并且目标向量在这一层的HnswNeighborArray对象中,还要存储entrypoint列表中距离它最近的前m个点。这就是HnswFindElementNeighbors做的工作。
其中调用的HnswSearchLayer,是在一层图中搜索距离目标向量最近的若干个点并返回:
List *HnswSearchLayer(char *base, Datum q, List *ep, int ef, int lc, Relation index, FmgrInfo *procinfo, Oid collation, int m, bool inserting, HnswElement skipElement);
它返回一个List,包含距离目标向量最近的若干个点,点的个数由参数ef决定,搜索的层由参数lc决定,搜索的起点由ep决定,目标向量是参数q,procinfo和collation是距离近比较的函数。一般每一层调用一次,上一层返回的List,作为下一层输入的ep,逐层向下搜索,这里只从使用角度说明了一下HnswSearchLayer,内部实现逻辑暂不讨论。
HnswSearchLayer之后调用SelectNeighbors,它在前一步查找出的 efConstruction 个最近点中,根据距离排序,找出最近的lm个,作为目标向量在这一层的neighbours:
List *SelectNeighbors(char *base, List *c, int lm, int lc, FmgrInfo *procinfo, Oid collation, HnswElement e2, HnswCandidate * newCandidate, HnswCandidate * *pruned, bool sortCandidates)
它的输入参数 c 是前一步返回的最近点列表,参数lm表示从中选出lm个neighbour,lc是层,返回的List是前ml个最近的点,同样,内部实现逻辑暂不讨论。
然后是 AddConnections,它的工作是,把前一步SelectNeighbors返回的neighbours保存到目标向量HnswElementData在某一层的HnswNeighborArray中。
至此,HnswFindElementNeighbors函数结束。
然后调用 UpdateGraphInMemory ,前一步在每一层找到了目标向量的neighbours,并且保存到目标向量的HnswElementData里,因为连接需要是双向的,还要更新这些neighbours的HnswElementData中的HnswNeighborArray,使它们指向目标向量,这个工作就是UpdateGraphInMemory来做,其内部主要调用了三个函数:
- FindDuplicateInMemory(base, element) -- 确定目标向量,是否与已经存在于图中的向量相同(即有可能插入时,不同的tuple会有相同向量,这种情况用HnswElementData::heaptids[]记录不同tuple的tid,但图中的节点对象只有一份),如果是,一定是在neighbours 列表中,那么仅仅比较目标向量和neighbours 列表中的向量是否相同,如有相同,在HnswElementData::heaptids[] 增加一项然后返回,不需要重复插入。
- AddElementInMemory(base, graph, element) -- 如果图索引中没有与目标向量相同的向量,就先把目标向量加入HnswGraph对象的HnswElementData链表中(HnswGraph::head)。
- UpdateNeighborsInMemory(base, procinfo, collation, element, m) -- 对于目标向量在每一层的neighbours 列表中的点,更新它们的HnswElementData::HnswNeighborArray,使它们指向目标向量。
最后,如果目标向量的level大于HnswGraph对象的entrypoint的level,则用目标向量就是新的entrypoint,由此可见最顶层的entrypoint是一个点。
至此,InsertTupleInMemory 函数结束。
FlushPages() :
HnswGraph代表了多层图在内存中的表示形式,在我们完成它的构建后,需要把它写到shared buffer pool的page中(进而写到磁盘)。还有一种情况是,内存不够,将一部分向量构建的图,先保存起来。这两种情况都要调用FlushPage来实现。HnswGraph中节点间的连接用指针,要存储到表的page时,考虑到再次加载时数据的地址已经变了,因此不能再用指针,应该为这个内存中的图设计一种存储方案,即tuple结构,能够内存地址无关的存储节点之间的连接,pgvector的设计是:HnswGraph中的每一个节点(HnswElementData)用一个HnswElementTuple和一个HnswNeighborTuple存储,即两个tuple在page里存储一个HnswElementData节点信息。
FlushPages首先调用 CreateMetaPage创建一个meta页,在ivfflat索引里,每个索引的第一个页也是meta页,这个页是标准页结构,但内容不是标准tuple结构,也不是标准index tuple结构。这个meta页也有一个 special area,里面存放的是 HnswPageOpaqueData,其中最重要的变量是nextblkno,指向下一个页的页号对于meta页,是InvalidBlockNumber。
与ivfflat的meta页类似,在页头PageHeaderData之后就是内容区,内容区的数据结构由HnswMetaPageData。
注意,这里不是先写wal而是直接写到shared buffer pool的page里。
由于hnsw的meta page的内容不是标准tuple,因此也没有line pointer数组。
line pointer数组里的一个元素是ItemIdData 结构体,是个32位的unsigned int,每调用一次PageAddItem,在page末尾插入一条tuple,line pointer数组里增加一个ItemIdData,ItemIdData里不仅仅是tuple的偏移量,也包括tuple数据的长度。
FlushPages然后调用CreateGraphPages,这个函数把HnswGraph中每个节点写到page里,内存HnswGraph中每一个节点,对应page中两个tuple,布局由HnswElementTupleData 和HnswNeighborTupleData定义,这两个tuple是紧挨着存储的。如果节点的向量太大了,一个page只能存放一个HnswElementTupleData,放不下它和HnswNeighborTupleData,这样只能下一个page存放HnswNeighborTupleData。
遍历HnswGraph图中的节点,不是深度优先或广度优先,而是从HnswGraph::head开始遍历HnswElementData链表(一个向量点在内存中的表示是HnswElementData,在page中的表示是HnswElementTupleData+HnswNeighborTupleData),先为element tuple和neighbour tuple分配内存(8k)然后计算它们的实际长度与总和长度,这个长度是它们的tuple在page上占用的空间。然后给HnswElementTupleData填充值:
/*
type -- HNSW_ELEMENT_TUPLE_TYPE
level -- 向量所在的最大的层的编号
heaptids -- 向量的基表tuple的tid
neighbortid -- neighbour tuple的tid
data -- 向量数据
*/
typedef struct HnswElementTupleData
{
uint8 type;
uint8 level;
uint8 deleted;
uint8 unused;
ItemPointerData heaptids[HNSW_HEAPTIDS];
ItemPointerData neighbortid;
uint16 unused2;
Vector data;
} HnswElementTupleData;
/*
type -- HNSW_NEIGHBOR_TUPLE_TYPE
count -- 所有层的所有友点的总个数
indextids -- 所有 neighbour 的指针,包括了所有层的 neighbour
*/
typedef struct HnswNeighborTupleData
{
uint8 type;
uint8 unused;
uint16 count;
ItemPointerData indextids[FLEXIBLE_ARRAY_MEMBER];
} HnswNeighborTupleData;
注意,这里 HnswElementTupleData 和 HnswNeighborTupleData 并不是仅仅存储某一层的向量和neighbours,而是存储了一个向量在所有层上的neighbours。
关键就在于HnswNeighborTupleData::indextids,内存分配时,分配了所有层的neighbour数组。
这里先说明一下HnswElementData结构:
typedef struct HnswElementData
{
HnswElementPtr next;
ItemPointerData heaptids[HNSW_HEAPTIDS];
// 插入节点时,可能多个不同的基表 tuple包含相同值的向量,
// 这样相同向量插入索引时(无论是内存还是磁盘),
// 不再重新创建HnswElementData或HnswElementTupleData,
// 而是在这个数组中增加一项:基表对应tuple的tid。
uint8 heaptidsLength;
uint8 level; // 最高的层数
uint8 deleted;
uint32 hash;
// 指针数组,每个成员指向一个HnswNeighborArray,
// 每个HnswNeighborArray表示某一层的所有友点
HnswNeighborsPtr neighbors;
BlockNumber blkno; // 对应存储在page中的HnswElementTupleData的page号
OffsetNumber offno; // 对应存储在page中的HnswElementTupleData的page偏移
OffsetNumber neighborOffno; // 对应存储在page中的HnswNeighborTupleData 的page偏移
BlockNumber neighborPage; // 对应存储在page中的HnswNeighborTupleData 的page号
DatumPtr value; // 向量数据
LWLock lock;
} HnswElementData;
我们需要把写入page的 element tuple 和 neighbour tuple 的页号和页内偏移记录到内存graph节点的HnswElementData结构体里,为什么呢?在第一次遍历图时,每个节点的 neighbours 所指向的其它节点(也是HnswElementData) 不一定都已经写入了page(即其HnswElementData->blkno 和 HnswElementData->offno 不一定有值),这样在我们写完了一个节点的HnswElementTupleData 后,再写这个节点的HnswNeighborTupleData时,会发现这些neighbours在哪个page哪个offset都不能确定,所以第一次遍历图和写page时,需要把每个HnswElementData的页号和偏移,记录在自己对应的 HnswElementData 中,也要写向页中写 HnswNeighborTupleData,但是只是占个位,并不写内容。
在 CreateGraphPages 的最后,调用 HnswUpdateMetaPage 更新一下meta page里的:
entryBlkno -- 查找的起始向量的HnswElementTupleData页号
entryOffno -- 查找的起始向量的HnswElementTupleData页内偏移
entryLevel -- 查找的起始向量的最大level
insertPage -- 插入新向量时的起始页号
之后调用static void WriteNeighborTuples(HnswBuildState * buildstate),再次遍历内存中graph,这次遍历时,每个节点的HnswElementData的 HnswElementTupleData 的tid(blockno+offset)都有值了,对于每个节点,根据内存中的图,找到这个节点的所有层的 neighbour 的 tid ,用来写入它的HnswNeighborTupleData,由于之前已经记录了这个节点的HnswNeighborTupleData的tid,加载其所在页,并在偏移位置上修改即可。其中HnswSetNeighborTuple(),就是从图中找到一个向量节点所有层的友点的tid,设置到的HnswNeighborTupleData里,从代码可以看到 HnswNeighborTupleData::indextids 是线性存放不同层的友点tid的,先是最上层,依次往下。
至此 FlushPages(buildstate);解释完了。
然后是HnswInsertTupleOnDisk(),为了说明它,必须详细说说HnswSearchLayer这个函数,因为HnswInsertTupleOnDisk()内部,查找目标向量最邻近点的函数也是HnswFindElementNeighbors,这与在内存中graph查找是一样的,而其内部也是调用HnswSearchLayer,看来HnswSearchLayer函数是可以处理在内存的graph中搜索,和在磁盘的page中搜索两种情况,它是一个绕不开的函数,下面将描述的非常详细:
HnswSearchLayer() 里定义了3个list,分别是:
visited list -- 记录已访问过的节点 -- V
closest list -- pairing_heap,树根是距离目标向量最近的点 -- C
furthest list -- pairing_heap,树根是距离目标向量最远的点 -- W
对于第一个ep(entry point),先把它加入V、C、W,然后取出C树根节点c,同时读W树根节点f,遍历c的所有友点n[i],计算友点和目标向量的距离d,如果 n[i]->d < f->distance(注意ep里已经包含与目标向量的距离了)或者是W的里节点个数不满(C、W的容量由ef决定)就把n[i]加入C和W,继续从C中取出根节点,直到C空了,或者C根节点与目标向量的距离比W还大(可能吗?),遍历图就结束了,最后,W中的点就是距离目标向量最近的点。
这里有许多细节忽略了,所以再说一遍:
1、ep是HnswCandidate里面已经包含distance了,就是事先已经计算好了。
2、还有一个Visited哈希表,遍历过的点都要加到Visited,在遍历(ep的)友点时,会检查友点是否Visited过,如果没有才会计算与目标向量的距离并与f比较。
3、W中向量数如果超过ef,就要从f中pop出树根节点。
4、整个过程就是不断从友点里选出距离目标向量最近的点,加入W,逐渐挤出距离最大的点。
5、这个过程也用于向量在page中的情况,在获取neighbours时(HnswLoadNeighbors),一个从page里加载的不完整的ep,从page中加载它的HnswNeighborArray,这个加载neighbours的操作不会一直连锁反应下去,仅仅到创建HnswElement对象,加载HnswElement::blkno 和 HnswElement::offno为止,因为这些信息是HnswNeighborTuple所包含的,要再多的信息,就要对每个HnswElement调用HnswLoadElement,这就是在磁盘上查询了,估计scan就是走这个流程。
最后 HnswInsertTupleOnDisk() 调用 UpdateGraphOnDisk() ,前面的HnswFindElementNeighbors() 结束后,在目标向量的HnswElementData的各层neighbours中,已经存储了离自己最近的友点(的指针或tid),但是还需要将这些点的neighbours指向目标向量。而且因为是“OnDisk”,目标向量的HnswElementData要写到page,其友点neighbours的指针也要写到page。这就是UpdateGraphOnDisk()要做的工作。
这个函数里,首先调用FindDuplicateOnDisk,因为有可能要插入的向量,在数据库(索引)中已经存在了,这时要不要插入呢?也要插入,但是不是会为这个向量在page中重复存储 HnswElementTupleData 和 HnswNeighborTupleData,而是找到索引中相同的那个向量的HnswElementTupleData,在heaptids数组中加一项,就是这个新向量所在基表tuple的tid。FindDuplicateOnDisk遍历目标向量的第0层的neighbours,如果有与之重复的向量,必定在其HnswNeighborArray数组里,这里有个细节,第0层(也可能是每一层)的neighbours与element的距离从小到大有序,也就是遍历neighbour判断是否有相同时,只要看第一个就够了,如果第一个不相同,后面也不可能相同(距离更大)。
然后执行 AddElementOnDisk(),执行到这里,说明索引中没有与新加入向量相同的向量,于是就要把新加入的向量的信息转为HnswElementTupleData 和 HnswNeighborTupleData存储到page中,那存储到哪个page呢?要先从metaPage中提取insertPage的页号。还要看这个页中有没有空间存放element tuple+neighbour tuple,如果不够,会查看已删除的tuple的空间够不够,如果还不够,就追加一个或两个新页来存放。关于删除索引项时都做了些什么,后面再说。
AddElementOnDisk() 执行完后,新向量的信息作为tuple被存储到page中了,但是这个向量还没有和它的neighbours联系起来,它的neighbours列表里,只是它的候选友点,还要将这些友点的neighbours中的一个指向它的tid,才算建立起联系(并存储到page中),这些友点可能neighbours 数组满了,这就要从数组中挤出一个neighbour,也有可能挤出的就是新加入的向量。这个工作在 HnswUpdateNeighborsOnDisk函数里做。
HnswUpdateNeighborsOnDisk 里的 update neighbour 既是指 update 目标向量x的neighbours数组,也是指update x的最近点的neighbours数组。函数里面主要有两个循环,level(层)循环和neighbours循环,即遍历了x所有层的所有neighbour。最外层是 level 循环,对于每一层,遍历x的neighbours数组,对于x->neighbours[i](表示x的某个neighbour,以下用y表示),先调用HnswLoadNeighbors(),把它的neighbours数组从page加载为内存结构体,但这个加载只会填充每个element的页号和页偏移。
然后调用 HnswUpdateConnection() ,对y的neighbours数组进行操作,如果数组容量有余,就直接增加一个指向x的tid,如果数组已满,就要淘汰掉一个tid,淘汰掉哪个呢?先遍历一下y->neighbours 数组,把里面指向的HnswElement更多数据加载到内存中,如果有谁被标记了deleted,就淘汰它,如果都没有被标记deleted,就调用SelectNeighbors,将x和y->neighbours加到列表里,输入给SelectNeighbors,这个函数的pruned参数将返回被淘汰的那个向量的HnswCandidate封装,淘汰的标准是,距离y最远被淘汰。
注意,HnswUpdateConnection() 这个函数的操作是在内存中的HnswElement,不是在page中,将HnswElement 的操作结果写 page 是后面函数做的。
HnswUpdateConnection() 函数返回后,它的 updateIdx 指向 y 中被更新的 neighbour,即 y->neighbours[updateIdx],即在内存中的表示已经被更新。接下来,还需要将这个更新写入 page(如果updateIdx是-1说明x被淘汰,则不要建立x与y的连接,继续检查x的下一个neighbour)。于是把y的 neighbour tuple 调入 page,更新 updateIdx 对应的tid元素(让它指向x)。这是个写page的过程,分build还是insert,如果是insert就写wal(generic_xlog),如果是build就直接写page。
然后,循环回来,处理x的下一个neighbour,以此类推,处理完同一层的所有neighbour,再进入下一层,所有层处理完后,HnswUpdateNeighborsOnDisk() 函数结束。注意,这个函数将更近写入了page。
然后根据需要更新metapage,之后UpdateGraphOnDisk就结束了,之后HnswInsertTupleOnDisk的主要逻辑就结束了。HnswInsertTupleOnDisk的逻辑非常重要,建索引时向磁盘插入向量点,和insert 基表tuple时触发的插入索引都走各个逻辑。
之后是删除流程,和scan流程。
删除heap表中记录时,并不会立即删除索引项(但是表中的记录项会被标记删除,索引项仍在,查询时可以查到索引,再到表的页中寻找,发现找不到,则返回不存在),hnswbulkdelete 不会被立即调用,只有在执行vacuum table_name时,hnswbulkdelete 才会被调用,然后hnswvacuumcleanup被调用。
在做vacuum时 hnswbulkdelete 才会被调用,它负责删除之前heap表中删除的记录对应的索引项。
这个工作需要 hnswbulkdelete 的实现者来做,在hnswbulkdelete的内部实现中,需要扫描每个索引项,从中得到对应的tid,然后用这个tid调用callback函数,去查这个tid所指的表tuple是不是被删除了,如果是,callback会返回true,那么索引中也要删除对应的索引项。
在 hnswbulkdelete 中删除索引项的过程是这这样的:调用 RemoveHeapTids ,扫描并查询每个索引项,如果其中的heaptid全部被删除了,就把这个索引项的tid记录到一个hash表中,已经被删除的索引项也会被加入到hash表中,一个索引项可能有多个heaptid,即重复的表记录的索引项只有一项,只有当所有重复表记录都删除了,这个索引项才会被删除,才会加到hash表。
然后RepairGraph再遍历全部索引项(HnswElementTupleData),如果其友点在hash表中,就按插入过程,将这个向量重新插入到Graph中。
然后MarkDeleted遍历一遍全部索引项,将heaptid数组里没有有效值的向量,标记为删除。这里有个问题,既然前面把tid数组里没有有效值的向量加入了hash表里,为什么不直接遍历这个hash表?这个过程可能不是那么简单,因为hnswbulkdelete调了不止一次。
有可能一个表,有多个index,删除表的记录然后vacuum时,多个索引的hnswbulkdelete都会调用,如果只有一个索引,如果有表tuple删除,无论删除多少条,vacuum时,hnswbulkdelete只调用一次,注意:一次vacuum一个索引调用一次hnswbulkdelete,在这个函数中,索引访问方法实现者,遍历一遍索引删除该删除的索引项,至于哪些索引项该删除,要用每个索引项的heaptid去调用callback函数,返回true表示应该删除,否则不删除。
以前被删除过的索引项,被标记为删除,在RemoveHeapTids中还会被加入hash表,在MarkDeleted中还会被遍历到。
RemoveHeapTids 对每个索引项调用callback函数,对于被删除的heap tuple的索引项,其heaptids数组中对应的tid被设为invalid,注意,索引项不一定被删除,因为可能一个索引项对应多个heap tuple。如果一个索引项的所有heap tuple都被删除了,其heaptids数组全为invalid。对于这种索引项,加入一个hash表,后面更新Graph时用。
最后在MarkDeleted时,还会遍历一遍索引表,对于heaptids全为invalid的索引项,设为deleted,并且抹除向量数据,和neighbour tuple的数据。
调试时,观察到一些奇怪的现象,当删除一条heap tuple再做vacuum,在hnswbulkdelete里并不删除这条heap tuple所对应的索引项,但是这条heap tuple确确实实被删掉了,但是索引项还在,查询时,能查到索引项,但是heap tuple查不出来。当在删除另一条heap tuple再做vacuum时,前一条被删除的heap tuple对应的索引项才被删除,当删除多个heap tuple 再做vacuum时,最后一个heap tuple的索引项对应的callback返回false,因此,这个索引项没有被删除,这个行为完全是由callback函数控制的!奇怪。
这里面还有并发insert、删除、查询的情况下,代码的处理没有讨论。
关于这个,还要看PG文档中关于索引并发的文章,和vacuum内部工作机制。
鉴于opengauss后台服务都是线程,做改造时,要注意每个函数都要是线程安全的。
参考
一文看懂HNSW算法理论的来龙去脉_hnsw自然语言-CSDN博客
向量数据库是如何检索的?使用可视化工具 Feder 洞悉 IVF_FLAT 类型索引背后的向量空间_ivf flat-CSDN博客
PGVector的背景、原理、使用方法及其他相关信息_云原生数据库 PolarDB(PolarDB)-阿里云帮助中心 (aliyun.coj