mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 07:12:27 +00:00
fix(python): apply the default limit of 10 to sync hybrid queries (#4344)
## Summary
A synchronous hybrid query without an explicit `.limit()` can return up
to 20 rows. The documented default is 10, and `AsyncHybridQuery` already
falls back to `DEFAULT_HYBRID_LIMIT` (10).
In `LanceHybridQueryBuilder`, each sub-query (vector and FTS) falls back
to its own default of 10 rows. The fused result was then sliced with
`limit=None`, i.e. not truncated at all. When the two legs match
different rows, the caller gets the union.
t = db.create_table("t", [{"text": ("dog" if i >= 20 else "cat") + f"
{i}", "vector": [float(i), 0.0]} for i in range(40)])
t.create_index("text", config=FTS())
len(t.search(query_type="hybrid").vector([0.0,
0.0]).text("dog").to_arrow())
# before: 20, after: 10
For the same reason, `.offset(n)` without `.limit()` did not raise the
sub-query limits to cover the skipped prefix; that only happened inside
`if self._limit:`.
## Changes
- Use `DEFAULT_HYBRID_LIMIT` when no limit is set, both for the
sub-query limits (`limit + offset`) and for the final slice of the
reranked results, matching `AsyncHybridQuery`.
## Test plan
- [x] New `test_hybrid_query_default_limit` in `test_hybrid_query.py`.
It is the sync counterpart of `test_async_hybrid_query_default_limit`,
plus an `offset()`-without-`limit()` check. It fails before the fix (20
rows) and passes after.
- [x] `pytest python/tests/test_hybrid_query.py
python/tests/test_rerankers.py`
- [x] `ruff format --check` / `ruff check`
I have no local Rust toolchain, so I ran the tests against the released
0.39.0 native module with the changed Python files overlaid. The changed
functions are identical between 0.39.0 and main.
This commit is contained in:
@@ -2296,7 +2296,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
|
||||
norm=self._norm,
|
||||
fts_query=self._fts_query._query,
|
||||
reranker=self._reranker,
|
||||
limit=self._limit,
|
||||
limit=self._limit or DEFAULT_HYBRID_LIMIT,
|
||||
with_row_ids=True,
|
||||
offset=self._offset,
|
||||
)
|
||||
@@ -2748,13 +2748,13 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
|
||||
)
|
||||
|
||||
# Apply common configurations
|
||||
if self._limit:
|
||||
# The final offset/limit window is sliced out of the combined,
|
||||
# reranked results, so each sub-query must fetch enough rows to
|
||||
# cover the skipped prefix as well as the window itself.
|
||||
sub_query_limit = self._limit + (self._offset or 0)
|
||||
self._vector_query.limit(sub_query_limit)
|
||||
self._fts_query.limit(sub_query_limit)
|
||||
# The final offset/limit window is sliced out of the combined,
|
||||
# reranked results, so each sub-query must fetch enough rows to
|
||||
# cover the skipped prefix as well as the window itself.
|
||||
limit = self._limit or DEFAULT_HYBRID_LIMIT
|
||||
sub_query_limit = limit + (self._offset or 0)
|
||||
self._vector_query.limit(sub_query_limit)
|
||||
self._fts_query.limit(sub_query_limit)
|
||||
# WAL-PK-FUSION: without the fallback, select `self._columns` as is.
|
||||
self._pk_fusion = None
|
||||
columns = self._columns
|
||||
|
||||
@@ -315,6 +315,33 @@ def test_hybrid_query_offset(sync_table: Table):
|
||||
assert offset_result["_rowid"].to_pylist() == full["_rowid"].to_pylist()[2:]
|
||||
|
||||
|
||||
def test_hybrid_query_default_limit(sync_table: Table):
|
||||
# The vector and FTS legs match disjoint rows, so fusing their results
|
||||
# yields more rows than the default limit.
|
||||
new_rows = []
|
||||
for i in range(20):
|
||||
new_rows.append({"text": "close_vec", "vector": [0.1, 0.1]})
|
||||
new_rows.append({"text": "dog", "vector": [50.0 + i, 50.0 + i]})
|
||||
sync_table.add(new_rows)
|
||||
|
||||
def query():
|
||||
return (
|
||||
sync_table.search(query_type="hybrid")
|
||||
.vector([0.1, 0.1])
|
||||
.text("dog")
|
||||
.with_row_id(True)
|
||||
)
|
||||
|
||||
# Like the async hybrid query, the default limit is 10.
|
||||
result = query().to_arrow()
|
||||
assert len(result) == 10
|
||||
|
||||
# The offset window is taken after the default limit is applied.
|
||||
full = query().limit(15).to_arrow()
|
||||
offset_result = query().offset(5).to_arrow()
|
||||
assert offset_result["_rowid"].to_pylist() == full["_rowid"].to_pylist()[5:]
|
||||
|
||||
|
||||
def test_hybrid_query_minimum_nprobes_zero_raises(sync_table: Table):
|
||||
# minimum_nprobes(0) must raise the same validation error a plain vector
|
||||
# query raises, not silently no-op because 0 is falsy.
|
||||
|
||||
Reference in New Issue
Block a user