Skip to content

Commit 13e5c26

Browse files
tae898claude
andauthored
Native find_nearest: include_vector=False returns hits without the indexed vector (explicit opt-in) (#204)
* Native find_nearest: include_vector=False returns hits without the indexed 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> * find_nearest include_vector: document the measured opt-in figures; compare hit distances like the query-form tests The "Hits without the vector" section of vectors.adoc now carries the timed A/B of the opt-in (native wheel 26.11.1.dev0, engine 26.10.2, k = 10, 12 clean pairs per figure): p50 B/A with 95% intervals for include_vector=False (hits not read, ids read, vector of every hit read) and for the unchanged default call, at 1536 and 256 dimensions. The figures are generated from the study's score file by a script, not typed. The text says the keyword is an opt-in for callers that do not read the vector (256 dimensions: reading every vector afterwards costs more than the default). test_the_keyword_changes_no_answer compared distances with ==, which the JVM build does not guarantee between two calls (main's _assert_same rule): ids compare exactly, distances exactly on the native build and to a few float32 ulps on the JVM build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 76c7695 commit 13e5c26

7 files changed

Lines changed: 502 additions & 4 deletions

File tree

‎docs/modules/ROOT/pages/how-to/vectors.adoc‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ print([row["name"] for row in rows])
162162
[[find-nearest]]
163163
=== find_nearest
164164

165-
`VectorIndex.find_nearest(query_vector, k=10, ef_search=None, allowed_rids=None)` searches without a statement. It takes a list or a NumPy array and returns a list of `(record, distance)` tuples, nearest first. Use it when you already hold the index object, or when you have a list of record ids to search within.
165+
`VectorIndex.find_nearest(query_vector, k=10, ef_search=None, allowed_rids=None, *, include_vector=True)` searches without a statement. It takes a list or a NumPy array and returns a list of `(record, distance)` tuples, nearest first. Use it when you already hold the index object, or when you have a list of record ids to search within.
166166

167167
On the native package the query vector crosses as one `float32` buffer whatever its form (a list, a `float64` array, or a `float32` array), so you do not need to convert it first. A vector index stores `float32`, so the numbers are rounded the same way as before. The table gives the median `find_nearest` latency with this change over the median without it (95% interval, below 1 is faster), measured on the native wheel `26.11.1.dev0` on one Linux x86_64 laptop with 11 to 12 clean pairs per figure. The wheel built before this change was the baseline.
168168

@@ -199,6 +199,55 @@ python-ish
199199
python
200200
----
201201

202+
=== Hits without the vector
203+
204+
Every hit of `find_nearest` carries its indexed vector, so a search at 1536 dimensions ships and decodes 1536 numbers per hit whether you read them or not. If you need only the ids, the distances or other properties of the hits, pass `include_vector=False`. On the native build each hit is then built without the vector. It loads the vector, with the rest of the record, in one lookup by record id the first time you read it (`get` of the vector, `has_property`, `to_dict`, `get_property_names`, `modify` or `save`). The values are the ones `db.lookup_by_rid` returns, and a hit deleted since the search raises `ArcadeDBError` on that first read. The arcadedb-jvm package accepts the argument and returns whole records either way, so the call costs the same there.
205+
206+
This is an opt-in for callers that do not read the vector. The default stays `include_vector=True`: every hit carries its vector, and the default call is the one described above, unchanged. A loop that reads the vector of every hit after passing `include_vector=False` pays one lookup per hit and does more work than the default call, so do not pass it for that.
207+
208+
The table gives the median `find_nearest` latency over the median of the default call before this change (95% interval, below 1 is faster), with k = 10 on 100,000 vectors per dimension count. It was measured on the native wheel `26.11.1.dev0` (the engine inside it is 26.10.2) on one Linux x86_64 laptop with 12 clean pairs per figure. The default call has not changed: its intervals lie between 0.993 and 1.011. With the keyword set, a search whose hits are not read, or whose ids are read, is faster at both sizes. If every vector is read afterwards, the call takes 1.0 [0.999, 1.004] times as long as the default call at 1536 dimensions and 1.348 [1.34, 1.352] times as long at 256 dimensions.
209+
210+
[cols="3,2,2",options="header"]
211+
|===
212+
| Call | 1536 dimensions | 256 dimensions
213+
214+
| `include_vector=False`, hits not read
215+
| 0.637 [0.633, 0.641]
216+
| 0.8 [0.793, 0.818]
217+
218+
| `include_vector=False`, hit ids read
219+
| 0.637 [0.633, 0.638]
220+
| 0.797 [0.793, 0.803]
221+
222+
| `include_vector=False`, then the vector of every hit read
223+
| 1.0 [0.999, 1.004]
224+
| 1.348 [1.34, 1.352]
225+
226+
| the default call, hits not read (not changed)
227+
| 1.0 [0.996, 1.002]
228+
| 1.005 [0.996, 1.011]
229+
230+
| the default call, hit ids read (not changed)
231+
| 1.001 [0.997, 1.005]
232+
| 1.002 [0.994, 1.005]
233+
234+
| the default call, the vector of every hit read (not changed)
235+
| 0.997 [0.995, 1.005]
236+
| 0.997 [0.993, 1.007]
237+
|===
238+
239+
[source,python]
240+
----
241+
for record, distance in index.find_nearest(query, k=2, include_vector=False):
242+
print(record.get("name"))
243+
----
244+
245+
[source,text]
246+
----
247+
python-ish
248+
python
249+
----
250+
202251
=== Filtered search
203252

204253
`allowed_rids` restricts `find_nearest` to the records whose ids you list, as strings. The search considers only those records, so it still returns `k` of them.

‎src/arcadedb/_native/records.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,17 @@ def __init__(
250250
# The dict is never changed in place (a save makes a new one), so the objects modify() hands it to can share it
251251
self._base = base
252252
self._property_names_cache = None
253+
# names of properties a search hit was built without (see ``_wrap_partial``); None for every other record
254+
self._lazy = None
253255

254256
# -- reading
255257

258+
def _fill(self) -> None:
259+
"""Load the properties a search hit was built without, in one lookup by RID (engine order); nothing to do for any other record."""
260+
if self._lazy:
261+
self._props = dict(self._db.lookup_by_rid(self._rid)._props)
262+
self._lazy = None
263+
256264
def _check_open(self) -> None:
257265
database = self._database
258266
if database is not None and database._closed and self._rid:
@@ -263,21 +271,27 @@ def _check_open(self) -> None:
263271

264272
def get(self, name: str, convert_types: bool = True) -> Any:
265273
self._check_open()
274+
if self._lazy and name in self._lazy:
275+
self._fill()
266276
return self._props.get(name)
267277

268278
def get_raw(self, name: str) -> Any:
269279
return self.get(name)
270280

271281
def has_property(self, name: str) -> bool:
272282
self._check_open()
283+
if self._lazy and name in self._lazy:
284+
self._fill()
273285
return name in self._props
274286

275287
def get_property_names(self) -> List[str]:
276288
self._check_open()
289+
self._fill()
277290
return list(self._props)
278291

279292
def to_dict(self, convert_types: bool = True) -> Dict[str, Any]:
280293
self._check_open()
294+
self._fill()
281295
return dict(self._props)
282296

283297
def get_rid(self) -> str:
@@ -318,6 +332,7 @@ def save(self):
318332
not seen. Saving an existing record needs an active transaction, like every write; outside one it raises ArcadeDBError
319333
("Transaction not begun"). Creating a new record is not checked.
320334
"""
335+
self._fill()
321336
db = self._db
322337
if self._rid is None:
323338
from .extras import veto_count
@@ -399,6 +414,7 @@ def delete(self):
399414

400415
def modify(self):
401416
self._check_open()
417+
self._fill()
402418
if self._rid and self._base is None:
403419
self._base = _snap(
404420
self._props
@@ -525,3 +541,15 @@ def _wrap_dict(database, record: dict, kind: str = "d"):
525541
type_name = props.pop("@type", None)
526542
kind = props.pop("@cat", kind)
527543
return _CLASS_OF.get(kind, Document)(database, type_name, rid, props)
544+
545+
546+
def _wrap_partial(database, rid, type_name, kind, props, omitted):
547+
"""The wrapper for a search hit built without the properties named in ``omitted`` (the indexed vector: not shipped with every hit).
548+
549+
Reading one of them, or anything that needs the whole record (``to_dict``, ``get_property_names``, ``modify``, ``save``), loads the
550+
record once by RID; ``get`` and ``has_property`` of any other property are answered from what was shipped.
551+
"""
552+
cls = {"v": Vertex, "e": Edge}.get(kind, Document)
553+
record = cls(database, type_name, rid, props)
554+
record._lazy = frozenset(omitted)
555+
return record

‎src/arcadedb/_native/schema_objects.py‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,18 @@ def _query_vector_param(query_vector):
193193
)
194194

195195

196+
def _quote_name(name: str) -> str:
197+
return "`" + name.replace("`", "``") + "`"
198+
199+
200+
# find_nearest(include_vector=False): the engine's row of a hit is the record's properties plus the columns @rid, @type and distance
201+
# (ScoredRecordRow; before 26.11.1, ArcadeData/arcadedb#10898, also a nested `record` column holding every property a second time).
202+
# `*` copies the row, `!name` drops the indexed vector and the `record` column. A property of the record called distance or record is
203+
# hidden by those columns in the row, so such a hit holds them back too (_LAZY_COLUMNS) and loads them with the vector.
204+
_LAZY_COLUMNS = ("record", "distance")
205+
_HITS_FROM = "FROM (SELECT expand(vectorNeighbors(?, ?, ?)))"
206+
207+
196208
class NativeVectorIndex(_BaseVectorIndex):
197209
"""``VectorIndex`` over ``vectorNeighbors``: ``find_nearest`` returns ``[(record, distance)]`` like the JPype wrapper."""
198210

@@ -210,10 +222,21 @@ def _check_live(self):
210222
return
211223

212224
def _run_search(
213-
self, *, query_vector, k, allowed_rids, approximate, ef_search=None
225+
self,
226+
*,
227+
query_vector,
228+
k,
229+
allowed_rids,
230+
approximate,
231+
ef_search=None,
232+
include_vector=True,
214233
):
215234
if approximate:
216235
return self._run_approximate(query_vector, k, allowed_rids)
236+
if not include_vector:
237+
return self._run_search_without_vector(
238+
query_vector, k, allowed_rids, ef_search
239+
)
217240
from .records import _wrap_dict
218241

219242
vector = _query_vector_param(query_vector)
@@ -257,6 +280,54 @@ def _run_search(
257280
out.append((record, float(row.get("distance"))))
258281
return self._sort_results(out)[:k]
259282

283+
def _run_search_without_vector(self, query_vector, k, allowed_rids, ef_search):
284+
"""``find_nearest(include_vector=False)``: the hits come back without the indexed vector, which loads on first use.
285+
286+
``expand(vectorNeighbors(..))`` alone ships every hit's vector, and the engine reads each record to build the row whatever
287+
the columns asked for. Here the statement drops the vector (and the nested ``record`` column of an engine before 26.11.1),
288+
and ``_Mixin._fill`` loads the record, once, by RID, the first time the vector or the whole record is needed.
289+
"""
290+
from .records import _wrap_partial
291+
292+
vector = _query_vector_param(query_vector)
293+
options = {}
294+
if ef_search is not None:
295+
options["efSearch"] = int(ef_search)
296+
if allowed_rids:
297+
options["filter"] = [_rid_text(r) for r in allowed_rids]
298+
prop = self._shim._prop
299+
dropped = ", ".join(
300+
"!" + _quote_name(name) for name in dict.fromkeys((prop, "record"))
301+
)
302+
sql = f"SELECT *, {dropped} {_HITS_FROM}" # nosec B608 - dropped is backtick-quoted schema names, the values go through ? parameters
303+
if options:
304+
sql = sql.replace("vectorNeighbors(?, ?, ?)", "vectorNeighbors(?, ?, ?, ?)")
305+
rows = self._database.query(
306+
"sql", sql, self._shim._name, vector, int(k), options
307+
)
308+
else:
309+
rows = self._database.query("sql", sql, self._shim._name, vector, int(k))
310+
omitted = (prop, *_LAZY_COLUMNS)
311+
out = []
312+
for row in rows:
313+
props = row.to_dict()
314+
rid = props.pop("@rid", None)
315+
type_name = props.pop("@type", None)
316+
distance = props.pop("distance")
317+
if rid is None:
318+
record = None
319+
else:
320+
record = _wrap_partial(
321+
self._database,
322+
_rid_text(rid),
323+
type_name,
324+
self._database._kind_of(type_name),
325+
props,
326+
omitted,
327+
)
328+
out.append((record, float(distance)))
329+
return self._sort_results(out)[:k]
330+
260331
def _run_approximate(self, query_vector, k, allowed_rids):
261332
"""The PQ-scored search of the engine (no statement runs it): the engine returns (RID, distance), the records are loaded here."""
262333
from . import lib

‎src/arcadedb/vector.py‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,7 @@ def _run_search(
566566
allowed_rids,
567567
approximate,
568568
ef_search=None,
569+
include_vector=True, # accepted for API parity, the JVM build loads whole records
569570
):
570571
java_vector = to_java_float_array(query_vector)
571572
allowed_rids_set = self._build_allowed_rids_set(allowed_rids)
@@ -584,6 +585,8 @@ def find_nearest(
584585
k=10,
585586
ef_search=None,
586587
allowed_rids=None,
588+
*,
589+
include_vector=True,
587590
):
588591
"""
589592
Find k nearest neighbors to the query vector.
@@ -605,16 +608,31 @@ def find_nearest(
605608
uses the Java index default/adaptive behavior.
606609
allowed_rids: Optional list of RID strings (e.g. ["#1:0", "#2:5"]) to
607610
restrict search
611+
include_vector: Keyword only, default ``True``: every hit carries its
612+
indexed vector, as always. Pass ``False`` when you need only the
613+
ids or the distances (or other properties) of the hits. On the
614+
native build each hit is then built without the indexed vector, and
615+
loads it, with the rest of the record, in one lookup by RID the
616+
first time you read it (``get`` of the vector, ``has_property``,
617+
``to_dict``, ``get_property_names``, ``modify``, ``save``); the
618+
values are those of ``lookup_by_rid``, and a hit deleted since the
619+
search raises ``ArcadeDBError`` on that first read. The JVM build
620+
accepts the argument and ignores it: it loads whole records either
621+
way, so the call costs the same.
608622
609623
Returns:
610624
List of tuples: [(record, score), ...]
611625
- record: The matching ArcadeDB record (Vertex, Document, or Edge)
612626
- score: The similarity score/distance
613627
"""
614628
self._check_live()
615-
return self._find_nearest_live(query_vector, k, ef_search, allowed_rids)
629+
return self._find_nearest_live(
630+
query_vector, k, ef_search, allowed_rids, include_vector
631+
)
616632

617-
def _find_nearest_live(self, query_vector, k, ef_search, allowed_rids):
633+
def _find_nearest_live(
634+
self, query_vector, k, ef_search, allowed_rids, include_vector=True
635+
):
618636
# find_nearest after _check_live(): find_nearest_by_key checks once and
619637
# comes here, so a call never pays the isValid() crossing twice.
620638
effective_ef_search = self._normalize_ef_search(ef_search)
@@ -626,6 +644,7 @@ def _find_nearest_live(self, query_vector, k, ef_search, allowed_rids):
626644
allowed_rids=allowed_rids,
627645
approximate=False,
628646
ef_search=effective_ef_search,
647+
include_vector=include_vector,
629648
)
630649

631650
except ArcadeDBError:

‎tests/conftest.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
collect_ignore.append("test_native_udf.py")
4444
# how find_nearest hands the query vector to the native library (the codec's side buffer)
4545
collect_ignore.append("test_native_find_nearest_params.py")
46+
# find_nearest(include_vector=False) on the native wheel fills a hit's vector on first use (counts lookups of the native wrapper)
47+
collect_ignore.append("test_native_vector_hit_lazy.py")
4648
# the columnar insert's stored records are compared between the JSON and the columnar entry points of the native library
4749
collect_ignore.append("test_native_columnar_insert.py")
4850
# the rows of an insert_many chunk with date-times, Decimals, bytes and numpy scalars are told apart by the entry points they cross

0 commit comments

Comments
 (0)