Skip to content

fix(udf): compare row against row when both arguments are columns - #35

Merged
anoop-narang merged 3 commits into
mainfrom
fix/elementwise-distance
Sep 10, 2026
Merged

anoop-narang merged 3 commits into
mainfrom
fix/elementwise-distance

Conversation

@anoop-narang

Copy link
Copy Markdown
Collaborator

The bug

A distance function given a column as its second argument silently compared every row against row 0 of that column.

SELECT id, cosine_distance(emb, emb) AS self_dist FROM t ORDER BY id

Each row's own vector against itself must be 0 for every row. Instead:

id  self_dist
1   0.0
2   0.86683166      <- must be 0
3   0.92379284
4   0.8087635

No error and no warning. And it is not stable: adding WHERE id >= 3 moves the 0 onto row 3, because the filter changes which row leads the scan. The same query over the same data can give different answers depending on batching, filtering, or parallelism.

extract_query_vec resolves argument 2 to a single vector, taking .value(0) when handed an array. That is correct for the intended shape — one query vector — but silently wrong for a column.

What it should do

Row-wise: row i of argument 1 against row i of argument 2. That is how every scalar function over two columns behaves, and it is what DuckDB does:

                          DuckDB       before        after
array_cosine_distance(emb, emb)   0, 0, 0      0, 1, 1       0, 0, 0
array_cosine_distance(emb, [1,0,0])  0, 1, 1   0, 1, 1       0, 1, 1

Only the column-against-column case diverged; the literal query-vector form was already correct and is unchanged.

The change

  • Second argument is an array with the same row count as the first → compare row against row.
  • Second argument is a scalar, a literal, or a single-row array → broadcast to every row, exactly as before. This is the documented form and it is untouched.
  • Second argument is an array of any other length → an error naming both lengths, rather than silently using row 0.

Nulls on either side yield a null distance, matching the existing single-vector path. Per-row dimensionality mismatches error, as they already did.

Verification

Expected values are DuckDB's, taken from array_cosine_distance / array_distance over the same three orthogonal unit vectors.

Confirmed the tests fail without the fix and pass with it:

without fix   self_distance_is_zero_on_every_row       FAILED  [0.0, 1.0, 1.0]
              self_distance_is_unchanged_by_a_filter   FAILED  [0.0, 1.0]
              l2_pairwise_compares_row_against_row     FAILED  [0.0, 2.0, 2.0]
              a_literal_query_vector_still_broadcasts  ok      <- documented path unaffected
with fix      all four                                 ok

Full suite: 78 passed, 0 failed (3 pre-existing ignored). clippy and fmt clean.

Note on l2_distance

While writing the oracle comparison: this crate's l2_kernel returns L2 squared, deliberately, to match USearch's MetricKind::L2sq. So l2_distance gives 2.0 for an orthogonal unit pair where DuckDB's array_distance gives 1.414. Ranking is identical since squaring is monotonic, so k-NN results agree — but absolute values do not, which matters for a threshold ported from DuckDB. Not changed here; pinned in a test so the convention is asserted rather than assumed.

A distance function given a column as its second argument resolved that
column to a single query vector by taking row 0, then compared every row
against it. `cosine_distance(v, v)` answered 0 for the first row and the
distance to the first row for all the others — no error, no warning, and
adding a filter moved which row came out as 0, because it changed which
row led the scan.

Compare row i against row i when the second argument has the same number
of rows as the first, which is how a scalar function over two columns
behaves everywhere else and what DuckDB's array_cosine_distance does. A
scalar, a literal, or a single-row array still broadcasts to every row,
so the documented query-vector form is unchanged. A second column of any
other length is now an error instead of silently using row 0.

Expected values in the tests are DuckDB's, over the same fixture.
@anoop-narang
anoop-narang requested a review from a team as a code owner September 10, 2026 15:07
@anoop-narang
anoop-narang requested review from zfarrell and removed request for a team September 10, 2026 15:07
Comment thread src/udf.rs Outdated
Comment thread src/udf.rs
Comment thread tests/pairwise_distance.rs
Comment thread src/udf.rs
claude[bot]
claude Bot previously approved these changes Sep 10, 2026
Three gaps in the previous commit, from review.

Only a list-typed array is a column of vectors. A flat Float32Array or
Float64Array is one query vector however many elements it holds, and
routing it by row count sent it to the pairwise path where it failed —
a shape that worked before. Decide on the array type instead.

The kernels are symmetric and neither the docs nor the signature fix
which side holds the query, so a single vector given as the first
argument now broadcasts over a column in the second, rather than being
refused for having one row.

Cover the two behaviours that had no test: a null on either side yields
a null distance, and two columns of different lengths error. The second
goes through invoke_with_args, since DataFusion gives every argument
the same row count and SQL cannot reach that arm.

Document both shapes in the module header and the README.
Comment thread README.md
Comment thread README.md
claude[bot]
claude Bot previously approved these changes Sep 10, 2026

@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.

All four prior threads are addressed in the code, not only in replies.

The type-based rule in is_vector_column (src/udf.rs:265) keeps the flat query-vector branches of extract_query_vec reachable, and the (1, _) arm handles a query vector on the left. try_extract_distance (src/rule.rs:492) rejects a column as the second argument, so the row-wise case cannot be rewritten into an ANN search.

Two non-blocking comments on README.md. CI Test and Clippy had not reported when this review started.

The symmetry paragraph read as unconditional. try_extract_distance
requires args[0] to be a column and args[1] a literal, so the reversed
form returns the same distances but is not rewritten and falls back to
an exact scan — correct answers, no index, no signal.

Also move the paragraph above the lead-in sentence it had been inserted
into, which ends in a colon pointing at the kernel table.

@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.

Prior threads are all resolved in code. Verified try_extract_distance (src/rule.rs:488-492) matches the new Limitations row, and the flat-array branches in extract_query_vec are reachable again through the non-list arm at src/udf.rs:156. No new findings.

@anoop-narang
anoop-narang merged commit cf1c0d1 into main Sep 10, 2026
6 checks passed
@anoop-narang
anoop-narang deleted the fix/elementwise-distance branch September 10, 2026 15:55
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.

1 participant