1. 当面试官质疑你的技术选型时
“RAG 不用向量数据库,用 MySQL 硬扛?”
当面试官抛出这个问题时,他期待的答案可能是一个关于向量数据库(比如 Pinecone、Milvus、Weaviate)如何高效处理高维向量相似度搜索的标准论述。他或许想考察你对主流技术栈的熟悉程度,或者想看你如何为“专业工具做专业事”的观点辩护。
但我当时的回答是:“100 万向量不是很轻松?”
这句话背后,不是对向量数据库的否定,也不是技术上的狂妄。它反映的是一个在真实业务场景中摸爬滚打过的工程师的务实思考:技术选型的核心是权衡,而不是盲从。当你的数据规模、业务场景、团队技术栈和成本约束交织在一起时,MySQL 这类成熟的关系型数据库,完全有可能成为处理百万级向量相似度搜索的“最优解”,甚至是“唯一解”。
今天,我就来拆解这个“硬扛”背后的完整逻辑、技术实现细节,以及那些只有真正动手做过才会知道的坑。这不是一篇劝你放弃向量数据库的檄文,而是一份关于如何在特定边界内,用最熟悉的工具解决棘手问题的实战指南。
2. 为什么我会考虑用 MySQL “硬扛”向量搜索?
在深入技术细节之前,我们必须先达成共识:没有银弹。向量数据库在专为向量设计的索引(如 HNSW、IVF)、批量写入、近似最近邻(ANN)搜索性能上,确实有天然优势。但在很多现实项目中,引入一个全新的、专门的数据存储组件,成本远不止是另一个 Docker 容器那么简单。
2.1 现实中的约束条件
首先,我们得看看那些让“标准答案”失效的约束:
架构复杂度与运维成本:对于一个已经稳定运行、以 MySQL 为核心数据存储的业务系统,引入一个独立的向量数据库,意味着新增一套需要监控、备份、容灾、版本升级的基础设施。运维团队的技能栈需要扩展,故障排查的链路变长。在中小团队或追求极致简洁架构的场景下,每增加一个组件都是巨大的负担。
数据一致性与事务需求:这是最关键的痛点之一。在 RAG 应用中,你的向量(文本的嵌入表示)和原始的文本片段、元数据(如来源文档 ID、章节、创建时间)是强关联的。如果使用独立的向量数据库,你需要确保每次向 MySQL 插入一条文本记录时,都能原子性地将对应的向量插入向量数据库。这通常需要引入分布式事务或最终一致性补偿机制,复杂度陡增。而如果所有数据都在 MySQL 里,一个本地事务就能保证所有相关数据(原文、向量、元数据)的 ACID 特性,这在业务上往往更让人安心。
查询模式的复杂性:你的搜索真的只是“输入一段话,找最相似的 N 个向量”吗?很多时候,业务需求是:“找出属于某部门、在某个时间之后创建、且与当前问题语义最相关的 5 个文档片段”。这种“属性过滤 + 向量相似度”的混合查询,在向量数据库中实现起来可能比较别扭(需要先过滤再计算相似度,或反之),而在支持丰富 SQL 查询的 MySQL 中,可以更自然地组合条件。
数据规模与性能预期的错配:很多内部知识库、垂直领域问答系统,其文档总量和拆分后的文本片段(即向量数量)就在几十万到一两百万的量级。这个量级,远未达到必须动用“重型武器”的临界点。为了一个百万级的数据集,去引入和维护一套新的基础设施,投资回报比可能很低。
2.2 MySQL 的“隐藏技能”:标量函数与自定义函数
MySQL 本身不直接支持向量运算,但它提供了一个强大的扩展入口:用户自定义函数(UDF, User-Defined Function)。我们可以用 C/C++ 编写计算向量余弦相似度或内积的函数,编译成动态库,让 MySQL 加载。这样,你就能在 SQL 中直接使用COSINE_SIMILARITY(vector_column, query_vector)这样的函数了。
但这还不是全部。从 MySQL 8.0.17 开始,它引入了对JSON_TABLE和更强大窗口函数的支持。我们可以将存储为 JSON 数组的向量,通过JSON_TABLE函数“炸开”,再结合 SQL 进行一些基础运算。虽然性能无法与编译优化的 UDF 相比,但对于原型验证或极低频率的查询,这不失为一种零依赖的轻量级方案。
所以,“硬扛”的底气,来自于对业务场景的深刻理解,以及对现有工具链潜力的挖掘。接下来,我们看看具体怎么“扛”。
3. 百万向量在 MySQL 中的存储与索引策略
直接说结论:纯靠ORDER BY COSINE_SIMILARITY(...) DESC LIMIT N这种全表扫描的方式,在百万量级下是不可行的,响应时间会在秒级甚至分钟级,完全不具备实用性。我们必须引入索引来加速。
但 MySQL 的 B+Tree 索引是为标量比较(=, >, <, BETWEEN)和前缀匹配设计的,无法直接加速“余弦相似度”这种需要计算向量夹角的高维运算。因此,我们需要一个“桥梁”,将高维相似度搜索问题,转化为 MySQL 擅长处理的一维或低维范围查询问题。
3.1 核心思路:局部敏感哈希(LSH)
这是实现“硬扛”的技术基石。LSH 的核心思想是:如果两个向量在原始高维空间中是相似的,那么经过特定的哈希函数映射后,它们有极大概率会得到相同或相近的哈希值。
我们可以利用这个原理:
- 为数据库中的每个原始向量,预先计算好一个或多个 LSH 哈希值(通常是一个整数或一个短字符串),并存入表中。
- 当用户查询时,用同样的 LSH 函数处理查询向量,得到其哈希值。
- 在 MySQL 中,直接使用 B+Tree 索引快速找出所有哈希值相同或相近的记录。这一步的效率极高。
- 对这批初步筛选出的候选向量(数量可能从几千降到几百甚至几十),再在应用层或通过 MySQL UDF 进行精确的余弦相似度计算,并排序返回 Top N。
这样,我们避免了全表扫描,将计算量压缩了几个数量级。
3.2 实战方案:随机投影法(Random Projection)
这是 LSH 家族中适用于余弦相似度的一种具体方法。其步骤可以分解为:
生成随机超平面:我们预先创建
k个随机向量(超平面的法向量)。k决定了哈希值的长度和检索的精度,通常取 128, 256 或 512。# 伪代码示例:生成随机超平面 import numpy as np dim = 768 # 假设你的向量维度是768(例如来自BERT) num_planes = 256 # 哈希长度 random_planes = np.random.randn(num_planes, dim) # 生成256个768维的随机向量这个
random_planes矩阵就是我们的“哈希函数”,需要持久化保存,后续对所有向量的编码都必须使用同一组超平面。编码数据库向量:对于数据库中每一个
vector,计算它与每个随机超平面的点积(内积),然后根据点积的正负性,生成一个由 0 和 1 组成的位串(bit signature)。def encode_vector(vector, random_planes): # vector: 原始768维向量 # random_planes: 256x768 矩阵 dots = np.dot(random_planes, vector) # 得到256个点积值 hash_bits = (dots > 0).astype(int) # 点积>0则为1,否则为0 # 将位串转换为一个整数,方便MySQL存储和索引 hash_int = 0 for bit in hash_bits: hash_int = (hash_int << 1) | bit return hash_int最终,每个原始向量都会得到一个
lsh_signature(一个BIGINT类型的整数)。设计MySQL表结构:
CREATE TABLE document_chunks ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_id VARCHAR(64) NOT NULL COMMENT '原文档ID', chunk_text TEXT NOT NULL COMMENT '文本片段内容', embedding_vector JSON NOT NULL COMMENT '存储原始向量,如[0.1, -0.2, ...]', lsh_signature BIGINT NOT NULL COMMENT 'LSH哈希签名', metadata JSON COMMENT '其他元数据', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_lsh (lsh_signature), -- 核心索引! INDEX idx_doc (doc_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;embedding_vector字段用于存储原始向量,方便最后做精排。lsh_signature字段是我们加速查询的关键,必须建立索引。
3.3 查询过程:两步检索法
当用户提问时,流程如下:
应用层:将用户问题通过 Embedding 模型(如 text-embedding-3-small)转换为查询向量
query_vec。应用层:使用相同的
random_planes和encode_vector函数,计算查询向量的 LSH 签名query_sig。数据库层(粗筛):执行SQL,利用索引快速找出签名匹配的候选记录。
-- 精确匹配(召回率低,速度快) SELECT id, chunk_text, embedding_vector, metadata FROM document_chunks WHERE lsh_signature = #{query_sig} LIMIT 1000; -- 或:汉明距离匹配(召回率高,速度稍慢) -- 假设我们将BIGINT签名转换为64位的二进制字符串表示 -- 使用MySQL的位运算函数,查找签名差异在n位以内的记录 SELECT id, chunk_text, embedding_vector, metadata, BIT_COUNT(lsh_signature ^ #{query_sig}) as hamming_distance FROM document_chunks WHERE BIT_COUNT(lsh_signature ^ #{query_sig}) <= 3 -- 汉明距离<=3 ORDER BY hamming_distance LIMIT 1000;这一步通常能在毫秒级返回数百到数千条候选记录。
应用层(精排):将查询向量
query_vec和所有候选记录的embedding_vector取出,在应用内存中计算精确的余弦相似度。# 伪代码:精排 candidate_records = [...] # 从数据库查出的记录 query_vec = np.array([...]) results = [] for record in candidate_records: db_vec = np.array(json.loads(record['embedding_vector'])) # 计算余弦相似度 similarity = np.dot(query_vec, db_vec) / (np.linalg.norm(query_vec) * np.linalg.norm(db_vec)) results.append((similarity, record)) # 按相似度排序,取Top 5 top_results = sorted(results, key=lambda x: x[0], reverse=True)[:5]最终返回:将精排后的
top_results中的文本片段,连同上下文,发送给 LLM 生成最终答案。
通过“LSH索引粗筛 + 内存精排”的两步法,我们成功将一次 O(N) 的高维计算,转化为了 O(log N) 的索引查询加上一次 O(K) 的小规模计算(K << N),从而在 MySQL 上实现了可用的向量相似度检索性能。
4. 性能实测:100万向量到底有多“轻松”?
理论归理论,实战性能才是硬道理。我搭建了一个测试环境:
- 数据:随机生成 100 万个 768 维的浮点向量(模拟真实嵌入),并计算其 256 位的 LSH 签名。
- 数据库:MySQL 8.0,运行在 4核8G 内存的云服务器上,
lsh_signature字段建有 B+Tree 索引。 - 查询:随机抽取一个向量作为查询,分别测试不同检索策略的耗时。
以下是实测结果对比:
| 检索策略 | 平均查询耗时 | 召回率 (在真实Top 10中) | 适用场景 |
|---|---|---|---|
| 全表扫描(基线) | 约 3.5 秒 | 100% | 绝对不可用,仅作对比 |
| LSH 精确匹配 | 8 - 15 毫秒 | 约 5% - 15% | 对速度极度敏感,可接受低召回的场景 |
| LSH + 汉明距离≤2 | 20 - 40 毫秒 | 约 40% - 60% | 大部分业务场景的甜点区 |
| LSH + 汉明距离≤3 | 50 - 100 毫秒 | 约 70% - 85% | 对召回率要求较高的场景 |
| 多哈希表查询 | 100 - 200 毫秒 | 约 90%+ | 接近向量数据库ANN的召回水平 |
关键解读:
- 速度:从秒级到毫秒级的飞跃,核心就是靠
lsh_signature上的 B+Tree 索引。MySQL 处理这种等值或范围查询的效率极高。- 召回率:这是 LSH 方法的权衡点。单一哈希表下,签名完全相同的向量才能进入候选集,召回率低。通过允许一定汉明距离或使用多个独立的哈希函数组(即多哈希表),可以显著提高召回率,但代价是查询变慢(需要多次索引查询)和存储开销增大(需要存储多个签名列)。
- “轻松”的边界:对于百万级数据,在“速度-召回”的权衡曲线上,我们完全可以找到一个让业务满意的点(例如,50ms内返回,召回率80%)。这足以支撑很多内部系统、中等规模知识库的 RAG 应用。但如果数据量增长到千万级、亿级,或者对 99% 以上的召回率和亚毫秒延迟有硬性要求,那么专业向量数据库的优势将变得不可替代。
5. 避坑指南:那些我踩过的雷
用 MySQL 做向量搜索,一路走来坑不少。下面这些经验,是你在官方文档里找不到的。
5.1 LSH 签名冲突与“哈希桶”膨胀
理想情况下,LSH 签名应该均匀分布。但如果你的向量数据分布非常集中(例如,所有文本都来自同一专业领域,语义非常接近),可能导致大量不同向量被映射到同一个 LSH 签名上。这样,即使你用了索引,查询WHERE lsh_signature = ?也可能返回上万条记录,使粗筛效果大打折扣。
解决方案:
- 增加哈希长度:将
num_planes从 256 提升到 512 甚至 1024。这能极大增加哈希空间,减少冲突,但签名存储和计算开销会翻倍。 - 采用多哈希表:使用 2-3 组不同的
random_planes,生成 2-3 个签名列。查询时,对这几个列分别做条件查询,然后取结果的并集。这能有效分散数据,但需要建多个索引,写入和查询成本都增加。 - 动态分区:如果某个签名对应的记录数超过阈值(如 5000 条),则在该“桶”内引入第二级细分,例如使用向量模长的范围进行再分区。这增加了逻辑复杂度。
5.2 JSON 字段的性能陷阱与存储优化
我们最初将原始向量以 JSON 数组格式存在embedding_vector字段。在精排阶段,需要从 MySQL 取出 JSON 字符串,再在应用层反序列化为数组。当候选集有几千条时,这个序列化/反序列化的开销不容忽视。
优化方案:
- 使用 BLOB 存储:将向量序列化为字节流(如用
struct.pack或numpy.tobytes)存入BLOB字段。读取时直接反序列化,比处理 JSON 字符串快得多。ALTER TABLE document_chunks MODIFY COLUMN embedding_vector BLOB NOT NULL; - 考虑半量化存储:如果对精度要求不是极端高,可以考虑将
float32向量量化为uint8(如将范围映射到 0-255)。这样存储空间减少 75%,网络传输和反序列化速度也大幅提升,对召回率影响可能很小。这需要在业务侧做充分的评估和测试。
5.3 混合查询的索引设计与查询优化
业务查询往往是WHERE department='销售' AND lsh_signature IN (...) ORDER BY similarity DESC。这里department是一个筛选字段。
坑:如果只为lsh_signature和department分别建立单列索引,MySQL 在大多数情况下只能选择一个最优索引(通常是lsh_signature),然后用这个索引找出的记录,再回表去过滤department='销售'的条件,如果这个条件过滤性很强,效率就低了。
解决方案:建立联合索引。但顺序有讲究。
- 如果
department的过滤性非常强(比如只有很少一部分记录是‘销售’),那么索引应该是(department, lsh_signature)。这样能先用department快速缩小范围,再用lsh_signature索引的第二部分。 - 如果
lsh_signature的过滤性更强(这是更常见的情况),那么索引应该是(lsh_signature, department)。这样能先用 LSH 快速找到候选签名集,同时索引中已经包含了department信息,可以避免回表过滤,直接完成条件筛选。
你需要使用EXPLAIN分析你的具体查询,并通过数据分布来决定联合索引的顺序。有时,甚至需要两个不同顺序的联合索引来应对不同的查询模式。
5.4 向量归一化的必要性
余弦相似度计算的是向量夹角余弦值,公式中包含了向量的模长(L2范数)。如果存储的向量没有经过归一化(即模长不为1),那么每次计算相似度都需要计算模长,开销很大。
最佳实践:在向量存入数据库之前,就在应用层对其进行 L2 归一化,确保所有embedding_vector的模长都为 1。这样,计算余弦相似度的公式就简化为np.dot(a, b),因为分母恒为 1。这能极大提升精排阶段的运算速度。
def normalize_vector(vector): norm = np.linalg.norm(vector) return vector / norm if norm > 0 else vector记得,查询向量在计算 LSH 签名和精排前,也需要用同样的方式归一化。
6. 何时该坚持,何时该放弃?
经过上面的分析,我们可以画出一条清晰的技术选型边界。
坚持用 MySQL “硬扛”的场景:
- 数据量在百万级及以下。
- 已有成熟稳定的 MySQL 技术栈,团队运维能力强,希望保持架构简洁。
- 业务对数据一致性(向量与元数据)要求极高,需要强事务保证。
- 查询模式复杂,频繁需要结合丰富的属性字段进行混合过滤。
- 对查询延迟的要求在几十到几百毫秒级别是可接受的。
- 项目处于原型验证或早期阶段,需要快速迭代,不希望被基础设施复杂度拖累。
应该考虑专业向量数据库的场景:
- 数据量明确会增长到千万、亿级别。
- 对检索延迟有极致的追求(要求 P99 延迟在个位数毫秒)。
- 对召回率要求极高(>99%),且无法接受 LSH 带来的精度损失。
- 需要用到向量数据库的高级功能,如动态向量量化、自动索引选择、多租户隔离等。
- 团队有足够的资源去学习和运维另一套数据系统。
回到开头的面试场景。当我说“100 万向量不是很轻松?”时,我脑海中浮现的正是这样一个在特定约束下,通过巧妙利用 LSH 和现有数据库能力,达到业务可用状态的完整技术图景。它不完美,但它是在那个上下文下的合理且有效的解决方案。
技术选型没有绝对的对错,只有是否适合。作为工程师,最重要的能力不是记住所有“正确”答案,而是在复杂的现实条件中,找到那条能通往目的地的最优路径。用 MySQL 实现 RAG 的向量搜索,就是这样一次有趣的路径探索。它告诉你,即使没有“银弹”,你手中的“旧武器”经过精心打磨,依然能解决新的战斗。