Reapply IndexHNSWFlatPanorama with backward compatible serialization (#4692)

Summary:
As title, this PR reverts the prior [revert PR](https://github.com/facebookresearch/faiss/pull/4690).

mnorris11 is this fix OK? Please feel free to push your test in here.

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

Reviewed By: subhadeepkaran

Differential Revision: D88390767

Pulled By: mnorris11

fbshipit-source-id: 1f57c46cfbfad5d46f2e20a2690c18bb298113d1
This commit is contained in:
Alexis Schlomer
2025-12-16 17:08:33 -08:00
committed by meta-codesync[bot]
parent 337dfe8043
commit 7372bc7982
18 changed files with 1331 additions and 38 deletions
+153
View File
@@ -0,0 +1,153 @@
# 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 multiprocessing as mp
import time
import faiss
import matplotlib.pyplot as plt
import numpy as np
try:
from faiss.contrib.datasets_fb import (
DatasetSIFT1M,
DatasetGIST1M,
SyntheticDataset,
)
except ImportError:
from faiss.contrib.datasets import (
DatasetSIFT1M,
DatasetGIST1M,
SyntheticDataset,
)
def eval_recall(index, efSearch_val, xq, gt, k):
"""Evaluate recall and QPS for a given efSearch value."""
t0 = time.time()
_, I = index.search(xq, k=k)
t = time.time() - t0
speed = t * 1000 / len(xq)
qps = 1000 / speed
corrects = (gt == I).sum()
recall = corrects / (len(xq) * k)
print(
f"\tefSearch {efSearch_val:3d}, Recall@{k}: "
f"{recall:.6f}, speed: {speed:.6f} ms/query, QPS: {qps:.2f}"
)
return recall, qps
def get_hnsw_index(index):
"""Extract the underlying HNSW index from a PreTransform index."""
if isinstance(index, faiss.IndexPreTransform):
return faiss.downcast_index(index.index)
return index
def eval_and_plot(name, ds, k=10, nlevels=8, plot_data=None):
"""Evaluate an index configuration and collect data for plotting."""
xq = ds.get_queries()
xb = ds.get_database()
gt = ds.get_groundtruth()
if hasattr(ds, "get_train"):
xt = ds.get_train()
else:
# Use database as training data if no separate train set
xt = xb
nb, d = xb.shape
nq, d = xq.shape
gt = gt[:, :k]
print(f"\n======{name} on {ds.__class__.__name__}======")
print(f"Database: {nb} vectors, {d} dimensions")
print(f"Queries: {nq} vectors")
# Create index
index = faiss.index_factory(d, name)
faiss.omp_set_num_threads(mp.cpu_count())
index.train(xt)
index.add(xb)
faiss.omp_set_num_threads(1)
# Get the underlying HNSW index for setting efSearch
hnsw_index = get_hnsw_index(index)
data = []
for efSearch in [16, 32, 64, 128, 256, 512]:
hnsw_index.hnsw.efSearch = efSearch
recall, qps = eval_recall(index, efSearch, xq, gt, k)
data.append((recall, qps))
if plot_data is not None:
data = np.array(data)
plot_data.append((name, data))
def benchmark_dataset(ds, dataset_name, k=10, nlevels=8, M=32):
"""Benchmark both regular HNSW and HNSW Panorama on a dataset."""
d = ds.d
plot_data = []
# HNSW Flat (baseline)
eval_and_plot(f"HNSW{M},Flat", ds, k=k, nlevels=nlevels, plot_data=plot_data)
# HNSW Flat Panorama (with PCA to concentrate energy)
eval_and_plot(
f"PCA{d},HNSW{M},FlatPanorama{nlevels}",
ds,
k=k,
nlevels=nlevels,
plot_data=plot_data,
)
# Plot results
plt.figure(figsize=(8, 6), dpi=80)
for name, data in plot_data:
plt.plot(data[:, 0], data[:, 1], marker="o", label=name)
plt.title(f"HNSW Indexes on {dataset_name}")
plt.xlabel(f"Recall@{k}")
plt.ylabel("QPS")
plt.yscale("log")
plt.legend(bbox_to_anchor=(1.02, 0.1), loc="upper left", borderaxespad=0)
plt.grid(True, alpha=0.3)
output_file = f"bench_hnsw_flat_panorama_{dataset_name}.png"
plt.savefig(output_file, bbox_inches="tight")
print(f"Saved plot to {output_file}")
plt.close()
if __name__ == "__main__":
k = 10
nlevels = 8
M = 32
# Test on 3 datasets with varying dimensionality:
# SIFT1M (128d), GIST1M (960d), and Synthetic high-dim (2048d)
datasets = [
(DatasetSIFT1M(), "SIFT1M"),
(DatasetGIST1M(), "GIST1M"),
# Synthetic high-dimensional dataset: 2048d, 100k train, 1M database, 10k queries
(SyntheticDataset(2048, 100000, 1000000, 10000), "Synthetic2048D"),
]
for ds, name in datasets:
print(f"\n{'='*60}")
print(f"Benchmarking on {name}")
print(f"{'='*60}")
benchmark_dataset(ds, name, k=k, nlevels=nlevels, M=M)
print("\n" + "="*60)
print("All benchmarks completed!")
print("="*60)
+1
View File
@@ -86,6 +86,7 @@ struct AQDistanceComputerLUT : FlatCodesDistanceComputer {
float bias;
void set_query(const float* x) final {
q = x;
// this is quite sub-optimal for multiple queries
aq.compute_LUT(1, x, LUT.data());
if (is_IP) {
+5 -1
View File
@@ -227,7 +227,11 @@ void IndexBinaryHNSW::search(
for (idx_t i = 0; i < n; i++) {
res.begin(i);
dis->set_query((float*)(x + i * code_size));
hnsw.search(*dis, res, vt);
// Given that IndexBinaryHNSW is not an IndexHNSW, we pass nullptr
// as the index parameter. This state does not get used in the
// search function, as it is merely there to to enable Panorama
// execution for IndexHNSWFlatPanorama.
hnsw.search(*dis, nullptr, res, vt);
res.end();
}
}
+58 -4
View File
@@ -103,15 +103,24 @@ namespace {
struct FlatL2Dis : FlatCodesDistanceComputer {
size_t d;
idx_t nb;
const float* q;
const float* b;
size_t ndis;
size_t npartial_dot_products;
float distance_to_code(const uint8_t* code) final {
ndis++;
return fvec_L2sqr(q, (float*)code, d);
}
float partial_dot_product(
const idx_t i,
const uint32_t offset,
const uint32_t num_components) final override {
npartial_dot_products++;
return fvec_inner_product(
q + offset, b + i * d + offset, num_components);
}
float symmetric_dis(idx_t i, idx_t j) override {
return fvec_L2sqr(b + j * d, b + i * d, d);
}
@@ -119,12 +128,13 @@ struct FlatL2Dis : FlatCodesDistanceComputer {
explicit FlatL2Dis(const IndexFlat& storage, const float* q = nullptr)
: FlatCodesDistanceComputer(
storage.codes.data(),
storage.code_size),
storage.code_size,
q),
d(storage.d),
nb(storage.ntotal),
q(q),
b(storage.get_xb()),
ndis(0) {}
ndis(0),
npartial_dot_products(0) {}
void set_query(const float* x) override {
q = x;
@@ -162,6 +172,50 @@ struct FlatL2Dis : FlatCodesDistanceComputer {
dis2 = dp2;
dis3 = dp3;
}
void partial_dot_product_batch_4(
const idx_t idx0,
const idx_t idx1,
const idx_t idx2,
const idx_t idx3,
float& dp0,
float& dp1,
float& dp2,
float& dp3,
const uint32_t offset,
const uint32_t num_components) final override {
npartial_dot_products += 4;
// compute first, assign next
const float* __restrict y0 =
reinterpret_cast<const float*>(codes + idx0 * code_size);
const float* __restrict y1 =
reinterpret_cast<const float*>(codes + idx1 * code_size);
const float* __restrict y2 =
reinterpret_cast<const float*>(codes + idx2 * code_size);
const float* __restrict y3 =
reinterpret_cast<const float*>(codes + idx3 * code_size);
float dp0_ = 0;
float dp1_ = 0;
float dp2_ = 0;
float dp3_ = 0;
fvec_inner_product_batch_4(
q + offset,
y0 + offset,
y1 + offset,
y2 + offset,
y3 + offset,
num_components,
dp0_,
dp1_,
dp2_,
dp3_);
dp0 = dp0_;
dp1 = dp1_;
dp2 = dp2_;
dp3 = dp3_;
}
};
struct FlatIPDis : FlatCodesDistanceComputer {
+90 -1
View File
@@ -276,7 +276,7 @@ void hnsw_search(
res.begin(i);
dis->set_query(x + i * index->d);
HNSWStats stats = hnsw.search(*dis, res, vt, params);
HNSWStats stats = hnsw.search(*dis, index, res, vt, params);
n1 += stats.n1;
n2 += stats.n2;
ndis += stats.ndis;
@@ -649,6 +649,95 @@ IndexHNSWFlat::IndexHNSWFlat(int d, int M, MetricType metric)
is_trained = true;
}
/**************************************************************
* IndexHNSWFlatPanorama implementation
**************************************************************/
void IndexHNSWFlatPanorama::compute_cum_sums(
const float* x,
float* dst_cum_sums,
int d,
int num_panorama_levels,
int panorama_level_width) {
// Iterate backwards through levels, accumulating sum as we go.
// This avoids computing the suffix sum for each vector, which takes
// extra memory.
float sum = 0.0f;
dst_cum_sums[num_panorama_levels] = 0.0f;
for (int level = num_panorama_levels - 1; level >= 0; level--) {
int start_idx = level * panorama_level_width;
int end_idx = std::min(start_idx + panorama_level_width, d);
for (int j = start_idx; j < end_idx; j++) {
sum += x[j] * x[j];
}
dst_cum_sums[level] = std::sqrt(sum);
}
}
IndexHNSWFlatPanorama::IndexHNSWFlatPanorama()
: IndexHNSWFlat(),
cum_sums(),
panorama_level_width(0),
num_panorama_levels(0) {}
IndexHNSWFlatPanorama::IndexHNSWFlatPanorama(
int d,
int M,
int num_panorama_levels,
MetricType metric)
: IndexHNSWFlat(d, M, metric),
cum_sums(),
panorama_level_width(
(d + num_panorama_levels - 1) / num_panorama_levels),
num_panorama_levels(num_panorama_levels) {
// For now, we only support L2 distance.
// Supporting dot product and cosine distance is a trivial addition
// left for future work.
FAISS_THROW_IF_NOT(metric == METRIC_L2);
// Enable Panorama search mode.
// This is not ideal, but is still more simple than making a subclass of
// HNSW and overriding the search logic.
hnsw.is_panorama = true;
}
void IndexHNSWFlatPanorama::add(idx_t n, const float* x) {
idx_t n0 = ntotal;
cum_sums.resize((ntotal + n) * (num_panorama_levels + 1));
for (size_t idx = 0; idx < n; idx++) {
const float* vector = x + idx * d;
compute_cum_sums(
vector,
&cum_sums[(n0 + idx) * (num_panorama_levels + 1)],
d,
num_panorama_levels,
panorama_level_width);
}
IndexHNSWFlat::add(n, x);
}
void IndexHNSWFlatPanorama::reset() {
cum_sums.clear();
IndexHNSWFlat::reset();
}
void IndexHNSWFlatPanorama::permute_entries(const idx_t* perm) {
std::vector<float> new_cum_sums(ntotal * (num_panorama_levels + 1));
for (idx_t i = 0; i < ntotal; i++) {
idx_t src = perm[i];
memcpy(&new_cum_sums[i * (num_panorama_levels + 1)],
&cum_sums[src * (num_panorama_levels + 1)],
(num_panorama_levels + 1) * sizeof(float));
}
std::swap(cum_sums, new_cum_sums);
IndexHNSWFlat::permute_entries(perm);
}
/**************************************************************
* IndexHNSWPQ implementation
**************************************************************/
+57 -1
View File
@@ -111,7 +111,7 @@ struct IndexHNSW : Index {
void link_singletons();
void permute_entries(const idx_t* perm);
virtual void permute_entries(const idx_t* perm);
DistanceComputer* get_distance_computer() const override;
};
@@ -125,6 +125,62 @@ struct IndexHNSWFlat : IndexHNSW {
IndexHNSWFlat(int d, int M, MetricType metric = METRIC_L2);
};
/** Panorama implementation of IndexHNSWFlat following
* https://www.arxiv.org/pdf/2510.00566.
*
* Unlike cluster-based Panorama, the vectors have to be higher dimensional
* (i.e. typically d > 512) and/or be able to compress a lot of their energy in
* the early dimensions to be effective. This is because HNSW accesses vectors
* in a random order, which makes cache misses dominate the distance computation
* time.
*
* The `num_panorama_levels` parameter controls the granularity of progressive
* distance refinement, allowing candidates to be eliminated early using partial
* distance computations rather than computing full distances.
*
* NOTE: This version of HNSW handles search slightly differently than the
* vanilla HNSW, as it uses partial distance computations with progressive
* refinement bounds. Instead of computing full distances immediately for all
* candidates, Panorama maintains lower and upper bounds that are incrementally
* tightened across refinement levels. Candidates are inserted into the search
* beam using approximate distance estimates (LB+UB)/2 and are only fully
* evaluated when they survive pruning and enter the result heap. This allows
* the algorithm to prune unpromising candidates early using Cauchy-Schwarz
* bounds on partial inner products. Hence, recall is not guaranteed to be the
* same as vanilla HNSW due to the heterogeneous precision within the search
* beam (exact vs. partial distance estimates affecting traversal order).
*/
struct IndexHNSWFlatPanorama : IndexHNSWFlat {
IndexHNSWFlatPanorama();
IndexHNSWFlatPanorama(
int d,
int M,
int num_panorama_levels,
MetricType metric = METRIC_L2);
void add(idx_t n, const float* x) override;
void reset() override;
void permute_entries(const idx_t* perm) override;
/// Inline for performance - called frequently in search hot path.
const float* get_cum_sum(idx_t i) const {
return cum_sums.data() + i * (num_panorama_levels + 1);
}
/// Compute cumulative sums for a vector (used both for database points and
/// queries).
static void compute_cum_sums(
const float* x,
float* dst_cum_sums,
int d,
int num_panorama_levels,
int panorama_level_width);
std::vector<float> cum_sums;
const size_t panorama_level_width;
const size_t num_panorama_levels;
};
/** PQ index topped with with a HNSW structure to access elements
* more efficiently.
*/
+4 -1
View File
@@ -81,6 +81,7 @@ struct PQDistanceComputer : FlatCodesDistanceComputer {
const float* sdc;
std::vector<float> precomputed_table;
size_t ndis;
const float* q;
float distance_to_code(const uint8_t* code) final {
ndis++;
@@ -109,7 +110,8 @@ struct PQDistanceComputer : FlatCodesDistanceComputer {
: FlatCodesDistanceComputer(
storage.codes.data(),
storage.code_size),
pq(storage.pq) {
pq(storage.pq),
q(nullptr) {
precomputed_table.resize(pq.M * pq.ksub);
nb = storage.ntotal;
d = storage.d;
@@ -123,6 +125,7 @@ struct PQDistanceComputer : FlatCodesDistanceComputer {
}
void set_query(const float* x) override {
q = x;
if (metric == METRIC_L2) {
pq.compute_distance_table(x, precomputed_table.data());
} else {
+1
View File
@@ -129,6 +129,7 @@ IndexIDMap* clone_IndexIDMap(const IndexIDMap* im) {
IndexHNSW* clone_IndexHNSW(const IndexHNSW* ihnsw) {
TRYCLONE(IndexHNSW2Level, ihnsw)
TRYCLONE(IndexHNSWFlatPanorama, ihnsw)
TRYCLONE(IndexHNSWFlat, ihnsw)
TRYCLONE(IndexHNSWPQ, ihnsw)
TRYCLONE(IndexHNSWSQ, ihnsw)
+74 -3
View File
@@ -113,18 +113,89 @@ struct FlatCodesDistanceComputer : DistanceComputer {
const uint8_t* codes;
size_t code_size;
FlatCodesDistanceComputer(const uint8_t* codes, size_t code_size)
: codes(codes), code_size(code_size) {}
const float* q = nullptr; // not used in all distance computers
FlatCodesDistanceComputer() : codes(nullptr), code_size(0) {}
FlatCodesDistanceComputer(
const uint8_t* codes,
size_t code_size,
const float* q = nullptr)
: codes(codes), code_size(code_size), q(q) {}
explicit FlatCodesDistanceComputer(const float* q)
: codes(nullptr), code_size(0), q(q) {}
FlatCodesDistanceComputer() : codes(nullptr), code_size(0), q(nullptr) {}
float operator()(idx_t i) override {
return distance_to_code(codes + i * code_size);
}
/// Computes a partial dot product over a slice of the query vector.
/// The slice is defined by the following parameters:
/// — `offset`: the starting index of the first component to include
/// — `num_components`: the number of consecutive components to include
///
/// Components refer to raw dimensions of the flat (uncompressed) query
/// vector.
///
/// By default, this method throws an error, as it is only implemented
/// in specific subclasses such as `FlatL2Dis`. Other flat distance
/// computers may override this when partial dot product support is needed.
///
/// Over time, this method might be changed to a pure virtual function (`=
/// 0`) to enforce implementation in subclasses that require this
/// functionality.
///
/// This method is not part of the generic `DistanceComputer` interface
/// because for compressed representations (e.g., product quantization),
/// calling `partial_dot_product` repeatedly is often less efficient than
/// computing the full distance at once.
///
/// Supporting efficient partial scans generally requires a different memory
/// layout, such as interleaved blocks that keep SIMD lanes full. This is a
/// non-trivial change and not supported in the current flat layout.
///
/// For more details on partial (or chunked) dot product computations and
/// the performance trade-offs involved, refer to the Panorama paper:
/// https://arxiv.org/pdf/2510.00566
virtual float partial_dot_product(
const idx_t /* i */,
const uint32_t /* offset */,
const uint32_t /* num_components */) {
FAISS_THROW_MSG("partial_dot_product not implemented");
}
/// compute distance of current query to an encoded vector
virtual float distance_to_code(const uint8_t* code) = 0;
/// Compute partial dot products of current query to 4 stored vectors.
/// See `partial_dot_product` for more details.
virtual void partial_dot_product_batch_4(
const idx_t idx0,
const idx_t idx1,
const idx_t idx2,
const idx_t idx3,
float& dp0,
float& dp1,
float& dp2,
float& dp3,
const uint32_t offset,
const uint32_t num_components) {
// default implementation for correctness
const float d0 =
this->partial_dot_product(idx0, offset, num_components);
const float d1 =
this->partial_dot_product(idx1, offset, num_components);
const float d2 =
this->partial_dot_product(idx2, offset, num_components);
const float d3 =
this->partial_dot_product(idx3, offset, num_components);
dp0 = d0;
dp1 = d1;
dp2 = d2;
dp3 = d3;
}
virtual ~FlatCodesDistanceComputer() override {}
};
+294 -15
View File
@@ -9,6 +9,8 @@
#include <cstddef>
#include <faiss/IndexHNSW.h>
#include <faiss/impl/AuxIndexStructures.h>
#include <faiss/impl/DistanceComputer.h>
#include <faiss/impl/IDSelector.h>
@@ -588,6 +590,28 @@ void HNSW::add_with_locks(
using MinimaxHeap = HNSW::MinimaxHeap;
using Node = HNSW::Node;
using C = HNSW::C;
/** Helper to extract search parameters from HNSW and SearchParameters */
static inline void extract_search_params(
const HNSW& hnsw,
const SearchParameters* params,
bool& do_dis_check,
int& efSearch,
const IDSelector*& sel) {
// can be overridden by search params
do_dis_check = hnsw.check_relative_distance;
efSearch = hnsw.efSearch;
sel = nullptr;
if (params) {
if (const SearchParametersHNSW* hnsw_params =
dynamic_cast<const SearchParametersHNSW*>(params)) {
do_dis_check = hnsw_params->check_relative_distance;
efSearch = hnsw_params->efSearch;
}
sel = params->sel;
}
}
/** Do a BFS on the candidates list */
int search_from_candidates(
const HNSW& hnsw,
@@ -602,18 +626,10 @@ int search_from_candidates(
int nres = nres_in;
int ndis = 0;
// can be overridden by search params
bool do_dis_check = hnsw.check_relative_distance;
int efSearch = hnsw.efSearch;
const IDSelector* sel = nullptr;
if (params) {
if (const SearchParametersHNSW* hnsw_params =
dynamic_cast<const SearchParametersHNSW*>(params)) {
do_dis_check = hnsw_params->check_relative_distance;
efSearch = hnsw_params->efSearch;
}
sel = params->sel;
}
bool do_dis_check;
int efSearch;
const IDSelector* sel;
extract_search_params(hnsw, params, do_dis_check, efSearch, sel);
C::T threshold = res.threshold;
for (int i = 0; i < candidates.size(); i++) {
@@ -735,6 +751,253 @@ int search_from_candidates(
return nres;
}
int search_from_candidates_panorama(
const HNSW& hnsw,
const IndexHNSW* index,
DistanceComputer& qdis,
ResultHandler<C>& res,
MinimaxHeap& candidates,
VisitedTable& vt,
HNSWStats& stats,
int level,
int nres_in,
const SearchParameters* params) {
int nres = nres_in;
int ndis = 0;
bool do_dis_check;
int efSearch;
const IDSelector* sel;
extract_search_params(hnsw, params, do_dis_check, efSearch, sel);
C::T threshold = res.threshold;
for (int i = 0; i < candidates.size(); i++) {
idx_t v1 = candidates.ids[i];
float d = candidates.dis[i];
FAISS_ASSERT(v1 >= 0);
if (!sel || sel->is_member(v1)) {
if (d < threshold) {
if (res.add_result(d, v1)) {
threshold = res.threshold;
}
}
}
vt.set(v1);
}
// Validate the index type so we can access cumulative sums, n_levels, and
// get the ability to compute partial dot products.
const auto* panorama_index =
dynamic_cast<const IndexHNSWFlatPanorama*>(index);
FAISS_THROW_IF_NOT_MSG(
panorama_index, "Index must be a IndexHNSWFlatPanorama");
auto* flat_codes_qdis = dynamic_cast<FlatCodesDistanceComputer*>(&qdis);
FAISS_THROW_IF_NOT_MSG(
flat_codes_qdis,
"DistanceComputer must be a FlatCodesDistanceComputer");
// Allocate space for the index array and exact distances.
size_t M = hnsw.nb_neighbors(0);
std::vector<idx_t> index_array(M);
std::vector<float> exact_distances(M);
const float* query = flat_codes_qdis->q;
std::vector<float> query_cum_sums(panorama_index->num_panorama_levels + 1);
IndexHNSWFlatPanorama::compute_cum_sums(
query,
query_cum_sums.data(),
panorama_index->d,
panorama_index->num_panorama_levels,
panorama_index->panorama_level_width);
float query_norm_sq = query_cum_sums[0] * query_cum_sums[0];
int nstep = 0;
while (candidates.size() > 0) {
float d0 = 0;
int v0 = candidates.pop_min(&d0);
if (do_dis_check) {
// tricky stopping condition: there are more than ef
// distances that are processed already that are smaller
// than d0
int n_dis_below = candidates.count_below(d0);
if (n_dis_below >= efSearch) {
break;
}
}
size_t begin, end;
hnsw.neighbor_range(v0, level, &begin, &end);
// Unlike the vanilla HNSW, we already remove (and compact) the visited
// nodes from the candidates list at this stage. We also remove nodes
// that are not selected.
size_t initial_size = 0;
for (size_t j = begin; j < end; j++) {
int v1 = hnsw.neighbors[j];
if (v1 < 0) {
break;
}
const float* cum_sums_v1 = panorama_index->get_cum_sum(v1);
index_array[initial_size] = v1;
exact_distances[initial_size] =
query_norm_sq + cum_sums_v1[0] * cum_sums_v1[0];
bool is_selected = !sel || sel->is_member(v1);
initial_size += is_selected && !vt.get(v1) ? 1 : 0;
vt.set(v1);
}
size_t batch_size = initial_size;
size_t curr_panorama_level = 0;
const size_t num_panorama_levels = panorama_index->num_panorama_levels;
while (curr_panorama_level < num_panorama_levels && batch_size > 0) {
float query_cum_norm = query_cum_sums[curr_panorama_level + 1];
const size_t panorama_level_width =
panorama_index->panorama_level_width;
size_t start_dim = curr_panorama_level * panorama_level_width;
size_t end_dim = (curr_panorama_level + 1) * panorama_level_width;
end_dim = std::min(end_dim, static_cast<size_t>(panorama_index->d));
size_t i = 0;
size_t next_batch_size = 0;
for (; i + 3 < batch_size; i += 4) {
idx_t idx_0 = index_array[i];
idx_t idx_1 = index_array[i + 1];
idx_t idx_2 = index_array[i + 2];
idx_t idx_3 = index_array[i + 3];
float dp[4];
flat_codes_qdis->partial_dot_product_batch_4(
idx_0,
idx_1,
idx_2,
idx_3,
dp[0],
dp[1],
dp[2],
dp[3],
start_dim,
end_dim - start_dim);
ndis += 4;
float new_exact_0 = exact_distances[i + 0] - 2 * dp[0];
float new_exact_1 = exact_distances[i + 1] - 2 * dp[1];
float new_exact_2 = exact_distances[i + 2] - 2 * dp[2];
float new_exact_3 = exact_distances[i + 3] - 2 * dp[3];
float cum_sum_0 = panorama_index->get_cum_sum(
idx_0)[curr_panorama_level + 1];
float cum_sum_1 = panorama_index->get_cum_sum(
idx_1)[curr_panorama_level + 1];
float cum_sum_2 = panorama_index->get_cum_sum(
idx_2)[curr_panorama_level + 1];
float cum_sum_3 = panorama_index->get_cum_sum(
idx_3)[curr_panorama_level + 1];
float cs_bound_0 = 2.0f * cum_sum_0 * query_cum_norm;
float cs_bound_1 = 2.0f * cum_sum_1 * query_cum_norm;
float cs_bound_2 = 2.0f * cum_sum_2 * query_cum_norm;
float cs_bound_3 = 2.0f * cum_sum_3 * query_cum_norm;
float lower_bound_0 = new_exact_0 - cs_bound_0;
float lower_bound_1 = new_exact_1 - cs_bound_1;
float lower_bound_2 = new_exact_2 - cs_bound_2;
float lower_bound_3 = new_exact_3 - cs_bound_3;
// The following code is not the most branch friendly (due to
// the maintenance of the candidate heap), but micro-benchmarks
// have shown that it is not worth it to write horrible code to
// squeeze out those cycles.
if (lower_bound_0 <= threshold) {
exact_distances[next_batch_size] = new_exact_0;
index_array[next_batch_size] = idx_0;
next_batch_size += 1;
} else {
candidates.push(idx_0, new_exact_0);
}
if (lower_bound_1 <= threshold) {
exact_distances[next_batch_size] = new_exact_1;
index_array[next_batch_size] = idx_1;
next_batch_size += 1;
} else {
candidates.push(idx_1, new_exact_1);
}
if (lower_bound_2 <= threshold) {
exact_distances[next_batch_size] = new_exact_2;
index_array[next_batch_size] = idx_2;
next_batch_size += 1;
} else {
candidates.push(idx_2, new_exact_2);
}
if (lower_bound_3 <= threshold) {
exact_distances[next_batch_size] = new_exact_3;
index_array[next_batch_size] = idx_3;
next_batch_size += 1;
} else {
candidates.push(idx_3, new_exact_3);
}
}
// Process the remaining candidates.
for (; i < batch_size; i++) {
idx_t idx = index_array[i];
float dp = flat_codes_qdis->partial_dot_product(
idx, start_dim, end_dim - start_dim);
ndis += 1;
float new_exact = exact_distances[i] - 2.0f * dp;
float cum_sum = panorama_index->get_cum_sum(
idx)[curr_panorama_level + 1];
float cs_bound = 2.0f * cum_sum * query_cum_norm;
float lower_bound = new_exact - cs_bound;
if (lower_bound <= threshold) {
exact_distances[next_batch_size] = new_exact;
index_array[next_batch_size] = idx;
next_batch_size += 1;
} else {
candidates.push(idx, new_exact);
}
}
batch_size = next_batch_size;
curr_panorama_level++;
}
// Add surviving candidates to the result handler.
for (size_t i = 0; i < batch_size; i++) {
idx_t idx = index_array[i];
if (res.add_result(exact_distances[i], idx)) {
nres += 1;
}
candidates.push(idx, exact_distances[i]);
}
nstep++;
if (!do_dis_check && nstep > efSearch) {
break;
}
}
if (level == 0) {
stats.n1++;
if (candidates.size() == 0) {
stats.n2++;
}
stats.ndis += ndis;
stats.nhops += nstep;
}
return nres;
}
std::priority_queue<HNSW::Node> search_from_candidate_unbounded(
const HNSW& hnsw,
const Node& node,
@@ -936,6 +1199,7 @@ int extract_k_from_ResultHandler(ResultHandler<C>& res) {
HNSWStats HNSW::search(
DistanceComputer& qdis,
const IndexHNSW* index,
ResultHandler<C>& res,
VisitedTable& vt,
const SearchParameters* params) const {
@@ -966,13 +1230,28 @@ HNSWStats HNSW::search(
}
int ef = std::max(efSearch, k);
if (bounded_queue) { // this is the most common branch
if (bounded_queue) { // this is the most common branch, for now we only
// support Panorama search in this branch
MinimaxHeap candidates(ef);
candidates.push(nearest, d_nearest);
search_from_candidates(
*this, qdis, res, candidates, vt, stats, 0, 0, params);
if (!is_panorama) {
search_from_candidates(
*this, qdis, res, candidates, vt, stats, 0, 0, params);
} else {
search_from_candidates_panorama(
*this,
index,
qdis,
res,
candidates,
vt,
stats,
0,
0,
params);
}
} else {
std::priority_queue<Node> top_candidates =
search_from_candidate_unbounded(
+31 -2
View File
@@ -8,12 +8,12 @@
#pragma once
#include <queue>
#include <unordered_set>
#include <vector>
#include <omp.h>
#include <faiss/Index.h>
#include <faiss/impl/DistanceComputer.h>
#include <faiss/impl/FaissAssert.h>
#include <faiss/impl/maybe_owned_vector.h>
#include <faiss/impl/platform_macros.h>
@@ -22,6 +22,10 @@
namespace faiss {
// Forward declarations to avoid circular dependency.
struct IndexHNSW;
struct IndexHNSWFlatPanorama;
/** Implementation of the Hierarchical Navigable Small World
* datastructure.
*
@@ -146,6 +150,9 @@ struct HNSW {
/// use bounded queue during exploration
bool search_bounded_queue = true;
/// use Panorama progressive pruning in search
bool is_panorama = false;
// methods that initialize the tree sizes
/// initialize the assign_probas and cum_nneighbor_per_level to
@@ -196,9 +203,15 @@ struct HNSW {
VisitedTable& vt,
bool keep_max_size_level0 = false);
/// search interface for 1 point, single thread
/// Search interface for 1 point, single thread
///
/// NOTE: We pass a reference to the index itself to allow for additional
/// state information to be passed (used for Panorama progressive pruning).
/// The alternative would be to override both HNSW::search and
/// HNSWIndex::search, which would be a nuisance of code duplication.
HNSWStats search(
DistanceComputer& qdis,
const IndexHNSW* index,
ResultHandler<C>& res,
VisitedTable& vt,
const SearchParameters* params = nullptr) const;
@@ -267,6 +280,22 @@ int search_from_candidates(
int nres_in = 0,
const SearchParameters* params = nullptr);
/// Equivalent to `search_from_candidates`, but applies pruning with progressive
/// refinement bounds.
/// This is used in `IndexHNSWFlatPanorama` to improve the search performance
/// for higher dimensional vectors.
int search_from_candidates_panorama(
const HNSW& hnsw,
const IndexHNSW* index,
DistanceComputer& qdis,
ResultHandler<HNSW::C>& res,
HNSW::MinimaxHeap& candidates,
VisitedTable& vt,
HNSWStats& stats,
int level,
int nres_in = 0,
const SearchParameters* params = nullptr);
HNSWStats greedy_update_nearest(
const HNSW& hnsw,
DistanceComputer& qdis,
+2
View File
@@ -367,6 +367,7 @@ float RaBitQDistanceComputerNotQ::distance_to_code_full(const uint8_t* code) {
}
void RaBitQDistanceComputerNotQ::set_query(const float* x) {
q = x;
FAISS_ASSERT(x != nullptr);
FAISS_ASSERT(
(metric_type == MetricType::METRIC_L2 ||
@@ -564,6 +565,7 @@ float RaBitQDistanceComputerQ::distance_to_code_full(const uint8_t* code) {
using rabitq_utils::Z_MAX_BY_QB;
void RaBitQDistanceComputerQ::set_query(const float* x) {
q = x;
FAISS_ASSERT(x != nullptr);
FAISS_ASSERT(
(metric_type == MetricType::METRIC_L2 ||
+1 -3
View File
@@ -98,9 +98,7 @@ struct ScalarQuantizer : Quantizer {
SQuantizer* select_quantizer() const;
struct SQDistanceComputer : FlatCodesDistanceComputer {
const float* q;
SQDistanceComputer() : q(nullptr) {}
SQDistanceComputer() : FlatCodesDistanceComputer(nullptr) {}
virtual float query_to_code(const uint8_t* code) const = 0;
+17 -1
View File
@@ -1168,11 +1168,15 @@ Index* read_index(IOReader* f, int io_flags) {
idx = idxp;
} else if (
h == fourcc("IHNf") || h == fourcc("IHNp") || h == fourcc("IHNs") ||
h == fourcc("IHN2") || h == fourcc("IHNc") || h == fourcc("IHc2")) {
h == fourcc("IHN2") || h == fourcc("IHNc") || h == fourcc("IHc2") ||
h == fourcc("IHfP")) {
IndexHNSW* idxhnsw = nullptr;
if (h == fourcc("IHNf")) {
idxhnsw = new IndexHNSWFlat();
}
if (h == fourcc("IHfP")) {
idxhnsw = new IndexHNSWFlatPanorama();
}
if (h == fourcc("IHNp")) {
idxhnsw = new IndexHNSWPQ();
}
@@ -1189,6 +1193,15 @@ Index* read_index(IOReader* f, int io_flags) {
idxhnsw = new IndexHNSWCagra();
}
read_index_header(idxhnsw, f);
if (h == fourcc("IHfP")) {
auto idx_panorama = dynamic_cast<IndexHNSWFlatPanorama*>(idxhnsw);
size_t nlevels;
READ1(nlevels);
const_cast<size_t&>(idx_panorama->num_panorama_levels) = nlevels;
const_cast<size_t&>(idx_panorama->panorama_level_width) =
(idx_panorama->d + nlevels - 1) / nlevels;
READVECTOR(idx_panorama->cum_sums);
}
if (h == fourcc("IHNc") || h == fourcc("IHc2")) {
READ1(idxhnsw->keep_max_size_level0);
auto idx_hnsw_cagra = dynamic_cast<IndexHNSWCagra*>(idxhnsw);
@@ -1201,6 +1214,7 @@ Index* read_index(IOReader* f, int io_flags) {
}
}
read_HNSW(&idxhnsw->hnsw, f);
idxhnsw->hnsw.is_panorama = (h == fourcc("IHfP"));
idxhnsw->storage = read_index(f, io_flags);
idxhnsw->own_fields = idxhnsw->storage != nullptr;
if (h == fourcc("IHNp") && !(io_flags & IO_FLAG_PQ_SKIP_SDC_TABLE)) {
@@ -1603,6 +1617,7 @@ IndexBinary* read_index_binary(IOReader* f, int io_flags) {
IndexBinaryHNSW* idxhnsw = new IndexBinaryHNSW();
read_index_binary_header(idxhnsw, f);
read_HNSW(&idxhnsw->hnsw, f);
idxhnsw->hnsw.is_panorama = false;
idxhnsw->storage = read_index_binary(f, io_flags);
idxhnsw->own_fields = true;
idx = idxhnsw;
@@ -1613,6 +1628,7 @@ IndexBinary* read_index_binary(IOReader* f, int io_flags) {
READ1(idxhnsw->base_level_only);
READ1(idxhnsw->num_base_level_search_entrypoints);
read_HNSW(&idxhnsw->hnsw, f);
idxhnsw->hnsw.is_panorama = false;
idxhnsw->storage = read_index_binary(f, io_flags);
idxhnsw->own_fields = true;
idx = idxhnsw;
+14 -6
View File
@@ -832,15 +832,23 @@ void write_index(const Index* idx, IOWriter* f, int io_flags) {
write_index(idxmap->index, f);
WRITEVECTOR(idxmap->id_map);
} else if (const IndexHNSW* idxhnsw = dynamic_cast<const IndexHNSW*>(idx)) {
uint32_t h = dynamic_cast<const IndexHNSWFlat*>(idx) ? fourcc("IHNf")
: dynamic_cast<const IndexHNSWPQ*>(idx) ? fourcc("IHNp")
: dynamic_cast<const IndexHNSWSQ*>(idx) ? fourcc("IHNs")
: dynamic_cast<const IndexHNSW2Level*>(idx) ? fourcc("IHN2")
: dynamic_cast<const IndexHNSWCagra*>(idx) ? fourcc("IHc2")
: 0;
uint32_t h = dynamic_cast<const IndexHNSWFlatPanorama*>(idx)
? fourcc("IHfP")
: dynamic_cast<const IndexHNSWFlat*>(idx) ? fourcc("IHNf")
: dynamic_cast<const IndexHNSWPQ*>(idx) ? fourcc("IHNp")
: dynamic_cast<const IndexHNSWSQ*>(idx) ? fourcc("IHNs")
: dynamic_cast<const IndexHNSW2Level*>(idx) ? fourcc("IHN2")
: dynamic_cast<const IndexHNSWCagra*>(idx) ? fourcc("IHc2")
: 0;
FAISS_THROW_IF_NOT(h != 0);
WRITE1(h);
write_index_header(idxhnsw, f);
if (h == fourcc("IHfP")) {
auto idx_panorama =
dynamic_cast<const IndexHNSWFlatPanorama*>(idxhnsw);
WRITE1(idx_panorama->num_panorama_levels);
WRITEVECTOR(idx_panorama->cum_sums);
}
if (h == fourcc("IHc2")) {
WRITE1(idxhnsw->keep_max_size_level0);
auto idx_hnsw_cagra = dynamic_cast<const IndexHNSWCagra*>(idxhnsw);
+5
View File
@@ -493,6 +493,11 @@ IndexHNSW* parse_IndexHNSW(
return new IndexHNSWFlat(d, hnsw_M, mt);
}
if (match("FlatPanorama([0-9]+)?")) {
int nlevels = mres_to_int(sm[1], 8); // default to 8 levels
return new IndexHNSWFlatPanorama(d, hnsw_M, nlevels, mt);
}
if (match("PQ([0-9]+)(x[0-9]+)?(np)?")) {
int M = std::stoi(sm[1].str());
int nbit = mres_to_int(sm[2], 8, 1);
+1
View File
@@ -782,6 +782,7 @@ struct faiss::simd16uint16 {};
DOWNCAST ( IndexLattice )
DOWNCAST ( IndexPreTransform )
DOWNCAST ( MultiIndexQuantizer )
DOWNCAST ( IndexHNSWFlatPanorama )
DOWNCAST ( IndexHNSWFlat )
DOWNCAST ( IndexHNSWPQ )
DOWNCAST ( IndexHNSWSQ )
+523
View File
@@ -0,0 +1,523 @@
# 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 faiss.contrib.datasets import SyntheticDataset
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_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 = 64
nb = 500
nt = 700
nq = 10
k = 5
# Generate data
_, xb, xq = self.generate_data(d, nt, nb, nq, seed=2024)
index = faiss.IndexHNSWFlatPanorama(d, 16, 8)
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, 8)
# 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")