Skip to content

feat(knn): probe core + no-shuffle join stage for indexed Lance vector join (Spark 4.2) - #797

Open
sezruby wants to merge 4 commits into
lance-format:mainfrom
sezruby:knn-4.2-probe-core
Open

sezruby wants to merge 4 commits into
lance-format:mainfrom
sezruby:knn-4.2-probe-core

Conversation

@sezruby

@sezruby sezruby commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What

The foundation of the indexed APPROX NEAREST join over Lance (SPARK-56395), as a new opt-in module lance-spark-knn-4.2_2.13: the JVM probe primitive plus the no-shuffle per-partition join stage that drives it. No Spark Catalyst / plan integration yet, so it reviews on its own.

Split out of the umbrella PR #796 for reviewability. The SQL Catalyst rewrite, physical operator, session extension, and end-to-end SQL/recall tests are the remaining follow-up (see below).

This was previously split further into [1/3] probe-core and [2/3] join-stage (#799); those are collapsed here so the probe primitive lands together with its only caller. #799 is closed in favor of this PR.

How it works

The indexed nearest-by join runs per Spark partition with no shuffle. Each task opens R's whole index (fragmentIds = None) and issues one native LanceProbe.probeRows(...) per left row; Lance does the cross-fragment IVF probe and heap-merge internally and returns the final top-k best-first, each hit already carrying its projected payload. There is no Exchange, no JVM-side cross-fragment merge, and no over-fetch / trim / second-materialize scan.

Contents

  • LanceProbe — opens a Lance dataset once and serves per-query nearest searches against a fixed fragment set. probeRows folds the nearest search and payload projection into a single native scan, returning each hit's row-id, ranking score, and materialized payload (via the connector's canonical Arrow→Spark adapter). Handles 64-bit-unsigned row ids. A schema-level guard declines any table whose own schema owns a reserved column (_rowid / _distance / _score), which the injected metadata would otherwise shadow.
  • LanceKnnJoinStage — the per-partition execution: runPartition opens one probe per partition, streams the join output lazily (no per-partition buffering), and closes the native handle on task completion (success or failure). resolveReadContext / mergeReadOptions pin the Lance snapshot once on the driver so every task probes one consistent version (mirroring the connector's incompatible-pinned-ref guard). coerceToSpark shapes payloads (structs, arrays, and the Arrow entry-list form a MapType cell arrives in) to what the join's encoder expects.
  • Metric / MaterializedHit — small value types.

Tests

Run standalone. The probe validation suite writes a real Lance dataset with Spark and needs no vector index — Lance's brute-force scan is an exact recall=1.0 oracle, isolating probe correctness from index quality:

  • LanceProbeValidationTest — probe result shape, brute-force-oracle equivalence, metric-direction correctness, unmapped-projection preservation, the schema-eligibility contract and reserved-column decline, dataset-handle reuse across probes, fragment-id restriction, and the executor namespace policy.
  • LanceKnnJoinStageTest (backend-free) — lazy streaming output, read-option merge conflicts, and schema-aware payload coercion.

Follow-up

  • [3/3] the Catalyst APPROX NEAREST interception rule, physical operator, session extension, and end-to-end SQL / recall tests + docs.

Note: this module is downstream of the connector modules, so the default -am connector build does not exercise it; build it with ./mvnw -pl lance-spark-knn-4.2_2.13 test (connector artifacts resolved from the local repo).

🤖 Generated with Claude Code

First slice of the indexed APPROX NEAREST join over Lance (SPARK-56395),
split out of the umbrella PR for reviewability. This slice is the JVM
primitive layer only — no Spark plan integration:

- LanceProbe: opens a Lance dataset once and serves per-query nearest
  searches. `probe` returns row refs + scores (payload fetched later);
  `probeRows` folds the search and payload projection into one scan for the
  no-overfetch path. 64-bit-unsigned row-id handling and the canonical
  Arrow -> Spark payload adapter live here.
- TopKHeap: bounded best-first merge, metric-direction aware.
- Metric / ScoredRowRef / MaterializedHit: value types.

Tests run standalone against a real Lance dataset (no vector index needed —
the brute-force scan is a recall=1.0 oracle): LanceProbeValidationTest
(probe shape, brute-force equivalence, probeRows == probe+materialize
parity, handle reuse, fragment restriction, namespace policy) and
TopKHeapTest. 13 tests pass.

New opt-in module `lance-spark-knn-4.2_2.13` (Spark 4.2 / Scala 2.13).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added the enhancement New feature or request label Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

ACTION NEEDED
Lance follows the Conventional Commits specification for release automation.

The PR title and description are used as the merge commit message. Please update your PR title and description to match the specification.

For details on the error please inspect the "PR Title Check" action.

@sezruby sezruby changed the title feat(knn): Lance vector-index probe core primitives (Spark 4.2) [1/3] feat(knn): probe-core primitives for indexed Lance vector join (Spark 4.2) [1/3] Aug 26, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 26, 2026
…reserved-name collision

Addresses the gatekeeper findings on the probe-core slice:

- Metric: Lance returns a distance for every metric, including cosine
  (1 - cosine_similarity) and dot (1 - dot_product), so smaller is better
  for all three. Cosine/Dot were flagged larger-is-better, which made the
  merge heap retain the farthest neighbor.
- TopKHeap: derive admission from the heap's own ordering (ord.lt) instead
  of a raw float comparison, so a NaN worst-survivor is evictable rather
  than pinning a slot forever.
- LanceProbe.probeRows: preserve projected payload columns that lack a
  supplied Spark type through the generic Arrow conversion (matching
  materialize/readRows) instead of silently dropping them.
- LanceProbe.probeRows: reject a projection that names a column the nearest
  scan injects (_rowid / _distance / _score) so it cannot collide inside the
  fused scan; expose fusesCleanly/ReservedProjectionColumns so the join stage
  can route such schemas to the split probe + materialize path.
- .bumpversion.toml: register lance-spark-knn-4.2_2.13/pom.xml so release
  version bumps reach the new module.

Regressions added: metric direction through the size-1 heap (all three
metrics), NaN eviction, unmapped-field preservation, and reserved-name
rejection. Module test phase: 17 tests, 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 26, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 26, 2026
Make the nearest-probe eligibility contract schema-level instead of
projection-level. Lance's nearest scan always injects _rowid and the
_distance/_score metadata; if the dataset's own schema already has a
column by one of those names, the injected metadata shadows it and NO
probe route recovers the physical column. Empirically an all-columns
probeRows scan reads a physical _distance out-of-band as the ranking
score and silently drops it from the payload — data loss, not an error.

Replace the projection-only fusesCleanly guard (which only caught an
explicit reserved column in the projection list, missing the empty /
all-columns form) with:
  - LanceProbe.schemaSupportsNearest / reservedSchemaColumns: the pure
    eligibility primitive the Catalyst rule consults to DECLINE such a
    table up front and fall back to default nearest-by execution.
  - requireNearestCompatibleSchema(): a defensive backstop reading the
    dataset schema once, called at the top of probe() and probeRows(),
    throwing a clear error naming the offending column.

Regression: write a real dataset WITH a _distance column and assert the
schema is reported non-nearest-compatible and BOTH probe entry points
fail fast naming _distance instead of returning a lossy payload; plus a
pure schemaSupportsNearest / reservedSchemaColumns contract test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 27, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 27, 2026
@sezruby

sezruby commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

@geruh @yanghua @LuciferYang — could I get a maintainer review on this when you have a moment?

Why this exists: the current path to KNN entries in Spark is a JVM cross join + distance compute. That's only viable on small datasets, and even there it's very slow; at larger scale it doesn't complete at all — brute-force cross join isn't a feasible way to get nearest neighbors at any real size. This series replaces it with Lance's native indexed vector search: LanceProbe opens the dataset once and serves per-query nearest searches through the native API, and probeRows folds the search and the payload projection into a single native scan so materialization happens natively too (no overfetch, no JVM-side join). It's substantially faster.

The offload is gated on index availability, with a dataset-size gate planned as well.

This PR: slice 1 of 3 of the indexed APPROX NEAREST join (SPARK-56395), split out of #796 to be reviewable on its own. It's the JVM probe-core layer only — LanceProbe / TopKHeap / value types — with no Spark plan integration, so it stands alone.

Status: CI is green across Spark 3.4–4.2, and the Lance Gatekeeper's recommendation is approve with a non-blocking risk (the _score reserved-name guard is conservative vs. the current _distance path). Mergeable, no conflicts. Follow-ups [2/3] (LanceKnnJoinStage) and [3/3] (the Catalyst rule + e2e tests) build on top of this.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. and removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 4, 2026
@LuciferYang

Copy link
Copy Markdown
Collaborator

I’m missing some context. Is this a feature that was already discussed and agreed to add?

@lance-gatekeeper lance-gatekeeper Bot removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 5, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-decision Latest Gatekeeper review requires a maintainer decision. label Sep 5, 2026
Collapse the [2/3] join-stage work into this PR: the indexed nearest-by
join runs per Spark partition with NO shuffle, driven by a single native
LanceProbe.probeRows(...) call per left row. Each task opens R's whole
index (fragmentIds = None), Lance does the cross-fragment IVF probe and
heap-merge internally and returns the final top-k best-first, each hit
already carrying its projected payload.

Because the search returns the final top-k best-first with the payload
fused in, there is no JVM-side over-fetch, trim, second materialize scan,
or top-k heap. Remove the code that only existed to serve that split
pipeline, none of which has a live caller on the SQL path:

- TopKHeap + TopKHeapTest (JVM merge/trim never runs)
- LanceProbe.probe (refs-only) + readScored, and materialize + readRows
  (the late-materialization split); keep only the fused probeRows path
- the ef knob (probeRows/buildNearestQuery/setEf) - never set
- Conf.internalK / Conf.ef / Conf.smallerIsBetter and the over-fetch
  branch in LanceKnnJoinStage.processRow / foldsInOneScan
- ScoredRowRef (rename ScoredRowRef.scala -> MaterializedHit.scala; keep
  only MaterializedHit) and dead helpers (extractRowAddr, rowAddrOf)

Keep the schema-level eligibility guard (requireNearestCompatibleSchema /
schemaSupportsNearest): a table whose own schema owns a reserved column
(_rowid/_distance/_score) must decline the indexed rewrite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sezruby sezruby changed the title feat(knn): probe-core primitives for indexed Lance vector join (Spark 4.2) [1/3] feat(knn): probe core + no-shuffle join stage for indexed Lance vector join (Spark 4.2) Sep 6, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-decision Latest Gatekeeper review requires a maintainer decision. label Sep 6, 2026

@lance-gatekeeper lance-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: maintainer decision required.

This revision folds the no-shuffle join stage from #799 into the foundation, but it does not resolve the open feature-agreement question. The scope record in #798 remains an author proposal rather than a verified maintainer decision.

Accepting the direction would land the probe with its only caller while Catalyst integration follows; deferring avoids maintaining an otherwise unused Spark 4.2 module. Maintainers should choose that scope explicitly. The prior probe-core _score fallback risk remains bounded, but it does not settle this product choice.

@lance-gatekeeper lance-gatekeeper Bot added the K-decision Latest Gatekeeper review requires a maintainer decision. label Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request K-decision Latest Gatekeeper review requires a maintainer decision.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants