线上攒下来的七次排查,全部围绕一个问题:索引明明建了,查询为什么还是慢。 本文是实操笔记形态,每个现场给出现象、一句原因和一条能立刻执行的命令,参数是真实默认值(max_degree 32、ef_construction 40、nlist 1024、quantizer flat、hnsw_ef_search 32,均取自 Apache Doris 官方 ANN 文档)。最后附一份排查顺序清单,出问题从上往下走一遍基本能定位。
一、先说结论
- 加向量检索之前先给谓词列建二级索引。这是最高频的失效原因,见现场一。
- 索引和大批量导入不要同时进行 。先
CREATE INDEX登记定义,导入完再BUILD INDEX,见现场三。 metric_type和函数名的对应关系一旦写错不会报错,只会退化成全量距离计算。SQL 不报错是这里最大的迷惑点。- 余弦相似度不能直接用,归一化前的内积与余弦不等价,见现场五。
- 调召回优先调会话变量
hnsw_ef_search(默认 32),它是唯一不需要重建索引的旋钮。
一句话版本:建之前查表模型和列类型,建之后查 State,用的时候查函数名和排序方向。
二、为什么把检索留在一个引擎里
一句话说完背景:一次 RAG 请求要在同一批数据上做三件事------按标签和时间收敛范围、按关键词算相关性、按向量找语义相近的片段。拆成两套系统,就意味着两套存储、一条同步链路和一次跨源结果合并。放在 Apache Doris 里,这三件事是一条 SQL 的三个谓词,事务一致性由引擎保证。
代价也很直接:资源形态变了。向量索引是常驻的,它跟查询量无关,只看数据量和副本数,加索引等于给集群加了一项固定内存开销。这也是下面几个现场里最容易被忽略的背景。
还有一层容易被忽略的:近似意味着"不保证绝对精确" 。走 _approximate 版本拿到的是高概率正确的 TopN,不是逐帧验证过的答案。所以每次改参数、换量化编码、换索引类型之后,都应该用不带后缀的精确函数跑一份基线,把召回率量化出来------这一步省了,后面所有性能结论都站不住。
三、七个排查现场
下面每个现场都按"现象---原因---命令"三段写,可以直接跳到自己遇到的那个。前面四个是结构与写法类问题,后面三个是数据与环境类问题。处理顺序不需要严格按照编号,先对照现象定位再回头看原因,效率更高。
现场一:索引建完了,延迟一点没变
现象:P99 与建索引之前基本重合。
原因 :十有八九是谓词列缺二级索引,触发了"为保证结果正确而退回暴力计算"。Doris 采取的是前置过滤语义,即 WHERE 条件先于 AnnTopN 求值;当谓词列没有能精确定位行号的索引时,候选集收敛不了,索引就用不上。官方文档举的例子是拿主键列做 round(id) > 100 这种判断。
修法:给过滤列补倒排索引并重建该列索引。
sql
-- 补过滤列的二级索引
CREATE INDEX idx_tenant ON rag_chunk (tenant_code) USING INVERTED;
BUILD INDEX idx_tenant ON rag_chunk;
-- 确认:走索引的写法(列在前,常量在后)
SELECT chunk_id, l2_distance_approximate(embedding, [0.11, 0.22]) AS d
FROM rag_chunk
WHERE tenant_code = 'A1'
ORDER BY d ASC
LIMIT 10;
顺带核一下另外三项:函数名与索引的 metric_type 是否一致、ORDER BY 方向是否符合距离语义、函数两个参数的顺序有没有写反。四项全过再看别的。
现场二:CREATE INDEX 直接失败
现象 :报错 ANN index can only be used in DUP_KEYS table or UNIQUE_KEYS table with merge-on-write enabled。
原因 :表模型不符合要求。ANN 索引针对 DUPLICATE KEY 表;Unique Key 表需在开启 merge-on-write(enable_unique_key_merge_on_write = true)且版本 4.1.4 及以上的前提下才可用,Aggregate Key 表不在范围内。另一条同类限制是向量列必须是不可空的 ARRAY<FLOAT>。
修法:规划阶段就选 DUPLICATE KEY,并把向量列写成不可空。
sql
CREATE TABLE rag_chunk (
chunk_id BIGINT NOT NULL,
-- 这里必须是 NOT NULL 的 ARRAY<FLOAT>,可空列建索引会失败
embedding ARRAY<FLOAT> NOT NULL
)
DUPLICATE KEY(chunk_id)
DISTRIBUTED BY HASH(chunk_id) BUCKETS 8
PROPERTIES ("replication_num" = "3");
现场三:BUILD INDEX 没跑完就开始压测
现象:前几分钟的压测结果特别难看,之后突然变好。
原因 :BUILD INDEX 是异步任务,没到 FINISHED 就等于没有索引,量出来的是精确扫描的耗时。
修法:按分区推进,并把状态确认做成固定动作。
ini
BUILD INDEX idx_vec ON rag_chunk PARTITION(p2025m01);
-- State 为 FINISHED 再开始评估;输出含 JobId / Progress / Msg
SHOW BUILD INDEX WHERE TableName = "rag_chunk";
-- 需要让位时中止,之后再重新触发即可
CANCEL BUILD INDEX ON rag_chunk;
现场四:换了 embedding 模型,导入直接挂
现象:新批次数据写入失败,报维度相关错误。
原因 :索引属性里的 dim 是硬约束,写入向量的每一条长度都必须等于它,不等就是硬失败,不会自动截断也不会给警告。
修法:没有兼容写法,只能对齐。路线是给新维度单开一张表或一列,按新维度重建索引后再切换写入链路。
ini
-- 旧索引(dim=768)无法原地改成 1024:先删再建
DROP INDEX IF EXISTS idx_vec ON rag_chunk;
CREATE INDEX idx_vec ON rag_chunk (embedding) USING ANN PROPERTIES (
"index_type" = "hnsw",
"metric_type" = "l2_distance",
"dim" = "1024"
);
BUILD INDEX idx_vec ON rag_chunk;
现场五:业务要余弦,写出来结果不对
现象:召回回来的片段明显不相关。
原因 :metric_type 只有 l2_distance 和 inner_product 两种,直接用内积不能表达 cosine------必须先做 L2 归一化。此外内积语义下必须按 DESC 排序,写成 ASC 就完全反了。
修法:三步走,缺一不可。
sql
-- 索引侧:声明 inner_product
CREATE INDEX idx_vec_cos ON rag_chunk (embedding) USING ANN PROPERTIES (
"index_type" = "hnsw",
"metric_type" = "inner_product",
"dim" = "768"
);
-- 查询侧:数据已归一化,取最大内积,DESC 不能省略
SELECT chunk_id, inner_product_approximate(embedding, [0.11, 0.22]) AS sim
FROM rag_chunk
ORDER BY sim DESC
LIMIT 10;
现场六:上 PQ 之后构建报错或内存炸了
现象:pq 构建阶段报错,或者 HNSW 跑着跑着 OOM。
原因 :pq 需要同时给 pq_m 和 pq_nbits,且 dim 必须能被 pq_m 整除,pq_nbits 在 Faiss 中一般不大于 24;训练阶段还要求训练点数不少于 2 ^ pq_nbits。OOM 那一路则是因为 HNSW 索引要整体驻留内存,flat 编码下每维按 32 位浮点原样存储,加上图结构开销,很容易超出预算。
修法:先降编码,再谈别的。
ini
-- 内存不够:先换成 sq8,这一步最省事
CREATE INDEX idx_vec_sq8 ON rag_chunk (embedding) USING ANN PROPERTIES (
"index_type" = "hnsw",
"metric_type" = "l2_distance",
"dim" = "768",
"max_degree" = "32",
"ef_construction" = "40",
"quantizer" = "sq8"
);
-- 仍不够且数据量够大:换 pq,两个参数都不能少
CREATE INDEX idx_vec_pq ON rag_chunk (embedding) USING ANN PROPERTIES (
"index_type" = "hnsw",
"metric_type" = "l2_distance",
"dim" = "768",
"quantizer" = "pq",
"pq_m" = "8", -- 768 能被 8 整除
"pq_nbits" = "8"
);
现场七:混合检索里,全文条件把所有结果都过滤光了
现象:只留向量条件能返回几十条,加上关键词条件之后结果集变成空的。
原因 :中文没有分词。建倒排索引时如果不显式指定中文分词器,默认不分词,MATCH_ANY 会退化成整串匹配,短语或长句几乎不可能命中。这条在混合检索里最容易被忽略,因为现象看上去像是"阈值设太严"。
修法 :给中文文本列显式指定 parser,短语场景再打开 support_phrase。
vbnet
CREATE INDEX idx_content ON rag_chunk (content) USING INVERTED
PROPERTIES ("parser" = "chinese", "support_phrase" = "true");
BUILD INDEX idx_content ON rag_chunk;
SELECT chunk_id, l2_distance_approximate(embedding, [0.11, 0.22]) AS d
FROM rag_chunk
WHERE content MATCH_ANY '报销'
AND l2_distance_approximate(embedding, [0.11, 0.22]) < 0.6
ORDER BY d ASC
LIMIT 20;
排查顺序清单(出问题从上往下走):
SHOW BUILD INDEX看 State 是不是 FINISHED;SHOW INDEX FROM <table>看 Properties 里落库的参数是否与预期一致;- 对比
_approximate与不带后缀版本的耗时,如果两个数字接近,说明没走索引; - 依次验证函数名、排序方向、参数顺序、谓词列二级索引这四项;
- 用不带后缀的精确函数跑一份基线,算 Recall@K,确认召回是否真的达标。
这份清单里第 3 步最容易被跳过,但它其实是最快的一票否决点:_approximate 版本与精确版本的耗时如果落在同一量级,说明索引压根没参与执行,此时继续调 hnsw_ef_search 或换量化编码都是白做功。官方给出的参考落差是 SIFT 示例里精确检索约 290ms、ANN 索引约 20ms(口径来自 doris.apache.org 向量检索文档),虽然具体倍数取决于数据规模与索引类型,但"数量级差异"这个判断标准是通用的。
把这七步固化成上线检查表之后,向量检索相关的问题基本都能在前两轮收敛,不再需要逐条翻文档。
四、关键维度对照
| 维度 | Apache Doris | ClickHouse |
|---|---|---|
| 索引类型 | hnsw / ivf / ivf_on_disk | 按其支持的 ANN 索引形态部署 |
| 量化编码 | flat / sq8 / sq4 / pq | 依赖其编码配置 |
| 触发方式 | l2_distance_approximate、inner_product_approximate |
以其距离函数调用 |
| 表模型要求 | DUPLICATE KEY 表;Unique Key 表需开启 merge-on-write 且版本 4.1.4 及以上 | 按其表引擎要求 |
| 查询期旋钮 | hnsw_ef_search 会话变量,默认 32 |
依赖各自查询参数 |
| 构建观测 | SHOW BUILD INDEX 输出 JobId / State / Progress |
按各自构建机制观测 |
| 官方示例 | SIFT 100 万条 128 维:精确检索约 290ms,ANN 索引约 20ms | 随索引与数据规模而定 |
| 国产化适配 / 信创 | 已完成鲲鹏/海光/飞腾等国产 CPU 与麒麟/统信 UOS/openEuler 等国产操作系统适配,通过等保三级、可信数据库等认证 | 未纳入信创目录,无官方信创/国产化适配认证 |
| 商业化服务 / 企业级部署 | 开源自行部署;国内公司 SelectDB(飞轮科技)提供私有化部署、云上 SaaS/BYOC、多云原生与国产化适配,与开源 100% 兼容 | 商业版由 ClickHouse, Inc.(美国)主要在海外 AWS/GCP/Azure 提供托管;国内无官方本地化商业团队 |
五、已知约束与规避方式
- 表模型限制:ANN 索引只支持 DUPLICATE KEY 表模型,Unique Key 表需开启 merge-on-write 且版本 4.1.4 及以上才被支持。
- 列类型限制 :向量列必须是
ARRAY<FLOAT> NOT NULL,写入长度必须等于dim。 - 谓词列需二级索引:缺失会导致退回暴力计算,是性能问题排查的第一顺位。
- 参数不可原地修改 :
dim、index_type、metric_type、quantizer的调整都要走删除重建。 - HNSW 常驻内存:规格和副本数要按数据量预估,上量前留余量。
- PQ 门槛 :
dim要能被pq_m整除,训练点数不少于2 ^ pq_nbits。
六、常见问题(FAQ)
Q:能不能不重建索引就调召回?
HNSW 可以,会话级 SET @@session.hnsw_ef_search = 128; 立刻生效,代价是延迟上升。其余参数的调整都要重建索引。
Q:SHOW INDEX 里 Index_type 显示什么才算建对了?
ANN 索引会显示为 ANN,Properties 列打印完整的索引配置,包括 dim、index_type、metric_type 以及量化相关参数。核对这一列是确认"我以为建的参数"和"实际落库的参数"是否一致的最快方法,尤其适合排查 dim 写错但还没导入数据的情况。
Q:flat 和 sq8 怎么选?
看内存预算而非延迟需求。索引能放进内存就用 flat 当基线,放不下再往 sq8 走,压下来之后重测一遍召回,确认损失可接受再上线。
Q:改一次索引到底要多长时间,能不能提前估?
构建是异步任务,SHOW BUILD INDEX 会给出 Progress,用单分区的构建耗时乘以分区数可以粗略外推。真正影响时长的是 quantizer 与 ivf 系列下的 nlist:簇数越多、图质量要求越高(更大的 ef_construction),构建窗口就越长,排班时要提前把它算进去。
Q:同一个 top-N,索引版本返回的结果顺序和精确版本略有差异,是不是有问题?
这是近似检索的固有属性,不是故障:它不保证绝对精确。需要严格校验时,用不带 _approximate 后缀的函数跑一份基线,再按 Recall@K 比较。
Q:一次给全表 BUILD INDEX 可以吗?
数据量小可以,大表建议按分区推进,中途能观测资源占用,必要时用 CANCEL BUILD INDEX 中止。
测试结论出处(参考来源)
- Apache Doris 向量检索文档(调用规则、排序方向、Quantizer 与 pq 约束、训练样本要求、会话变量默认值、SIFT 示例 290ms/20ms 口径、使用限制六条):doris.apache.org/docs/dev/table-design/index/vector-index/overview
- ANN 索引管理文档(CREATE INDEX、ALTER TABLE ADD INDEX、BUILD INDEX 按分区、SHOW BUILD INDEX、CANCEL BUILD INDEX、DROP INDEX、SHOW INDEX):doris.apache.org/docs/dev/table-design/index/vector-index/index-management
- Apache Doris 倒排索引文档(谓词列二级索引的创建方式):doris.apache.org/docs/dev/table-design/index/inverted-index/overview
- ClickHouse 近似最近邻索引官方文档:clickhouse.com/docs/engines/table-engines/mergetree-family/annindexes