Skip to content

support ACORN-1 - #673

Open
cpegeric wants to merge 12 commits into
unum-cloud:mainfrom
cpegeric:acorn-1
Open

support ACORN-1 #673
cpegeric wants to merge 12 commits into
unum-cloud:mainfrom
cpegeric:acorn-1

Conversation

@cpegeric

@cpegeric cpegeric commented Oct 29, 2025

Copy link
Copy Markdown
Contributor

Support ACORN-1 by visiting 2-hop-neighbors.

This is the first time to modify the C++ usearch code. This is just the proposal of ACORN-1. Please consider this change and amend whatever you feel appropriate.

  • So, to implement ACORN-1, when visiting a node in the HNSW search, we take the neighbors and 2-hop neighbors of the node, and for those that pass the filter, computes their distance to the query vector to see if they are relevant.
  • stick to original HNSW model and ACORN-1-inspired heuristic only on the lowest layer of the graph.

@cpegeric
cpegeric marked this pull request as draft October 29, 2025 16:21
@cpegeric cpegeric closed this Oct 29, 2025
@cpegeric cpegeric reopened this Oct 29, 2025
@cpegeric
cpegeric marked this pull request as ready for review October 29, 2025 16:48
@ashvardanian ashvardanian added the v3 Breaking changes planned for v3 label Nov 1, 2025
@ashvardanian

Copy link
Copy Markdown
Member

Thanks, @cpegeric! Will be looking into this in a couple of weeks 🤗

@cpegeric

Copy link
Copy Markdown
Contributor Author

@ashvardanian I found the current fix is buggy and run very slow even in non-filtered search... strange, I already have a check only filtered search with run the new code but not expected. Anyway, just for your reference. Let's see I will have time to revisit the code. thanks.

cpegeric and others added 2 commits June 25, 2026 18:50
…um-cloud#672)

Rewrites search_to_find_in_base_ so the 2-hop "bridge" expansion is used where it
helps and avoided where it hurts, fixing the prior implementation's slowness and
its recall/correctness problems.

The 2-hop bridge (visit a filtered-out member's neighbors so the filter-passing
subgraph stays connected) is a large win for SELECTIVE filters — on a 50k/64-d set
it matches the exhaustive baseline's recall at ~13-15x the QPS — but it is pure
M^2 overhead for PERMISSIVE filters, where a plain traverse-through-all is both
faster and higher recall. The old code ran the bridge unconditionally, so it was
slow at permissive filters and, on a very selective filter (e.g. a predicate
matching a single key), could not reach the isolated passing member at all and
returned nothing.

This version:

  - Estimates the filter pass-rate from the entry point's base-layer neighborhood
    (a graph-LOCAL sample near the query, not a uniform global one) and dispatches:
    permissive/unfiltered -> plain HNSW base expansion; selective -> ACORN (filter
    members first, only passing ones enter the frontier, then one extra hop through
    the 1-hop neighbors).
  - Falls back once to an exhaustive baseline walk when an ACORN pass returns fewer
    than `wanted` (k) results, so pathologically selective / isolated passing sets
    are never silently dropped. Gated on `wanted` rather than `expansion`, since a
    selective filter legitimately has fewer than `ef` passing members and gating on
    `ef` would fall back on every query and erase the speedup at large `ef`.
  - Collapses the duplicated dummy/ACORN branches into one loop; threads `wanted`
    into search_to_find_in_base_ for the fallback gate.

Validated: full cpp/test.cpp suite passes (incl. test_filtered_search), single-key
predicates return the matching member, and recall/QPS hold across 1%/10%/50% filter
selectivities (selective uses ACORN, permissive falls back to baseline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cpegeric

Copy link
Copy Markdown
Contributor Author

Reworked the ACORN-1 implementation in search_to_find_in_base_ to be regime-adaptive — fixing both the slowness and the recall/correctness issues in the original proposal.

Problems with the always-on 2-hop bridge

The 2-hop bridge (visit a filtered-out member's neighbors so the filter-passing subgraph stays connected) is a big win for selective filters, but running it unconditionally caused two problems:

  • Permissive filters paid full M²-per-step overhead for no benefit — there a plain traverse-through-all is both faster and higher recall.
  • Pathologically selective filters (e.g. a predicate matching a single key) couldn't reach the isolated passing member by bridging at all, and returned nothing — the existing test_filtered_search key==10 case (1 of 2048) was broken.

What changed

  • Regime dispatch. Estimate the filter pass-rate from the entry point's base-layer neighborhood — a graph-local sample near the query (better than a uniform global sample for filters correlated with the vector space). Below ~25% pass → ACORN (filter-first; only passing members enter the frontier, then one extra hop through the 1-hop neighbors). Otherwise → plain HNSW base expansion.
  • Starvation fallback. If an ACORN pass returns fewer than wanted (k) results, redo once with an exhaustive baseline walk, so sparse/isolated passing sets are never silently dropped. Gated on wanted, not expansion — a selective filter legitimately has fewer than ef passing members, so gating on ef would fall back on every query and erase the speedup at large ef. (wanted is threaded into search_to_find_in_base_.)
  • Cleanup. Collapsed the duplicated dummy/ACORN branches into one loop; removed the mid-function #defines.

Results

50k vectors, dim 64, recall@10 vs exact filtered top-k:

filter this PR (auto) baseline (no ACORN)
1% pass, ef=512 0.988 @ ~980 QPS 1.0 @ ~67 QPS
10% pass 0.94 @ ~2000 QPS → ACORN 0.97 @ ~1100 QPS
50% pass 0.90 @ ~4300 QPS → baseline 0.90 @ ~4300 QPS
single-key match ✅ returns the 1 member

So: ~13–15× faster than baseline at matched recall for selective filters, automatic fallback to baseline for permissive ones, and correct for pathologically selective filters.

Validation

Full cpp/test.cpp suite passes (incl. test_filtered_search); single-key/sparse predicates return the matching members; recall and QPS verified across 1% / 10% / 50% selectivities. Self-reviewed adversarially — the one real bug found (the ef-gated fallback negating the speedup at high ef) is fixed and re-verified (60 → ~980 QPS at 1% / ef=512).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v3 Breaking changes planned for v3

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants