Skip to content
ClickHouse Docs
ClickHouse DocsClickHouse Docs

精确与近似向量搜索

对于给定点,在多维 (向量) 空间中查找距离其最近的 N 个点,这个问题称为最近邻搜索,简称向量搜索。 解决向量搜索通常有两种通用方法:

  • 精确向量搜索会计算给定点与向量空间中所有点之间的距离。这可以确保达到尽可能高的准确性,也就是说,返回的点一定是真正的最近邻。由于需要穷尽整个向量空间,精确向量搜索在实际应用中可能会过慢。
  • 近似向量搜索是指一类技术 (例如图、随机森林等特殊数据结构) ,其计算速度远快于精确向量搜索。其结果精度通常已经“足够好”,能够满足实际使用需求。许多近似技术都提供参数,用于在结果精度和搜索时间之间进行权衡。

向量搜索 (精确或近似) 可以用如下 SQL 形式来编写:

WITH [...] AS reference_vector
SELECT [...]
FROM table
WHERE [...] -- a WHERE clause is optional
ORDER BY <DistanceFunction>(vectors, reference_vector)
LIMIT <N>

向量空间中的点存储在名为 vectorsArray(Float64)Array(Float32)Array(BFloat16) 类型列中。 在实际使用中,我们通常建议使用 BFloat16 数组。 参考向量是一个常量数组,通过公用表表达式给出。 <DistanceFunction> 用于计算参考点与所有已存储点之间的距离。 这里可以使用任意一种可用的距离函数<N> 指定要返回多少个邻居。

可以直接使用上述 SELECT 查询按原样执行精确向量搜索。 这类查询的运行时间通常与已存储向量的数量、向量元素数量 (= 维度) 以及向量元素的位宽成正比。 此外,由于 ClickHouse 会对所有向量执行穷举扫描,运行时间还取决于该查询使用的线程数 (请参见设置 max_threads) 。

示例

CREATE TABLE tab
(
    id Int32,
    vec Array(BFloat16)
)
ENGINE = MergeTree
ORDER BY id;

INSERT INTO tab VALUES (0, [1.0, 0.0]), (1, [1.1, 0.0]), (2, [1.2, 0.0]), (3, [1.3, 0.0]), (4, [1.4, 0.0]), (5, [1.5, 0.0]), (6, [0.0, 2.0]), (7, [0.0, 2.1]), (8, [0.0, 2.2]), (9, [0.0, 2.3]), (10, [0.0, 2.4]), (11, [0.0, 2.5]);

WITH [0., 2.] AS reference_vec
SELECT id, vec
FROM tab
ORDER BY L2Distance(vec, reference_vec) ASC
LIMIT 3;

返回值

   ┌─id─┬─vec─────┐
1. │  6 │ [0,2]   │
2. │  7 │ [0,2.1] │
3. │  8 │ [0,2.2] │
   └────┴─────────┘

向量相似度索引

ClickHouse 提供了一种特殊的“向量相似度”索引,用于执行近似向量搜索。

创建向量相似度索引

可以像下面这样在新表上创建向量相似度索引:

CREATE TABLE table
(
    [...],
    vectors Array([Float64|Float32|BFloat16]),
    INDEX <index_name> vectors TYPE vector_similarity(<type>, <distance_function>, <dimensions>) [GRANULARITY <N>]
)
ENGINE = MergeTree
ORDER BY [...]

或者,如需为现有表添加向量相似度索引:

ALTER TABLE table ADD INDEX <index_name> vectors TYPE vector_similarity(<type>, <distance_function>, <dimensions>) [GRANULARITY <N>];

向量相似度索引是一类特殊的跳过索引 (参见此处此处) 。 因此,上面的 ALTER TABLE 语句只会为今后新插入表中的数据构建索引。 若要同时为现有数据构建索引,则需要将其物化:

ALTER TABLE table MATERIALIZE INDEX <index_name> SETTINGS mutations_sync = 2;

函数 <distance_function> 必须是

  • L2Distance,即 Euclidean distance (欧几里得距离) ,表示欧几里得空间中两点之间的直线距离,
  • cosineDistance,即 cosine distance (余弦距离) ,表示两个非零向量之间的夹角,或
  • dotProduct,即 dot product (内积) ,表示两个向量对应元素乘积之和。在归一化数据上,它等价于 cosineDistance

对于归一化数据,通常 L2Distance 是最佳选择;否则,建议使用 cosineDistance 来补偿标度差异。

<dimensions> 指定底层列中数组的元素个数。 如果 ClickHouse 在创建索引时发现数组的元素个数不一致,则会放弃该索引并返回错误。

可选的 GRANULARITY 参数 <N> 表示索引粒度的大小 (见此处) 。 与默认索引粒度为 1 的常规跳过索引不同,向量相似度索引默认使用 1 亿作为索引粒度。 这个值可确保即使对于较大的 parts,内部构建的索引数量也很少。 我们建议仅由理解其影响的高级用户修改索引粒度 (见下文) 。

向量相似度索引是通用的,也就是说它们可以支持不同的近似搜索方法。 实际使用的方法由参数 <type> 指定。 截至目前,唯一可用的方法是 HNSW (学术论文) ,这是一种流行且先进的近似向量搜索技术,基于分层近邻图。 如果使用 HNSW 作为 <type>,用户还可以选择指定更多 HNSW 专用参数:

CREATE TABLE table
(
    [...],
    vectors Array([Float64|Float32|BFloat16]),
    INDEX index_name vectors TYPE vector_similarity('hnsw', <distance_function>, <dimensions>[, <quantization>, <hnsw_max_connections_per_layer>, <hnsw_candidate_list_size_for_construction>]) [GRANULARITY N]
)
ENGINE = MergeTree
ORDER BY [...]

可用的 HNSW 专用参数如下:

  • <quantization> 用于控制邻近图中向量的量化方式。可能值为 f64f32f16bf16i8b1。默认值为 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 模型 (例如大语言模型 LLM) 生成的向量,通常由数百个或数千个浮点值组成。 因此,单个向量值可能会占用数 KB 的内存。 如果用户想估算表中底层向量列所需的存储空间,以及向量相似度索引所需的主内存,可以使用下面两个公式:

表中向量列的存储消耗 (未压缩) :

Storage consumption = Number of vectors * Dimension * Size of column data type

dbpedia dataset 为例:

存储消耗 = 100万 * 1536 * 4(Float32)= 6.1 GB

必须先将向量相似度索引从磁盘完整加载到主内存中,才能执行搜索。 同样,向量索引也是先在内存中完整构建,然后再保存到磁盘。

加载向量索引所需的内存占用:

索引中向量的内存占用 (mv) = 向量数量 * 维度 * 量化数据类型的大小
内存图的内存占用 (mg) = 向量数量 * hnsw_max_connections_per_layer * Bytes_per_node_id (= 4) * Layer_node_repetition_factor (= 2)

内存消耗:mv + mg

dbpedia 数据集示例:

Memory for vectors in the index (mv) = 1 million * 1536 * 2 (for BFloat16) = 3072 MB
Memory for in-memory graph (mg) = 1 million * 64 * 2 * 4 = 512 MB

Memory consumption = 3072 + 512 = 3584 MB

上述公式未将向量相似度索引用于分配预分配缓冲区、缓存等运行时数据结构所需的额外内存计算在内。

使用向量相似度索引

向量相似度索引支持以下形式的 SELECT 查询:

WITH [...] AS reference_vector
SELECT [...]
FROM table
WHERE [...] -- a WHERE clause is optional
ORDER BY <DistanceFunction>(vectors, reference_vector)
LIMIT <N>

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

以下示例中,查询

EXPLAIN indexes = 1
WITH [0.462, 0.084, ..., -0.110] AS reference_vec
SELECT id, vec
FROM tab
ORDER BY L2Distance(vec, reference_vec) ASC
LIMIT 10;

可能返回

    ┌─explain─────────────────────────────────────────────────────────────────────────────────────────┐
 1. │ Expression (Project names)                                                                      │
 2. │   Limit (preliminary LIMIT (without OFFSET))                                                    │
 3. │     Sorting (Sorting for ORDER BY)                                                              │
 4. │       Expression ((Before ORDER BY + (Projection + Change column names to column identifiers))) │
 5. │         ReadFromMergeTree (default.tab)                                                         │
 6. │         Indexes:                                                                                │
 7. │           PrimaryKey                                                                            │
 8. │             Condition: true                                                                     │
 9. │             Parts: 1/1                                                                          │
10. │             Granules: 575/575                                                                   │
11. │           Skip                                                                                  │
12. │             Name: idx                                                                           │
13. │             Description: vector_similarity GRANULARITY 100000000                                │
14. │             Parts: 1/1                                                                          │
15. │             Granules: 10/575                                                                    │
    └─────────────────────────────────────────────────────────────────────────────────────────────────┘

在此示例中,dbpedia 数据集 中的 100 万个向量 (每个向量维度为 1536) 存储在 575 个粒度中,即每个粒度约 1.7k 行。该查询请求 10 个最近邻,向量相似度索引在 10 个独立粒度中找到这 10 个最近邻。查询执行期间将读取这 10 个粒度。

如果输出中包含 Skip 以及向量索引的名称和类型 (在本示例中为 idxvector_similarity) ,则表示向量相似度索引已生效。 在本例中,向量相似度索引跳过了四个粒度中的两个,即 50% 的数据。 可跳过的粒度越多,索引的使用效率就越高。

后过滤与前过滤

用户可以选择在 SELECT 查询中通过 WHERE 子句指定额外的过滤条件。 ClickHouse 将采用后过滤或前过滤策略对这些过滤条件进行求值。 简而言之,两种策略的区别在于过滤条件的求值顺序:

  • 后过滤意味着会先评估向量相似度索引,随后 ClickHouse 再评估 WHERE 子句中指定的附加过滤条件。
  • 前过滤意味着过滤条件的评估顺序正好相反。

这两种策略各有不同的权衡:

  • 后过滤的一个普遍问题是,返回的行数可能少于 LIMIT <N> 子句请求的数量。当向量相似度索引返回的一行或多行结果不满足附加过滤条件时,就会出现这种情况。
  • 前过滤通常仍是一个尚未解决的问题。某些专门的向量数据库提供了前过滤算法,但大多数关系型数据库 (包括 ClickHouse) 都会退回到精确近邻搜索,也就是不使用索引的穷举扫描。

采用哪种策略取决于过滤条件。

附加过滤条件是分区键的一部分

如果附加过滤条件是分区键的一部分,ClickHouse 就会应用分区剪枝。 例如,某个表按列 year 进行范围分区,并执行以下查询:

WITH [0., 2.] AS reference_vec
SELECT id, vec
FROM tab
WHERE year = 2025
ORDER BY L2Distance(vec, reference_vec) ASC
LIMIT 3;

ClickHouse 会剪枝掉除 2025 年分区之外的所有分区。

无法通过索引评估额外过滤条件

如果额外过滤条件无法通过索引 (主键索引、跳过索引) 评估,ClickHouse 将应用后过滤。

可以使用主键索引评估额外过滤条件

如果额外过滤条件可以通过主键评估 (即它们构成主键的前缀) ,并且

  • 如果过滤条件在某个分片内至少排除一行,ClickHouse 将回退为对该分片内“保留下来”的范围执行前过滤,
  • 如果过滤条件在某个分片内没有排除任何行,ClickHouse 将对该分片执行后过滤。

在实际场景中,后一种情况通常不太可能发生。

可以使用跳过索引评估额外过滤条件

如果额外过滤条件可以通过跳过索引 (minmax 索引、set 索引等) 评估,ClickHouse 会执行后过滤。 在这种情况下,会先评估向量相似度索引,因为预计与其他跳过索引相比,它能过滤掉更多行。

若要更细致地控制后过滤与前过滤,可以使用两个设置:

设置 vector_search_filter_strategy (默认值:auto,即实现上述启发式策略) 可以设为 prefilter。 这对于额外过滤条件选择性极高的场景很有用,可用于强制启用前过滤。 例如,以下查询可能会从前过滤中受益:

SELECT bookid, author, title
FROM books
WHERE price < 2.00
ORDER BY cosineDistance(book_vector, getEmbedding('Books on ancient Asian empires'))
LIMIT 10

假设价格低于 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 指定数量的行。 例如,我们可以再次发起查询,但将 multiplier 设置为 3.0

SELECT bookid, author, title
FROM books
WHERE price < 2.00
ORDER BY cosineDistance(book_vector, getEmbedding('Books on ancient Asian empires'))
LIMIT 10
SETTING vector_search_index_fetch_multiplier = 3.0;

ClickHouse 将从每个分片中的向量索引拉取 3.0 x 10 = 30 个最近邻,然后再评估额外的过滤器。 最终只会返回距离最近的十个近邻。 需要注意的是,设置 vector_search_index_fetch_multiplier 可以缓解这个问题,但在极端情况下 (WHERE 条件选择性非常强) ,返回的行数仍可能少于请求的 N 行。

重评分

ClickHouse 中的跳过索引通常在粒度级别进行过滤。也就是说, (在内部) 查询一次跳过索引会返回一个可能匹配的粒度列表,从而减少后续扫描中需要读取的数据量。 这对一般的跳过索引来说效果很好,但对于向量相似度索引,则会造成“粒度不匹配”。 更具体地说,向量相似度索引会针对给定的参考向量找出最相似的 N 个向量的行号。 在设置 vector_search_with_rescoring = 1 时,ClickHouse 会读取候选行中原始的全精度向量,并在常规 SQL 管道中计算最终距离。 当查询计划允许时,ClickHouse 会在最终距离计算之前,将扫描结果过滤为向量索引返回的候选行。 这一步称为重评分,它可以提高准确性,尤其是在量化向量索引中,因为最终排名使用的是存储的向量,而不是索引距离。 如果附加过滤器移除了过多候选项,或者需要更高的召回率,请增大设置 vector_search_index_fetch_multiplier,以便向量索引返回更多候选行用于重评分。

因此,ClickHouse 提供了一种优化,可禁用重评分,并直接从索引返回最相似的向量及其距离。 该优化默认启用,参见设置 vector_search_with_rescoring。 其大致工作方式是,ClickHouse 将最相似的向量及其距离通过虚拟列 _distance 提供出来。 要查看这一点,请使用 EXPLAIN header = 1 运行向量搜索查询:

EXPLAIN header = 1
WITH [0., 2.] AS reference_vec
SELECT id
FROM tab
ORDER BY L2Distance(vec, reference_vec) ASC
LIMIT 3
SETTINGS vector_search_with_rescoring = 0
Query id: a2a9d0c8-a525-45c1-96ca-c5a11fa66f47

    ┌─explain────────────────────────────────────────────────────────────────────────────────────────────────────┐
 1. │ Expression (Project names)                                                                                 │
 2. │ Header: id Int32                                                                                           │
 3. │   Limit (preliminary LIMIT (without OFFSET))                                                               │
 4. │   Header: L2Distance(__table1.vec, _CAST([0., 2.]_Array(BFloat16), 'Array(BFloat16)'_String)) BFloat16     │
 5. │           __table1.id Int32                                                                                │
 6. │     Sorting (Sorting for ORDER BY)                                                                         │
 7. │     Header: L2Distance(__table1.vec, _CAST([0., 2.]_Array(BFloat16), 'Array(BFloat16)'_String)) BFloat16   │
 8. │             __table1.id Int32                                                                              │
 9. │       Expression ((Before ORDER BY + (Projection + Change column names to column identifiers)))            │
10. │       Header: L2Distance(__table1.vec, _CAST([0., 2.]_Array(BFloat16), 'Array(BFloat16)'_String)) BFloat16 │
11. │               __table1.id Int32                                                                            │
12. │         ReadFromMergeTree (default.tab)                                                                    │
13. │         Header: id Int32                                                                                   │
14. │                 _distance BFloat16                                                                         │
    └────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

性能调优

压缩调优

在几乎所有使用场景中,底层列中的向量都是稠密的,因此通常难以有效压缩。 因此,压缩会降低对向量列执行插入和读取的性能。 因此,我们建议禁用压缩。 为此,请像下面这样为向量列指定 CODEC(NONE)

CREATE TABLE tab
(
    id Int32,
    vec Array(BFloat16) CODEC(NONE),
    INDEX idx vec TYPE vector_similarity('hnsw', 'L2Distance', 2)
)
ENGINE = MergeTree ORDER BY id;

调优索引创建

向量相似度索引的生命周期与 parts 的生命周期密切相关。 换句话说,每当创建一个定义了向量相似度索引的新 part 时,对应的索引也会同时创建。 这通常发生在数据插入时,或在合并过程中。 遗憾的是,HNSW 的索引创建耗时较长是众所周知的问题,这会显著拖慢插入和合并操作。 因此,向量相似度索引更适合用于数据不可变或很少发生变化的场景。

要加快索引创建,可以采用以下方法:

首先,可以将索引创建并行化。 可通过服务器设置 max_build_vector_similarity_index_thread_pool_size 配置用于创建索引的最大线程数。 为获得最佳性能,建议将该设置值配置为 CPU 核心数。

其次,为了加快 INSERT 语句的执行,用户可以通过会话设置 materialize_skip_indexes_on_insert 禁止在新插入的 parts 上创建跳过索引。 对此类 parts 执行的 SELECT 查询将回退为精确搜索。 由于新插入的 parts 相对于整个表通常较小,因此预计对性能的影响可以忽略不计。

第三,为了加快合并,用户可以通过会话设置 materialize_skip_indexes_on_merge 禁止在已合并的 parts 上创建跳过索引。 配合语句 ALTER TABLE […] MATERIALIZE INDEX […],这可以显式控制向量相似度索引的生命周期。 例如,可以将索引创建延后到所有数据都已摄取完成之后,或延后到系统负载较低的时段 (如周末) 再进行。

调优索引使用

SELECT 查询要使用向量相似度索引,需要先将其加载到主内存中。 为避免重复将同一个向量相似度索引加载到主内存,ClickHouse 为这类索引提供了专用的内存缓存。 缓存越大,不必要的加载就越少。 可通过服务器设置 vector_similarity_index_cache_size 配置缓存的最大大小。 默认情况下,缓存最大可增长到 5 GB。

以下日志消息 (system.text_log) 表明正在加载向量相似度索引。 如果这类消息在不同的向量搜索查询中反复出现,则说明缓存容量过小。

2026-02-03 07:39:10.351635 [1386] f0ac5c85-1b1c-4f35-8848-87a1d1aa00ba : VectorSimilarityIndex Start loading vector similarity index

<...>

2026-02-03 07:40:25.217603 [1386] f0ac5c85-1b1c-4f35-8848-87a1d1aa00ba : VectorSimilarityIndex Loaded vector similarity index: max_level = 2, connectivity = 64, size = 1808111, capacity = 1808111, memory_usage = 8.00 GiB, bytes_per_vector = 4096, scalar_words = 1024, nodes = 1808111, edges = 51356964, max_edges = 233395072

我们再次强调,在排查向量搜索查询缓慢的问题时,首先应检查向量索引缓存,并在必要时增大其容量。

当前向量相似度索引缓存的大小可在 system.metrics 中查看:

SELECT metric, value
FROM system.metrics
WHERE metric = 'VectorSimilarityIndexCacheBytes'

可从 system.query_log 获取具有特定查询 id 的查询的缓存命中和未命中信息:

SYSTEM FLUSH LOGS query_log;

SELECT ProfileEvents['VectorSimilarityIndexCacheHits'], ProfileEvents['VectorSimilarityIndexCacheMisses']
FROM system.query_log
WHERE type = 'QueryFinish' AND query_id = '<...>'
ORDER BY event_time_microseconds;

对于生产环境中的用例,我们建议将缓存设置得足够大,以确保所有向量索引始终驻留在内存中。

调整量化

量化是一种用于降低向量内存占用,以及减少构建和遍历向量索引计算开销的技术。 ClickHouse 向量索引支持以下量化选项:

Quantization Name Storage per dimension
f32 单精度 4 字节
f16 半精度 2 字节
bf16 (default) 半精度 (brain float) 2 字节
i8 四分之一精度 1 字节
b1 二进制 1 比特

与搜索原始全精度浮点值 (f32) 相比,量化会降低向量搜索的精度。 不过,在大多数数据集上,半精度 brain float 量化 (bf16) 带来的精度损失微乎其微,因此向量相似度索引默认采用这种量化方式。 四分之一精度 (i8) 和二进制 (b1) 量化会给向量搜索带来较明显的精度损失。 只有在向量相似度索引的大小显著超过可用 DRAM 容量时,我们才建议使用这两种量化方式。 在这种情况下,我们还建议启用重评分 (vector_search_index_fetch_multipliervector_search_with_rescoring) 以提高准确性。 只有在以下情况下才建议使用二进制量化:1) 嵌入向量已归一化 (即向量长度 = 1,OpenAI 模型通常是归一化的);2) 距离函数使用的是余弦距离。 二进制量化在内部使用 Hamming 距离来构建和搜索邻近图。 重评分步骤会使用表中存储的原始全精度向量,通过余弦距离识别最近邻。

调整数据传输

向量搜索查询中的参考向量由用户提供,通常通过调用大语言模型 (LLM) 获取。 在 ClickHouse 中执行向量搜索的典型 Python 代码可能如下所示

search_v = openai_client.embeddings.create(input = "[Good Books]", model='text-embedding-3-large', dimensions=1536).data[0].embedding

params = {'search_v': search_v}
result = chclient.query(
   "SELECT id FROM items
    ORDER BY cosineDistance(vector, %(search_v)s)
    LIMIT 10",
    parameters = params)

嵌入向量 (上述代码片段中的 search_v) 的维度可能非常大。 例如,OpenAI 提供的模型会生成维度为 1536 甚至 3072 的嵌入向量。 在上述代码中,ClickHouse Python driver 会将嵌入向量替换为便于人类阅读的字符串,随后再将整个 SELECT 查询作为字符串发送。 假设嵌入向量由 1536 个单精度浮点数组成,发送的字符串长度可达 20 kB。 这会导致 CPU 大量消耗在标记化、解析以及成千上万次字符串到浮点数的转换上。 此外,ClickHouse server 日志文件也需要占用大量空间,同时还会导致 system.query_log 膨胀。

请注意,大多数 LLM 模型都会将嵌入向量作为原生浮点数列表或 NumPy 数组返回。 因此,我们建议 Python 应用程序使用以下方式,以二进制形式绑定参考向量参数:

search_v = openai_client.embeddings.create(input = "[Good Books]", model='text-embedding-3-large', dimensions=1536).data[0].embedding

params = {'$search_v_binary$': np.array(search_v, dtype=np.float32).tobytes()}
result = chclient.query(
   "SELECT id FROM items
    ORDER BY cosineDistance(vector, reinterpret($search_v_binary$, 'Array(Float32)'))
    LIMIT 10"
    parameters = params)

在该示例中,参考向量会以原始二进制形式发送,并在服务器端重新解释为浮点数数组。 这样可以节省服务器端的 CPU 时间,并避免服务器日志和 system.query_log 过度膨胀。

管理与监控

向量相似度索引的磁盘占用大小可从 system.data_skipping_indices 获取:

SELECT database, table, name, formatReadableSize(data_compressed_bytes)
FROM system.data_skipping_indices
WHERE type = 'vector_similarity';

示例输出:

┌─database─┬─table─┬─name─┬─formatReadab⋯ssed_bytes)─┐
│ default  │ tab   │ idx  │ 348.00 MB                │
└──────────┴───────┴──────┴──────────────────────────┘

与常规跳过索引的差异

和所有常规跳过索引一样,向量相似度索引也是基于粒度构建的,每个索引块由 GRANULARITY = [N] 个粒度组成 (对普通跳过索引来说,[N] 的默认值为 1) 。 例如,如果表的主索引粒度为 8192 (设置 index_granularity = 8192) ,且 GRANULARITY = 2,那么每个索引块将包含 16384 行。 但用于近似邻居搜索的数据结构和算法本质上是面向行的。 它们会存储一组行的紧凑表示,并且也会为向量搜索查询返回行。 因此,向量相似度索引的行为方式与普通跳过索引相比,会表现出一些相当不直观的差异。

当用户在某一列上定义向量相似度索引时,ClickHouse 会在内部为每个索引块创建一个向量相似度“子索引”。 这里所说的“局部”是指,子索引只了解其所属索引块中的行。 沿用前面的示例,假设某一列有 65536 行,那么会得到四个索引块 (跨越八个粒度) ,并为每个索引块创建一个向量相似度子索引。 从理论上讲,一个子索引可以直接返回其索引块内距离最近的 N 个点对应的行。 对于设置了 vector_search_with_rescoring = 1 的查询,如果查询计划允许这种优化,ClickHouse 就可以在基于存储向量计算最终距离之前,先利用这些行位置过滤行。 如果不使用重评分,ClickHouse 会通过虚拟列 _distance 直接使用向量索引中的距离。 这两种模式仍都会使用周围的粒度范围来调度读取,这不同于常规跳过索引,后者是在索引块级别跳过数据的。

GRANULARITY 参数决定会创建多少个向量相似度子索引。 GRANULARITY 值越大,创建的向量相似度子索引就越少,但每个子索引也越大;极端情况下,一列 (或该列的数据分区片段) 只会有一个子索引。 在这种情况下,这个子索引对该列的所有行都具有“全局”视角,并且可以直接返回该列 (或数据分区片段) 中包含相关行的所有粒度 (这样的粒度最多不超过 LIMIT [N] 个) 。 在 vector_search_with_rescoring = 1 时,ClickHouse 随后可以读取匹配的行位置,并为这些行计算精确距离。 当 GRANULARITY 值较小时,每个子索引最多可以返回 LIMIT N 个候选行。 因此,可能需要读取并进行后过滤的候选行会更多。 请注意,这两种情况下的搜索精度同样高,区别只在于处理性能。 通常建议为向量相似度索引使用较大的 GRANULARITY,只有在出现向量相似度结构内存占用过高等问题时,才退回到较小的 GRANULARITY 值。 如果未为向量相似度索引指定 GRANULARITY,默认值为 1 亿。

示例

查询:

Querysql
CREATE TABLE tab
(
    id Int32,
    vec Array(BFloat16),
    INDEX idx vec TYPE vector_similarity('hnsw', 'L2Distance', 2)
)
ENGINE = MergeTree
ORDER BY id;

INSERT INTO tab VALUES (0, [1.0, 0.0]), (1, [1.1, 0.0]), (2, [1.2, 0.0]), (3, [1.3, 0.0]), (4, [1.4, 0.0]), (5, [1.5, 0.0]), (6, [0.0, 2.0]), (7, [0.0, 2.1]), (8, [0.0, 2.2]), (9, [0.0, 2.3]), (10, [0.0, 2.4]), (11, [0.0, 2.5]);

WITH [0., 2.] AS reference_vec
SELECT id, vec
FROM tab
ORDER BY L2Distance(vec, reference_vec) ASC
LIMIT 3;
Responseresult
   ┌─id─┬─vec─────┐
1. │  6 │ [0,2]   │
2. │  7 │ [0,2.1] │
3. │  8 │ [0,2.2] │
   └────┴─────────┘

更多使用近似向量搜索的示例数据集如下:

使用量化编解码器进行向量搜索

Experimental 功能

要启用 Quantized 编解码器,请先运行 SET enable_quantized_codec = 1。 如果你遇到问题,请在 ClickHouse repository 中提交 issue。

简介

向量相似度索引通过遍历图来回答最近邻查询;当图可完全驻留于内存中时,其性能非常出色。 但以下两个因素限制了它的适用性:

  • 构建成本高。 构建向量相似度索引的成本很高。
  • 内存占用高。 除向量本身外,向量相似度索引还会占用大量内存,成为主要成本。
  • 筛选。 使用选择性较高的 WHERE 过滤器时,图遍历会变得低效:它要么无法到达满足谓词的少量行,要么必须检查数量严重失衡的候选项才能找到它们。

穷尽扫描则不受这两种限制:它不需要辅助数据结构,parts 可通过拼接自然合并,筛选也只会减少需要扫描的行数。 穷尽扫描最大的缺点是必须读取大量数据:扫描以完整 Float32BFloat16 精度存储的向量时,由于必须从磁盘加载整个向量列,主要开销来自存储 I/O。

Quantized 列编解码器解决了这一问题。 它将每个向量存储两次:一次为原始全精度值,另一次为紧凑的量化表示。 向量搜索查询会先使用开销低且适合 SIMD 的距离函数扫描量化编码,生成由最有希望的结果候选项组成的候选列表。 第二步会根据全精度向量重新对这些候选项进行排序。 与扫描原始向量相比,初始扫描量化编码时从存储中读取的字节数要少得多。

该编解码器非常适合 ClickHouse,因为开销最大的部分 (扫描) 正是 ClickHouse engine 擅长处理的任务:

  • 向量化。 扫描内核利用 SIMD 指令降低 CPU 消耗。
  • 跨核心和 parts 并行。 扫描很容易并行化:可同时在所有可用线程和表的所有 parts 上计算距离。
  • 分布式。 在分片集群中,工作会分发到各台机器:每个分片并行扫描自己的数据部分,协调器再合并候选列表。
  • 无需额外构建成本。 写入向量时会生成量化编码。无需构建、调优或重建额外索引,因此数据写入表后即可搜索。

应用编解码器

Array(Float32) (或 Array(Float64) / Array(BFloat16)) 列指定 Quantized(...) 编解码器。

SET enable_quantized_codec = 1;

CREATE TABLE vectors
(
    id UInt32,
    vec Array(BFloat16) CODEC(Quantized('rabitq', 1536))
)
ENGINE = MergeTree
ORDER BY ...;

无法在之后通过 ALTER TABLE 添加或修改该编解码器。

量化方法

每种量化方法在大小和准确性之间各有不同的权衡。 dimensions 参数表示向量长度。

  • Quantized('rabitq', dimensions) — 每个坐标使用一个符号位,再加上一个无偏余弦校正因子 (dimensions/8 + 4 字节) 。体积小、popcount 成本低,是一个很强的默认选择。仅支持 cosineDistance
  • Quantized('turboquant', dimensions) — 每个坐标使用两位 (1 位 MSE 编码和 1 位残差编码) ,可获得保真度更高的候选结果 (dimensions/4 + 4 字节) 。仅支持 cosineDistance
  • Quantized('int8', dimensions) — 每个坐标使用一个 Int8 编码,再加上向量范数 (dimensions + 4 字节) ;体积最大,但也是最忠实的平坦编码。支持 L2DistancecosineDistance
  • Quantized('prefix', dimensions, leading_dimensions, 'int8'|'bf16') — Matryoshka:只保留前 leading_dimensions 个坐标,并存为 Int8 (带每向量标度) 或 BFloat16。适用于使用 Matryoshka Representation Learning 训练的嵌入向量,可生成极小的编码。支持 L2DistancecosineDistance
  • Quantized('product', dimensions, nbits, m) — Product Quantization:按分片使用通过 k-means 训练得到的码本;每个向量会变成 m 个、每个 nbits 位的编码 (因此 dimensions 必须是 m 的倍数) 。这是最紧凑的方案,且单位字节召回率最高,但代价是在 insert 时需要执行训练步骤。支持 L2DistancecosineDistance

rabitqturboquant 要求 dimensions 必须是 8 的倍数。

使用编解码器

常规的 top-k 查询形态会自动使用该编解码器 (请参阅精确搜索) :

WITH [...] AS reference_vec
SELECT id
FROM vectors
ORDER BY cosineDistance(vec, reference_vec) ASC
LIMIT 10
SETTINGS vector_search_use_quantized_codes = 1;

vector_search_use_quantized_codes = 1 设为启用后,优化器可将查询重写为两阶段扫描。 该设置默认关闭。 未启用此设置时,相同查询会对原始向量执行常规精确扫描。 所有方法均支持距离函数 cosineDistance;其中 int8prefixproduct 方法还额外支持 L2Distance 距离函数。

设置 vector_search_index_fetch_multiplier 用于指定相对于查询 LIMIT 要入围的候选数量。 较大的乘数可提高召回率,但会增加重评分开销。 默认值为 1 (不进行过采样) ;通常需要将其提高到 (例如) 10,才能获得良好的召回率。

量化位 (QBit)

加速精确向量搜索的一种常见方法是使用较低精度的浮点数据类型。 例如,如果向量存储为 Array(BFloat16) 而不是 Array(Float32),数据大小会减少一半,查询运行时间预计也会相应缩短。 这种方法称为量化。虽然它能加快计算速度,但即使对所有向量进行穷尽扫描,也可能会降低结果的准确性。

使用传统量化时,我们在搜索和数据存储这两个阶段都会损失精度。以上述示例为例,我们存储的是 BFloat16 而不是 Float32,这意味着即使之后有需要,也无法再进行更高精度的搜索。另一种方案是同时存储两份数据:一份量化后的数据和一份全精度数据。虽然这样可行,但会带来冗余存储。设想这样一种场景:原始数据为 Float64,并且希望使用不同精度 (16 位、32 位或完整 64 位) 进行搜索。这样一来,就需要存储三份独立的数据副本。

ClickHouse 提供了 Quantized Bit (QBit) 数据类型,通过以下方式解决这些限制:

  1. 存储原始的全精度数据。
  2. 支持在查询时指定量化精度。

这是通过以按位分组的格式存储数据来实现的 (即将所有向量的第 i 位存储在一起) ,从而只读取所请求精度级别的数据。这样,你既能获得量化带来的 I/O 和计算量减少所带来的速度优势,又能在需要时使用全部原始数据。当选择最高精度时,搜索即为精确搜索。

要声明一个 QBit 类型的列,请使用以下语法:

column_name QBit(element_type, dimension[, stride])

其中:

  • element_type – 每个向量元素的数据类型。支持的类型有 Int8BFloat16Float32Float64
  • dimension – 每个向量中的元素个数
  • stride – 可选。dimension 的一个除数,它会将各维度划分为 dimension / stride 个连续分组,并分别存储在独立的流中,因此如果只对前几个维度进行搜索,需要读取的流就更少 (对 Matryoshka 嵌入向量很有用) 。默认值为 dimension,此时该类型在字节级别上与非 stride 的 QBit 完全相同。详见 QBit 数据类型页面

创建 QBit 表并插入数据

CREATE TABLE fruit_animal (
    word String,
    vec QBit(Float64, 5)
) ENGINE = MergeTree
ORDER BY word;

INSERT INTO fruit_animal VALUES
    ('apple', [-0.99105519, 1.28887844, -0.43526649, -0.98520696, 0.66154391]),
    ('banana', [-0.69372815, 0.25587061, -0.88226235, -2.54593015, 0.05300475]),
    ('orange', [0.93338752, 2.06571317, -0.54612565, -1.51625717, 0.69775337]),
    ('dog', [0.72138876, 1.55757105, 2.10953259, -0.33961248, -0.62217325]),
    ('cat', [-0.56611276, 0.52267331, 1.27839863, -0.59809804, -1.26721048]),
    ('horse', [-0.61435682, 0.48542571, 1.21091247, -0.62530446, -1.33082533]);

下面使用 L2 距离查找与表示单词 'lemon' 的向量最接近的邻居。距离函数中的第三个参数用于指定精度 (以位为单位) ——值越高,精度越高,但所需计算也越多。

你可以在这里查看 QBit 支持的所有距离函数。

全精度搜索 (64 位) :

SELECT
    word,
    L2DistanceTransposed(vec, [-0.88693672, 1.31532824, -0.51182908, -0.99652702, 0.59907770], 64) AS distance
FROM fruit_animal
ORDER BY distance;
   ┌─word───┬────────────distance─┐
1. │ apple  │ 0.14639757188169716 │
2. │ banana │   1.998961369007679 │
3. │ orange │   2.039041552613732 │
4. │ cat    │   2.752802631487914 │
5. │ horse  │  2.7555776805484813 │
6. │ dog    │   3.382295083120104 │
   └────────┴─────────────────────┘

低精度搜索:

SELECT
    word,
    L2DistanceTransposed(vec, [-0.88693672, 1.31532824, -0.51182908, -0.99652702, 0.59907770], 12) AS distance
FROM fruit_animal
ORDER BY distance;
   ┌─word───┬───────────distance─┐
1. │ apple  │  0.757668703053566 │
2. │ orange │ 1.5499475034938677 │
3. │ banana │ 1.6168396735102937 │
4. │ cat    │  2.429752230904804 │
5. │ horse  │  2.524650475528617 │
6. │ dog    │   3.17766975527459 │
   └────────┴────────────────────┘

注意,使用 12 位量化时,我们能在加快查询执行速度的同时,较好地近似这些距离。相对顺序基本保持一致,'apple' 仍然是最接近的匹配项。

性能考量

QBit 的性能优势来自 I/O 操作减少:使用较低精度时,需要从存储中读取的数据更少。此外,当 QBit 包含 Float32 数据且精度参数为 16 或更低时,由于计算量减少,还能获得额外的性能收益。精度参数直接决定了准确性与速度之间的权衡:

  • 更高精度 (更接近原始数据位宽) :结果更准确,但查询更慢
  • 更低精度:查询更快,但结果是近似值,内存占用更低

参考资料

博客文章:

Navigation