Files
faiss/tests/test_contrib.py
kzahiri1 b4c66ba603 Fix reverse_index_factory/get_code_size round-trip for HNSW, IMI and IVFPQR (#5564)
Summary:
Three defects in `contrib/factory_tools.py` where a reversed factory string rebuilds a different index or fails to parse. `test_get_code_size_hnsw_roundtrip` already pins this invariant for flat HNSW; these cases escape it.

1. **HNSW storage dropped.** The `IndexHNSW` branch never read `index.storage`, so `HNSW32,SQ8` and `HNSW32,PQ16x8` both reversed to `"HNSW32"` and rebuilt as `IndexHNSWFlat`. `get_code_size` then applied the flat `d*4` formula: at d=128 it reported 768 bytes/vector for an index whose storage is 128 plus the 256-byte graph estimate, so 384.

2. **IMI regex fields transposed.** `index_factory.cpp:295` parses `IMI2x([0-9]+)`, but the regex read `IMI\d+x2`. Every real IMI key (`IMI2x10,PQ16`, and the ones in `demos/demo_auto_tune.py`) raised `RuntimeError("cannot parse")`.

3. **IndexIVFPQR matched by the IndexIVFPQ check**, dropping the refinement stage: `IVF64,PQ8+16` reversed to `IVF64,PQ8x8`, 8 bytes instead of 24.

Consumers are `benchs/bench_all_ivf/parse_bench_all_ivf.py` (imports `get_code_size` as `unitsize`) and `benchs/bench_fw/index.py`.

Flat storage stays implicit as the bare `HNSW<M>` string, and a null `storage` pointer still returns `HNSW<M>` instead of raising.

**Behavior change worth flagging:** `IndexHNSW2Level` storage is `Index2Layer`, which `reverse_index_factory` does not handle, so it now raises `NotImplementedError` instead of silently returning a wrong `"HNSW32"`. It is not reachable from `index_factory`.

Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5564

Test Plan:
Four tests added to `TestFactoryTools`. All four fail on unpatched `factory_tools.py` and pass with it:

```
# pristine contrib/factory_tools.py, new tests present
4 failed, 7 passed
FAILED test_get_code_size_hnsw_non_flat_storage
FAILED test_get_code_size_imi
FAILED test_hnsw_storage_reverse_index_factory
FAILED test_ivfpqr_reverse_index_factory

# with the fix
11 passed
```

Full `tests/test_contrib.py tests/test_factory.py`, pristine vs branch:

```
pristine: 2 failed, 88 passed
branch:   2 failed, 92 passed
```

Failure sets are identical. The two are `TestComputeGT::test_compute_GT_gpu` and `test_compute_GT_ip_gpu`, pre-existing and unrelated (`AttributeError: module 'faiss' has no attribute 'GpuMultipleClonerOptions'` on a CPU-only build).

An A/B sweep over 40 factory strings changes exactly the 7 expected lines and nothing else:

```
- HNSW32,SQ8      IndexHNSWSQ   HNSW32        768
+ HNSW32,SQ8      IndexHNSWSQ   HNSW32,SQ8    384
- HNSW32,SQfp16   IndexHNSWSQ   HNSW32        768
+ HNSW32,SQfp16   IndexHNSWSQ   HNSW32,SQfp16 512
- HNSW32,PQ16x8   IndexHNSWPQ   HNSW32        768
+ HNSW32,PQ16x8   IndexHNSWPQ   HNSW32,PQ16x8 272
- IVF64,PQ8+16    IndexIVFPQR   IVF64,PQ8x8   8
+ IVF64,PQ8+16    IndexIVFPQR   IVF64,PQ8+16  24
- IMI2x5,Flat     IndexIVFFlat  IMI2x5,Flat   <RuntimeError>
+ IMI2x5,Flat     IndexIVFFlat  IMI2x5,Flat   512
- IMI2x5,PQ8      IndexIVFPQ    IMI2x5,PQ8x8  <RuntimeError>
+ IMI2x5,PQ8      IndexIVFPQ    IMI2x5,PQ8x8  8
- IMI2x8,PQ8x8    IndexIVFPQ    IMI2x8,PQ8x8  <RuntimeError>
+ IMI2x8,PQ8x8    IndexIVFPQ    IMI2x8,PQ8x8  8
```

flake8 at the 80-char limit from CONTRIBUTING reports the same 12 pre-existing findings before and after, none in the changed lines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed By: mnorris11

Differential Revision: D118661065

Pulled By: trang-nm-nguyen

fbshipit-source-id: b50a9522361e3fd14f276b36196d3c728567b980
2026-09-04 11:14:06 -07:00

1090 lines
38 KiB
Python

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import os
import platform
import shutil
import tempfile
import unittest
from contextlib import contextmanager
import faiss
import numpy as np
from common_faiss_tests import get_dataset_2
from faiss.contrib import (
big_batch_search,
clustering,
datasets,
evaluation,
factory_tools,
inspect_tools,
ivf_tools,
)
from faiss.contrib.exhaustive_search import (
exponential_query_iterator,
knn,
knn_ground_truth,
range_ground_truth,
range_search_max_results,
)
from faiss.contrib.ondisk import merge_ondisk
class TestComputeGT(unittest.TestCase):
def do_test_compute_GT(self, metric=faiss.METRIC_L2, ngpu=0):
d = 64
xt, xb, xq = get_dataset_2(d, 0, 10000, 100)
index = faiss.IndexFlat(d, metric)
index.add(xb)
Dref, Iref = index.search(xq, 10)
# iterator function on the matrix
def matrix_iterator(xb, bs):
for i0 in range(0, xb.shape[0], bs):
yield xb[i0 : i0 + bs]
Dnew, Inew = knn_ground_truth(
xq, matrix_iterator(xb, 1000), 10, metric, ngpu=ngpu
)
np.testing.assert_array_equal(Iref, Inew)
# decimal = 4 required when run on GPU
np.testing.assert_almost_equal(Dref, Dnew, decimal=4)
def test_compute_GT(self):
self.do_test_compute_GT()
def test_compute_GT_ip(self):
self.do_test_compute_GT(faiss.METRIC_INNER_PRODUCT)
def test_compute_GT_gpu(self):
self.do_test_compute_GT(ngpu=-1)
def test_compute_GT_ip_gpu(self):
self.do_test_compute_GT(faiss.METRIC_INNER_PRODUCT, ngpu=-1)
class TestDatasets(unittest.TestCase):
"""here we test only the synthetic dataset. Datasets that require
disk or manifold access are in
//deeplearning/projects/faiss-forge/test_faiss_datasets/:test_faiss_datasets
"""
def test_synthetic(self):
ds = datasets.SyntheticDataset(32, 1000, 2000, 10)
xq = ds.get_queries()
self.assertEqual(xq.shape, (10, 32))
xb = ds.get_database()
self.assertEqual(xb.shape, (2000, 32))
ds.check_sizes()
def test_synthetic_ip(self):
ds = datasets.SyntheticDataset(32, 1000, 2000, 10, "IP")
index = faiss.IndexFlatIP(32)
index.add(ds.get_database())
np.testing.assert_array_equal(
ds.get_groundtruth(100), index.search(ds.get_queries(), 100)[1]
)
def test_synthetic_iterator(self):
ds = datasets.SyntheticDataset(32, 1000, 2000, 10)
xb = ds.get_database()
xb2 = []
for xbi in ds.database_iterator():
xb2.append(xbi)
xb2 = np.vstack(xb2)
np.testing.assert_array_equal(xb, xb2)
class TestExhaustiveSearch(unittest.TestCase):
def test_knn_cpu(self):
xb = np.random.rand(200, 32).astype("float32")
xq = np.random.rand(100, 32).astype("float32")
index = faiss.IndexFlatL2(32)
index.add(xb)
Dref, Iref = index.search(xq, 10)
Dnew, Inew = knn(xq, xb, 10)
assert np.all(Inew == Iref)
assert np.allclose(Dref, Dnew)
index = faiss.IndexFlatIP(32)
index.add(xb)
Dref, Iref = index.search(xq, 10)
Dnew, Inew = knn(xq, xb, 10, metric=faiss.METRIC_INNER_PRODUCT)
assert np.all(Inew == Iref)
assert np.allclose(Dref, Dnew)
def do_test_range(self, metric):
ds = datasets.SyntheticDataset(32, 0, 1000, 10)
xq = ds.get_queries()
xb = ds.get_database()
D, I = faiss.knn(xq, xb, 10, metric=metric)
threshold = float(D[:, -1].mean())
index = faiss.IndexFlat(32, metric)
index.add(xb)
ref_lims, ref_D, ref_I = index.range_search(xq, threshold)
new_lims, new_D, new_I = range_ground_truth(
xq,
ds.database_iterator(bs=100),
threshold,
ngpu=0,
metric_type=metric,
)
evaluation.check_ref_range_results(
ref_lims, ref_D, ref_I, new_lims, new_D, new_I
)
def test_range_L2(self):
self.do_test_range(faiss.METRIC_L2)
def test_range_IP(self):
self.do_test_range(faiss.METRIC_INNER_PRODUCT)
def test_query_iterator(self, metric=faiss.METRIC_L2):
ds = datasets.SyntheticDataset(32, 0, 1000, 1000)
xq = ds.get_queries()
xb = ds.get_database()
D, I = faiss.knn(xq, xb, 10, metric=metric)
threshold = float(D[:, -1].mean())
index = faiss.IndexFlat(32, metric)
index.add(xb)
ref_lims, ref_D, ref_I = index.range_search(xq, threshold)
def matrix_iterator(xb, bs):
for i0 in range(0, xb.shape[0], bs):
yield xb[i0 : i0 + bs]
# check repro OK
_, new_lims, new_D, new_I = range_search_max_results(
index, matrix_iterator(xq, 100), threshold, max_results=1e10
)
evaluation.check_ref_range_results(
ref_lims, ref_D, ref_I, new_lims, new_D, new_I
)
max_res = ref_lims[-1] // 2
new_threshold, new_lims, new_D, new_I = range_search_max_results(
index, matrix_iterator(xq, 100), threshold, max_results=max_res
)
self.assertLessEqual(new_lims[-1], max_res)
ref_lims, ref_D, ref_I = index.range_search(xq, new_threshold)
evaluation.check_ref_range_results(
ref_lims, ref_D, ref_I, new_lims, new_D, new_I
)
def test_query_iterator_similarity_metric(self):
"""range_search_max_results must keep the highest-scoring results
(and raise the radius) for any similarity metric, not just
METRIC_INNER_PRODUCT. Regression test for a bug where pruning
for METRIC_Jaccard (also a similarity metric, see
faiss.is_similarity_metric) kept the lowest-scoring results
instead of the highest-scoring ones."""
class FakeSimilarityIndex:
"""Stands in for an Index using a similarity metric (higher
score = better match), exposing only what
range_search_max_results touches."""
def __init__(self, sims, metric_type):
self.sims = sims
self.metric_type = metric_type
def range_search(self, xq, radius):
lims = [0]
D = []
I = []
for i in range(len(xq)):
s = self.sims[i]
idx = np.nonzero(s > radius)[0]
D.append(s[idx])
I.append(idx.astype("int64"))
lims.append(lims[-1] + len(idx))
return (
np.array(lims, dtype="uint64"),
np.hstack(D).astype("float32"),
np.hstack(I).astype("int64"),
)
rs = np.random.RandomState(1)
nq, ndb = 5, 200
sims = [rs.rand(ndb).astype("float32") for _ in range(nq)]
index = FakeSimilarityIndex(sims, faiss.METRIC_Jaccard)
xq = np.zeros((nq, 1), dtype="float32") # unused by the fake index
radius = 0.0 # keep everything initially
total = sum(len(s) for s in sims)
max_results = total // 2
new_radius, lims, D, I = range_search_max_results(
index, iter([xq]), radius, max_results=max_results
)
self.assertGreaterEqual(new_radius, radius)
self.assertGreater(len(D), 0)
self.assertGreaterEqual(D.min(), new_radius - 1e-6)
class TestInspect(unittest.TestCase):
def test_LinearTransform(self):
# training data
xt = np.random.rand(1000, 20).astype("float32")
# test data
x = np.random.rand(10, 20).astype("float32")
# make the PCA matrix
pca = faiss.PCAMatrix(20, 10)
pca.train(xt)
# apply it to test data
yref = pca.apply_py(x)
A, b = inspect_tools.get_LinearTransform_matrix(pca)
# verify
ynew = x @ A.T + b
np.testing.assert_array_almost_equal(yref, ynew)
def test_IndexFlat(self):
xb = np.random.rand(13, 20).astype("float32")
index = faiss.IndexFlatL2(20)
index.add(xb)
np.testing.assert_array_equal(xb, inspect_tools.get_flat_data(index))
def test_make_LT(self):
rs = np.random.RandomState(123)
X = rs.rand(13, 20).astype("float32")
A = rs.rand(5, 20).astype("float32")
b = rs.rand(5).astype("float32")
Yref = X @ A.T + b
lt = inspect_tools.make_LinearTransform_matrix(A, b)
Ynew = lt.apply(X)
np.testing.assert_allclose(Yref, Ynew, rtol=1e-06)
def test_NSG_neighbors(self):
# FIXME number of elements to add should be >> 100
ds = datasets.SyntheticDataset(32, 0, 200, 10)
index = faiss.index_factory(ds.d, "NSG")
index.add(ds.get_database())
neighbors = inspect_tools.get_NSG_neighbors(index.nsg)
# neighbors should be either valid indexes or -1
np.testing.assert_array_less(-2, neighbors)
np.testing.assert_array_less(neighbors, ds.nb)
class TestRangeEval(unittest.TestCase):
def test_precision_recall(self):
Iref = [[1, 2, 3], [5, 6], [], []]
Inew = [[1, 2], [6, 7], [1], []]
lims_ref = np.cumsum([0] + [len(x) for x in Iref])
Iref = np.hstack(Iref)
lims_new = np.cumsum([0] + [len(x) for x in Inew])
Inew = np.hstack(Inew)
precision, recall = evaluation.range_PR(lims_ref, Iref, lims_new, Inew)
self.assertEqual(precision, 0.6)
self.assertEqual(recall, 0.6)
def test_PR_multiple(self):
metric = faiss.METRIC_L2
ds = datasets.SyntheticDataset(32, 1000, 1000, 10)
xq = ds.get_queries()
xb = ds.get_database()
# good for ~10k results
threshold = 15
index = faiss.IndexFlat(32, metric)
index.add(xb)
ref_lims, ref_D, ref_I = index.range_search(xq, threshold)
# now make a slightly suboptimal index
index2 = faiss.index_factory(32, "PCA16,Flat")
index2.train(ds.get_train())
index2.add(xb)
# PCA reduces distances so will have more results
new_lims, new_D, new_I = index2.range_search(xq, threshold)
all_thr = np.array([5.0, 10.0, 12.0, 15.0])
for mode in "overall", "average":
ref_precisions = np.zeros_like(all_thr)
ref_recalls = np.zeros_like(all_thr)
for i, thr in enumerate(all_thr):
lims2, _, I2 = evaluation.filter_range_results(
new_lims, new_D, new_I, thr
)
prec, recall = evaluation.range_PR(
ref_lims, ref_I, lims2, I2, mode=mode
)
ref_precisions[i] = prec
ref_recalls[i] = recall
precisions, recalls = evaluation.range_PR_multiple_thresholds(
ref_lims, ref_I, new_lims, new_D, new_I, all_thr, mode=mode
)
np.testing.assert_array_almost_equal(ref_precisions, precisions)
np.testing.assert_array_almost_equal(ref_recalls, recalls)
class TestOperatingPoints(unittest.TestCase):
def test_equal_time_better_perf_shadows(self):
# A point with strictly better performance at the *same* time
# dominates an earlier point and must shadow it. The admission test
# is_pareto_optimal uses t <= t_new, so the prune loop must match.
op = evaluation.OperatingPoints()
op.add_operating_point("A", 0.5, 1.0)
op.add_operating_point("B", 0.9, 1.0)
keys = [k for k, _, _ in op.operating_points]
self.assertEqual(keys, ["B"])
# no kept point may be dominated by another kept point
pts = op.operating_points
for _, pi, ti in pts:
for _, pj, tj in pts:
self.assertFalse(pj >= pi and tj <= ti and (pj > pi or tj < ti))
class TestPreassigned(unittest.TestCase):
def test_index_pretransformed(self):
ds = datasets.SyntheticDataset(128, 2000, 2000, 200)
xt = ds.get_train()
xq = ds.get_queries()
xb = ds.get_database()
index = faiss.index_factory(128, "PCA64,IVF64,PQ4np")
index.train(xt)
index.add(xb)
index_downcasted = faiss.extract_index_ivf(index)
index_downcasted.nprobe = 10
xq_trans = index.chain.at(0).apply_py(xq)
D_ref, I_ref = index.search(xq, 4)
quantizer = index_downcasted.quantizer
Dq, Iq = quantizer.search(xq_trans, index_downcasted.nprobe)
D, I = ivf_tools.search_preassigned(index, xq, 4, Iq, Dq)
np.testing.assert_almost_equal(D_ref, D, decimal=4)
np.testing.assert_array_equal(I_ref, I)
def test_float(self):
ds = datasets.SyntheticDataset(128, 2000, 2000, 200)
d = ds.d
xt = ds.get_train()
xq = ds.get_queries()
xb = ds.get_database()
# define alternative quantizer on the 20 first dims of vectors
km = faiss.Kmeans(20, 50)
km.train(xt[:, :20].copy())
alt_quantizer = km.index
index = faiss.index_factory(d, "IVF50,PQ16np")
index.by_residual = False
# (optional) fake coarse quantizer
fake_centroids = np.zeros((index.nlist, index.d), dtype="float32")
index.quantizer.add(fake_centroids)
# train the PQ part
index.train(xt)
# add elements xb
a = alt_quantizer.search(xb[:, :20].copy(), 1)[1].ravel()
ivf_tools.add_preassigned(index, xb, a)
# search elements xq, increase nprobe, check 4 first results w/
# groundtruth
prev_inter_perf = 0
for nprobe in 1, 10, 20:
index.nprobe = nprobe
a = alt_quantizer.search(xq[:, :20].copy(), index.nprobe)[1]
D, I = ivf_tools.search_preassigned(index, xq, 4, a)
inter_perf = faiss.eval_intersection(I, ds.get_groundtruth()[:, :4])
self.assertTrue(inter_perf >= prev_inter_perf)
prev_inter_perf = inter_perf
# test range search
index.nprobe = 20
a = alt_quantizer.search(xq[:, :20].copy(), index.nprobe)[1]
# just to find a reasonable radius
D, I = ivf_tools.search_preassigned(index, xq, 4, a)
radius = D.max() * 1.01
lims, DR, IR = ivf_tools.range_search_preassigned(
index, xq, radius.item(), a
)
# with that radius the k-NN results are a subset of the range search
# results
for q in range(len(xq)):
l0, l1 = lims[q], lims[q + 1]
self.assertTrue(set(I[q]) <= set(IR[l0:l1]))
@unittest.skipIf(
platform.system() == "Windows",
"test_binary hangs for Windows on newer versions of MKL.",
)
def test_binary(self):
ds = datasets.SyntheticDataset(128, 2000, 2000, 200)
d = ds.d
xt = ds.get_train()
xq = ds.get_queries()
xb = ds.get_database()
# define alternative quantizer on the 20 first dims of vectors
# (will be in float)
km = faiss.Kmeans(20, 50)
km.train(xt[:, :20].copy())
alt_quantizer = km.index
binarizer = faiss.index_factory(d, "ITQ,LSHt")
binarizer.train(xt)
xb_bin = binarizer.sa_encode(xb)
xq_bin = binarizer.sa_encode(xq)
index = faiss.index_binary_factory(d, "BIVF200")
fake_centroids = np.zeros((index.nlist, index.d // 8), dtype="uint8")
index.quantizer.add(fake_centroids)
index.is_trained = True
# add elements xb
a = alt_quantizer.search(xb[:, :20].copy(), 1)[1].ravel()
ivf_tools.add_preassigned(index, xb_bin, a)
# recompute GT in binary
k = 15
ib = faiss.IndexBinaryFlat(128)
ib.add(xb_bin)
Dgt, Igt = ib.search(xq_bin, k)
# search elements xq, increase nprobe, check 4 first results w/
# groundtruth
prev_inter_perf = 0
for nprobe in 1, 10, 20:
index.nprobe = nprobe
a = alt_quantizer.search(xq[:, :20].copy(), index.nprobe)[1]
D, I = ivf_tools.search_preassigned(index, xq_bin, k, a)
inter_perf = faiss.eval_intersection(I, Igt)
self.assertGreaterEqual(inter_perf, prev_inter_perf)
prev_inter_perf = inter_perf
# test range search
index.nprobe = 20
a = alt_quantizer.search(xq[:, :20].copy(), index.nprobe)[1]
# just to find a reasonable radius
D, I = ivf_tools.search_preassigned(index, xq_bin, 4, a)
radius = int(D.max() + 1)
lims, DR, IR = ivf_tools.range_search_preassigned(
index, xq_bin, radius, a
)
# with that radius the k-NN results are a subset of the range
# search results
for q in range(len(xq)):
l0, l1 = lims[q], lims[q + 1]
self.assertTrue(set(I[q]) <= set(IR[l0:l1]))
def test_range_search_preassigned_coarse_dis_none_matches_zeros(self):
"""coarse_dis=None must match coarse_dis=zeros. Before the fix,
np.empty left uninitialized values that IVFPQ by_residual=True
reads for distance correction, producing nondeterministic results."""
ds = datasets.SyntheticDataset(32, 1000, 500, 50)
index = faiss.index_factory(ds.d, "IVF10,PQ4x4")
index.train(ds.get_train())
index.add(ds.get_database())
index.nprobe = 5
self.assertTrue(index.by_residual)
xq = ds.get_queries()
_, list_nos = index.quantizer.search(xq, index.nprobe)
zero_dis = np.zeros(list_nos.shape, dtype=np.float32)
lz, Dz, Iz = ivf_tools.range_search_preassigned(
index, xq, 10.0, list_nos, zero_dis
)
ln, Dn, In = ivf_tools.range_search_preassigned(
index, xq, 10.0, list_nos
)
np.testing.assert_array_equal(lz, ln)
np.testing.assert_array_equal(Iz, In)
np.testing.assert_array_equal(Dz, Dn)
class TestRangeSearchMaxResults(unittest.TestCase):
def do_test(self, metric_type):
ds = datasets.SyntheticDataset(32, 0, 1000, 200)
index = faiss.IndexFlat(ds.d, metric_type)
index.add(ds.get_database())
# find a reasonable radius
D, _ = index.search(ds.get_queries(), 10)
radius0 = float(np.median(D[:, -1]))
# baseline = search with that radius
lims_ref, Dref, Iref = index.range_search(ds.get_queries(), radius0)
# now see if using just the total number of results, we can get back
# the same result table
query_iterator = exponential_query_iterator(ds.get_queries())
init_radius = 1e10 if metric_type == faiss.METRIC_L2 else -1e10
radius1, lims_new, Dnew, Inew = range_search_max_results(
index,
query_iterator,
init_radius,
min_results=Dref.size,
clip_to_min=True,
)
evaluation.check_ref_range_results(
lims_ref, Dref, Iref, lims_new, Dnew, Inew
)
def test_L2(self):
self.do_test(faiss.METRIC_L2)
def test_IP(self):
self.do_test(faiss.METRIC_INNER_PRODUCT)
def test_binary(self):
ds = datasets.SyntheticDataset(64, 1000, 1000, 200)
tobinary = faiss.index_factory(ds.d, "LSHrt")
tobinary.train(ds.get_train())
index = faiss.IndexBinaryFlat(ds.d)
xb = tobinary.sa_encode(ds.get_database())
xq = tobinary.sa_encode(ds.get_queries())
index.add(xb)
# find a reasonable radius
D, _ = index.search(xq, 10)
radius0 = int(np.median(D[:, -1]))
# baseline = search with that radius
lims_ref, Dref, Iref = index.range_search(xq, radius0)
# now see if using just the total number of results, we can get back
# the same result table
query_iterator = exponential_query_iterator(xq)
radius1, lims_new, Dnew, Inew = range_search_max_results(
index,
query_iterator,
ds.d // 2,
min_results=Dref.size,
clip_to_min=True,
)
evaluation.check_ref_range_results(
lims_ref, Dref, Iref, lims_new, Dnew, Inew
)
class TestClustering(unittest.TestCase):
def test_python_kmeans(self):
"""Test the python implementation of kmeans"""
ds = datasets.SyntheticDataset(32, 5000, 0, 0)
x = ds.get_train()
# bad distribution to stress-test split code
xt = x[:5000].copy()
xt[:2500] = x[0]
km_ref = faiss.Kmeans(ds.d, 50, niter=10)
km_ref.train(xt)
err = faiss.knn(xt, km_ref.centroids, 1)[0].sum()
data = clustering.DatasetAssign(xt)
centroids = clustering.kmeans(50, data, 10)
err2 = faiss.knn(xt, centroids, 1)[0].sum()
# err=33498.332 err2=33380.477
self.assertLess(err2, err * 1.1)
def test_2level(self):
"verify that 2-level clustering is not too sub-optimal"
ds = datasets.SyntheticDataset(32, 10000, 0, 0)
xt = ds.get_train()
km_ref = faiss.Kmeans(ds.d, 100)
km_ref.train(xt)
err = faiss.knn(xt, km_ref.centroids, 1)[0].sum()
centroids2, _ = clustering.two_level_clustering(xt, 10, 100)
err2 = faiss.knn(xt, centroids2, 1)[0].sum()
self.assertLess(err2, err * 1.1)
def test_ivf_train_2level(self):
"check 2-level clustering with IVF training"
ds = datasets.SyntheticDataset(32, 10000, 1000, 200)
index = faiss.index_factory(ds.d, "PCA16,IVF100,SQ8")
faiss.extract_index_ivf(index).nprobe = 10
index.train(ds.get_train())
index.add(ds.get_database())
Dref, Iref = index.search(ds.get_queries(), 1)
index = faiss.index_factory(ds.d, "PCA16,IVF100,SQ8")
faiss.extract_index_ivf(index).nprobe = 10
clustering.train_ivf_index_with_2level(
index, ds.get_train(), verbose=True, rebalance=False
)
index.add(ds.get_database())
Dnew, Inew = index.search(ds.get_queries(), 1)
# normally 47 / 200 differences
ndiff = (Iref != Inew).sum()
self.assertLess(ndiff.item(), 57)
def test_balanced_clustering(self):
"""Test balanced_assignment_with_penalties from notebook N10159950"""
ds = datasets.SyntheticDataset(32, 10000, 10000, 0)
nc = 100
# train centroids
km = faiss.Kmeans(ds.d, nc)
km.train(ds.get_train())
centroids = km.centroids
# create biased database (shifted by a constant vector)
biased_xb = ds.get_database().copy()
rs = np.random.RandomState(123)
biased_xb += rs.randn(ds.d).astype("float32") * 0.3
# unconstrained assignment on biased data
d2, assign_unc = faiss.knn(biased_xb, centroids, 1)
assign_unc = assign_unc.ravel()
imf_unc = clustering.imbalance_factor(nc, assign_unc)
mse_unc = float(d2.mean())
# balanced assignment
assign_bal, stats = clustering.balanced_assignment_with_penalties(
biased_xb, centroids, alpha=0.03, num_iter=20
)
# balanced assignment should reduce imbalance factor
self.assertLess(stats["imf"], imf_unc / 1.5)
# MSE may increase but should not be too much worse (< 2x)
self.assertLess(stats["mse"], mse_unc * 1.5)
# all points should be assigned
self.assertEqual(len(assign_bal), len(biased_xb))
self.assertTrue(np.all(assign_bal >= 0))
self.assertTrue(np.all(assign_bal < nc))
class TestBigBatchSearch(unittest.TestCase):
def do_test(self, factory_string, metric=faiss.METRIC_L2):
ds = datasets.SyntheticDataset(32, 2000, 400, 100)
k = 10
index = faiss.index_factory(ds.d, factory_string, metric)
assert index.metric_type == metric
index.train(ds.get_train())
index.add(ds.get_database())
index.nprobe = 5
Dref, Iref = index.search(ds.get_queries(), k)
faiss.omp_set_num_threads(1)
for method in ("pairwise_distances", "knn_function"):
for threaded in 0, 2:
Dnew, Inew = big_batch_search.big_batch_search(
index, ds.get_queries(), k, method=method, threaded=threaded
)
self.assertLess((Inew != Iref).sum() / Iref.size, 1e-4)
np.testing.assert_almost_equal(Dnew, Dref, decimal=4)
def test_Flat(self):
self.do_test("IVF64,Flat")
def test_Flat_IP(self):
self.do_test("IVF64,Flat", metric=faiss.METRIC_INNER_PRODUCT)
def test_PQ(self):
self.do_test("IVF64,PQ4np")
def test_SQ(self):
self.do_test("IVF64,SQ8")
def test_checkpoint(self):
ds = datasets.SyntheticDataset(32, 2000, 400, 100)
k = 10
index = faiss.index_factory(ds.d, "IVF64,SQ8")
index.train(ds.get_train())
index.add(ds.get_database())
index.nprobe = 5
Dref, Iref = index.search(ds.get_queries(), k)
faiss.omp_set_num_threads(1)
checkpoint = tempfile.mktemp()
try:
# First big batch search
try:
Dnew, Inew = big_batch_search.big_batch_search(
index,
ds.get_queries(),
k,
method="knn_function",
threaded=2,
checkpoint=checkpoint,
checkpoint_freq=0.1,
crash_at=20,
)
except ZeroDivisionError:
pass
else:
self.assertFalse("should have crashed")
# Second big batch search
Dnew, Inew = big_batch_search.big_batch_search(
index,
ds.get_queries(),
k,
method="knn_function",
threaded=2,
checkpoint=checkpoint,
checkpoint_freq=5,
)
self.assertLess((Inew != Iref).sum() / Iref.size, 1e-4)
np.testing.assert_almost_equal(Dnew, Dref, decimal=4)
finally:
if os.path.exists(checkpoint):
os.unlink(checkpoint)
class TestInvlistSort(unittest.TestCase):
def test_sort(self):
"""make sure that the search results do not change
after sorting the inverted lists"""
ds = datasets.SyntheticDataset(32, 2000, 200, 20)
index = faiss.index_factory(ds.d, "IVF50,SQ8")
index.train(ds.get_train())
index.add(ds.get_database())
index.nprobe = 5
Dref, Iref = index.search(ds.get_queries(), 5)
ivf_tools.sort_invlists_by_size(index)
list_sizes = ivf_tools.get_invlist_sizes(index.invlists)
assert np.all(list_sizes[1:] >= list_sizes[:-1])
Dnew, Inew = index.search(ds.get_queries(), 5)
np.testing.assert_equal(Dnew, Dref)
np.testing.assert_equal(Inew, Iref)
def test_hnsw_permute(self):
"""
make sure HNSW permutation works
(useful when used as coarse quantizer)
"""
ds = datasets.SyntheticDataset(32, 0, 1000, 50)
index = faiss.index_factory(ds.d, "HNSW32,Flat")
index.add(ds.get_database())
Dref, Iref = index.search(ds.get_queries(), 5)
rs = np.random.RandomState(1234)
perm = rs.permutation(index.ntotal)
index.permute_entries(perm)
Dnew, Inew = index.search(ds.get_queries(), 5)
np.testing.assert_equal(Dnew, Dref)
Inew_remap = perm[Inew]
np.testing.assert_equal(Inew_remap, Iref)
class TestCodeSet(unittest.TestCase):
def test_code_set(self):
"""CodeSet and np.unique should produce the same output"""
d = 8
n = 1000 # > 256 and using only 0 or 1 so there must be duplicates
codes = np.random.randint(0, 2, (n, d), dtype=np.uint8)
s = faiss.CodeSet(d)
inserted = s.insert(codes)
np.testing.assert_equal(
np.sort(np.unique(codes, axis=0), axis=None),
np.sort(codes[inserted], axis=None),
)
@unittest.skipIf(
platform.system() == "Windows",
"OnDiskInvertedLists is unsupported on Windows.",
)
class TestMerge(unittest.TestCase):
@contextmanager
def temp_directory(self):
temp_dir = tempfile.mkdtemp()
try:
yield temp_dir
finally:
shutil.rmtree(temp_dir)
def do_test_ondisk_merge(self, shift_ids=False):
with self.temp_directory() as tmpdir:
# only train and add index to disk without adding elements.
# this will create empty inverted lists.
ds = datasets.SyntheticDataset(32, 2000, 200, 20)
index = faiss.index_factory(ds.d, "IVF32,Flat")
index.train(ds.get_train())
faiss.write_index(index, tmpdir + "/trained.index")
# create 4 shards and add elements to them
ns = 4 # number of shards
for bno in range(ns):
index = faiss.read_index(tmpdir + "/trained.index")
i0, i1 = int(bno * ds.nb / ns), int((bno + 1) * ds.nb / ns)
if shift_ids:
index.add_with_ids(ds.xb[i0:i1], np.arange(0, ds.nb / ns))
else:
index.add_with_ids(ds.xb[i0:i1], np.arange(i0, i1))
faiss.write_index(index, tmpdir + "/block_%d.index" % bno)
# construct the output index and merge them on disk
index = faiss.read_index(tmpdir + "/trained.index")
block_fnames = [
tmpdir + "/block_%d.index" % bno for bno in range(4)
]
merge_ondisk(
index, block_fnames, tmpdir + "/merged_index.ivfdata", shift_ids
)
faiss.write_index(index, tmpdir + "/populated.index")
# perform a search from index on disk
index = faiss.read_index(tmpdir + "/populated.index")
index.nprobe = 5
D, I = index.search(ds.xq, 5)
# ground-truth
gtI = ds.get_groundtruth(5)
recall_at_1 = (I[:, :1] == gtI[:, :1]).sum() / float(ds.xq.shape[0])
self.assertGreaterEqual(recall_at_1, 0.5)
def test_ondisk_merge(self):
self.do_test_ondisk_merge()
def test_ondisk_merge_with_shift_ids(self):
# verified that recall is same for test_ondisk_merge and
self.do_test_ondisk_merge(True)
class TestFactoryTools(unittest.TestCase):
def test_idmap(self):
index = faiss.IndexIDMap(faiss.IndexFlatL2(32))
self.assertEqual(
factory_tools.reverse_index_factory(index), "IDMap,Flat"
)
# IndexIDMap2 subclasses IndexIDMap, so it must be checked first.
index2 = faiss.IndexIDMap2(faiss.IndexFlatL2(32))
self.assertEqual(
factory_tools.reverse_index_factory(index2), "IDMap2,Flat"
)
def test_ivf_scalar_quantizer_types(self):
d = 32
sq_types = {
faiss.ScalarQuantizer.QT_8bit: "IVF32,SQ8",
faiss.ScalarQuantizer.QT_4bit: "IVF32,SQ4",
faiss.ScalarQuantizer.QT_8bit_uniform: "IVF32,SQ8u",
faiss.ScalarQuantizer.QT_4bit_uniform: "IVF32,SQ4u",
faiss.ScalarQuantizer.QT_6bit: "IVF32,SQ6",
faiss.ScalarQuantizer.QT_fp16: "IVF32,SQfp16",
faiss.ScalarQuantizer.QT_bf16: "IVF32,SQbf16",
faiss.ScalarQuantizer.QT_8bit_direct: "IVF32,SQ8_direct",
faiss.ScalarQuantizer.QT_8bit_direct_signed: (
"IVF32,SQ8_direct_signed"
),
faiss.ScalarQuantizer.QT_0bit: "IVF32,SQ0",
faiss.ScalarQuantizer.QT_1bit_tqmse: "IVF32,SQtqmse1",
faiss.ScalarQuantizer.QT_2bit_tqmse: "IVF32,SQtqmse2",
faiss.ScalarQuantizer.QT_3bit_tqmse: "IVF32,SQtqmse3",
faiss.ScalarQuantizer.QT_4bit_tqmse: "IVF32,SQtqmse4",
faiss.ScalarQuantizer.QT_8bit_tqmse: "IVF32,SQtqmse8",
faiss.ScalarQuantizer.QT_2bit_tq: "IVF32,SQtq2",
faiss.ScalarQuantizer.QT_3bit_tq: "IVF32,SQtq3",
faiss.ScalarQuantizer.QT_4bit_tq: "IVF32,SQtq4",
faiss.ScalarQuantizer.QT_5bit_tq: "IVF32,SQtq5",
}
for qtype, expected in sq_types.items():
index = faiss.IndexIVFScalarQuantizer(
faiss.IndexFlatL2(d), d, 32, qtype
)
self.assertEqual(
factory_tools.reverse_index_factory(index), expected
)
def test_scalar_quantizer_types(self):
d = 32
sq_types = {
faiss.ScalarQuantizer.QT_8bit: "SQ8",
faiss.ScalarQuantizer.QT_4bit: "SQ4",
faiss.ScalarQuantizer.QT_8bit_uniform: "SQ8u",
faiss.ScalarQuantizer.QT_4bit_uniform: "SQ4u",
faiss.ScalarQuantizer.QT_6bit: "SQ6",
faiss.ScalarQuantizer.QT_fp16: "SQfp16",
faiss.ScalarQuantizer.QT_bf16: "SQbf16",
faiss.ScalarQuantizer.QT_8bit_direct: "SQ8_direct",
faiss.ScalarQuantizer.QT_8bit_direct_signed: "SQ8_direct_signed",
faiss.ScalarQuantizer.QT_1bit_tqmse: "SQtqmse1",
faiss.ScalarQuantizer.QT_2bit_tqmse: "SQtqmse2",
faiss.ScalarQuantizer.QT_3bit_tqmse: "SQtqmse3",
faiss.ScalarQuantizer.QT_4bit_tqmse: "SQtqmse4",
faiss.ScalarQuantizer.QT_8bit_tqmse: "SQtqmse8",
faiss.ScalarQuantizer.QT_2bit_tq: "SQtq2",
faiss.ScalarQuantizer.QT_3bit_tq: "SQtq3",
faiss.ScalarQuantizer.QT_4bit_tq: "SQtq4",
faiss.ScalarQuantizer.QT_5bit_tq: "SQtq5",
}
for qtype, expected in sq_types.items():
index = faiss.IndexScalarQuantizer(d, qtype)
self.assertEqual(
factory_tools.reverse_index_factory(index), expected
)
def test_get_code_size_hnsw_non_default_m(self):
d = 128
# Non-default M values previously raised
# RuntimeError("cannot parse HNSW16")
self.assertEqual(
factory_tools.get_code_size(d, "HNSW16"), d * 4 + 16 * 2 * 4
)
self.assertEqual(
factory_tools.get_code_size(d, "HNSW64"), d * 4 + 64 * 2 * 4
)
self.assertEqual(
factory_tools.get_code_size(d, "HNSW16,Flat"), d * 4 + 16 * 2 * 4
)
# HNSW32 backward compat: formula generalizes correctly
self.assertEqual(
factory_tools.get_code_size(d, "HNSW32"), d * 4 + 32 * 2 * 4
)
def test_get_code_size_ivf_hnsw_non_default_m(self):
d = 128
# IVF+HNSW coarse quantizer with non-default M: code size is inner
# type only
self.assertEqual(
factory_tools.get_code_size(d, "IVF64_HNSW16,Flat"), d * 4
)
self.assertEqual(
factory_tools.get_code_size(d, "IVF64_HNSW64,PQ8x8"),
(8 * 8 + 7) // 8,
)
def test_get_code_size_hnsw_roundtrip(self):
d = 128
index = faiss.index_factory(d, "HNSW16,Flat")
factory_str = factory_tools.reverse_index_factory(index)
self.assertEqual(factory_str, "HNSW16")
# get_code_size(reverse_index_factory(index)) must not raise for any M
code_size = factory_tools.get_code_size(d, factory_str)
self.assertEqual(code_size, d * 4 + 16 * 2 * 4)
def test_hnsw_storage_reverse_index_factory(self):
d = 128
# flat storage stays implicit, everything else must be spelled out
cases = {
"HNSW32,Flat": "HNSW32",
"HNSW32,SQ8": "HNSW32,SQ8",
"HNSW32,SQfp16": "HNSW32,SQfp16",
"HNSW32,PQ16x8": "HNSW32,PQ16x8",
}
for key, expected in cases.items():
index = faiss.index_factory(d, key)
factory_str = factory_tools.reverse_index_factory(index)
self.assertEqual(factory_str, expected)
# the string must rebuild the same index class
rebuilt = faiss.index_factory(d, factory_str)
self.assertEqual(type(rebuilt), type(index))
def test_get_code_size_hnsw_non_flat_storage(self):
d = 128
graph = 32 * 2 * 4
self.assertEqual(
factory_tools.get_code_size(d, "HNSW32,SQ8"), d + graph
)
self.assertEqual(
factory_tools.get_code_size(d, "HNSW32,SQfp16"), d * 2 + graph
)
self.assertEqual(
factory_tools.get_code_size(d, "HNSW32,PQ16x8"), 16 + graph
)
def test_get_code_size_imi(self):
# the coarse quantizer adds no per-vector bytes
d = 64
self.assertEqual(factory_tools.get_code_size(d, "IMI2x10,PQ16"), 16)
self.assertEqual(factory_tools.get_code_size(d, "IMI2x5,Flat"), d * 4)
# round-trip: get_code_size must parse what reverse emits
index = faiss.index_factory(d, "IMI2x5,PQ8")
factory_str = factory_tools.reverse_index_factory(index)
self.assertEqual(factory_str, "IMI2x5,PQ8x8")
self.assertEqual(factory_tools.get_code_size(d, factory_str), 8)
def test_ivfpqr_reverse_index_factory(self):
d = 128
index = faiss.index_factory(d, "IVF64,PQ8+16")
factory_str = factory_tools.reverse_index_factory(index)
self.assertEqual(factory_str, "IVF64,PQ8+16")
rebuilt = faiss.index_factory(d, factory_str)
self.assertEqual(type(rebuilt), type(index))
self.assertEqual(factory_tools.get_code_size(d, factory_str), 8 + 16)
def test_rabitq_reverse_index_factory(self):
d = 64
quantizer = faiss.IndexFlatL2(d)
for nb_bits in [1, 2, 4]:
expected = "RaBitQ" if nb_bits == 1 else f"RaBitQ{nb_bits}"
index = faiss.IndexRaBitQ(d, faiss.METRIC_L2, nb_bits)
self.assertEqual(
factory_tools.reverse_index_factory(index), expected
)
ivf_index = faiss.IndexIVFRaBitQ(
quantizer, d, 8, faiss.METRIC_L2, False, nb_bits
)
self.assertEqual(
factory_tools.reverse_index_factory(ivf_index),
f"IVF8,{expected}",
)