github-actions[bot] commented on code in PR #67289:
URL: https://github.com/apache/doris/pull/67289#discussion_r3957156051


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownFilterThroughVectorSearchTopN.java:
##########
@@ -23,33 +23,35 @@
 import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
 import org.apache.doris.nereids.trees.plans.logical.LogicalTVFRelation;
 import org.apache.doris.nereids.trees.plans.logical.LogicalTopN;
+import org.apache.doris.tablefunction.FullTextSearchTableValuedFunction;
 import org.apache.doris.tablefunction.VectorSearchTableValuedFunction;
 
 /**
- * Move an outer vector_search WHERE predicate below its Doris merge TopN.
+ * Move an outer Lance external-search WHERE predicate below its Doris merge 
TopN.
  *
- * <p>The TopN immediately above a vector_search TVF is added by {@code 
BindExpression} to merge
+ * <p>The TopN immediately above a search TVF is added by {@code 
BindExpression} to merge
  * the candidates returned by all Lance fragment scans. The SQL WHERE 
predicate must therefore be
  * evaluated below this TopN so it can become a residual conjunct on the Doris 
Lance scan node:
  *
  * <pre>
  * Filter                         TopN
  *   TopN            ->            Filter
- *     vector_search                 vector_search
+ *     search TVF                    search TVF
  * </pre>
  *
- * <p>This remains a postfilter relative to Lance nearest(): every fragment 
first returns its ANN
- * candidates, and Doris filters those candidates before the local/global 
TopN. It is deliberately
- * not converted into the Lance prefilter carried by the TVF's {@code filter} 
property.
+ * <p>This remains a postfilter relative to the Lance search: every split 
first returns candidates,
+ * and Doris filters those candidates before the local/global TopN. It is 
deliberately not
+ * converted into the Lance prefilter carried by the TVF's {@code filter} 
property.
  */
 public class PushDownFilterThroughVectorSearchTopN extends 
OneRewriteRuleFactory {
     @Override
     public Rule build() {
         return logicalFilter(logicalTopN(logicalTVFRelation()))
                 .then(filter -> {
                     LogicalTopN<LogicalTVFRelation> topN = filter.child();
-                    if (!VectorSearchTableValuedFunction.NAME.equals(
-                            topN.child().getFunction().getName())) {
+                    String functionName = topN.child().getFunction().getName();
+                    if 
(!VectorSearchTableValuedFunction.NAME.equals(functionName)
+                            && 
!FullTextSearchTableValuedFunction.NAME.equals(functionName)) {

Review Comment:
   [P1] Keep the outer WHERE above FTS's global TopN. Every physical FTS split 
is already capped at `top_k + offset` before Doris evaluates this residual 
filter. For `top_k=1`, if the highest-scoring row fails the predicate and the 
runner-up passes, one segment returns only the rejected row (empty result), 
while placing those rows in two physical segments returns both split-local 
candidates and the runner-up survives. Thus index maintenance alone can change 
the SQL result. Either leave a post-search WHERE above the synthetic 
snapshot-wide TopN, or make it a true Lance prefilter/otherwise overfetch until 
the global filtered TopN is complete; please cover the one-vs-two-segment case.



##########
be/src/format_v2/table/lance_reader.cpp:
##########
@@ -561,6 +689,41 @@ Status LanceTableReader::_open_dataset(const DatasetKey& 
key) {
     return Status::OK();
 }
 
+Status LanceTableReader::_prepare_fts_query_context() {
+    DORIS_CHECK(_dataset != nullptr);
+    DORIS_CHECK(_fts_query_context == nullptr);
+    DORIS_CHECK(_scan_params != nullptr);
+    const auto& full_text =
+            
_scan_params->lance_scan_params.external_search_request.search_query.full_text_search;
+    if (full_text.__isset.global_statistics) {
+        return Status::NotSupported(
+                "Lance FE-provided FTS global statistics require a lance-c 
consumer API");
+    }
+    const auto coverage_mode = full_text.coverage_mode == 
TFtsCoverageMode::STRICT
+                                       ? LANCE_FTS_COVERAGE_STRICT
+                                       : LANCE_FTS_COVERAGE_INDEX_ONLY;
+    // Keep statistics preparation at the reader/scanner lifetime today. A 
future FE-provided
+    // opaque statistics payload should enter through this boundary and create 
the same context,
+    // leaving segment-scoped scanner execution unchanged.
+    if (full_text.query_type == TFtsQueryType::MATCH) {
+        const auto match_operator = full_text.match_operator == 
TFtsMatchOperator::AND
+                                            ? LANCE_FTS_MATCH_OPERATOR_AND
+                                            : LANCE_FTS_MATCH_OPERATOR_OR;
+        _fts_query_context = lance_dataset_prepare_fts_match_query(

Review Comment:
   [P1] Exclude snapshot-pruned documents from the global BM25 scorer. Lance 
updates/rewrites remove changed fragments from `IndexMetadata.fragment_bitmap` 
while retaining the physical scalar segment; its search prefilter blocks those 
stale rows from output, but the prepared context used here builds `num_docs`, 
`total_tokens`, and term document frequencies from every raw physical index 
before that mask is applied. Stale documents can therefore change average 
document length/IDF and even reverse the ranking of live rows in the pinned 
snapshot. Please build statistics from each segment's effective current 
fragment coverage (or pass snapshot-scoped global statistics through the 
reserved contract), with an update/rewrite regression against a clean rebuilt 
index.



##########
be/src/format_v2/table/lance_reader.cpp:
##########
@@ -533,6 +657,10 @@ Status LanceTableReader::_ensure_dataset_open(const 
TFileRangeDesc& range) {
         return Status::InvalidArgument(
                 "Lance reader cannot mix dataset snapshots or storage 
options");
     }
+    if (_search_kind == SearchKind::FULL_TEXT && prepare_fts_context &&

Review Comment:
   [P2] Share FTS preparation across local scanners. FileScanLocalState can 
start up to 16 FileScannerV2 instances, each with its own LanceTableReader, so 
this branch makes every active scanner independently open the same snapshot, 
enumerate every committed FTS segment, and rebuild the corpus-wide BM25 context 
before scanning its assigned split. With N segment splits (up to the scanner 
cap), that becomes N whole-index preparations and up to N-by-N segment opens 
for one BE query. Please introduce a query/scan-local owner for the exact 
dataset snapshot and prepared context (or an equivalent shared-session cache), 
then attach it to the local segment scanners; expose preparation time/count and 
cover multi-scanner execution.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java:
##########
@@ -321,12 +352,44 @@ private Optional<List<Split>> 
createIndexSegmentSplits(LanceTableMetadata metada
         return Optional.of(plan.buildSplits());
     }
 
-    private static List<LanceIndexSegmentInfo> selectIndexSegments(
-            List<LanceIndexSegmentInfo> indexSegments, int vectorFieldId) {
+    private List<Split> createFullTextIndexSegmentSplits(LanceTableMetadata 
metadata,
+            Map<Long, LanceFragmentInfo> visibleFragments) throws 
UserException {
+        TFullTextSearchParams fullText =
+                externalSearchRequest.getSearchQuery().getFullTextSearch();
+        if (searchFieldId < 0) {
+            throw new UserException("Lance full-text column '" + 
fullText.getColumn()
+                    + "' has no field ID in the Lance schema");
+        }
+        List<LanceIndexSegmentInfo> matchingSegments = 
selectFullTextIndexSegments(
+                metadata.getIndexSegments(), searchFieldId, 
fullText.getColumn());
+        if (matchingSegments.isEmpty()) {
+            throw new UserException("No committed Lance FTS index exists for 
column '"
+                    + fullText.getColumn() + "' at dataset version " + 
metadata.getVersion());
+        }
+        IndexSegmentSplitPlan plan = planIndexSegments(
+                metadata, matchingSegments, visibleFragments, true)
+                .orElseThrow(() -> new UserException("Lance FTS index for 
column '"

Review Comment:
   [P2] Return an empty result for INDEX_ONLY with no visible coverage. 
`planIndexSegments()` filters segment coverage against the pinned snapshot and 
returns `Optional.empty()` when all matching committed segments cover zero 
visible fragments, so this unconditional `orElseThrow()` runs before the 
coverage-mode branch. For `coverage_mode=index_only`, searching only the 
indexed subset when that subset is empty should produce zero rows; the generic 
file scan path supports zero splits and Lance-C explicitly supports an empty 
selected FTS segment. As written, a valid query fails after indexed fragments 
are replaced/compacted out of the snapshot. Please preserve the distinction 
between 'no matching index' and 'matching index with no visible coverage', 
return zero splits for INDEX_ONLY, and add a regression for this case.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to