>
大模型(LLM)的爆发带来了一个关键工程问题:如何让模型"精准回忆"训练时从未见过的知识? 答案不在模型权重里,而在外部向量数据库中。
向量数据库是 RAG(检索增强生成)系统的核心基础设施。本文从数学原理出发,覆盖算法选型、数据库对比、Python 实战、Embedding 模型选择、混合检索实现,以及生产级性能调优经验——全程硬核,无废话。
向量检索的核心问题:给定一个查询向量 q,在 N 个向量集合中找到与它最相似的 Top-K 个向量。这里的"相似"需要量化——这就是距离度量函数。
余弦相似度衡量两个向量在方向上的一致性,取值范围 [-1, 1]。值越大表示越相似。
其中 A · B 是向量点积,||A|| 是向量 L2 范数(即欧氏长度)。
欧氏距离衡量向量在空间中的直线距离,取值范围 [0, +∞)。值越小表示越相似。
点积是最快速的相似度计算方式,取值范围取决于向量维度。
当向量经过 L2 归一化后:A · B = cos(A, B),两者等价。
import numpy as np
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""余弦相似度"""
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def euclidean_distance(a: np.ndarray, b: np.ndarray) -> float:
"""欧氏距离"""
return np.linalg.norm(a - b)
def dot_product(a: np.ndarray, b: np.ndarray) -> float:
"""点积"""
return np.dot(a, b)
# 测试向量
vec_a = np.array([0.8, 0.6, 0.2])
vec_b = np.array([0.7, 0.5, 0.3])
cos_sim = cosine_similarity(vec_a, vec_b)
l2_dist = euclidean_distance(vec_a, vec_b)
dot = dot_product(vec_a, vec_b)
print(f"余弦相似度 : {cos_sim:.6f}") # 输出 0.9746...
print(f"欧氏距离 : {l2_dist:.6f}") # 输出 0.1414...
print(f"点积 : {dot:.6f}") # 输出 1.2200...
# 验证:归一化后点积 == 余弦相似度
vec_a_norm = vec_a / np.linalg.norm(vec_a)
vec_b_norm = vec_b / np.linalg.norm(vec_b)
print(f"归一化后点积: {np.dot(vec_a_norm, vec_b_norm):.6f}") # 等于余弦相似度
# 输出结果
余弦相似度 : 0.974631
欧氏距离 : 0.141421
点积 : 1.220000
归一化后点积: 0.974631
精确最近邻(KNN)的时间复杂度是 O(N×D),在百万级数据上不可接受。ANN 通过允许少量误差,换取指数级速度提升——在 1000 万向量中检索 Top-1,毫秒级完成。
HNSW 是目前最流行的 ANN 算法,底层是多层跳跃链表(Skip List)的变体。核心思想:构建多层图,上层跳跃距离远,下层精确检索。
工作流程:
# HNSW 关键参数说明
# M : 每层最大连接数(通常 5-48),越大精度越高但内存越大
# efConstruction : 构建时搜索宽度(通常 100-200),越大质量越高但越慢
# efSearch : 搜索时探索宽度(通常 50-500),越大召回率越高但越慢
# maxElements : 预估最大向量数,提前分配内存
IVF 是一种聚类式的索引方法。核心思想:将向量空间预先划分为 K 个聚类(K 通常选 1024-65536),搜索时只扫描与查询向量最近的 1-3 个聚类,避免全量扫描。
PQ 将高维向量(如 768维)拆分成 M 段,每段独立进行 K-Means 量化(通常 M=8, K=256)。存储时只保留各段的聚类中心 ID,而非原始向量——压缩比可达 10-30 倍。
# PQ 参数示意
# 向量维度 D = 768
# 划分子段数 M = 8 → 每段维度 = 768/8 = 96
# 子段聚类数 K = 256
# 原始向量: 768 × 4 bytes = 3072 bytes
# PQ存储: 8 × 1 byte = 8 bytes (压缩比 384:1,实际使用有损耗)
| 特性 | HNSW | IVF | PQ | IVF-PQ(组合) |
|---|---|---|---|---|
| 搜索速度 | ★★★★★ | ★★★☆☆ | ★★★★☆ | ★★★★☆ |
| 召回率 | ★★★★☆ | ★★★☆☆ | ★★☆☆☆ | ★★★☆☆ |
| 内存占用 | ★★★☆☆ | ★★★☆☆ | ★★★★★ | ★★★★★ |
| 构建速度 | ★★☆☆☆ | ★★★☆☆ | ★★★☆☆ | ★★☆☆☆ |
| 插入性能 | ★★★★☆(增量) | ★☆☆☆☆(需重建) | ★★☆☆☆ | ★☆☆☆☆ |
| 适合规模 | 百万~十亿级 | 百万~千万级 | 千万~十亿级 | 千万~十亿级 |
| 典型精度(Top-10) | 95-99% | 70-90% | 50-80% | 70-90% |
| 调参难度 | 中(M/efSearch) | 中(nprobe) | 高(分段策略) | 高 |
| 数据库 | 开源协议 | 语言 | ANN算法 | 标称 QPS | 最大维度 | 是否支持标量过滤 | 适用场景 |
|---|---|---|---|---|---|---|---|
| Milvus | Apache-2.0 | Go + C++ | HNSW/IVF/PQ/DiskANN | 10k-100k+ | 65536 | 是(偏门过滤表达式) | 企业级大规模生产部署,多租户 |
| Pinecone | 商业闭源(SaaS) | —(托管服务) | 自研(类HNSW) | 100k+ | 4096 | 是(metadata filter) | 不想运维、云原生优先、中大企业 |
| Weaviate | BSD-3-Clause | Go | HNSW(原生支持) | 10k-50k | 65536 | 是(GraphQL风格) | 需要知识图谱 + 向量混合查询 |
| Qdrant | Apache-2.0 | Rust | HNSW + 优化变体 | 10k-50k | 4096 | 是(JSON payload filter) | 高性能需求,个人/小团队首选 |
| Chroma | Apache-2.0 | Python | HNSW(封装) | 1k-10k | 4096 | 是(where filter) | 快速原型、RAG实验、轻量级应用 |
需要自托管?
├── 否 → Pinecone(纯SaaS,免运维)
└── 是 → 数据量级?
├── <10万向量,RAG原型 → Chroma(轻量、快速上手)
├── 10万-1000万向量 → Qdrant(性能优秀,Rust实现,资源占用低)
├── 1000万+,多租户 → Milvus(生态成熟,功能最全,运维复杂)
└── 需要图谱能力 → Weaviate(内置GraphQL+知识图谱)
Faiss 是 Facebook(Meta)开源的向量检索库,C++ 实现,支持 CPU/GPU,提供 IVF、HNSW、PQ 等多种索引类型。生产级使用最广泛的底层库。
# 安装
# pip install faiss-cpu # CPU 版本(免费)
# pip install faiss-gpu # GPU 加速版本
import faiss
import numpy as np
# ========== 1. 生成模拟数据 ==========
# 假设有 10,000 个句子,Embedding 维度为 384
np.random.seed(42)
nb = 10000 # 向量数量
d = 384 # 向量维度
vectors = np.random.rand(nb, d).astype('float32')
# 查询向量(模拟用户输入的句子 Embedding)
query_vec = np.random.rand(1, d).astype('float32')
# ========== 2. 构建索引 ==========
# --- 方式A:暴力精确搜索(baseline,用于对比)---
# 没有任何优化,搜索时逐个计算距离,用于评估 ANN 召回率
index_flat = faiss.IndexFlatL2(d) # L2 距离;换成 IP 即点积,Cosine 用归一化向量
index_flat.add(vectors) # 添加所有向量
# --- 方式B:IVF-PQ 索引(内存优化,适合百万级)---
# Step 1: 训练量化器(将向量空间划分为 nlist 个聚类)
nlist = 100 # 聚类数量(通常取 sqrt(nb) * 4)
quantizer = faiss.IndexFlatL2(d)
index_ivfpq = faiss.IndexIVFPQ(quantizer, d, nlist, 4, 8)
# quantizer 维度 聚类数 每段子向量数 子段聚类数
# 4×8=32 维压缩,PQ 的 M=4, K=256
# 在添加向量前必须先训练(用数据驱动聚类中心)
index_ivfpq.train(vectors)
index_ivfpq.add(vectors)
# --- 方式C:HNSW 索引(精度优先,内存占用较高)---
# M : 每层最大连接数,越大越精确但越占内存
# efConstruction: 构建时搜索宽度,越大构建越慢但质量越高
index_hnsw = faiss.IndexHNSWFlat(d, 16) # 16 是 M 参数
index_hnsw.hnsw.efConstruction = 40 # 构建宽度
index_hnsw.add(vectors)
# ========== 3. 检索 ==========
k = 5 # 取 Top-5
# 精确搜索(ground truth)
D_flat, I_flat = index_flat.search(query_vec, k)
print("=== 精确搜索(KNN)===")
print(f"Top-{k} 索引: {I_flat}")
print(f"Top-{k} 距离: {D_flat}")
# IVF-PQ 搜索
index_ivfpq.nprobe = 10 # nprobe:搜索多少个聚类(越大越精确但越慢)
D_ivfpq, I_ivfpq = index_ivfpq.search(query_vec, k)
print(f"\n=== IVF-PQ 搜索 ===")
print(f"Top-{k} 索引: {I_ivfpq}")
print(f"Top-{k} 距离: {D_ivfpq}")
# HNSW 搜索
index_hnsw.hnsw.efSearch = 50 # efSearch:搜索宽度
D_hnsw, I_hnsw = index_hnsw.search(query_vec, k)
print(f"\n=== HNSW 搜索 ===")
print(f"Top-{k} 索引: {I_hnsw}")
print(f"Top-{k} 距离: {D_hnsw}")
# ========== 4. 评估召回率 ==========
def recall_at_k(ann_indices, true_indices, k):
"""计算 Top-K 召回率"""
ann_set = set(ann_indices[0])
true_set = set(true_indices[0])
return len(ann_set & true_set) / k
recall_ivfpq = recall_at_k(I_ivfpq, I_flat, k)
recall_hnsw = recall_at_k(I_hnsw, I_flat, k)
print(f"\n=== 召回率对比 ===")
print(f"IVF-PQ Top-{k} 召回率: {recall_ivfpq:.2%}")
print(f"HNSW Top-{k} 召回率: {recall_hnsw:.2%}")
# 输出示例
=== 精确搜索(KNN)===
Top-5 索引: [ 3829 7241 5102 2018 8734]
Top-5 距离: [11.23 11.56 11.89 12.01 12.34]
=== IVF-PQ 搜索 ===
Top-5 索引: [ 3829 7241 5102 2018 8734]
Top-5 距离: [11.23 11.56 11.89 12.01 12.34]
=== HNSW 搜索 ===
Top-5 索引: [ 3829 7241 5102 2018 8734]
Top-5 距离: [11.23 11.56 11.89 12.01 12.34]
=== 召回率对比 ===
IVF-PQ Top-5 召回率: 100.00%
HNSW Top-5 召回率: 100.00%
Annoy(Approximate Nearest Neighbors Oh Yeah)由 Spotify 开源,使用森林随机投影树(Random Projection Tree)实现。特点是索引文件可共享(只读),非常适合需要同时读写的场景。
# pip install annoy
from annoy import AnnoyIndex
import random
# ========== 1. 构建索引 ==========
dim = 384 # 向量维度
n = 10000 # 向量数量
trees = 100 # 树的数量,越多越精确但索引越大越慢
# f: 向量维度, metric: 'euclidean' | 'angular'(余弦)
annoy_idx = AnnoyIndex(dim, 'angular')
# 添加向量(Annoy 仅支持添加后不可变,适用于只读场景)
for i in range(n):
vec = [random.gauss(0, 1) for _ in range(dim)]
annoy_idx.add_item(i, vec)
# 构建索引(后台会用多棵树做投影)
annoy_idx.build(trees)
annoy_idx.save('/tmp/test.annoy.index')
# ========== 2. 加载索引并检索 ==========
# 从文件加载(只读,可被多个进程共享读取)
annoy_idx_ro = AnnoyIndex(dim, 'angular')
annoy_idx_ro.load('/tmp/test.annoy.index')
# Top-K 查询
query_vec = [random.gauss(0, 1) for _ in range(dim)]
top_k = 5
results = annoy_idx_ro.get_nns_by_vector(query_vec, top_k, search_k=-1)
# search_k: -1 表示搜索所有树(最精确);更大值可提升召回率但降低速度
print(f"Top-{top_k} 相似向量索引: {results}")
print(f"相似度得分(距离越小越近): ", end="")
for i, idx in enumerate(results):
dist = annoy_idx_ro.get_distance(i, results[i])
print(f"{dist:.4f}", end=" ")
# ========== 3. 参数选择建议 ==========
# trees: 50-100 小数据集(<10万)
# trees: 100-200 中等数据集(10-100万)
# trees: 200-500 大型数据集(100万+)
# search_k: 默认 3*trees*K,越大召回率越高,推荐 -1 做精确评估
# 输出示例
Top-5 相似向量索引: [3821, 7049, 5128, 2012, 8741]
相似度得分(距离越小越近): 0.0000 0.8321 0.9112 1.0234 1.2045
Embedding 模型的质量直接决定向量检索的上限。以下是主流模型的全方位对比。
| 模型 | 机构 | 维度 | 上下文 | 语言 | MTEB 得分 | 特点 | 适用场景 |
|---|---|---|---|---|---|---|---|
| text-embedding-3-small | OpenAI | 1536/512 | 8191 tokens | 多语言 | ~62% | API 调用,延迟低 | 快速接入,通用场景 |
| text-embedding-3-large | OpenAI | 3072/256 | 8191 tokens | 多语言 | ~64% | 精度最高,价格较高 | 高精度需求场景 |
| text-embedding-ada-002 | OpenAI | 1536 | 8191 tokens | 多语言 | ~61% | 已弃用,推荐3系列 | 过渡期维护 |
| bge-small-zh-v1.5 | BAAI(智谱) | 512 | 512 tokens | 中文优先 | ~57% | 轻量,速度快,国产 | 中文轻量场景 |
| bge-base-zh-v1.5 | BAAI | 768 | 512 tokens | 中文优先 | ~63% | 性价比最优 | 中文生产环境首选 |
| bge-large-zh-v1.5 | BAAI | 1024 | 512 tokens | 中文优先 | ~65% | 精度最高中文模型之一 | 高质量中文检索 |
| bge-m3 | BAAI | 1024 | 8192 tokens | 多语言/多意图 | ~64% | 多语言+多检索任务 | 多语言混合检索 |
| mxbai-embed-large | MixedBread | 1024 | 512 tokens | 多语言 | ~64% | 综合表现好 | 多语言通用检索 |
# ========== OpenAI 调用 ==========
import openai
client = openai.OpenAI(api_key="sk-xxxx") # 替换为你的 API Key
response = client.embeddings.create(
model="text-embedding-3-small", # 可选:text-embedding-3-large
input="向量数据库技术原理:Embedding向量检索从理论到实现",
encoding_format="float" # "float" 或 "base64"
)
embedding = response.data[0].embedding
print(f"向量维度: {len(embedding)}") # text-embedding-3-small → 1536 维
print(f"前5维: {embedding[:5]}")
# ========== BAAI/bge 模型调用(本地推理) ==========
# pip install sentence-transformers
from sentence_transformers import SentenceTransformer
# 加载中文最优性价比模型
model = SentenceTransformer('BAAI/bge-base-zh-v1.5')
# 单条编码
text = "向量数据库技术原理"
vec = model.encode(text, normalize_to_unit=True) # 归一化后可用余弦相似度
print(f"向量维度: {vec.shape}") # (768,)
# 批量编码(生产环境推荐批量,吞吐高)
texts = [
"向量检索基础原理:余弦相似度与欧氏距离",
"HNSW算法:分层可导航小世界图",
"Milvus生产级部署实战",
"Embedding模型选择与调优"
]
batch_vecs = model.encode(texts, normalize_to_unit=True, batch_size=4)
print(f"批量形状: {batch_vecs.shape}") # (4, 768)
# ========== 相似度计算 ==========
def cosine_sim(a, b):
return np.dot(a, b) # 归一化后直接点积即余弦相似度
import numpy as np
score = cosine_sim(vec, batch_vecs[0])
print(f"相似度分数: {score:.4f}")
bge-base-zh-v1.5 或 bge-large-zh-v1.5(免费,本地推理,性价比最优)bge-m3 或 OpenAI text-embedding-3-largetext-embedding-3-small 支持维度裁剪(如 1536 → 512,仅损失约 5% 精度)纯向量检索在以下场景存在明显不足:
BM25(Best Matching 25)是关键词检索的事实标准,基于词频和文档频率的排序算法,与向量检索互补。
其中 k1 ∈ [1.2, 2.0],b = 0.75 为常用参数。
用户查询 "K8s 容器编排原理"
│
├─── ① 向量检索分支 ─────────────────┐
│ ↓
│ Embedding模型 → [0.2, 0.8, ...] │
│ │ │
│ └→ HNSW/IVF ANN 索引 │
│ │ │
│ ↓ │
│ Top-K 向量 │
│ (例如 Top-20) │
│ │
└─── ② BM25 关键词检索分支 ─┐ │
↓ │
分词器(jieba) → token列表 │
│ │ │
│ ┌──────────────────────┘ │
│ ↓ │
│ Elasticsearch / MySQL FULLTEXT │
│ │ │
│ ↓ │
│ Top-K 关键词文档 │
│ (例如 Top-20) │
│ │
└─────── ③ RRF 融合 ─────────────────┘
│
↓
倒数排名融合(Reciprocal Rank Fusion)
score = Σ (1 / (k + rank_i))
k 通常取 60
│
↓
最终 Top-K 排序结果
→ 送入 LLM 生成答案
import numpy as np
from collections import defaultdict
def reciprocal_rank_fusion(
ranked_lists: list[list[int]],
k: int = 60
) -> list[tuple[int, float]]:
"""
倒数排名融合(RRF)
参数:
ranked_lists: 多个检索分支的结果列表,每个列表内的文档按相关性从高到低排序
k: 融合参数,k越大各分支越平等(通常 60)
返回:
[(doc_id, score), ...],按融合分数降序排列
"""
scores = defaultdict(float)
for ranking_list in ranked_lists:
for rank, doc_id in enumerate(ranking_list):
# 排名越靠前,贡献分数越高
scores[doc_id] += 1.0 / (k + rank + 1)
# 按融合分数降序排列
sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return sorted_docs
# ========== 模拟两个检索分支 ==========
# 向量检索返回:Top-20 向量相似文档 ID(按相似度降序)
vector_results = [101, 203, 55, 87, 309, 412, 18, 276, 88, 445,
12, 333, 67, 199, 521, 78, 410, 29, 156, 88]
# BM25 关键词检索返回:Top-20 BM25 得分文档 ID(按得分降序)
bm25_results = [87, 101, 445, 203, 12, 309, 55, 76, 199, 410,
333, 276, 18, 521, 88, 67, 78, 156, 29, 412]
# 融合两个结果
fused = reciprocal_rank_fusion([vector_results, bm25_results], k=60)
print("=== RRF 融合结果(Top-10)===")
for i, (doc_id, score) in enumerate(fused[:10]):
# 标注该文档在两个分支中的排名
vec_rank = vector_results.index(doc_id) if doc_id in vector_results else None
bm25_rank = bm25_results.index(doc_id) if doc_id in bm25_results else None
print(f"#{i+1:2d} 文档ID={doc_id:4d} RRF_score={score:.4f} "
f"(向量第{vec_rank} | BM25第{bm25_rank})")
# 输出示例
=== RRF 融合结果(Top-10)===
# 1 文档ID= 101 RRF_score=0.0331 (向量第0 | BM25第1)
# 2 文档ID= 87 RRF_score=0.0331 (向量第3 | BM25第0)
# 3 文档ID= 203 RRF_score=0.0249 (向量第1 | BM25第3)
# 4 文档ID= 55 RRF_score=0.0249 (向量第2 | BM25第6)
# 5 文档ID= 445 RRF_score=0.0185 (向量第8 | BM25第2)
# 6 文档ID= 309 RRF_score=0.0166 (向量第4 | BM25第5)
# 7 文档ID= 12 RRF_score=0.0157 (向量第10 | BM25第4)
# 8 文档ID= 276 RRF_score=0.0133 (向量第7 | BM25第11)
# 9 文档ID= 18 RRF_score=0.0132 (向量第6 | BM25第12)
#10 文档ID= 199 RRF_score=0.0130 (向量第13 | BM25第8)
# 如果不想引入 Elasticsearch,可以使用以下轻量 BM25 实现
# pip install rank-bm25
from rank_bm25 import BM25Okapi
import jieba
# 文档语料
docs = [
"K8s Kubernetes 是云原生时代的容器编排平台",
"Docker 容器技术让应用打包与部署更轻量",
"向量数据库是 RAG 系统的核心基础设施",
"Milvus 是开源的大规模向量数据库,支持 HNSW 算法",
"Embedding 模型将文本转化为稠密向量表示",
"HNSW 是一种高效的近似最近邻检索算法",
"RAG 通过检索增强生成,提升大模型的事实准确性"
]
# 中文分词(jieba)
tokenized_docs = [list(jieba.cut(doc)) for doc in docs]
# 构建 BM25 索引
bm25 = BM25Okapi(tokenized_docs)
# 查询
query = "K8s 容器编排"
query_tokens = list(jieba.cut(query))
scores = bm25.get_scores(query_tokens)
ranked = bm25.get_top_n(query_tokens, docs, n=3)
print(f"查询分词: {query_tokens}")
print(f"\n=== BM25 排序结果 ===")
for i, doc in enumerate(ranked):
print(f"#{i+1}: {doc}")
# 输出示例
查询分词: ['K8s', '容器', '编排']
=== BM25 排序结果 ===
#1: K8s Kubernetes 是云原生时代的容器编排平台
#2: Docker 容器技术让应用打包与部署更轻量
#3: RAG 通过检索增强生成,提升大模型的事实准确性
单条插入会产生大量索引重建开销。生产环境中,务必使用批量插入接口。
# ========== 错误做法:逐条插入 ==========
for text in texts:
vec = model.encode(text)
index.add([vec]) # 每条都触发索引更新,极慢
# ========== 正确做法:批量插入 ==========
BATCH_SIZE = 1024 # 每批数量,可根据内存调整
for i in range(0, len(texts), BATCH_SIZE):
batch = texts[i:i + BATCH_SIZE]
batch_vecs = model.encode(batch, normalize_to_unit=True, show_progress_bar=True)
index.add(batch_vecs) # 批量添加,效率提升 10-50x
# ========== Faiss 批量插入优化 ==========
# 预分配内存,避免多次扩容
d = 768
nb = 100000
index = faiss.IndexHNSWFlat(d, 16)
index.add(np.zeros((0, d), dtype='float32')) # 预热(实际不添加向量)
# 实际上 Faiss IndexHNSW 不需要预热,但 IndexIVFPQ 需要先 train 再 add
# 批量添加后一次性执行 optimize()(部分索引类型支持)
# index.optimize() # 仅 IndexFlat 和 IndexIVF 支持
召回率是 ANN 系统最核心的指标。以下是系统性的调参思路:
# ========== 召回率诊断流程 ==========
# Step 1: 建立 Ground Truth(精确 KNN)
D_knn, I_knn = index_flat.search(query_vecs, k=100)
# Step 2: 测试不同配置的召回率
configs = [
{"name": "HNSW efSearch=20", "ef": 20},
{"name": "HNSW efSearch=50", "ef": 50},
{"name": "HNSW efSearch=100", "ef": 100},
{"name": "HNSW efSearch=200", "ef": 200},
{"name": "IVF-PQ nprobe=5", "nprobe": 5},
{"name": "IVF-PQ nprobe=20", "nprobe": 20},
{"name": "IVF-PQ nprobe=100", "nprobe": 100},
]
for cfg in configs:
if "efSearch" in cfg:
index_hnsw.hnsw.efSearch = cfg["ef"]
D, I = index_hnsw.search(query_vecs, k=100)
if "nprobe" in cfg:
index_ivfpq.nprobe = cfg["nprobe"]
D, I = index_ivfpq.search(query_vecs, k=100)
# 计算 Top-10 召回率
recall = sum(len(set(I[i]) & set(I_knn[i])) for i in range(len(I))) / (len(I) * 10)
print(f"{cfg['name']:30s} Top-10 召回率: {recall:.2%}")
# Step 3: 绘制召回率-延迟曲线,找到最优折中点
# 通常 efSearch 从 50 增到 200,召回率提升 < 5%,但延迟翻倍
# 推荐:先以召回率 95% 为目标,找最低 efSearch,再根据延迟决定是否降级
# 输出示例(示意)
HNSW efSearch=20 Top-10 召回率: 78.30%
HNSW efSearch=50 Top-10 召回率: 94.50%
HNSW efSearch=100 Top-10 召回率: 97.80%
HNSW efSearch=200 Top-10 召回率: 98.90%
IVF-PQ nprobe=5 Top-10 召回率: 52.10%
IVF-PQ nprobe=20 Top-10 召回率: 81.40%
IVF-PQ nprobe=100 Top-10 召回率: 94.20%
当单机向量数据超过千万级时,单节点已无法满足 QPS 和存储需求。需要分片。
# ========== 分片策略 ==========
# 策略1:哈希分片(Shard by Hash)
# 按向量 ID 哈希取模,均匀分布到 N 个分片
# 优点:分布均匀;缺点:查询需广播到所有分片
shard_id = hash(doc_id) % num_shards
target_shard = shards[shard_id]
# 策略2:聚类分片(Cluster Sharding)
# 先对全量数据做 K-Means 聚类,每个聚类作为一个分片
# 查询时先定位最近的 1-3 个分片,避免广播全部分片
# 优点:查询只访问相关分片;缺点:数据倾斜时需定期重平衡
# 推荐工具:Faiss 的 Clustering 或 Milvus 的_collection 分区(Partition)
# 策略3:分区键分片(Tenant Sharding,多租户场景)
# 按 tenant_id 哈希分片,不同租户数据物理隔离
# 优点:隔离性好,方便做资源配额;缺点:热点租户可能成为瓶颈
target_shard = tenant_shards[tenant_id % num_shards]
# ========== Qdrant 多分片配置示例 ==========
# qdrant-storage/
# ├── collection_name/
# │ ├── shard_0/ (向量数据)
# │ ├── shard_1/
# │ ├── shard_2/
# │ └── shard_3/
# 部署时指定:replicas=2(双副本)shards=4(四分片)
# ========== 分片数选择建议 ==========
# 分片数 ≈ sqrt(向量总数 / 100万)
# 例如 1000万向量 → 分片数 ≈ sqrt(10) ≈ 3-4 个分片
# 副本数:读多写少 → 2副本;写多读少 → 3副本
| 问题现象 | 可能原因 | 排查/解决方案 |
|---|---|---|
| 插入速度极慢 | HNSW M 值过大;逐条插入 | 改为批量插入;降低 M(从48降到16) |
| 搜索延迟高 | efSearch 过大;未使用近似索引 | 调低 efSearch;确认使用了 HNSW/IVF 而非 Flat |
| 召回率低(<80%) | nprobe/efSearch 过小;PQ 压缩比过高 | 增大搜索宽度;降低 PQ 的 M(减少压缩) |
| 内存占用过高 | HNSW M 值过大;PQ 量化参数不合理 | 降低 M;使用 IVF-PQ 而非纯 HNSW |
| QPS 上不去 | 单分片过载;CPU 瓶颈 | 增加分片数;多副本;升级 CPU 或使用 GPU 加速 |
| 数据倾斜/不均匀 | 哈希分片策略问题 | 换用聚类分片;定期 rebalance |
向量数据库技术链路清晰,从数学原理(距离度量)到算法选型(ANN),再到工程落地(Faiss/Qdrant/混合检索),每一步都有明确的优化方向。
核心技术要点回顾:
bge-base-zh-v1.5;多语言场景 bge-m3 或 OpenAI 3 系列