feat: expose approx_mode on vector queries

`ApproxMode` landed in the Rust core in #3549, but the Python and Node.js bindings were never plumbed, so `approx_mode` is unreachable from either SDK. Both were built mirroring the existing `ef` plumbing.

Co-authored-by: Leo <leo@insight.co>
This commit is contained in:
Leo
2026-11-03 12:45:52 +08:00
committed by GitHub
co-authored by Leo
parent f9389f113e
commit f2bcb156ad
13 changed files with 326 additions and 12 deletions
+3
View File
@@ -825,6 +825,7 @@ class VectorQuery:
def search_effort(self, search_effort: Optional[float]): ...
def minimum_nprobes(self, minimum_nprobes: int): ...
def maximum_nprobes(self, maximum_nprobes: int): ...
def approx_mode(self, approx_mode: str): ...
def bypass_vector_index(self): ...
def nearest_to_text(self, query: dict) -> HybridQuery: ...
def order_by(self, ordering: Optional[List[ColumnOrdering]]): ...
@@ -851,6 +852,7 @@ class HybridQuery:
def search_effort(self, search_effort: Optional[float]): ...
def minimum_nprobes(self, minimum_nprobes: int): ...
def maximum_nprobes(self, maximum_nprobes: int): ...
def approx_mode(self, approx_mode: str): ...
def bypass_vector_index(self): ...
def order_by(self, ordering: Optional[List[ColumnOrdering]]): ...
def to_vector_query(self) -> VectorQuery: ...
@@ -882,6 +884,7 @@ class PyQueryRequest:
lower_bound: Optional[float]
upper_bound: Optional[float]
ef: Optional[int]
approx_mode: Optional[str]
refine_factor: Optional[int]
distance_type: Optional[str]
bypass_vector_index: Optional[bool]
+107
View File
@@ -912,6 +912,12 @@ class Query(pydantic.BaseModel):
ef : Optional[int]
The size of the nearest neighbor list maintained during HNSW search
This will only be set on vector search.
approx_mode : Optional[str]
The speed / accuracy tradeoff to use for approximate vector search
One of "fast", "normal" or "accurate".
This will only be set on vector search.
full_text_query : Optional[Union[str, dict]]
The full text search query
@@ -1014,6 +1020,9 @@ class Query(pydantic.BaseModel):
# size of the nearest neighbor list maintained during HNSW search
ef: Optional[int] = None
# speed / accuracy tradeoff for approximate vector search
approx_mode: Optional[str] = None
# Bypass the vector index and use a brute force search
bypass_vector_index: Optional[bool] = None
@@ -1044,6 +1053,7 @@ class Query(pydantic.BaseModel):
query.lower_bound = req.lower_bound
query.upper_bound = req.upper_bound
query.ef = req.ef
query.approx_mode = req.approx_mode
query.refine_factor = req.refine_factor
query.bypass_vector_index = req.bypass_vector_index
query.postfilter = req.postfilter
@@ -1212,6 +1222,7 @@ class LanceQueryBuilder(ABC):
self._vector = None
self._text = None
self._ef = None
self._approx_mode = None
self._bypass_vector_index = None
self._order_by = None
@@ -2136,6 +2147,35 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
self._ef = ef
return self
def approx_mode(
self, approx_mode: Literal["fast", "normal", "accurate"]
) -> LanceVectorQueryBuilder:
"""Set the speed / accuracy tradeoff to use for approximate search.
This currently affects RQ-quantized indexes, such as IVF_RQ, and
prefiltered search on HNSW indexes, where "fast" enables the ACORN
traversal. Other index types ignore this setting.
Examples
--------
>>> import lancedb
>>> db = lancedb.connect("memory://")
>>> table = db.create_table("approx_vectors", [{"vector": [0.1, 0.2]}])
>>> query = table.search([0.1, 0.2]).approx_mode("fast")
Parameters
----------
approx_mode: "fast" or "normal" or "accurate"
The tradeoff to use. By default "normal" is used.
Returns
-------
LanceVectorQueryBuilder
The LanceQueryBuilder object.
"""
self._approx_mode = approx_mode
return self
def refine_factor(self, refine_factor: int) -> LanceVectorQueryBuilder:
"""Set the refine factor to use, increasing the number of vectors sampled.
@@ -2230,6 +2270,7 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
offset=self._offset,
fast_search=self._fast_search,
ef=self._ef,
approx_mode=self._approx_mode,
bypass_vector_index=self._bypass_vector_index,
order_by=self._order_by,
)
@@ -3060,6 +3101,43 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
self._ef = ef
return self
def approx_mode(
self, approx_mode: Literal["fast", "normal", "accurate"]
) -> LanceHybridQueryBuilder:
"""
Set the speed / accuracy tradeoff to use for approximate search.
This currently affects RQ-quantized indexes, such as IVF_RQ, and
prefiltered search on HNSW indexes, where "fast" enables the ACORN
traversal. Other index types ignore this setting.
Examples
--------
>>> import lancedb
>>> db = lancedb.connect("memory://")
>>> table = db.create_table(
... "approx_hybrid", [{"vector": [0.1, 0.2], "text": "dog"}]
... )
>>> query = (
... table.search(query_type="hybrid")
... .vector([0.1, 0.2])
... .text("dog")
... .approx_mode("accurate")
... )
Parameters
----------
approx_mode: "fast" or "normal" or "accurate"
The tradeoff to use. By default "normal" is used.
Returns
-------
LanceHybridQueryBuilder
The LanceHybridQueryBuilder object.
"""
self._approx_mode = approx_mode
return self
def metric(self, metric: Literal["l2", "cosine", "dot"]) -> LanceHybridQueryBuilder:
"""Set the distance metric to use.
@@ -3304,6 +3382,8 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
self._vector_query.refine_factor(self._refine_factor)
if self._ef:
self._vector_query.ef(self._ef)
if self._approx_mode:
self._vector_query.approx_mode(self._approx_mode)
if self._bypass_vector_index:
self._vector_query.bypass_vector_index()
if self._lower_bound is not None or self._upper_bound is not None:
@@ -4430,6 +4510,33 @@ class AsyncVectorQueryBase:
self._inner.ef(ef)
return self
def approx_mode(self, approx_mode: Literal["fast", "normal", "accurate"]) -> Self:
"""
Set the speed / accuracy tradeoff to use for approximate search
This currently affects RQ-quantized indexes, such as IVF_RQ, and prefiltered
search on HNSW indexes, where "fast" enables the ACORN traversal. Other index
types ignore this setting.
"fast" favors lower latency and may reduce recall, "normal" (the default) uses
the index's default balance, and "accurate" favors higher recall and may
increase latency.
Examples
--------
>>> import asyncio
>>> import lancedb
>>> async def build_query():
... db = await lancedb.connect_async("memory://")
... table = await db.create_table(
... "approx_async", [{"vector": [0.1, 0.2]}]
... )
... return table.vector_search([0.1, 0.2]).approx_mode("accurate")
>>> query = asyncio.run(build_query())
"""
self._inner.approx_mode(approx_mode)
return self
def refine_factor(self, refine_factor: int) -> Self:
"""
A multiplier to control how many additional rows are taken during the refine
+13 -4
View File
@@ -128,6 +128,15 @@ def _should_push_down_query_table(
return namespace_client is not None and "QueryTable" in pushdown_operations
def _requires_local_namespace_execution(query: Query) -> bool:
# The namespace QueryTable request does not support these query options.
return (
query.take_offsets is not None
or query.search_effort is not None
or query.approx_mode is not None
)
_CUDA_UNAVAILABLE_MESSAGE = (
"CUDA acceleration is unavailable in this Python environment. Install a "
"CUDA-enabled PyTorch build and verify that the CUDA device is accessible, or "
@@ -5023,8 +5032,7 @@ class LanceTable(Table):
)
and not self._route_pushdown_to_rust
and self.current_branch() is None
and query.take_offsets is None
and query.search_effort is None
and not _requires_local_namespace_execution(query)
):
from lancedb.namespace import _execute_server_side_query
@@ -7199,6 +7207,8 @@ class AsyncTable:
async_query = async_query.column(query.vector_column)
if query.ef:
async_query = async_query.ef(query.ef)
if query.approx_mode:
async_query = async_query.approx_mode(query.approx_mode)
if query.bypass_vector_index:
async_query = async_query.bypass_vector_index()
@@ -7224,8 +7234,7 @@ class AsyncTable:
self._namespace_client, self._pushdown_operations
)
and not self._route_pushdown_to_rust
and query.take_offsets is None
and query.search_effort is None
and not _requires_local_namespace_execution(query)
):
from lancedb.namespace import _execute_server_side_query
+47
View File
@@ -173,6 +173,9 @@ class _FailingSyncInner:
async def to_arrow(self):
raise RuntimeError("direct table to_arrow should not be used")
async def _execute_query(self, query, **kwargs):
raise RuntimeError("local execution reached")
class _FailingAsyncInner:
def name(self):
@@ -1264,6 +1267,25 @@ class TestPushdownOperations:
]
assert all(r.k <= 2**31 - 1 for r in namespace_client.requests)
@pytest.mark.parametrize(
"query_kwargs",
[
{"approx_mode": "fast"},
{"search_effort": 0.75},
{"approx_mode": "fast", "search_effort": 0.75},
],
ids=["approx_mode", "search_effort", "both"],
)
def test_lance_table_local_only_options_are_not_pushed_down(self, query_kwargs):
namespace_client = _NamespaceClient()
table = _namespace_lance_table(namespace_client)
# The QueryTable request cannot carry these options, so the query has to
# run locally rather than silently ignore them.
with pytest.raises(RuntimeError, match="local execution reached"):
table._execute_query(Query(vector=[1.0, 2.0], **query_kwargs))
assert namespace_client.requests == []
def test_probe_fields_pass_through_independently(self):
request = _query_to_namespace_request(
["geneva", "hist"],
@@ -1348,6 +1370,31 @@ class TestAsyncPushdownOperations:
]
assert all(r.k <= 2**31 - 1 for r in namespace_client.requests)
@pytest.mark.parametrize(
"query_kwargs",
[
{"approx_mode": "fast"},
{"search_effort": 0.75},
{"approx_mode": "fast", "search_effort": 0.75},
],
ids=["approx_mode", "search_effort", "both"],
)
async def test_async_table_local_only_options_are_not_pushed_down(
self, query_kwargs
):
namespace_client = _NamespaceClient()
table = AsyncTable(
_FailingAsyncInner(),
namespace_path=["geneva"],
namespace_client=namespace_client,
pushdown_operations={"QueryTable"},
)
with pytest.raises(AssertionError, match="direct async query"):
await table._execute_query(Query(vector=[1.0, 2.0], **query_kwargs))
assert namespace_client.requests == []
def test_local_table_to_arrow_and_to_pandas_are_unchanged(tmp_path):
db = lancedb.connect(str(tmp_path / "db"))
+52
View File
@@ -1308,6 +1308,28 @@ async def test_invalid_nprobes_async(table_async: AsyncTable):
)
def test_approx_mode_works_sync(table):
for mode in ["fast", "normal", "accurate"]:
LanceVectorQueryBuilder(table, [0, 0], "vector").approx_mode(mode).to_list()
def test_invalid_approx_mode_sync(table):
with pytest.raises(ValueError, match="approx_mode must be one of"):
LanceVectorQueryBuilder(table, [0, 0], "vector").approx_mode("turbo").to_list()
@pytest.mark.asyncio
async def test_approx_mode_works_async(table_async: AsyncTable):
for mode in ["fast", "normal", "accurate"]:
await table_async.vector_search([0, 0]).approx_mode(mode).to_list()
@pytest.mark.asyncio
async def test_invalid_approx_mode_async(table_async: AsyncTable):
with pytest.raises(ValueError, match="approx_mode must be one of"):
table_async.vector_search([0, 0]).approx_mode("turbo")
def test_query_builder_with_prefilter(table):
df = (
LanceVectorQueryBuilder(table, [0, 0], "vector")
@@ -1962,6 +1984,9 @@ def test_query_serialization_sync(table: lancedb.table.Table):
q = table.search([5.0, 6.0]).ef(7).to_query_object()
check_set_props(q, ef=7, vector_column="vector", vector=[5.0, 6.0])
q = table.search([5.0, 6.0]).approx_mode("fast").to_query_object()
check_set_props(q, approx_mode="fast", vector_column="vector", vector=[5.0, 6.0])
q = table.search([5.0, 6.0]).bypass_vector_index().to_query_object()
check_set_props(
q, bypass_vector_index=True, vector_column="vector", vector=[5.0, 6.0]
@@ -2190,6 +2215,19 @@ async def test_query_serialization_async(table_async: AsyncTable):
limit=10,
)
q = (await table_async.search([5.0, 6.0])).approx_mode("fast").to_query_object()
check_set_props(
q,
approx_mode="fast",
vector=sample_vector,
postfilter=False,
minimum_nprobes=None,
maximum_nprobes=None,
with_row_id=False,
bypass_vector_index=False,
limit=10,
)
q = (await table_async.search([5.0, 6.0])).bypass_vector_index().to_query_object()
check_set_props(
q,
@@ -2228,6 +2266,20 @@ async def test_query_serialization_async(table_async: AsyncTable):
)
@pytest.mark.asyncio
async def test_hybrid_query_serialization_async(table_async: AsyncTable):
q = (
table_async.query()
.nearest_to([5.0, 6.0])
.nearest_to_text(MatchQuery("dog", "text"))
.ef(7)
.approx_mode("fast")
.to_query_object()
)
assert q.ef == 7
assert q.approx_mode == "fast"
def test_query_schema(tmp_path):
db = lancedb.connect(tmp_path)
tbl = db.create_table(