fix: fall back to brute force in HNSW AnnIterator at high filter ratios - #1863
rere950303 wants to merge 2 commits into
Conversation
When the bitset filters out at least kHnswSearchKnnBFFilterThreshold (93%) of the points, FaissHnswIterator kept traversing the graph and visited almost every node to find the few unfiltered ones. kNN Search already switches to brute force at this threshold, and RangeSearch does since zilliztech#1535. At or above the threshold the iterator now scans the unfiltered points once and hands them to IndexIterator in a single batch. Distances come from the same storage distance computer (or the refine index when present, as the brute-force kNN Search does); label mapping and result id mapping are unchanged. A side effect is that points unreachable from the entry point are now returned too; the RaBitQ acceptance test is updated accordingly. Signed-off-by: Hyungwook Yang <yhwjjang1995@naver.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: rere950303 The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Follow-up (not in this PR): DiskANN has the same gap. This one is larger than the HNSW change, because the brute-force scan has to read the vectors from disk (or scan PQ codes and then refine from disk). I'd like to open a separate issue to agree on the approach first. |
|
Welcome @rere950303! It looks like this is your first PR to zilliztech/knowhere 🎉 |
|
@rere950303 I'm taking a look |
|
@rere950303 I've did a benchmark for our typical datasets that we use for testing. Sure, I do confirm performance gains for the provided operating point (ef=750), which is a bit unusual operating point for the search. However, for typical search scenario, 93% and even 95% is way too "early" threshold, because a switch to the brute-force overall slows down iterators significantly (but may improve the recall rate). For 99% - sure, the brute force clearly wins in all of the cases that have been tried, although I believe that it also depends on the dimensionality of the dataset. I'd rather bet of 97% as a default threshold that better suits a wider set of use cases. I think that the provided PR makes sense if the ANN iterator threshold would go up from 93% to 97%. The reasonable way to do so might be to introduce a new constant here .Given your particular search scenario, where ef is quite high and that benefits from a lower level of such a threshold, you could also consider making such a threshold parameter configurable. So, it needs to be transformed from a static field into a regular passable search parameter. I think that it is acceptable to introduce a new static float threshold first in this PR, and transition the threshold into a regular config parameter in the following PR. Please let me know what you think. |
…witch Add kHnswSearchIteratorBFFilterThreshold (0.97) and use it to decide when FaissHnswIterator scans the unfiltered points. At typical ef values the graph traversal of an iterator stays cheaper than the brute force below ~97%. The accumulated_alpha behavior at the kNN threshold (93%) is unchanged. Signed-off-by: Hyungwook Yang <yhwjjang1995@naver.com>
|
@alexanderguzhva Thanks for running it on your datasets. Agreed. Pushed 29bfcc8:
I'm happy to follow up with a separate PR that turns it into a search parameter. You're right that ef=750 is unusual, and I need to correct the issue description on that point. On our previous version (knowhere 2.3.14), pymilvus 2.4's V1 iterator clamped |
|
/lgtm |
issue: #1862
Summary
AnnIteratorhad no brute-force fallback at high filter ratios. At high filter ratiosFaissHnswIteratorkept traversing the graph and visited almost every node to find the few unfiltered ones.kHnswSearchIteratorBFFilterThreshold(97%, same value as the range-search threshold) decides the switch. At or above it, the iterator scans the unfiltered points once and hands them toIndexIteratorin a single batch.IndexIteratorkeeps them in its heap and returns them in order.accumulated_alphabehavior at the kNN threshold (93%) is unchanged.Searchdoes. Label mapping and result id mapping are unchanged.RangeSearch.Behavior change
At high filter ratios the iterator now returns every unfiltered point, including points that are unreachable from the entry point in the filtered graph.
RaBitQ filtered results and exhausted iterators match full-code referencespinned the reachable set, so it now expects all unfiltered points once the threshold is reached.Tests
Test HNSW iterator falls back to brute force at high filter ratio(HNSW, HNSW_SQ, HNSW_PQ, with and without refine; 98% / 99%; first-N and random bitsets; L2 / IP / COSINE). It checks that:Searchfor the quantized ones).knowhere_tests(Release, aarch64): 286 test cases; the only failure before the test update was the RaBitQ reachability assertion above.Benchmark
Synthetic, 200k × 768 fp32, COSINE, HNSW M=16 / efConstruction=200, ef=750, random bitset, 20 queries, time to get the first 16 results from
AnnIterator(best of 3), aarch64 10 cores.Milvus search iterator V2 calls
AnnIteratoron every page, so each page of a filtered iterator search pays the traversal. In our deployment, the p99 latency of filtered HNSWsearch_iteratorcalls rose by roughly an order of magnitude after moving from the pymilvus V1 iterator to V2. Note that this also came with a larger effectiveef: pymilvus 2.4's V1 iterator clampsefto the batch size before issuingRangeSearch, while V2 passes the configuredefthrough.