Skip to content
向量数据库原理:从 ANN 索引到数据管理
概述
向量数据库是一种以高维向量为主要数据模型、以相似性搜索为核心操作的数据库系统。传统数据库使用 B+ Tree 或 LSM Tree 来加速等值查询和范围查询,但高维向量之间没有天然的全局顺序,无法直接使用“小于/大于”比较建立有序索引。向量数据库通过空间划分、量化和图结构等方法构建索引,使相似性搜索不必扫描全部向量。
这类系统通常还提供数据插入、删除、持久化、副本和元数据过滤能力,因此它不同于单纯的向量检索库。单纯检索库(如 Faiss)提供高效索引和搜索;向量数据库则在索引之外解决数据管理问题。
典型应用包括语义搜索、RAG(检索增强生成)、推荐召回、多模态检索、异常检测等。这里只讨论与向量数据库内核机制相关的部分,不展开 Embedding 模型的训练细节。
向量表示与距离度量
向量数据库的“向量”通常指从原始数据中提取的稠密数值向量,常见来源是神经网络 Embedding。一个向量可以写成:
v = [v_1, v_2, ..., v_d]d 是维度。文本、图像、音频经过 Embedding 模型后,都转换为同一个向量空间中的点。语义相近的内容,其向量在空间中距离更近。
常用距离度量
余弦相似度:
cosine_similarity(u, v) = (u · v) / (||u|| * ||v||)它只关注方向,不关注模长。对文本 Embedding 很常用,因为语句长度不影响语义方向。
欧氏距离(L2):
euclidean_distance(u, v) = sqrt( sum_i (u_i - v_i)^2 )它衡量向量终点之间的直线距离,对向量各维度的绝对差异敏感。
内积(Dot Product):
dot_product(u, v) = sum_i (u_i * v_i)内积同时受方向和模长影响。在推荐场景中,内积可以表达“兴趣强度”一类含义。
距离度量选择
选择距离度量取决于 Embedding 模型的训练目标。模型输出的向量空间如果按余弦相似度优化,就应使用余弦;如果按内积优化,则使用内积。距离度量的选择会影响索引实现:有些索引可以统一支持多种度量,有些索引只在特定度量下保持较好的性能。
需要注意,当所有向量都做了归一化(模长为 1)后,余弦相似度和内积在排序上等价:
u · v = cos(u, v) * ||u|| * ||v|| = cos(u, v)因此许多向量数据库允许配置归一化,把余弦搜索转换成内积搜索。
精确 KNN 与 ANN 的本质区别
精确 KNN
给定查询向量 q,精确 KNN 返回数据集中与 q 距离最小的 k 个向量。最直接的方式是暴力搜索:计算 q 与每个向量的距离,然后取最小的 k 个。
时间复杂度为 O(n × d),其中 n 是向量数量,d 是维度。n 增大时,距离计算次数线性增长;d 增大时,单次距离计算成本也线性增长。精确 KNN 不是不能做,而是成本高。
另外还有两个隐藏问题:
- 高维向量在空间中的分布通常不像低维空间那样有规律,基于树的空间划分容易退化为扫描。
- 精确结果在某些场景下并不必要。推荐和检索任务本身有噪声,top-10 中混入一个并非严格最近邻的向量,通常不会影响使用。
近似最近邻 ANN
ANN(Approximate Nearest Neighbor)允许牺牲少量召回率,换取更低的查询延迟和内存占用。它不保证返回全局最优的 k 个近邻,但希望返回“足够接近”的 k 个结果。
召回率
ANN 索引的质量通常用召回率衡量:在返回的 k 条结果中,有多少条真正属于暴力搜索得到的 k 近邻。用公式表达:
recall@k = | 搜索结果 ∩ 真实KNN结果 | / k召回率、查询延迟、内存占用、索引大小和构建时间是互相制约的。索引结构的差别,本质上是把资源花在哪个环节上的差别。
近似最近邻索引分类与性能指标
常见 ANN 索引可以按思路分为几类:
- 基于空间划分:用树或聚类把向量空间切成区域,查询时只搜索附近区域。代表有 KD-Tree、Annoy、IVF。
- 基于哈希:用局部敏感哈希(LSH)将相近向量以较大概率映射到同一个桶。代表有 LSH。
- 基于量化:把向量压缩成紧凑编码,用编码距离近似原始距离。代表有 PQ、OPQ、ScaNN。
- 基于图:把每个向量作为图顶点,近邻关系作为边,查询时从入口点沿边走向近邻。代表有 HNSW、NSG、DiskANN。
这些方法可以组合。IVF-PQ 是空间划分与量化的组合;DiskANN 是图与量化的组合。
评估索引时需要关注以下指标:
- 召回率:近似质量。
- 查询延迟:单次 top-k 搜索的耗时,常用平均延迟或 P99 延迟观察长尾。
- QPS:每秒可处理的查询数,与延迟和并发度有关。
- 内存占用:索引在内存中的体积。
- 索引体积:持久化文件大小,影响加载时间。
- 构建时间:批量建索引需要多长时间。
- 更新成本:插入、删除、修改向量后索引的维护代价。
不同索引在这些维度上没有统一的最优解。下面的章节逐个介绍常用索引结构。
IVF:基于倒排的空间划分索引
IVF(Inverted File Index)是一种基于聚类和倒排列表的索引。它把向量空间划分成若干区域,每个区域对应一个倒排列表。
工作原理
构建时先对向量集合做 k-means 聚类,得到 nlist 个聚类中心。然后把每个向量分配到距离最近的聚类中心,并写入该中心对应的倒排列表。每个倒排列表中的条目可以只存向量 ID,也可以连同原始向量一起存。
查询时,计算查询向量与 nlist 个聚类中心的距离,选出最近的 nprobe 个聚类中心,只扫描这些中心对应的倒排列表,计算其中向量的距离,最后归并出 top-k。
这里有两个参数:
- nlist:聚类中心数量,也叫倒排列表数量。nlist 越大,每个列表越短,但查询时需要评估的聚类中心也越多。
- nprobe:查询时扫描的倒排列表数量。nprobe 越大,召回的候选范围越大,召回率越高,延迟也越高。
Faiss 示例
Faiss 是常用的向量检索库。下面的 Python 代码以 Flat(不压缩)方式构建 IVF 索引:
python
import faiss
import numpy as np
d = 128
nlist = 100
k = 10
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFFlat(quantizer, d, nlist)
# 训练必须在 add 之前完成
index.train(data)
index.add(data)
# 查询时扫描 10 个倒排列表
index.nprobe = 10
D, I = index.search(queries, k=10)返回的 I 是候选向量的 ID,D 是对应距离。训练和添加分离是 IVF 的重要特点:聚类中心需要通过训练得到,训练集可以是数据集本身,也可以是同分布采样。
注意点
如果 index 未经过训练就调用 add,Faiss 会报错。nprobe 是查询时动态参数,代表召回与延迟的权衡点。对于分布变化很大的数据,聚类中心可能过期,需要重新训练和重建索引。
PQ 与 IVF-PQ:量化索引
PQ 基本思想
PQ(Product Quantization,乘积量化)是一种用可配置比特数表示向量的压缩技术。它把 d 维向量切分成 m 段,每段是一个子向量。对每一段子向量空间,用 k-means 聚类得到若干个质心,并用一个较短的编码表示该段所属的质心 [2]。
如果每个子空间有 2^b 个质心,那么每段使用 b 位编码。一个向量被压缩成 m × b 位,而不是存储 d 个浮点数。例如把 128 维 float32 向量切分为 16 段、每段 8 位编码,压缩后的编码体积大约是:
16 * 8 / 8 = 16 字节原始向量占用则是:
128 * 4 = 512 字节这里不包含码本和倒排列表的额外开销。
PQ 的训练过程很关键:需要对待编码向量的每个子空间分别做 k-means,得到码本。训练完成后,编码和解码都依赖码本。码本质量直接影响距离精度。
从 IVF 到 IVF-PQ
PQ 可以单独使用,也可以与 HNSW、IVF 等索引结构配合 [2]。IVF-PQ 是其中最常见的一种组合。
IVF-Flat 在每个倒排列表里保存原始向量,内存占用仍然偏高。IVF-PQ 的做法是:先用 IVF 选出少数倒排列表,再对列表中的每个向量使用 PQ 编码进行距离近似,而不是解出完整向量做精确距离计算。
以 Faiss 的 IndexIVFPQ 为例,添加向量时,先用粗量化器找到最近的聚类中心,然后计算向量与聚类中心的残差,对残差做 PQ 编码。查询时也把查询向量映射到同一套量化空间,用查表的方式估算距离。这样做可以大幅降低内存占用,但距离计算是近似的。
Faiss 示例
python
import faiss
m = 16 # 子向量数量
nbits = 8 # 每段编码位数
nlist = 100
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFPQ(quantizer, d, nlist, m, nbits)
index.train(data)
index.add(data)
index.nprobe = 20
D, I = index.search(queries, k=10)IndexIVFPQ 的参数中,m 影响压缩粒度:m 越大,每段维度越少,量化误差通常越小,但编码和查表开销也会增加。nbits 决定每个子空间的质心数量,nbits 越大,码本越大、精度越高,内存占用也越高。
注意点
PQ 是有损压缩。压缩率越高,距离近似的误差越大。对于需要精确距离做重排的场景,通常会先用量化索引召回候选,再用原始向量对候选做精确重算。码本训练需要一定量的数据;如果数据分布和训练分布不一致,召回质量会下降。
HNSW:分层图索引
HNSW(Hierarchical Navigable Small World)是目前广泛使用的基于图的 ANN 索引。它的设计目标是让搜索路径通过图的边从任意入口快速收敛到查询点附近。
从 NSW 到 HNSW
NSW(Navigable Small World)在数据点之间建立近邻图,查询时从一个初始点出发,贪心地走向更接近查询点的邻居。问题在于,当图很大时,搜索可能要经过很多跳才能收敛。
HNSW 把图分成多层。上层是稀疏的长距离图,包含较少的点,负责快速接近目标区域;下层是稠密的近邻图,负责精细搜索。HNSW 论文给出了三个关键设计 [1]:
- 显式选择图的入口节点,而不是随机选择。
- 按不同尺度分层,不同层使用不同长度的边。
- 使用启发式邻居选择,而不是简单取最近邻,从而保持图中长距离边的连通性。
插入新向量时,每个新向量被分配一个随机层级 L。该向量会出现在从第 0 层到第 L 层的所有层中;L 越大,高层越稀疏。第 0 层包含全部数据点。搜索时从最高层的入口点开始,在每一层执行贪心搜索,然后把找到的最近点作为下一层的入口,逐步向下。
HNSW 搜索示例
下面的伪代码描述单层贪心搜索的候选扩展过程:
text
function search_layer(query, entry, ef):
candidates = min_heap(entry)
visited = set(entry)
results = max_heap size ef # 按距离从远到近排序
while candidates is not empty:
node = candidates.pop() # 当前最近的候选
if len(results) >= ef and distance(node, query) > distance(results.max(), query):
break # 最近的候选已经比结果中最远的还远
for neighbor in node.neighbors:
if neighbor not in visited:
visited.add(neighbor)
if len(results) < ef or distance(neighbor, query) < distance(results.max(), query):
candidates.push(neighbor)
results.push(neighbor)
if len(results) > ef:
results.pop()
return resultsHNSW 搜索从最高层开始,在当前层找到该层内足够好的候选,然后取候选中的最近点作为下一层的入口。在底层使用更大的候选集大小 efSearch 做最终收集。
参数
- M:每个节点的最大边数。M 越大,图更稠密,召回更高,内存和构建时间也更高。
- efConstruction:建图时使用的候选队列大小。它影响插入时邻居选择的精度。
- efSearch:查询时使用的候选队列大小。它直接控制召回和延迟。
在 Faiss 中,HNSW 索引的使用方式如下:
python
import faiss
index = faiss.IndexHNSWFlat(d, 32) # M = 32
index.hnsw.efConstruction = 200
index.hnsw.efSearch = 64
index.add(data)
D, I = index.search(queries, k=10)注意点与限制
HNSW 是图索引,随机访问模式较强。如果把图保存在磁盘上,查询会遇到大量随机读;因此很多实现默认把图放在内存中,或使用内存映射文件。删除节点比插入节点更复杂,因为需要处理被删除节点的邻居关系;一些向量数据库对删除采用标记删除,在后台合并索引时再物理清理。
DiskANN、ScaNN 与更多索引
DiskANN
DiskANN 面向超大规模、内存受限场景。基于图的 ANN 索引通常要求图常驻内存,否则查询时磁盘 I/O 会破坏性能。DiskANN 的思路是:把图结构放在磁盘上,在内存中保留数据集的压缩版本,搜索时先用压缩向量估计距离,减少从磁盘读取的节点数 [3]。
LM-DiskANN 进一步改进了磁盘原生图索引的内存占用,支持动态更新,并在多个基准上取得了接近内存索引的召回和延迟表现 [3]。
ScaNN
ScaNN 是 Google 提出的向量检索方法。它的重点在量化误差与 top-k 排序的关系上:普通量化优化的是“所有向量的距离误差尽可能小”,但真正影响 ANN 质量的是“top-k 结果的顺序是否保持”。ScaNN 在训练量化器时考虑得分重排序,使量化后的距离排序更接近原始距离排序。
NSG
NSG(Navigating Spreading-out Graph)是一种图索引优化。它构建的导航图比单纯近邻图有更好的连通性和更小的平均出度,从而减少查询跳数,同时保持较高召回。
这些索引的具体实现往往带有很强的工程细节,且在不同库中接口差异较大。选型时不应只关注算法名,还要看实现是否支持动态更新、持久化、过滤和并发查询。
相似度搜索完整执行流程
向量数据库的一次相似度搜索,不只是执行 ANN 索引查询。一个典型流程如下:
- 客户端发送查询向量、topK 和可选过滤条件。
- 查询引擎解析请求,确定使用哪个集合/表、哪个索引。
- 如果集合被分片,查询请求通常会被广播到所有包含相关数据的分片。
- 每个分片使用 ANN 索引召回候选 ID。
- 对候选向量计算更精确的距离,或执行重排序。
- 应用元数据过滤和后置处理。
- 合并各分片结果,返回全局 topK。
在只使用单个检索库时,步骤 4 到 6 通常由调用方自己拼装。向量数据库则把这一流程封装起来。
下面是一个 Node.js 风格的伪代码示例,用于展示客户端视角的查询:
javascript
const { data } = await vectorDB.search({
collection: 'documents',
vector: [1, 0, -1, 2],
topK: 10,
filter: {
status: 'published'
}
});
for (const item of data) {
console.log(item.id, item.score, item.payload);
}这段代码不代表任何具体 SDK 的正式 API,只用于说明查询接口的一般形态:输入向量、返回 ID 和相似度分数,过滤条件作为查询参数传给服务端。
向量数据管理与索引更新
向量数据库除了搜索,还要处理向量数据的生命周期。
插入
插入向量时,数据库需要完成两步动作:把向量写入存储,同时更新内存或磁盘索引。两者必须协调,否则可能出现索引中能看到数据、但存储中没有日志,或者反过来。
以 HNSW 为例,新向量会被分配到随机层级,并在每层查找合适邻居,插入到图中。以 IVF 为例,新向量会被放入距离最近的聚类中心对应的倒排列表。
删除与更新
删除在 ANN 索引中有两种常见实现:
- 物理删除:直接从索引结构中移除向量。图索引的物理删除需要重新连接邻居,成本较高。
- 标记删除:在索引条目上打删除标记,查询时跳过被标记的向量。磁盘空间和索引体积不会立即缩小,需要后台清理。
更新通常被拆成“删除旧版本 + 插入新版本”。向量数据库如果支持多版本,同一 ID 可能暂时存在多个版本,查询时只返回最新版本。
索引更新限制
IVF 的聚类中心和 PQ 码本都依赖训练数据。如果新数据分布明显偏离训练分布,索引的召回率会下降。多数系统不会在每次插入时重新训练码本,而是定期重建索引,或使用增量更新策略。这是向量数据库与普通数据库在更新模型上的重要差别。
存储、持久化与恢复
向量数据库的持久化层需要保存三类内容:
- 原始向量数据。
- ANN 索引结构。
- 元数据(标签、时间戳、权限等)。
WAL 与快照
为了保证写入不丢失,向量数据库通常采用 WAL(Write-Ahead Log)。写入请求先以追加方式写入日志文件,日志落盘后,再更新内存中的索引。WAL 可以简单理解为一条条操作记录:
json
{"op": "upsert", "id": "vec_001", "vector": [1, 0, -1, 2], "payload": {"chapter": 3}, "ts": 1710000000}如果数据库在写入过程中崩溃,重启后可以重放 WAL,把数据和索引恢复到崩溃前状态。
为避免 WAL 无限增长,数据库会周期性生成快照。快照是某一时刻的数据和索引的完整副本。恢复时先加载最近快照,再重放该快照之后的 WAL 记录。
内存映射与磁盘索引
当向量数量超过内存容量时,数据库可以使用 mmap 把数据文件映射到进程地址空间。操作系统按页加载数据,索引仍然可以按原有代码访问向量,但实际 I/O 由操作系统管理。磁盘原生的图索引实现会大量依赖这类技术来降低内存占用。
注意点
WAL 只能保证已确认写入不丢失,不能解决所有一致性问题。索引构建可能是异步的,查询时可能看到部分索引状态。需要了解系统对读写一致性的定义:是写后立即可查,还是最终一致。
元数据过滤与混合检索
实际向量搜索几乎不会只在裸向量上工作。比如“搜索与这段文本语义相似的公开文章”同时要求 status = published。这就是元数据过滤。
过滤执行顺序
按过滤和向量搜索的先后顺序,可以分为:
- 预过滤(pre-filter):先按元数据条件筛选出候选集合,再对候选集合做向量搜索。
- 后过滤(post-filter):先做向量搜索,再从结果中剔除不满足条件的项。
- 过滤感知的索引(filter-aware):把过滤条件下推到索引的遍历或倒排列表访问过程中。
预过滤可能漏掉真正的近邻:如果一个向量在元数据上满足条件,但在倒排列表或图搜索的候选集合中没有出现,它就不会进入最终结果。后过滤的问题是结果数量可能小于 topK,因为向量搜索阶段没有考虑条件,返回的近邻可能在过滤后被丢弃。过滤感知的索引更准确,但实现复杂度更高。
示例
javascript
const { data } = await vectorDB.search({
collection: 'articles',
vector: [1, 0, -1, 2],
topK: 10,
filter: {
status: 'published',
tags: ['database', 'vector']
}
});这段代码是示意:不同数据库对 filter 参数的支持程度不同。有的只支持标量等值,有的支持范围、数组包含和地理条件;有的数据库会在索引搜索阶段应用 filter,有的只做后过滤。
返回结果少于 topK 时,不一定是异常。如果采用后过滤,则一旦搜索结果中有较多数据被过滤掉,返回数量就会减少。在选型时,需要确认具体数据库的过滤策略。
分布式向量检索基础
单机向量数据库容量和吞吐都有上限。分布式系统通过分片和副本扩展能力,也引入了一致性和查询合并问题。
分片
数据按某个键(向量 ID、集合名或自定义键)分散到多个分片。每个分片维护自己的 ANN 索引。查询时,协调节点把请求广播到涉及的分片,然后合并各分片的 top-k。
全局 top-k 的难点
如果每个分片只返回本地 top-k,合并结果很可能不是全局 top-k。原因很简单:全局 top-k 中的某个向量在它所在的分片中可能只能排到第 100 名,不会出现在该分片的本地 top-k 中。
缓解方法是 oversampling:让每个分片返回比最终 topK 更多的候选,合并后做全局重排。增加候选倍数可以降低漏检概率,但不能保证完全等价于全局精确搜索。要实现全局精确 top-k,需要更复杂的跨分片剪枝协议。
javascript
const shardResults = await Promise.all(
shards.map(shard => shard.search(query, topK * candidateFactor))
);
const globalTopK = mergeAndRerank(shardResults).slice(0, topK);candidateFactor 的具体取值需要根据分片数量、数据分布和召回要求调整。
复制与一致性
复制用于高可用和读取扩展。副本之间需要同步写入:同步复制可以保证读到的数据一致,但写延迟更高;异步复制写延迟低,但故障时可能丢失最近更新。向量数据库的 ANN 索引本身近似,因此一些系统不保证跨副本的强一致,只保证最终一致。
向量数据库生态与选型参考
向量数据库生态大致可以分为三类:
专业向量数据库
Milvus、Qdrant、Weaviate 等以向量为第一数据模型,提供索引管理、分区、过滤、持久化和分布式能力。它们通常内置 HNSW、IVF 等索引,并通过 REST/gRPC 或 SDK 提供接口。
向量检索库
Faiss、HNSWLib、ScaNN、DiskANN 等以算法库形式存在,适合在应用内构建和查询索引。它们不负责持久化、副本、权限等数据库功能,需要使用者自己管理生命周期。
传统数据库和搜索引擎的向量扩展
pgvector 为 PostgreSQL 增加向量类型和索引;OpenSearch 提供基于 Faiss 的 PQ 支持 [2];Elasticsearch 也提供向量搜索能力。这类扩展适合已经使用传统数据库的团队,但在索引类型、过滤能力、扩展性和一致性上,与专业向量数据库存在差异。
选型参考维度
- 数据规模:单机内存能否放下索引;是否需要磁盘索引。
- 索引与过滤:支持哪些 ANN 索引;过滤是预过滤、后过滤还是过滤感知。
- 更新模式:离线批量导入还是在线增量写入;删除和更新成本。
- 一致性要求:是否要求写后立即可见;副本故障时的数据丢失窗口。
- 持久化与恢复:WAL、快照、备份恢复机制是否完整。
- 生态接口:是否提供目标语言的 SDK;能否与现有数据管道集成。
这些维度没有统一答案,应该根据数据特征和查询要求取舍。
