精确和近似向量搜索
对于给定的点,在多维(向量)空间中寻找 N 个最近点的问题被称为最近邻搜索,或者简称:向量搜索。 解决向量搜索问题有两种一般方法
- 精确向量搜索计算给定点与向量空间中所有点之间的距离。 这确保了最佳的准确性,即返回的点保证是实际的最近邻。 由于向量空间被详尽地探索,精确向量搜索对于实际使用来说可能太慢。
- 近似向量搜索指的是一组技术(例如,特殊的数据结构,如图和随机森林),它们比精确向量搜索计算结果快得多。 结果的准确性通常对于实际使用来说“足够好”。 许多近似技术提供参数来调整结果准确性和搜索时间之间的权衡。
精确或近似向量搜索可以在 SQL 中如下编写
向量空间中的点存储在 `vectors` 列的数组类型中,例如 Array(Float64)、Array(Float32) 或 Array(BFloat16)。 参考向量是一个常量数组,以通用表表达式的形式给出。 <DistanceFunction> 计算参考点与所有存储点之间的距离。 可以使用任何可用的 距离函数 来完成此操作。 <N> 指定应返回多少个邻居。
精确向量搜索
可以使用上述 SELECT 查询按原样执行精确向量搜索。 此类查询的运行时通常与存储向量的数量及其维度(即数组元素的数量)成正比。 此外,由于 ClickHouse 对所有向量执行暴力扫描,因此运行时还取决于查询的线程数(请参阅设置 max_threads)。
示例
返回
近似向量搜索
向量相似度索引
ClickHouse 提供了一个特殊的“向量相似度”索引来执行近似向量搜索。
向量相似度索引在 ClickHouse 25.8 及更高版本中可用。 如果遇到问题,请在 ClickHouse 仓库 中提交 issue。
创建向量相似度索引
可以在新表上创建向量相似度索引,如下所示
或者,将向量相似度索引添加到现有表
向量相似度索引是特殊类型的跳跃索引(请参阅 此处 和 此处)。 因此,上述 ALTER TABLE 语句仅导致为以后插入到表中的新数据构建索引。 要为现有数据构建索引,需要将其具体化
函数 <distance_function> 必须是
对于归一化数据,通常最好选择 L2Distance,否则建议使用 cosineDistance 来补偿比例。
<dimensions> 指定底层列中数组的基数(元素数)。 如果 ClickHouse 在创建索引期间发现具有不同基数的数组,则会丢弃该索引并返回错误。
可选的 GRANULARITY 参数 <N> 指的是索引颗粒的大小(请参阅 此处)。 与默认索引粒度为 1 的常规跳跃索引不同,向量相似度索引默认使用 1 亿作为索引粒度。 此值确保即使对于大型部分,也只会构建几个索引。 我们建议只有了解其含义的高级用户才更改索引粒度(请参阅 下方)。
向量相似度索引在通用性方面具有优势,可以适应不同的近似搜索方法。 实际使用的方法由参数 <type> 指定。 截至目前,唯一可用的方法是 HNSW(学术论文),这是一种基于分层邻近图的流行且最先进的近似向量搜索技术。 如果 HNSW 被用作类型,用户可以选择指定进一步的 HNSW 特定参数
这些 HNSW 特定参数可用
<quantization>控制邻近图中向量的量化。 可能的值是f64、f32、f16、bf16、i8或b1。 默认值为bf16。 请注意,此参数不会影响底层列中向量的表示形式。<hnsw_max_connections_per_layer>控制每个图节点中的邻居数量,也称为 HNSW 超参数M。 默认值为32。 值0表示使用默认值。<hnsw_candidate_list_size_for_construction>控制在 HNSW 图构建过程中动态候选列表的大小,也称为 HNSW 超参数ef_construction。 默认值为128。 值0表示使用默认值。
所有 HNSW 特定参数的默认值在大多数用例中都能很好地工作。 因此,我们不建议自定义 HNSW 特定参数。
适用其他限制
- 只能在类型为 Array(Float32)、Array(Float64) 或 Array(BFloat16) 的列上构建向量相似度索引。 不允许使用可为空和低基数的浮点数组,例如
Array(Nullable(Float32))和Array(LowCardinality(Float32))。 - 向量相似度索引必须构建在单个列上。
- 向量相似度索引可以在计算表达式上构建(例如,
INDEX index_name arraySort(vectors) TYPE vector_similarity([...])),但这些索引以后不能用于近似邻搜索。 - 向量相似度索引要求底层列中的所有数组都具有
<dimension>个元素 - 这在创建索引期间进行检查。 为了尽早检测此要求,用户可以为向量列添加 约束,例如CONSTRAINT same_length CHECK length(vectors) = 256。 - 同样,底层列中的数组值不能为空(
[])或具有默认值(也为[])。
估算存储和内存消耗
用于典型 AI 模型(例如,大型语言模型,LLMs)的向量由数百或数千个浮点值组成。 因此,单个向量值可能需要占用数千字节的内存。 想要估算表中底层向量列所需的存储空间以及向量相似度索引所需的内存的用户可以使用以下两个公式
表中向量列的存储消耗(未压缩)
例如,对于 dbpedia 数据集
向量相似度索引必须完全从磁盘加载到主内存中才能执行搜索。 同样,向量索引也是完全在内存中构建然后保存到磁盘的。
加载向量索引所需的内存消耗
例如,对于 dbpedia 数据集
上述公式不考虑向量相似度索引分配运行时数据结构(例如预分配的缓冲区和缓存)所需的额外内存。
使用向量相似度索引
要使用向量相似度索引,设置 compatibility 必须为 ''(默认值),或 '25.1' 或更高版本。
向量相似度索引支持以下形式的 SELECT 查询
ClickHouse 的查询优化器尝试匹配上述查询模板并利用可用的向量相似度索引。 只有当 SELECT 查询中的距离函数与索引定义中的距离函数相同时,查询才能使用向量相似度索引。
高级用户可以为设置 hnsw_candidate_list_size_for_search(也称为 HNSW 超参数“ef_search”)提供自定义值,以调整搜索期间候选列表的大小(例如,SELECT [...] SETTINGS hnsw_candidate_list_size_for_search = <value>)。 该设置的默认值 256 在大多数用例中都能很好地工作。 较高的设置值意味着更高的准确性,但性能较慢。
如果查询可以使用向量相似度索引,ClickHouse 会检查 SELECT 查询中提供的 LIMIT <N> 是否在合理范围内。 更具体地说,如果 <N> 大于设置 max_limit_for_vector_search_queries 的值(默认值为 100),则会返回错误。 过大的 LIMIT 值会降低搜索速度,通常表明存在用法错误。
要检查 SELECT 查询是否使用向量相似度索引,可以在查询前加上 EXPLAIN indexes = 1。
例如,查询
可能会返回
在本例中,100万个向量存储在 dbpedia 数据集 中,每个向量的维度为 1536,存储在 575 个颗粒中,即每个颗粒 1.7k 行。查询请求 10 个邻居,向量相似度索引在 10 个单独的颗粒中找到这 10 个邻居。在查询执行期间,将读取这 10 个颗粒。
如果输出包含 Skip 以及向量索引的名称和类型(在本例中,idx 和 vector_similarity),则使用向量相似度索引。在这种情况下,向量相似度索引丢弃了四个颗粒中的两个,即 50% 的数据。可以丢弃的颗粒越多,索引使用效率越高。
要强制使用索引,您可以设置 force_data_skipping_indexes (提供索引名称作为设置值) 来运行 SELECT 查询。
后过滤和预过滤
用户可以选择在 SELECT 查询中指定带有附加过滤条件的 WHERE 子句。ClickHouse 将使用后过滤或预过滤策略评估这些过滤条件。简而言之,这两种策略决定了评估过滤条件的顺序
- 后过滤意味着首先评估向量相似度索引,然后 ClickHouse 评估
WHERE子句中指定的附加过滤器。 - 预过滤意味着过滤条件的评估顺序相反。
这两种策略各有不同的权衡
- 后过滤的一个常见问题是,它可能返回少于
LIMIT <N>子句中请求的行数。当向量相似度索引返回的一个或多个结果行未能满足附加过滤器时,会发生这种情况。 - 预过滤通常是一个未解决的问题。某些专门的向量数据库提供预过滤算法,但大多数关系数据库(包括 ClickHouse)将回退到精确邻居搜索,即在没有索引的情况下进行暴力扫描。
使用的策略取决于过滤条件。
附加过滤器是分区键的一部分
如果附加过滤条件是分区键的一部分,那么 ClickHouse 将应用分区修剪。例如,表按列 year 进行范围分区,并且运行以下查询
ClickHouse 将修剪除 2025 之外的所有分区。
附加过滤器无法使用索引进行评估
如果无法使用索引(主键索引、跳过索引)评估附加过滤条件,ClickHouse 将应用后过滤。
附加过滤器可以使用主键索引进行评估
如果可以使用 主键 评估附加过滤条件(即,它们构成主键的前缀),并且
- 过滤条件消除了零件内的至少一行,那么 ClickHouse 将对零件内的“幸存”范围回退到预过滤,
- 过滤条件未消除零件内的任何行,那么 ClickHouse 将对该零件执行后过滤。
在实际用例中,后一种情况不太可能发生。
附加过滤器可以使用跳过索引进行评估
如果可以使用 跳过索引(minmax 索引、集合索引等)评估附加过滤条件,Clickhouse 执行后过滤。在这种情况下,首先评估向量相似度索引,因为它预计会相对于其他跳过索引删除最多的行。
为了更精细地控制后过滤与预过滤,可以使用两个设置
设置 vector_search_filter_strategy (默认值:auto,它实现了上述启发式方法) 可以设置为 prefilter。这对于在附加过滤条件极其选择性时强制预过滤很有用。例如,以下查询可能会受益于预过滤
假设只有极少数书籍的价格低于 2 美元,后过滤可能会返回零行,因为向量索引返回的前 10 个匹配项的价格可能都高于 2 美元。通过强制预过滤(在查询中添加 SETTINGS vector_search_filter_strategy = 'prefilter'),ClickHouse 首先找到所有价格低于 2 美元的书籍,然后对找到的书籍执行暴力向量搜索。
作为解决上述问题的替代方法,可以配置设置 vector_search_index_fetch_multiplier (默认值:1.0,最大值:1000.0) 为大于 1.0 的值(例如,2.0)。从向量索引获取的最近邻居数量乘以设置值,然后将附加过滤器应用于这些行以返回 LIMIT 数量的行。例如,我们可以再次查询,但乘数为 3.0
ClickHouse 将从每个部分的向量索引中获取 3.0 x 10 = 30 个最近邻居,然后评估附加过滤器。只会返回十个最接近的邻居。请注意,设置 vector_search_index_fetch_multiplier 可以缓解该问题,但在极端情况下(非常选择性的 WHERE 条件),仍然有可能返回少于 N 个请求的行。
重评分
ClickHouse 中的跳过索引通常在颗粒级别进行过滤,即在跳过索引中进行查找(在内部)会返回一个潜在匹配颗粒的列表,从而减少后续扫描中读取的数据量。这对于一般的跳过索引来说效果很好,但在向量相似度索引的情况下,会产生“粒度不匹配”。更详细地说,向量相似度索引确定给定参考向量最相似的 N 个向量的行号,然后需要将这些行号推断到颗粒号。ClickHouse 然后从磁盘加载这些颗粒,并对这些颗粒中的所有向量重复距离计算。此步骤称为重评分,虽然从理论上讲它可以提高准确性——请记住,向量相似度索引仅返回一个近似结果,但显然在性能方面不是最优的。
因此,ClickHouse 提供了一种禁用重评分并直接从索引返回最相似的向量及其距离的优化。默认情况下启用该优化,请参阅设置 vector_search_with_rescoring。从高层来看,它的工作方式是 ClickHouse 将最相似的向量及其距离作为虚拟列 _distances 提供。要查看此信息,请使用 EXPLAIN header = 1 运行向量搜索查询
在不进行重评分 (vector_search_with_rescoring = 0) 且启用并行副本的情况下运行的查询可能会回退到重评分。
性能调优
调整压缩
在几乎所有用例中,底层列中的向量都是稠密的并且不能很好地压缩。因此,压缩 会降低向量列的插入和读取速度。因此,我们建议禁用压缩。为此,为向量列指定 CODEC(NONE),如下所示
调整索引创建
向量相似度索引的生命周期与零件的生命周期相关联。换句话说,每当创建具有定义的向量相似度索引的新零件时,索引也会创建。这通常发生在数据 插入 或在 合并 期间。不幸的是,HNSW 以其较长的索引创建时间而闻名,这会显著降低插入和合并的速度。只有在数据是不可变的或很少更改的情况下,才应理想地使用向量相似度索引。
为了加快索引创建速度,可以使用以下技术
首先,索引创建可以并行化。可以使用服务器设置 max_build_vector_similarity_index_thread_pool_size 配置最大索引创建线程数。为了获得最佳性能,应将设置值配置为 CPU 核心数。
其次,为了加快 INSERT 语句的速度,用户可以使用会话设置 materialize_skip_indexes_on_insert 禁用对新插入零件的跳过索引的创建。对这些零件的 SELECT 查询将回退到精确搜索。由于插入的零件通常比整个表的大小小,因此预计性能影响可以忽略不计。
第三,为了加快合并速度,用户可以使用会话设置 materialize_skip_indexes_on_merge 禁用对合并零件的跳过索引的创建。结合语句 ALTER TABLE [...] MATERIALIZE INDEX [...],可以对向量相似度索引的生命周期进行显式控制。例如,可以在所有数据摄取完成后或在系统负载较低的时期(例如周末)推迟索引创建。
调整索引使用
SELECT 查询需要将向量相似度索引加载到主内存中使用。为了避免将相同的向量相似度索引重复加载到主内存中,ClickHouse 为此类索引提供了一个专用的内存内缓存。此缓存越大,不必要的加载就越少。可以使用服务器设置 vector_similarity_index_cache_size 配置最大缓存大小。默认情况下,缓存可以增长到 5 GB。
以下日志消息 (system.text_log) 指示正在加载向量相似度索引。如果此类消息为不同的向量搜索查询重复出现,则表示缓存大小太小。
向量相似度索引缓存存储向量索引颗粒。如果单个向量索引颗粒大于缓存大小,则它们将不会被缓存。因此,请确保根据“估计存储和内存消耗”或 system.data_skipping_indices 中的公式计算向量索引大小,并相应地调整缓存大小。
我们重申,验证并必要时增加向量索引缓存应该是调查缓慢向量搜索查询时的第一步。
当前向量相似度索引缓存的大小显示在 system.metrics 中
可以使用 system.query_log 获取具有某些查询 ID 的查询的缓存命中和未命中情况
对于生产用例,我们建议缓存足够大,以便所有向量索引始终保留在内存中。
调整量化
量化 是一种减少向量内存占用量和构建和遍历向量索引的计算成本的技术。ClickHouse 向量索引支持以下量化选项
| 量化 | 名称 | 每维存储 |
|---|---|---|
| f32 | 单精度 | 4 字节 |
| f16 | 半精度 | 2 字节 |
| bf16 (默认) | 半精度(脑浮点数) | 2 字节 |
| i8 | 四分之一精度 | 1 字节 |
| b1 | 二进制 | 1 位 |
量化会降低向量搜索的精度,相比于搜索原始的全精度浮点值 (f32)。然而,在大多数数据集上,半精度脑浮点量化 (bf16) 导致的可忽略不计的精度损失,因此向量相似度索引默认使用这种量化技术。四分之一精度 (i8) 和二进制 (b1) 量化会导致向量搜索中明显的精度损失。我们仅建议在向量相似度索引的大小明显大于可用 DRAM 大小时使用这两种量化。在这种情况下,我们还建议启用重排序 (vector_search_index_fetch_multiplier, vector_search_with_rescoring) 以提高准确性。仅当满足以下条件时,才推荐使用二进制量化:1) 归一化嵌入 (即向量长度 = 1,OpenAI 模型通常是归一化的),以及 2) 使用余弦距离作为距离函数。二进制量化内部使用汉明距离来构建和搜索邻近图。重排序步骤使用存储在表中的原始全精度向量通过余弦距离识别最近邻居。
调整数据传输
向量搜索查询中的参考向量由用户提供,通常通过调用大型语言模型 (LLM) 来检索。在 ClickHouse 中运行向量搜索的典型 Python 代码可能如下所示
嵌入向量 (search_v 在上面的代码片段中) 可能具有非常大的维度。例如,OpenAI 提供的模型会生成具有 1536 甚至 3072 维的嵌入向量。在上面的代码中,ClickHouse Python 驱动程序将嵌入向量替换为人类可读的字符串,然后将 SELECT 查询完全作为字符串发送。假设嵌入向量由 1536 个单精度浮点值组成,则发送的字符串长度达到 20 kB。这会产生很高的 CPU 使用率,用于标记化、解析和执行数千次字符串到浮点数的转换。此外,ClickHouse 服务器日志文件需要大量的空间,也会导致 system.query_log 膨胀。
请注意,大多数 LLM 模型将嵌入向量作为原生浮点数的列表或 NumPy 数组返回。因此,我们建议 Python 应用程序以二进制形式绑定参考向量参数,使用以下样式
在该示例中,参考向量以二进制形式原样发送,并在服务器上重新解释为浮点数数组。这可以节省服务器端的 CPU 时间,并避免服务器日志和 system.query_log 膨胀。
管理和监控
可以从 system.data_skipping_indices 获取向量相似度索引的磁盘大小
示例输出
与常规跳过索引的区别
与所有常规 跳过索引 一样,向量相似度索引是在颗粒上构建的,每个索引块由 GRANULARITY = [N] 个颗粒组成 (对于常规跳过索引,[N] 默认值为 1)。例如,如果表的 первичный 索引粒度为 8192 (设置 index_granularity = 8192) 且 GRANULARITY = 2,则每个索引块将包含 16384 行。然而,近似邻居搜索的数据结构和算法本质上是面向行的。它们存储一组行的紧凑表示形式,并为向量搜索查询返回行。这导致向量相似度索引的行为方式与常规跳过索引相比存在一些不太直观的差异。
当用户在列上定义向量相似度索引时,ClickHouse 内部为每个索引块创建一个向量相似度“子索引”。该子索引是“局部”的,因为它只了解其包含索引块的行。在前面的示例中,假设一列有 65536 行,我们获得四个索引块 (跨越八个颗粒) 和每个索引块的向量相似度子索引。理论上,子索引可以直接返回其索引块内的 N 个最近点的行。但是,由于 ClickHouse 以颗粒粒度从磁盘加载数据到内存,子索引会将匹配行推断到颗粒粒度。这与常规跳过索引不同,常规跳过索引以索引块的粒度跳过数据。
GRANULARITY 参数确定创建多少个向量相似度子索引。较大的 GRANULARITY 值意味着更少但更大的向量相似度子索引,直到列 (或列的数据部分) 只有一个子索引。在这种情况下,子索引具有列所有行的“全局”视图,可以直接返回包含相关行的列 (部分) 的所有颗粒 (最多有 LIMIT [N] 个这样的颗粒)。在第二步中,ClickHouse 将加载这些颗粒并识别最佳行,通过对颗粒的所有行执行暴力距离计算。使用较小的 GRANULARITY 值,每个子索引返回最多 LIMIT N 个颗粒。因此,需要加载更多的颗粒并进行后过滤。请注意,两种情况下的搜索准确性相同,只有处理性能不同。通常建议为向量相似度索引使用较大的 GRANULARITY,只有在出现诸如向量相似度结构内存消耗过大等问题时,才回退到较小的 GRANULARITY 值。如果未为向量相似度索引指定 GRANULARITY,则默认值为 1 亿。
示例
返回
使用近似向量搜索的进一步示例数据集
量化位 (QBit)
加速精确向量搜索的一种常见方法是使用较低精度的 浮点数据类型。例如,如果向量存储为 Array(BFloat16) 而不是 Array(Float32),则数据大小减小一半,并且预计查询运行时间会相应减少。这种方法被称为量化。虽然它可以加速计算,但可能会降低结果准确性,即使执行所有向量的详尽扫描也是如此。
使用传统量化时,我们在搜索和存储数据时都会失去精度。在上面的示例中,我们将存储 BFloat16 而不是 Float32,这意味着即使将来需要,我们也无法执行更准确的搜索。一种替代方法是存储两个副本的数据:量化的和全精度。虽然这可行,但需要冗余存储。考虑一下,我们有 Float64 作为原始数据,并且想要使用不同的精度 (16 位、32 位或全 64 位) 运行搜索。我们需要存储三个单独的副本的数据。
ClickHouse 提供了量化位 (QBit) 数据类型,通过以下方式解决了这些限制
- 存储原始全精度数据。
- 允许在查询时指定量化精度。
这是通过以位分组格式存储数据来实现的 (这意味着所有第 i 位的所有向量都存储在一起),从而仅以请求的精度级别进行读取。您获得了量化带来的减少 I/O 和计算的加速优势,同时在需要时保留所有原始数据。当选择最大精度时,搜索变为精确。
要声明 QBit 类型的列,请使用以下语法
其中
element_type– 每个向量元素的类型。支持的类型是BFloat16、Float32和Float64dimension– 每个向量中的元素数量
创建 QBit 表并添加数据
使用 QBit 进行向量搜索
让我们使用 L2 距离找到表示单词“lemon”的向量的最近邻居。距离函数中的第三个参数指定以位为单位的精度 - 较高的值提供更高的准确性,但需要更多的计算。
您可以在 此处 找到 QBit 的所有可用距离函数。
全精度搜索 (64 位)
降低精度搜索
请注意,使用 12 位量化,我们获得了具有更快查询执行速度的距离的良好近似值。相对顺序基本保持一致,'apple' 仍然是最接近的匹配项。
性能考虑因素
QBit 的性能优势来自于减少的 I/O 操作,因为使用较低精度时,需要从存储读取的数据更少。此外,当 QBit 包含 Float32 数据时,如果精度参数为 16 或更低,则由于减少的计算量,将会有额外的优势。精度参数直接控制准确性和速度之间的权衡
- 更高的精度 (更接近原始数据宽度):更准确的结果,更慢的查询
- 更低的精度:更快的查询,近似结果,减少内存使用
参考文献
博客