Skip to content

feat: indexed nearest-by join over Lance — Spark 4.2 SQL Catalyst - #14

Open
sezruby wants to merge 3 commits into
mainfrom
knn-4.2-sql
Open

feat: indexed nearest-by join over Lance — Spark 4.2 SQL Catalyst#14
sezruby wants to merge 3 commits into
mainfrom
knn-4.2-sql

Conversation

@sezruby

@sezruby sezruby commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Adds a single module, lance-spark-knn-4.2_2.13, that transparently accelerates Spark 4.2's native APPROX NEAREST k BY DISTANCE ... join (the NearestByJoin operator from SPARK-56395) over a Lance scan.

What it does

Spark's default rewrite lowers NearestByJoin to an O(|L|×|R|) cross-product + MaxMinByK. This module rewrites it onto a no-shuffle, per-partition native probe against a Lance vector index instead.

  • IndexedNearestByJoinRule — a post-hoc resolution rule (the only injection point that sees the unrewritten NearestByJoin; Spark's own RewriteNearestByJoin runs first, in the FinishAnalysis batch) rewrites the operator into a single LanceKnnJoinLogicalPlan.
  • LanceKnnJoinStrategy lowers that to LanceKnnJoinExec, which drives one mapPartitions → per-row LanceProbe (native ANN/exact search) → bounded top-K → late materialize by row address. No shuffle, no broadcast.
  • Opt-in behind spark.lance.knn.indexedNearestByJoin.enabled (off by default). When disabled, the query falls through to Spark's built-in rewrite.
  • Right-side WHERE is translated to a Lance prefilter, or the rewrite is refused when the predicate can't be pushed in full — never a silently dropped residual.
  • Metrics: L2, cosine, dot. nprobes / refineFactor are tunable via spark.lance.knn.* configs.

The module carries its own probe core (LanceKnnJoinStage / LanceProbe / TopKHeap / Metric); it depends on lance-spark-base for LanceRuntime and on the Spark 4.2 connector at test scope.

Tests

  • SQL end-to-end: parser → rule → strategy → exec against a real Lance dataset, oracle-checked — including WHERE prefilter pushdown and the opt-in gating.
  • IVF-PQ approximate-recall suite driven through the same SQL path (builds a real index; checks recall floor and that refineFactor helps).
  • Rule-unit suite (metric/direction matching, alias/filter unwrapping, prefilter translator) plus probe/heap unit tests.

Notes

  • Not compiled in this environment — review / a reactor build is the gate.
  • The fork's default CI matrix (spark.yml runs mvn test -pl lance-spark-<spark>_<scala> -am) builds the connector modules and their upstream deps only; this module is downstream, so it is not built by that matrix. Verifying it needs a reactor build (e.g. ./mvnw test -pl lance-spark-knn-4.2_2.13 -am).
  • Scope is intentionally SQL-only. The manual df.kNearestJoin DataFrame API is deferred — it can be added later as a Spark-version-agnostic base module when there's demand.

🤖 Generated with Claude Code

Add `lance-spark-knn-4.2_2.13`: a Catalyst integration that transparently
accelerates Spark 4.2's `APPROX NEAREST k BY DISTANCE ...` (the `NearestByJoin`
operator from SPARK-56395) over a Lance scan. It rewrites the operator onto a
no-shuffle, per-partition native probe against a Lance vector index instead of
Spark's default O(|L|x|R|) cross-product + MaxMinByK rewrite.

How it works:
- `IndexedNearestByJoinRule` is a postHocResolutionRule — the only injection
  point that sees the unrewritten `NearestByJoin`, since Spark's own
  `RewriteNearestByJoin` runs first in the FinishAnalysis batch. It rewrites the
  operator to a single `LanceKnnJoinLogicalPlan`; `LanceKnnJoinStrategy` lowers
  that to `LanceKnnJoinExec`, which drives one `mapPartitions` -> per-row
  `LanceProbe` (native ANN/exact search) -> bounded top-K -> late materialize by
  row address. No shuffle, no broadcast.
- Opt-in behind `spark.lance.knn.indexedNearestByJoin.enabled` (off by default);
  a disabled query falls through to Spark's built-in rewrite.
- Right-side WHERE is pushed to Lance as a prefilter, or the rewrite is refused
  when the predicate cannot be pushed in full (never silently drops a residual).

Metrics: L2, cosine, dot. The module carries its own probe core
(LanceKnnJoinStage / LanceProbe / TopKHeap / Metric); it depends on
lance-spark-base for LanceRuntime and on the Spark 4.2 connector at test scope.

Tests: SQL end-to-end (parser -> rule -> strategy -> exec against a real Lance
dataset, oracle-checked, incl. WHERE pushdown and opt-in gating), an IVF-PQ
approximate-recall suite driven through the SQL path, plus rule-unit and
probe/heap unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sezruby and others added 2 commits August 20, 2026 15:13
…Test

The end-to-end (no-index/exact) and IVF-PQ recall tests are the same kind
of test — full SQL execution through the Catalyst chain against a real
Lance dataset — and duplicated SparkSession setup, schema builders, vector
generation, the oracle top-K, and l2/randomVector. Folding them into one
class (exact + approximate sections) shares that scaffolding: 653 -> 481
lines, no loss of coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ters

translateFilter's asRightColumn only matched a bare top-level Attribute, so
a right-side WHERE on a nested struct field (e.g. meta.category = 'A')
refused the rewrite and fell back to Spark's brute-force cross-product.
Make asRightColumn recurse through GetStructField and render a dotted path
(col.field, arbitrarily deep). Lance's scan filter planner runs with
enable_relations = false, so a dotted identifier is interpreted as a nested
column path — this maps 1:1.

The recursion still gates on the root resolving to a right-side attribute,
so a left-side or foreign root refuses. Array/map element access (col[i])
remains unsupported and refuses via the catch-all. Adds accept + refuse
unit cases to IndexedNearestByJoinRuleTest's translator tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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