Predicate-aware HNSW traversal: filtered vector search with user-function and RBAC participation#1768
Merged
Merged
Conversation
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>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d table in isFilterablePushdown Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
|
Reviewed; no blockers found. |
dawsontoth
approved these changes
Jul 13, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #1241 — predicate-aware HNSW traversal so a vector sort combined with a filter keeps exploring until it has
efmatching results, instead of post-filtering a fixed top-efcandidate 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) => booleanpredicate. 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 budgetef * filterExpansion(default 24) bounds work and returns partial results on exhaustion;nodesVisited/filterEvaluationsare exposed for tuning.search.ts(executeConditions) composes companion AND conditions + a requestvectorFilter+ a staticallowReadRecordRBAC 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.searchplumbsvectorFilter(JS-API only — never parsed from a REST query string) and record-levelallowReadRecord.Where to look / lower-confidence areas
filterExpansiondefault = 24, not the design sketch's 8. Harper's HNSW visits a large fraction of the graph per query, so fillingefat selectivitysneeds ~ef/svisits;8truncated recall below post-filtering at ≤12% selectivity.24fills 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.executeConditionsreorders sibling-filter construction before executing the lead only when the lead is a filterable custom index (HNSW), which never populates the joinfilteredmap. All other paths (joins etc.) keep the original ordering. Covered by the existing query/join suites.processEntryfilter stage as every existing WHERE-clause filter (against the stored record, before any source revalidation). This meansallowReadRecordinherits 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).