john-mlika opened a new issue, #16586:
URL: https://github.com/apache/lucene/issues/16586
### What happens
KnnFloatVectorQuery / KnnByteVectorQuery with a pre-filter build a per-leaf
accept set in AcceptDocs.fromIteratorSupplier, then hand it to the codec. Five
things go wrong on that path.
1. With deletions, the accept set is built one doc at a time.
DocIdSetIteratorAcceptDocs.createBitSetAcceptDocsIfNecessary
(AcceptDocs.java:177-182) passes iterator() to createBitSet, and with live docs
iterator() is a FilteredDocIdSetIterator, which has no intoBitSet, so
FixedBitSet#or falls back to nextDoc() per doc, and createBitSet then applies
liveDocs a second time. One deleted doc in the segment is enough, for any
accept set dense enough for the bit set branch (cost >= maxDoc >> 7).
createBitSet already handles liveDocs, it should get the raw iterator.
2. The cached filter is never used as-is. AbstractKnnVectorQuery#rewrite
(AbstractKnnVectorQuery.java:88-92) ANDs FieldExistsQuery(field) into the
filter, so what reaches the accept set is a fresh ConjunctionDISI per query
instead of the cached BitSetIterator, and ConjunctionDISI has no intoBitSet
either. The conjunct came in with #656 for the exact search cost estimate, not
for correctness: HNSW only scores graph ordinals and exactSearch intersects
with the vector iterator anyway. It only survives when some leaf has a doc
without a vector, otherwise FieldExistsQuery rewrites to MatchAllDocsQuery and
drops out.
3. The filter weight itself is not cached. Same method, line 97:
rewritten.createWeight(indexSearcher, ...) instead of
indexSearcher.createWeight(...), so the query cache is consulted only for the
clauses of a BooleanQuery filter, never for the filter.
AbstractVectorSimilarityQuery (:152) goes through the searcher.
4. Small filters scan the whole segment. The query runs an exact search only
when the filter accepts at most perLeafTopK docs
(AbstractKnnVectorQuery.java:282). The reader has its own rule,
HnswGraphSearcher#expectedVisitedNodes = log(graphSize) * k, and refuses the
graph when the accept set is at most that
(Lucene99HnswVectorsReader.java:369-372). But its fallback scans every vector
of the segment testing the accept bits one by one (:382). So between
perLeafTopK and log(graphSize) * k accepted docs, the query asks for an
approximate search, the codec declines, and 200k vectors get tested to find 400
of them, with a DirectMonotonicReader lookup per ordinal on a field that has
vectorless docs.
5. On a field where some docs have no vector, the filtered graph search
tests acceptance through an off-heap lookup. Lucene99HnswVectorsReader#search
(:365) hands the search scorer.getAcceptOrds(accepted), which for the sparse
off-heap values is a lazy Bits whose get(ord) is acceptDocs.get(ordToDoc(ord)),
a DirectMonotonicReader read. FilteredHnswGraphSearcher does that on every
neighbour and 2-hop neighbour it looks at. At 2% selectivity that lookup is
~40% of the query on such a field (0.30 of 0.74 ms, async-profiler); the same
query on a fully vectored field takes 0.40 ms.
1 and 2 compound: fixing 1 alone still walks a ConjunctionDISI per doc,
fixing 2 alone still wraps the cached bit set in FilteredDocIdSetIterator on
any segment with deletes.
### Numbers
FilteredKnnVectorQueryBenchmark (added in PR-1): one segment, 200k docs,
128-dim random unit vectors, float HNSW with the default codec, k=100,
pre-filter a TermQuery already in the LRUQueryCache. main @ 411013eebd, JDK 25
with --add-modules jdk.incubator.vector, AMD Genoa, each cell its own JMH
process pinned to 10 vCPUs. ms per query.
```
java --add-modules jdk.incubator.vector -jar lucene-benchmark-jmh-*.jar
FilteredKnnVectorQueryBenchmark \
-p filterSelectivity=0.95 -p vectorlessFraction=0.1 -p
deletedFraction=0.05 -f 3
```
Problems 1 to 3 (PR-1 fixes 1, PR-2 fixes 2 and 3), 3 forks per arm:
| filter selectivity | docs without vector | deleted docs | main | PR-1 |
PR-1+2 |
|---|---|---|---|---|---|
| 95% | 0% | 0% | 0.442 | 0.452 | 0.445 |
| 95% | 0% | 5% | 2.046 | 1.227 (1.67x) | 1.242 (1.65x) |
| 95% | 10% | 0% | 2.265 | 2.199 | 0.472 (4.79x) |
| 95% | 10% | 5% | 3.187 | 2.918 (1.09x) | 1.226 (2.60x) |
| 5% | 0% | 0% | 0.432 | 0.506 | 0.501 |
| 5% | 0% | 5% | 0.557 | 0.526 | 0.495 (1.13x) |
| 5% | 10% | 0% | 1.002 | 1.059 | 0.888 (1.13x) |
| 5% | 10% | 5% | 1.097 | 1.041 | 0.908 (1.21x) |
Same index, 5% deleted, across selectivities:
| filter selectivity | docs without vector | main | PR-1 | PR-1+2 |
|---|---|---|---|---|
| 99% | 0% | 2.062 | 1.231 (1.68x) | 1.232 (1.67x) |
| 50% | 0% | 1.372 | 0.930 (1.48x) | 0.899 (1.53x) |
| 20% | 0% | 0.907 | 0.730 (1.24x) | 0.675 (1.34x) |
| 1% | 0% | 0.403 | 0.364 (1.11x) | 0.376 (1.07x) |
| 99% | 10% | 3.242 | 2.850 (1.14x) | 1.291 (2.51x) |
| 50% | 10% | 2.144 | 2.053 (1.04x) | 1.133 (1.89x) |
| 20% | 10% | 1.457 | 1.288 (1.13x) | 0.937 (1.55x) |
| 1% | 10% | 0.776 | 0.771 (1.01x) | 0.716 (1.08x) |
The gain scales with how many docs the filter accepts, since that is what
the accept set build costs; below ~1% the graph search dominates. Rows with no
deletions and no vectorless docs are the noise floor, nothing runs there (fork
to fork spread is up to 13% at 0.45 ms, the 0.85x is that). PR-1 needs
deletions, PR-2 needs a vectorless doc somewhere. Top-100 (doc, score) lists
for 32 queries per cell are identical across the three arms.
Problem 4 (PR-3 fixes the scan, PR-4 the decision), PR-1+2 as the baseline,
no deletions, 2 forks interleaved. PR-3 makes the reader's scan enumerate the
accept set instead of testing every ordinal:
| accepted docs | every doc has a vector | 10% of docs without a vector |
|---|---|---|
| 200 | 0.193 -> 0.022 | 1.499 -> 0.033 |
| 1000 | 0.276 -> 0.049 | 1.641 -> 0.080 |
Recall against brute force over the accepted docs is 1.0 before and after,
the scan was exact already, it just cost 5.5 to 45 times more. Above
log(graphSize) * k accepted (about 1220 here) the reader takes the graph, and
that is the wrong call for a while: at 4000 accepted, scoring the accept set is
cheaper than the filtered walk on this corpus, and PR-4, which weighs the two
instead of comparing the accept count to log(graphSize) * k, takes that cell
from 0.389 to 0.131 ms on a dense field and 0.497 to 0.205 with 10% vectorless.
By 10% accepted the graph wins and PR-4 leaves it alone (within 2%, same at
95%).
I first did problem 4 on the query side, raising the query's exact search
threshold to log(maxDoc) * perLeafTopK. It measured 3.5 to 26x over main in the
band but it is the wrong place: once the reader's batched scan exists, the
query's per-doc exactSearch pre-empts it and costs 1.15 to 2.1x more for the
same answer (0.038 vs 0.021 ms at 200 accepted, 0.074 vs 0.047 at 1000, 0.879
vs 0.412 on 2-3 leaves at k=1000 and 2%). So the decision stays in the reader,
which is the only layer that can see the graph.
Problem 5 (also PR-3), same index with 10% vectorless docs, the accepted
ordinals materialised once per query before the filtered graph search:
| filter selectivity | before | after |
|---|---|---|
| 2% | 0.795 | 0.513 (1.55x) |
| 2%, 5% deleted | 0.841 | 0.585 (1.44x) |
| 5% | 0.910 | 0.676 (1.35x) |
| 10% | 1.014 | 0.851 (1.19x) |
| 50% | 0.737 | 0.744 |
Dense field unchanged, 0.440 -> 0.441. The pass is one advance per accepted
doc plus a bit set over the vectors, so it is gated on the filtered searcher
being chosen and the accept set being small enough that the lookups it removes
outnumber the advances (about 2*sqrt(expectedVisitedNodes*graphSize), 29.5k
accepted on this index); above that it does nothing.
Where this was found: elasticsearch 9.4.4 / lucene 10.4.0, a 5.8M doc index
shaped like production (10.6% docs without a vector, filter accepting 99.9%,
six knn clauses, k=4000), 430 -> 289 cpu-ms per request with PR-1, 182 with
PR-1+2 (2.37x). That multiplier is elasticsearch's, it runs its cancellation
check inside the per-doc walk
(ExitableDirectoryReader.checkAndThrowWithSampling was 40% of the unpatched
profile, FilteredDocIdSetIterator.nextDoc 37%). Plain lucene has no such
wrapper, the JMH numbers above are the ones to expect here. PR-3 and PR-4 have
not been measured at that scale.
### Proposed fix
Four PRs, stacked, all against main:
- PR-1, AcceptDocs: pass iteratorSupplier.get() to createBitSet. +4/-1, with
a test that counts nextDoc() calls on the source iterator and fails on main
when the segment has deletions. Adds the benchmark.
- PR-2, AbstractKnnVectorQuery: drop the conjunct behind protected boolean
requiresVectorPresenceInFilter() (default false;
DiversifyingChildren*KnnVectorQuery return true since their nextParent() loop
assumes the vector iterator landed on an accepted child, Seeded/Patience
forward to their delegate). Create the filter weight through
IndexSearcher#createWeight. exactSearch reports the docs it scored as TotalHits
rather than acceptIterator.cost(), which would now count vectorless accepted
docs. One consequence, AcceptDocs#cost() becomes the accepted doc count rather
than accepted docs with a vector, the PR discusses what the HNSW reader does
with it.
- PR-3, KnnVectorValues: two overloads that work from the accept set instead
of one ordinal at a time. getAcceptOrds(Bits, DocIdSetIterator), which the
sparse off-heap values implement by leap-frogging the accept iterator against
their IndexedDISI into a FixedBitSet over ordinals, asked for when the filtered
searcher is going to run and the pass is cheaper than the lookups it removes.
And acceptedOrdsIterator(Bits, DocIdSetIterator), which enumerates the accepted
ordinals in order so the exhaustive scan scores the accept set rather than
testing every ordinal; the scan moves to one class the two scanning readers
share, values that cannot enumerate keep today's loop. On the int8 flat format,
which has no graph and scans every filtered query, this is 1.7 to 17x at 0.5 to
5% accepted. Results identical.
- PR-4, Lucene99HnswVectorsReader#search: weigh what the filtered graph
search would cost against scoring every accepted vector, both estimated from
what those loops do, instead of comparing an unfiltered visit estimate to the
accept count. A reader that scored the whole accept set says so on the
collector and the query does not score it again.
Not proposing: clamping AcceptDocs#cost() to the vector count (measured, no
gain, the reader already clamps to graphSize), or #15592 style cost() avoidance
(reverted in #15687). Can backport to 10.x if wanted.
### Testing
Each PR carries its tests in BaseKnnVectorQueryTestCase (float, byte, MMap,
seeded and patience variants) and ParentBlockJoinKnnVectorQueryTestCase, plus
TestAcceptDocs. On the stacked branches core, join, sandbox and backward-codecs
pass, and the knn query test classes pass ten iterations.
I'll open the PRs if the approach looks right.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]