Skip to content

Native find_nearest: include_vector=False returns hits without the indexed vector (explicit opt-in) - #204

Draft
tae898 wants to merge 4 commits into
perf/native-find-nearest-ndarrayfrom
perf/native-find-nearest-lazy-vector
Draft

tae898 wants to merge 4 commits into
perf/native-find-nearest-ndarrayfrom
perf/native-find-nearest-lazy-vector

Conversation

@tae898

@tae898 tae898 commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

find_nearest keeps its default: every hit carries its indexed vector, and the default search is the code of #203 (query as one float32 buffer), unchanged. This PR adds a keyword-only include_vector=False for callers that need only the ids, the distances or other properties of the hits.

On the native build, find_nearest(q, k, include_vector=False) runs SELECT *, !<vector property>, !record FROM (SELECT expand(vectorNeighbors(...))) and builds each hit without the vector (768 or 1536 floats per hit as JSON text, most of it read by nobody). A hit loads its record once, by RID, the first time the vector is read (get, has_property) or the whole record is needed (to_dict, get_property_names, modify, save). Values, property order and types are those of lookup_by_rid, and a hit deleted since the search raises ArcadeDBError on that first read. A caller that opts in and then reads the vector of every hit pays one extra lookup per hit, so the keyword is for searches that do not read it. The JVM build accepts the keyword and ignores it: it loads whole records either way, so the call costs the same (stated in the docstring and in docs/modules/ROOT/pages/how-to/vectors.adoc, which gets one short paragraph).

Why an opt-in and not the default. The first version of this PR made every hit lazy. Its timed A/B (native wheel of main 7e64c5e, engine 26.10.2, find_nearest k = 10, ab.py 95% CI, 12 clean pairs) was good for callers that do not read the vector and bad for callers that do:

unit p50 B/A at 1536-d p50 B/A at 256-d
hits not read 0.66 [0.657, 0.663] 0.805 [0.803, 0.815]
id read 0.663 [0.659, 0.665] 0.808 [0.807, 0.812]
id and vector read 1.011 [1.008, 1.016] 1.336 [1.333, 1.346]

At 256 dimensions the lookup per hit costs more than shipping the vector, which is above the 1.10 cap for a default. The statement control was flat. So the default stays as it is, and a caller who knows it does not need the vector says so.

Stacked on #203: the base of this PR is perf/native-find-nearest-ndarray, and the diff is the three files of the change (src/arcadedb/_native/records.py, src/arcadedb/_native/schema_objects.py, src/arcadedb/vector.py) plus docs and tests. Retarget to main after #203 merges.

Tests. tests/test_vector_hit_records.py runs on both backends: the default call and the opt-in call equal lookup_by_rid in type, property names and order, every value and the vector, for a four-bucket type with a subtype and in every first-access order; a hit can be modified and saved; ef_search and allowed_rids work with the keyword; the keyword is keyword-only and changes no answer; an opt-in hit of a type with properties called record and distance (the names of the search row's own columns) equals the lookup. tests/test_native_vector_hit_lazy.py is native only: the default search ships the vector, runs a statement that drops nothing and looks up nothing; the opt-in search ships no vector and looks up nothing, then one lookup per record however the vector is reached, the loaded vector equals the default's, and a closed database does a lookup for none. Not run locally (no workload on this machine); CI runs them. black 26.10.1, isort 9.0.2 (profile black), bandit and compile are clean on the changed files.

Known and left alone: on the default path (this PR does not touch it), a type with a property called record or distance still gets a wrong or failing hit on native find_nearest, because those names are the search row's own columns. The opt-in path handles them. A fix of the default path would be a separate change.

The timing of this design (the opt-in against the default, and the default path against #203's head, both dims) is a pre-registered study, not run yet; this PR makes no speed claim until it is. It stays a draft.

Refs #19

Edited 2026-11-04: reworked from "lazy by default" to an explicit opt-in (include_vector=False), stacked on #203; the first body described the lazy-by-default design and its side fix for properties named record or distance on the default path.

🤖 Generated with Claude Code

tae898 and others added 3 commits November 3, 2026 08:23
…a Python list

NativeVectorIndex._run_search turned every query into a list with tolist(). The codec then checked the list for float32-exactness
(_exact_float_list: a pass of numpy over the list, tag BL) or, for a float64 query whose values float32 rounds, printed JSON number
text (about 611 us at 1536 dimensions, #107). A numeric 1-d query (an ndarray of any numeric dtype, or a
list/tuple of numbers, which numpy walks in C) now goes to the codec as a float32 array and travels through the float32 side buffer
(tag B) directly. The engine reads the vector as float32 whatever it is given, so the rounding is unchanged. Anything else (text or
None entries, a generator, ragged input, numpy missing) stays a list and reaches the engine as before.

Port of humemai/arcadedb-embedded-python#523 (backlog row N-07) onto main after #107: the per-element _prep calls it removed are
gone since #107, what remains is tolist() plus the exactness check, and the float64 text path. Untimed; a timed A/B decides it.

Tests: test_find_nearest_query_forms.py (both backends: every form answers the same neighbours at the same distances, refusals
unchanged; passes before and after) and test_native_find_nearest_params.py (native: where the floats travel and that the list
exactness check is not run; 5 of its 6 tests fail on the code before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No textual conflicts: main's #202 (record ids as a :rid parameter, src/arcadedb/_native/records.py and database.py) and #201 (run_in_transaction witness) touch other hunks than this branch. The record-id rule of #202 is kept: this branch pastes no record id into SQL text (the lazy per-hit vector load, if present, goes through lookup_by_rid, which keeps its literal id by design, see tests/test_native_statement_texts.py).

Static checks after the merge: compile() and pyflakes of the changed files (the unused typing.Optional in records.py is on main too), pre-commit black and isort pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tae898

tae898 commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Timed A/B (native wheel; arm A = main's CI native wheel, arm B = the same tree with only this PR's files, A/A control; laptop timed lane, tools/ab.py --noise, ABBA, round 0 dropped). find_nearest p50 latency, B/A median with 95% CI (below 1 = this PR is faster):

caller 1536-d 256-d
hits not read 0.66 [0.657, 0.663] 0.805 [0.803, 0.815]
each hit's id read 0.663 [0.659, 0.665] 0.808 [0.807, 0.812]
each hit's id and vector read 1.011 [1.008, 1.016] 1.336 [1.333, 1.346]
statement path (control, not changed by this PR) 1.001 [0.994, 1.006] 1.007 [0.994, 1.012]

Clean pairs per figure: 12; answers equal on both arms in every round. By the rule written before the run this is a DROP as the default: a caller who reads the vector of each hit pays one extra lookup per hit, 1.336 [1.333, 1.346] at 256 dimensions, above the rule's 1.10 regression cap, while callers who do not read the vector gain clearly.

So the cut is real but only for callers who do not need the vectors, which makes it an API question rather than a default. The options as we see them, with no choice made here: (1) an explicit opt-in on find_nearest that returns hits without their vector (other vector stores expose the same choice as an include-vector flag); (2) leave find_nearest as it is and close this PR. This PR stays draft until that is decided; an opt-in version would get its own timed check of both the opt-in and the default path.

…dexed vector, loaded by RID on first read

find_nearest keeps its default: every hit carries its vector, and the default search is the code of the query-as-float32 change (#203), untouched. A new keyword-only include_vector=False asks for hits built without the vector, for callers that need only ids or distances. On the native wheel the search then runs SELECT *, !<vector property>, !record FROM (SELECT expand(vectorNeighbors(...))) and each hit loads its record once by RID the first time the vector or the whole record is needed. The JVM build accepts the keyword and ignores it (it loads whole records either way).

Reworks the earlier lazy-by-default version after its timed A/B: hits not read 0.66 and 0.805 (1536 and 256 dims), but vector read 1.011 and 1.336, so lazy-by-default is a cost for callers that read the vector. Tests run on both backends (values equal lookup_by_rid, keyword accepted) and on the native wheel (default ships the vector with no lookup and no dropped column, opt-in ships none and looks up once per record).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tae898
tae898 changed the base branch from main to perf/native-find-nearest-ndarray November 4, 2026 02:03
@tae898
tae898 force-pushed the perf/native-find-nearest-lazy-vector branch from 9e0c931 to c91a828 Compare November 4, 2026 02:03
@tae898 tae898 changed the title Native find_nearest: a hit loads its vector on first use, not with every search Native find_nearest: include_vector=False returns hits without the indexed vector (explicit opt-in) Nov 4, 2026
@tae898

tae898 commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Change of design, 2026-11-04. The first version of this PR made every find_nearest hit lazy. Its timed A/B (main's native wheel 7e64c5e, engine 26.10.2, 12 clean pairs, ab.py 95% CI) showed that it helps callers that do not read the vector (hits not read p50 B/A 0.66 [0.657, 0.663] at 1536-d and 0.805 [0.803, 0.815] at 256-d) and hurts callers that do (id and vector read 1.011 [1.008, 1.016] and 1.336 [1.333, 1.346], above the 1.10 cap for a default). So the default is back to what it is today: hits carry their vector, and the default search is #203's code. The lazy hits are now an explicit opt-in, find_nearest(..., include_vector=False), for callers who need only ids or distances; the JVM build accepts the keyword and ignores it.

The PR is rebased onto #203 (head 02103b5) and its base is now perf/native-find-nearest-ndarray; new head c91a828. The body is rewritten to match. A new pre-registered timed study will measure both paths (the default must stay non-inferior to #203, the opt-in must win when hits are not read or only the id is read); until it runs there is no speed claim and the PR stays a draft.

This branch has not been deployed

No deployments
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