fix(python): reject empty query vectors before column inference (#4415)

Empty list and NumPy vector queries were validated too late: local
search inferred a vector column first, producing misleading schema
errors, while remote search indexed `vector[0]` and raised `IndexError`.

Add a shared empty-vector check before column inference and query
creation, and validate direct vector-builder inputs. Local and remote
sync search now raise `ValueError: Query vector must not be empty` for
these inputs.

Validation:

- All 26 new regression cases failed on the original code and pass with
the fix, covering list/NumPy inputs, automatic/explicit vector search,
inferred/explicit column names, one/two local vector columns, and remote
search before query submission.
- After merging current `main`, all 32 focused cases passed, covering
these regressions and upstream async searches without a query.
- The query, table, remote, utility, hybrid-query, and database suites
passed in the rebuilt uv environment: 541 passed, 1 skipped, 1 slow test
deselected.
- `ruff check .`, `ruff format .`, and `cargo fmt --all` passed from the
repository root.

Fixes #4389

<!-- lance-gatekeeper-fix:v1 agent=f1f4875090ef391bb1aa912dd54026dc
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
This commit is contained in:
lancedb-gatefixer[bot]
2026-10-03 21:30:13 -07:00
committed by GitHub
parent 3e0a1a8780
commit 7f5933594f
5 changed files with 55 additions and 1 deletions
+5 -1
View File
@@ -40,7 +40,7 @@ from .rerankers.base import Reranker
from .rerankers.rrf import RRFReranker from .rerankers.rrf import RRFReranker
from .rerankers.util import check_reranker_result from .rerankers.util import check_reranker_result
from .schema import is_blob_like_field, schema_has_blob_field from .schema import is_blob_like_field, schema_has_blob_field
from .util import flatten_columns from .util import _validate_query_vector, flatten_columns
from . import _wal_hybrid # WAL-PK-FUSION: delete. from . import _wal_hybrid # WAL-PK-FUSION: delete.
from ._blob import ( from ._blob import (
BLOB_MODE_TO_HANDLING, BLOB_MODE_TO_HANDLING,
@@ -907,6 +907,9 @@ class LanceQueryBuilder(ABC):
fast_search: bool fast_search: bool
Skip flat search of unindexed data. Skip flat search of unindexed data.
""" """
if query_type != "fts":
_validate_query_vector(query)
if ordering_field_name is not None: if ordering_field_name is not None:
import warnings import warnings
@@ -1619,6 +1622,7 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
str_query: Optional[str] = None, str_query: Optional[str] = None,
fast_search: bool = None, fast_search: bool = None,
): ):
_validate_query_vector(query)
super().__init__(table) super().__init__(table)
self._query = query self._query = query
self._distance_type = None self._distance_type = None
+11
View File
@@ -280,6 +280,14 @@ def infer_vector_column_dim(data_type: pa.DataType) -> Optional[int]:
return None return None
def _validate_query_vector(query: Any) -> None:
"""Reject empty vector inputs before vector-column inference or execution."""
if (isinstance(query, list) and not query) or (
isinstance(query, np.ndarray) and query.size == 0
):
raise ValueError("Query vector must not be empty")
def _query_vector_dim(query: Optional[Any]) -> Optional[int]: def _query_vector_dim(query: Optional[Any]) -> Optional[int]:
if query is None: if query is None:
return None return None
@@ -301,6 +309,9 @@ def infer_vector_column_name(
query: Optional[Any], # inferred later in query builder query: Optional[Any], # inferred later in query builder
vector_column_name: Optional[str], vector_column_name: Optional[str],
): ):
if query_type != "fts":
_validate_query_vector(query)
if vector_column_name is not None: if vector_column_name is not None:
return vector_column_name return vector_column_name
+6
View File
@@ -2138,6 +2138,12 @@ def test_ensure_vector_query_nested_empty_list():
ensure_vector_query([[]]) ensure_vector_query([[]])
@pytest.mark.parametrize("query", [[], np.array([], dtype=np.float32)])
def test_vector_query_builder_empty_vector(table, query):
with pytest.raises(ValueError, match="^Query vector must not be empty$"):
LanceVectorQueryBuilder(table, query, "vector")
def test_fast_search(tmp_path): def test_fast_search(tmp_path):
db = lancedb.connect(tmp_path) db = lancedb.connect(tmp_path)
+15
View File
@@ -16,6 +16,7 @@ import uuid
from packaging.version import Version from packaging.version import Version
import lancedb import lancedb
import numpy as np
from lancedb.conftest import MockTextEmbeddingFunction from lancedb.conftest import MockTextEmbeddingFunction
from lancedb.query import AsyncQuery, ColumnOrdering from lancedb.query import AsyncQuery, ColumnOrdering
from lancedb.remote import ClientConfig from lancedb.remote import ClientConfig
@@ -1592,6 +1593,20 @@ def test_query_sync_empty_query():
assert data == expected assert data == expected
@pytest.mark.parametrize("query", [[], np.array([], dtype=np.float32)])
@pytest.mark.parametrize("vector_column_name", [None, "vector"])
@pytest.mark.parametrize("query_type", ["auto", "vector"])
def test_query_sync_empty_vector(query, vector_column_name, query_type):
def handler(body):
pytest.fail("An empty query vector must be rejected before sending a query")
with query_test_table(handler) as table:
with pytest.raises(ValueError, match="^Query vector must not be empty$"):
table.search(
query, vector_column_name=vector_column_name, query_type=query_type
).limit(3).to_arrow()
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("search_kwargs", [{}, {"query": None}]) @pytest.mark.parametrize("search_kwargs", [{}, {"query": None}])
async def test_async_search_without_query(search_kwargs): async def test_async_search_without_query(search_kwargs):
+18
View File
@@ -3777,6 +3777,24 @@ def test_empty_query(mem_db: DBConnection):
assert df.num_rows == 42 assert df.num_rows == 42
@pytest.mark.parametrize("query", [[], np.array([], dtype=np.float32)])
@pytest.mark.parametrize("vector_column_name", [None, "vector"])
@pytest.mark.parametrize("query_type", ["auto", "vector"])
@pytest.mark.parametrize("multiple_vector_columns", [False, True])
def test_search_empty_vector(
mem_db, query, vector_column_name, query_type, multiple_vector_columns
):
fields = [pa.field("vector", pa.list_(pa.float32(), 8))]
if multiple_vector_columns:
fields.append(pa.field("vec2", pa.list_(pa.float32(), 4)))
table = mem_db.create_table("empty_vector_query", schema=pa.schema(fields))
with pytest.raises(ValueError, match="^Query vector must not be empty$"):
table.search(
query, vector_column_name=vector_column_name, query_type=query_type
).limit(3).to_arrow()
def test_search_with_schema_inf_single_vector(mem_db: DBConnection): def test_search_with_schema_inf_single_vector(mem_db: DBConnection):
class MyTable(LanceModel): class MyTable(LanceModel):
text: str text: str