Faiss+ES

38 阅读8分钟

📚 核心概念

向量索引的三个阶段

第一阶段:中心点训练 (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 * 1006.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: 如何提高搜索精度?

方案:

  1. 增加 nprobe(从 4 改为 20)
  2. 增加 nlist(更多聚类中心)
  3. 使用 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