mirror of
https://github.com/facebookresearch/faiss.git
synced 2026-10-11 22:50:00 +00:00
Summary: Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5361 `test_batch_boundaries` flaked (~36%) on the `AVX512_SPR` dispatch variant. The reference `IndexFlat` distance kernel is SIMD-dispatched, so its float reduction order changes with the runtime SIMD level (wide FMA under `AVX512_SPR`), while the `IndexFlatPanorama` kernel is compiled once at baseline and uses the algebraically-equivalent `a*a + b*b - 2ab` decomposition instead of `(a-b)^2`. Both are exact but round differently, so near-tied neighbors reorder at batch boundaries. The earlier scalar de-flake (D109488316 / D109488320, T276491016) only widened the `atol`/`otol` tolerance for the `_NONE` gap, leaving the larger `AVX512_SPR` gap. Fix: replace the brittle exact-id overlap-rate gate in `assert_search_results_equal` with the tie-aware comparator `faiss.contrib.evaluation.check_ref_knn_with_draws`, which requires distances to match within `rtol` and ids to agree only as sets within each distance tie-group. This is variant-agnostic (covers `NONE` / `AVX2` / `AVX512` / `AVX512_SPR`) and is the standard faiss kNN comparison helper used across the suite. Reviewed By: mnorris11 Differential Revision: D110092203 fbshipit-source-id: ec6fc8c6114d7dc9bb7b6911478a6d5b0b993b5c
760 lines
30 KiB
Python
760 lines
30 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.
|
|
|
|
"""
|
|
Comprehensive test suite for IndexFlatPanorama.
|
|
|
|
Panorama is an adaptation of IndexFlat that uses level-oriented storage
|
|
and progressive filtering with Cauchy-Schwarz bounds to achieve significant
|
|
speedups (up to 45x when combined with PCA or Cayley transforms) with zero
|
|
loss in accuracy.
|
|
|
|
Paper: https://www.arxiv.org/pdf/2510.00566
|
|
"""
|
|
|
|
import unittest
|
|
|
|
import faiss
|
|
import numpy as np
|
|
from common_faiss_tests import for_all_simd_levels
|
|
from faiss.contrib.datasets import SyntheticDataset
|
|
from faiss.contrib.evaluation import check_ref_knn_with_draws
|
|
|
|
|
|
@for_all_simd_levels
|
|
class TestIndexFlatPanorama(unittest.TestCase):
|
|
"""Test Suite for IndexFlatPanorama."""
|
|
|
|
# Metrics to test
|
|
METRICS = [faiss.METRIC_L2, faiss.METRIC_INNER_PRODUCT]
|
|
|
|
# Helper methods for index creation and data generation
|
|
|
|
def generate_data(self, d, nt, nb, nq, seed=42):
|
|
ds = SyntheticDataset(d, nt, nb, nq, seed=seed)
|
|
return ds.get_train(), ds.get_database(), ds.get_queries()
|
|
|
|
def create_flat(self, d, xb=None, metric=faiss.METRIC_L2):
|
|
index = faiss.IndexFlat(d, metric)
|
|
if xb is not None:
|
|
index.add(xb)
|
|
return index
|
|
|
|
def create_panorama(
|
|
self, d, nlevels, xb=None, batch_size=128, metric=faiss.METRIC_L2
|
|
):
|
|
"""Create and initialize IndexFlatPanorama."""
|
|
index = faiss.IndexFlatPanorama(d, metric, nlevels, batch_size)
|
|
if xb is not None:
|
|
index.add(xb)
|
|
return index
|
|
|
|
def assert_search_results_equal(
|
|
self,
|
|
D_regular,
|
|
I_regular,
|
|
D_panorama,
|
|
I_panorama,
|
|
rtol=1e-5,
|
|
atol=1e-4,
|
|
):
|
|
# The reference IndexFlat kernel is SIMD-dispatched, so its float
|
|
# reduction order changes with the runtime SIMD level (wide FMA under
|
|
# AVX512_SPR), while the Panorama kernel is compiled once at baseline
|
|
# and uses the algebraically equivalent a * a + b * b - 2(a * b)
|
|
# decomposition instead of (a - b) * (a - b). Both are exact but round
|
|
# differently, so near-tied neighbors can swap order. Compare with the
|
|
# tie-aware kNN comparator: distances must match within tolerance and
|
|
# ids only need to agree as sets within each distance tie-group. This is
|
|
# variant-agnostic (NONE / AVX2 / AVX512 / AVX512_SPR) rather than tuned
|
|
# to one dispatch level's gap.
|
|
np.testing.assert_allclose(
|
|
D_regular,
|
|
D_panorama,
|
|
rtol=rtol,
|
|
atol=atol,
|
|
err_msg="Distances mismatch",
|
|
)
|
|
check_ref_knn_with_draws(
|
|
D_regular, I_regular, D_panorama, I_panorama, rtol=rtol
|
|
)
|
|
|
|
def assert_range_results_equal(
|
|
self,
|
|
lims_regular,
|
|
D_regular,
|
|
I_regular,
|
|
lims_panorama,
|
|
D_panorama,
|
|
I_panorama,
|
|
nq,
|
|
):
|
|
np.testing.assert_array_equal(
|
|
lims_regular, lims_panorama, err_msg="Different result counts"
|
|
)
|
|
|
|
for i in range(nq):
|
|
n_results = lims_regular[i + 1] - lims_regular[i]
|
|
if n_results > 0:
|
|
ids_reg = I_regular[lims_regular[i] : lims_regular[i + 1]]
|
|
dist_reg = D_regular[lims_regular[i] : lims_regular[i + 1]]
|
|
ids_pan = I_panorama[lims_panorama[i] : lims_panorama[i + 1]]
|
|
dist_pan = D_panorama[lims_panorama[i] : lims_panorama[i + 1]]
|
|
|
|
sort_reg, sort_pan = np.argsort(ids_reg), np.argsort(ids_pan)
|
|
np.testing.assert_array_equal(
|
|
ids_reg[sort_reg], ids_pan[sort_pan]
|
|
)
|
|
np.testing.assert_allclose(
|
|
dist_reg[sort_reg], dist_pan[sort_pan], rtol=1e-5
|
|
)
|
|
|
|
# Core functionality tests
|
|
|
|
def test_exact_match_with_flat(self):
|
|
"""Core test: Panorama must return identical results to IndexFlat"""
|
|
d, nb, nt, nq, nlevels, k = 128, 50000, 60000, 10, 8, 20
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=42)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
D_regular, I_regular = index_regular.search(xq, k)
|
|
D_panorama, I_panorama = index_panorama.search(xq, k)
|
|
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
def test_exact_match_with_flat_medium(self):
|
|
"""Core test: Medium scale version"""
|
|
d, nb, nt, nq, nlevels, k = 64, 10000, 15000, 10, 4, 10
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=42)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
D_regular, I_regular = index_regular.search(xq, k)
|
|
D_panorama, I_panorama = index_panorama.search(xq, k)
|
|
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
def test_range_search(self):
|
|
"""Test range search returns correct results within radius"""
|
|
d, nb, nt, nq, nlevels = 128, 50000, 75000, 10, 8
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=123)
|
|
|
|
for metric in self.METRICS:
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(d, nlevels, xb, metric=metric)
|
|
|
|
for radius in [0.5, 1.0, 2.0, 5.0]:
|
|
with self.subTest(metric=metric, radius=radius):
|
|
lims_reg, D_reg, I_reg = index_regular.range_search(
|
|
xq, radius
|
|
)
|
|
lims_pan, D_pan, I_pan = index_panorama.range_search(
|
|
xq, radius
|
|
)
|
|
|
|
is_valid = (
|
|
(D_pan <= radius)
|
|
if metric == faiss.METRIC_L2
|
|
else (D_pan >= radius)
|
|
)
|
|
self.assertTrue(
|
|
np.all(is_valid),
|
|
f"Some distances exceed radius={radius}",
|
|
)
|
|
self.assert_range_results_equal(
|
|
lims_reg, D_reg, I_reg, lims_pan, D_pan, I_pan, nq
|
|
)
|
|
|
|
# Parameter variation tests
|
|
|
|
def test_different_n_levels(self):
|
|
"""Test correctness with various n_levels parameter values"""
|
|
d, nb, nt, nq, k = 128, 25000, 40000, 10, 15
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=456)
|
|
|
|
for metric in self.METRICS:
|
|
index_base = self.create_flat(d, xb, metric=metric)
|
|
D_base, I_base = index_base.search(xq, k)
|
|
|
|
nt = faiss.omp_get_max_threads()
|
|
faiss.omp_set_num_threads(1)
|
|
|
|
prev_ratio_dims_scanned = float("inf")
|
|
for nlevels in [1, 2, 4, 8, 16, 32]:
|
|
with self.subTest(metric=metric, nlevels=nlevels):
|
|
faiss.cvar.indexPanorama_stats.reset()
|
|
index = self.create_panorama(d, nlevels, xb, metric=metric)
|
|
D, I = index.search(xq, k)
|
|
self.assert_search_results_equal(D_base, I_base, D, I)
|
|
|
|
stats = faiss.cvar.indexPanorama_stats
|
|
ratio_dims_scanned = stats.ratio_dims_scanned
|
|
self.assertLess(ratio_dims_scanned, prev_ratio_dims_scanned)
|
|
prev_ratio_dims_scanned = ratio_dims_scanned
|
|
|
|
faiss.omp_set_num_threads(nt)
|
|
|
|
def test_uneven_dimension_division(self):
|
|
"""Test when n_levels doesn't evenly divide dimension"""
|
|
test_cases = [(65, 4), (63, 8), (100, 7)]
|
|
|
|
for metric in self.METRICS:
|
|
for d, nlevels in test_cases:
|
|
with self.subTest(metric=metric, d=d, nlevels=nlevels):
|
|
nb, nt, nq, k = 5000, 7000, 10, 5
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=789)
|
|
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
D_regular, I_regular = index_regular.search(xq, k)
|
|
D_panorama, I_panorama = index_panorama.search(xq, k)
|
|
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
def test_single_level(self):
|
|
"""Test edge case with n_levels=1"""
|
|
d, nb, nt, nq, nlevels, k = 32, 500, 700, 10, 1, 5
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=333)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
D_regular, I_regular = index_regular.search(xq, k)
|
|
D_panorama, I_panorama = index_panorama.search(xq, k)
|
|
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
def test_multiple_levels_small_dimension(self):
|
|
"""Test edge case: more levels than dimension naturally supports"""
|
|
d, nb, nt, nq, nlevels, k = 16, 200, 300, 10, 8, 5
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=1212)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
D_regular, I_regular = index_regular.search(xq, k)
|
|
D_panorama, I_panorama = index_panorama.search(xq, k)
|
|
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
# ID selector tests
|
|
|
|
def test_id_selector_range(self):
|
|
"""Test ID filtering with range selector"""
|
|
d, nb, nt, nq, nlevels, k = 128, 80000, 120000, 500, 8, 20
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=321)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
params = faiss.SearchParameters()
|
|
params.sel = faiss.IDSelectorRange(10000, 50000)
|
|
|
|
D_regular, I_regular = index_regular.search(
|
|
xq, k, params=params
|
|
)
|
|
D_panorama, I_panorama = index_panorama.search(
|
|
xq, k, params=params
|
|
)
|
|
|
|
self.assertTrue(np.all(I_panorama >= 10000))
|
|
self.assertTrue(np.all(I_panorama < 50000))
|
|
|
|
np.testing.assert_array_equal(I_regular, I_panorama)
|
|
np.testing.assert_allclose(D_regular, D_panorama, rtol=1e-5)
|
|
|
|
def test_id_selector_batch(self):
|
|
"""Test ID filtering with batch selector"""
|
|
d, nb, nt, nq, nlevels, k = 128, 60000, 90000, 400, 8, 20
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=522)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
allowed_ids = np.array(
|
|
[i * 100 for i in range(500)], dtype=np.int64
|
|
)
|
|
params = faiss.SearchParameters()
|
|
params.sel = faiss.IDSelectorBatch(allowed_ids)
|
|
|
|
D_regular, I_regular = index_regular.search(
|
|
xq, k, params=params
|
|
)
|
|
D_panorama, I_panorama = index_panorama.search(
|
|
xq, k, params=params
|
|
)
|
|
|
|
allowed_set = set(allowed_ids)
|
|
for id_val in I_panorama.flatten():
|
|
self.assertIn(int(id_val), allowed_set)
|
|
|
|
np.testing.assert_array_equal(I_regular, I_panorama)
|
|
np.testing.assert_allclose(D_regular, D_panorama, rtol=1e-5)
|
|
|
|
def test_selector_with_small_dataset(self):
|
|
"""Test ID selectors with dataset smaller than batch size"""
|
|
d, nb, nt, nq, nlevels, k = 32, 100, 200, 10, 4, 5
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=888)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
params = faiss.SearchParameters()
|
|
params.sel = faiss.IDSelectorRange(20, 60)
|
|
|
|
D_regular, I_regular = index_regular.search(
|
|
xq, k, params=params
|
|
)
|
|
D_panorama, I_panorama = index_panorama.search(
|
|
xq, k, params=params
|
|
)
|
|
|
|
self.assertTrue(np.all(I_panorama >= 20))
|
|
self.assertTrue(np.all(I_panorama < 60))
|
|
|
|
np.testing.assert_array_equal(I_regular, I_panorama)
|
|
np.testing.assert_allclose(D_regular, D_panorama, rtol=1e-5)
|
|
|
|
def test_selector_excludes_all(self):
|
|
"""Test selector that excludes all results"""
|
|
d, nb, nt, nq, nlevels, k = 32, 300, 400, 5, 4, 10
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=999)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
params = faiss.SearchParameters()
|
|
params.sel = faiss.IDSelectorRange(nb + 100, nb + 200)
|
|
|
|
D, I = index_panorama.search(xq, k, params=params)
|
|
self.assertTrue(np.all(I == -1))
|
|
|
|
# Batch size and edge case tests
|
|
|
|
def test_batch_boundaries(self):
|
|
"""Test correctness at various batch size boundaries"""
|
|
d, nq, k = 128, 10, 15
|
|
# random train not needed for Flat indexes
|
|
# seed the (otherwise unseeded) query draw so runs are reproducible
|
|
np.random.seed(1234)
|
|
xq = np.random.rand(nq, d).astype("float32")
|
|
|
|
for metric in self.METRICS:
|
|
# Use index's batch_size
|
|
probe_index = self.create_panorama(d, nlevels=8, metric=metric)
|
|
batch_size = probe_index.batch_size
|
|
|
|
test_sizes = [
|
|
batch_size - 1,
|
|
batch_size,
|
|
batch_size * 2,
|
|
batch_size * 3,
|
|
batch_size * 5 - 1,
|
|
]
|
|
for nb in test_sizes:
|
|
with self.subTest(nb=nb, metric=metric):
|
|
np.random.seed(987)
|
|
xb = np.random.rand(nb, d).astype("float32")
|
|
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels=8, xb=xb, metric=metric
|
|
)
|
|
|
|
D_regular, I_regular = index_regular.search(xq, k)
|
|
D_panorama, I_panorama = index_panorama.search(xq, k)
|
|
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
def test_batch_size(self):
|
|
"""Test correctness across different internal batch sizes"""
|
|
d, nb, nt, nq, nlevels = 128, 10000, 15000, 10, 8
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=4242)
|
|
|
|
for metric in self.METRICS:
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
|
|
for k, bs in [(1, 1), (10, 128), (100, 4096)]:
|
|
with self.subTest(metric=metric, k=k, batch_size=bs):
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb=xb, batch_size=bs, metric=metric
|
|
)
|
|
D_regular, I_regular = index_regular.search(xq, k)
|
|
D_panorama, I_panorama = index_panorama.search(xq, k)
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
def test_empty_result_handling(self):
|
|
"""Test handling of empty search results (shapes only)"""
|
|
d, nb, nt, nq, nlevels, k = 32, 100, 200, 10, 4, 10
|
|
_, xb, _ = self.generate_data(d, nt, nb, nq, seed=111)
|
|
xq = (
|
|
np.random.rand(nq, d).astype("float32") + 10.0
|
|
) # Queries far from database
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index = self.create_panorama(d, nlevels, xb, metric=metric)
|
|
D, I = index.search(xq, k)
|
|
|
|
self.assertEqual(D.shape, (nq, k))
|
|
self.assertEqual(I.shape, (nq, k))
|
|
|
|
def test_very_small_dataset(self):
|
|
"""Test with dataset smaller than batch size (< 128 vectors)"""
|
|
test_cases = [10, 50, 100]
|
|
|
|
for metric in self.METRICS:
|
|
for nb in test_cases:
|
|
with self.subTest(metric=metric, nb=nb):
|
|
d, nt, nlevels, nq = 32, max(nb, 100), 4, 10
|
|
k = min(3, nb)
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=666 + nb)
|
|
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
D_regular, I_regular = index_regular.search(xq, k)
|
|
D_panorama, I_panorama = index_panorama.search(xq, k)
|
|
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
def test_range_search_edge_cases(self):
|
|
"""Test range search with extreme radius values"""
|
|
d, nb, nt, nq, nlevels = 32, 500, 700, 10, 4
|
|
xt, xb, xq = self.generate_data(d, nt, nb, nq, seed=777)
|
|
|
|
for metric in self.METRICS:
|
|
index_regular = self.create_flat(d, metric=metric)
|
|
index_panorama = self.create_panorama(d, nlevels, metric=metric)
|
|
|
|
for radius in [0.01, 0.1, 100.0, 1000.0]:
|
|
with self.subTest(metric=metric, radius=radius):
|
|
lims_reg, D_reg, I_reg = index_regular.range_search(
|
|
xq, radius
|
|
)
|
|
lims_pan, D_pan, I_pan = index_panorama.range_search(
|
|
xq, radius
|
|
)
|
|
|
|
np.testing.assert_array_equal(lims_reg, lims_pan)
|
|
|
|
if len(I_reg) > 0:
|
|
self.assert_range_results_equal(
|
|
lims_reg, D_reg, I_reg, lims_pan, D_pan, I_pan, nq
|
|
)
|
|
|
|
# Dynamic operations tests
|
|
|
|
def test_incremental_add(self):
|
|
"""Test adding vectors incrementally in multiple batches"""
|
|
d, nlevels, k = 128, 8, 15
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, metric=metric)
|
|
index_panorama = self.create_panorama(d, nlevels, metric=metric)
|
|
|
|
# Keep total nb under 100k
|
|
batch_sizes = [5000, 10000, 15000, 20000]
|
|
for batch_size in batch_sizes:
|
|
xb_batch = np.random.rand(batch_size, d).astype("float32")
|
|
index_regular.add(xb_batch)
|
|
index_panorama.add(xb_batch)
|
|
|
|
nq = 10
|
|
xq = np.random.rand(nq, d).astype("float32")
|
|
|
|
D_regular, I_regular = index_regular.search(xq, k)
|
|
D_panorama, I_panorama = index_panorama.search(xq, k)
|
|
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
def test_add_search_add_search(self):
|
|
"""Test interleaved add and search operations"""
|
|
d, nlevels, k = 32, 4, 5
|
|
np.random.seed(555)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, metric=metric)
|
|
index_panorama = self.create_panorama(d, nlevels, metric=metric)
|
|
|
|
# First add and search
|
|
xb1 = np.random.rand(200, d).astype("float32")
|
|
index_regular.add(xb1)
|
|
index_panorama.add(xb1)
|
|
|
|
xq1 = np.random.rand(10, d).astype("float32")
|
|
|
|
D_reg_1, I_reg_1 = index_regular.search(xq1, k)
|
|
D_pan_1, I_pan_1 = index_panorama.search(xq1, k)
|
|
self.assert_search_results_equal(
|
|
D_reg_1, I_reg_1, D_pan_1, I_pan_1
|
|
)
|
|
|
|
# Second add and search
|
|
xb2 = np.random.rand(300, d).astype("float32")
|
|
index_regular.add(xb2)
|
|
index_panorama.add(xb2)
|
|
|
|
xq2 = np.random.rand(10, d).astype("float32")
|
|
D_reg_2, I_reg_2 = index_regular.search(xq2, k)
|
|
D_pan_2, I_pan_2 = index_panorama.search(xq2, k)
|
|
self.assert_search_results_equal(
|
|
D_reg_2, I_reg_2, D_pan_2, I_pan_2
|
|
)
|
|
|
|
def test_reconstruct(self):
|
|
"""Test reconstruct and reconstruct_n return original vectors"""
|
|
d, nb, nt, nq, nlevels = 964, 1000, 15000, 10, 128
|
|
_, xb, _ = self.generate_data(d, nt, nb, nq, seed=2025)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
# Test reconstruct for single vector
|
|
idx = 123
|
|
v_panorama = index_panorama.reconstruct(idx)
|
|
np.testing.assert_array_equal(xb[idx], v_panorama)
|
|
|
|
# Test reconstruct_n for range of vectors
|
|
start_idx, n_vectors = 120, 10
|
|
vn_panorama = index_panorama.reconstruct_n(start_idx, n_vectors)
|
|
np.testing.assert_array_equal(
|
|
xb[start_idx : start_idx + n_vectors], vn_panorama
|
|
)
|
|
|
|
def test_remove_ids_then_add(self):
|
|
"""Test removing vectors with remove_ids() then adding more vectors"""
|
|
d, nb, nt, nq, nlevels, k = 964, 50000, 0, 10, 128, 15
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=2026)
|
|
|
|
xb1 = xb[: nb // 2]
|
|
xb2 = xb[nb // 2 :]
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, xb1, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb1, metric=metric
|
|
)
|
|
|
|
# Remove every even ID
|
|
ids_to_remove = np.arange(0, nb, 2, dtype=np.int64)
|
|
|
|
nremove_regular = index_regular.remove_ids(ids_to_remove)
|
|
nremove_panorama = index_panorama.remove_ids(ids_to_remove)
|
|
|
|
# Verify same number of IDs removed
|
|
self.assertEqual(nremove_regular, nremove_panorama)
|
|
|
|
# Verify ntotal updated correctly
|
|
self.assertEqual(index_regular.ntotal, index_panorama.ntotal)
|
|
|
|
# Verify search results match between regular and panorama
|
|
D_reg_1, I_reg_1 = index_regular.search(xq, k)
|
|
D_pan_1, I_pan_1 = index_panorama.search(xq, k)
|
|
self.assert_search_results_equal(
|
|
D_reg_1, I_reg_1, D_pan_1, I_pan_1
|
|
)
|
|
|
|
# Second add and search
|
|
index_regular.add(xb2)
|
|
index_panorama.add(xb2)
|
|
|
|
# Verify ntotal updated correctly
|
|
self.assertEqual(index_regular.ntotal, index_panorama.ntotal)
|
|
|
|
# Verify second search results match
|
|
D_reg_2, I_reg_2 = index_regular.search(xq, k)
|
|
D_pan_2, I_pan_2 = index_panorama.search(xq, k)
|
|
self.assert_search_results_equal(
|
|
D_reg_2, I_reg_2, D_pan_2, I_pan_2
|
|
)
|
|
|
|
def test_merge_from(self):
|
|
"""Test merging indexes with merge_from()"""
|
|
d, nb, nt, nq, nlevels, k, batch_size = 964, 50000, 0, 10, 128, 15, 16
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=2027)
|
|
|
|
# Split data and create two separate indexes
|
|
split = nb // 2
|
|
xb1 = xb[:split]
|
|
xb2 = xb[split:]
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index1_regular = self.create_flat(d, xb1, metric=metric)
|
|
index2_regular = self.create_flat(d, xb2, metric=metric)
|
|
|
|
index1_panorama = self.create_panorama(
|
|
d, nlevels, xb1, batch_size, metric=metric
|
|
)
|
|
index2_panorama = self.create_panorama(
|
|
d, nlevels * 2, xb2, batch_size // 2, metric=metric
|
|
)
|
|
|
|
# Merge second index into first
|
|
index1_regular.merge_from(index2_regular, 0)
|
|
index1_panorama.merge_from(index2_panorama, 0)
|
|
|
|
# Verify ntotal after merge
|
|
self.assertEqual(index1_regular.ntotal, index1_panorama.ntotal)
|
|
self.assertEqual(index2_regular.ntotal, index2_panorama.ntotal)
|
|
|
|
# Verify merged index results
|
|
D_regular, I_regular = index1_regular.search(xq, k)
|
|
D_panorama, I_panorama = index1_panorama.search(xq, k)
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
def test_permute_entries(self):
|
|
"""Test permuting entries with permute_entries()"""
|
|
d, nb, nt, nq, nlevels, k = 964, 50000, 0, 20, 128, 10
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=2028)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_regular = self.create_flat(d, xb, metric=metric)
|
|
index_panorama = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
|
|
# Create a random permutation
|
|
np.random.seed(1234)
|
|
perm = np.random.permutation(nb).astype(np.int64)
|
|
|
|
# Apply permutation to both indexes
|
|
index_regular.permute_entries(perm)
|
|
index_panorama.permute_entries(perm)
|
|
|
|
# Search after permutation
|
|
D_regular, I_regular = index_regular.search(xq, k)
|
|
D_panorama, I_panorama = index_panorama.search(xq, k)
|
|
|
|
# Verify permuted indexes match each other
|
|
self.assert_search_results_equal(
|
|
D_regular, I_regular, D_panorama, I_panorama
|
|
)
|
|
|
|
def test_serialization(self):
|
|
"""Test write/read Panorama indexes preserves search results"""
|
|
d, nb, nt, nq, nlevels, k = 964, 10000, 15000, 100, 128, 20
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=2024)
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index = self.create_panorama(d, nlevels, xb, metric=metric)
|
|
|
|
D_before, I_before = index.search(xq, k)
|
|
index_after = faiss.deserialize_index(
|
|
faiss.serialize_index(index)
|
|
)
|
|
D_after, I_after = index_after.search(xq, k)
|
|
|
|
np.testing.assert_array_equal(I_before, I_after)
|
|
np.testing.assert_array_equal(D_before, D_after)
|
|
|
|
def test_ratio_dims_scanned(self):
|
|
"""Test the correctness of the ratio of dimensions scanned"""
|
|
d, nb, nq, k = 128, 500000, 10, 1
|
|
|
|
# Setup: All vectors in the dataset are [1, 1, ..., 1], except for
|
|
# one, which is [0, 0, ..., 0]. The queries are [0, 0, ..., 0]. This
|
|
# ensures that pruning happens as early as it can. As a result, the
|
|
# ratio of dimensions scanned should be approximately (1 / nlevels).
|
|
xb = np.ones((nb, d)).astype("float32")
|
|
xb[-1] = 0
|
|
xq = np.zeros((nq, d)).astype("float32")
|
|
|
|
for metric in self.METRICS:
|
|
with self.subTest(metric=metric):
|
|
index_base = self.create_flat(d, xb, metric=metric)
|
|
D_base, I_base = index_base.search(xq, k)
|
|
|
|
nt = faiss.omp_get_max_threads()
|
|
faiss.omp_set_num_threads(1)
|
|
|
|
ratios = []
|
|
nlevels_list = [1, 2, 4, 8, 16, 32]
|
|
for nlevels in nlevels_list:
|
|
with self.subTest(nlevels=nlevels):
|
|
faiss.cvar.indexPanorama_stats.reset()
|
|
index = self.create_panorama(
|
|
d, nlevels, xb, metric=metric
|
|
)
|
|
D, I = index.search(xq, k)
|
|
self.assert_search_results_equal(D_base, I_base, D, I)
|
|
|
|
ratios.append(
|
|
faiss.cvar.indexPanorama_stats.ratio_dims_scanned
|
|
)
|
|
|
|
expected_ratios = [1 / nlevels for nlevels in nlevels_list]
|
|
np.testing.assert_allclose(ratios, expected_ratios, atol=1e-3)
|
|
|
|
faiss.omp_set_num_threads(nt)
|