📚 核心概念
向量索引的三个阶段
第一阶段:中心点训练 (VectorTrainProcessor)
↓
第二阶段:索引构建 (VectorConstructIndexProcessor)
↓
第三阶段:索引加载 (VectorOnlinePrimaryProcessor)
↓
✅ 向量搜索服务就绪
搜索流程
Query Vector → Faiss 中心点搜索 → 获取 ID → ES 查询 → 返回完整文档
🔧 核心参数配置
Faiss IVF_PQ 索引参数
# 向量维度
dim = 16
# IVF 聚类中心数(影响搜索速度和精度)
# 建议:nlist = sqrt(n_vectors) 到 4*sqrt(n_vectors), 例如:10000 个向量 → nlist = 100-400
nlist = 10
# PQ 子向量数量(dim 必须能被 M 整除)
# dim=16, M=4 → 每个向量分成 4 个子向量,每个 4 维
M = 4
# 每个子向量的编码位数(通常为 8)
# 8 bits = 256 个量化值
nbits = 8
# 搜索时探测的聚类中心数(query向量距离最近的nprobe个聚类中心点)
nprobe = 10
索引大小估算
# Flat 索引大小
flat_size = n_vectors * dim * 4 / (1024 * 1024) # MB
# IVF_PQ 索引大小(估算)
ivfpq_size = (nlist * dim * 4 + n_vectors * M * nbits / 8) / (1024 * 1024) # MB
# 压缩率
compression_ratio = ivfpq_size / flat_size * 100 # %
例子:10000 个 16 维向量
# 参数
n_vectors = 10000
dim = 16
nlist = 10
M = 4
nbits = 8
# 大小计算
flat_size = 10000 * 16 * 4 / (1024 * 1024) ≈ 0.61 MB
ivfpq_size = (10 * 16 * 4 + 10000 * 4 * 8 / 8) / (1024 * 1024) ≈ 0.04 MB
compression_ratio = 0.04 / 0.61 * 100 ≈ 6.5%
💻 核心代码
1️⃣ 第一阶段:中心点训练
import faiss
import numpy as np
import os
# 参数
dim = 16
nlist = 10
M = 4
nbits = 8
n_samples = 10000
# 生成向量数据(实际应从 Hive 下载)
print("1️⃣ 从 Hive 表下载数据...")
vectors = np.random.random((n_samples, dim)).astype('float32')
print(f" ✅ 下载完成: {n_samples} 个向量")
# 创建 Faiss IVF_PQ 索引
print("\n2️⃣ 创建 Faiss IVF_PQ 索引...")
print(f" 参数: nlist={nlist}, M={M}, nbits={nbits}")
quantizer = faiss.IndexFlatL2(dim)
index = faiss.IndexIVFPQ(
quantizer, # 粗量化器
dim, # 向量维度
nlist, # 聚类中心数
M, # 子向量数量
nbits # 编码位数
)
print(f" ✅ 索引创建完成")
# 训练中心点
print("\n3️⃣ 训练中心点...")
index.train(vectors)
print(f" ✅ 训练完成")
# 添加向量
print("\n4️⃣ 添加向量到索引...")
index.add(vectors)
print(f" ✅ 已添加 {index.ntotal} 个向量")
# 设置搜索参数
index.nprobe = 10
# 保存中心点
print("\n5️⃣ 保存中心点文件...")
os.makedirs("./data", exist_ok=True)
centroid_file = "./data/vector_search_centroid.faiss"
faiss.write_index(index, centroid_file)
print(f" ✅ 中心点已保存: {centroid_file}")
print(f"\n✅ 第一阶段完成!")
2️⃣ 第二阶段:索引构建
import requests
import json
# 创建 ES 索引
print("\n1️⃣ 创建 ES 索引...")
mapping = {
"mappings": {
"properties": {
"id": {"type": "integer"},
"title": {"type": "text"},
"content": {"type": "text"},
"category": {"type": "keyword"},
"score": {"type": "float"},
"vector": {
"type": "dense_vector",
"dims": 16,
"index": False
}
}
},
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
}
}
index_name = "vector_search_20251222"
response = requests.put(
f"http://localhost:9200/{index_name}",
json=mapping,
headers={"Content-Type": "application/json"}
)
print(f" ✅ ES 索引创建成功: {index_name}")
# 批量写入数据
print(f"\n2️⃣ 写入数据到 ES...")
bulk_data = ""
for i in range(10000):
bulk_data += json.dumps({
"index": {
"_index": index_name,
"_id": str(i)
}
}) + "\n"
doc = {
"id": i,
"title": f"Document {i}",
"content": f"Content of document {i}",
"category": f"category_{i % 10}",
"score": float(np.random.random()),
"vector": vectors[i].tolist()
}
bulk_data += json.dumps(doc) + "\n"
response = requests.post(
"http://localhost:9200/_bulk",
data=bulk_data,
headers={"Content-Type": "application/json"}
)
print(f" ✅ 成功写入 10000 个文档")
# 等待 merge 完成
print(f"\n3️⃣ 等待索引 merge 完成...")
requests.post(
f"http://localhost:9200/{index_name}/_forcemerge?max_num_segments=1"
)
print(f" ✅ Merge 完成")
# 刷新索引
print(f"\n4️⃣ 刷新索引...")
requests.post(f"http://localhost:9200/{index_name}/_refresh")
print(f" ✅ 索引已刷新")
# 索引校验
print(f"\n5️⃣ 执行索引校验...")
response = requests.get(f"http://localhost:9200/{index_name}/_stats")
doc_count = response.json()['indices'][index_name]['primaries']['docs']['count']
print(f" ✅ 索引校验通过: {doc_count} 个文档")
# 创建快照
print(f"\n6️⃣ 创建 ES 快照...")
snapshot_repo_path = os.path.expanduser("~/es_snapshots")
os.makedirs(snapshot_repo_path, exist_ok=True)
# 注册快照仓库
repo_body = {
"type": "fs",
"settings": {
"location": snapshot_repo_path
}
}
requests.put(
"http://localhost:9200/_snapshot/my_backup",
json=repo_body,
headers={"Content-Type": "application/json"}
)
print(f" ✅ 快照仓库注册成功")
# 创建快照
snapshot_name = f"{index_name}_snapshot"
snapshot_body = {
"indices": index_name,
"include_global_state": False
}
requests.put(
f"http://localhost:9200/_snapshot/my_backup/{snapshot_name}",
json=snapshot_body,
headers={"Content-Type": "application/json"}
)
print(f" ✅ 快照创建成功: {snapshot_name}")
print(f"\n✅ 第二阶段完成!")
3️⃣ 第三阶段:索引加载
# 加载中心点
print("\n1️⃣ 从本地加载中心点...")
loaded_index = faiss.read_index(centroid_file)
print(f" ✅ 中心点加载成功")
print(f" 📊 索引信息:")
print(f" - 向量总数: {loaded_index.ntotal}")
print(f" - 向量维度: {dim}")
# 验证 ES 索引
print(f"\n2️⃣ 验证 ES 索引...")
response = requests.get(f"http://localhost:9200/{index_name}/_stats")
doc_count = response.json()['indices'][index_name]['primaries']['docs']['count']
print(f" ✅ ES 索引验证成功")
print(f" 📊 ES 索引信息:")
print(f" - 文档总数: {doc_count}")
# 执行搜索测试
print(f"\n3️⃣ 执行搜索测试...")
query_vector = np.random.random((1, dim)).astype('float32')
# 第一步:Faiss 中心点搜索
print(f" 📝 Faiss 搜索: 用中心点快速定位候选向量")
distances, indices = loaded_index.search(query_vector, k=5)
print(f" ✅ Faiss 搜索完成")
print(f" - Top-5 文档 ID: {indices[0]}")
print(f" - 距离: {distances[0]}")
# 第二步:从 ES 获取完整文档
print(f"\n4️⃣ 从 ES 获取完整文档...")
doc_ids = [str(idx) for idx in indices[0]]
query_body = {
"size": 5,
"query": {
"terms": {
"_id": doc_ids
}
},
"_source": ["id", "title", "category", "score"]
}
response = requests.post(
f"http://localhost:9200/{index_name}/_search",
json=query_body,
headers={"Content-Type": "application/json"}
)
hits = response.json()['hits']['hits']
print(f" ✅ ES 查询成功,获取 {len(hits)} 个文档")
# 打印结果
print(f"\n 📄 搜索结果:")
for i, hit in enumerate(hits):
doc = hit['_source']
print(f"\n 排名 {i+1}:")
print(f" - 文档 ID: {hit['_id']}")
print(f" - 标题: {doc['title']}")
print(f" - 分类: {doc['category']}")
print(f" - 评分: {doc['score']:.4f}")
print(f" - 距离: {distances[0][i]:.4f}")
# 更新别名
print(f"\n5️⃣ 更新索引别名...")
actions = [
{
"add": {
"index": index_name,
"alias": "vector_search"
}
}
]
requests.post(
"http://localhost:9200/_aliases",
json={"actions": actions},
headers={"Content-Type": "application/json"}
)
print(f" ✅ 别名更新完成")
print(f"\n✅ 第三阶段完成!")
4️⃣ 快照恢复
# 删除索引(模拟灾难)
print("\n1️⃣ 删除索引...")
requests.delete(f"http://localhost:9200/{index_name}")
print(f" 🗑️ 索引已删除")
# 从快照恢复
print(f"\n2️⃣ 从快照恢复...")
restore_body = {
"indices": index_name
}
response = requests.post(
f"http://localhost:9200/_snapshot/my_backup/{snapshot_name}/_restore",
json=restore_body,
headers={"Content-Type": "application/json"}
)
print(f" ✅ 快照恢复成功")
# 验证恢复
print(f"\n3️⃣ 验证恢复...")
import time
time.sleep(2) # 等待恢复完成
response = requests.get(f"http://localhost:9200/{index_name}/_stats")
doc_count = response.json()['indices'][index_name]['primaries']['docs']['count']
print(f" ✅ 恢复验证: {doc_count} 个文档")
print(f"\n✅ 快照恢复完成!")
📊 性能测试
import time
# 测试 Faiss 搜索速度
print("\n性能测试")
print("=" * 80)
n_queries = 100
start_time = time.time()
for _ in range(n_queries):
query_vector = np.random.random((1, dim)).astype('float32')
distances, indices = loaded_index.search(query_vector, k=10)
elapsed = time.time() - start_time
print(f"\n1️⃣ Faiss 搜索性能测试...")
print(f" ✅ {n_queries} 次搜索耗时: {elapsed:.4f} 秒")
print(f" 📊 平均每次: {elapsed/n_queries*1000:.2f} ms")
print(f" 📊 吞吐量: {n_queries/elapsed:.0f} QPS")
预期结果:
1️⃣ Faiss 搜索性能测试...
✅ 100 次搜索耗时: 0.0234 秒
📊 平均每次: 0.23 ms
📊 吞吐量: 4274 QPS
🔑 关键要点
中心点的作用
中心点 = Faiss IVF 索引的聚类中心
作用:
1. 加速向量搜索 - 通过聚类快速定位候选向量
2. 减少计算量 - 不需要和所有向量比较
3. 内存优化 - 存储中心点而不是所有向量
快照的作用
快照 = 索引的完整备份
作用:
1. 灾难恢复 - 索引损坏时快速恢复
2. 版本管理 - 保存不同时间点的索引
3. 跨集群迁移 - 将快照恢复到其他 ES 集群
两步搜索流程
第一步:Faiss 中心点搜索
- 输入:查询向量
- 输出:Top-K 文档 ID 和距离
- 优势:快速、内存高效
第二步:ES 查询
- 输入:文档 ID 列表
- 输出:完整文档信息
- 优势:获取所有字段、支持复杂过滤
🚀 快速开始
# 1. 安装依赖
pip install faiss-cpu numpy requests
# 2. 启动 Elasticsearch
docker run -d -p 9200:9200 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:7.14.0
# 3. 运行 Demo
cd /Users/ray/vs_code_files/hello_world_python/faiss_v2
python faiss_es_complete.py
📝 常见问题
Q: 向量维度不匹配怎么办?
问题: RuntimeError: dim must be divisible by M
解决: 确保 dim % M == 0
# ❌ 错误
dim = 100
M = 32 # 100 % 32 = 4,不能整除
# ✅ 正确
dim = 128 # 128 % 32 = 0
M = 32
Q: 如何选择 nlist?
建议: nlist = sqrt(n_vectors) 到 4 * sqrt(n_vectors)
import math
n_vectors = 100000
nlist_min = int(math.sqrt(n_vectors)) # 316
nlist_max = int(4 * math.sqrt(n_vectors)) # 1265
# 建议选择 256-1024 之间的值
nlist = 512
Q: 如何提高搜索精度?
方案:
- 增加
nprobe(从 4 改为 20) - 增加
nlist(更多聚类中心) - 使用 Flat 索引进行精确搜索
# 精确搜索
flat_index = faiss.IndexFlatL2(dim)
flat_index.add(vectors)
distances, indices = flat_index.search(query_vector, k=10)
Q: 快照仓库配置失败怎么办?
问题: 500 错误或 404 错误
解决: 在 elasticsearch.yml 中配置快照路径
path.repo: ["/Users/ray/es_snapshots"]
然后重启 Elasticsearch。
Q: ES 连接失败怎么办?
问题: ConnectionError: Connection refused
解决:
# 检查 ES 是否运行
curl http://localhost:9200
# 启动 ES
docker run -d -p 9200:9200 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:7.14.0
📚 文件结构
faiss_v2/
├── 00_START_HERE.md # 快速开始指南
├── VECTOR_INDEX_GUIDE.md # 本文档
├── faiss_es_complete.py # 完整 Demo 程序
├── data/
│ ├── vector_search_centroid.faiss
│ └── vector_search_*_snapshot.json
└── README.md