milvus架构篇
关键字:架构,shard,vchannel,pchannel,segment,封闭区段,增长区段,对象存储,数据**日志**快照,索引日志快照
milvus的数据结构
collection:table
channel:通道(多querynode并行查询)
segment:物理存储单元
patition:逻辑存储单元
官方的
结构
milvus官方架构图
milvus是一个使用go编写的向量数据库,采用的是一个RPC的架构,一个天然的分布式系统。
- access layer数据接入层,集群需要负载均衡选取节点(可使用nginx)
- coordinator service协调服务层,主要用于请求和底层节点(worker node)的交接
- etc存储元数据
- root根节点 - 处理ddl和dcl请求,collection/partition/index的操作,需要把这些元数据保存到meta storage
- query协调器 -
- 管理query node
- 负载均衡所有的query节点
- 对增长段和非增长段进行查询
- data协调器 -
- 管理data node
- segment,collection,partition
- index协调器 - 管理索引节点(已经弃用 2024/03/20)
- worker node工作节点,被协调器调度工作
- 查询节点 - 先从Log队列中获取日志事件
- 从对象存储中(minio)加载数据
- 数据节点 - 通过订阅日志时间来完成dml请求
- 监听增长segment,到达一定就进行封闭为seal segment,若存在index就开始创建索引
- 处理修改数据的日志操作
- 负责把增量数据写入增长segment
- 索引节点
- 构建索引
- object storage对象存储层
- 日志存储 - 在milvus中,日志是以快照的方式存在,其实日志就是数据像redis的rbd文件
- insertBinlog,deleteBinlog,ddlBinlog
- 元数据存储 - etcd作为源数据存储,存储元数据快照,同时etcd进行服务注册和健康检查
- 索引文件存储
操作**日志**binlog
消息队列存储处理DML语句是会进入日志消息队列也就是传统增删改查,默认使用rocketmq,但是大数据量情况下建议使用kafka。使用队列通过发布和订阅来完成日志事件。
并且采用的是通道获取日志:虚拟通道和实际通道(类似操作系统中的用户态和内核态)
比如一个insert_log就是一个插入日志,里面对应了我们的patition,collection,segment,数据列信息等等,存到我们的物理segment中,是真正的日志就是数据。
数据处理部分
下面主要分清几个概念:shard分片,vchannel虚拟通道,pchannel物理通道
创建collection时指定分片,同时n个分片对应n个虚拟通道,对于插入删除操作会根据主键hash值进行路由到分片。具体参考hashmap里面的put方法。
一个虚拟通道在工作时需要搭配物理通道来存储数据
插入数据时考虑日志代理节点(proxy)是否需要扩容,是否有足够分片来负责均衡
从上图,可以清楚地看清对象存储中的collection是以segment为存储单位,一个collection分为多个segment,而且每个segment都有自己的索引。当我们load加载时需要把所有segment(按collection加载)加载到我们的index node中,在内存中处理数据的二进制快照文件,反序列化成数据然后根据索引类型构建索引,最后需要反序列化回到对象存储中,下次修读取进行load时直接从我们的对象存储中加载并且反序列化进行操作。
从上面知道,其实milvus的数据并不会放到内存中,而是把索引加载到内存中而已,然后根据索引去对象存储中加载数据。
而且我们的segment有两种,一种是增长型(新增数据),一种是封闭型(历史数据),一开始所有的segment都是增长型,当某个segment数据量达到一定时,就改成封闭型,不再接受数据,并且开始构建索引,这就是为什么milvus不在一开始构建而在1024段时进行构建的原因,能够避免多次构建segment索引。
创建集合
- proxy接收请求,创建collection任务放入队列中,默认proxy队列为1024,若队列满了会报错拒绝服务器,可以在配置文件中调整。
- 空闲协程获取任务消费,交付给root协调节点
- rootCoord获取到collection和partition等信息,然后为collection分配虚拟通道
- 添加物理通道对虚拟通道的映射
- rootCoord通知etcd进行collection的metadata记录
- 广播创建collection成功的通知给所有节点
后面基本都是proxy接收请求,故省略前部分。
删除集合
- proxy接收请求
- 携程取出,交予root协调节点,并且生产删除日志消息放到我们的消息队列中
- query节点异步删除所有的index,segment,释放collection
- data节点进行数据删除
- 通知etcd注销数据
新增数据
- proxy接收
- 向root协调节点申请时间戳,向datacoord申请主键
- 按照分片通道进行hash路由,生成插入日志并且放入通道
- datacoord从通道中获取消息日志,并且根据collection的聚类信息形成新的insert_log
- 最后把我们的insert_log写入到存储引擎中
加载数据
- 接收请求,由query协调节点
- 根据负载均衡把collection对应的shard分配到对应的query node中(hash)
- 从存储引擎加载到本地
查询数据
- proxy接收请求,把任务放到Dq通道中
- 所有QueryNode都订阅这个通道,获取任务向本地的segment进行查询
- 最快获取的node进行返回
索引篇
一般的索引不影响结果,只是一种加快查询数据的结果,但是milvus中的索引是有可能会改变查询结果的,主要和他的召回率有关。
评判一个索引的好坏主要有两个因素
- 召回率
- 速度
- 内存占用大小
IVF
聚类倒排桶算法,一种比较常见的索引,是基于聚类桶进行加快索引检索过程的。
构建过程
构建先需要kmean聚类,找出聚类中心点,创建倒排索引,这里创建collection的时候需要指定关键参数-nlist就是去指定该聚类的个数,然后倒排索引就最多有nlist个桶。
检索过程
检索过程就是需要创建索引指定的向量距离计算算法,用来计算临近向量,一般有L2,IP内积,余弦等等。
关键参数nprobe,搜索聚类个数。
通过上面两个过程,可以发现nlist(指定创建倒排聚类个数)和nprobe(检索聚类个数)。
召回也就是能搜索到的数据,然后对所有数据进行score排序(向量距离),最后返回topk。
参数
合理设置nlist和nprobe可以达到一个平衡,当然这需要根据业务场景和数据量来多次测试。
对于nlist如果过大会导致聚类桶过多从而退化成暴力搜索,nprobe接近nlist也是会这种情况。根据我以前的使用我是设置成nlist=1024和nprobe=64.
nprobe越接近nlist召回率越高,当相等召回率100% ,nprobe越小QPS越高。
上述就是IVF的索引结构,但是因为内存和召回的原因,IVF下面又有很多的分化
类型
IVF_FLAT
基于nprobe满足的召回率,,而且该索引并没有进行压缩,目前我们使用的就是该索引
参数和IVF两个基本参数一样
IVF_SQ8
将FLAOT转化成UINT8,由4个字节变成1个字节,空间上缩小了3/4,但是需要牺牲很小一部分精度
参数和IVF两个基本参数一样
IVF_PQ
基于乘积量化,由高维数据变成低纬数据,但是这种的向量距离计算是根据分段索引的,所以精度会损失一部分
参数:
- m 将向量压缩成多少个小向量
- 压缩比
比如现在采用256维度的索引,若m = 4的索引,通常是压缩成4维度作为子向量,就可以达到1 / 4 / 4 = 1/16,缩减16倍
HNSW
一种基于层级图的索引,这种索引具有高召回率,高QPS,但是占用内存大,不适合中小型服务器。目前应该是milvus中第二快的索引。
上层稀疏,下层密集,区分度好
搜索过程就是在本层找到最近的节点,然后逼近。
参数:
- M 节点最大度
- efConstruction 搜索范围
ANNOY
一颗基于树的索引(暂时没有了接太多)
DiskANN
磁盘索引用于大量优化内存空间
检索篇
目前至此的向量检索主要有4种
- 欧式距离(L2)
- 点乘内积
当对嵌入向量数据归一后,我们的IP和L2计算结果是相等的
3.余弦
工作优化
目前我认为从milvus上的优化主要有
- 开启mmap零拷贝,减少内核和用户线程的数据拷贝,最新milvus支持
- 利用partition,partition为我们的逻辑分区,用上能大量减少检索向量的数量
- 目前是使用IVF_FLAT索引,没有进行向量的压缩,可以通过设置nlist和nprobe达到一个平衡值,之前的nlist和nprobe分别是64和10,官方建议值是nlist = 4 * sqrt(数据量),nprobe可以根据召回设计
- 更换索引类型
关于条件检索
关于时间的探讨
目前milvus对条件检索其实不太友好,并没有支持太多的功能,关于带条件的检索和不带条件的检索区别就是带expr就是在走索引之前把所有的向量过滤一遍,但是并不一定带条件比不带条件的要慢;
比如下面举一个例子
我有1000条的数据 expr = "city = '北京' ",topk = 5
带expr的情况:先对1000条数据进行全量比较city条件,花费50ms,然后过滤出来的数据有20条,然后进行索引的检索,搜查桶,如果是nlist = nprobe的话,那么我们就需要对20条数据进行向量距离的计算,就比如L2,20条花费了10ms,那么一共需要60ms的时间
不带expr的情况:直接计算1000条的向量距离,可能需要花费100ms;
这种情况就是不带条件有可能比带条件的花费时间更长的原因;
milvus隐藏段
milvus基于这里同时推出了动态字段和json类型的,其实两者是一样的。动态字段其实也就是milvus中的一个隐藏字段$meta,这就是一个json类型的字段。所以milvus不像es那样能随意实现动态映射的。
目前的痛点:
基于上面回到我们一开始走文件导入是直接文本分段,并且没有使用到隐藏段,所以就无法用到条件的过滤。现在阶段只支持addBatch方式手动添加字段,具体看下面文档。