一次 TiDB 向量查询优化实战:
最近我在一个搜索项目里接入了 TiDB Cloud Serverless 的向量检索能力。整体体验是好的,开发效率也很高,但上线后很快发现一个现实问题:向量查询的 RU 消耗比预期高,慢 SQL 也比较稳定地出现。
先说结论,免得大家看半天不知道重点:
- 把
documents.locale全部统一成小写,并把查询条件改成locale = ?,这是正确的,也应该做。 - 但这件事不是这条向量查询的主要优化来源。至少在我这次的实际测试里,
idx_locale并没有让这条 SQL 走到我原本期待的索引路径。 - 真正把 RU 和耗时明显降下来的,是把查询改成 two-phase:先只算 TopN
id + distance,再回表拿详情。
我这篇文章想分享的不是“我改了什么”,而是“我是怎么一步一步验证的,哪些猜测被证实了,哪些又被 explain 打脸了”。
背景
项目是一个混合搜索服务,关键词召回走 TiDB Full-Text,语义召回走 TiDB Auto Embedding + 向量距离排序。相关表结构大概是这样:
CREATE TABLE `documents` (
`id` bigint AUTO_INCREMENT NOT NULL,
`slug` varchar(255) NOT NULL,
`locale` varchar(16) NOT NULL,
`title` varchar(255) NOT NULL,
`description` text NOT NULL,
`content` text NOT NULL,
`source_path` varchar(1024) NOT NULL,
`embedding` vector(1024) GENERATED ALWAYS AS (
EMBED_TEXT("tidbcloud_free/amazon/titan-embed-text-v2", content)
) STORED,
CONSTRAINT `documents_id` PRIMARY KEY(`id`),
CONSTRAINT `uniq_slug_locale` UNIQUE (`slug`, `locale`)
);
CREATE INDEX `idx_locale` ON `documents` (`locale`);
CREATE INDEX `idx_vector_embedding` ON `documents` (`embedding`);
这张表并不算大,实验时总数据量只有 10,799 行,其中 locale = 'en' 的数据是 713 行。
如果只看单次延迟,200ms+ 似乎还不算灾难。但这是 TiDB Cloud Serverless,RU 是账单里更敏感的部分。再考虑到这类查询未来会随着数据量和调用量继续放大,还是值得把问题掰开看清楚。
一开始看到的慢 SQL
最初的查询长这样:
SELECT
CAST(id AS CHAR) AS id,
slug,
title,
content,
locale
FROM documents
WHERE LOWER(locale) = ?
ORDER BY VEC_EMBED_COSINE_DISTANCE(embedding, ?) ASC
LIMIT ?;
直觉上这个 SQL 的可疑点非常明显:
locale列上套了LOWER(locale),这通常会破坏普通等值过滤的优化机会。- 查询在做向量 TopN 排序时,同时把
title、content这些大字段也一起带着。
我最初的想法,其实和很多人看到这条 SQL 时的第一反应差不多:
- 如果把库里的
locale全部改成小写; - 再把查询参数也统一转成小写;
- 然后把条件改成
locale = ?; - 再配一个
idx_locale;
那这条查询是不是就能避免全表扫描,成本自然也就降下来了?
这个想法并不离谱,但问题在于:它还只是推测,不是证据。
我们是怎么验证的
为了避免“改完感觉好像快了”的错觉,我专门写了一个对比脚本,固定同一组 query 和 locale,直接跑三组 EXPLAIN ANALYZE 。脚本会做三件事:
- 审计
locale数据是否已经全部小写。 - 打印当前索引状态。
- 对比三种 SQL 形态的执行计划和实际 RU。
对比的三种 case 是:
1. current
现网慢 SQL 形态,也就是 LOWER(locale) + 单段向量排序:
SELECT
CAST(id AS CHAR) AS id,
slug,
title,
content,
locale
FROM documents
WHERE LOWER(locale) = ?
ORDER BY VEC_EMBED_COSINE_DISTANCE(embedding, ?) ASC
LIMIT ?;
2. eq-locale
只改 locale 过滤方式,其他保持不变:
SELECT
CAST(id AS CHAR) AS id,
slug,
title,
content,
locale
FROM documents
WHERE locale = ?
ORDER BY VEC_EMBED_COSINE_DISTANCE(embedding, ?) ASC
LIMIT ?;
3. two-phase
先取 TopN id + distance,再回表拿详情:
SELECT
CAST(doc.id AS CHAR) AS id,
doc.slug,
doc.title,
doc.content,
doc.locale
FROM documents AS doc
INNER JOIN (
SELECT
id,
VEC_EMBED_COSINE_DISTANCE(embedding, ?) AS distance
FROM documents
WHERE locale = ?
ORDER BY distance ASC
LIMIT ?
) AS ranked
ON ranked.id = doc.id
ORDER BY ranked.distance ASC;
先确认数据前提:locale 小写化确实成立
脚本先做了一个很简单但必要的审计:
totalRows = 10799
nonLowercaseRows = 0
也就是说,这时库里已经没有非小写 locale 了,所以:
- “把查询条件从
LOWER(locale) = ?改成locale = ?会不会漏数据?” - 至少在这批数据上,答案是不会。
这个前提如果不先确认,后面的优化结论就不稳。
索引状态
当时的索引状态也符合预期:
PRIMARY(id)
uniq_slug_locale(slug, locale)
idx_title(title)
idx_fts_title(title)
idx_fts_content(content)
idx_vector_embedding(embedding)
idx_locale(locale)
也就是说,idx_locale 已经在了。接下来就不是“有没有索引”的问题,而是“优化器和执行引擎在这类向量查询里会不会用它”的问题。
实测结果
同一条 query、同一个 locale,三组 EXPLAIN ANALYZE 的关键结果如下:
| Case | 关键 SQL 形态 | Plan 关键信息 | Time | RU | 结论 |
|---|---|---|---|---|---|
current |
LOWER(locale) + 单段 TopN |
Selection eq(lower(locale), ?),TableFullScan,pushed down filter: empty |
231.9ms |
1315.92 |
LOWER(locale) 确实不友好 |
eq-locale |
locale = ? + 单段 TopN |
过滤变成 pushed down filter:eq(locale, ?),但仍是 TableFullScan |
224.0ms |
1315.92 |
去掉 LOWER 是必要 hygiene,但不是主要优化 |
two-phase |
先 TopN id + distance,再回表 |
子查询只保留 id + embedding + distance,外层 IndexHashJoin + TableRangeScan 回表 10 行 |
104.9ms |
681.14 |
真正显著降 RU/耗时 |
如果把 current 和 two-phase 直接对比:
- 耗时从
231.9ms降到104.9ms,下降约54.8% - RU 从
1315.92降到681.14,下降约48.2%
这个量级已经不是“偶然快一点”,而是明显的执行形态变化。
结果怎么理解
1. LOWER(locale) 确实是问题,但不是最大的那个问题
current 的计划里有这样两个明显信号:
- 谓词是
eq(lower(test.documents.locale), ?) TableFullScan上显示pushed down filter: empty
这说明 LOWER(locale) 至少让这条过滤条件没有以我们想要的方式下推到扫描层。
而到了 eq-locale 这版,plan 变成了:
pushed down filter:eq(test.documents.locale, ?)
这一步非常关键,因为它证实了一件事:
把
LOWER(locale)去掉,确实让过滤表达式变得更“可优化”了。
所以,把 locale 统一小写、查询参数统一小写、SQL 改成 locale = ?,这条路本身没有问题。我现在仍然认为这件事应该做,而且应该长期保留。
2. 但 idx_locale 并没有把这条向量查询带到索引路径上
真正容易误判的地方在这里。
虽然 eq-locale 已经不再对列做 LOWER(),而且库里也确实存在 idx_locale,但 plan 仍然是:
TableFullScan_25
也就是说,这条向量查询在当前这组数据和当前执行路径下,依然没有走 idx_locale。
这个是观察结果,不是猜测。
至于“为什么没走”,下面这句是我的推断,不是 plan 直接告诉我的事实:
从 plan 看起来,TiFlash 在处理这类
WHERE locale = ? ORDER BY VEC_EMBED_COSINE_DISTANCE(...) LIMIT N的查询时,仍然选择了列式扫描 + 向量排序的路径,而不是先通过idx_locale做一个常规索引访问,再把剩余候选送去算向量距离。
换句话说:
locale = ?让谓词表达式变好了;- 但它没有把这条查询改造成我最初以为的那种“先走 locale 索引、再算向量”的访问路径。
这也是为什么 eq-locale 的 RU 几乎没变,还是 1315.92。
这里我反而更相信 RU 和 plan 形态,而不是一次 latency 的小幅波动。因为一次 explain 的时间有噪声,但 RU 一模一样,基本就说明这不是结构性的优化。
3. 真正的瓶颈,是“向量 TopN 阶段带着大字段跑”
two-phase 有效的原因,在 plan 里非常直观。
单段查询里,TiFlash 的投影阶段要带着这些列往下走:
id, slug, locale, title, content, vec_cosine_distance(...)
也就是说,向量距离计算、排序、TopN 这一段,不只是处理向量本身,还一直携带着比较宽的行。
而 two-phase 里,子查询的投影明显收缩成了:
id, embedding, vec_cosine_distance(...)
等 TopN 10 个 id 先算出来之后,外层再通过:
IndexHashJoinTableRangeScan
只把最终 10 行详情回表拿出来。
这基本说明了一件事:
这条查询的主要成本,不只是“有没有过滤到 713 行”,而是“在向量 TopN 阶段,是否把
title/content/slug/locale这些宽列也一起卷进去”。
这也是为什么 two-phase 即使子查询里仍然是 TableFullScan,整体 RU 依然能接近砍半。
所以,这次优化到底验证了什么?
如果把这次实验浓缩成几句话,我会这样总结:
被证实的部分
documents.locale统一小写是值得做的。- 查询参数统一转小写,并使用
locale = ?,是正确的数据契约。 LOWER(locale)会让 plan 变差,至少会破坏我们想要的过滤下推形态。- 对于这类“向量排序 + 宽行返回”的查询,two-phase 能显著降低 RU 和耗时。
被证伪或至少没有被证实的部分
- “只要把
LOWER(locale)去掉,就能避免全表扫描”这件事,在我这次测试里没有成立。 - “有了
idx_locale,这条向量查询就会自然走 locale 索引路径”这件事,在我这次测试里也没有成立。
这也是我觉得这次实验最有价值的地方:它把“合理猜想”和“真实可复现的结论”分开了。
我们最终在线上采用的写法
最终落地的向量查询,就是 two-phase:
SELECT
CAST(doc.id AS CHAR) AS id,
doc.slug,
doc.title,
doc.content,
doc.locale
FROM documents AS doc
INNER JOIN (
SELECT
id,
VEC_EMBED_COSINE_DISTANCE(embedding, ?) AS distance
FROM documents
WHERE locale = ?
ORDER BY distance ASC
LIMIT ?
) AS ranked
ON ranked.id = doc.id
ORDER BY ranked.distance ASC;
与此同时,我们也保留了两条基础约束:
documents.locale写入时一律转成小写。- 应用层查询
locale时一律先转小写,再做locale = ?。
我现在对这组改动的理解是:
locale规范化负责建立稳定的数据契约,避免无谓的表达式损耗;two-phase负责真正把向量查询的 RU 打下来。
两者都应该做,但两者解决的不是同一个层次的问题。
我想请教 TiDB 团队的几个问题
这部分也是我发帖最想讨论的地方。如果 TiDB 的同学看到,很欢迎指正。
- 对于
WHERE locale = ? ORDER BY VEC_EMBED_COSINE_DISTANCE(embedding, ?) LIMIT N这类查询,当前仍然出现TableFullScan,这是符合预期的吗? - 在 TiFlash / 向量查询路径下,普通二级索引(比如
idx_locale)和向量排序是怎么协同的?哪些情况下会用,哪些情况下基本不会用? - 对于“标量过滤 + 向量排序 + 宽行返回”这种模式,TiDB 官方推荐的 best practice 是不是本来就应该偏向 two-phase?
- 如果后续数据规模再扩大,或者
locale的选择性更高,有没有更推荐的设计,比如分表、分区、候选集预过滤、物化中间结果之类的方案?
一个不算结论的结论
这次优化让我挺警惕一件事:
很多 SQL 优化建议,在传统 OLTP 场景里是“几乎总是对的”;但一旦和向量检索、列式执行、TopN 排序这些路径叠在一起,直觉就不一定可靠了。
LOWER(locale) 的确该去掉,但如果只停在这一步,很容易以为问题已经解决。实际上,真正花钱的地方可能根本不在那里。
至少在我这次的案例里,explain 给出的答案很明确:
LOWER(locale)是坏味道,要改;idx_locale没有带来预想中的访问路径变化;two-phase才是当前最有效、最稳定、最可解释的优化。
如果 TiDB 社区里有朋友也在做向量查询优化,或者 TiDB 官方对这类 plan 有更深入的解释,我很想继续交流。
我觉得这种“把多个候选 SQL 摆在一起,用同一输入直接 compare EXPLAIN ANALYZE”的方式,比只看某一条慢 SQL 然后凭经验下判断,可靠得多。