Skip to content

Commit 561e6ec

Browse files
wilyan09007meta-codesync[bot]
authored andcommitted
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 #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: #5509 Reviewed By: juancarpio27 Differential Revision: D117536893 Pulled By: mnorris11 fbshipit-source-id: 8568437eb596a202262fc455748063fafca202c1
1 parent 613e0ac commit 561e6ec

2 files changed

Lines changed: 73 additions & 23 deletions

File tree

faiss/IndexShards.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,8 @@ void IndexShardsTemplate<IndexT>::search(
243243

244244
this->runOnIndex(fn);
245245

246-
if (this->metric_type == METRIC_L2) {
247-
merge_knn_results<idx_t, CMin<distance_t, int>>(
246+
if (is_similarity_metric(this->metric_type)) {
247+
merge_knn_results<idx_t, CMax<distance_t, int>>(
248248
n,
249249
k,
250250
nshard,
@@ -253,7 +253,7 @@ void IndexShardsTemplate<IndexT>::search(
253253
distances,
254254
labels);
255255
} else {
256-
merge_knn_results<idx_t, CMax<distance_t, int>>(
256+
merge_knn_results<idx_t, CMin<distance_t, int>>(
257257
n,
258258
k,
259259
nshard,

tests/test_meta_index.py

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,26 @@ def test_id_remap_ivf(self):
7474

7575
class Shards(unittest.TestCase):
7676

77+
def add_flat_shards(
78+
self,
79+
shard_index,
80+
xbase,
81+
metric=faiss.METRIC_L2,
82+
metric_arg=0,
83+
ni=3,
84+
wrap=None,
85+
):
86+
"""Split xbase over ni IndexFlat shards of shard_index. xbase may be
87+
None to leave the shards empty, e.g. to fill them via shard_index.add()
88+
"""
89+
for i in range(ni):
90+
n = 0 if xbase is None else len(xbase)
91+
shard = faiss.IndexFlat(shard_index.d, metric)
92+
shard.metric_arg = metric_arg
93+
if xbase is not None:
94+
shard.add(xbase[i * n // ni : (i + 1) * n // ni])
95+
shard_index.add_shard(shard if wrap is None else wrap(shard))
96+
7797
@unittest.skipIf(
7898
os.name == "posix" and os.uname().sysname == "Darwin",
7999
"There is a bug in the OpenMP implementation on OSX.",
@@ -92,26 +112,10 @@ def test_shards(self):
92112
shard_index_threaded = faiss.IndexShards(d, True) # explicitly threaded
93113
shard_index_2 = faiss.IndexShards(d, True, False)
94114

95-
ni = 3
96-
# Populate both indexes with the same data
97-
for i in range(ni):
98-
i0 = int(i * nb / ni)
99-
i1 = int((i + 1) * nb / ni)
100-
101-
# Add to non-threaded index
102-
index_nt = faiss.IndexFlatL2(d)
103-
index_nt.add(xb[i0:i1])
104-
shard_index_nonthreaded.add_shard(index_nt)
105-
106-
# Add to threaded index
107-
index_t = faiss.IndexFlatL2(d)
108-
index_t.add(xb[i0:i1])
109-
shard_index_threaded.add_shard(index_t)
110-
111-
# Add to shard_index_2 for the original test logic
112-
index_2 = faiss.IndexFlatL2(d)
113-
irm = faiss.IndexIDMap(index_2)
114-
shard_index_2.add_shard(irm)
115+
self.add_flat_shards(shard_index_nonthreaded, xb)
116+
self.add_flat_shards(shard_index_threaded, xb)
117+
# populated below by the parallel add rather than shard by shard
118+
self.add_flat_shards(shard_index_2, None, wrap=faiss.IndexIDMap)
115119

116120
# test parallel add
117121
shard_index_2.verbose = True
@@ -145,6 +149,52 @@ def test_shards(self):
145149
# thousands of the nq*k cells, far above this floor.
146150
assert ndiff < nq * k / 100.0, f"too many mismatches: {ndiff}"
147151

152+
def test_shards_metrics(self):
153+
# IndexShards merges the per-shard results itself, so it has to know
154+
# whether the metric ranks by similarity (largest first) or by distance
155+
# (smallest first). Only METRIC_L2 used to be treated as a distance, so
156+
# every other dis-similarity metric returned the FARTHEST vectors once
157+
# results crossed a shard boundary.
158+
rs = np.random.RandomState(123)
159+
dim, n, nquery, k = 16, 600, 50, 10
160+
# Components in [0, 1) keep every metric well-defined: Jaccard needs
161+
# positive components and GOWER numeric dimensions in [0, 1].
162+
base = rs.rand(n, dim).astype("float32")
163+
queries = rs.rand(nquery, dim).astype("float32")
164+
p = 1.5 # metric_arg for METRIC_Lp
165+
166+
for metric in (
167+
faiss.METRIC_INNER_PRODUCT,
168+
faiss.METRIC_L2,
169+
faiss.METRIC_L1,
170+
faiss.METRIC_Linf,
171+
faiss.METRIC_Lp,
172+
faiss.METRIC_Canberra,
173+
faiss.METRIC_BrayCurtis,
174+
faiss.METRIC_JensenShannon,
175+
faiss.METRIC_Jaccard,
176+
faiss.METRIC_NaNEuclidean,
177+
faiss.METRIC_GOWER,
178+
):
179+
with self.subTest(metric=metric):
180+
ref_index = faiss.IndexFlat(dim, metric)
181+
ref_index.metric_arg = p
182+
ref_index.add(base)
183+
Dref, _Iref = ref_index.search(queries, k)
184+
185+
shard_index = faiss.IndexShards(dim, False, True)
186+
self.add_flat_shards(shard_index, base, metric, metric_arg=p)
187+
D, _I = shard_index.search(queries, k)
188+
189+
if faiss.is_similarity_metric(metric):
190+
self.assertTrue(np.all(D[:, :-1] >= D[:, 1:]))
191+
else:
192+
self.assertTrue(np.all(D[:, :-1] <= D[:, 1:]))
193+
# Same neighbors as the unsharded index. Distances are compared
194+
# rather than labels so equidistant neighbors may come in
195+
# either order.
196+
np.testing.assert_array_almost_equal(D, Dref, decimal=5)
197+
148198
def test_shards_ivf(self):
149199
ds = SyntheticDataset(32, 1000, 100, 20)
150200
ref_index = faiss.index_factory(ds.d, "IVF32,SQ8")

0 commit comments

Comments
 (0)