向量检索是 RAG 系统的核心基础设施,但很多工程师对"为什么用 HNSW"说不清楚。本文深入解析主流向量索引算法的原理、性能特征和工程选型策略。
一、向量检索的核心问题
1.1 什么是最近邻搜索(ANN)
RAG 系统的检索步骤,本质上是一个**最近邻搜索(Nearest Neighbor Search)**问题:
给定查询向量 q(维度 d),
从数据库中 N 个向量 {v₁, v₂, ..., vₙ} 中
找到与 q 最相近的 Top-K 个向量
暴力搜索(计算所有向量的距离)的时间复杂度是 O(N·d):
# 暴力搜索(精确但慢)
import numpy as np
def brute_force_search(query, database, k=10):
# 计算 query 与所有向量的余弦相似度
similarities = np.dot(database, query) / (
np.linalg.norm(database, axis=1) * np.linalg.norm(query)
)
# 返回 Top-K
top_k_indices = np.argsort(similarities)[-k:][::-1]
return top_k_indices, similarities[top_k_indices]
当 N=100 万,d=1536(OpenAI text-embedding-3-small 的维度)时,每次查询需要计算 15 亿次浮点运算——在 CPU 上约需 1-2 秒,无法满足实时检索需求。
这就是为什么我们需要近似最近邻(Approximate Nearest Neighbor, ANN)算法。
1.2 精确率与速度的权衡
ANN 的核心权衡:
- 精确率(Recall):找到的 Top-K 中,有多少是真正最近邻
- 搜索速度(QPS):每秒能处理多少查询
- 内存占用:索引结构本身需要多少内存
- 构建时间:建立索引需要多少时间
通常的权衡曲线:
召回率 90% → 10x 加速
召回率 95% → 5x 加速
召回率 99% → 2x 加速
召回率 100% → 1x 加速(相当于暴力搜索)
二、HNSW:工程实践的首选
2.1 算法原理
HNSW(Hierarchical Navigable Small World)是目前最广泛使用的 ANN 算法,被 Qdrant、Weaviate、pgvector 等几乎所有主流向量数据库采用。
其核心思想来自"小世界网络"理论:在六度分隔理论中,任意两个人之间最多只需要 6 步就能建立联系。HNSW 将这个思想应用到向量空间。
HNSW 的层次化结构:
层 3(最顶层):极少数节点,长程连接,快速定位大致区域
[A] ─────────────────── [Z]
层 2:较少节点,中程连接
[A] ─────── [M] ─────── [Z]
层 1:更多节点,中短程连接
[A] ── [D] ── [M] ── [R] ── [Z]
层 0(底层):所有节点,短程连接(精确搜索区域)
[A]-[B]-[C]-[D]-[E]-...-[Z]
搜索时:从顶层快速定位区域,逐层向下精化,最终在底层找到精确结果
2.2 关键参数解析
import hnswlib
# 创建 HNSW 索引
dim = 1536 # 向量维度
max_elements = 1000000 # 最大元素数
index = hnswlib.Index(space='cosine', dim=dim)
index.init_index(
max_elements=max_elements,
# 关键参数 1:M(每层的最大连接数)
# 影响:内存占用、搜索质量、构建速度
# 增大 M → 更高召回率,更多内存
# 典型值:8-64,推荐从 16 开始
M=16,
# 关键参数 2:ef_construction(构建时的候选集大小)
# 影响:索引质量、构建时间
# 增大 → 更高质量,更慢构建
# 典型值:100-500,推荐 200
ef_construction=200,
random_seed=42
)
# 添加向量
index.add_items(vectors, ids)
# 设置查询时的 ef(候选集大小)
# 影响:查询时的精度-速度权衡
# 必须 ≥ k(返回数量)
index.set_ef(50)
# 搜索
labels, distances = index.knn_query(query_vector, k=10)
2.3 HNSW 参数调优指南
class HNSWParameterTuner:
"""
根据业务需求推荐 HNSW 参数
"""
def recommend(
self,
dataset_size: int,
target_recall: float = 0.95,
target_qps: int = 1000,
memory_budget_gb: float = 10.0,
dim: int = 1536
) -> dict:
# 估算内存占用
# 粗略公式:内存(GB) ≈ dataset_size × dim × 4bytes × 1.5(索引开销) / 1e9
base_memory = dataset_size * dim * 4 / 1e9
# 根据目标召回率推荐 M 值
if target_recall >= 0.99:
M = 32
elif target_recall >= 0.95:
M = 16
else:
M = 8
# 估算该配置的内存占用(M 越大内存越多)
estimated_memory = base_memory * (1 + M * 0.1)
if estimated_memory > memory_budget_gb:
# 内存超限,降低 M
M = max(4, int(M * memory_budget_gb / estimated_memory))
# ef_construction 推荐值
ef_construction = min(400, max(100, M * 10))
# 查询时 ef 推荐值
ef_search = max(50, int(10 / (target_qps / 1000)))
return {
"M": M,
"ef_construction": ef_construction,
"ef_search": ef_search,
"estimated_memory_gb": round(estimated_memory, 2),
"expected_recall": self._estimate_recall(M, ef_search),
"notes": self._generate_notes(dataset_size, M, ef_construction)
}
def _estimate_recall(self, M: int, ef: int) -> float:
"""粗略估算召回率"""
base_recall = min(0.7 + M * 0.01, 0.95)
ef_boost = min(ef * 0.001, 0.05)
return min(base_recall + ef_boost, 0.999)
三、IVF:大规模场景的扩展方案
3.1 IVF 的核心思想
IVF(Inverted File Index,倒排文件索引)的思路更接近传统数据库索引:
IVF 构建过程:
1. 用 K-Means 把所有向量聚类成 nlist 个簇
2. 每个向量分配到最近的簇中心
3. 构建"簇中心 → 该簇内所有向量"的倒排索引
IVF 搜索过程:
1. 计算 query 与所有簇中心的距离(O(nlist × d),很快)
2. 选出最近的 nprobe 个簇
3. 只在这 nprobe 个簇内做精确搜索
核心权衡:
- nprobe 越大 → 召回率越高,速度越慢
- nlist 越大 → 簇越小,搜索越精确,但聚类成本越高
3.2 IVF 实现示例(使用 FAISS)
import faiss
import numpy as np
# 配置参数
dim = 1536
nlist = 1024 # 聚类数量,推荐 4√N 到 16√N
nprobe = 64 # 搜索时探测的簇数量,影响精度-速度权衡
# 创建量化器(用于计算到簇中心的距离)
quantizer = faiss.IndexFlatL2(dim)
# 创建 IVF 索引
index = faiss.IndexIVFFlat(
quantizer, # 量化器
dim, # 维度
nlist, # 聚类数量
faiss.METRIC_INNER_PRODUCT # 使用内积(cosine 相似度)
)
# 训练索引(需要训练数据来做 K-Means)
# 注意:训练数据量应 ≥ 39 × nlist
training_vectors = np.random.random((100000, dim)).astype('float32')
# L2 归一化(用于 cosine 相似度)
faiss.normalize_L2(training_vectors)
index.train(training_vectors) # 执行 K-Means 聚类
# 添加向量
database_vectors = np.random.random((1000000, dim)).astype('float32')
faiss.normalize_L2(database_vectors)
index.add(database_vectors)
# 设置搜索参数
index.nprobe = nprobe
# 搜索
query = np.random.random((1, dim)).astype('float32')
faiss.normalize_L2(query)
distances, indices = index.search(query, k=10)
四、PQ(乘积量化):内存压缩的利器
4.1 PQ 的工作原理
PQ(Product Quantization)解决的是另一个问题:向量本身的内存占用。
问题场景:
100 万个 1536 维 float32 向量
内存占用:100万 × 1536 × 4bytes ≈ 5.9 GB
这还只是向量本身,加上 HNSW 的图结构,总共需要 15-20 GB
对于亿级规模,内存需求达到 TB 级别
PQ 通过向量压缩解决这个问题:
PQ 压缩原理(以 1536 维向量为例):
1. 将 1536 维向量分成 M 个子向量
每个子向量维度 = 1536 / M(如 M=96,每段 16 维)
2. 对每个子空间,用 K-Means 训练 256 个码字(code words)
每个子向量可以用 1 个 8-bit 整数(0-255)表示
3. 压缩结果:
原始大小:1536 × 4 bytes = 6144 bytes
压缩大小:96 × 1 byte = 96 bytes
压缩率:64:1
4.2 IVFPQ:IVF + PQ 的黄金组合
# 创建 IVFPQ 索引
nlist = 1024 # IVF 的聚类数
M = 96 # PQ 的子空间数
nbits = 8 # 每个子空间的 bits(256 个码字)
index = faiss.IndexIVFPQ(
quantizer, # 量化器
dim,
nlist,
M, # PQ 子空间数
nbits # 每子空间 bits
)
# 需要训练 K-Means 和 PQ 码本
index.train(training_vectors)
index.add(database_vectors)
index.nprobe = 64
# 查询(自动用 PQ 做非对称距离计算)
distances, indices = index.search(query, k=10)
4.3 各索引类型性能对比
| 索引类型 | 内存(1M 1536d) | 召回率(top10) | QPS | 适用规模 |
|---|---|---|---|---|
| Flat(暴力) | ~5.9 GB | 100% | ~100 | < 10万 |
| HNSW | ~15-30 GB | 95-99% | 1000-10000 | 1万-1000万 |
| IVFFlat | ~6 GB | 90-97% | 500-5000 | 100万-1亿 |
| IVFPQ | ~0.3 GB | 80-92% | 2000-20000 | 1000万+ |
| HNSW+PQ | ~2-5 GB | 90-97% | 1000-8000 | 100万-1亿 |
五、主流向量数据库的索引策略
5.1 Qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, HnswConfigDiff
client = QdrantClient("localhost", port=6333)
# 创建集合时配置 HNSW 参数
client.create_collection(
collection_name="articles",
vectors_config=VectorParams(
size=1536,
distance=Distance.COSINE,
hnsw_config=HnswConfigDiff(
m=16, # HNSW M 参数
ef_construct=200, # 构建时 ef
full_scan_threshold=10000, # 小数据集直接全扫描
)
)
)
# 查询时动态调整精度-速度权衡
from qdrant_client.models import SearchParams
results = client.search(
collection_name="articles",
query_vector=query_embedding,
limit=10,
search_params=SearchParams(
hnsw_ef=128, # 查询时的 ef,越大越准但越慢
exact=False, # 使用近似搜索
)
)
5.2 pgvector(PostgreSQL 扩展)
-- 创建表和索引
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
content TEXT,
embedding VECTOR(1536)
);
-- 创建 HNSW 索引(pgvector 0.5+ 支持)
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
-- 或者创建 IVFFlat 索引
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- 查询
SET ivfflat.probes = 10; -- 或 hnsw.ef_search = 100
SELECT id, content, embedding <=> $1 AS distance
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;
六、工程选型决策树
向量数据规模?
│
├── < 10万条
│ └── 推荐:Flat(暴力搜索)
│ 原因:数据量小,精确搜索延迟可接受,无需索引
│
├── 10万 - 1000万条
│ ├── 内存充足(16GB+)?
│ │ └── 推荐:HNSW
│ │ 原因:最高召回率,操作简单
│ └── 内存受限?
│ └── 推荐:IVFFlat 或 HNSW+PQ
│
├── 1000万 - 10亿条
│ ├── 强调高召回率(> 95%)?
│ │ └── 推荐:分片 HNSW(多节点)
│ └── 接受 85-92% 召回率?
│ └── 推荐:IVFPQ(极大节省内存)
│
└── > 10亿条
└── 推荐:专业向量数据库(Milvus/Zilliz)
原因:需要分布式架构支持
七、性能调优实践清单
class VectorSearchOptimizationChecklist:
"""
向量检索性能调优检查清单
"""
def run_diagnosis(self, collection_stats: dict) -> list:
recommendations = []
n = collection_stats["total_vectors"]
dim = collection_stats["dimension"]
avg_query_latency_ms = collection_stats["avg_latency_ms"]
recall = collection_stats["measured_recall"]
# 1. 检查是否应该用不同索引
if n < 10000 and avg_query_latency_ms > 10:
recommendations.append({
"priority": "HIGH",
"issue": "小数据集使用了复杂索引",
"action": "切换到 Flat 索引,消除索引开销"
})
# 2. 检查维度是否过高
if dim > 2048:
recommendations.append({
"priority": "MEDIUM",
"issue": f"向量维度过高 ({dim}d)",
"action": "考虑使用 PCA 降维到 512-1024d,或选用更小的 embedding 模型"
})
# 3. 检查召回率
if recall < 0.85:
recommendations.append({
"priority": "HIGH",
"issue": f"召回率偏低: {recall:.1%}",
"action": "增大 ef_search/nprobe 参数,或提高 M/nlist 值重建索引"
})
# 4. 检查查询延迟
if avg_query_latency_ms > 100:
recommendations.append({
"priority": "HIGH",
"issue": f"查询延迟过高: {avg_query_latency_ms}ms",
"action": "降低 ef_search,启用 GPU 加速,或添加向量缓存层"
})
return recommendations
八、总结
向量索引的本质是在内存、速度、精度三个维度上做工程权衡:
- HNSW:精度优先,是 1000万以内数据集的首选
- IVF:速度和内存的均衡,适合大规模场景
- PQ:内存优先,用于超大规模数据的压缩存储
- IVFPQ:百亿规模的标配,内存效率极高
理解这三种算法的原理和参数调优方法,是构建高性能 RAG 系统的必备基础知识。在实际工程中,选择合适的向量数据库(Qdrant、Weaviate、pgvector)比自己实现索引更重要,关键在于根据数据规模正确配置索引参数。