向量索引的选择与管理
什么是向量索引
向量索引是针对向量类型数据而建立的索引机制,与传统的标量索引的目的类似,其最终目的也是为了加速向量检索的过程。由于向量检索的本质与传统的标量检索不一样,因此向量索引的原理与标量索引的原理也不一样。经过多年的发展,学术界和工业界开发出了多种不同类型的向量索引,常见的类型包括平坦索引、倒排类索引、树形索引和图结构索引等等,同时还可以叠加各种量化压缩机制,通过牺牲一定的召回率来减少索引大小和提升检索性能。
VectorDB当前已经提供了一些常用的向量索引类型供用户选择,同时也在进一步丰富索引的类型。同时,VectorDB也提供了比较灵活的向量索引管理机制,包括:
- 创建索引:支持在建表的同时创建向量索引,也支持建表之后动态新创建索引。
- 删除索引:支持将已存在的向量索引删除,删除索引只会删除索引数据,不会影响原始向量数据。
- 重建索引:通过先删除旧的索引,然后创建新的索引,可以实现索引类型的变更,新旧索引类型不需要一致。
- 构建索引:支持在任意时刻对已存在的索引(不论该索引此前是否构建过)执行再次构建,并且,在构建过程中,不影响旧索引数据继续提供检索服务。在构建完毕之后,自动删除旧的索引数据,让新的索引数据生效。
- 展示索引信息:支持展示指定索引的详情,包括索引参数、构建状态等。
向量索引的评估
从业务角度来看,选择何种类型的向量索引是需要仔细地评估。在评估一个向量索引是否适合自身场景时,重点关注如下指标:
- 性能(Performance):在指定的算力条件和检索参数下,该索引的单次时延表现,以及总的吞吐(QPS)表现。业务需要判断检索的性能是否满足需求。
- 召回率(Recall):召回率是指,检索出来的结果集中(假定结果数量为K),满足真实情况下最接近目标向量的K个向量的集合的比例,也就是说,KNN检索的召回率是100%,ANN检索的召回率<=100%。业务需要根据自身场景和效果要求,确定可接受的最低召回率要求。
- 成本(Cost):对于向量索引而言,大家谈成本主要指的是内存存储成本和算力成本。一般不用太在意磁盘的存储成本,因为内存的单位成本比磁盘(即使SSD)成本至少高一个数量级。同时CPU和GPU的算力成本也是比较高和非常高的。
很难有一种向量索引,能够在上述三个关键指标中都取得非常理想的表现,需要业务在这些指标中进行权衡,我们可以将这个权衡简称为 CRP-Tradeoff。
向量索引的选择
VectorDB当前支持FLAT和HNSW索引,同时也支持“无索引”,即不对向量字段建立任何向量索引,仍然可以提供向量检索,而且是召回率为100%的KNN检索。如何根据业务特点选择这些索引,我们通过一个表格来进行对比分析:
索引类型 |
索引简介 |
成本 |
性能 |
召回率 |
适用场景 |
---|---|---|---|---|---|
无索引 | 不建立向量索引,在检索时,需要先将原始向量数据从磁盘中读取并加载到内存,然后在内存中执行KNN检索。 | 很低 | 很低 | 很高(100%) | 对成本有苛刻要求,同时对性能要求不高的场景。 |
FLAT | 即平坦索引,顾名思义,将原始向量数据平坦地摆放在内存中,不增加任何辅助机制。在检索时,需要计算每个向量与目标向量的相似性,所以一般也被称为“暴力检索”。其本质是全内存+KNN检索,相比于无索引,其数据需要全内存,会带来相应的成本。 | 高 | 中低 | 很高(100%) | 可以接受较高成本,也可以接受稍差一点的性能(相比于非暴力检索),但是对召回率有苛刻要求的场景。 |
HNSW | HNSW的全称是Hierarchical Navigable Small World,即分层可导航小世界,是一种经典的图结构索引,该索引的优势在于性能和召回率都比较稳定,整体效果受向量数据集的分布/倾斜的影响很小。HNSW索引目前最为广泛使用的索引方法之一,用于支撑ANN检索。 | 高 | 高(但在极限过滤场景下,可能会很低) | 高(但在极限过滤场景下,可能会很低) | 可以接受较高的成本,对性能也有较高的诉求,对召回率也有较高的诉求的场景。综合上来看是比较平衡的场景,此类场景广泛存在。 |
Puck | Puck是百度自行研发的向量索引,在百度内部得到了广泛的应用,索引了上万亿条向量。Puck是倒排类索引,但在基础倒排结构上做了大量的改造和优化,效果提升明显。 | 高 | 高 | 中、高 | 与图结构索引不同的是,倒排类索引在构建索引之前,往往需要在采样数据集的基础上执行一个基于K-Means聚类的训练过程训练出码本,Puck也不例外。训练过程比较消耗资源,因此训练时间可能较长,故此类索引不建议频繁重建,更适用于向量规模较大(比如单个数据集达到上亿级别),且对召回率没有过于严格要求的场景。基于大语言模型的应用场景不建议使用该索引。 |
HNSW索引看起来非常平衡,但在实际应用中,受该索引算法的固有特性影响,容易出现性能急剧下降或者召回率严重下滑的情况,这种情况一般出现在“极限过滤”的场景。所谓极限过滤场景,是指在检索时,需要将该索引集合中的绝大部分数据都过滤掉,只检索极小一部分数据。主要原因在于,在构建HNSW的图结构时,依据的是构建集合中的全部数据及其他们之间的距离,而在检索时,由于绝大部分数据又都过滤掉了不参与距离计算,检索的结果中可能出现严重的漏召现象,从而导致召回率严重下降,当然其严重程度也受数据的分布影响。这类极限场景容易出现在ToC的业务中,比如一个ToC业务,有很多终端用户,但是每个终端用户的数据量很小(例如几百几千条),同时在检索的时候,由于用户隐私问题,只能检索属于该用户自身的数据。如果按照数十万上百万的规模来对这类数据集构建HNSW索引,一个索引集中包含数百到上千个用户的数据,检索时仅检索其中的一个用户的数据,就会造成极限过滤场景。这类极限过滤场景并不容易优化,若您的业务是此类场景,建议在使用之前联系VectorDB团队,共同研究优化方案。
除了上述已经上线可用的索引类型,VectorDB团队还在研发支持量化压缩的向量索引,包括Puck_PQ和HNSW_PQ,旨在为客户提供更多的选择。
如何管理向量索引
在本小节中,我们以Python SDK的使用方式来展示第一小节中的各类向量索引管理机制。
- 创建一个莫愁客户端对象
import time
import json
import random
import pymochow
import logging
from pymochow.configuration import Configuration
from pymochow.auth.bce_credentials import BceCredentials
from pymochow.exception import ClientError, ServerError
from pymochow.model.schema import Schema, Field, SecondaryIndex, VectorIndex, HNSWParams
from pymochow.model.enum import FieldType, IndexType, MetricType, ServerErrCode
from pymochow.model.enum import TableState, IndexState
from pymochow.model.table import Partition, Row, AnnSearch, HNSWSearchParams, FLATSearchParams
logging.basicConfig(filename='documentinsight.log', level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
if __name__ == "__main__":
account = 'root'
api_key = '$您的API密钥'
endpoint = '$您的实例端点' # 例如:'http://127.0.0.1:5287'
db_name = 'DocumentInsight'
table_name = 'ManageVectorIndex'
# 根据配置创建一个MochowClient
config = Configuration(credentials=BceCredentials(account, api_key), endpoint=endpoint)
mochow_client = pymochow.MochowClient(config)
- 建库建表,在建表时,我们对向量字段不建立任何索引,然后插入几条数据到表中。
# 建库
db = mochow_client.create_database(db_name);
# 若库已存在,则直接使用下述代码获取一个库对象
#db = mochow_client.database(db_name)
# 建表,其中向量字段不建立任何向量索引,即无索引模式
fields = []
fields.append(Field("DocId", FieldType.UINT64, primary_key=True, partition_key=True, not_null=True))
fields.append(Field("Title", FieldType.STRING, not_null=True))
fields.append(Field("Vector", FieldType.FLOAT_VECTOR, dimension=2, not_null=True))
table = db.create_table(
table_name=table_name,
replication=1,
partition=Partition(partition_num=1),
schema=Schema(fields=fields, indexes=[])
)
while True:
time.sleep(1)
table = db.describe_table(table_name)
if table.state == TableState.NORMAL:
break
# 插入5条数据
rows = [
Row(DocId=1, Title="LLM技术详解", Vector=[0.111111, 0.222222]),
Row(DocId=2, Title="深入浅出大语言模型技术", Vector=[0.111112, 0.222222]),
Row(DocId=3, Title="基于大模型的RAG应用开发实践", Vector=[0.111113, 0.222222]),
Row(DocId=4, Title="揭秘AI原生应用开发——原理与实战", Vector=[0.111114, 0.222222]),
Row(DocId=5, Title="向量数据库在RAG开发中的应用与实践", Vector=[0.111115, 0.222222])
]
table.insert(rows=rows)
- 在没有任何向量索引的情况下执行向量检索。
# 向量检索,检索Top2,此时莫愁提供的是KNN检索
vectorFloats = [0.1, 0.2]
anns = AnnSearch(vector_field="Vector", vector_floats=vectorFloats,
params=FLATSearchParams(limit=2))
result = table.search(anns=anns, retrieve_vector=True)
print("Search result via 无索引模式: {}\n".format(result))
如下所示,VectorDB成功完成了检索,输出了符合预期的结果(DocId为1和2的这两条数据):
Search result via 无索引模式: {metadata:{content__length:u'335',content__type:u'application/json',request_id:u'907369b4-7bf3-4079-b1cf-1ea575c8e9e4'},rows:[{'row': {'DocId': 1, 'Vector': [0.11111100018024445, 0.2222220003604889], 'Title': 'LLM技术详解'}, 'distance': 0.0006172714056447148, 'score': 1.0}, {'row': {'DocId': 2, 'Vector': [0.11111199855804443, 0.2222220003604889], 'Title': '深入浅出大语言模型技术'}, 'distance': 0.0006172936409711838, 'score': 1.0}],code:0,msg:u'Success'}
- 针对向量字段创建一个FLAT类型的向量索引,并执行构建,然后执行检索。
# 创建一个FLAT类型的向量索引然后构建该索引
index_name = "Vector_Idx";
indexes = []
indexes.append(VectorIndex(index_name=index_name, index_type=IndexType.FLAT,
field="Vector", metric_type=MetricType.L2,
auto_build=False))
table.create_indexes(indexes=indexes)
time.sleep(1)
table.rebuild_index(index_name=index_name);
# 查询该索引等待其构建完毕
while True:
time.sleep(1)
index = table.describe_index(index_name)
if index.state == IndexState.NORMAL:
break
# 再次执行向量检索,检索Top2,此时莫愁提供的是FLAT索引
vectorFloats = [0.1, 0.2]
anns = AnnSearch(vector_field="Vector", vector_floats=vectorFloats,
params=FLATSearchParams(limit=2))
result = table.search(anns=anns, retrieve_vector=True)
print("Search result via FLAT索引: {}\n".format(result))
如下所示,VectorDB成功完成了检索,输出了符合预期的结果(DocId为1和2的这两条数据):
Search result via FLAT索引: {metadata:{content__length:u'335',content__type:u'application/json',request_id:u'39e4fcad-f9c9-45e9-b6b8-439d2edf6293'},rows:[{'row': {'DocId': 1, 'Vector': [0.11111100018024445, 0.2222220003604889], 'Title': 'LLM技术详解'}, 'distance': 0.0006172714056447148, 'score': 1.0}, {'row': {'DocId': 2, 'Vector': [0.11111199855804443, 0.2222220003604889], 'Title': '深入浅出大语言模型技术'}, 'distance': 0.0006172936409711838, 'score': 1.0}],code:0,msg:u'Success'}
- 删除刚才创建的FLAT索引。
# 删除刚才创建的FLAT索引
table.drop_index(index_name);
time.sleep(1)
- 再次针对向量字段创建一个类型为HNSW的向量索引,且索引名称保持不变,最后再次执行向量检索。
# 改为创建成HNSW索引,且索引名称不变
indexes = []
indexes.append(VectorIndex(index_name=index_name, index_type=IndexType.HNSW,
field="Vector", metric_type=MetricType.L2,
auto_build=False,
params=HNSWParams(m=32, efconstruction=200)))
table.create_indexes(indexes=indexes)
time.sleep(1)
table.rebuild_index(index_name=index_name);
# 查询该索引等待其构建完毕
while True:
time.sleep(1)
index = table.describe_index(index_name)
if index.state == IndexState.NORMAL:
break
# 再次执行向量检索,检索Top2,此时莫愁提供的是HNSW索引
vectorFloats = [0.1, 0.2]
anns = AnnSearch(vector_field="Vector", vector_floats=vectorFloats,
params=HNSWSearchParams(ef=200, limit=2))
result = table.search(anns=anns, retrieve_vector=True)
print("Search result via HNSW索引: {}\n".format(result))
如下所示,VectorDB成功完成了检索,输出了符合预期的结果(DocId为1和2的这两条数据):
Search result via HNSW索引: {metadata:{content__length:u'335',content__type:u'application/json',request_id:u'f71499ff-f17d-40da-a93a-c989faf65650'},rows:[{'row': {'DocId': 1, 'Vector': [0.11111100018024445, 0.2222220003604889], 'Title': 'LLM技术详解'}, 'distance': 0.0006172714056447148, 'score': 1.0}, {'row': {'DocId': 2, 'Vector': [0.11111199855804443, 0.2222220003604889], 'Title': '深入浅出大语言模型技术'}, 'distance': 0.0006172936409711838, 'score': 1.0}],code:0,msg:u'Success'}
- 针对刚才创建的HNSW索引,再次执行构建,然后再次执行向量检索。
# 再次构建该HNSW索引
table.rebuild_index(index_name=index_name);
# 查询该索引等待其构建完毕
while True:
time.sleep(1)
index = table.describe_index(index_name)
if index.state == IndexState.NORMAL:
break
# 再次执行向量检索,检索Top2,此时莫愁提供的是HNSW索引
vectorFloats = [0.1, 0.2]
anns = AnnSearch(vector_field="Vector", vector_floats=vectorFloats,
params=HNSWSearchParams(ef=200, limit=2))
result = table.search(anns=anns, retrieve_vector=True)
print("Search result via 再次构建的HNSW索引: {}\n".format(result))
如下所示,VectorDB成功完成了检索,输出了符合预期的结果(DocId为1和2的这两条数据):
Search result via 再次构建的HNSW索引: {metadata:{content__length:u'335',content__type:u'application/json',request_id:u'493a0cfc-166d-48c8-ad97-502767966e89'},rows:[{'row': {'DocId': 1, 'Vector': [0.11111100018024445, 0.2222220003604889], 'Title': 'LLM技术详解'}, 'distance': 0.0006172714056447148, 'score': 1.0}, {'row': {'DocId': 2, 'Vector': [0.11111199855804443, 0.2222220003604889], 'Title': '深入浅出大语言模型技术'}, 'distance': 0.0006172936409711838, 'score': 1.0}],code:0,msg:u'Success'}