Skip to content

Predicate-aware HNSW traversal: filtered vector search with user-function and RBAC participation#1768

Merged
kriszyp merged 3 commits into
mainfrom
kris/1241-predicate-hnsw
Jul 13, 2026
Merged

Predicate-aware HNSW traversal: filtered vector search with user-function and RBAC participation#1768
kriszyp merged 3 commits into
mainfrom
kris/1241-predicate-hnsw

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 12, 2026

Copy link
Copy Markdown
Member

Implements #1241 — predicate-aware HNSW traversal so a vector sort combined with a filter keeps exploring until it has ef matching results, instead of post-filtering a fixed top-ef candidate set (which under-fills under selective filters and quietly leaks the presence of nearby restricted records).

What changed

  • HierarchicalNavigableSmallWorld.search(cond, ctx, filter?) accepts an optional (primaryKey) => boolean predicate. It gates result admission at layer 0 only; upper layers and the candidate/visited sets ignore it, so non-matching nodes still route (ACORN). A visit budget ef * filterExpansion (default 24) bounds work and returns partial results on exhaustion; nodesVisited / filterEvaluations are exposed for tuning.
  • search.ts (executeConditions) composes companion AND conditions + a request vectorFilter + a static allowReadRecord RBAC hook into one predicate and pushes it into a leading filterable custom index; it also stays in the post-filter chain as a deterministic re-check. Very selective condition filters continue to take the exact brute-force path via the planner's existing count-ordering.
  • Table.search plumbs vectorFilter (JS-API only — never parsed from a REST query string) and record-level allowReadRecord.
  • Unit tests (index-level mechanism + end-to-end vectorFilter/RBAC/int8/OR), a filtered-recall benchmark scenario, and a DESIGN.md note.

Where to look / lower-confidence areas

  • filterExpansion default = 24, not the design sketch's 8. Harper's HNSW visits a large fraction of the graph per query, so filling ef at selectivity s needs ~ef/s visits; 8 truncated recall below post-filtering at ≤12% selectivity. 24 fills down to ~4% and — per the new benchmark — predicate recall dominates post-filter at every selectivity (e.g. 99.3% vs 23.7% at 1%), with brute-force the fast crossover at 0.1%. This budget/selectivity interaction with the planner's brute-force threshold is the main tuning knob worth a second opinion.
  • executeConditions reorders sibling-filter construction before executing the lead only when the lead is a filterable custom index (HNSW), which never populates the join filtered map. All other paths (joins etc.) keep the original ordering. Covered by the existing query/join suites.
  • RBAC record guards run at the same processEntry filter stage as every existing WHERE-clause filter (against the stored record, before any source revalidation). This means allowReadRecord inherits the same cache/source-staleness semantics that condition-based filtering already has on caching tables — noted here as a pre-existing property, not introduced by this PR; defense-in-depth re-checking post-revalidation would be a separate, filter-wide hardening.

Review

Cross-model review (Gemini + Codex + Harper-domain adjudication) run pre-PR. Addressed in the diff: OR-query RBAC enforcement, always-active visit budget (bounds the filled-but-distant case), no mutation of the caller's condition object, and guards built once at top level.

Generated by KrAIs (Claude Opus 4.8).

Docs: companion documentation PR: HarperFast/documentation#580 (Filtered vector search docs).

Make HNSW beam search predicate-aware so a vector sort combined with a
filter keeps exploring until it has `ef` MATCHING results instead of
post-filtering a fixed top-`ef` candidate set (which under-fills under
selective filters and leaks the presence of nearby restricted records).

- HierarchicalNavigableSmallWorld.search() accepts an optional
  (primaryKey) => boolean predicate. It gates result admission at layer 0
  only; upper layers and the candidate/visited sets ignore it, so
  non-matching nodes still route (ACORN). A visit budget (ef *
  filterExpansion) bounds the under-filled/selective case and returns
  partial results; nodesVisited / filterEvaluations exposed for tuning.
- search.ts composes companion AND conditions + a request `vectorFilter`
  + a static `allowReadRecord` RBAC hook into one predicate and pushes it
  into a leading filterable custom index; kept in the post-filter chain
  as a deterministic re-check. Very selective condition filters still go
  to the exact brute-force path via the planner's existing ordering.
- vectorFilter (JS-API only) and record-level allowReadRecord wired
  through Table.search.
- Unit tests (index-level mechanism + end-to-end vectorFilter/RBAC/int8),
  filtered-recall benchmark scenario, DESIGN.md note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kriszyp
kriszyp requested review from heskew and ldt1996 July 12, 2026 15:08
gemini-code-assist[bot]

This comment was marked as resolved.

kriszyp and others added 2 commits July 12, 2026 09:13
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d table in isFilterablePushdown

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp merged commit 2e250f7 into main Jul 13, 2026
102 of 103 checks passed
@kriszyp
kriszyp deleted the kris/1241-predicate-hnsw branch July 13, 2026 18:24
kriszyp added a commit that referenced this pull request Jul 15, 2026
…gap 2)

Replace the short-lived static allowReadRecord (#1241/#1768, unreleased)
with a unified model: one allowRead(user, target, context) definition
serves both scopes. The framework defaults (Resource base + table
RBAC check) are this-free and marked isDefaultAllowRead; they keep the
single entry evaluation. An application-OVERRIDDEN allowRead on a table
is record-scoped: evaluated once per record with `this` = the (frozen)
record during query execution — pushed into HNSW traversal for vector
sorts, applied as a post-filter otherwise, and re-checked on the
materialized record after caching-source revalidation.

This closes #1422 gap 2 (single-record GET 403'd but collection GET
leaked every row): the authorize wrapper now defers collection reads on
tables (supportsRowLevelAllowRead) with an overridden allowRead to the
per-record path instead of evaluating the override against a
collection resource with no record loaded. Single-record get() keeps
the entry check (record loaded; TableResource proxies attribute reads
to the record, so record-scoped overrides read correctly there too).

Per-record calls are fail-closed on throw (#1422 gap 1 parity), must be
synchronous (a returned Promise is a clear error, not fail-open), and
dispatch via the resolved class method — never a record.allowRead
property lookup, so a record attribute named allowRead can't shadow
the check. Records also expose a non-enumerable allowRead delegate on
the per-table structPrototype for direct app-code use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kriszyp added a commit to HarperFast/documentation that referenced this pull request Jul 15, 2026
…orFilter, allowReadRecord, filterExpansion (5.2)

Companion to HarperFast/harper#1768.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants