Full-Text Search SQL extension #631
Replies: 3 comments 8 replies
|
Thanks for putting this together! I think the proposal makes sense, but my biggest concern is introducing an orthogonal API to perform these operations. We're not a large project and I fear the maintenance burden of maintaining two APIs that solve the same problem. Can we succinctly summarize the advantages of this over the existing TVF approach? From what I can tell most of the advantages of this approach can be implementing using the existing TVF, they just have not yet been. The exception is using FTS as a scalar boolean in arbitrary expressions (ex. |
|
Had a little time today to look at this and I actually quite like the direction this is going. Apologies for the Claude plan output, but I made sure it was concise enough (but hopefully actionable) that it doesn't read too heavily. Basically, I think the goals are to:
This way the existing TVF becomes a very lightweight implementation and the proposed predicate approach provides a little more flexibility in terms of syntax. Would appreciate any feedback, and from there we can decide a bit on how this gets done. I should have some cycles coming up and would like to contribute where I can. The main divergence from the proposal is the execution model. The proposal keeps the predicate path local-only (JNI Unchanged from the proposal
Where it changed
Concrete shape1. One query model + converter. Keep
2. One scan spec, carried by the existing options plumbing. The predicate rule already serializes the query into relation options under
3. Backend dispatch in the scan/partition reader. Selector is the
4. Filters + limit (correctness). Reuse 5. Suggested build orderEach step is independently reviewable; the
Upstream dependencyOnly one, and only for distributing the namespaced query path: a fragment subset on Notes / open
|
|
@ivscheianu @wombatu-kun thanks for all the work on this! Just merged #501. If I recall the plan is to follow up on some of the remaining work like surfacing score and supporting COUNT(*). What do we think this looks like? |
Uh oh!
There was an error while loading. Please reload this page.
Summary
This proposes a Full-Text Search (FTS) SQL extension that exposes full-text relevance as a
WHEREpredicate over Lance tables, complementing the existingSEARCH()/VECTOR_SEARCH()/HYBRID_SEARCH()table functions. It adds three predicate functions -lance_match,lance_phrase,lance_multi_match- a projectable_scorerelevance column,COUNT(*)evaluated through the FTS index, and a correct globalORDER BY _score ... LIMIT kranked-retrieval path. It targets all supported Spark/Scala combinations (3.4, 3.5, 4.0, 4.1; Scala 2.12/2.13) and reuses existing infrastructure: the DataSource V2 pushdown hub, the optimizer-rule injection pattern already used by the blob rules, inline metadata columns, and Lance's native inverted-index FTS.Sponsorship and collaboration
Following the Contribution Workflow, this asks @hamersaw to sponsor the design. @ivscheianu has prototyped much of the predicate surface described here and is invited to co-develop, so the work lands as one shared implementation rather than parallel efforts.
1. Motivation
Users want full-text relevance as an ordinary SQL predicate, not only as a dedicated search source. The two shapes are genuinely different:
SELECT ... FROM SEARCH('lance.db.docs', 'vector database', 10)returns the globally top-k most relevant rows. Ideal when ranking is the query.SELECT ... FROM docs WHERE lance_match(body, 'vector database') AND year >= 2024 ...treats relevance as one filter among many, composable with joins, additionalWHEREconjuncts,GROUP BY,COUNT(*), and window functions. The table-function surface cannot express "filter this pipeline by relevance" without forcing search to be the leaf operator.Both shapes share the same Lance inverted index; this proposal adds the predicate shape that is currently missing.
2. How it works today
2.1 The search table functions
SEARCH,VECTOR_SEARCH, andHYBRID_SEARCHare registered viaSparkSessionExtensions.injectTableFunction(org.lance.spark.search.LanceSearchTableFunctions). They build aLanceSearchQuery(SearchType.FULL_TEXT/VECTOR) and call the namespace APInamespace.queryTable(QueryTableRequest), reading the Arrow IPC response. The scan is single-partition (LanceSearchScanreturns one partition;LanceSearchColumnarPartitionReaderissues onequeryTablecall), which is what makes the returned top-k a correct global ranking.SEARCHappends a_scorecolumn;VECTOR_SEARCHappends_distance;HYBRID_SEARCHappends_score,_distance, and a reciprocal-rank-fusion_relevance_scorecomputed in Spark.2.2 FTS index creation
The extension grammar already supports
ALTER TABLE t CREATE INDEX name USING fts (col) WITH (...), executed byAddIndexExecagainst LanceIndexType.INVERTED, with optionsbase_tokenizer,language,stem,remove_stop_words,ascii_folding,lower_case,max_token_length, andwith_position(required for phrase queries). FTS index creation is therefore out of scope here; this proposal is purely query-side and assumes an inverted index exists on the searched column(s).2.3 The gap
Nothing on
mainlets you write FTS in aWHEREclause: the only FTS surface is the table-function form, which forces search to be the leaf operator and cannot serve as one predicate among many.3. Proposed design
3.1 SQL surface
Three Lance-namespaced scalar predicate functions, valid only inside a top-level
ANDconjunction of aWHEREover a Lance table:The
optionsargument is akey=value,key=valuestring mapped onto Lance'sFullTextQuery.match(text, column, boost, Optional<fuzziness>, maxExpansions, Operator, prefixLength):operatorAND|ORORfuzzinessprefix_lengthmax_expansionsboost_score, not which rows match)lance_multi_matchacceptsoperatorandboostin its options and matches the query against each listed column.lance_phrasetakes a positionalslopinteger (maximum token gap) rather than an options map.Examples:
Hard constraints, enforced fail-fast at planning time: at most one FTS predicate per query, in the top-level
AND(FTS underOR/NOTis rejected, because Lance applies one FTS query per scan);col/queryare string literals;_scoreis selectable only when an FTS predicate is active (3.4).3.2 Predicate functions and the optimizer rule
The functions are
UnboundFunctionsentinels: they resolve and type-check as real catalog functions so the analyzer acceptsWHERE lance_match(body,'x'), but theirproduceResult(...)throws ("lance_match must be pushed down; enable the Lance SQL extension and keep the predicate in a top-level AND"). They are markers consumed by an optimizer rule and never evaluate row-by-row; if a predicate is not stripped, the user gets a precise error instead of silently-wrong results. This is the same sentinel contract the existingSEARCH()table functions rely on.A single Catalyst optimizer rule does the rewrite. Walking the entire condition tree, it validates fail-fast at planning time (rejecting any FTS call under
OR/NOT, enforcing the single-predicate and literal-argument rules, and validating_scoreco-occurrence), builds a LanceFullTextQueryfrom the function and its options, serializes it into the relation's options map, removes the FTS conjunct from theFilter(residual non-FTS conjuncts stay and flow normally toSupportsPushDownV2Filters), and rewrites the relation viarelation.copy(options = ...). Using the named-argumentcopy(options = ...)form (the same approach the merged blob-context rules use) keeps the rule portable across the Spark-version differences in theDataSourceV2RelationAPI.The rule is injected with
injectOptimizerRule, landing in thepreCBObatch afterV2ScanRelationPushDown(matching the precedent set by the merged blob rules). Encoding FTS into the relation'soptionsrather than into a transientFiltershape is deliberate: Spark runs optimizer batches to a fixed point, so the subsequent pushdown re-run re-creates the scan with FTS attached, and column pruning, limit, top-N, aggregate pushdown, and metadata-column resolution all observe it.End-to-end flow for
SELECT id, body, _score FROM docs WHERE lance_match(body, 'vector database', 'operator=AND') ORDER BY _score DESC LIMIT 10:The FTS query is threaded as
Optional<FtsQuerySpec>throughLanceScanBuilder -> LanceScan -> LanceInputPartition -> LanceFragmentScanner, and applied via the Lance APIScanOptions.Builder.fullTextQuery(org.lance.ipc.FullTextQuery).3.3
_scoreas a metadata column_scoreis exposed as an inline staticMetadataColumnonLanceDataset(name_score,FloatType, nullable), alongside the existingROW_ID_COLUMN/ROW_ADDRESS_COLUMN/FRAGMENT_ID_COLUMN, and included inmetadataColumns(). As a metadata column it is hidden fromSELECT *, preserving backward compatibility. The value is produced by Lance's scanner: settingScanOptions.fullTextQuery(...)makes Lance autoproject the relevance column, and the reader maps it into the_scoreoutput slot - there is no Spark-side BM25. Because_scoreis meaningful only with an active FTS query,metadataColumns()always advertises it (so the analyzer can resolve it), and the optimizer rule validates that any projected_scoreco-occurs with an FTS predicate on the same relation, otherwise raising a clearAnalysisException.3.4
COUNT(*)through the FTS indexLanceScanBuilder.pushAggregationtoday short-circuitsCOUNT(*)to a manifest metadata fast-path only when there are no pushed predicates. With an active FTS query the manifest total ignores FTS selectivity, so the fast-path is bypassed and the count is computed through the index. Preferred path: a driver-side count using the Lance primitivesDataset.countIndexedRows(column, query, Optional<fragmentIds>)/Dataset.countRows(filter), returned as a single-row local scan with no executors. Fallback (partial or untrained index, wherecountIndexedRowswould undercount): a per-fragment scan-based count inLanceCountStarPartitionReader, which setsfullTextQuery(...)and usesscanner.countRows()per fragment, summed by Spark. The driver chooses based on index coverage.3.5 Ranked retrieval and global top-k
Target:
SELECT ..., _score FROM t WHERE lance_match(col,'q') ORDER BY _score DESC LIMIT k. This is the capability no current implementation provides, and the one with a real correctness subtlety.Mechanism.
LanceScanBuilderalready implementsSupportsPushDownTopN. We extend it: when FTS is active and the sole sort key is_score DESC, accept the push, ask each fragment for its local top-k by score (limit(k)+setColumnOrderings([_score DESC])), and let Spark merge the at-mostk x numFragmentsrows into the global top-k (isPartiallyPushed()stays true, so Spark keeps the global sort/limit above the scan). The merge input is bounded and cheap.Correctness across fragments (an explicit Lance-core dependency). BM25 score is
sum over terms of IDF(t) * tf-saturation, andIDF(t)depends on corpus-level document countNand document frequencydf(t). If each fragment's scan computes IDF from only its own documents, per-fragment_scorevalues are not comparable, and a rawk x numFragmentsmerge is not a correct global top-k. A correct distributed merge requires Lance to score each fragment's rows against dataset-global corpus statistics carried by the dataset-level inverted index. We propose to state this as a Lance-core invariant (C1): per-fragment FTS scans must score using global IDF.Default that is correct today. Until C1 is guaranteed,
ORDER BY _score DESC LIMIT kdefaults to a single-partition, dataset-level ranked scan (Dataset.newScanwithfullTextQuery,limit=k, score ordering) - structurally the same single-partition shape theSEARCH()functions already use, so it is correct by construction. The distributed per-fragment top-k (the parallel path above) is opt-in (fts.distributed_topn=true) and gated on a Lance-core capability check for global IDF. This gives correct ranking out of the box with a clean, flag-gated upgrade to distributed ranking once Lance core confirms C1.Robustness of
ORDER BY _score. A naive predicate-path implementation fails onORDER BY _score DESC: Spark inserts a range-partitioning exchange whoseRangePartitioner.sketchruns a sampling scan to choose bounds, and that sampling scan re-plans the relation outside the path that attached FTS, so the scan is rebuilt without the FTS query and_scoreis "requested without an active FTS query". The fix follows from 3.2: because the FTS query is encoded in the relationoptionsand stored onLanceScan(and included inequals/hashCode), it is intrinsic to the scan and re-materialized identically by everynewScanBuildercall, including the sampling scan,ReusedExchange, and AQE re-optimization. No rule needs to re-fire during sampling. For the unboundedORDER BY _scorecase, the single-partition global scan also removes the range exchange entirely.3.6 Complementarity with the search table functions
SEARCH()table functionsWHERE,GROUP BY, windows; FTS is one predicate among manyqueryTable)COUNT(*)of matches, hybrid SQLThe two stay complementary. For
WHERE lance_match(...)without score ordering (the filter use case) the distributed path is always used - the unique value this adds. ForWHERE lance_match(...) ORDER BY _score DESC LIMIT kthe V2 scan builder internally chooses the single-partition global ranked Lance scan, but does not rewrite the query into aSEARCH()table function, which would change semantics for residual predicates and conflate the two surfaces.3.7 Design decisions and rejected alternatives
relation.copy(options = ...). The named-argument form is portable across the Spark-version differences in theDataSourceV2RelationAPI, as already proven by the merged blob-context rules._scoreas a metadata column, not a schema decorator. A decorator (as used for_distanceviaLanceVirtualColumnsTable) would promote_scoreinto the baseschema()and regressSELECT *.SupportsMetadataColumnskeeps it hidden by default.AnalysisExceptionat planning time.4. Who is affected
lance_match/lance_phrase/lance_multi_match, a projectable_score, FTS-awareCOUNT(*), and ranked retrieval. Fully backward compatible:SELECT *is unchanged (_scoreis hidden), and existing queries are unaffected.docs/src/operations/dql/fts.md, mirroringsearch.md, cross-linked with the FTS index page and withsearch.md(when to use predicate FTS vsSEARCH()).5. Risks and mitigations
relation.copy(options = ...)(named arg), proven version-portable by the merged blob-context rules; compile/test matrix on 3.4/3.5/4.0/4.1.V2ScanRelationPushDownoptionsso the fixed-point pushdown re-run re-creates the scan with FTS; assert via tests that pruning/limit/topN/aggregate observe FTS.AnalysisExceptions; sentinelproduceResultthrows rather than silently mis-evaluating._scorein sampling / reused / AQE plansLanceScanwithequals/hashCode; regression test reproducing theRangePartitioner.sketchfailure.LIMITwithout order returns arbitrary k matches (documented); keepisPartiallyPushed()true; bypass count fast-path under FTS; residual predicates combine with FTS in oneScanOptions.ACCEPT_ANY_SCHEMAcolumnscountIndexedRows) into statistics later. Out of scope for the first PRs.6. Incremental implementation plan
Small, independently reviewable PRs, ordered so each lands value and de-risks the next.
lance_matchsentinel, option parsing, the optimizer rule (strip + validate + serialize into options), and threadingOptional<FtsQuerySpec>toLanceFragmentScanner.fullTextQuery(...); register the function and rule in everyLanceSparkSessionExtensions.lance_phrase+lance_multi_matchwithslopand per-column matching._scoremetadata column (unordered): inlineSCORE_COLUMN, conditional exposure, map Lance's autoprojected score; verifySELECT *excludes it and standalone_scoreis rejected.COUNT(*)through the FTS index: bypass the metadata fast-path under FTS; prefercountIndexedRows/countRows, fallback to per-fragment count.ORDER BY _score DESC LIMIT kto a single-partition ranked Lance scan.RangePartitioner.sketchrobustness + unboundedORDER BY _score: prove FTS survives sampling/reused/AQE because it is intrinsic toLanceScan; add the regression test.pushTopNfor_score; gate behindfts.distributed_topnand a Lance-core capability check. Last and flag-gated.docs/src/operations/dql/fts.md, cross-linked with the FTS index and search pages.7. Open questions for sponsors and reviewers
fullTextQueryscans against dataset-global corpus statistics today, or per-fragment? This decides whether the distributed top-k push is "enable a flag" or "blocked on upstream"._scoreis not projected (a pure filter), can scoring be turned off with guaranteed-identical matching, to skip score computation?ORDER BY _score DESC LIMIT kshould default to the single-partition global scan (correct, lower parallelism) rather than distributed-by-default with a caveat?_scoreexposure model: advertise_scorealways and validate co-occurrence in the rule (simpler analysis), or advertise it only once the rule has attached FTS (stricter, couplesmetadataColumns()to optimizer state)?FullTextQuery.booleanQuery/Occur{SHOULD,MUST,MUST_NOT}, or hard-cap at one for v1?lance_matchonly under the Lance catalog (lance.lance_match), or also session-wide for the bare name (collision risk with user UDFs)?SEARCH(): is internal delegation to a dataset-level ranked scan acceptable, or should ranked queries be documented as "useSEARCH()" and the predicate surface stay filter-only?All reactions