Repository navigation
in on an indexed attribute scans the whole index (one setImmediate per entry) instead of seeking each value #3240
Description
Problem
A search condition { attribute: X, comparator: 'in', value: [...] } on an indexed attribute does not look up the listed values in X's index. It reads every entry in that index and tests each key against the value set. On the async path, the test for each entry also waits on its own setImmediate. Search cost therefore grows with the size of the index, not with the number of matches, and wall time is several times CPU time.
resources/search.ts, searchByIndex:
case 'in':
// Phase 1: route through filter — index-merge optimization is a Phase 2 follow-up.
...
start = true;
needFullScan = true;needFullScan sends an indexed attribute to index.getRange({ start: true, ... }).map(...). That is a scan of the whole index, with one new Promise(r => setImmediate(...)) per entry. This has been the case since in was added in f3e1db0, and it ships in 5.3.x and on main.
Nothing reports it. The query returns correct results, allowFullScan: false does not reject it, and an application author has every reason to believe an in on an @indexed attribute is an index lookup.
Measured
Unit-level repro on main (5ed056a), rocksdb. A 5,000-row table with two @indexed array attributes, each row holding one value or '*'. The query is tierKeys in [gold, *] AND categoryKeys in [c1, c2, c3, *], which matches 2,172 / 1,426 / 599 rows for tier only / categories only / both. Mean of 40 searches after warm-up:
| Query shape | Rows | Wall per search | Process CPU per search |
|---|---|---|---|
tierKeys in [...] |
2,172 | 71.5 ms | 16.4 ms |
tierKeys in [...] AND categoryKeys in [...] |
599 | 70.2 ms | 16.8 ms |
same values as or groups of equals |
2,172 | 2.4 ms | 2.6 ms |
both conditions as or groups of equals |
599 | 2.7 ms | 3.1 ms |
The in form is about 26× slower in wall time and about 5× in CPU. Its cost is flat across conditions because every variant reads the whole index. Inside a single in search, rows arrive in bursts with 5–14 ms gaps and a ~30 ms tail after the last row: the per-entry setImmediate yields.
This was found in an application benchmark (a cart-quote endpoint probing a 5,000-row promotions table on every request). The in probe was a large share of the quote's 19 ms data phase. A Postgres GIN bitmap scan answered the same predicate in 0.75 ms.
Fix
inon an indexed attribute → one point range per value. Use the same plan as anorgroup ofequalsconditions: union the per-value ranges and dedupe withdistinctRecordsfor multi-valued attributes, which theorpath already handles. Do this either by rewriting the condition inprepareConditionsor by teachingsearchByIndexto iterate the value list. The estimate should be the sum of the per-value estimates, so the planner orders it correctly against other conditions.- Intersect the second indexed AND term when it is an
in/orgroup. The adaptive switch infilterByType'sattributeComparator, which replaces record filtering with an id set after enough misses, applies only to a leaf condition. With both terms written asorgroups, the AND above loads 1,426 records to return 599. The candidate-key intersection already used for filtered vector search (collectCandidateKeys, Answer a filtered vector search's indexed conditions from the index, instead of decoding a record at every visited node #2691) intersects on primary key before any record is loaded, and could plan plain AND searches whose terms are all index-answerable.
Part 1 is the large win and is small. Part 2 removes the remaining record loads for non-matching rows.
Until part 1 lands, applications can write { operator: 'or', conditions: values.map(value => ({ attribute, value })) } instead of in.
Acceptance
- An
incondition on an indexed attribute reads only the index entries for its listed values. A unit test asserts the number of index entries read, not only the results. - An AND of two index-answerable conditions loads only records in their intersection.
- A multi-valued attribute with several listed values on one record returns that record once.
🤖 Generated with Claude Code
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
Record the measured durability gap and the Harper issues in the imple…