Repository navigation
Filtered vector search: build allow-bitsets from secondary indexes and stop decoding records per visited node #2688
Description
Summary
Filtered vector search evaluates its predicate by loading and decoding the whole record for every visited HNSW node. On a document-with-sections corpus (~9 KB records, filter selectivity 5–40%) that makes a filtered top-50 query 35–50× slower than Postgres + pgvector on the same data at the same recall, while the native HNSW module's allow-bitset search path sits unused. Fix in three parts: build allow-bitsets from secondary indexes (equality and comparison operators, AND/OR), feed them to the plane's bitset search, and evaluate any remaining pushed-down predicate from index entries or lazy field access instead of a full decode.
Measured
Synthetic corpus, 20k documents / ~100k sections, 768-d, one box, recall@50 within 0.004 of pgvector (Harper efSearch 100 × filterExpansion 24; pgvector ef_search 100, iterative scan). Filter: tenantId = ? AND publishedAt >= ?, 5–40% selective.
| stack | filtered ANN + 5 dependent steps, p50 @ conc 1 | @ conc 8 | @ conc 32 |
|---|---|---|---|
| Harper resource, in-process | 276 ms | 364 ms | 882 ms |
| Postgres + pgvector, 3 statements | 7.3 ms | 11.9 ms | 40.5 ms |
| Postgres + pgvector, 1 CTE | 8.7 ms | 9.6 ms | 25.6 ms |
The five non-ANN steps (hydrate, rerank, ±2-ordinal expansion, collapse) cost Harper ~1 ms in total. The rest is the filtered traversal: up to ef × filterExpansion = 2,400 node visits, each a ~130 µs record load + decode. The same program with three dependent reads and no filter (a summary-tree walk) has Harper ahead of both Postgres variants, so this is specifically the filter path.
Root cause
HierarchicalNavigableSmallWorld.tssearchPlane(~L756–796) only ever callsplane.searchWithPredicate(...), adaptingfilter(primaryKey)through a TSFN batch. The plane binding'ssearch(vector, k, ef, filter: Uint8Array, filterExpansion)(hnswPlaneBinding.ts~L40) has no caller. The HNSW module's DESIGN.md lists the bitset path as tier 1 for "RBAC allow-lists and companion-condition candidate sets" and leaves the pk→nodeId glue to Harper; that glue does not exist.- Every predicate Harper can hand the traversal loads the record: a caller
vectorFilter(record), and the pushed-down siblingconditionscomposed bysearch.tscomposeRecordFilter(~L109–121). Indexed attributes in those conditions are never consulted as indexes during traversal. filterExpansion = 24exists to compensate for expensive admission; with a bit test per visit it is unnecessary.- Postgres wins here for one reason: it checks
tenant_id/published_aton fixed-width tuple slots with the text TOASTed out of line. It is cheap column access, not a smarter index.
Proposal
- Allow-bitsets from secondary indexes. For an AND group where every sibling condition targets an indexed attribute: equality → key-only range scan of the secondary index for that value;
gt/ge/lt/le/between→ key-only range scan over the ordered index; map each primary key to its node id; OR → union, AND → intersection. Pass the result toplane.search(..., filter)(plain bitset overidHighWater; roaring when sparse). No record is touched before the ANN returns. Cache per (attribute, value) for low-cardinality equalities (tenant, corpus, ACL role) and invalidate from the post-commit delivery in Implement the shared transaction-log runtime for derived indexes #2489; a range condition intersects the cached equality bitset with a fresh scan. - Retire
filterExpansionfor bitset filters, keep it only for residual function predicates. Planner rule stays: when the candidate set is small (below a threshold in the low thousands) do exact cosine over the set instead of a filtered traversal, as the planner already does for selective condition filters. - Cheap residual predicates. Anything that cannot become a bitset (a
vectorFilterfunction, a non-indexed attribute) is evaluated from index point-lookups or lazy field access over the raw record bytes, not a full decode. Reading two scalar fields from a 9 KB record should cost ~1 µs, not 130 µs. - Full-text conditions (Expose full-text conditions and $score through Table.search and REST #2511) are a bitset lane of the same shape: a
matchesgroup produces a doc-id set from Tantivy that maps to node ids and intersects with the vector traversal, which gives "must contain these terms AND be near" without a second ranked list.
Acceptance
- The benchmark above at the same recall gate: Harper filtered ANN top-50 within 2× of pgvector's at concurrency 1, and the full six-step program at or below the single-CTE Postgres variant at concurrency 8 and 32.
explainreportsfilterEvaluations = 0for a bitset-served filter and the bitset build cost separately.- Equality,
ge/gt/le/lt/between, and nested AND/OR over indexed attributes all take the bitset path; a mixed group with one non-indexed sibling takes the bitset for what it can and the cheap residual path for the rest. - Recall under filter is unchanged or better than today's predicate path at the same
ef(bitset admission never routes worse than predicate admission). - Unit coverage for bitset build from each comparator, cache invalidation on write, and the small-set exact fallback.
Non-goals
- Changing the HNSW graph, distance, or quantization.
- Cross-node or sharded filtering.
- Replacing
vectorFilterfor callers; it keeps working, only faster.
Related
- Predicate-aware HNSW traversal: filtered vector search with user-function and RBAC participation #1241 (closed) introduced the predicate-aware traversal this replaces for indexed conditions.
- Native HNSW index: file-primary mmap graph as a backend on the shared derived-index runtime #2430 native plane; Implement the shared transaction-log runtime for derived indexes #2489 shared derived-index runtime (the invalidation source for cached bitsets).
- Expose full-text conditions and $score through Table.search and REST #2511 full-text conditions and
$score— item 4. - [Epic] Vector/HNSW roadmap (research) #1658 vector/HNSW roadmap.
- HarperFast/hnsw
DESIGN.md"Filtering", tier 1 bitset fast path. - Measurement harness: HarperFast/retrieval-plan-bench (private).
🤖 Generated with Claude Code
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
Admit a filtered vector search from an indexed companion condition's …
Answer a filtered vector search's indexed conditions from the index, …