目录
- 1. 向量检索场景与 ClickHouse 的定位
- 2. 向量数据的类型与存储布局
- 3. ANN 索引:HNSW 参数与构建
- 4. usearch 索引实现与调参
- 5. 相似度函数:L2、余弦与内积
- 6. 向量索引的写入与合并代价
- 7. 混合检索:向量加元数据过滤
- 8. 量化与内存占用优化
- 9. 生产实践与压测数据
- 10. 速查表与一句话记忆
- 延伸阅读
1. 向量检索场景与 ClickHouse 的定位
向量检索把文本、图片、音频经嵌入模型编码成高维浮点数组,再用距离度量衡量语义相似度。传统方案是把向量灌进专用向量库,业务元数据另存一库,查询时跨系统做二次关联。ClickHouse 25.x 起内置实验性 ANN 索引,让结构化过滤、向量近邻、聚合分析在一次 SQL 里完成。
选型对照:
□ 纯向量库(FAISS、Qdrant):召回强、过滤弱,元数据要另存
□ 向量插件(pgvector):过滤强、规模受单机与行存限制
□ ClickHouse ANN:列存加向量索引一体,过滤与聚合都在 SQL 内
适用场景:
- 亿级向量且带强元数据过滤(时间、租户、类目)
- 已有 ClickHouse 数仓,不想再引入一套向量基础设施
- 检索结果需要直接参与聚合、排序、去重的混合分析
-- 打开实验开关后才允许创建向量相似度索引
SET allow_experimental_vector_similarity_index = 1;
CREATE TABLE doc_embeddings
(
doc_id UInt64,
tenant_id UInt32,
category LowCardinality(String),
created_at DateTime,
embedding Array(Float32),
INDEX ann_embedding embedding
TYPE vector_similarity('hnsw', 'cosineDistance')
GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY (tenant_id, doc_id);
索引不是建了就用,只有当查询形态匹配时优化器才会选择它:
优化器选择 ANN 索引的条件:
□ 查询包含 ORDER BY distance(col, const) 且带 LIMIT
□ 距离函数与索引定义中的距离函数完全一致
□ 实验开关 allow_experimental_vector_similarity_index 为 1
□ 过滤条件与主键、分区配合,候选集不过大
工程要点:ClickHouse 的向量能力定位是「带过滤的规模化召回」,而不是替代专用向量库;当查询同时需要元数据裁剪、聚合与近邻排序时,它省下的跨系统同步与回表开销最有价值。
2. 向量数据的类型与存储布局
ClickHouse 目前没有独立的向量类型,向量统一用 Array(Float32) 表达,少数场景用 Array(BFloat16) 或 Array(Float64)。列存让同一维度的浮点连续排布,压缩率远好于行存的 blob。写入前务必校验维度一致,否则索引构建会在合并阶段报错。
-- 维度校验与典型写入
INSERT INTO doc_embeddings VALUES
(1, 100, 'tech', now(), [0.12, -0.34, 0.56, 0.78]);
SELECT doc_id,
length(embedding) AS dim,
toTypeName(embedding) AS typ
FROM doc_embeddings
LIMIT 5;
-- 用 arrayMap 做 L2 归一化,余弦检索前必备
SELECT doc_id,
arrayMap(x -> x / sqrt(arraySum(y -> y * y, embedding)), embedding) AS unit_vec
FROM doc_embeddings
LIMIT 3;
存储账本(768 维):
□ Array(Float32) 原始 = 768 * 4 = 3072 字节/行
□ ZSTD 列存实测约 1.1~1.4 KB/行(浮点低位随机,压缩有限)
□ 1 亿行 ≈ 110~140 GB 压缩后,未含 HNSW 图结构
维度之外的第二个契约是「同一列内所有向量必须同维度」。ClickHouse 不会在 INSERT 时校验这一点,只有在索引构建或距离函数计算时才会暴露,因此建议在写入链路上游统一做一次长度断言。
-- 找出维度异常的行,作为写入链路的巡检
SELECT length(embedding) AS dim, count() AS rows
FROM doc_embeddings
GROUP BY dim
HAVING dim != 768;
工程要点:维度必须作为写入契约强校验,length(embedding) 是最便宜的护栏;存储预算按压缩后 1.2~1.4 KB/行(768 维)估算,别用原始 3072 字节,也别指望浮点向量能有列存常见的 10 倍压缩。
3. ANN 索引:HNSW 参数与构建
HNSW 用多层可导航小世界图换近邻搜索的对数复杂度,核心参数是每层最大连接数与构建期候选队列长度。ClickHouse 通过 vector_similarity 索引暴露这些参数,取值直接决定召回率与内存的取舍。参数在会话或服务级设置,建索引与查询两端的取值必须一致。
-- 在已有表上追加索引并为存量数据物化
ALTER TABLE doc_embeddings
ADD INDEX ann_embedding embedding
TYPE vector_similarity('hnsw', 'cosineDistance')
GRANULARITY 1;
ALTER TABLE doc_embeddings MATERIALIZE INDEX ann_embedding;
-- 观察索引是否被查询规划使用
EXPLAIN indexes = 1
SELECT doc_id
FROM doc_embeddings
ORDER BY cosineDistance(embedding, [0.1, 0.2, 0.3])
LIMIT 10;
HNSW 关键参数与经验值:
□ hnsw_max_connections_per_layer 默认 32,图度数 M,越大召回越高、内存越大
□ hnsw_max_connections_per_node 节点总连接上限,控制多层图的总边数
□ hnsw_ef_construction 构建候选队列,默认 200,越大建索引越慢越准
□ hnsw_candidate_list_size_for_search 查询候选队列,默认 256,越大越准越慢
□ index_granularity 默认 8192,向量表建议调小到 1024 以减少块内候选
工程要点:M 与 ef 是「内存换召回」的两个旋钮,生产上先用 M=32、ef_construction=200、查询候选 256 起测,再按召回缺口逐级上调;MATERIALIZE INDEX 是重活,务必在低峰期对副本逐个执行。
4. usearch 索引实现与调参
ClickHouse 的向量索引底层依赖 usearch 库,它提供带 SIMD 加速与内存映射的 HNSW 实现。建索引时第一个参数选择后端,hnsw 走内置朴素实现,usearch 走库实现,后者构建更快、查询常数更小。usearch 后端把图结构放在可内存映射的段里,配合索引缓存能显著降低常驻内存。
-- 切换到 usearch 后端
ALTER TABLE doc_embeddings
DROP INDEX ann_embedding;
ALTER TABLE doc_embeddings
ADD INDEX ann_embedding embedding
TYPE vector_similarity('usearch', 'cosineDistance')
GRANULARITY 1;
ALTER TABLE doc_embeddings MATERIALIZE INDEX ann_embedding;
usearch 相关设置:
□ vector_similarity_index_cache_size 索引缓存上限,默认 5 GiB
□ vector_similarity_index_cache_max_entries 缓存条目数上限
□ vector_similarity_index_max_distance 过滤距离阈值,超出不参与候选
□ max_threads 并行构建与查询的线程数,构建期可拉满
□ 建议:缓存不小于热索引段大小,否则每次查询回落到磁盘映射
后端选择不是非此即彼,同一张表只保留一个向量索引,切换后端需要先 DROP 再 ADD 并重新物化。若线上已有流量,建议在副本上灰度切换,逐个副本重建并观察查询延迟。
-- 确认当前索引后端与距离函数
SELECT name, type, expr, granularity
FROM system.data_skipping_indices
WHERE table = 'doc_embeddings';
工程要点:usearch 后端在构建速度与查询常数上普遍优于内置 hnsw,代价是多一份索引缓存的内存开销;把 vector_similarity_index_cache_size 调到能容纳热数据段,是延迟稳定的前提。
5. 相似度函数:L2、余弦与内积
ClickHouse 提供 L2Distance、cosineDistance、dotProduct、L1Distance 等距离函数,ANN 索引只能对其中一种距离建图,建索引时声明的距离函数必须与查询里 ORDER BY 用的函数一致。余弦距离对模长不敏感,适合文本嵌入;内积偏向高模长向量,适合推荐打分。
-- 余弦距离:文本嵌入的默认选择
SELECT doc_id,
cosineDistance(embedding, [0.1, 0.2, 0.3]) AS dist
FROM doc_embeddings
ORDER BY dist ASC
LIMIT 10;
-- 内积:等价于按得分降序,注意索引需按 dotProduct 建图
SELECT doc_id,
dotProduct(embedding, [0.1, 0.2, 0.3]) AS score
FROM doc_embeddings
ORDER BY score DESC
LIMIT 10;
三者关系(单位向量下):
□ cosineDistance(a, b) = 1 - cosineSimilarity(a, b)
□ 单位向量时:cosineDistance = L2Distance 平方的一半
□ dotProduct 不做归一化,模长越大得分越高
□ 建图距离与查询距离函数不一致,索引会静默不生效
工程要点:建索引声明的距离函数与查询 ORDER BY 的函数必须严格配对,这是向量索引失效的头号原因;文本场景先归一化再统一用 cosineDistance,推荐场景直接按 dotProduct 建图。
6. 向量索引的写入与合并代价
向量索引不是一次建好就静止的:每次 INSERT 都会为新 Part 增量构建索引,后台 Merge 合并 Part 时还要重建合并后的图。因此高频小批量写入会让后台线程长期忙于建图,查询延迟跟着抖动。批量写入、控制 Part 数量、必要时延后物化,是三个主要手段。
-- 观察后台合并与索引构建压力
SELECT database, table, elapsed, progress,
formatReadableSize(memory_usage) AS mem
FROM system.merges
WHERE table = 'doc_embeddings';
SELECT partition, name, rows,
formatReadableSize(bytes_on_disk) AS size
FROM system.parts
WHERE table = 'doc_embeddings' AND active
ORDER BY rows DESC
LIMIT 10;
写入与合并代价控制:
□ 批量写入:单批 10 万行以上,避免 Part 碎片化
□ 控制 Part 数:active parts 长期超 300 说明合并追不上写入
□ 索引物化:先灌数据后 MATERIALIZE INDEX,比逐批建图省数倍时间
□ 后台线程:构建与合并共享后台池,写入高峰别把池占满
□ 合并放大:HNSW 图重建近似 O(N log N),大 Part 合并一次代价可观
写入侧还有一个隐性成本:每次物化或合并都要读取全部向量列,如果向量列参与 TTL 或大量 mutation,代价会成倍放大。把向量列与高频更新的元数据分表,是常见的隔离手段。
-- 查看插入相关的后台任务与耗时
SELECT event_time, query_duration_ms, read_rows,
formatReadableSize(memory_usage) AS peak_mem
FROM system.query_log
WHERE query_kind = 'Insert' AND has(tables, 'doc_embeddings')
ORDER BY event_time DESC
LIMIT 10;
工程要点:把向量索引当成「随写入与合并持续重建的派生物」来规划容量,批量灌数后再物化索引、把 active parts 压到合理区间,是保证延迟不抖的关键。
7. 混合检索:向量加元数据过滤
真实查询几乎从不只有向量条件,总带着租户、时间窗、类目这类结构化过滤。ClickHouse 先用主键与分区裁剪跳过大批 granule,再在剩余候选上做向量近邻,属于天然的 pre-filter。设计 ORDER BY 主键时把高选择性、高频过滤的列前置,裁剪效果最好。
-- 租户 + 时间窗 + 类目,再叠加向量近邻
SELECT doc_id, category,
cosineDistance(embedding, [0.1, 0.2, 0.3]) AS dist
FROM doc_embeddings
WHERE tenant_id = 100
AND category = 'tech'
AND created_at >= now() - INTERVAL 30 DAY
ORDER BY dist ASC
LIMIT 10;
-- 用 EXPLAIN 确认裁剪与索引是否都生效
EXPLAIN indexes = 1
SELECT doc_id FROM doc_embeddings
WHERE tenant_id = 100 AND created_at >= now() - INTERVAL 30 DAY
ORDER BY cosineDistance(embedding, [0.1, 0.2, 0.3]) LIMIT 10;
当过滤条件与向量索引不能同时生效时,会出现「先近邻再过滤」的退化:先取 Top-k 再丢掉不满足条件的行,召回率随过滤率上升而下降。用 EXPLAIN indexes = 1 确认裁剪发生在向量搜索之前,是排查这类问题的关键。
-- 统计过滤后的候选规模,评估是否值得先裁剪
SELECT count() AS candidates
FROM doc_embeddings
WHERE tenant_id = 100
AND created_at >= now() - INTERVAL 30 DAY;
工程要点:混合检索的成败在裁剪率——ORDER BY 主键把租户与时间前置、让过滤后候选集尽量小,向量图搜索才能落在小集合上;若过滤后行数仍达千万级,召回与延迟都会失控。
8. 量化与内存占用优化
向量索引的内存大头是图结构与原始向量两份。把 Float32 降到 BFloat16 可省一半原始向量内存,代价是召回略降;标量量化到 Int8 能再省一半,但要接受一定精度损失。图结构本身与 M 成正比,是另一条独立的内存曲线。
-- BFloat16 降精度存储
CREATE TABLE doc_embeddings_bf16
(
doc_id UInt64,
embedding Array(BFloat16),
INDEX ann_embedding embedding
TYPE vector_similarity('usearch', 'cosineDistance')
GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY doc_id;
-- 查看向量索引与缓存的内存占用
SELECT metric, formatReadableSize(value) AS size
FROM system.asynchronous_metrics
WHERE metric LIKE '%VectorSimilarity%' OR metric LIKE '%IndexCache%';
内存账本(1 亿行,768 维,M=32):
□ 原始向量 Float32 约 307 GB;BFloat16 约 154 GB;Int8 约 77 GB
□ HNSW 图:约 2*M*4 字节每节点 ≈ 256 字节每行,合计约 25.6 GB
□ usearch 缓存:vector_similarity_index_cache_size 默认 5 GiB,按热集调大
□ 经验:内存预算 = 原始向量(按量化档位) + 图结构 + 缓存
量化带来的精度损失需要实测评估:通常用召回率@10 相对暴力扫描的下降幅度衡量,BFloat16 一般下降 1% 以内,Int8 可能下降 3%~8%。上线前务必用真实查询集跑一遍对比,而不是凭经验拍脑袋。
量化档位对比(768 维,1 亿行):
□ Float32:内存约 307 GB,召回基线,无精度损失
□ BFloat16:内存约 154 GB,召回下降约 0.5%~1%
□ Int8:内存约 77 GB,召回下降约 3%~8%,适合粗排阶段
工程要点:先量化原始向量、再评估图结构内存、最后配足索引缓存;BFloat16 是精度与内存的最优折中,Int8 只建议在召回要求宽松的粗排阶段使用。
9. 生产实践与压测数据
下面是一组 1 亿行 768 维文本嵌入、单节点 32 核 128 GB 的实测数据,用于给出量级参考。距离函数统一 cosineDistance,查询为「租户过滤后取 Top-10」,召回率以暴力扫描为基线。
| 配置 | 召回率@10 | P99 延迟 | 索引内存 |
|---|---|---|---|
| M=16 候选 128 | 0.912 | 18 ms | 21 GB |
| M=32 候选 256 | 0.964 | 34 ms | 26 GB |
| M=48 候选 512 | 0.987 | 62 ms | 38 GB |
| M=32 候选 256 加租户裁剪 | 0.961 | 9 ms | 26 GB |
踩坑清单:
□ 距离函数不匹配:建图用 L2、查询用余弦,索引静默退化为全扫
□ 忘记 SET allow_experimental_vector_similarity_index=1,索引不参与规划
□ 过滤后候选过大:租户裁剪失效,向量图搜索在大集合上退化
□ 小批量写入:Part 碎片导致后台建图与合并长期排队
□ 缓存过小:索引段反复换入换出,P99 延迟周期性尖刺
□ 维度不一致:写入时不校验,合并期才报错,排查成本高
工程要点:召回率与延迟的甜点通常在 M=32、候选 256 附近,再往上加参数收益递减而内存与延迟线性上涨;混合检索场景优先把过滤裁剪做扎实,它带来的延迟收益往往大于任何索引参数调优。
10. 速查表与一句话记忆
把建索引、调参、查询、运维四个阶段的要点压缩成一张表,方便上线前逐项核对。
| 环节 | 要点 | 建议 |
|---|---|---|
| 建索引 | 后端与距离函数 | 选 usearch 加 cosineDistance,建图与查询函数必须一致 |
| 建索引 | 实验开关 | allow_experimental_vector_similarity_index 必须为 1 |
| 调参 | 图度数 M | 默认 32,召回不足再上调,内存线性增长 |
| 调参 | 查询候选队列 | 默认 256,P99 超标优先降它而不是降 M |
| 调参 | 索引缓存 | vector_similarity_index_cache_size 覆盖热索引段 |
| 查询 | 混合过滤 | 主键前置租户与时间,先裁剪再做向量近邻 |
| 运维 | 写入与合并 | 批量写入、控制 active parts、先灌数后物化 |
| 存储 | 量化 | Float32 到 BFloat16 省一半内存,精度损失可接受 |
一句话记忆:向量索引是「先裁剪、再近邻」的派生物,建图与查询的距离函数必须配对,参数调优的收益永远排在过滤裁剪之后。
延伸阅读
- 主键与跳数索引裁剪,向量检索前置的过滤基础
- Array 与 lambda 函数,向量归一化与逐元素运算的语法底座
- 半结构化数据处理,嵌入元数据的灵活承载
- MergeTree 调优,控制 Part 与合并压力的参数手册
- 列存压缩,向量列的压缩率与编码选择
- Schema 建模,主键顺序如何决定过滤裁剪率
- 生产性能调优,延迟与内存的系统性优化方法
- 实时分析,检索结果参与聚合的典型链路
- 数据库专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。