Skip to content

fix(python): apply offset when combining async hybrid results - #4028

Merged
wjones127 merged 2 commits into
lancedb:mainfrom
winklemad:fix-async-hybrid-offset
Sep 8, 2026
Merged

wjones127 merged 2 commits into
lancedb:mainfrom
winklemad:fix-async-hybrid-offset

Conversation

@winklemad

@winklemad winklemad commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4027

Summary

AsyncHybridQuery (table.query().nearest_to(...).nearest_to_text(...)) paginates incorrectly when .offset() is used: the second page repeats rows from the first page and silently drops others.

offset() on a hybrid query pushes the offset down into both sub-queries (HybridQuery::offset in python/src/query.rs forwards to inner_vec and inner_fts), so each sub-query independently skips its own first offset rows before the results are fused. AsyncHybridQuery.to_batches then called _combine_hybrid_results(..., limit=self._inner.get_limit()) without an offset, so the reranked table was sliced starting at position 0 and the sub-query limits were never raised to cover the skipped prefix.

On the 4-row fixture in test_hybrid_query.py, with _rowid ordering [3, 0, 2, 1]:

query before after
.limit(2) [0, 3] [0, 3]
.offset(2).limit(2) [3, 1] [2, 1]

Row 3 was returned on both pages and row 2 was never returned at all.

This is the async counterpart of #3769 (Fixes #3765), which fixed the same bug in the synchronous LanceHybridQueryBuilder. #3765 explicitly deferred the async path; this PR closes that gap and reuses the offset parameter that #3769 already added to _combine_hybrid_results. The synchronous path is unaffected — it was fixed in #3769.

Changes

python/python/lancedb/query.py, AsyncHybridQuery.to_batches:

  • Each sub-query now fetches limit + offset rows and its own offset is reset to 0, so the fused result contains the full prefix the window is sliced out of.
  • The combined, reranked table is sliced with offset= instead of always starting at 0.

Both halves are needed: raising the sub-query limits without the final slice still returns page 1, and slicing without raising the limits still misses rows.

nodejs has no equivalent hybrid combine path, so there is no SDK parity gap here.

Test plan

  • New regression test test_async_hybrid_query_offset in python/python/tests/test_hybrid_query.py, mirroring the sync test_hybrid_query_offset. It asserts the offset window is a suffix of the un-offset result and that page 1 + page 2 together cover every row exactly once (a row-count-only assertion would pass even with duplicates).
  • pytest python/tests/test_hybrid_query.py — 16 passed
  • pytest python/tests/test_rerankers.py — 9 passed, 11 skipped
  • pytest python/tests/test_query.py — 86 passed
  • pytest --doctest-modules python/lancedb/query.py — 13 passed
  • ruff format --check / ruff check — clean

Scope, after review

@lancedb-gatekeeper raised three points. Two were mine and are fixed in 04d07c2; the third is deliberately left alone and I'd like a maintainer's call on it.

Fixed — effective limit was read from the FTS child only. HybridQuery::get_limit() (python/src/query.rs:1159) returns self.inner_fts.inner.current_request().limit, so an FTS-first hybrid with no explicit .limit() yielded None, skipped the widening branch and passed limit=None to the combiner — returning the union of both candidate lists instead of the documented default of 10. The limit is now derived from both children with a DEFAULT_HYBRID_LIMIT = 10 fallback, so construction order no longer matters.

Fixed — explain_plan() / analyze_plan() described a different query than the one that ran. Both built their children straight from self._inner, bypassing the limit/offset rewrite in to_batches, and reported skip=2, fetch=2 while execution used skip=0, fetch=4. Child preparation now lives in one _create_child_queries() helper used by all three.

Visible change to explain_plan() output: because the plan is now built from the real execution children, which carry with_row_id(), the two ProjectionExec lines gain a _rowid column. The doctest is updated to match. This is the diagnostic becoming truthful rather than the assertion being weakened — it is still an exact-match comparison.

Not fixed here — RRF candidate-pool invariance. Widening each sub-query to limit + offset does change the candidate pool between page requests, so the fused ranking can shift and pagination can still repeat rows. That's a real problem, but it is exactly what the merged sync path does today:

# LanceHybridQueryBuilder (sync), merged in #3769
sub_query_limit = self._limit + (self._offset or 0)

Making the pool invariant means choosing a contract — a fixed candidate pool, or an explicit cursor — and that ought to apply to sync and async together rather than letting the two paths diverge. I've asked in the review thread which way you'd prefer, and I'm happy to do it here or in a follow-up covering both paths.

So, to be precise about what this PR delivers: it makes .offset() take effect on the async hybrid path and makes the diagnostics honest. It does not make hybrid pagination stable across pages under reranking — that needs the contract decision above.

AsyncHybridQuery pushes offset() down into the vector and FTS sub-queries,
so each of them skipped its own first N rows and the fused result was then
sliced from position 0. A paginated async hybrid search therefore repeated
rows from the previous page and dropped others entirely.

Fetch limit + offset rows per sub-query and slice the requested window out
of the combined, reranked table, matching the sync builder.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions github-actions Bot added bug Something isn't working Python Python SDK labels Aug 22, 2026

@lancedb-gatekeeper lancedb-gatekeeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Gate recommendation: request changes.

Moving the offset after fusion is the right boundary, but the revision still needs one canonical child-query window shared by execution and diagnostics. That window must preserve stable page semantics, honor the effective default limit regardless of builder order, and be the query that explain/analyze actually report.

Comment thread python/python/lancedb/query.py Outdated
# to be taken out of the combined, reranked results instead, so
# fetch the skipped prefix here too and slice it off afterwards.
if limit:
fts_query.limit(limit + offset)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fetching only limit + offset candidates makes the candidate set grow between page requests, so RRF can reorder earlier results and pagination still repeats and drops rows. On this head, source ranks vector [A, B, X, V4] and FTS [C, D, X, F4] produced page1=[A, C], page2=[C, B], while .limit(4) was [X, A, C, B]: C repeats and X is never visited. X enters both lists at rank 3, becomes the top fused result only in the deeper pool, and is then removed by the final offset. Please make the reranker candidate pool invariant across pages (or introduce an explicit fixed candidate/cursor contract) and cover a deep cross-list-overlap case.

Reproducer run against this head
names = ["A", "B", "X", "V4", "C", "D", "F4"]
texts = ["none", "none", "q q", "none", "q q q q", "q q q", "q"]
vectors = [[0, 0], [.1, 0], [.2, 0], [.3, 0], [10, 0], [11, 0], [12, 0]]
data = pa.table({'name': names, 'text': texts, 'vector': pa.array(vectors, type=pa.list_(pa.float32(), 2))})
with tempfile.TemporaryDirectory() as path:
    db = await lancedb.connect_async(path)
    table = await db.create_table("t", data)
    await table.create_index("text", config=FTS(with_position=False))
    base = table.query().nearest_to([0, 0]).nearest_to_text("q")
    full = (await base.limit(4).to_arrow())["name"].to_pylist()
    page1 = (await table.query().nearest_to([0, 0]).nearest_to_text("q").limit(2).to_arrow())["name"].to_pylist()
    page2 = (await table.query().nearest_to([0, 0]).nearest_to_text("q").offset(2).limit(2).to_arrow())["name"].to_pylist()
    assert not (set(page1) & set(page2))  # fails: {"C"}

Observed: full=[X, A, C, B], page1=[A, C], page2=[C, B].

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. I’ll treat the sync-parity candidate-window consequence as an accepted risk and won’t pursue this finding further.

Comment thread python/python/lancedb/query.py Outdated
fts_query.with_row_id()
vec_query.with_row_id()

limit = self._inner.get_limit()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effective limit is not available reliably from self._inner.get_limit(): HybridQuery.get_limit() reads only the FTS child, and an FTS-first hybrid without an explicit .limit() leaves that request at None even though hybrid search documents a default of 10. This skips the widening branch and passes limit=None to the combiner. With ten vector-only candidates and ten disjoint FTS candidates, .nearest_to_text("q").nearest_to([0, 0]).offset(2) returned 18 rows on this head instead of 10. Derive the effective hybrid limit independent of construction order (including the default) and add FTS-first coverage.

Reproducer run against this head
names = [f"V{i}" for i in range(10)] + [f"F{i}" for i in range(10)]
texts = ["none"] * 10 + ["q"] * 10
vectors = [[float(i), 0] for i in range(10)] + [[100.0 + i, 0] for i in range(10)]
data = pa.table({'name': names, 'text': texts, 'vector': pa.array(vectors, type=pa.list_(pa.float32(), 2))})
with tempfile.TemporaryDirectory() as path:
    db = await lancedb.connect_async(path)
    table = await db.create_table("t", data)
    await table.create_index("text", config=FTS(with_position=False))
    result = await table.query().nearest_to_text("q").nearest_to([0, 0]).offset(2).to_arrow()
    assert len(result) == 10  # observed: 18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 04d07c2: FTS-first hybrid queries without an explicit limit now fall back to 10, and the regression test verifies a 10-row result.

Comment thread python/python/lancedb/query.py Outdated

limit = self._inner.get_limit()
offset = req.offset or 0
if offset:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rewrite is applied only in to_batches. explain_plan() and analyze_plan() still build the original child queries from self._inner, so they report and execute skip=2, fetch=2 while normal execution now uses skip=0, fetch=4. That makes both diagnostic APIs describe a different query from the one users run. Factor child-query preparation into a shared helper used by execution, explain, and analyze.

Reproducer run against this head
query = table.query().nearest_to([0, 0]).nearest_to_text("q").offset(2).limit(2)
await query.to_arrow()
plan = await query.explain_plan()
limits = [line.strip() for line in plan.splitlines() if "GlobalLimitExec" in line]
assert limits == [
    "GlobalLimitExec: skip=0, fetch=4",
    "GlobalLimitExec: skip=0, fetch=4",
]  # observed two `skip=2, fetch=2` lines

analyze_plan() likewise reported two executed skip=2, fetch=2 operators.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 04d07c2: execution, explain_plan(), and analyze_plan() now share child-query preparation; the verified diagnostic plans report skip=0, fetch=4 for both children.

@lancedb-gatekeeper lancedb-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 22, 2026

@winklemad winklemad left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is a genuinely useful review, and I've verified all three against the source rather than taking them on trust.

Effective limit (:3888) — confirmed, my bug. HybridQuery::get_limit() at python/src/query.rs:1159 reads self.inner_fts.inner.current_request().limit, i.e. the FTS child only. So an FTS-first hybrid with no explicit .limit() returns None, my if limit: branch is skipped, and limit=None reaches the combiner. Your 18-rows-instead-of-10 result follows directly. I'll derive the effective limit independent of construction order, including the documented default of 10, and add FTS-first coverage.

explain/analyze divergence (:3890) — confirmed, my bug. explain_plan and analyze_plan build children straight from self._inner, so they bypass the rewrite I put in to_batches and report a different query than the one that executes. I'll factor child preparation into one helper used by all three.

Candidate pool (:3896) — you're right, and I want to flag something before fixing it. Widening to limit + offset does change the pool between pages, so RRF can reorder and pagination still repeats. But this is exactly what the merged sync path does today:

# LanceHybridQueryBuilder, sync
sub_query_limit = self._limit + (self._offset or 0)

I deliberately mirrored that, on the reasoning that an async fix diverging from merged sync behaviour would be the wrong call to make unilaterally. So the invariant-pool problem isn't specific to this PR — sync has it too, and fixing it properly means changing behaviour on a path that's already released.

That's a scope decision I don't think is mine to make, so: would you prefer I

  1. keep this PR at parity with sync (fix the two above only) and open a separate issue for pool invariance covering both paths, or
  2. solve the invariant pool here for async, accepting that async and sync then differ until sync is updated, or
  3. fix both paths in this PR?

My preference is (1) — the two confirmed bugs above are self-contained and clearly wrong today, whereas pool invariance needs a deliberate contract (fixed candidate pool vs. an explicit cursor) that ought to apply to both paths at once. I'll also scope the PR description down so it doesn't claim more than it delivers.

Happy to go whichever way you'd rather. I'll push the two confirmed fixes in the meantime.

Two follow-ups to the offset fix.

The effective limit was read from HybridQuery::get_limit(), which only looks
at the FTS sub-query. nearest_to() and nearest_to_text() build their new
sibling from a fresh query, and that is the sub-query the default limit is
filled in on, so which side carries it depends on the order the hybrid query
was built in. An FTS-first hybrid with no explicit limit therefore combined
its results with no limit at all and returned the whole union of both
candidate lists instead of the documented default of 10.

The paging rewrite also lived inline in to_batches, so explain_plan() and
analyze_plan() built their sub-queries straight from the hybrid query and
reported a skip/fetch window that is never executed. Both now share
_create_child_queries() with to_batches, so the plans describe the query
that runs.

@lancedb-gatekeeper lancedb-gatekeeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Gate recommendation: approve with a non-blocking risk.

The current revision fixes both actionable defects: FTS-first hybrids now retain the default limit of 10, and execution, explain_plan(), and analyze_plan() share the same child-query preparation.

The author has chosen sync parity. The accepted risk is that deeper hybrid pages can repeat or omit rows when the enlarged candidate pool changes reranking. No further change is requested for this pull request.

@lancedb-gatekeeper lancedb-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. and removed K-changes Latest Gatekeeper recommendation requests changes. labels Aug 22, 2026
@wjones127
wjones127 self-requested a review September 8, 2026 20:55
@wjones127
wjones127 merged commit 577fb48 into lancedb:main Sep 8, 2026
15 checks passed
@winklemad

Copy link
Copy Markdown
Contributor Author

Thanks @wjones127!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. Python Python SDK

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async hybrid query with .offset() repeats and drops rows across pages

2 participants