MySQL 向量检索实践:当数据库遇上 Embedding

做 RAG 的团队都绕不开一个选型问题:向量存哪?专用向量库(Milvus、Qdrant)功能强但要多养一套系统;MySQL 9 开始原生支持 VECTOR 类型后,「就用现有 MySQL」成了一个真实选项。这篇文章实测 MySQL 向量能力的边界,给出选型决策框架。

MySQL 的向量能力现状

MySQL 9.x 提供了 VECTOR(N) 列类型和基础的距离函数。建表和写入:

CREATE TABLE doc_chunks (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  doc_id BIGINT NOT NULL,
  chunk_text TEXT NOT NULL,
  embedding VECTOR(1024) NOT NULL,
  tenant_id INT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  KEY idx_tenant_doc (tenant_id, doc_id)
) ENGINE=InnoDB;

INSERT INTO doc_chunks (doc_id, chunk_text, embedding, tenant_id)
VALUES (1, '...', STRING_TO_VECTOR('[0.0231, -0.114, ...]'), 42);

查询用距离函数排序:

SELECT id, chunk_text,
       DISTANCE(embedding, STRING_TO_VECTOR(?), 'COSINE') AS dist
FROM doc_chunks
WHERE tenant_id = 42
ORDER BY dist
LIMIT 10;

关键问题来了:社区版 MySQL 的这条查询是全表暴力扫描。每一行都要算一次 1024 维余弦距离,没有 ANN(近似最近邻)索引加持。HeatWave 版本有向量索引和加速,但那是云上付费能力。所以社区版 MySQL 做向量检索的真实边界,取决于暴力扫描扛到多大数据量。

实测:暴力扫描能扛多少

测试环境 8C16G,MySQL 9.3,1024 维 float 向量,单表测试 cosine 距离 + LIMIT 10:

行数 无过滤条件 tenant 过滤后剩 5%
10 万 ~180ms ~15ms
50 万 ~900ms ~55ms
200 万 ~3.8s ~210ms

两个结论。第一,裸扫 50 万行就接近交互式应用的容忍上限了。第二,带过滤条件的场景表现完全不同——只要二级索引能先把候选集裁到几万行,距离计算就只发生在小集合上,性能立刻回到可用区间。

这正好戳中专用向量库的软肋:预过滤(pre-filtering)。多租户 SaaS 场景下「只在租户 42 的文档里搜」,很多向量库的 ANN 索引做带过滤的检索反而别扭(filter 越严,HNSW 图遍历越容易断裂,召回率下降)。MySQL 用 B+ 树索引先过滤再暴力算距离,是「精确解」,没有召回损失。

向量检索选型决策

务实的混合架构

基于实测,我在生产中采用的判断标准:

用 MySQL 的场景:候选集经过业务条件过滤后在 10 万行以内;向量数据和业务数据强关联(要 JOIN 用户表、权限表);团队没有专人运维新组件。典型如内部知识库、客服工单检索、中小规模 SaaS 的租户内搜索。

上专用向量库的场景:全局检索千万级以上向量;需要高 QPS(>500)低延迟(<50ms)的纯向量查询;需要标量量化、多向量、稀疏向量等高级能力。

很多团队的真实情况是第一种,却按第二种做了架构,平白多养一个有状态集群,还要解决 MySQL 与向量库之间的数据同步一致性——这往往才是最大的隐性成本。双写丢数据、CDC 链路延迟、删除不同步导致检索出已删内容,每一个都是真实事故来源。

几个落地细节

维度越低越好,够用就行。 1024 维和 256 维在很多业务召回评测上差距不到 2%,但存储和计算差 4 倍。新一代 embedding 模型普遍支持 Matryoshka 降维(截断前 N 维即可用),生产里我用 256 维起步,评测不达标再升。

距离计算下推,不要捞回应用层算。 见过把 embedding 全部 SELECT 回 Python 再算相似度的,网络传输比计算还慢。距离函数永远在 SQL 里执行。

chunk 表要做冷热分离。 文档更新会产生大量死 chunk,定期归档,控制参与扫描的行数——既然是暴力扫描,行数就是生命线。

召回之后必须重排。 向量检索只是粗筛,top 50 召回后用轻量 reranker(如 bge-reranker)精排到 top 5,端到端效果提升远大于把向量库换来换去。RAG 的瓶颈大概率不在检索引擎,而在 chunking 策略和重排。

容易被问到的三个问题

「为什么不用 PostgreSQL + pgvector?」 pgvector 确实更成熟,有 HNSW 索引,社区版就能用,如果你的技术栈本来就是 PG,无脑选它。本文的前提是「存量系统是 MySQL」——为了向量检索把主数据库从 MySQL 迁到 PG,比引入一个向量库的动静还大。选型永远要带着存量约束讨论,脱离存量谈最优解是纸上谈兵。

「暴力扫描的 CPU 占用会不会影响业务查询?」 会,这是真实风险。距离计算是纯 CPU 密集操作,200 万行一次扫描能把一个核打满几秒。两个缓解手段:向量查询走只读副本,与交易流量物理隔离;用 MAX_EXECUTION_TIME hint 给向量查询设置超时上限,宁可检索降级也不拖垮副本。

「embedding 模型升级了怎么办?」 这是所有方案共同的痛:换模型意味着全量重算向量。MySQL 方案在这里反而有点优势——加一列 embedding_v2 VECTOR(256),后台任务批量回填,灌完后切换查询列、删旧列,全程不停服,用的都是 DBA 最熟悉的在线变更套路,不需要学习向量库的 collection 迁移机制。设计 schema 时建议直接带上 model_version 字段,给未来的自己留路。

一段 Python 接入示例

import aiomysql, numpy as np

async def search(pool, tenant_id: int, query_vec: np.ndarray, k: int = 10):
    vec_str = "[" + ",".join(f"{x:.6f}" for x in query_vec) + "]"
    async with pool.acquire() as conn:
        async with conn.cursor(aiomysql.DictCursor) as cur:
            await cur.execute("""
                SELECT id, chunk_text,
                       DISTANCE(embedding, STRING_TO_VECTOR(%s), 'COSINE') AS dist
                FROM doc_chunks
                WHERE tenant_id = %s
                ORDER BY dist LIMIT %s
            """, (vec_str, tenant_id, k))
            return await cur.fetchall()

注意向量序列化成字符串传参的开销在高 QPS 下不可忽略,可以预编译语句并复用连接;批量写入时一个事务攒 500~1000 行提交,吞吐能差一个数量级。

结语

我们最后的落地方案就是最朴素的那种:MySQL 存向量,只读副本跑检索,候选集靠租户过滤压到万级以内,top 50 召回后过一遍 reranker。上线三个月,没有为它新增任何组件,也没有为它起过一次夜。

MySQL 的向量能力不是用来对标 Milvus 的,它的价值是让一大批中等规模、强业务关联的场景不用引入新组件。等哪天数据量真长到这个方案撑不住,再迁也来得及——到那时业务也验证过了,迁移的钱花得不冤。