Skip to content

find_nearest: merge the bucket hits by score, then load only the final k - #51

Closed
tae898 wants to merge 1 commit into
mainfrom
port/find-nearest-top-k-first
Closed

tae898 wants to merge 1 commit into
mainfrom
port/find-nearest-top-k-first

Conversation

@tae898

@tae898 tae898 commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

VectorIndex.find_nearest on a type with B buckets loaded and wrapped every hit of every bucket index (one record lookup and one Python wrapper per hit, B * k of them) and then sorted and kept k. This merges the per-bucket hit lists by score first and loads only the final k: a new bridge call GraphCalls.topHits(db, pairLists, k) takes the Java hit lists, sorts the candidates by score with a stable sort (equal scores keep the bucket order, as the Python sort of the concatenated hits did), cuts to k, and returns the k records, scores and RIDs in one crossing. Without the bridge jar the Python fallback does the same, reading each candidate's score and looking up only the k winners. This is the port of humemai/arcadedb-embedded-python#534 (backlog row B-01, JVM half); the patch applied to src/arcadedb, src/java and tests unchanged apart from the arcadedb_embedded to arcadedb import rename.

Results are identical: the same k records in the same order with the same scores. Checked with a fixed-seed dump of 120 find_nearest calls (k = 1, 5, 10, 50; 600 vectors, 16 dimensions, 4 buckets) taken on humemai origin/main and on this branch, byte-identical (rid and repr(score) per hit, plus the hit shape). One edge differs on purpose: a hit whose record is missing now raises only if it is among the final k (before: if it was anywhere in any bucket's list); the message is unchanged.

What it saves, untimed: record lookups and wrappers per search are k instead of B * k (40 to 10 at B = 4 and k = 10, counted by the new tests). With B = 1 nothing changes, every hit is a final hit. No timing was run.

Local checks (on the committed bytes, bridge jar rebuilt from this branch's src/java in eclipse-temurin:25-jdk against the jars of an installed arcadedb-embedded 26.11.1.dev0 wheel, tests run with PYTHONPATH=src, not a wheel built from this repo): tests/test_vector_top_k_first.py (7 new tests: result equals a stable merge of the raw per-bucket lists for k = 1, 5, 10, 50, 400; k wraps; k lookups on the no-jar path) passes, and its two count tests fail on the old vector.py (40 vs 10) while the result tests pass on both; together with test_vector.py, test_vector_second_pass.py, test_vector_params_verification.py, test_vector_delta_visibility.py, test_vector_sql.py and test_typed_rows.py that is 147 passed; the existing test that counted GraphCalls.hits calls (test_lsm_vector_search_looks_up_all_hits_in_one_bridge_call) now counts topHits; black, isort and bandit are clean on the changed files. Not run: the full suite, scripts/build.sh, CI, the pre-commit hook set as a whole. No doc page in this repo mentions GraphCalls or find_nearest, so no docs changed.

🤖 Generated with Claude Code

…l k (B * k lookups -> k)

A type with B buckets has B vector indexes; the search loaded and wrapped every hit of every bucket and kept k. GraphCalls.topHits merges the hit lists by score (stable, as the Python sort was), cuts to k, and loads those records in one crossing. Same records, order and scores; a missing record is still named, but now only when it is among the final k. New test file pins the result against a merge of the raw per-bucket lists and counts k wraps and k lookups (the no-bridge-jar fallback too). Port of humemai/arcadedb-embedded-python#534.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tae898
tae898 force-pushed the port/find-nearest-top-k-first branch from 09b90ba to a352125 Compare October 29, 2026 12:20
@tae898

tae898 commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main cda51dc (force-push, new head a352125; one commit, no conflicts). The timed study that decides this draft is re-pinned to wheels built from these two commits with the same engine jars (CI run 40857158472).

@tae898

tae898 commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

The timed A/B for this PR is done (laptop, JVM wheel, 2 pinned cores, ab.py ABBA rounds with an A/A control, 11 clean pairs per cell; A = the merge-base wheel, B = this PR; ratio B/A, below 1 = this PR is faster). The rule was written before the run.

cell type figure B/A 95% CI reading
sift128 4 buckets, k=10 p50 D4_fn_k10_p50_ms 0.938 [0.925, 0.974] B better by 3 to 8 percent
sift128 4 buckets, k=100 p50 D4_fn_k100_p50_ms 0.822 [0.769, 0.851] B better by 15 to 23 percent
sift128 1 bucket, k=10 p50 D1_fn_k10_p50_ms 1.002 [0.971, 1.022] no detectable difference
sift128 default type, k=10 p50 DD_fn_k10_p50_ms 1.037 [0.974, 1.056] no detectable difference
dbp1536 default type, k=10 p50 DD_fn_k10_p50_ms 1.017 [0.976, 1.035] no detectable difference
sift128 peak RSS peak_rss_mib 1.177 [0.663, 1.300] no detectable difference
dbp1536 peak RSS peak_rss_mib 1.073 [0.962, 1.112] no detectable difference

Answers: 0 run(s) without CHECK ok (0 with a MISMATCH against the old algorithm, 0 with wrong wrap counts); cross-build comparison: 136 (round, type, k) answers where A and Z agree, 0 where B differs.

So the change does what it says where it applies: with several buckets it loads k records instead of buckets times k, and is faster, more so at larger k. On the default type there is nothing to save, because ArcadeDB creates one bucket per type by default (arcadedb.typeDefaultBuckets = 1), and there it shows no measurable difference. By the rule this does not ship: its regression guard trips (sift128 DD_fn_k10_p50_ms upper CI end 1.056 > 1.05; sift128 peak_rss_mib upper CI end 1.300 > 1.1; dbp1536 peak_rss_mib upper CI end 1.112 > 1.1). The peak RSS intervals are very wide (a JVM's peak RSS varies between launches), so that part of the guard says more about the measurement than about the change, but the rule was fixed before the run and I keep to it.

Closing this PR for now. Multi-bucket vector types are not the default; if they become a target, this can come back with a study whose headline is a multi-bucket type and a steadier memory measure (several fresh JVMs per arm).

@tae898 tae898 closed this Nov 3, 2026
@tae898
tae898 deleted the port/find-nearest-top-k-first branch November 5, 2026 14:21
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.

1 participant