Files
faiss/tests/test_search_params.py
YingzuoLiu 3778f5892d Fix IDSelector handling in unbounded HNSW search (#5508)
Summary:
- apply `SearchParametersHNSW.sel` to the unbounded HNSW search path
- keep filtered nodes available for graph traversal while excluding them from the result set
- add regression coverage for L2 and inner-product search, restrictive `efSearch`, and an empty selector

## Root cause

The bounded HNSW path applies the selector through its result handler. The unbounded path did not receive the selector and used the same heap for traversal pruning and final results, so IDs rejected by `SearchParameters.sel` could be returned.

## Implementation

When a selector is present, the unbounded path maintains a separate result heap containing only accepted IDs. The existing candidate heap remains unchanged and can still contain rejected nodes, preserving traversal through them. When no selector is present, the existing unfiltered result behavior is preserved.

## Validation

- issue reproducer: unbounded search now returns ID 1 at distance 100.0 instead of rejected ID 0
- `tests/test_search_params.py`: 52 passed
- HNSW C++ tests: 14 passed
- full CPU C++ test suite: 278 passed, 5 skipped
- clang-format and `git diff --check`: passed

Fixes https://github.com/facebookresearch/faiss/issues/5504

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

Reviewed By: alibeklfc

Differential Revision: D120364656

Pulled By: mnorris11

fbshipit-source-id: 074234b80cf7b2e06a85d57489a1c214dfd3195b
2026-09-16 17:55:51 -07:00

894 lines
32 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 numpy as np
import faiss
import unittest
import sys
from common_faiss_tests import for_all_simd_levels
from faiss.contrib import datasets
from faiss.contrib.evaluation import sort_range_res_2, check_ref_range_results
faiss.omp_set_num_threads(4)
@for_all_simd_levels
class TestSelector(unittest.TestCase):
"""
Test the IDSelector filtering for as many (index class, id selector class)
combinations as possible.
"""
def do_test_id_selector(
self,
index_key,
id_selector_type="batch",
mt=faiss.METRIC_L2,
k=10,
use_heap=True,
):
"""Verify that the id selector returns the subset of results that are
members according to the IDSelector.
Supports id_selector_type="batch", "bitmap", "range",
"range_sorted", "and", "or", "xor"
"""
d = 32 # make sure dimension is multiple of 8 for binary
ds = datasets.SyntheticDataset(d, 1000, 100, 20)
if index_key == "BinaryFlat":
rs = np.random.RandomState(123)
xb = rs.randint(256, size=(ds.nb, d // 8), dtype="uint8")
xq = rs.randint(256, size=(ds.nq, d // 8), dtype="uint8")
index = faiss.IndexBinaryFlat(d)
index.use_heap = use_heap
# Use smaller radius for Hamming distance
base_radius = 4
is_binary = True
else:
xb = ds.get_database()
xq = ds.get_queries()
xt = ds.get_train()
index = faiss.index_factory(d, index_key, mt)
index.train(xt)
base_radius = float("inf") # Will be set based on results
is_binary = False
# reference result
if "range" in id_selector_type:
subset = np.arange(30, 80).astype("int64")
elif id_selector_type == "or":
lhs_rs = np.random.RandomState(123)
lhs_subset = lhs_rs.choice(ds.nb, 50, replace=False).astype("int64")
rhs_rs = np.random.RandomState(456)
rhs_subset = rhs_rs.choice(ds.nb, 20, replace=False).astype("int64")
subset = np.union1d(lhs_subset, rhs_subset)
elif id_selector_type == "and":
lhs_rs = np.random.RandomState(123)
lhs_subset = lhs_rs.choice(ds.nb, 50, replace=False).astype("int64")
rhs_rs = np.random.RandomState(456)
rhs_subset = rhs_rs.choice(ds.nb, 10, replace=False).astype("int64")
subset = np.intersect1d(lhs_subset, rhs_subset)
elif id_selector_type == "xor":
lhs_rs = np.random.RandomState(123)
lhs_subset = lhs_rs.choice(ds.nb, 50, replace=False).astype("int64")
rhs_rs = np.random.RandomState(456)
rhs_subset = rhs_rs.choice(ds.nb, 40, replace=False).astype("int64")
subset = np.setxor1d(lhs_subset, rhs_subset)
else:
rs = np.random.RandomState(123)
subset = rs.choice(ds.nb, 50, replace=False).astype("int64")
index.add(xb[subset])
if "IVF" in index_key and id_selector_type == "range_sorted":
self.assertTrue(index.check_ids_sorted())
Dref, Iref0 = index.search(xq, k)
Iref = subset[Iref0]
Iref[Iref0 < 0] = -1
if base_radius == float("inf"):
radius = float(Dref[Iref > 0].max()) * 1.01
else:
radius = base_radius
try:
Rlims_ref, RDref, RIref = index.range_search(xq, radius)
except RuntimeError as e:
if "not implemented" in str(e):
have_range_search = False
else:
raise
else:
RIref = subset[RIref]
# normalize the range search results
RDref, RIref = sort_range_res_2(Rlims_ref, RDref, RIref)
have_range_search = True
# result with selector: fill full database and search with selector
index.reset()
index.add(xb)
if id_selector_type == "range":
sel = faiss.IDSelectorRange(30, 80)
elif id_selector_type == "range_sorted":
sel = faiss.IDSelectorRange(30, 80, True)
elif id_selector_type == "array":
sel = faiss.IDSelectorArray(subset)
elif id_selector_type == "bitmap":
bitmap = np.zeros(ds.nb, dtype=bool)
bitmap[subset] = True
bitmap = np.packbits(bitmap, bitorder="little")
sel = faiss.IDSelectorBitmap(bitmap)
elif id_selector_type == "not":
ssubset = set(subset)
inverse_subset = np.array(
[i for i in range(ds.nb) if i not in ssubset]
).astype("int64")
sel = faiss.IDSelectorNot(faiss.IDSelectorBatch(inverse_subset))
elif id_selector_type == "or":
sel = faiss.IDSelectorOr(
faiss.IDSelectorBatch(lhs_subset),
faiss.IDSelectorBatch(rhs_subset),
)
elif id_selector_type == "and":
sel = faiss.IDSelectorAnd(
faiss.IDSelectorBatch(lhs_subset),
faiss.IDSelectorBatch(rhs_subset),
)
elif id_selector_type == "xor":
sel = faiss.IDSelectorXOr(
faiss.IDSelectorBatch(lhs_subset),
faiss.IDSelectorBatch(rhs_subset),
)
else:
sel = faiss.IDSelectorBatch(subset)
params = (
faiss.SearchParametersIVF(sel=sel)
if "IVF" in index_key
else (
faiss.SearchParametersPQ(sel=sel)
if "PQ" in index_key
else faiss.SearchParameters(sel=sel)
)
)
Dnew, Inew = index.search(xq, k, params=params)
if is_binary:
# For binary indexes, we need to check:
# 1. All returned IDs are valid (in the subset or -1)
# 2. The distances match
# Check that all returned IDs are valid
valid_ids = np.ones_like(Inew, dtype=bool)
# Create a mask of valid IDs (those in subset)
subset_set = set(subset) # Convert to set for O(1) lookups
# Handle -1 values separately (they're always valid)
valid_ids = np.logical_or(
Inew == -1, np.isin(Inew, list(subset_set))
)
self.assertTrue(
np.all(valid_ids), "Some returned IDs are not in the subset"
)
# Check that distances match
np.testing.assert_almost_equal(Dref, Dnew, decimal=5)
else:
# For non-binary indexes, we can do exact comparison
np.testing.assert_array_equal(Iref, Inew)
np.testing.assert_almost_equal(Dref, Dnew, decimal=5)
if have_range_search:
Rlims_new, RDnew, RInew = index.range_search(
xq, radius, params=params
)
np.testing.assert_array_equal(Rlims_ref, Rlims_new)
RDref, RIref = sort_range_res_2(Rlims_ref, RDref, RIref)
if is_binary:
# For binary indexes, check that all returned IDs are valid
valid_ids = np.ones(len(RInew), dtype=bool)
# Use vectorized operation instead of loop
subset_set = set(subset) # Convert to set for O(1) lookups
valid_ids = np.isin(RInew, list(subset_set))
self.assertTrue(
np.all(valid_ids),
"Some range search IDs are not in the subset",
)
# Check that distances match
np.testing.assert_almost_equal(RDref, RDnew, decimal=5)
else:
# For non-binary indexes, we can do exact comparison
np.testing.assert_array_equal(RIref, RInew)
np.testing.assert_almost_equal(RDref, RDnew, decimal=5)
def test_IVFFlat(self):
self.do_test_id_selector("IVF32,Flat")
def test_IVFFlat_range_sorted(self):
self.do_test_id_selector("IVF32,Flat", id_selector_type="range_sorted")
def test_IVFPQ(self):
self.do_test_id_selector("IVF32,PQ4x4np")
def test_IVFPQfs(self):
self.do_test_id_selector("IVF32,PQ4x4fs")
def test_IVFPQfs_k1(self):
self.do_test_id_selector("IVF32,PQ4x4fs", k=1)
def test_IVFPQfs_k40(self):
# test reservoir codepath
self.do_test_id_selector("IVF32,PQ4x4fs", k=40)
def test_IVFSQ(self):
self.do_test_id_selector("IVF32,SQ8")
def test_pretrans(self):
self.do_test_id_selector("PCA16,IVF32,Flat")
def test_SQ(self):
self.do_test_id_selector("SQ8")
def test_Flat(self):
self.do_test_id_selector("Flat")
def test_Flat_IP(self):
self.do_test_id_selector("Flat", mt=faiss.METRIC_INNER_PRODUCT)
def test_Flat_id_range(self):
self.do_test_id_selector("Flat", id_selector_type="range")
def test_Flat_IP_id_range(self):
self.do_test_id_selector(
"Flat", id_selector_type="range", mt=faiss.METRIC_INNER_PRODUCT
)
def test_Flat_id_array(self):
self.do_test_id_selector("Flat", id_selector_type="array")
def test_Flat_IP_id_array(self):
self.do_test_id_selector(
"Flat", id_selector_type="array", mt=faiss.METRIC_INNER_PRODUCT
)
def test_Flat_id_bitmap(self):
self.do_test_id_selector("Flat", id_selector_type="bitmap")
def test_Flat_id_not(self):
self.do_test_id_selector("Flat", id_selector_type="not")
def test_Flat_id_or(self):
self.do_test_id_selector("Flat", id_selector_type="or")
# not implemented
# def test_PQ(self):
# self.do_test_id_selector("PQ4x4np")
# def test_AQ(self):
# self.do_test_id_selector("RQ3x4")
def do_test_id_selector_weak(self, index_key):
"""verify that the selected subset is the subset in the list"""
ds = datasets.SyntheticDataset(32, 1000, 100, 20)
index = faiss.index_factory(ds.d, index_key)
index.train(ds.get_train())
index.add(ds.get_database())
k = 10
Dref, Iref = index.search(ds.get_queries(), k)
# reference result
rs = np.random.RandomState(123)
subset = rs.choice(ds.nb, 50, replace=False).astype("int64")
sel = faiss.IDSelectorBatch(subset)
params = faiss.SearchParametersHNSW()
params.sel = sel
Dnew, Inew = index.search(ds.get_queries(), k, params=params)
mask = np.zeros(ds.nb, dtype=bool)
mask[subset] = True
for q in range(len(Iref)):
(mask_q,) = np.where(mask[Iref[q]])
l = len(mask_q)
np.testing.assert_array_equal(Iref[q, mask_q], Inew[q, :l])
np.testing.assert_array_equal(Dref[q, mask_q], Dnew[q, :l])
def test_HSNW(self):
self.do_test_id_selector_weak("HNSW")
def make_line_index(self, metric=faiss.METRIC_L2):
"""build an HNSW index over 4 points on a line"""
xb = np.array(
[[0.0, 0.0], [10.0, 0.0], [20.0, 0.0], [30.0, 0.0]],
dtype=np.float32,
)
index = faiss.IndexHNSWFlat(2, 4, metric)
index.hnsw.efConstruction = 40
index.add(xb)
return xb, index
def unbounded_params(self, ef_search, sel):
return faiss.SearchParametersHNSW(
efSearch=ef_search, bounded_queue=False, sel=sel
)
def test_HNSW_unbounded_queue_id_selector(self):
cases = (
(faiss.METRIC_L2, [[0.0, 0.0]], 100.0),
(faiss.METRIC_INNER_PRODUCT, [[1.0, 0.0]], 10.0),
)
for metric, query, expected_distance in cases:
_, index = self.make_line_index(metric)
xq = np.array(query, dtype=np.float32)
for ef_search in (1, 40):
with self.subTest(metric=metric, ef_search=ef_search):
params = self.unbounded_params(
ef_search, faiss.IDSelectorRange(1, 2)
)
D, I = index.search(xq, 1, params=params)
np.testing.assert_array_equal(I, [[1]])
np.testing.assert_array_equal(D, [[expected_distance]])
def test_HNSW_unbounded_queue_selector_rejects_all(self):
_, index = self.make_line_index()
params = self.unbounded_params(40, faiss.IDSelectorRange(4, 4))
xq = np.zeros((1, 2), dtype=np.float32)
_, I = index.search(xq, 1, params=params)
np.testing.assert_array_equal(I, [[-1]])
def test_HNSW_unbounded_queue_rejected_starting_node(self):
xb, index = self.make_line_index()
starting_id = index.hnsw.entry_point
allowed_id = (starting_id + 1) % len(xb)
params = self.unbounded_params(
1, faiss.IDSelectorRange(allowed_id, allowed_id + 1)
)
# The query coincides with the entry point (distance zero), so the
# upper-level search keeps it as the level-0 starting node.
xq = xb[starting_id : starting_id + 1]
expected_distance = np.sum((xq[0] - xb[allowed_id]) ** 2)
D, I = index.search(xq, 1, params=params)
np.testing.assert_array_equal(I, [[allowed_id]])
np.testing.assert_array_equal(D, [[expected_distance]])
def test_idmap(self):
ds = datasets.SyntheticDataset(32, 100, 100, 20)
rs = np.random.RandomState(123)
ids = rs.choice(10000, size=100, replace=False)
mask = ids % 2 == 0
index = faiss.index_factory(ds.d, "IDMap,SQ8")
index.train(ds.get_train())
# ref result
index.add_with_ids(ds.get_database()[mask], ids[mask])
Dref, Iref = index.search(ds.get_queries(), 10)
# with selector
index.reset()
index.add_with_ids(ds.get_database(), ids)
valid_ids = ids[mask]
sel = faiss.IDSelectorTranslated(
index, faiss.IDSelectorBatch(valid_ids)
)
Dnew, Inew = index.search(
ds.get_queries(), 10, params=faiss.SearchParameters(sel=sel)
)
np.testing.assert_array_equal(Iref, Inew)
np.testing.assert_array_almost_equal(Dref, Dnew, decimal=5)
# let the IDMap::search add the translation...
Dnew, Inew = index.search(
ds.get_queries(),
10,
params=faiss.SearchParameters(sel=faiss.IDSelectorBatch(valid_ids)),
)
np.testing.assert_array_equal(Iref, Inew)
np.testing.assert_array_almost_equal(Dref, Dnew, decimal=5)
def test_idmap_range_empty_params(self):
# This test crashes without checking both params && params->sel
# are non null in IndexIDMap::range_search().
index = faiss.IndexIDMap(faiss.IndexFlatL2(2))
xb = np.array(
[
[0, 0],
[1, 0],
[0, 2],
],
dtype="float32",
)
ids = np.array([10, 20, 30], dtype="int64")
index.add_with_ids(xb, ids)
xq = np.array([[0, 0]], dtype="float32")
lims_ref, D_ref, I_ref = index.range_search(xq, 5.0)
lims, D, I = index.range_search(
xq, 5.0, params=faiss.SearchParameters()
)
np.testing.assert_array_equal(lims_ref, lims)
np.testing.assert_array_equal(sorted(I_ref), sorted(I))
np.testing.assert_array_equal(sorted(D_ref), sorted(D))
def test_idmap_range_ivf_params(self):
quantizer = faiss.IndexFlatL2(1)
quantizer.add(np.array([[0.0], [100.0]], dtype="float32"))
base = faiss.IndexIVFFlat(quantizer, 1, 2, faiss.METRIC_L2)
base.nprobe = 1
index = faiss.IndexIDMap(base)
index.add_with_ids(
np.array([[0.0], [100.0]], dtype="float32"),
np.array([10, 20], dtype="int64"),
)
params = faiss.SearchParametersIVF(nprobe=2)
params.sel = faiss.IDSelectorBatch(
np.array([10, 20], dtype="int64")
)
lims, distances, labels = index.range_search(
np.array([[0.0]], dtype="float32"),
10001.0,
params=params,
)
order = np.argsort(labels)
labels = labels[order]
distances = distances[order]
np.testing.assert_array_equal(lims, [0, 2])
np.testing.assert_array_equal(distances, [0.0, 10000.0])
np.testing.assert_array_equal(labels, [10, 20])
def test_bounds(self):
# https://github.com/facebookresearch/faiss/issues/3156
d = 64 # dimension
nb = 100000 # database size
xb = np.random.random((nb, d))
index_ip = faiss.IndexFlatIP(d)
index_ip.add(xb)
index_l2 = faiss.IndexFlatIP(d)
index_l2.add(xb)
out_of_bounds_id = nb + 15 # + 14 or lower will work fine
id_selector = faiss.IDSelectorArray([out_of_bounds_id])
search_params = faiss.SearchParameters(sel=id_selector)
# ignores out of bound, does not crash
distances, indices = index_ip.search(xb[:2], k=3, params=search_params)
distances, indices = index_l2.search(xb[:2], k=3, params=search_params)
def test_BinaryFlat(self):
self.do_test_id_selector("BinaryFlat")
def test_BinaryFlat_id_range(self):
self.do_test_id_selector("BinaryFlat", id_selector_type="range")
def test_BinaryFlat_id_array(self):
self.do_test_id_selector("BinaryFlat", id_selector_type="array")
def test_BinaryFlat_no_heap(self):
self.do_test_id_selector("BinaryFlat", use_heap=False)
@for_all_simd_levels
class TestSearchParams(unittest.TestCase):
def do_test_with_param(self, index_key, ps_params, params):
"""
Test equivalence between setting
1. param_name_2 = value with ParameterSpace
2. pass in a SearchParameters with param_name = value
"""
ds = datasets.SyntheticDataset(32, 1000, 100, 20)
index = faiss.index_factory(ds.d, index_key)
if index_key.startswith("PQ"):
index.polysemous_training.n_iter = 50000
index.polysemous_training.n_redo = 1
index.train(ds.get_train())
index.add(ds.get_database())
I0, D0 = index.search(ds.get_queries(), 10)
Dnew, Inew = index.search(ds.get_queries(), 10, params=params)
# make sure the parameter does indeed change the result...
self.assertFalse(np.all(Inew == I0))
for param_name, value in ps_params.items():
faiss.ParameterSpace().set_index_parameter(index, param_name, value)
Dref, Iref = index.search(ds.get_queries(), 10)
np.testing.assert_array_equal(Iref, Inew)
np.testing.assert_array_equal(Dref, Dnew)
def test_nprobe(self):
self.do_test_with_param(
"IVF32,Flat", {"nprobe": 3}, faiss.SearchParametersIVF(nprobe=3)
)
def test_efSearch(self):
self.do_test_with_param(
"HNSW", {"efSearch": 4}, faiss.SearchParametersHNSW(efSearch=4)
)
def test_quantizer_hnsw(self):
self.do_test_with_param(
"IVF200_HNSW,Flat",
{"quantizer_efSearch": 5, "nprobe": 10},
faiss.SearchParametersIVF(
nprobe=10,
quantizer_params=faiss.SearchParametersHNSW(efSearch=5),
),
)
def test_PQ_polysemous_ht(self):
self.do_test_with_param(
"PQ4x8",
{"ht": 10},
faiss.SearchParametersPQ(
polysemous_ht=10, search_type=faiss.IndexPQ.ST_polysemous
),
)
def test_max_codes(self):
"tests whether the max nb codes is taken into account"
ds = datasets.SyntheticDataset(32, 1000, 100, 20)
index = faiss.index_factory(ds.d, "IVF32,Flat")
index.train(ds.get_train())
index.add(ds.get_database())
stats = faiss.cvar.indexIVF_stats
stats.reset()
D0, I0 = index.search(
ds.get_queries(), 10, params=faiss.SearchParametersIVF(nprobe=8)
)
ndis0 = stats.ndis
target_ndis = ndis0 // ds.nq # a few queries will be below, a few above
for q in range(ds.nq):
stats.reset()
Dq, Iq = index.search(
ds.get_queries()[q : q + 1],
10,
params=faiss.SearchParametersIVF(
nprobe=8, max_codes=target_ndis
),
)
self.assertLessEqual(stats.ndis, target_ndis)
if stats.ndis < target_ndis:
np.testing.assert_equal(I0[q], Iq[0])
def test_ownership(self):
# see https://github.com/facebookresearch/faiss/issues/2996
subset = np.arange(0, 50)
sel = faiss.IDSelectorBatch(subset)
self.assertTrue(sel.this.own())
params = faiss.SearchParameters(sel=sel)
self.assertTrue(sel.this.own()) # otherwise mem leak!
# this is a somewhat fragile test because it assumes the
# gc decreases refcounts immediately.
prev_count = sys.getrefcount(sel)
del params
new_count = sys.getrefcount(sel)
self.assertEqual(new_count, prev_count - 1)
# check for other objects as well
sel1 = faiss.IDSelectorBatch([1, 2, 3])
sel2 = faiss.IDSelectorBatch([4, 5, 6])
sel = faiss.IDSelectorAnd(sel1, sel2)
# make storage is still managed by python
self.assertTrue(sel1.this.own())
self.assertTrue(sel2.this.own())
class TestSelectorCallback(unittest.TestCase):
def test(self):
ds = datasets.SyntheticDataset(32, 1000, 100, 20)
index = faiss.index_factory(ds.d, "IVF32,Flat")
index.train(ds.get_train())
k = 10
rs = np.random.RandomState(123)
subset = rs.choice(ds.nb, 50, replace=False)
params = faiss.SearchParametersIVF(
sel=faiss.IDSelectorBatch(subset), nprobe=4
)
Dref, Iref = index.search(ds.get_queries(), k, params=params)
def is_member(idx):
return idx in subset
params = faiss.SearchParametersIVF(
sel=faiss.PyCallbackIDSelector(is_member), nprobe=4
)
Dnew, Inew = index.search(ds.get_queries(), k, params=params)
np.testing.assert_array_equal(Iref, Inew)
np.testing.assert_almost_equal(Dref, Dnew, decimal=5)
class TestSortedIDSelectorRange(unittest.TestCase):
"""to test the sorted id bounds, there are a few cases to consider"""
def do_test_sorted(self, imin, imax, n=100):
selr = faiss.IDSelectorRange(imin, imax, True)
sp = faiss.swig_ptr
for seed in range(10):
rs = np.random.RandomState(seed)
ids = rs.choice(30, n).astype("int64")
ids.sort()
j01 = np.zeros(2, dtype="uint64")
selr.find_sorted_ids_bounds(
len(ids), sp(ids), sp(j01[:1]), sp(j01[1:])
)
j0, j1 = j01.astype(int)
(ref_idx,) = np.where((ids >= imin) & (ids < imax))
np.testing.assert_array_equal(ref_idx, np.arange(j0, j1))
def test_sorted_in_range(self):
self.do_test_sorted(10, 20)
def test_sorted_out_0(self):
self.do_test_sorted(-10, 20)
def test_sorted_out_1(self):
self.do_test_sorted(10, 40)
def test_sorted_in_range_smalln(self):
self.do_test_sorted(10, 20, n=5)
def test_12_92(self):
selr = faiss.IDSelectorRange(30, 80, True)
ids = np.array([12, 92], dtype="int64")
j01 = np.zeros(2, dtype="uint64")
sp = faiss.swig_ptr
selr.find_sorted_ids_bounds(len(ids), sp(ids), sp(j01[:1]), sp(j01[1:]))
assert j01[0] >= j01[1]
class TestPrecomputed(unittest.TestCase):
def do_test_knn_and_range(self, factory, range=True):
ds = datasets.SyntheticDataset(32, 10000, 100, 20)
index = faiss.index_factory(ds.d, factory)
index.train(ds.get_train())
index.add(ds.get_database())
index.nprobe = 5
Dref, Iref = index.search(ds.get_queries(), 10)
Dq, Iq = index.quantizer.search(ds.get_queries(), index.nprobe)
Dnew, Inew = index.search_preassigned(ds.get_queries(), 10, Iq, Dq)
np.testing.assert_equal(Iref, Inew)
np.testing.assert_allclose(Dref, Dnew, atol=1e-5)
if range:
r2 = float(np.median(Dref[:, 5]))
Lref, Dref, Iref = index.range_search(ds.get_queries(), r2)
assert Lref.size > 10 # make sure there is something to test...
Lnew, Dnew, Inew = index.range_search_preassigned(
ds.get_queries(), r2, Iq, Dq
)
check_ref_range_results(Lref, Dref, Iref, Lnew, Dnew, Inew)
def test_knn_and_range_Flat(self):
self.do_test_knn_and_range("IVF32,Flat")
def test_knn_and_range_SQ(self):
self.do_test_knn_and_range("IVF32,SQ8")
def test_knn_and_range_PQ(self):
self.do_test_knn_and_range("IVF32,PQ8x4np")
def test_knn_and_range_FS(self):
self.do_test_knn_and_range("IVF32,PQ8x4fs", range=False)
class TestIVFEarlyTermination(unittest.TestCase):
"""
Coverage for the new SearchParametersIVF early-stop fields:
- ensure_topk_full
- max_empty_result_buckets
- max_lists_num
"""
def _build(self, factory, mt=faiss.METRIC_L2, d=32):
ds = datasets.SyntheticDataset(d, 2000, 500, 30)
index = faiss.index_factory(d, factory, mt)
index.train(ds.get_train())
index.add(ds.get_database())
return ds, index
def test_ensure_topk_full_fills_heap(self):
ds, index = self._build("IVF32,Flat")
k = 10
params = faiss.SearchParametersIVF()
params.nprobe = 32
params.max_codes = k // 2 # tight budget: forces truncation
params.ensure_topk_full = False
_, I_tight = index.search(ds.get_queries(), k, params=params)
params.ensure_topk_full = True
_, I_full = index.search(ds.get_queries(), k, params=params)
# ensure_topk_full=true must produce at least as many valid slots.
miss_tight = np.sum(I_tight == -1)
miss_full = np.sum(I_full == -1)
self.assertLessEqual(miss_full, miss_tight)
# Test precondition: tight budget produced some misses.
self.assertGreater(miss_tight, 0)
def test_range_default_matches_baseline(self):
"""Default max_empty_result_buckets=0 must preserve pre-existing
range_search results byte-for-byte."""
ds, index = self._build("IVF32,Flat")
# Set nprobe via the index attribute for the no-params baseline.
index.nprobe = 8
radius = 2.0
Lref, Dref, Iref = index.range_search(ds.get_queries(), radius)
params = faiss.SearchParametersIVF()
params.nprobe = 8
# all new fields are at defaults
Lnew, Dnew, Inew = index.range_search(
ds.get_queries(), radius, params=params
)
np.testing.assert_array_equal(Lref, Lnew)
# Per-query (id, distance) pairs must match as sorted sets since
# within-query order is not guaranteed across OMP threads.
for q in range(ds.nq):
a = sorted(
zip(
Iref[Lref[q] : Lref[q + 1]].tolist(),
Dref[Lref[q] : Lref[q + 1]].tolist(),
)
)
b = sorted(
zip(
Inew[Lnew[q] : Lnew[q + 1]].tolist(),
Dnew[Lnew[q] : Lnew[q + 1]].tolist(),
)
)
self.assertEqual(a, b)
def test_range_early_exit_is_subset(self):
ds, index = self._build("IVF32,Flat")
# Radius chosen empirically against this synthetic dataset
# (d=32, nb=500, IVF32) so that several probed buckets per
# query yield zero in-radius hits — required to exercise
# the empty-bucket exit path. Too-tight or too-wide radii
# leave the test vacuous.
radius = 15.0
p_full = faiss.SearchParametersIVF()
p_full.nprobe = 32
p_full.max_empty_result_buckets = 0 # disabled
Lf, Df, If = index.range_search(ds.get_queries(), radius, params=p_full)
p_early = faiss.SearchParametersIVF()
p_early.nprobe = 32
p_early.max_empty_result_buckets = 1 # aggressive early-exit
Le, De, Ie = index.range_search(
ds.get_queries(), radius, params=p_early
)
# Early-exit must never expand the result set.
self.assertTrue(np.all(np.diff(Le) <= np.diff(Lf)))
# And each per-query id-set must be a subset of the full result.
for q in range(ds.nq):
full = set(If[Lf[q] : Lf[q + 1]].tolist())
early = set(Ie[Le[q] : Le[q + 1]].tolist())
self.assertTrue(early.issubset(full))
# At least one query must strictly shrink.
any_shrunk = bool(np.any(np.diff(Le) < np.diff(Lf)))
self.assertTrue(
any_shrunk,
"no query shrank: max_empty_result_buckets has no effect",
)
def test_range_early_exit_counter_resets_on_hit(self):
"""Tightening max_empty_result_buckets (1 vs 3) must never
produce *more* results. With 3 the loop tolerates two
intervening empty buckets between non-empty ones; with 1 it
terminates on the first empty bucket. Per-query result
counts must therefore satisfy tight ≤ relaxed."""
ds, index = self._build("IVF32,Flat")
radius = 15.0
p_tight = faiss.SearchParametersIVF()
p_tight.nprobe = 32
p_tight.max_empty_result_buckets = 1
Lt, _, _ = index.range_search(ds.get_queries(), radius, params=p_tight)
p_relaxed = faiss.SearchParametersIVF()
p_relaxed.nprobe = 32
p_relaxed.max_empty_result_buckets = 3
Lr, _, _ = index.range_search(
ds.get_queries(), radius, params=p_relaxed
)
self.assertTrue(np.all(np.diff(Lt) <= np.diff(Lr)))
def test_range_rejects_non_default_parallel_mode(self):
ds, index = self._build("IVF32,Flat")
ivf = faiss.downcast_index(index)
ivf.parallel_mode = 1
params = faiss.SearchParametersIVF()
params.nprobe = 4
params.max_empty_result_buckets = 2
with self.assertRaises(RuntimeError):
index.range_search(ds.get_queries()[:1], 1.0, params=params)
def test_ndis_stats_post_filter(self):
"""IndexIVFStats::ndis is documented as 'nb of distances
computed' — a post-filter quantity. With a 50%-keep selector,
ndis must drop because rejected codes don't have a distance
computed."""
ds, index = self._build("IVF32,Flat")
index.nprobe = 32
stats = faiss.cvar.indexIVF_stats
stats.reset()
index.search(ds.get_queries(), 10)
ndis_no_sel = stats.ndis
self.assertGreater(ndis_no_sel, 0)
# 50%-keep batch selector.
keep = np.arange(0, ds.nb, 2).astype("int64")
sel = faiss.IDSelectorBatch(keep)
params = faiss.SearchParametersIVF(nprobe=32, sel=sel)
stats.reset()
index.search(ds.get_queries(), 10, params=params)
ndis_with_sel = stats.ndis
self.assertLess(ndis_with_sel, ndis_no_sel)
# Loose-bound sanity: ~50% retention should drop ndis at least
# noticeably below the no-selector value.
self.assertLess(ndis_with_sel * 4, ndis_no_sel * 3)
def test_ensure_topk_full_with_restrictive_selector(self):
"""Under a 10%-keep selector and
max_codes < k, the heap must still be filled (no -1 entries)
because the post-filter break condition keeps probing until k
valid candidates have been observed."""
ds, index = self._build("IVF32,Flat")
# 10%-keep selector — 50 valid ids in 500-vector dataset.
keep = np.arange(0, ds.nb, 10).astype("int64")
sel = faiss.IDSelectorBatch(keep)
params = faiss.SearchParametersIVF()
params.nprobe = 32
params.max_codes = 5 # tight pre-filter budget
params.ensure_topk_full = True # post-filter soft cap
params.sel = sel
_, I = index.search(ds.get_queries(), 10, params=params)
self.assertEqual(int(np.sum(I == -1)), 0)
def test_fields_exposed_and_defaulted(self):
p = faiss.SearchParametersIVF()
self.assertEqual(p.max_lists_num, 0)
self.assertIs(p.ensure_topk_full, False)
self.assertEqual(p.max_empty_result_buckets, 0)
p.max_lists_num = 5
p.ensure_topk_full = True
p.max_empty_result_buckets = 2
self.assertEqual(p.max_lists_num, 5)
self.assertIs(p.ensure_topk_full, True)
self.assertEqual(p.max_empty_result_buckets, 2)