数学与算法相关问题
如何获得对我的数据的有益分析建议?
如果你想分析一个矩阵的数据分布,可以这样打印矩阵统计信息:
- Python 用法:
MatrixStats(my_matrix).comments
- C++ 用法:
MatrixStats(n, d, my_matrix).comments
这些命令会输出关于矩阵内容的可读性较强的分析,例如:是否包含 NaN(无效数字)?是否存在重复向量?某些维度是否恒定?向量是否已归一化? 如果你在使用 Faiss 时遇到异常行为,建议先运行这个分析。
MatrixStats 是 Faiss 提供的矩阵统计工具,能帮助快速定位数据异常。
为什么在暴力检索大数值向量时会有奇怪的结果?
浮点数计算存在舍入误差,尤其当不同数量级的浮点数相加时会出现“灾难性消除”(catastrophic cancellation)问题。
举两个例子:
-
如果向量的每个分量都很大但彼此相近,且检索时使用了距离分解公式
(如
IndexFlat在查询批大小大于20时,详见这里),那么大数的差异可能在计算过程中被消除,导致严重误差。可参考此示例。解决办法:对向量进行“中心化”处理(减去均值),这样不会影响距离计算结果,但能改善数值稳定性。
-
如果某些分量远大于其他分量,在累计距离时,较小的分量会被大分量“淹没”或消除。见此示例。 这里没有根本的解决办法。幸运的是,这种现象主要在计算彼此距离非常远的向量时出现,对相似性检索影响较小。
关于有限精度计算的理论基础,可以参考 "Matrix computations" (Golub & van Loan, Hopkins Univ Press)第2.7章。
如何分析 IndexIVFPQ 的精度问题?
IndexIVFPQ(又称为 "IVFx,PQy")依赖于向量压缩与倒排表(inverted list),只在数据集中一部分样本上进行距离计算。如果发现 IndexIVFPQ 检索精度过低,可以:
-
将
nprobe(查询倒排表数量)设置为质心总数,强制全量检索,此时剩余误差主要来自 PQ(产品量化)压缩。默认nprobe=1,通常过低。 -
构建一个
IndexIVFFlat(即"IVFx,Flat")替代IndexIVFPQ,看看只用非穷尽检索时损失了多少精度。
通常同时满足(1)(2)时应该能达到100%准确率。如果没有,可能是距离相等导致排序有歧义。
nprobe参数越大,检索准确率越高,但计算速度会变慢。
为什么 Faiss 不支持多层倒排文件(multi-level IVF)检索?
IVFADC 和类似 IVFxx 的索引方法,本质上属于树结构的特例,只有2层且叶子结点含有大量数据。这么设计是因为:
- 在内存中做线性扫描效率极高。
- PQ(产品量化)场景下距离运算可“分解”并提前存表,大大加速。
若扩展为多层倒排结构,虽然搜索速度可能提升,但准确率可能显著下降。Nister & Stewenius 的论文 “Scalable recognition with a vocabulary tree”(CVPR’06)采用的是多层分支做法。
另一种方案是以点之间的图结构为基础,用广度优先遍历(BFS)找最近邻,如 HNSW(分层链式小世界图)方法,这是 Faiss 推荐的候选分区方式,也属于“量化方法”的一种变体。
三层结构(如 IVFPQR)已在论文 "Searching in one billion vectors: re-rank with source coding"(Jegou 等, ICASSP’11)中提出,并被 Faiss 实现。虽然准确率优于 IVFADC,但参数调优较困难。
理论上,最精确的编码方法应使用“非正交码本”(non-orthogonal codebooks),即可用任意向量组合编码。但这种编码速度很慢。更多内容参考 Additive quantizers。
如何复现实验论文中的 PQ(产品量化)结果?
所指论文为 'Product quantization for nearest neighbor search', Jegou, Douze, Schmid, PAMI'11。
具体实现与讨论参见 GitHub Issue #1045。
如何索引二维或三维数据?
Faiss 对低维(如2维、3维)数据支持并不好。这类情景下更推荐使用基于树结构的数据结构(如 kd树、KDTree),能够实现对数时间复杂度的精确检索。常见实现如 Scikit-learn 的 KDTree。
API相关问题
可以在多个线程中同时进行搜索和新增操作吗?
- CPU 版本支持“并发搜索”(即多个线程可同时检索)。
- GPU 版本 不支持 并发搜索。
- 并发“添加”(add/add)或“搜索+添加”(search/add)都不被支持。
- Faiss 不自带加锁机制,需要调用者自己进行线程同步(如加锁)。
详见 Issue #492 及其解决方案。
开发多线程检索/写入时,请确保自行加锁,防止数据异常。
为什么对单个查询向量的检索速度慢?
Faiss 针对“批量检索”做了大量优化,原因有:
-
多数索引结构依赖聚类,查询时需做矩阵-向量或矩阵-矩阵乘法。实际测试表明,“一批向量一起处理”(矩阵-矩阵)远快于逐个处理相同数量的向量(矩阵-向量多次)。
-
Faiss 的并行化设计是以“查询为单位”分配任务。如果以其他方式(如按倒排表分配)效率则会变差。
-
多线程情况下,批量请求能让所有处理器核心都被充分利用。
建议尽量将单个查询“打包成批量”一起检索,利用硬件最大性能。
用 C++ 构建 IndexIVFFlat/IndexIVFPQ/IndexIVFPQR 时需要自己保留 coarse quantizer(粗量化器)引用吗?
-
如果你是在 C++ 手动构造 coarse quantizer,需要自己负责释放资源。要将所有权转移给
IndexIVF,请设置own_fields为 true。 -
如果是用
index_factory、read_index或clone_index等方法来创建索引,则所有子索引对象都属于主索引,无需关心资源释放问题。 -
Python 下则自主管理,无需手动干预所有权。详见 Wiki: Troubleshooting#crashes-in-pure-python-code
如何将索引的构建过程分布到多台机器?
可以按以下方案实现分布式索引构建:
- 用一份具有代表性的数据进行训练,保存训练好的 IndexIVF 类型索引。
- 各节点分别加载此索引,添加本地数据,然后将填充后的新索引保存。
- 在中心节点加载所有填充好的索引,然后合并。C++ 合并示例见 test_merge.cpp。
如果每台机器的数据分布差异很大,可以“各自训练+结果合并”:
- 各节点单独用本地数据训练索引,再把数据添加到索引。
- 保存各节点索引文件。
- 在中心节点将这些索引通过
IndexShards聚合。
如果采用了强压缩(如 PQ),分布式训练合并后,距离难以直接比较,总体精度可能低于“先整体训练再分发”方案,请进行实际评估。
Faiss 支持字符串类型 id 吗?
Faiss 的向量 id 仅支持 64 位整数(int64),不支持字符串及其他类型。这一设计短期内不会改变。讨论见 issue #641。
如需管理字符串 id,可以自行维护"字符串 ↔ int64"映射关系。
对重复向量的检索速度为什么很慢?
IndexIVF 与 IndexHNSW 等索引在检索大量完全相同(或几乎相同)的向量时会效率很低:
IndexIVF会将所有重复向量分配到同一个倒排表,导致单表极度拥挤,查找极慢IndexHNSW见 Issue #1097
对“几乎重复”的向量同样存在类似影响。
解决方法:在建索引前先去重!
Faiss 默认不会自动去重,以免无谓损耗内存和计算。
但你可以使用 IndexFlatDedup 索引自动去重,或通过 MatrixStats 检查数据重复性。
如数据存在大量重复,请务必预处理去重,提高检索性能。
可重现性(Reproducibility)
Faiss 的所有随机种子默认都固定,这意味着同样数据和流程下,两次训练结果通常是逐位相同的。不过,某些高性能场景(如多线程)下无法完全保证这一点,详见 多线程下复现性。
具体设置随机种子的用法见 demo_seeds_ivfpq。
关于训练流程的问题
为什么选择 k-means(K均值聚类)?
Faiss 主要通过 k-means 算法实现量化:
k-means 算法会把向量集 划分到 个质心 ,以最小化均方误差(MSE):
其中,向量到质心的分配为:
k-means 算法流程如下:
-
更新每个质心:将分配给该质心的所有向量的均值作为新的质心
其中
$A_j = \{i=1..k \text{ st. } a_i = j\} \qquad \forall j=1..k$ -
重新分配:根据距离每个质心的最近距离,重新分配每个样本。
该算法包含额外的技巧以避免质心坍缩(即无样本分配给某些质心)。由于每步都能减少 MSE,且方案有限,所以 一定收敛,但不能保证达到全局最优;全局最小化 MSE 属于 NP-难问题。
量化误差选用L2范数(平方距离)是为了保证“中心更新”为最优(即均值)。如果使用其他距离度量则方法不适用,但距离函数的凸性有所帮助。
余弦相似(Cosine similarity)的 k-means
为了便于推导,这里假定向量和质心都已经做了 L2 归一化,余弦相似就变为点积。要使划分后结果尽量贴合原始向量,目标是最大化下式:
k-means 第一步更新质心 的公式为:
第二步重新分配为:
这两个步骤保证了算法收敛到一个局部最优解。在 Faiss 中,将其称为 “normalized k-means” 或 “球面 k-means” 以支持余弦相似度。
可以忽略“WARNING clustering XXX points to YYY centroids(警告:用k均值聚类XXX个点到YYY个质心)” 吗?
当使用 k-means 算法将 个点分为 个簇时,常见几种情况:
-
:此时将直接抛出异常(assertion),因为样本比簇还少,聚类没有意义。
-
当
n < min_points_per_centroid * k时:会出现上述警告。这意味着通常点数太少,无法可靠地估计中心点(质心)。但如果要索引的数据集本身就很小,这种情况是可以接受的。 -
当
n < max_points_per_centroid * k时:属于正常区间,效果较好。 -
当
n > max_points_per_centroid * k时:此时数据点过多,k-means 聚类会变得不必要地慢。这时训练集会被抽样处理。
其中,参数 {min,max}_points_per_centroid(默认分别为39和256)属于 ClusteringParameters 结构体。
你可以修改这些参数来静默警告,例如将 cp.min_points_per_centroid = 1 和 cp.max_points_per_centroid = 10000000。
在 Python 的 KMeans 对象中,这些参数可以在构造函数中直接设置。
对于索引类型,k-means 聚类会被用于 IndexPQ、IndexIVFFlat,以及在 IndexIVFPQ 中被调用两次(一次针对 coarse quantizer,k=ncentroids,一次针对 PQ,k=2^nbits_per_idx),IndexIVFPQR 中则会调用三次。
对应的 ClusteringIndex 在 C++ 下可以通过 IndexPQ::pq::cp 及 IndexIVF::cp 访问,在 Python 下为 index.pq.cp 和 index.cp。
k-means 聚类需要多少训练数据?
通常经验法则建议:超过 20 次迭代和每个聚类中心 k 乘以1000个训练点后,k-means 量化器的表现提升会逐渐减小。当 k 值变大时,实际上可以使用更少的训练点及更少迭代次数。这也是 k-means 聚类函数默认会对向量进行抽样的原因(见上一个问题)。
例如,将 62 亿个向量聚类到 8 万个中心点和,从中随机有偏采样 2048 万个向量进行同样聚类,最终聚类的质量通常差别不大,相当于节省了大量计算(当然,抽样需要无偏,见下一问)。
如何对海量向量实现无偏采样?
如果数据集能够全部加载到内存中,最简单的方式就是不放回地随机采样。
Python 示例:
import numpy as np
rs = np.random.RandomState(123)
idx = rs.choice(nt_sample, size=nt, replace=False)
xt = xt[idx]
在 C++ 中,可以使用 fvecs_maybe_subsample 辅助函数。
如果数据集太大无法全部加载至内存,或者数据以流的形式输入,可以参考 水塘抽样(reservoir sampling)。 以下是 Python 示例实现:reservoir_sampling.ipynb。
可以对内积(inner product)索引使用 k-means 吗?
是的,Faiss 支持在基于内积数据(inner product)的场景中运行 k-means。此时,分配过程会采用“最大内积搜索”,并且聚类中心点会被当前分配给它的向量的均值所更新。
k-means 算法原本是为最小化向量到其分配中心点的欧氏距离平方(L2距离)而设计的。在这种前提下,k-means 的收敛性才能得到保证。若切换为其它度量方式(如内积),则没有显式的损失函数可优化,收敛性也无法保证。
更多讨论见:issue #2363、issue #2466。
k-means 是否可以使用非 L2 距离(分配策略)?
从计算上来说是可以的。但只有在使用平方 L2 距离时,聚类算法的收敛性和最优性质才有理论保证。
一种典型情况是用于内积分配的量化器训练。
实际经验也表明,在每次迭代后将中心点归一化(即设置 C++ Clustering 或 Python KMeans 对象的 spherical 字段为 True)效果更好。
详细讨论参见:issue #2363,以及 Faiss 论文 的第 5.1 节。
如何为 IVF 索引的中心点训练做“热启动”(warm start)?
如果需要适应例如数据分布的漂移,可以参考这里的“热启动”操作:warm_start_ivf_centroids.ipynb。
你也可以参考论文 DEDRIFT: Robust Similarity Search under Content Drift。
用某一类数据训练的索引可以用来索引同维度的其它类型数据吗?
不可以。
训练阶段的主要目标是利用数据(其聚类分布、子空间结构等),优化索引效率。训练时提供的样本必须能够真实反映今后需要索引的数据分布。如果分布显著不同,索引性能(准确率、检索速度)都会下降。
有以下几种常见场景需要重新训练索引:
- 对 CNN 进行再训练后,产生了新的描述子
- 索引的数据类型在统计上发生了显著变化(如,某一类的数据比例从1%变成了90%)
添加和检索数据时,Faiss 仅会检查数据的维度是否正确(且这只在 Python 包装器中实现)。不会自动检测数据分布。
Faiss 支持重新训练(re-training)索引吗?
目前还不支持。
Faiss 索引对象的三种状态如下:
is_trained = false,ntotal = 0,通过train()进入下一状态is_trained = true,ntotal = 0,通过add()进入下一状态is_trained = true,ntotal > 0,通过reset()回到第二状态
由于一个新索引的初始化相当轻量,因此直接新建一个索引会比“反训练/撤销训练”更实用高效。
从 C++ Index 对象中提取信息
你可以参考 这里的代码片段,在 Python 笔记本中直接复制粘贴,以实现一些常用操作。
其他常见问题
为什么在 Python 里会出现“参数数量或类型不对”错误?
这通常发生于向 C++ 函数传递了 numpy 的 int 类型,而对方期望的是标准 int 类型。
例如:
import faiss
import numpy as np
ncent = np.int32(123)
clus = faiss.Clustering(10, ncent)
这样会报错。解决方法是强制转换为 python int:
clus = faiss.Clustering(10, int(ncent))
如何保证检索结果的多样性?
有时,查询到的结果并不理想:比如有 100 个完全相同的向量存在于数据集中,某次检索命中其中一个,其余99个都充满返回列表。Faiss 会这样做是因为它确实找到了最近邻,但实际应用中这样的结果并不友好。
可行方案:
-
尽量避免把完全相同的对象多次加入索引
-
检索时要求结果数量比实际需求多,然后在应用层去重
是否可以根据某些条件动态排除向量参与检索?
Faiss 支持在检索时对部分向量进行过滤,但仅能基于向量 id,具体方法可参考 检索子集元素。
Faiss 主要依赖批量计算距离,在遍历时判断某个 id 是否需要包含到结果里,这样会有处理开销。若需要排除的向量很少,直接多检索一些(增大 k),再在结果里去除无效项即可。若排除逻辑较为简单(如,只有几种属性),可以为每种属性分别建一个索引。
搜索结果出现 -1 的 id 代表什么?
如在 IndexIVF 或 IndexHNSW 结构中,常常不是扫描所有元素。如果查找结果不足,则未填充部分用 -1 表示。
可以通过设置参数增大检索范围,如:
- 对
IndexIVF:ParameterSpace().set_index_parameter(index, 'nprobe', 100) - 对
IndexHNSW:ParameterSpace().set_index_parameter(index, 'efSearch', 100)
nprobe/efSearch 参数权衡速度和精度。
如果 nprobe 设置为与 nlist 一样(inverted list 的数量),此时已变成暴力穷举检索,效率反而不如 Flat index。因此应保证 nprobe 明显小于 nlist 以提升速度。
支持每个 PQ 子向量编码使用 8 位以外的比特数吗?
当前支持情况如下:
ProductQuantizer对象支持 1 到 16 位任意比特数(code size 会向上取整补齐到整数字节,如 PQ3x4 使用2字节);IndexPQ支持同样的比特数范围;IndexIVFPQ也支持(Faiss 1.6.2 之前只支持8位);MultiIndexQuantizer支持每个编码最多16位。
如何设置 IndexShards 或 IndexReplicas 子索引的 nprobe 参数?
nprobe 字段不能直接访问。你可以遍历设置每个子索引,或直接使用 ParameterSpace 对象。
C++ 示例:
IndexShards index_shards = .....;
ParameterSpace().set_index_parameter(index_shards, "nprobe", 123);
Python 示例:
index_shards = faiss.IndexShards(...)
ParameterSpace().set_index_parameter(index_shards, "nprobe", 123)
在 GPU 上,通过 index_cpu_to_gpu* 自动构建 IndexShards 或 IndexReplicas,这时请用 GpuParameterSpace。
在 Python 里,直接设置 index_shards.nprobe = 123 不会报错,但实际上 nprobe 并未生效。因为 Python 可以动态向对象添加属性,并不会作用于底层 C++。
如何设置“不可直访问”(opaque)的 IVF 索引的 nprobe?
有时 IVF 索引由 Python 看到的是 Index 类型(C++ 为 Index*),如 main_index.index 是 IndexPreTransform 的主索引。
此时有两种方式设置 nprobe:
C++ 示例:
auto cpu_index = faiss::read_index(faissindex_file);
auto index_ivf = faiss::ivflib::extract_index_ivf(cpu_index);
index_ivf->nprobe = 123;
或
auto cpu_index = faiss::read_index(faissindex_file);
ParameterSpace().set_index_parameter(cpu_index, "nprobe", 123);
Python 示例:
cpu_index = faiss.read_index(faissindex_file)
index_ivf = faiss.extract_index_ivf(cpu_index)
index_ivf.nprobe = 123
或
cpu_index = faiss.read_index(faissindex_file)
ParameterSpace().set_index_parameter(cpu_index, "nprobe", 123)
如用 GPU 索引,将 ParameterSpace 替换为 GpuParameterSpace。
直接设置 cpu_index.index.nprobe = 123 不会报错,但不会真正影响nprobe,因为 Python 认为 index.index 是通用对象并动态添加字段。这与 SWIG 包装对象的特性有关。
删除索引后,为什么内存占用没有下降?
程序的驻留集大小(RSS,一种内存使用度量)可通过 Faiss 的 get_mem_usage_kb 或 top 等工具获取。删除索引后 RSS 要下降,需满足以下条件:
-
(仅限 Python)索引对象的引用计数必须降至 0,此时对象才会被删除并触发底层 C++ 析构。如果仍有任何引用,则内存不会回收。例如:
a = IndexFlatL2(10); b = a; del a这样并不会真正删除对象。
-
C++ 层的析构函数(destructor)会释放存储空间,并通过
free把内存返还给进程堆。 -
libc 的内存管理器需要再将这部分内存返还给操作系统(通常通过
sbrk或mmap系统调用)。由于内存碎片或操作系统及库实现等原因,有时无法及时回收。详情可见 mallopt 文档。
Python 与 C++ 的回收机制不同,内存释放无法完全预测。若是 GPU 索引,可以使用
import gc
gc.collect()
强制启动一次垃圾回收,以释放 GPU 内存。
为什么调用 faiss.omp_set_num_threads(n) 时,OpenMP 线程数没有被正确设置?
在使用 Faiss 时,很多用户会发现即使调用了 faiss.omp_set_num_threads(n) 设定 OpenMP 线程数,实际生效的线程数可能并不如预期。出现这个问题的原因在于 OpenMP(开放多处理接口)线程数设置是线程本地的,也就是说设置只在当前线程有效。
举个例子:如果你在线程 A 中调用 faiss.omp_set_num_threads(n),但随后实际运行 Faiss 的 OpenMP 代码是在线程 B 中启动的,那么线程 B 实际用到的线程数并不会受到线程 A 的设置影响。
OpenMP 线程数设置在不同线程之间不会自动共享。因此,需要确保设置和实际运行 OpenMP 代码的线程是同一个,否则你设置的线程数量不会生效。
如何正确设置 OpenMP 线程数?
有两种常见的方式可以确保 OpenMP 线程数被正确设置:
-
在实际运行 OpenMP 代码的同一线程中调用
faiss.omp_set_num_threads(n)也就是说,不要在主线程设置后,启动子线程实际执行 Faiss 计算,否则设置会失效。# 在同一个线程中设置并运行
import faiss
faiss.omp_set_num_threads(4)
# 在当前线程调用 Faiss,线程数设定才会生效 -
使用环境变量
OMP_NUM_THREADS(所有线程通用) 你可以在启动 Python 或运行程序前,设置环境变量OMP_NUM_THREADS,这样所有线程都会继承这个设置,不用再每次手动调用faiss.omp_set_num_threads(n)。# 在 Linux 或 macOS 终端设置
export OMP_NUM_THREADS=4
python your_script.py或者在 Windows 命令行:
set OMP_NUM_THREADS=4
python your_script.py
环境变量 OMP_NUM_THREADS 能全局设置 OpenMP 线程数,对于多线程程序很有帮助。如果和 faiss.omp_set_num_threads(n) 同时使用,后者会覆盖前者的设置(仅对调用线程有效)。
注意事项
- 设置线程数时要考虑系统资源和任务实际需求。 设置过多线程可能反而导致系统卡顿或效率下降。
- 环境变量适用于所有用到 OpenMP 的库,不仅仅是 Faiss。
- 如需在多线程环境中明确控制线程数,优先使用环境变量。
如果你发现线程数设置不生效,大概率是因为线程设置与实际运行 OpenMP 代码的线程不一致。尽量将线程数设置和计算放在同一线程,或者直接用环境变量控制全局线程数。