Files
faiss/tests/test_meta_index.py
T
William 561e6ecdf3 Merge shard results by metric type rather than assuming similarity (#5509)
Summary:
`IndexShardsTemplate::search` picked the comparator for merging per-shard results by testing for `METRIC_L2`:

```cpp
if (this->metric_type == METRIC_L2) {
    merge_knn_results<idx_t, CMin<distance_t, int>>(...);   // smaller is better
} else {
    merge_knn_results<idx_t, CMax<distance_t, int>>(...);   // larger is better
}
```

Everything that is not `METRIC_L2` was therefore merged as a similarity. `METRIC_L1`, `METRIC_Linf` and `METRIC_Lp` return distances, so once results crossed a shard boundary they came back farthest-first, while the same vectors in a single unsharded index came back nearest-first.

This PR selects the comparator with the existing `is_similarity_metric()` helper from `MetricType.h`, which is exactly the distinction the branch needs.

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

## Behaviour change

`is_similarity_metric()` is true only for `METRIC_INNER_PRODUCT` and `METRIC_Jaccard`, so:

| metric | before | after |
| --- | --- | --- |
| `METRIC_L2` | `CMin` | `CMin` (unchanged) |
| `METRIC_INNER_PRODUCT` | `CMax` | `CMax` (unchanged) |
| `METRIC_L1`, `METRIC_Linf`, `METRIC_Lp`, `METRIC_Canberra`, `METRIC_BrayCurtis`, `METRIC_JensenShannon` | `CMax` | **`CMin`** |
| `METRIC_Jaccard` | `CMax` | `CMax` (unchanged) |

`IndexShardsTemplate<IndexBinary>` is also unaffected: `IndexBinary::metric_type` defaults to `METRIC_L2`, which keeps `CMin` as before.

## Reproduction

Against the released `faiss-cpu` 1.15.0 wheel, using the reporter's example:

```python
import faiss, numpy as np

xb0 = np.array([[0., 0.]], dtype='float32')
xb1 = np.array([[10., 0.]], dtype='float32')
xq  = np.array([[1., 0.]], dtype='float32')

flat = faiss.IndexFlat(2, faiss.METRIC_L1)
flat.add(np.vstack([xb0, xb1]))
print(flat.search(xq, 2))

s0 = faiss.IndexFlat(2, faiss.METRIC_L1); s0.add(xb0)
s1 = faiss.IndexFlat(2, faiss.METRIC_L1); s1.add(xb1)
shards = faiss.IndexShards(2, False, True)
shards.add_shard(s0); shards.add_shard(s1)
print(shards.search(xq, 2))
```

```
single IndexFlat  D,I = [[1. 9.]] [[0 1]]     # nearest first, correct
IndexShards       D,I = [[9. 1.]] [[1 0]]     # farthest first, reversed
```

## Test

Adds `Shards::test_shards_distance_metric_ordering` to `tests/test_meta_index.py`. It splits the dataset across three `METRIC_L1` shards and checks that each result row is ordered nearest-first and matches the distances an unsharded `IndexFlat(METRIC_L1)` returns. Distances rather than labels are compared so that equidistant neighbours may be returned in either order.

The test fails on `main` (the rows come back reversed) and passes with this change.

## Testing notes

I reproduced the bug against the released 1.15.0 wheel as shown above, but I was not able to build faiss from source on this machine (Windows, no local C++ toolchain), so I have not executed the C++ build or run the test suite locally. The added test's pass/fail claim above follows from the comparator change rather than from a local run. Please treat CI as the gate, and I am happy to adjust if anything in the suite disagrees.

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

Reviewed By: juancarpio27

Differential Revision: D117536893

Pulled By: mnorris11

fbshipit-source-id: 8568437eb596a202262fc455748063fafca202c1
2026-08-26 12:48:20 -07:00

248 lines
8.3 KiB
Python

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import os
import numpy as np
import faiss
import unittest
from common_faiss_tests import Randu10k
from faiss.contrib.datasets import SyntheticDataset
ru = Randu10k()
xb = ru.xb
xt = ru.xt
xq = ru.xq
nb, d = xb.shape
nq, d = xq.shape
class IDRemap(unittest.TestCase):
def test_id_remap_idmap(self):
# reference: index without remapping
index = faiss.IndexPQ(d, 8, 8)
k = 10
index.train(xt)
index.add(xb)
_Dref, Iref = index.search(xq, k)
# try a remapping
ids = np.arange(nb)[::-1].copy().astype("int64")
sub_index = faiss.IndexPQ(d, 8, 8)
index2 = faiss.IndexIDMap(sub_index)
index2.train(xt)
index2.add_with_ids(xb, ids)
_D, I = index2.search(xq, k)
assert np.all(I == nb - 1 - Iref)
def test_id_remap_ivf(self):
# coarse quantizer in common
coarse_quantizer = faiss.IndexFlatIP(d)
ncentroids = 25
# reference: index without remapping
index = faiss.IndexIVFPQ(coarse_quantizer, d, ncentroids, 8, 8)
index.nprobe = 5
k = 10
index.train(xt)
index.add(xb)
_Dref, Iref = index.search(xq, k)
# try a remapping
ids = np.arange(nb)[::-1].copy().astype("int64")
index2 = faiss.IndexIVFPQ(coarse_quantizer, d, ncentroids, 8, 8)
index2.nprobe = 5
index2.train(xt)
index2.add_with_ids(xb, ids)
_D, I = index2.search(xq, k)
assert np.all(I == nb - 1 - Iref)
class Shards(unittest.TestCase):
def add_flat_shards(
self,
shard_index,
xbase,
metric=faiss.METRIC_L2,
metric_arg=0,
ni=3,
wrap=None,
):
"""Split xbase over ni IndexFlat shards of shard_index. xbase may be
None to leave the shards empty, e.g. to fill them via shard_index.add()
"""
for i in range(ni):
n = 0 if xbase is None else len(xbase)
shard = faiss.IndexFlat(shard_index.d, metric)
shard.metric_arg = metric_arg
if xbase is not None:
shard.add(xbase[i * n // ni : (i + 1) * n // ni])
shard_index.add_shard(shard if wrap is None else wrap(shard))
@unittest.skipIf(
os.name == "posix" and os.uname().sysname == "Darwin",
"There is a bug in the OpenMP implementation on OSX.",
)
def test_shards(self):
k = 32
ref_index = faiss.IndexFlatL2(d)
ref_index.add(xb)
_Dref, Iref = ref_index.search(xq, k)
# Create both threaded and non-threaded shard indexes
shard_index_nonthreaded = faiss.IndexShards(
d, False
) # explicitly non-threaded
shard_index_threaded = faiss.IndexShards(d, True) # explicitly threaded
shard_index_2 = faiss.IndexShards(d, True, False)
self.add_flat_shards(shard_index_nonthreaded, xb)
self.add_flat_shards(shard_index_threaded, xb)
# populated below by the parallel add rather than shard by shard
self.add_flat_shards(shard_index_2, None, wrap=faiss.IndexIDMap)
# test parallel add
shard_index_2.verbose = True
shard_index_2.add(xb)
for test_no in range(3):
with_threads = test_no == 1
if with_threads:
remember_nt = faiss.omp_get_max_threads()
faiss.omp_set_num_threads(1)
# Use the threaded index
test_index = shard_index_threaded
else:
# Use the non-threaded index
test_index = shard_index_nonthreaded
if test_no != 2:
_D, I = test_index.search(xq, k)
else:
_D, I = shard_index_2.search(xq, k)
if with_threads:
faiss.omp_set_num_threads(remember_nt)
ndiff = (I != Iref).sum()
# IndexShards merges per-shard top-k by distance; float32 ULP ties
# at the k=32 boundary reorder neighbors vs. the unsharded
# IndexFlatL2 reference (amplified by the threaded shard merge).
# Allow ~1% mismatches; a real merge regression collapses
# thousands of the nq*k cells, far above this floor.
assert ndiff < nq * k / 100.0, f"too many mismatches: {ndiff}"
def test_shards_metrics(self):
# IndexShards merges the per-shard results itself, so it has to know
# whether the metric ranks by similarity (largest first) or by distance
# (smallest first). Only METRIC_L2 used to be treated as a distance, so
# every other dis-similarity metric returned the FARTHEST vectors once
# results crossed a shard boundary.
rs = np.random.RandomState(123)
dim, n, nquery, k = 16, 600, 50, 10
# Components in [0, 1) keep every metric well-defined: Jaccard needs
# positive components and GOWER numeric dimensions in [0, 1].
base = rs.rand(n, dim).astype("float32")
queries = rs.rand(nquery, dim).astype("float32")
p = 1.5 # metric_arg for METRIC_Lp
for metric in (
faiss.METRIC_INNER_PRODUCT,
faiss.METRIC_L2,
faiss.METRIC_L1,
faiss.METRIC_Linf,
faiss.METRIC_Lp,
faiss.METRIC_Canberra,
faiss.METRIC_BrayCurtis,
faiss.METRIC_JensenShannon,
faiss.METRIC_Jaccard,
faiss.METRIC_NaNEuclidean,
faiss.METRIC_GOWER,
):
with self.subTest(metric=metric):
ref_index = faiss.IndexFlat(dim, metric)
ref_index.metric_arg = p
ref_index.add(base)
Dref, _Iref = ref_index.search(queries, k)
shard_index = faiss.IndexShards(dim, False, True)
self.add_flat_shards(shard_index, base, metric, metric_arg=p)
D, _I = shard_index.search(queries, k)
if faiss.is_similarity_metric(metric):
self.assertTrue(np.all(D[:, :-1] >= D[:, 1:]))
else:
self.assertTrue(np.all(D[:, :-1] <= D[:, 1:]))
# Same neighbors as the unsharded index. Distances are compared
# rather than labels so equidistant neighbors may come in
# either order.
np.testing.assert_array_almost_equal(D, Dref, decimal=5)
def test_shards_ivf(self):
ds = SyntheticDataset(32, 1000, 100, 20)
ref_index = faiss.index_factory(ds.d, "IVF32,SQ8")
ref_index.train(ds.get_train())
xb = ds.get_database()
ref_index.add(ds.get_database())
Dref, Iref = ref_index.search(ds.get_database(), 10)
ref_index.reset()
sharded_index = faiss.IndexShardsIVF(
ref_index.quantizer, ref_index.nlist, False, True
)
for shard in range(3):
index_i = faiss.clone_index(ref_index)
index_i.add(xb[shard * nb // 3 : (shard + 1) * nb // 3])
sharded_index.add_shard(index_i)
Dnew, Inew = sharded_index.search(ds.get_database(), 10)
np.testing.assert_equal(Inew, Iref)
np.testing.assert_allclose(Dnew, Dref)
def test_shards_ivf_train_add(self):
ds = SyntheticDataset(32, 1000, 600, 20)
quantizer = faiss.IndexFlatL2(ds.d)
sharded_index = faiss.IndexShardsIVF(quantizer, 40, False, False)
for _ in range(3):
sharded_index.add_shard(faiss.index_factory(ds.d, "IVF40,Flat"))
sharded_index.train(ds.get_train())
sharded_index.add(ds.get_database())
Dnew, Inew = sharded_index.search(ds.get_queries(), 10)
index_ref = faiss.IndexIVFFlat(quantizer, ds.d, sharded_index.nlist)
index_ref.train(ds.get_train())
index_ref.add(ds.get_database())
Dref, Iref = index_ref.search(ds.get_queries(), 10)
np.testing.assert_equal(Inew, Iref)
np.testing.assert_allclose(Dnew, Dref)
# mess around with the quantizer's centroids
centroids = quantizer.reconstruct_n()
centroids = centroids[::-1].copy()
quantizer.reset()
quantizer.add(centroids)
D2, I2 = sharded_index.search(ds.get_queries(), 10)
self.assertFalse(np.all(I2 == Inew))