Skip to content

Take a KNN query's vector, k and mmr_lambda from a joined table - #14

Merged
vlasky merged 1 commit into
vlasky:mainfrom
MayCXC:knn-plan-refusal-vlasky
Oct 28, 2026
Merged

vlasky merged 1 commit into
vlasky:mainfrom
MayCXC:knn-plan-refusal-vlasky

Conversation

@MayCXC

@MayCXC MayCXC commented Oct 16, 2026 •

Copy link
Copy Markdown

A KNN query whose k comes from another table fails, and one whose query vector or mmr_lambda comes from it can return no rows:

create virtual table v using vec0(embedding float[2] distance_metric=l2, tag integer);
insert into v(rowid, embedding, tag) values
  (1, '[1,0]', 1), (2, '[2,0]', 1), (3, '[3,0]', 1),
  (4, '[4,0]', 2), (5, '[5,0]', 2), (6, '[6,0]', 2);
create table categories(id integer primary key, q text, n integer, lambda real);
insert into categories values (1, '[0,0]', 2, 1.0), (2, '[0,0]', 2, 1.0);

select c.id, v.rowid from v join categories c on c.id = v.tag
where v.embedding match '[0,0]' and v.k = c.n;
-- A LIMIT or 'k = ?' constraint is required on vec0 knn queries.
select c.id, v.rowid from v join categories c on c.id = v.tag
where v.embedding match c.q and v.k = 2;
-- no rows
select c.id, v.rowid from v join categories c on c.id = v.tag
where v.embedding match '[0,0]' and v.k = 2 and v.mmr_lambda = c.lambda;
-- no rows

vec0BestIndex skips an unusable constraint, and when a KNN argument reads another table of the query, SQLite also weighs plans that scan vec0 before that table, in which the argument is unusable. A k taken from a one-row table fails the same way in either FROM order, and an mmr_lambda taken from one returns no rows.

When a MATCH on a vector column is present, vec0BestIndex now returns SQLITE_CONSTRAINT for a plan in which the MATCH, k or a hidden parameter column declared after k (mmr_lambda among them) is unusable, the way json_each refuses a plan that cannot supply its argument, so the planner takes one that gives vec0 every argument. Each query above then returns the 2 nearest in each category, (1, 1), (1, 2), (2, 4), (2, 5): with categories read first, v.tag = c.id is usable too.

Filters on rowid, partition key, metadata and distance columns are left to the plan, as in @mceachen's port, photostructure/sqlite-vec@64db37c. vec0 cannot tell a lookup join from a filter join, and refusing the plans without such a filter turns his lookup from the 3 nearest rows into 3 rows per category. ARCHITECTURE.md's idxStr section says when a filter applies before the k all the same: the other table supplies a KNN argument, a CROSS JOIN names it first, vec0 is on the right of a LEFT JOIN with the filter in ON, or the KNN is a correlated subquery whose filter reads the outer query's columns.

tests/test-knn-joined-constraints.py takes the query vector, k and mmr_lambda from another table in both FROM orders, checks that a table that supplies one of them filters before the k, and that a lookup join returns k rows in either FROM order. 10 of its 16 cases fail on main and all pass here; refusing the filters as well fails the 4 lookup cases. The full suite passes (159 passed, 4 skipped, 259 snapshots).

The same change for asg017/sqlite-vec is asg017#331.

mceachen added a commit to photostructure/sqlite-vec that referenced this pull request Oct 16, 2026
… joined table

A join that supplied k or mmr_lambda from another table failed with the LIMIT
error or silently dropped mmr_lambda, and one that supplied the query vector
could return no rows, because vec0BestIndex accepted plans that scan vec0
before that table. This ports the MATCH, k and mmr_lambda part of
vlasky#14; its refusal of plans without joined filters is left out,
because it would turn a lookup join into k rows per joined row.
@mceachen

Copy link
Copy Markdown

Claude and I ported the MATCH/k/mmr_lambda checks in
photostructure@64db37c,
leaving out joined-filter refusal.

These checks fix a LIMIT error for joined k and empty results for joined
mmr_lambda or MATCH. The existing
test_the_query_k_and_mmr_lambda_can_come_from_another_table passes on
unpatched e67755f in both FROM orders. Our port tests k and mmr_lambda
separately with constant MATCH, and tests MATCH through a primary-key lookup:
v join p on p.id = v.tag where v.embedding match p.vector and v.k = 2
(p.id integer primary key).

The filter checks change this lookup join:

create virtual table v using vec0(embedding float[2], cat integer);
with recursive points(i) as (
  values (1) union all select i + 1 from points where i < 30
)
insert into v(rowid, embedding, cat)
select i, '[' || i || ',0]', (i - 1) % 3 + 1 from points;

create table categories(id integer primary key, name text);
insert into categories values (1, 'a'), (2, 'b'), (3, 'c');

select v.rowid, c.name from v join categories c on c.id = v.cat
where v.embedding match '[0,0]' and v.k = 3;

On SQLite 3.54.1 and 3.50.4, this returns 3 rows on e67755f and 9
(3 per category) with 9339147. Our partial port keeps 3.

The PR forces one KNN search per category. vec0 cannot distinguish filtering
candidates from looking up names for the nearest rows overall. A plain CTE
is flattened here and still returns 9 rows; AS MATERIALIZED keeps 3.

For per-category searches, this already works on main:

select c.id,
  (select group_concat(v.rowid) from v
   where v.embedding match '[0,0]' and v.k = 3 and v.cat = c.id) as nearest
from categories c;

A join that supplied k from another table failed with "A LIMIT or 'k = ?'
constraint is required on vec0 knn queries.", one that supplied
mmr_lambda returned no rows, and one that supplied the query vector
through a lookup could return no rows. xBestIndex skipped an unusable
constraint, and when a KNN argument reads another table of the query,
SQLite also weighs plans that scan vec0 before that table, in which the
argument is unusable. Six points on a line and a one-row table w:
`select v.rowid from v, w where v.embedding match '[0,0]' and v.k = w.n`
failed in either FROM order, and `v.k = 2 and v.mmr_lambda = w.lambda`
returned nothing.

When a MATCH on a vector column is present, xBestIndex now returns
SQLITE_CONSTRAINT for a plan in which the MATCH, k or a hidden parameter
column declared after k (mmr_lambda among them) is unusable, the way
json_each refuses a plan that cannot supply its argument, so the planner
takes one that gives vec0 every argument.

Filters on rowid, partition key, metadata and distance columns are left
to the plan. vec0 cannot tell a lookup join from a filter join, and
refusing the plans without such a filter turned the lookup
`select v.rowid, c.name from v join categories c on c.id = v.cat where
v.embedding match '[0,0]' and v.k = 3` from the 3 nearest rows into 3 rows
per category, as Matthew McEachen measured on SQLite 3.54.1 and 3.50.4
(vlasky#14 (comment))
and as it measures here on 3.46.1. ARCHITECTURE.md's idxStr section says
when such a filter applies before the k all the same. The scope and the
tests follow his port,
photostructure@64db37c

tests/test-knn-joined-constraints.py takes the query vector, k and
mmr_lambda from another table in both FROM orders, checks that a table
that supplies one of them filters before the k, and that a lookup join
returns k rows in either FROM order. Ten of its sixteen cases fail on
e67755f, and the four lookup cases fail with filters refused as well.
@MayCXC
MayCXC force-pushed the knn-plan-refusal-vlasky branch from 9339147 to db4345a Compare October 21, 2026 14:39
MayCXC added a commit to MayCXC/sqlite-vec that referenced this pull request Oct 21, 2026
A join that supplied k from another table failed with "A LIMIT or 'k = ?'
constraint is required on vec0 knn queries.", and one that supplied the
query vector through a lookup could return no rows. xBestIndex skipped an
unusable constraint, and when a KNN argument reads another table of the
query, SQLite also weighs plans that scan vec0 before that table, in which
the argument is unusable. Six points on a line and a one-row table w:
`select v.rowid from v, w where v.embedding match '[0,0]' and v.k = w.n`
failed in either FROM order.

When a MATCH on a vector column is present, xBestIndex now returns
SQLITE_CONSTRAINT for a plan in which the MATCH, k or a hidden parameter
column declared after k is unusable, the way json_each refuses a plan
that cannot supply its argument, so the planner takes one that gives vec0
every argument.

Filters on rowid, partition key, metadata and distance columns are left
to the plan. vec0 cannot tell a lookup join from a filter join, and
refusing the plans without such a filter turned the lookup
`select v.rowid, c.name from v join categories c on c.id = v.cat where
v.embedding match '[0,0]' and v.k = 3` from the 3 nearest rows into 3 rows
per category, as Matthew McEachen measured on SQLite 3.54.1 and 3.50.4
(vlasky#14 (comment))
and as it measures here on 3.46.1. ARCHITECTURE.md's idxStr section says
when such a filter applies before the k all the same. The scope and the
tests follow his port,
photostructure@64db37c

tests/test-knn-joined-constraints.py takes the query vector and k from
another table in both FROM orders, checks that a table that supplies one
of them filters before the k, and that a lookup join returns k rows in
either FROM order. Six of its twelve cases fail on 04d28bd.
@MayCXC MayCXC changed the title Refuse a KNN plan that would apply its filters after the k Take a KNN query's vector, k and mmr_lambda from a joined table Oct 21, 2026
@MayCXC

MayCXC commented Oct 21, 2026

Copy link
Copy Markdown
Author

Your reproduction gives the same here on SQLite 3.46.1: 3 rows on e67755f, 9 on 9339147. The branch now has your scope (db4345a): vec0BestIndex refuses a plan only when the MATCH, or an equality on k or a hidden column after it, is unusable, and leaves filters to the plan, so the lookup returns 3 rows in either FROM order. Where it differs from your port is that last part: the columns after k are taken by position rather than by mmr_lambda's index, so a parameter column added after mmr_lambda is covered without another change.

The tests follow yours. k and mmr_lambda come from another table separately with a constant MATCH; the query vector, k and mmr_lambda each also come through a primary-key lookup on a metadata and a partition key column, which reads categories first and gives each category its own k; and your lookup runs in both FROM orders on both columns. 10 of the 16 cases fail on e67755f, and the 4 lookup cases fail on 9339147. ARCHITECTURE.md's idxStr section says why filters are left to the plan and which query shapes still apply one before the k, your correlated subquery among them.

@vlasky

vlasky commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks for this. The approach is the one SQLite documents for required virtual-table parameters, and in testing it fixes more than the description claims (k in (2, 3), k = w.n + 0, MATCH from a joined table beside a constant rowid all work now). I could not push to your branch: your repository is a fork of asg017/sqlite-vec rather than of this one, so GitHub never grants the maintainer-edit permission here even with the box ticked (the push is refused with 403). So I merged the PR with one commit of mine on top, 2dfe13a, with a narrow fix and some documentation corrections; please have a look.

The fix: a duplicate k returned nothing. With the test file's fixture,

select v.rowid from v, w where v.embedding match '[0,0]' and v.k = 2 and v.k = w.n;
-- main: [1, 2]    this branch: []

Every plan with v.k = w.n unusable was refused, and in the plan that remained the last k term won and v.k = 2 was left to SQLite, which compares it against the hidden column (NULL) and drops every row. The refusal now checks whether a usable constraint on the same column under the same operator supplies the argument, and keeps the plan if so. I also gated the SQLITE_CONSTRAINT return on SQLite 3.26.0, the first release that accepts it from xBestIndex; before that any error fails the statement.

Two claims in the new ARCHITECTURE.md paragraph did not hold up, so I reworded them:

  • "SQLite takes such a plan whenever the query allows it": both join orders stay available and the planner picks by cost. After ANALYZE on the fixture the lookup join's plan becomes SCAN c; SCAN v, one KNN per category. The text now says the result is plan-dependent and that a global k needs the KNN in a subquery or CTE.
  • Scalar rowid equality is not applied before the k. With a request row (id = 6, q = '[0,0]'), v.embedding match r.q and v.k = 2 and v.rowid = r.id returns nothing, since only rowid in (...) is pushed into candidate selection. The text now says so, and a test pins both forms.

The commit also notes that a cycle (the argument's table reads vec0) now fails with "no query solution", where main silently returned nothing, and adds tests for that, for a k of 0, and for successive outer rows with different k values. The full suite passes with your tests included on SQLite 3.50.4 and the vendored 3.45.3. Merged.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants