一次 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 的可疑点非常明显:

  1. locale 列上套了 LOWER(locale),这通常会破坏普通等值过滤的优化机会。
  2. 查询在做向量 TopN 排序时,同时把 titlecontent 这些大字段也一起带着。

我最初的想法,其实和很多人看到这条 SQL 时的第一反应差不多:

  • 如果把库里的 locale 全部改成小写;
  • 再把查询参数也统一转成小写;
  • 然后把条件改成 locale = ?
  • 再配一个 idx_locale

那这条查询是不是就能避免全表扫描,成本自然也就降下来了?

这个想法并不离谱,但问题在于:它还只是推测,不是证据。

我们是怎么验证的

为了避免“改完感觉好像快了”的错觉,我专门写了一个对比脚本,固定同一组 query 和 locale,直接跑三组 EXPLAIN ANALYZE 。脚本会做三件事:

  1. 审计 locale 数据是否已经全部小写。
  2. 打印当前索引状态。
  3. 对比三种 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), ?)TableFullScanpushed 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/耗时

如果把 currenttwo-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 先算出来之后,外层再通过:

  • IndexHashJoin
  • TableRangeScan

只把最终 10 行详情回表拿出来。

这基本说明了一件事:

这条查询的主要成本,不只是“有没有过滤到 713 行”,而是“在向量 TopN 阶段,是否把 title/content/slug/locale 这些宽列也一起卷进去”。

这也是为什么 two-phase 即使子查询里仍然是 TableFullScan,整体 RU 依然能接近砍半。

所以,这次优化到底验证了什么?

如果把这次实验浓缩成几句话,我会这样总结:

被证实的部分

  1. documents.locale 统一小写是值得做的。
  2. 查询参数统一转小写,并使用 locale = ?,是正确的数据契约。
  3. LOWER(locale) 会让 plan 变差,至少会破坏我们想要的过滤下推形态。
  4. 对于这类“向量排序 + 宽行返回”的查询,two-phase 能显著降低 RU 和耗时。

被证伪或至少没有被证实的部分

  1. “只要把 LOWER(locale) 去掉,就能避免全表扫描”这件事,在我这次测试里没有成立
  2. “有了 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;

与此同时,我们也保留了两条基础约束:

  1. documents.locale 写入时一律转成小写。
  2. 应用层查询 locale 时一律先转小写,再做 locale = ?

我现在对这组改动的理解是:

  • locale 规范化负责建立稳定的数据契约,避免无谓的表达式损耗;
  • two-phase 负责真正把向量查询的 RU 打下来。

两者都应该做,但两者解决的不是同一个层次的问题。

我想请教 TiDB 团队的几个问题

这部分也是我发帖最想讨论的地方。如果 TiDB 的同学看到,很欢迎指正。

  1. 对于 WHERE locale = ? ORDER BY VEC_EMBED_COSINE_DISTANCE(embedding, ?) LIMIT N 这类查询,当前仍然出现 TableFullScan,这是符合预期的吗?
  2. 在 TiFlash / 向量查询路径下,普通二级索引(比如 idx_locale)和向量排序是怎么协同的?哪些情况下会用,哪些情况下基本不会用?
  3. 对于“标量过滤 + 向量排序 + 宽行返回”这种模式,TiDB 官方推荐的 best practice 是不是本来就应该偏向 two-phase?
  4. 如果后续数据规模再扩大,或者 locale 的选择性更高,有没有更推荐的设计,比如分表、分区、候选集预过滤、物化中间结果之类的方案?

一个不算结论的结论

这次优化让我挺警惕一件事:

很多 SQL 优化建议,在传统 OLTP 场景里是“几乎总是对的”;但一旦和向量检索、列式执行、TopN 排序这些路径叠在一起,直觉就不一定可靠了。

LOWER(locale) 的确该去掉,但如果只停在这一步,很容易以为问题已经解决。实际上,真正花钱的地方可能根本不在那里。

至少在我这次的案例里,explain 给出的答案很明确:

  • LOWER(locale) 是坏味道,要改;
  • idx_locale 没有带来预想中的访问路径变化;
  • two-phase 才是当前最有效、最稳定、最可解释的优化。

如果 TiDB 社区里有朋友也在做向量查询优化,或者 TiDB 官方对这类 plan 有更深入的解释,我很想继续交流。

我觉得这种“把多个候选 SQL 摆在一起,用同一输入直接 compare EXPLAIN ANALYZE”的方式,比只看某一条慢 SQL 然后凭经验下判断,可靠得多。

觉得文章有帮助?

如果我的分享对你有所启发,欢迎通过赞助来支持我持续创作。

❤️ 赞助我

评论