mirror of
https://github.com/facebookresearch/faiss.git
synced 2026-10-12 02:13:36 +00:00
Summary: Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5444 We fix every remaining flake8 E501 (line > 80 chars) violation in fbcode/faiss/ (excluding gpu/), closing out a lint-cleanup initiative that a June 29, 2026 diff stack (9a05463576 -> d304e29b9a -> 7d140d86ca) explicitly promised but left 41% incomplete. The middle diff in that stack aligned .flake8's max-line-length to black's 80-char width and stated the remaining 277 violations after the config change were "genuine lint (E501 long strings/URLs, F401/F841/B011, etc.), addressed in later commits in this stack." The follow-up commit fixed only the F401/F403/F841/B011 categories (45 issues) and never touched E501, leaving 192 violations across 53 files live in trunk. A direct re-scan today found 201 violations across 54 files (two files gained lines since the original count). We fixed 183 violations across 52 files. Two of the originally-flagged files, benchs/bench_rabitq.py and demos/index_pq_flat_separate_codes_from_codebook.py, already carry a file-level `# flake8: noqa` directive (the second is a Bento/Grimaldi notebook export that also fails standalone py_compile due to embedded shell magic) -- flake8 ignores both files entirely, so no changes were needed there and they are excluded from this diff. Fix approach, by category: - The large majority of violations are prose: docstrings and comments rewrapped at word boundaries, preserving each file's existing indentation and docstring conventions. No wording was changed. - Code lines (function calls, f-strings, assert messages, long argument lists) were reflowed in black-compatible style: parenthesization, comma breaks, or implicit adjacent string-literal concatenation. Every touched file is confirmed unchanged by `black --line-length 80 --diff` after the edit. - A small number of genuinely unwrappable long URLs (no reasonable break point) were suppressed with `# noqa: E501` rather than force-wrapped, matching the precedent set by the original black-format diff stack. - demos/offline_ivf/offline_ivf.py: a repeated, non-trivial ratio expression used in both an assert condition and its (lazily-evaluated) failure message was hoisted into a single local variable, eliminating the duplicate computation. The expression is pure and deterministic (built from already-computed arrays), so this is behaviorally identical to the original -- not a functional change. - `arc lint -a` additionally auto-fixed a real bug it detected while processing these files: `raise NotImplemented` -> `raise NotImplementedError` in contrib/evaluation.py (the sentinel object NotImplemented is not an exception type; raising it produces a TypeError masking the intended NotImplementedError). It also added a missing trailing newline in benchs/fb/bench_index_binary_from_float.py and, in the same file, rewrote the `#!/usr/bin/env python2` shebang to `#!/usr/bin/env fbpython` -- that rewrite was reverted by hand, since the file still does `import cPickle` (a Python 2-only module), so the auto-fix would have made the shebang describe an interpreter the script cannot actually run under. Fixing the underlying py2-to-py3 migration is out of scope for this line-length-only sweep. - Rebasing onto master (this diff's base commit had drifted) surfaced two concurrent upstream fixes to `check_ref_knn_with_draws(...)` calls in tests/test_ivf_flat_panorama.py and tests/test_refine_panorama.py (an unrelated in-flight correctness fix passing `D_panorama`/`atol` instead of a stale `D_regular` arg with no `atol`). The rebase took upstream's corrected arguments and re-applied only this diff's line-wrap on top. We verified zero functional change with an AST-level check: every string and f-string literal in every touched file was extracted before and after the edit and compared with whitespace normalized, confirming no wording, value, or expression was altered beyond the intended `# noqa: E501` suffixes and the one documented ratio-hoisting simplification above. Reviewed By: mnorris11 Differential Revision: D112525458 fbshipit-source-id: e4b85d73dafcaac779f3522a383a66e490fa942a
603 lines
19 KiB
Python
603 lines
19 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 IndexHNSWFlatPanorama.
|
|
|
|
IndexHNSWFlatPanorama combines the hierarchical navigable small world (HNSW)
|
|
graph structure with Panorama's level-oriented storage and progressive
|
|
filtering for efficient approximate nearest neighbor search.
|
|
|
|
Paper: https://www.arxiv.org/pdf/2510.00566
|
|
"""
|
|
|
|
import unittest
|
|
import tempfile
|
|
import os
|
|
import numpy as np
|
|
|
|
import faiss
|
|
from common_faiss_tests import for_all_simd_levels
|
|
from faiss.contrib.datasets import SyntheticDataset
|
|
|
|
|
|
@for_all_simd_levels
|
|
class TestIndexHNSWFlatPanorama(unittest.TestCase):
|
|
"""Test Suite for IndexHNSWFlatPanorama."""
|
|
|
|
# Helper methods for index creation and data generation
|
|
|
|
def generate_data(self, d, nt, nb, nq, seed=1234):
|
|
"""Generate synthetic data using SyntheticDataset."""
|
|
ds = SyntheticDataset(d, nt, nb, nq, seed=seed)
|
|
return ds.get_train(), ds.get_database(), ds.get_queries()
|
|
|
|
def compute_ground_truth(self, xb, xq, k):
|
|
"""Compute ground truth using brute force search."""
|
|
d = xb.shape[1]
|
|
index = faiss.IndexFlatL2(d)
|
|
index.add(xb)
|
|
D, I = index.search(xq, k)
|
|
return D, I
|
|
|
|
def compute_recall(self, gt_I, test_I):
|
|
"""Compute recall@k - fraction of ground truth results found in
|
|
test results."""
|
|
nq, k = gt_I.shape
|
|
recalls = [np.isin(gt_I[i], test_I[i]).sum() for i in range(nq)]
|
|
return sum(recalls) / (nq * k)
|
|
|
|
# Core functionality tests
|
|
|
|
def test_basic_construction(self):
|
|
"""Test basic index construction and properties."""
|
|
d = 128
|
|
M = 16
|
|
num_levels = 8
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, M, num_levels)
|
|
|
|
self.assertEqual(index.d, d)
|
|
self.assertEqual(index.ntotal, 0)
|
|
self.assertTrue(index.is_trained)
|
|
self.assertEqual(
|
|
index.hnsw.nb_neighbors(0), 2 * M
|
|
) # Level 0 has 2*M neighbors
|
|
self.assertEqual(index.num_panorama_levels, num_levels)
|
|
self.assertEqual(index.metric_type, faiss.METRIC_L2)
|
|
|
|
def test_add_and_search(self):
|
|
"""Test basic add and search operations."""
|
|
d = 128
|
|
nb = 1000
|
|
nt = 1500
|
|
nq = 10
|
|
k = 10
|
|
|
|
# Generate data
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=1234)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 16, 8)
|
|
index.add(xb)
|
|
|
|
self.assertEqual(index.ntotal, nb)
|
|
self.assertEqual(index.cum_sums.size(), nb * (8 + 1))
|
|
|
|
# Search
|
|
D, I = index.search(xq, k)
|
|
|
|
# Check that results are valid
|
|
self.assertTrue(np.all(I >= -1))
|
|
self.assertTrue(np.all(I < nb))
|
|
self.assertTrue(np.all(D >= 0.0))
|
|
|
|
def test_recall(self):
|
|
"""Test search recall quality."""
|
|
d = 128
|
|
nb = 1000
|
|
nt = 1500
|
|
nq = 100
|
|
k = 10
|
|
|
|
# Generate data
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=42)
|
|
|
|
# Create index
|
|
index = faiss.IndexHNSWFlatPanorama(d, 32, 8)
|
|
index.hnsw.efConstruction = 40
|
|
index.hnsw.efSearch = 64
|
|
|
|
# Add to index
|
|
index.add(xb)
|
|
|
|
# Search
|
|
D, I = index.search(xq, k)
|
|
|
|
# Compute ground truth
|
|
gt_D, gt_I = self.compute_ground_truth(xb, xq, k)
|
|
|
|
# Check recall
|
|
recall = self.compute_recall(gt_I, I)
|
|
print(f"Recall@{k}: {recall}")
|
|
|
|
# With efSearch=64, we should get reasonably good recall
|
|
# The threshold is lower than vanilla HNSW because of approximate
|
|
# distances
|
|
self.assertGreaterEqual(recall, 0.85)
|
|
|
|
def test_different_panorama_levels(self):
|
|
"""Test with different numbers of panorama levels."""
|
|
d = 256
|
|
nb = 500
|
|
nt = 700
|
|
nq = 50
|
|
k = 5
|
|
|
|
# Generate data
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=456)
|
|
|
|
# Compute ground truth
|
|
gt_D, gt_I = self.compute_ground_truth(xb, xq, k)
|
|
|
|
# Test different number of panorama levels
|
|
for nlevels in [4, 8, 16]:
|
|
with self.subTest(nlevels=nlevels):
|
|
index = faiss.IndexHNSWFlatPanorama(d, 32, nlevels)
|
|
index.hnsw.efSearch = 64
|
|
|
|
index.add(xb)
|
|
|
|
D, I = index.search(xq, k)
|
|
|
|
recall = self.compute_recall(gt_I, I)
|
|
print(f"Recall@{k} with {nlevels} levels: {recall}")
|
|
|
|
# More levels should still maintain reasonable recall
|
|
self.assertGreaterEqual(recall, 0.80)
|
|
|
|
def test_uneven_dimension_division(self):
|
|
"""Test when n_levels doesn't evenly divide dimension."""
|
|
test_cases = [(65, 4), (63, 8), (100, 7), (960, 128), (964, 128)]
|
|
|
|
nb, nt, nq, k = 1000, 700, 20, 5
|
|
|
|
for d, nlevels in test_cases:
|
|
with self.subTest(d=d, nlevels=nlevels):
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=789)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 32, nlevels)
|
|
index.hnsw.efSearch = 64
|
|
index.add(xb)
|
|
|
|
_, I = index.search(xq, k)
|
|
|
|
_, gt_I = self.compute_ground_truth(xb, xq, k)
|
|
|
|
recall = self.compute_recall(gt_I, I)
|
|
self.assertGreaterEqual(
|
|
recall,
|
|
0.80,
|
|
f"Recall too low for d={d}, nlevels={nlevels}: "
|
|
f"{recall:.4f}",
|
|
)
|
|
|
|
def test_consistency(self):
|
|
"""Test that search results are consistent across multiple searches."""
|
|
d = 64
|
|
nb = 500
|
|
nt = 700
|
|
nq = 10
|
|
k = 5
|
|
|
|
# Generate data
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=123)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 16, 8)
|
|
index.add(xb)
|
|
|
|
# Search twice and compare results
|
|
D1, I1 = index.search(xq, k)
|
|
D2, I2 = index.search(xq, k)
|
|
|
|
# Results should be identical
|
|
np.testing.assert_array_equal(I1, I2)
|
|
np.testing.assert_array_equal(D1, D2)
|
|
|
|
def test_io(self):
|
|
"""Test serialization and deserialization."""
|
|
d = 964
|
|
nb = 500
|
|
nt = 700
|
|
nq = 10
|
|
k = 5
|
|
nlevels = 128
|
|
|
|
# Generate data
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=2024)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 16, nlevels)
|
|
index.add(xb)
|
|
|
|
# Get search results before saving
|
|
D_before, I_before = index.search(xq, k)
|
|
|
|
# Save and load using temporary file
|
|
fname = tempfile.mktemp(suffix=".index")
|
|
try:
|
|
faiss.write_index(index, fname)
|
|
loaded_index = faiss.read_index(fname)
|
|
|
|
self.assertIsInstance(loaded_index, faiss.IndexHNSWFlatPanorama)
|
|
self.assertEqual(loaded_index.d, d)
|
|
self.assertEqual(loaded_index.ntotal, nb)
|
|
self.assertEqual(loaded_index.num_panorama_levels, nlevels)
|
|
|
|
# Search after loading
|
|
D_after, I_after = loaded_index.search(xq, k)
|
|
|
|
# Results should be identical after serialization
|
|
np.testing.assert_array_equal(I_before, I_after)
|
|
np.testing.assert_array_equal(D_before, D_after)
|
|
finally:
|
|
if os.path.exists(fname):
|
|
os.unlink(fname)
|
|
|
|
def test_clone(self):
|
|
"""Test index cloning."""
|
|
d = 64
|
|
nb = 500
|
|
nt = 700
|
|
nq = 10
|
|
k = 5
|
|
|
|
# Generate data
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=2025)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 16, 8)
|
|
index.add(xb)
|
|
|
|
# Get search results before cloning
|
|
D_before, I_before = index.search(xq, k)
|
|
|
|
# Clone
|
|
cloned_index = faiss.clone_index(index)
|
|
|
|
self.assertIsInstance(cloned_index, faiss.IndexHNSWFlatPanorama)
|
|
self.assertEqual(cloned_index.d, d)
|
|
self.assertEqual(cloned_index.ntotal, nb)
|
|
self.assertEqual(cloned_index.num_panorama_levels, 8)
|
|
|
|
# Search after cloning
|
|
D_after, I_after = cloned_index.search(xq, k)
|
|
|
|
# Results should be identical after cloning
|
|
np.testing.assert_array_equal(I_before, I_after)
|
|
np.testing.assert_array_equal(D_before, D_after)
|
|
|
|
def test_factory(self):
|
|
"""Test factory string creation."""
|
|
d = 64
|
|
nb = 500
|
|
nt = 700
|
|
|
|
# Test factory creation with default levels
|
|
index1 = faiss.index_factory(d, "HNSW32_FlatPanorama")
|
|
self.assertIsInstance(index1, faiss.IndexHNSWFlatPanorama)
|
|
self.assertEqual(index1.d, d)
|
|
self.assertEqual(
|
|
index1.hnsw.nb_neighbors(0), 2 * 32
|
|
) # Level 0 has 2*M neighbors
|
|
self.assertEqual(index1.num_panorama_levels, 8) # default
|
|
|
|
# Test factory creation with explicit levels
|
|
index2 = faiss.index_factory(d, "HNSW16_FlatPanorama12")
|
|
self.assertIsInstance(index2, faiss.IndexHNSWFlatPanorama)
|
|
self.assertEqual(index2.d, d)
|
|
self.assertEqual(
|
|
index2.hnsw.nb_neighbors(0), 2 * 16
|
|
) # Level 0 has 2*M neighbors
|
|
self.assertEqual(index2.num_panorama_levels, 12)
|
|
|
|
# Test that it works
|
|
_, xb, _ = self.generate_data(d, nt, nb, 10, seed=888)
|
|
index2.add(xb)
|
|
self.assertEqual(index2.ntotal, nb)
|
|
|
|
def test_reset(self):
|
|
"""Test index reset."""
|
|
d = 64
|
|
nb = 500
|
|
nt = 700
|
|
|
|
# Generate data
|
|
_, xb, _ = self.generate_data(d, nt, nb, 10, seed=999)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 16, 8)
|
|
index.add(xb)
|
|
|
|
self.assertEqual(index.ntotal, nb)
|
|
self.assertGreater(index.cum_sums.size(), 0)
|
|
|
|
# Reset
|
|
index.reset()
|
|
|
|
self.assertEqual(index.ntotal, 0)
|
|
self.assertEqual(index.cum_sums.size(), 0)
|
|
|
|
def test_high_dimensional(self):
|
|
"""Test with high-dimensional data (Panorama is designed for this)."""
|
|
d = 512
|
|
nb = 500
|
|
nt = 700
|
|
nq = 50
|
|
k = 10
|
|
|
|
# Generate data
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=789)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 32, 16)
|
|
index.hnsw.efSearch = 128
|
|
index.add(xb)
|
|
|
|
D, I = index.search(xq, k)
|
|
|
|
# Compute ground truth
|
|
gt_D, gt_I = self.compute_ground_truth(xb, xq, k)
|
|
|
|
recall = self.compute_recall(gt_I, I)
|
|
print(f"High-dimensional (d={d}) Recall@{k}: {recall}")
|
|
|
|
# Should work well for high-dimensional data
|
|
self.assertGreaterEqual(recall, 0.85)
|
|
|
|
def test_add_after_search(self):
|
|
"""Test adding vectors after initial search."""
|
|
d = 64
|
|
nb_initial = 300
|
|
nt_initial = 500
|
|
nb_add = 200
|
|
nt_add = 300
|
|
nq = 10
|
|
k = 5
|
|
|
|
# Generate data
|
|
_, xb_initial, xq = self.generate_data(
|
|
d, nt_initial, nb_initial, nq, seed=1111
|
|
)
|
|
_, xb_add, _ = self.generate_data(d, nt_add, nb_add, 1, seed=2222)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 16, 8)
|
|
|
|
# Add initial vectors
|
|
index.add(xb_initial)
|
|
|
|
# Search on initial index
|
|
D_before, I_before = index.search(xq, k)
|
|
|
|
# Add more vectors after search
|
|
index.add(xb_add)
|
|
|
|
self.assertEqual(index.ntotal, nb_initial + nb_add)
|
|
self.assertEqual(
|
|
index.cum_sums.size(),
|
|
(nb_initial + nb_add) * (index.num_panorama_levels + 1),
|
|
)
|
|
|
|
# Search again - should work correctly with all vectors
|
|
D_after, I_after = index.search(xq, k)
|
|
|
|
# Compute ground truth on all vectors
|
|
xb_all = np.vstack([xb_initial, xb_add])
|
|
|
|
gt_D, gt_I = self.compute_ground_truth(xb_all, xq, k)
|
|
|
|
recall = self.compute_recall(gt_I, I_after)
|
|
print(f"Recall after adding more vectors: {recall}")
|
|
|
|
# Recall might be slightly lower than single-batch due to HNSW
|
|
# graph structure
|
|
self.assertGreaterEqual(recall, 0.80)
|
|
|
|
# Verify that previously found neighbors can still be found
|
|
# (they might not be in exact same positions due to new neighbors)
|
|
found_count = 0
|
|
for i in range(nq):
|
|
for j in range(k):
|
|
if I_before[i, j] >= 0:
|
|
# Check if this neighbor is still in the result set
|
|
if I_before[i, j] in I_after[i, :]:
|
|
found_count += 1
|
|
|
|
retention = float(found_count) / (nq * k)
|
|
print(f"Retention of previous neighbors: {retention}")
|
|
# Should retain a reasonable number of previous neighbors (new ones
|
|
# might push some out)
|
|
# The threshold is lower to account for the approximate nature of HNSW
|
|
self.assertGreaterEqual(retention, 0.5)
|
|
|
|
def test_permute_entries(self):
|
|
"""Test permuting entries in the index."""
|
|
d = 64
|
|
nb = 500
|
|
nt = 700
|
|
nq = 10
|
|
k = 5
|
|
|
|
# Generate data
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=3333)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 16, 8)
|
|
index.add(xb)
|
|
|
|
# Search before permutation
|
|
D_before, I_before = index.search(xq, k)
|
|
|
|
# Create a permutation (reverse order for simplicity)
|
|
perm = np.arange(nb - 1, -1, -1, dtype=np.int64)
|
|
|
|
# Store cum_sums before permutation for verification
|
|
cum_sums_before = faiss.vector_to_array(index.cum_sums).copy()
|
|
|
|
# Apply permutation
|
|
index.permute_entries(perm)
|
|
|
|
# Verify cum_sums were permuted correctly
|
|
cum_sums_after = faiss.vector_to_array(index.cum_sums)
|
|
for i in range(nb):
|
|
src = perm[i]
|
|
for j in range(index.num_panorama_levels + 1):
|
|
expected = cum_sums_before[
|
|
src * (index.num_panorama_levels + 1) + j
|
|
]
|
|
actual = cum_sums_after[i * (index.num_panorama_levels + 1) + j]
|
|
self.assertEqual(
|
|
actual,
|
|
expected,
|
|
f"cum_sums not permuted correctly at i={i}, j={j}",
|
|
)
|
|
|
|
# Search after permutation
|
|
D_after, I_after = index.search(xq, k)
|
|
|
|
# Results should be identical (with permuted IDs)
|
|
np.testing.assert_allclose(
|
|
D_before, D_after, err_msg="Distance changed after permutation"
|
|
)
|
|
|
|
# The ID should be the permuted version
|
|
# If before we found vector j, after permutation we should find
|
|
# the new position of vector j
|
|
for i in range(nq):
|
|
for j in range(k):
|
|
if I_before[i, j] >= 0:
|
|
# Find where I_before[i, j] moved to
|
|
old_id = I_before[i, j]
|
|
new_id = np.where(perm == old_id)[0][0]
|
|
self.assertEqual(
|
|
I_after[i, j],
|
|
new_id,
|
|
f"Permuted ID mismatch at position ({i}, {j})",
|
|
)
|
|
|
|
# Verify overall recall is maintained
|
|
gt_D, gt_I = self.compute_ground_truth(xb, xq, k)
|
|
|
|
# Map ground truth IDs through the permutation
|
|
gt_I_permuted = np.copy(gt_I)
|
|
for i in range(nq):
|
|
for j in range(k):
|
|
if gt_I[i, j] >= 0:
|
|
# Find where gt_I[i, j] moved to
|
|
gt_I_permuted[i, j] = np.where(perm == gt_I[i, j])[0][0]
|
|
|
|
recall = self.compute_recall(gt_I_permuted, I_after)
|
|
print(f"Recall after permutation: {recall}")
|
|
self.assertGreaterEqual(recall, 0.85)
|
|
|
|
def test_id_selector_range(self):
|
|
"""Test ID filtering with range selector."""
|
|
d = 128
|
|
nb = 1000
|
|
nt = 1500
|
|
nq = 10
|
|
k = 10
|
|
|
|
# Generate data
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=321)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 32, 8)
|
|
index.hnsw.efSearch = 64
|
|
index.add(xb)
|
|
|
|
selector = faiss.IDSelectorRange(200, 600)
|
|
params = faiss.SearchParametersHNSW()
|
|
params.sel = selector
|
|
|
|
D, I = index.search(xq, k, params=params)
|
|
|
|
# Verify all returned IDs are in range (HNSW may return < k)
|
|
count = 0
|
|
for i in range(nq):
|
|
for j in range(k):
|
|
if I[i, j] != -1:
|
|
self.assertGreaterEqual(I[i, j], 200)
|
|
self.assertLess(I[i, j], 600)
|
|
count += 1
|
|
|
|
print(f"IDSelectorRange test: {count} results")
|
|
|
|
def test_id_selector_batch(self):
|
|
"""Test ID filtering with batch selector."""
|
|
d = 128
|
|
nb = 1000
|
|
nt = 1500
|
|
nq = 10
|
|
k = 5
|
|
|
|
# Generate data
|
|
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=654)
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 32, 8)
|
|
index.hnsw.efSearch = 64
|
|
index.add(xb)
|
|
|
|
allowed_ids = np.arange(0, nb, 10, dtype=np.int64)
|
|
|
|
selector = faiss.IDSelectorBatch(allowed_ids)
|
|
params = faiss.SearchParametersHNSW()
|
|
params.sel = selector
|
|
|
|
D, I = index.search(xq, k, params=params)
|
|
|
|
# Verify all returned IDs are in allowed set (HNSW may return < k)
|
|
allowed_set = set(allowed_ids)
|
|
count = 0
|
|
for i in range(nq):
|
|
for j in range(k):
|
|
if I[i, j] != -1:
|
|
self.assertIn(
|
|
I[i, j], allowed_set, f"ID {I[i, j]} not in allowed set"
|
|
)
|
|
count += 1
|
|
|
|
print(f"IDSelectorBatch test: {count} results")
|
|
|
|
def test_panorama_stats(self):
|
|
"""Test that ratio_dims_scanned decreases as nlevels increases."""
|
|
d, nb, nq, k = 128, 10000, 10, 10
|
|
|
|
rs = np.random.RandomState(42)
|
|
xb = rs.rand(nb, d).astype("float32")
|
|
xq = rs.rand(nq, d).astype("float32")
|
|
|
|
nt = faiss.omp_get_max_threads()
|
|
faiss.omp_set_num_threads(1)
|
|
|
|
try:
|
|
ratios = []
|
|
nlevels_list = [1, 4, 8, 16]
|
|
for nlevels in nlevels_list:
|
|
faiss.cvar.indexPanorama_stats.reset()
|
|
|
|
index = faiss.IndexHNSWFlatPanorama(d, 32, nlevels)
|
|
index.hnsw.efSearch = 64
|
|
index.add(xb)
|
|
index.search(xq, k)
|
|
|
|
ratio = faiss.cvar.indexPanorama_stats.ratio_dims_scanned
|
|
ratios.append(ratio)
|
|
print(f" nlevels={nlevels:2d} ratio={ratio:.4f}")
|
|
|
|
for i in range(1, len(ratios)):
|
|
self.assertLessEqual(
|
|
ratios[i],
|
|
ratios[i - 1],
|
|
f"ratio should decrease: nlevels={nlevels_list[i]} "
|
|
f"ratio={ratios[i]:.4f} > prev={ratios[i-1]:.4f}",
|
|
)
|
|
self.assertLess(ratios[-1], 1.0, "Some pruning should occur")
|
|
finally:
|
|
faiss.omp_set_num_threads(nt)
|