跳至内容

Faiss 向量检索

Faiss 向量索引与相似度检索入门。

  • Faiss
  • 向量检索

Faiss 负责索引和检索向量,原文和类别等信息需要单独保存。 它直接嵌入 Python 或 C++ 程序,提供精确检索、近似检索和向量压缩等能力。Embedding 模型把文本转换成向量,Faiss 根据指定的距离度量寻找近邻。

官网:https://faiss.ai/

1. 向量与文档 ID

向量是一组数字,维度就是这组数字的数量。 语义检索希望意思接近的文本,在向量空间中也比较接近。

用户搜索“忘记密码了”,文档写的是“账号密码重置”。用户输入的“忘记密码了”不包含“重置”一词,但合适的 Embedding 模型仍可能让两句话获得较高的相似度。

文档建索引: 文档批量编码,向量与文档 ID 写入索引,原文单独保存。

代码块 Text
1
文档 → Embedding 模型 → 文档向量 + 文档 ID → Faiss 索引

查询检索: 用户输入单独编码,搜索返回文档 ID 和分数,应用按 ID 读取原文。

代码块 Text
1
查询文本 → Embedding 模型 → 查询向量 → Faiss 搜索 → 文档 ID、分数 → 按 ID 查原文

文档和查询需要使用兼容的模型与编码方式。更换模型后,即使维度相同,旧文档向量也未必能与新查询向量比较,需要重新编码文档。

文档检索示例包含以下数据,名称由应用程序定义:

数据保存位置用途
vectorFaiss 索引512 维文档向量,用于相似度检索
idFaiss 索引和应用字典连接向量与原文的整数 ID
text应用字典原始文本
category应用字典文档类别,用于限定检索范围

索引中的 ID 与原文的对应关系由应用维护。 只保存向量索引,不能恢复原文和类别;只修改原文,也不会自动更新向量。

2. 文档编码

环境为 Python 3.13、CPU,以下命令适用于 Apple Silicon Mac 和 Linux:

代码块 Shell
1
2
3
4
python3 -m venv .venv
source .venv/bin/activate
python -m pip install "faiss-cpu==1.12.0" "sentence-transformers==5.1.2"
export OMP_NUM_THREADS=1

faiss-cpu 是 CPU 安装包,代码中使用 import faiss。1.12.0 在 Apple Silicon Mac 上要求 macOS 14 及以上。

OMP_NUM_THREADS=1 将 OpenMP 线程数设为 1,需在启动 Python 前执行。本次 macOS 验证在默认线程配置下出现搜索进程异常退出,单线程下全部示例运行通过。

模型采用 BAAI/bge-small-zh-v1.5,通过 Sentence Transformers 生成 512 维稠密向量。首次加载会下载模型文件。按模型文档的建议,短查询添加检索指令,文档正文保持原样。

第 2~6 节代码在同一 Python 会话中依次执行,示例包含六条文档:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import numpy as np
from sentence_transformers import SentenceTransformer

docs = [
    {"id": 101, "text": "Faiss 提供向量索引和相似度搜索,原始文档由应用程序管理。", "category": "数据库"},
    {"id": 102, "text": "Milvus 支持在向量搜索中按类别等字段过滤文档。", "category": "数据库"},
    {"id": 103, "text": "MySQL 使用关系表保存数据,通过 SQL 查询和更新记录。", "category": "数据库"},
    {"id": 104, "text": "梯度下降根据损失函数的梯度调整模型参数。", "category": "机器学习"},
    {"id": 105, "text": "文本分类模型为文档预测类别,用于组织搜索结果。", "category": "机器学习"},
    {"id": 106, "text": "Docker Compose 使用配置文件管理多个容器及其数据卷。", "category": "运维"},
]

model = SentenceTransformer("BAAI/bge-small-zh-v1.5", device="cpu")
texts = [doc["text"] for doc in docs]
vectors = np.ascontiguousarray(model.encode(texts, normalize_embeddings=True), dtype=np.float32)
ids = np.array([doc["id"] for doc in docs], dtype=np.int64)
docs_by_id = {doc["id"]: doc.copy() for doc in docs}
print(vectors.shape)
# 输出:(6, 512)

vectors 的每一行对应一篇文档。normalize_embeddings=True 将向量归一化为单位长度,ascontiguousarray 将结果整理为连续存放的 float32 数组。

ids 使用 int64,顺序与向量行一一对应。docs_by_id 按整数 ID 保存原文和类别,后续查询与修改以这个字典为准。

数据量较大时,文档编码和向量添加可分别分批处理。每批编码多少条取决于模型的内存占用,每批添加多少条取决于索引构建时的资源需求,两者不必相同。

3. 索引与度量

3.1 距离度量

距离度量规定向量怎样比较,索引类型规定向量怎样组织和搜索。 IndexFlatL2 和 IndexFlatIP 都逐个比较原始向量,但分数含义不同:

  • L2:欧氏距离。 比较向量在空间中的距离,数值越小越接近。Faiss 返回欧氏距离的平方,不执行开平方。
  • IP:内积。 对应维度相乘后求和,受向量长度和方向影响,数值越大越靠前。
  • 余弦相似度:比较向量方向,取值范围为 -1 到 1,数值越大越相似。

Faiss 的常用做法是把文档和查询都归一化为单位长度,并使用内积索引,此时 IP 分数等于余弦相似度。本文采用 IndexFlatIP,文档编码和查询编码都开启归一化。

归一化会改变原始内积的含义。模型如果依赖向量长度表达信息,不应直接套用余弦检索的设置;度量需要与模型的训练和使用方式对应。

相似度分数不等于答案正确的概率。不同模型的分数分布不同,业务阈值需要根据实际查询和相关文档确定。

3.2 索引类型

Top-K 指按所选度量排序最靠前的 K 条记录。索引的检索方式分为两类:

  • 精确检索:比较原始向量,找出真实的 Top-K。
  • 近似检索(ANN):减少搜索范围或压缩向量,节省计算和内存,但可能漏掉部分近邻。

常见的浮点向量索引如下:

索引检索方式主要取舍
IndexFlatL2 / IndexFlatIP逐个比较原始向量,精确检索无需训练,查询计算量随数据量和维度增长
IndexIVFFlat向量聚成若干组,只搜索选中的组保存原始向量,可能漏掉未访问组中的近邻
IndexHNSWFlat建立多层近邻图,沿连接寻找候选无需训练,添加时需要建图,图结构增加内存
IndexPQ把向量分段编码,比较全部编码压缩向量,需要训练码本,会引入量化误差
IndexIVFPQ在选中的 IVF 分组内比较 PQ 编码同时减少搜索范围和存储量,两部分都可能影响召回

3.3 索引参数与训练

建索引参数决定索引结构,搜索参数控制每次查询的搜索范围:

索引建索引时设置搜索时设置
IVF 系列nlist:聚类分组数nprobe:一次查询访问多少组
HNSWM:节点连接规模;efConstruction:建图时的候选规模efSearch:查询时的候选规模
PQM:分段数;nbits:每段编号的位数IndexIVFPQ 通过 nprobe 控制访问的分组数

Faiss 的 HNSW 搜索参数名是 efSearch,与 Milvus 接口中的 ef 不同。PQ 的 M 表示分段数,与 HNSW 的同名参数含义不同,标准 PQ 要求向量维度能被分段数整除。

3.3.1 IVF(倒排文件)

IVF 把向量聚成若干组,每组有一个中心和一份向量列表。查询向量与组中心比较,搜索只访问选中的几组。IndexIVFFlat 的近似误差来自未访问的组。

以 IndexIVFFlat 为例,nlist=100 表示将向量分成 100 组:

  • nprobe=10:查询访问其中 10 组,减少比较量,可能漏掉其他组中的近邻。
  • nprobe=100:访问全部分组。在没有额外扫描数量限制时,能得到精确结果,也失去了跳过分组的收益。

增大 nprobe 通常能提高召回率,也会增加查询耗时。增大 nlist 会增加聚类训练和中心比较的成本,分组数并非越大越好。

3.3.2 HNSW(分层近邻图)

HNSW 把向量作为节点,在相近节点之间建立连接。搜索沿较稀疏的上层接近目标区域,在底层继续寻找候选。

增大 efSearch 通常能提高召回率,也会增加查询耗时。

3.3.3 PQ(乘积量化)

PQ 把向量分成多段,每段由训练得到的代表向量近似。这些代表向量组成码本,每条向量只需保存各段所选代表向量的编号。

PQ 的 nbits=8 表示每段有 256 个代表向量,编号占 1 字节。

例如,512 维向量分成 64 段,每段 8 维,nbits=8 时编码占 64 字节;原始 float32 向量占 2048 字节。这里只比较向量本身的存储,不包含码本、ID 和其他索引结构。

扫描全部编码,不等于在原始向量上精确检索。 IndexPQ 虽然比较了全部编码,量化误差仍可能改变结果排序。

3.3.4 索引训练

训练索引与训练 Embedding 模型是两件事。 IVF 的训练学习聚类中心,PQ 的训练学习码本,都不会更新模型权重。需要训练的索引按以下顺序使用:

  1. train(training_vectors):从有代表性的向量中学习中心或码本。
  2. add(vectors) 或 add_with_ids(vectors, ids):把待检索向量加入已训练索引,具体接口取决于索引类型。
  3. search(query_vectors, k):在已加入的向量中检索。

train 不会把训练数据自动加入检索库。IndexFlat 和 IndexHNSWFlat 不需要训练;后者仍会在添加向量时建图。HNSW 与 PQ 组合时,PQ 部分需要训练。

3.4 建立索引与添加向量

六条文档使用 IndexFlatIP 逐个比较即可。裸的 Flat 索引采用从 0 开始的顺序编号,IndexIDMap2 为它添加业务 ID 映射:

代码块 Python
1
2
3
4
5
6
import faiss

index = faiss.IndexIDMap2(faiss.IndexFlatIP(vectors.shape[1]))
index.add_with_ids(vectors, ids)
print(index.is_trained, index.ntotal)
# 输出:True 6

vectors.shape[1] 是向量维度 512。is_trained=True 表示索引已具备添加向量的条件;Flat 无需调用 train。ntotal 是已加入的向量数。

add_with_ids 把每行向量与对应 ID 一起存入索引,搜索结果直接返回 101、102 等业务 ID。

ID 的唯一性由应用保证。 重复调用 add_with_ids 会继续添加向量,相同 ID 不会覆盖旧记录,不能作为更新操作使用。

4. 搜索与过滤

4.1 查询编码

查询到来时,只需编码查询文本,已加入索引的文档向量直接复用。这个模型对短查询采用以下检索指令:

代码块 Python
1
2
3
4
5
query = "检索结果只保留指定类别的文档"
query_text = "为这个句子生成表示以用于检索相关文章:" + query
query_vector = np.ascontiguousarray(model.encode([query_text], normalize_embeddings=True), dtype=np.float32)
print(query_vector.shape)
# 输出:(1, 512)

query_vector 有一行,对应一次查询;维度和归一化方式与文档向量一致。检索指令只添加到查询,不添加到文档。

4.2 相似度搜索

search 接收二维查询数组,k=3 表示返回相似度最高的三条记录:

代码块 Python
1
2
3
4
5
scores, result_ids = index.search(query_vector, k=3)
for doc_id, score in zip(result_ids[0], scores[0]):
    if doc_id == -1:  # 候选不足 k 条时,Faiss 用 -1 补齐结果。
        continue
    print(int(doc_id), round(float(score), 4), docs_by_id[int(doc_id)]["text"])

scores 和 result_ids 的形状都是 (1, 3),第一个维度对应查询,第二个维度对应返回名次。-1 不是有效文档 ID。

当前使用 IP,分数按从大到小排列;文档和查询都已归一化,分数就是余弦相似度。一次 CPU 运行的结果如下,分数可能随模型文件和依赖版本变化:

运行结果 Text
1
2
3
# 105 0.5629 文本分类模型为文档预测类别,用于组织搜索结果。
# 102 0.554 Milvus 支持在向量搜索中按类别等字段过滤文档。
# 103 0.5067 MySQL 使用关系表保存数据,通过 SQL 查询和更新记录。

105 讲的是文本分类,得分高于介绍字段过滤的 102。向量上相近的内容未必最能回答问题。 Flat 已经精确计算了内积,替换为另一种精确索引不会自动解决这种语义偏差。模型对中文语料、专业术语的表示能力,以及文档切分方式,都会影响结果。

4.3 类别过滤

Faiss 不直接保存或解释 category 字段。只检索“数据库”类别时,应用选出对应向量,建立子集索引:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
positions = [i for i, doc_id in enumerate(ids) if docs_by_id[int(doc_id)]["category"] == "数据库"]
category_index = faiss.IndexIDMap2(faiss.IndexFlatIP(vectors.shape[1]))
category_index.add_with_ids(vectors[positions], ids[positions])

scores, result_ids = category_index.search(query_vector, k=2)
for doc_id in result_ids[0]:
    if doc_id == -1:
        continue
    doc = docs_by_id[int(doc_id)]
    print(doc["id"], doc["category"], doc["text"])

六条文档中,“数据库”类别的候选是 101、102、103。上述查询返回 102、103;105 属于“机器学习”,没有进入这个索引。

限定类别检索与全量 Top-K 后筛选,结果可能不同:

全量 Top-2 是 105、102,只有 102 属于“数据库”。

  • 全量 Top-2 后筛类别:删掉 105,只剩 102。
  • 在“数据库”类别内取 Top-2:返回 102、103。

103 没进入全量 Top-2,却应该出现在类别 Top-2 中。

每次查询都重建子集索引会增加构建和复制成本。条件固定时可以复用子集索引,文档更新时需要同步维护它。Faiss 的 IDSelector 能按应用提供的 ID 集合限定候选,支持情况与效率取决于索引;类别、时间等字段条件仍由应用转换成 ID 集合。

5. 查询、更新与删除

5.1 按 ID 查询

读取原文不需要计算向量相似度,直接访问应用字典:

代码块 Python
1
2
3
4
5
6
for doc_id in [101, 102]:
    doc = docs_by_id[doc_id]
    print(doc["id"], doc["category"], doc["text"])
# 输出:
# 101 数据库 Faiss 提供向量索引和相似度搜索,原始文档由应用程序管理。
# 102 数据库 Milvus 支持在向量搜索中按类别等字段过滤文档。

5.2 更新记录

只修改类别,不影响文本向量:

代码块 Python
1
2
3
docs_by_id[101]["category"] = "检索工具"
print(docs_by_id[101]["category"])
# 输出:检索工具

如果正文变化,需要重新编码正文,并替换索引中的旧向量。下面更新 101 的正文,保留刚修改的类别:

代码块 Python
1
2
3
4
5
6
7
8
9
updated_doc = {**docs_by_id[101], "text": "Faiss 按向量相似度返回文档 ID,原文和类别由应用自行保存。"}
updated_vector = np.ascontiguousarray(model.encode([updated_doc["text"]], normalize_embeddings=True), dtype=np.float32)
updated_id = np.array([101], dtype=np.int64)

removed = index.remove_ids(updated_id)
index.add_with_ids(updated_vector, updated_id)
docs_by_id[101] = updated_doc
print(removed, index.ntotal)
# 输出:1 6

removed 是删除的向量数。删除旧向量并添加新向量后,总数仍为 6;业务 ID 仍为 101。

这几次调用不是一个原子操作。若更新期间还有其他请求搜索,需要由应用协调,避免读取到向量和原文不对应的中间状态。

5.3 删除记录

删除 106 时,需要同时移除索引中的向量和应用保存的原文:

代码块 Python
1
2
3
4
removed = index.remove_ids(np.array([106], dtype=np.int64))
del docs_by_id[106]
print(removed, index.ntotal, 106 in docs_by_id)
# 输出:1 5 False

1 5 False 表示删除了一个向量,索引剩余五条,应用字典中也已没有 106。

IndexIDMap2 保留其他记录的业务 ID。直接使用顺序编号的 Flat 索引时,删除会让后续向量的编号前移。

删除和更新能力取决于底层索引。 Faiss 的 HNSW 索引不支持删除节点,包装 IndexIDMap2 也不会增加这项能力。IVF 的部分按 ID 访问操作涉及 DirectMap,更换索引类型时需要重新核对。

第 2 节的 docs、vectors、ids 和第 4.3 节的 category_index 仍是更新前的数据。后续保存采用已修改的 index 与 docs_by_id;旧数组或子集索引不能直接当作当前数据继续使用。

6. 本地保存与恢复

6.1 保存索引与原文

write_index 保存索引。原文与类别另存为 JSON,与索引文件成对使用:

代码块 Python
1
2
3
4
5
import json
from pathlib import Path

faiss.write_index(index, "docs.faiss")
Path("docs.json").write_text(json.dumps(list(docs_by_id.values()), ensure_ascii=False, indent=2), encoding="utf-8")
文件保存的内容不包含的内容
docs.faiss本例的 Flat 向量、索引结构、业务 ID 映射原文、类别、Embedding 模型
docs.json当前五条文档的 ID、原文和类别向量与索引结构

修改内存中的数据不会自动覆盖这些文件。需要保留修改时,重新保存索引与对应原文;不能把旧 JSON 与新索引混用。两个文件分开写入没有事务保证,持续写入的服务需要安排一致的保存时机或版本切换。

本例使用 CPU 索引,可直接保存。GPU 索引需要转换为 CPU 索引才能写入文件。read_index 不验证文件内容,只读取自己生成或已验证来源的索引文件。索引读写说明

6.2 加载已有数据

下面的代码可在新 Python 会话中独立执行,工作目录中需有刚保存的 docs.faiss 和 docs.json:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import json
from pathlib import Path

import faiss

loaded_index = faiss.read_index("docs.faiss")
loaded_docs = json.loads(Path("docs.json").read_text(encoding="utf-8"))
loaded_docs_by_id = {doc["id"]: doc for doc in loaded_docs}
print(loaded_index.ntotal, loaded_docs_by_id[101]["category"], 106 in loaded_docs_by_id)
# 输出:5 检索工具 False

loaded_index.search(query_vector, k=3) 可继续检索,不需要重新添加文档向量。新会话还没有查询向量,需执行第 2 节的 NumPy、Sentence Transformer 导入和模型初始化,以及第 4.1 节的查询编码;返回 ID 对应的原文从 loaded_docs_by_id 读取。

编码方式应随索引一起记录,包括模型名称与版本、维度、是否归一化,以及查询指令。Faiss 文件保留索引的维度和度量,但不会替应用记录完整的文本编码方式。

7. 索引选择与评估

7.1 召回率与查询成本

Flat 的精确结果可作为基准。近邻检索的 Recall@K 表示:返回的 K 条记录中,有多少属于精确检索的 Top-K,占 K 的比例。

精确 Top-5 是 A、B、C、D、E,近似检索返回 A、B、C、F、G。

Recall@5 就是 3/5=60%3/5=60\%。达到 100% 只说明找齐了向量空间中的近邻,文档能否回答问题仍需单独评价。

不同索引的比较应使用相同文档向量、查询向量、度量和 K。根据实际瓶颈选择候选方案:

  • Flat 已满足延迟要求:保留 Flat,减少训练、建图和调参成本。
  • 查询太慢,内存充足:比较 IVF、HNSW 在目标召回率下的查询耗时。
  • 原始向量占用过大:比较 PQ 等压缩方式带来的内存节省与召回损失。

测量时需要包含索引训练、构建、数据更新的成本,并记录 CPU 或 GPU、线程数、查询批量大小。查询次数很少时,复杂索引节省的搜索时间未必能抵消构建开销。

7.2 内存与应用管理

Flat 中的 float32 向量每个维度占 4 字节。100 万条 512 维向量的原始数据约占 2.048 GB,另有 ID 映射、原文、模型和程序内存。add_with_ids 会把向量保存到索引中,如果应用仍保留原来的 vectors 数组,两份存储都需要计入。

索引文件支持保存和恢复搜索结构;网络接口、业务字段过滤、多个客户端的写入协调仍由应用处理。CPU 索引支持并发只读搜索,但同一索引发生修改时需要调用方协调读写。

评论

使用 GitHub 登录后参与讨论,评论将公开保存在 GitHub。

阅读到这里时加载评论。