This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new f4c9efe2e0 [core] Support full mode for full-text search (#8316)
f4c9efe2e0 is described below
commit f4c9efe2e05ed682e9f512521054ab11ec916695
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Jun 22 15:41:02 2026 +0800
[core] Support full mode for full-text search (#8316)
Support non-FAST global index search modes for full-text search by
searching both existing full-text index files and raw row ranges that
are not covered by those indexes. `FULL` uses the snapshot row-id range
and `DETAIL` uses data-file row-id ranges through `GlobalIndexCoverage`,
while `FAST` remains index-only. Python full-text search now fails fast
for non-FAST raw full-text fallback because it would need to rebuild
temporary full-text indexes, which is not implemented there yet.
---
.../paimon/globalindex/GlobalIndexCoverage.java | 36 +-
.../paimon/table/source/FullTextReadImpl.java | 102 +++-
.../paimon/table/source/FullTextScanImpl.java | 12 +-
.../table/source/FullTextSearchBuilderImpl.java | 2 +-
.../paimon/table/source/FullTextSearchSplit.java | 78 +--
...rchSplit.java => IndexFullTextSearchSplit.java} | 59 ++-
.../paimon/table/source/RawFullTextReadImpl.java | 521 +++++++++++++++++++++
.../table/source/RawFullTextSearchSplit.java | 61 +++
.../table/source/FullTextSearchBuilderTest.java | 242 +++++++++-
.../pypaimon/globalindex/global_index_coverage.py | 14 +-
.../pypaimon/table/source/full_text_scan.py | 15 +
paimon-python/pypaimon/tests/global_index_test.py | 16 +
.../pypaimon/tests/vector_search_filter_test.py | 29 +-
.../index/TantivyFullTextGlobalIndexerFactory.java | 7 +-
.../TantivyFullTextGlobalIndexerFactoryTest.java | 53 +++
15 files changed, 1109 insertions(+), 138 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexCoverage.java
b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexCoverage.java
index 6ef52eee2d..5191d0f942 100644
---
a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexCoverage.java
+++
b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexCoverage.java
@@ -82,24 +82,7 @@ public class GlobalIndexCoverage {
return unindexedRanges(Collections.singleton(fieldId));
}
- private void addCoverage(int fieldId, Range range) {
- coverageByField.computeIfAbsent(fieldId, k -> new
ArrayList<>()).add(range);
- }
-
- private List<Range> indexedRanges(Collection<Integer> fieldIds) {
- List<Range> ranges = null;
- for (Integer fieldId : fieldIds) {
- List<Range> fieldRanges = coverageByField.get(fieldId);
- if (fieldRanges == null || fieldRanges.isEmpty()) {
- return Collections.emptyList();
- }
- fieldRanges = Range.sortAndMergeOverlap(fieldRanges, true);
- ranges = ranges == null ? fieldRanges : Range.and(ranges,
fieldRanges);
- }
- return ranges == null ? Collections.emptyList() :
Range.sortAndMergeOverlap(ranges, true);
- }
-
- private List<Range> unindexedRanges(Collection<Integer> fieldIds) {
+ public List<Range> unindexedRanges(Collection<Integer> fieldIds) {
GlobalIndexSearchMode searchMode =
table.coreOptions().globalIndexSearchMode();
if (searchMode == GlobalIndexSearchMode.FAST) {
return Collections.emptyList();
@@ -124,6 +107,23 @@ public class GlobalIndexCoverage {
return Range.sortAndMergeOverlap(unindexedRanges, true);
}
+ private void addCoverage(int fieldId, Range range) {
+ coverageByField.computeIfAbsent(fieldId, k -> new
ArrayList<>()).add(range);
+ }
+
+ private List<Range> indexedRanges(Collection<Integer> fieldIds) {
+ List<Range> ranges = null;
+ for (Integer fieldId : fieldIds) {
+ List<Range> fieldRanges = coverageByField.get(fieldId);
+ if (fieldRanges == null || fieldRanges.isEmpty()) {
+ return Collections.emptyList();
+ }
+ fieldRanges = Range.sortAndMergeOverlap(fieldRanges, true);
+ ranges = ranges == null ? fieldRanges : Range.and(ranges,
fieldRanges);
+ }
+ return ranges == null ? Collections.emptyList() :
Range.sortAndMergeOverlap(ranges, true);
+ }
+
private List<Range> dataRangesByDataFiles() {
SnapshotReader snapshotReader =
table.newSnapshotReader()
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
index e6e93b5211..97609ba564 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
@@ -18,7 +18,6 @@
package org.apache.paimon.table.source;
-import org.apache.paimon.fs.FileIO;
import org.apache.paimon.globalindex.GlobalIndexIOMeta;
import org.apache.paimon.globalindex.GlobalIndexReadThreadPool;
import org.apache.paimon.globalindex.GlobalIndexReader;
@@ -31,13 +30,17 @@ import
org.apache.paimon.globalindex.io.GlobalIndexFileReader;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.predicate.FullTextQuery;
import org.apache.paimon.predicate.FullTextSearch;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.types.DataField;
import org.apache.paimon.utils.IOUtils;
+import org.apache.paimon.utils.Range;
import org.apache.paimon.utils.RoaringNavigableMap64;
+import javax.annotation.Nullable;
+
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -54,18 +57,29 @@ import static
org.apache.paimon.utils.Preconditions.checkNotNull;
public class FullTextReadImpl implements FullTextRead {
private final FileStoreTable table;
+ @Nullable private final PartitionPredicate partitionFilter;
private final int limit;
private final List<DataField> textColumns;
private final FullTextQuery query;
public FullTextReadImpl(
FileStoreTable table, int limit, DataField textColumn,
FullTextQuery query) {
- this(table, limit, Collections.singletonList(textColumn), query);
+ this(table, null, limit, Collections.singletonList(textColumn), query);
}
public FullTextReadImpl(
FileStoreTable table, int limit, List<DataField> textColumns,
FullTextQuery query) {
+ this(table, null, limit, textColumns, query);
+ }
+
+ public FullTextReadImpl(
+ FileStoreTable table,
+ @Nullable PartitionPredicate partitionFilter,
+ int limit,
+ List<DataField> textColumns,
+ FullTextQuery query) {
this.table = table;
+ this.partitionFilter = partitionFilter;
this.limit = limit;
this.textColumns = Collections.unmodifiableList(new
ArrayList<>(textColumns));
this.query = query;
@@ -87,20 +101,43 @@ public class FullTextReadImpl implements FullTextRead {
fieldsByName.put(textColumn.name(), textColumn);
}
- Map<String, List<FullTextSearchSplit>> splitsByColumn = new
HashMap<>();
+ Map<String, List<IndexFullTextSearchSplit>> splitsByColumn = new
HashMap<>();
+ List<Range> rawRowRanges = new ArrayList<>();
for (FullTextSearchSplit split : splits) {
- splitsByColumn.computeIfAbsent(split.columnName(), k -> new
ArrayList<>()).add(split);
+ if (split instanceof IndexFullTextSearchSplit) {
+ IndexFullTextSearchSplit indexSplit =
(IndexFullTextSearchSplit) split;
+ splitsByColumn
+ .computeIfAbsent(indexSplit.columnName(), k -> new
ArrayList<>())
+ .add(indexSplit);
+ } else if (split instanceof RawFullTextSearchSplit) {
+ rawRowRanges.addAll(((RawFullTextSearchSplit)
split).rowRanges());
+ }
}
- return evalQuery(query, fieldsByName, splitsByColumn,
indexPathFactory, executor)
- .topK(limit);
+ GlobalIndexFileReader indexFileReader = m ->
table.fileIO().newInputStream(m.filePath());
+ ScoredGlobalIndexResult result =
+ evalQuery(
+ query,
+ fieldsByName,
+ splitsByColumn,
+ indexPathFactory,
+ indexFileReader,
+ executor);
+ if (!rawRowRanges.isEmpty()) {
+ result =
+ new RawFullTextReadImpl(table, partitionFilter, limit,
query, this::evalQuery)
+ .withRawSearch(
+ result, rawRowRanges, fieldsByName,
splitsByColumn, executor);
+ }
+ return result.topK(limit);
}
- private ScoredGlobalIndexResult evalQuery(
+ ScoredGlobalIndexResult evalQuery(
FullTextQuery query,
Map<String, DataField> fieldsByName,
- Map<String, List<FullTextSearchSplit>> splitsByColumn,
+ Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
IndexPathFactory indexPathFactory,
+ GlobalIndexFileReader indexFileReader,
ExecutorService executor) {
if (query instanceof FullTextQuery.Match) {
return evalColumnQuery(
@@ -109,6 +146,7 @@ public class FullTextReadImpl implements FullTextRead {
fieldsByName,
splitsByColumn,
indexPathFactory,
+ indexFileReader,
executor);
}
if (query instanceof FullTextQuery.Phrase) {
@@ -118,6 +156,7 @@ public class FullTextReadImpl implements FullTextRead {
fieldsByName,
splitsByColumn,
indexPathFactory,
+ indexFileReader,
executor);
}
if (query instanceof FullTextQuery.MultiMatch) {
@@ -126,6 +165,7 @@ public class FullTextReadImpl implements FullTextRead {
fieldsByName,
splitsByColumn,
indexPathFactory,
+ indexFileReader,
executor);
}
if (query instanceof FullTextQuery.Boost) {
@@ -136,12 +176,14 @@ public class FullTextReadImpl implements FullTextRead {
fieldsByName,
splitsByColumn,
indexPathFactory,
+ indexFileReader,
executor),
evalQuery(
boost.negative(),
fieldsByName,
splitsByColumn,
indexPathFactory,
+ indexFileReader,
executor),
boost.negativeBoost());
}
@@ -151,6 +193,7 @@ public class FullTextReadImpl implements FullTextRead {
fieldsByName,
splitsByColumn,
indexPathFactory,
+ indexFileReader,
executor);
}
throw new IllegalArgumentException("Unsupported full-text query: " +
query);
@@ -159,8 +202,9 @@ public class FullTextReadImpl implements FullTextRead {
private ScoredGlobalIndexResult evalMultiMatch(
FullTextQuery.MultiMatch query,
Map<String, DataField> fieldsByName,
- Map<String, List<FullTextSearchSplit>> splitsByColumn,
+ Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
IndexPathFactory indexPathFactory,
+ GlobalIndexFileReader indexFileReader,
ExecutorService executor) {
List<String> columns = query.columns();
List<Float> boosts = query.boosts();
@@ -182,6 +226,7 @@ public class FullTextReadImpl implements FullTextRead {
fieldsByName,
splitsByColumn,
indexPathFactory,
+ indexFileReader,
executor));
}
return or(results).topK(limit);
@@ -190,20 +235,33 @@ public class FullTextReadImpl implements FullTextRead {
private ScoredGlobalIndexResult evalBoolean(
FullTextQuery.BooleanQuery query,
Map<String, DataField> fieldsByName,
- Map<String, List<FullTextSearchSplit>> splitsByColumn,
+ Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
IndexPathFactory indexPathFactory,
+ GlobalIndexFileReader indexFileReader,
ExecutorService executor) {
ScoredGlobalIndexResult result = null;
for (FullTextQuery child : query.must()) {
ScoredGlobalIndexResult childResult =
- evalQuery(child, fieldsByName, splitsByColumn,
indexPathFactory, executor);
+ evalQuery(
+ child,
+ fieldsByName,
+ splitsByColumn,
+ indexPathFactory,
+ indexFileReader,
+ executor);
result = result == null ? childResult : and(result, childResult);
}
List<ScoredGlobalIndexResult> shouldResults = new
ArrayList<>(query.should().size());
for (FullTextQuery child : query.should()) {
shouldResults.add(
- evalQuery(child, fieldsByName, splitsByColumn,
indexPathFactory, executor));
+ evalQuery(
+ child,
+ fieldsByName,
+ splitsByColumn,
+ indexPathFactory,
+ indexFileReader,
+ executor));
}
if (!shouldResults.isEmpty()) {
ScoredGlobalIndexResult shouldResult = or(shouldResults);
@@ -215,7 +273,13 @@ public class FullTextReadImpl implements FullTextRead {
}
for (FullTextQuery child : query.mustNot()) {
ScoredGlobalIndexResult childResult =
- evalQuery(child, fieldsByName, splitsByColumn,
indexPathFactory, executor);
+ evalQuery(
+ child,
+ fieldsByName,
+ splitsByColumn,
+ indexPathFactory,
+ indexFileReader,
+ executor);
result = andNot(result, childResult);
}
return result.topK(limit);
@@ -225,10 +289,11 @@ public class FullTextReadImpl implements FullTextRead {
FullTextQuery query,
String column,
Map<String, DataField> fieldsByName,
- Map<String, List<FullTextSearchSplit>> splitsByColumn,
+ Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
IndexPathFactory indexPathFactory,
+ GlobalIndexFileReader indexFileReader,
ExecutorService executor) {
- List<FullTextSearchSplit> columnSplits = splitsByColumn.get(column);
+ List<IndexFullTextSearchSplit> columnSplits =
splitsByColumn.get(column);
if (columnSplits == null || columnSplits.isEmpty()) {
return ScoredGlobalIndexResult.createEmpty();
}
@@ -253,7 +318,7 @@ public class FullTextReadImpl implements FullTextRead {
List<CompletableFuture<Optional<ScoredGlobalIndexResult>>> futures =
new ArrayList<>(columnSplits.size());
- for (FullTextSearchSplit split : columnSplits) {
+ for (IndexFullTextSearchSplit split : columnSplits) {
futures.add(
eval(
globalIndexer,
@@ -262,6 +327,7 @@ public class FullTextReadImpl implements FullTextRead {
split.rowRangeEnd(),
split.fullTextIndexFiles(),
query,
+ indexFileReader,
executor));
}
@@ -285,6 +351,7 @@ public class FullTextReadImpl implements FullTextRead {
long rowRangeEnd,
List<IndexFileMeta> fullTextIndexFiles,
FullTextQuery query,
+ GlobalIndexFileReader indexFileReader,
ExecutorService executor) {
List<GlobalIndexIOMeta> indexIOMetaList = new ArrayList<>();
for (IndexFileMeta indexFile : fullTextIndexFiles) {
@@ -295,9 +362,6 @@ public class FullTextReadImpl implements FullTextRead {
indexFile.fileSize(),
meta.indexMeta()));
}
- @SuppressWarnings("resource")
- FileIO fileIO = table.fileIO();
- GlobalIndexFileReader indexFileReader = m ->
fileIO.newInputStream(m.filePath());
GlobalIndexReader reader =
globalIndexer.createReader(indexFileReader, indexIOMetaList,
executor);
FullTextSearch fullTextSearch =
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
index 123a8b9980..5fdebadf70 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
@@ -19,6 +19,7 @@
package org.apache.paimon.table.source;
import org.apache.paimon.Snapshot;
+import org.apache.paimon.globalindex.GlobalIndexCoverage;
import org.apache.paimon.globalindex.GlobalIndexerFactory;
import org.apache.paimon.globalindex.GlobalIndexerFactoryUtils;
import org.apache.paimon.index.GlobalIndexMeta;
@@ -121,11 +122,20 @@ public class FullTextScanImpl implements FullTextScan {
columnEntry.getValue().entrySet()) {
Range range = rangeEntry.getKey();
splits.add(
- new FullTextSearchSplit(
+ new IndexFullTextSearchSplit(
columnName, range.from, range.to,
rangeEntry.getValue()));
}
}
+ if (!allIndexFiles.isEmpty()) {
+ List<Range> rawRowRanges =
+ new GlobalIndexCoverage(table, snapshot, partitionFilter,
allIndexFiles)
+ .unindexedRanges(textColumnIds);
+ if (!rawRowRanges.isEmpty()) {
+ splits.add(new RawFullTextSearchSplit(rawRowRanges));
+ }
+ }
+
return () -> splits;
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchBuilderImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchBuilderImpl.java
index f4644005aa..6d136d5ee2 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchBuilderImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchBuilderImpl.java
@@ -71,7 +71,7 @@ public class FullTextSearchBuilderImpl implements
FullTextSearchBuilder {
@Override
public FullTextRead newFullTextRead() {
checkArgument(limit > 0, "Limit must be positive, set via
withLimit()");
- return new FullTextReadImpl(table, limit, textColumns(), query);
+ return new FullTextReadImpl(table, partitionFilter, limit,
textColumns(), query);
}
private List<DataField> textColumns() {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchSplit.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchSplit.java
index 924b95057d..a6e7f86cba 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchSplit.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchSplit.java
@@ -18,84 +18,10 @@
package org.apache.paimon.table.source;
-import org.apache.paimon.index.IndexFileMeta;
-
import java.io.Serializable;
-import java.util.Collections;
-import java.util.List;
-import java.util.Objects;
-/** Split of full-text search. */
-public class FullTextSearchSplit implements Serializable {
+/** Base split of full-text search. */
+public abstract class FullTextSearchSplit implements Serializable {
private static final long serialVersionUID = 1L;
-
- private final String columnName;
- private final long rowRangeStart;
- private final long rowRangeEnd;
- private final List<IndexFileMeta> fullTextIndexFiles;
-
- public FullTextSearchSplit(
- long rowRangeStart, long rowRangeEnd, List<IndexFileMeta>
fullTextIndexFiles) {
- this(null, rowRangeStart, rowRangeEnd, fullTextIndexFiles);
- }
-
- public FullTextSearchSplit(
- String columnName,
- long rowRangeStart,
- long rowRangeEnd,
- List<IndexFileMeta> fullTextIndexFiles) {
- this.columnName = columnName;
- this.rowRangeStart = rowRangeStart;
- this.rowRangeEnd = rowRangeEnd;
- this.fullTextIndexFiles =
Collections.unmodifiableList(fullTextIndexFiles);
- }
-
- public String columnName() {
- return columnName;
- }
-
- public long rowRangeStart() {
- return rowRangeStart;
- }
-
- public long rowRangeEnd() {
- return rowRangeEnd;
- }
-
- public List<IndexFileMeta> fullTextIndexFiles() {
- return fullTextIndexFiles;
- }
-
- @Override
- public boolean equals(Object o) {
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
- FullTextSearchSplit that = (FullTextSearchSplit) o;
- return Objects.equals(columnName, that.columnName)
- && rowRangeStart == that.rowRangeStart
- && rowRangeEnd == that.rowRangeEnd
- && Objects.equals(fullTextIndexFiles, that.fullTextIndexFiles);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(columnName, rowRangeStart, rowRangeEnd,
fullTextIndexFiles);
- }
-
- @Override
- public String toString() {
- return "FullTextSearchSplit{"
- + "columnName='"
- + columnName
- + '\''
- + ", rowRangeStart="
- + rowRangeStart
- + ", rowRangeEnd="
- + rowRangeEnd
- + ", fullTextIndexFiles="
- + fullTextIndexFiles
- + '}';
- }
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchSplit.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/IndexFullTextSearchSplit.java
similarity index 55%
copy from
paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchSplit.java
copy to
paimon-core/src/main/java/org/apache/paimon/table/source/IndexFullTextSearchSplit.java
index 924b95057d..976620e015 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchSplit.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/IndexFullTextSearchSplit.java
@@ -19,28 +19,39 @@
package org.apache.paimon.table.source;
import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.IndexFileMetaSerializer;
+import org.apache.paimon.io.DataInputViewStreamWrapper;
+import org.apache.paimon.io.DataOutputViewStreamWrapper;
-import java.io.Serializable;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
-/** Split of full-text search. */
-public class FullTextSearchSplit implements Serializable {
+/** Split to read full-text index files. */
+public class IndexFullTextSearchSplit extends FullTextSearchSplit {
private static final long serialVersionUID = 1L;
- private final String columnName;
- private final long rowRangeStart;
- private final long rowRangeEnd;
- private final List<IndexFileMeta> fullTextIndexFiles;
+ private static final int VERSION = 1;
- public FullTextSearchSplit(
+ private static final ThreadLocal<IndexFileMetaSerializer> INDEX_SERIALIZER
=
+ ThreadLocal.withInitial(IndexFileMetaSerializer::new);
+
+ private String columnName;
+ private long rowRangeStart;
+ private long rowRangeEnd;
+ private transient List<IndexFileMeta> fullTextIndexFiles;
+
+ public IndexFullTextSearchSplit(
long rowRangeStart, long rowRangeEnd, List<IndexFileMeta>
fullTextIndexFiles) {
this(null, rowRangeStart, rowRangeEnd, fullTextIndexFiles);
}
- public FullTextSearchSplit(
+ public IndexFullTextSearchSplit(
String columnName,
long rowRangeStart,
long rowRangeEnd,
@@ -48,7 +59,7 @@ public class FullTextSearchSplit implements Serializable {
this.columnName = columnName;
this.rowRangeStart = rowRangeStart;
this.rowRangeEnd = rowRangeEnd;
- this.fullTextIndexFiles =
Collections.unmodifiableList(fullTextIndexFiles);
+ this.fullTextIndexFiles = Collections.unmodifiableList(new
ArrayList<>(fullTextIndexFiles));
}
public String columnName() {
@@ -67,15 +78,35 @@ public class FullTextSearchSplit implements Serializable {
return fullTextIndexFiles;
}
+ private void writeObject(ObjectOutputStream out) throws IOException {
+ out.defaultWriteObject();
+ out.writeInt(VERSION);
+ IndexFileMetaSerializer serializer = INDEX_SERIALIZER.get();
+ DataOutputViewStreamWrapper view = new
DataOutputViewStreamWrapper(out);
+ serializer.serializeList(fullTextIndexFiles, view);
+ }
+
+ private void readObject(ObjectInputStream in) throws IOException,
ClassNotFoundException {
+ in.defaultReadObject();
+ int version = in.readInt();
+ if (version != VERSION) {
+ throw new IOException("Unsupported IndexFullTextSearchSplit
version: " + version);
+ }
+ IndexFileMetaSerializer serializer = INDEX_SERIALIZER.get();
+ DataInputViewStreamWrapper view = new DataInputViewStreamWrapper(in);
+ this.fullTextIndexFiles =
+ Collections.unmodifiableList(new
ArrayList<>(serializer.deserializeList(view)));
+ }
+
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) {
return false;
}
- FullTextSearchSplit that = (FullTextSearchSplit) o;
- return Objects.equals(columnName, that.columnName)
- && rowRangeStart == that.rowRangeStart
+ IndexFullTextSearchSplit that = (IndexFullTextSearchSplit) o;
+ return rowRangeStart == that.rowRangeStart
&& rowRangeEnd == that.rowRangeEnd
+ && Objects.equals(columnName, that.columnName)
&& Objects.equals(fullTextIndexFiles, that.fullTextIndexFiles);
}
@@ -86,7 +117,7 @@ public class FullTextSearchSplit implements Serializable {
@Override
public String toString() {
- return "FullTextSearchSplit{"
+ return "IndexFullTextSearchSplit{"
+ "columnName='"
+ columnName
+ '\''
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/RawFullTextReadImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/RawFullTextReadImpl.java
new file mode 100644
index 0000000000..6508dd5751
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/RawFullTextReadImpl.java
@@ -0,0 +1,521 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table.source;
+
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.GlobalIndexWriter;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.GlobalIndexerFactoryUtils;
+import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.FullTextQuery;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.CloseableIterator;
+import org.apache.paimon.utils.IOUtils;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import javax.annotation.Nullable;
+
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Implementation for raw full-text search. */
+class RawFullTextReadImpl {
+
+ private final FileStoreTable table;
+ @Nullable private final PartitionPredicate partitionFilter;
+ private final int limit;
+ private final FullTextQuery query;
+ private final IndexSearch indexSearch;
+
+ RawFullTextReadImpl(
+ FileStoreTable table,
+ @Nullable PartitionPredicate partitionFilter,
+ int limit,
+ FullTextQuery query,
+ IndexSearch indexSearch) {
+ this.table = table;
+ this.partitionFilter = partitionFilter;
+ this.limit = limit;
+ this.query = query;
+ this.indexSearch = indexSearch;
+ }
+
+ ScoredGlobalIndexResult withRawSearch(
+ ScoredGlobalIndexResult indexedResult,
+ List<Range> rawRowRanges,
+ Map<String, DataField> fieldsByName,
+ Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
+ ExecutorService executor) {
+ rawRowRanges = Range.sortAndMergeOverlap(rawRowRanges, true);
+ if (rawRowRanges.isEmpty()) {
+ return indexedResult;
+ }
+
+ ScoredGlobalIndexResult rawResult =
+ readRawSearch(rawRowRanges, fieldsByName, splitsByColumn,
executor);
+ return overrideWithRawSearch(indexedResult, rawRowRanges, rawResult);
+ }
+
+ private ScoredGlobalIndexResult readRawSearch(
+ List<Range> rawRowRanges,
+ Map<String, DataField> fieldsByName,
+ Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
+ ExecutorService executor) {
+ RowType readType = SpecialFields.rowTypeWithRowId(table.rowType());
+ TableScan.Plan plan =
rawReadBuilder(readType).withRowRanges(rawRowRanges).newScan().plan();
+ ReadBuilder readBuilder = rawReadBuilder(readType);
+ int rowIdIndex = readType.getFieldIndex(SpecialFields.ROW_ID.name());
+ Map<String, RawFullTextIndex> rawIndexes =
+ createRawFullTextIndexes(fieldsByName, splitsByColumn,
readType, rawRowRanges);
+
+ try {
+ try (RecordReader<InternalRow> reader =
readBuilder.newRead().createReader(plan);
+ CloseableIterator<InternalRow> iterator =
reader.toCloseableIterator()) {
+ while (iterator.hasNext()) {
+ InternalRow row = iterator.next();
+ for (RawFullTextIndex rawIndex : rawIndexes.values()) {
+ long rowId = row.getLong(rowIdIndex);
+ rawIndex.write(row, rowId);
+ }
+ }
+ }
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to read raw rows for full-text
search.", e);
+ }
+
+ try {
+ Map<String, List<IndexFullTextSearchSplit>> rawSplitsByColumn =
new HashMap<>();
+ for (Map.Entry<String, RawFullTextIndex> entry :
rawIndexes.entrySet()) {
+ IndexFullTextSearchSplit split = entry.getValue().finish();
+ if (split != null) {
+ rawSplitsByColumn.put(entry.getKey(),
Collections.singletonList(split));
+ }
+ }
+
+ return indexSearch
+ .eval(
+ query,
+ fieldsByName,
+ rawSplitsByColumn,
+ new RawFullTextIndexPathFactory(rawIndexes),
+ m ->
+ new MemorySeekableInputStream(
+ rawFileBytes(rawIndexes,
m.filePath().getName())),
+ executor)
+ .topK(limit);
+ } finally {
+ IOUtils.closeAllQuietly(rawIndexes.values());
+ }
+ }
+
+ private static ScoredGlobalIndexResult overrideWithRawSearch(
+ ScoredGlobalIndexResult indexedResult,
+ List<Range> rawRowRanges,
+ ScoredGlobalIndexResult rawResult) {
+ RoaringNavigableMap64 rawRows = new RoaringNavigableMap64();
+ for (Range range : rawRowRanges) {
+ rawRows.addRange(range);
+ }
+ RoaringNavigableMap64 filteredIndexedRows =
+ RoaringNavigableMap64.or(new RoaringNavigableMap64(),
indexedResult.results());
+ filteredIndexedRows.andNot(rawRows);
+ RoaringNavigableMap64 resultRows =
+ RoaringNavigableMap64.or(filteredIndexedRows,
rawResult.results());
+ return ScoredGlobalIndexResult.create(
+ resultRows,
+ rowId ->
+ rawResult.results().contains(rowId)
+ ? rawResult.scoreGetter().score(rowId)
+ : indexedResult.scoreGetter().score(rowId));
+ }
+
+ private Map<String, RawFullTextIndex> createRawFullTextIndexes(
+ Map<String, DataField> fieldsByName,
+ Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
+ RowType readType,
+ List<Range> rawRowRanges) {
+ Map<String, RawFullTextIndex> rawIndexes = new HashMap<>();
+ long rowRangeStart = rawRowRanges.get(0).from;
+ long rowRangeEnd = rawRowRanges.get(rawRowRanges.size() - 1).to;
+ String fallbackIndexType = firstIndexType(splitsByColumn);
+ for (String column : query.columns()) {
+ if (rawIndexes.containsKey(column)) {
+ continue;
+ }
+ DataField textColumn = checkNotNull(fieldsByName.get(column));
+ String indexType = indexType(column, splitsByColumn);
+ if (indexType == null) {
+ indexType = checkNotNull(fallbackIndexType);
+ }
+ GlobalIndexer globalIndexer =
+ GlobalIndexerFactoryUtils.load(indexType)
+ .create(textColumn, rawSearchOptions());
+ try {
+ RawFullTextIndexFileWriter fileWriter = new
RawFullTextIndexFileWriter(column);
+ GlobalIndexWriter indexWriter =
globalIndexer.createWriter(fileWriter);
+ if (!(indexWriter instanceof GlobalIndexSingleColumnWriter)) {
+ throw new IllegalArgumentException(
+ "Full-text raw search requires a single-column
global index writer.");
+ }
+ rawIndexes.put(
+ column,
+ new RawFullTextIndex(
+ column,
+ readColumnIndex(textColumn, readType),
+ indexType,
+ textColumn.id(),
+ rowRangeStart,
+ rowRangeEnd,
+ (GlobalIndexSingleColumnWriter) indexWriter,
+ fileWriter));
+ } catch (IOException e) {
+ throw new RuntimeException(
+ "Failed to create raw full-text index writer for
column: " + column, e);
+ }
+ }
+ return rawIndexes;
+ }
+
+ @Nullable
+ private static String indexType(
+ String column, Map<String, List<IndexFullTextSearchSplit>>
splitsByColumn) {
+ List<IndexFullTextSearchSplit> splits = splitsByColumn.get(column);
+ if (splits == null || splits.isEmpty() ||
splits.get(0).fullTextIndexFiles().isEmpty()) {
+ return null;
+ }
+ return splits.get(0).fullTextIndexFiles().get(0).indexType();
+ }
+
+ @Nullable
+ private static String firstIndexType(
+ Map<String, List<IndexFullTextSearchSplit>> splitsByColumn) {
+ for (List<IndexFullTextSearchSplit> splits : splitsByColumn.values()) {
+ if (splits != null
+ && !splits.isEmpty()
+ && !splits.get(0).fullTextIndexFiles().isEmpty()) {
+ return splits.get(0).fullTextIndexFiles().get(0).indexType();
+ }
+ }
+ return null;
+ }
+
+ private static int readColumnIndex(DataField textColumn, RowType readType)
{
+ return readType.getFieldIndexByFieldId(textColumn.id());
+ }
+
+ private static byte[] rawFileBytes(Map<String, RawFullTextIndex>
rawIndexes, String fileName) {
+ for (RawFullTextIndex rawIndex : rawIndexes.values()) {
+ if (rawIndex.containsFile(fileName)) {
+ return rawIndex.fileBytes(fileName);
+ }
+ }
+ throw new IllegalArgumentException("Unknown raw full-text index file:
" + fileName);
+ }
+
+ private ReadBuilder rawReadBuilder(RowType readType) {
+ ReadBuilder readBuilder =
table.newReadBuilder().withReadType(readType);
+ if (partitionFilter != null) {
+ readBuilder.withPartitionFilter(partitionFilter);
+ }
+ return readBuilder;
+ }
+
+ private Options rawSearchOptions() {
+ Options options = new
Options(table.coreOptions().toConfiguration().toMap());
+ options.setString("tantivy.searcher-pool.max-size", "0");
+ return options;
+ }
+
+ interface IndexSearch {
+
+ ScoredGlobalIndexResult eval(
+ FullTextQuery query,
+ Map<String, DataField> fieldsByName,
+ Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
+ IndexPathFactory indexPathFactory,
+ GlobalIndexFileReader indexFileReader,
+ ExecutorService executor);
+ }
+
+ private static class RawFullTextIndex implements Closeable {
+
+ private final String column;
+ private final int columnIndex;
+ private final String indexType;
+ private final int fieldId;
+ private final long rowRangeStart;
+ private final long rowRangeEnd;
+ private final GlobalIndexSingleColumnWriter writer;
+ private final RawFullTextIndexFileWriter fileWriter;
+ private final RoaringNavigableMap64 indexedRows = new
RoaringNavigableMap64();
+
+ private RawFullTextIndex(
+ String column,
+ int columnIndex,
+ String indexType,
+ int fieldId,
+ long rowRangeStart,
+ long rowRangeEnd,
+ GlobalIndexSingleColumnWriter writer,
+ RawFullTextIndexFileWriter fileWriter) {
+ this.column = column;
+ this.columnIndex = columnIndex;
+ this.indexType = indexType;
+ this.fieldId = fieldId;
+ this.rowRangeStart = rowRangeStart;
+ this.rowRangeEnd = rowRangeEnd;
+ this.writer = writer;
+ this.fileWriter = fileWriter;
+ }
+
+ private void write(InternalRow row, long rowId) {
+ if (row.isNullAt(columnIndex)) {
+ return;
+ }
+ writer.write(row.getString(columnIndex), rowId - rowRangeStart);
+ indexedRows.add(rowId);
+ }
+
+ @Nullable
+ private IndexFullTextSearchSplit finish() {
+ List<ResultEntry> resultEntries = writer.finish();
+ if (resultEntries.isEmpty() || indexedRows.isEmpty()) {
+ return null;
+ }
+
+ List<IndexFileMeta> indexFiles = new
ArrayList<>(resultEntries.size());
+ for (ResultEntry entry : resultEntries) {
+ byte[] bytes = fileWriter.fileBytes(entry.fileName());
+ GlobalIndexMeta meta =
+ new GlobalIndexMeta(
+ rowRangeStart, rowRangeEnd, fieldId, null,
entry.meta());
+ indexFiles.add(
+ new IndexFileMeta(
+ indexType,
+ entry.fileName(),
+ bytes.length,
+ entry.rowCount(),
+ meta,
+ null));
+ }
+ return new IndexFullTextSearchSplit(column, rowRangeStart,
rowRangeEnd, indexFiles);
+ }
+
+ private Path path(String fileName) {
+ return fileWriter.path(fileName);
+ }
+
+ private byte[] fileBytes(String fileName) {
+ return fileWriter.fileBytes(fileName);
+ }
+
+ private boolean containsFile(String fileName) {
+ return fileWriter.containsFile(fileName);
+ }
+
+ @Override
+ public void close() {
+ if (writer instanceof AutoCloseable) {
+ IOUtils.closeQuietly((AutoCloseable) writer);
+ }
+ }
+ }
+
+ private static class RawFullTextIndexFileWriter implements
GlobalIndexFileWriter {
+
+ private final String column;
+ private final String id = UUID.randomUUID().toString();
+ private final Map<String, byte[]> files = new HashMap<>();
+
+ private RawFullTextIndexFileWriter(String column) {
+ this.column = column;
+ }
+
+ @Override
+ public String newFileName(String prefix) {
+ return "raw-" + column + "-" + prefix + "-" + id + "-" +
files.size() + ".index";
+ }
+
+ @Override
+ public PositionOutputStream newOutputStream(String fileName) {
+ return new MemoryPositionOutputStream(bytes -> files.put(fileName,
bytes));
+ }
+
+ private Path path(String fileName) {
+ return new Path("memory://full-text-raw/" + fileName);
+ }
+
+ private byte[] fileBytes(String fileName) {
+ return checkNotNull(files.get(fileName));
+ }
+
+ private boolean containsFile(String fileName) {
+ return files.containsKey(fileName);
+ }
+ }
+
+ private static class RawFullTextIndexPathFactory implements
IndexPathFactory {
+
+ private final Map<String, RawFullTextIndex> rawIndexes;
+
+ private RawFullTextIndexPathFactory(Map<String, RawFullTextIndex>
rawIndexes) {
+ this.rawIndexes = rawIndexes;
+ }
+
+ @Override
+ public Path toPath(String fileName) {
+ RawFullTextIndex rawIndex = rawIndex(fileName);
+ return rawIndex.path(fileName);
+ }
+
+ @Override
+ public Path newPath() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isExternalPath() {
+ return false;
+ }
+
+ private RawFullTextIndex rawIndex(String fileName) {
+ for (RawFullTextIndex rawIndex : rawIndexes.values()) {
+ if (rawIndex.containsFile(fileName)) {
+ return rawIndex;
+ }
+ }
+ throw new IllegalArgumentException("Unknown raw full-text index
file: " + fileName);
+ }
+ }
+
+ private static class MemoryPositionOutputStream extends
PositionOutputStream {
+
+ private final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ private final FileCommitter committer;
+
+ private MemoryPositionOutputStream(FileCommitter committer) {
+ this.committer = committer;
+ }
+
+ @Override
+ public long getPos() {
+ return out.size();
+ }
+
+ @Override
+ public void write(int b) {
+ out.write(b);
+ }
+
+ @Override
+ public void write(byte[] b) throws IOException {
+ out.write(b);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) {
+ out.write(b, off, len);
+ }
+
+ @Override
+ public void flush() {}
+
+ @Override
+ public void close() {
+ committer.commit(out.toByteArray());
+ }
+ }
+
+ private static class MemorySeekableInputStream extends SeekableInputStream
{
+
+ private final byte[] bytes;
+ private int pos;
+
+ private MemorySeekableInputStream(byte[] bytes) {
+ this.bytes = bytes;
+ }
+
+ @Override
+ public void seek(long desired) {
+ if (desired < 0 || desired > bytes.length) {
+ throw new IllegalArgumentException("Cannot seek to position: "
+ desired);
+ }
+ pos = (int) desired;
+ }
+
+ @Override
+ public long getPos() {
+ return pos;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ if (pos >= bytes.length) {
+ return -1;
+ }
+ int read = Math.min(len, bytes.length - pos);
+ System.arraycopy(bytes, pos, b, off, read);
+ pos += read;
+ return read;
+ }
+
+ @Override
+ public void close() {}
+
+ @Override
+ public int read() {
+ if (pos >= bytes.length) {
+ return -1;
+ }
+ return bytes[pos++] & 0xFF;
+ }
+ }
+
+ private interface FileCommitter {
+
+ void commit(byte[] bytes);
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/RawFullTextSearchSplit.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/RawFullTextSearchSplit.java
new file mode 100644
index 0000000000..a95ea76255
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/RawFullTextSearchSplit.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table.source;
+
+import org.apache.paimon.utils.Range;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/** Split to scan raw rows for full-text search. */
+public class RawFullTextSearchSplit extends FullTextSearchSplit {
+
+ private static final long serialVersionUID = 1L;
+
+ private final List<Range> rowRanges;
+
+ public RawFullTextSearchSplit(List<Range> rowRanges) {
+ this.rowRanges = Collections.unmodifiableList(new
ArrayList<>(rowRanges));
+ }
+
+ public List<Range> rowRanges() {
+ return rowRanges;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ RawFullTextSearchSplit that = (RawFullTextSearchSplit) o;
+ return Objects.equals(rowRanges, that.rowRanges);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(rowRanges);
+ }
+
+ @Override
+ public String toString() {
+ return "RawFullTextSearchSplit{" + "rowRanges=" + rowRanges + '}';
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
index c9178d3dbf..5f61bc5de6 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
@@ -24,6 +24,7 @@ import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.serializer.InternalRowSerializer;
import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
import org.apache.paimon.globalindex.GlobalIndexResult;
import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
@@ -35,6 +36,7 @@ import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.io.CompactIncrement;
import org.apache.paimon.io.DataIncrement;
import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.predicate.FullTextQuery;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
@@ -49,10 +51,15 @@ import org.apache.paimon.table.sink.CommitMessage;
import org.apache.paimon.table.sink.CommitMessageImpl;
import org.apache.paimon.types.DataField;
import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.Range;
import org.junit.jupiter.api.Test;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -118,6 +125,162 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
assertThat(ids).containsAnyOf(0, 1, 3);
}
+ @Test
+ public void testFullTextSearchNonFastModesScanUnindexedData() throws
Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+
+ String[] indexedDocuments = {
+ "Apache Paimon is a lake format", "Paimon supports full-text
search"
+ };
+ writeDocuments(table, indexedDocuments);
+ buildAndCommitIndex(table, indexedDocuments);
+ writeDocuments(
+ table,
+ new String[] {
+ "Vector search is also supported", "Fresh Paimon documents
should be searchable"
+ });
+
+ GlobalIndexResult fastResult =
+ table.newFullTextSearchBuilder()
+ .withQuery(FullTextQuery.match("Fresh",
TEXT_FIELD_NAME))
+ .withLimit(10)
+ .executeLocal();
+ assertThat(readIds(table, fastResult)).isEmpty();
+
+ for (String searchMode : Arrays.asList("full", "detail")) {
+ FileStoreTable nonFastModeTable =
+ (FileStoreTable)
+ table.copy(
+ Collections.singletonMap(
+
CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(),
+ searchMode));
+ GlobalIndexResult result =
+ nonFastModeTable
+ .newFullTextSearchBuilder()
+ .withQuery(FullTextQuery.match("Fresh",
TEXT_FIELD_NAME))
+ .withLimit(10)
+ .executeLocal();
+
+ assertThat(readIds(nonFastModeTable, result)).containsExactly(1);
+ }
+ }
+
+ @Test
+ public void testFullTextSearchRawSearchRespectsPartitionFilter() throws
Exception {
+ Identifier identifier = identifier("PartitionedTextTable");
+ Schema schema =
+ Schema.newBuilder()
+ .column("pt", DataTypes.INT())
+ .column("id", DataTypes.INT())
+ .column(TEXT_FIELD_NAME, DataTypes.STRING())
+ .partitionKeys("pt")
+ .option(CoreOptions.BUCKET.key(), "-1")
+ .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+ .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
"true")
+ .build();
+ catalog.createTable(identifier, schema, false);
+ FileStoreTable table = getTable(identifier);
+
+ RowType partitionType = RowType.of(DataTypes.INT());
+ InternalRowSerializer serializer = new
InternalRowSerializer(partitionType);
+ BinaryRow partition1 = serializer.toBinaryRow(GenericRow.of(1)).copy();
+
+ writePartitionedDocuments(
+ table, 1, new String[] {"indexed Paimon document", "another
document"});
+ buildAndCommitIndexForColumn(
+ table,
+ TEXT_FIELD_NAME,
+ new String[] {"indexed Paimon document", "another document"},
+ partition1);
+ writePartitionedDocuments(table, 2, new String[] {"fresh Paimon
document"});
+
+ PartitionPredicate partitionFilter =
+ PartitionPredicate.fromMultiple(
+ partitionType, Collections.singletonList(partition1));
+ FileStoreTable fullModeTable =
+ (FileStoreTable)
+ table.copy(
+ Collections.singletonMap(
+
CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), "full"));
+
+ GlobalIndexResult result =
+ fullModeTable
+ .newFullTextSearchBuilder()
+ .withPartitionFilter(partitionFilter)
+ .withQuery(FullTextQuery.match("fresh",
TEXT_FIELD_NAME))
+ .withLimit(10)
+ .executeLocal();
+
+ assertThat(readIds(fullModeTable, result)).isEmpty();
+ }
+
+ @Test
+ public void testFullTextSearchRawSearchOverridesPartiallyIndexedRows()
throws Exception {
+ FileStoreTable table = createMultiTextTable();
+ writeMultiTextDocuments(
+ table,
+ new String[][] {
+ {"paimon title", "other body"},
+ {"other title", "paimon body"},
+ {"paimon title", "paimon body"}
+ });
+ buildAndCommitIndexForColumn(
+ table, "title", new String[] {"paimon title", "other title",
"paimon title"});
+ buildAndCommitIndexForColumn(table, "body", new String[] {"other
body", "paimon body"});
+
+ FileStoreTable fullModeTable =
+ (FileStoreTable)
+ table.copy(
+ Collections.singletonMap(
+
CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), "full"));
+ ScoredGlobalIndexResult result =
+ (ScoredGlobalIndexResult)
+ fullModeTable
+ .newFullTextSearchBuilder()
+ .withQuery(
+ FullTextQuery.multiMatch(
+ "paimon",
Arrays.asList("title", "body")))
+ .withLimit(10)
+ .executeLocal();
+
+ assertThat(result.results()).contains(2L);
+ assertThat(result.scoreGetter().score(2L)).isEqualTo(2.0f);
+ }
+
+ @Test
+ public void testFullTextSearchRawSearchRemovesPartialIndexedRows() throws
Exception {
+ FileStoreTable table = createMultiTextTable();
+ writeMultiTextDocuments(
+ table,
+ new String[][] {
+ {"paimon title", "paimon body"},
+ {"paimon title", "other body"}
+ });
+ buildAndCommitIndexForColumn(table, "title", new String[] {"paimon
title", "paimon title"});
+ buildAndCommitIndexForColumn(table, "body", new String[] {"paimon
body"});
+
+ FileStoreTable fullModeTable =
+ (FileStoreTable)
+ table.copy(
+ Collections.singletonMap(
+
CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), "full"));
+ GlobalIndexResult result =
+ fullModeTable
+ .newFullTextSearchBuilder()
+ .withQuery(
+ new FullTextQuery.BooleanQuery(
+ Collections.emptyList(),
+ Arrays.asList(
+ FullTextQuery.match("paimon",
"title"),
+ FullTextQuery.match("paimon",
"body")),
+ Collections.emptyList()))
+ .withLimit(10)
+ .executeLocal();
+
+ assertThat(result.results()).containsExactly(0L);
+ }
+
@Test
public void testHybridSearchBuilderWithFullTextRoute() throws Exception {
createTableDefault();
@@ -554,6 +717,61 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
assertThat(searchBuilder.executeLocal().results().isEmpty()).isTrue();
}
+ @Test
+ public void testFullTextSearchSplitSerialization() throws Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+
+ String[] documents = {"Apache Paimon", "full-text search"};
+ writeDocuments(table, documents);
+ buildAndCommitIndex(table, documents);
+
+ FullTextScan.Plan plan =
+ table.newFullTextSearchBuilder()
+ .withQuery(FullTextQuery.match("Paimon",
TEXT_FIELD_NAME))
+ .withLimit(2)
+ .newFullTextScan()
+ .scan();
+
+ assertThat(plan.splits()).hasSize(1);
+ IndexFullTextSearchSplit original = (IndexFullTextSearchSplit)
plan.splits().get(0);
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ObjectOutputStream out = new ObjectOutputStream(bos)) {
+ out.writeObject(original);
+ }
+
+ IndexFullTextSearchSplit deserialized;
+ try (ObjectInputStream in =
+ new ObjectInputStream(new
ByteArrayInputStream(bos.toByteArray()))) {
+ deserialized = (IndexFullTextSearchSplit) in.readObject();
+ }
+
+ assertThat(deserialized.columnName()).isEqualTo(original.columnName());
+
assertThat(deserialized.rowRangeStart()).isEqualTo(original.rowRangeStart());
+
assertThat(deserialized.rowRangeEnd()).isEqualTo(original.rowRangeEnd());
+
assertThat(deserialized.fullTextIndexFiles()).hasSize(original.fullTextIndexFiles().size());
+ for (int i = 0; i < original.fullTextIndexFiles().size(); i++) {
+ assertThat(deserialized.fullTextIndexFiles().get(i).fileName())
+
.isEqualTo(original.fullTextIndexFiles().get(i).fileName());
+ }
+
+ RawFullTextSearchSplit rawOriginal =
+ new RawFullTextSearchSplit(Collections.singletonList(new
Range(2, 3)));
+ bos = new ByteArrayOutputStream();
+ try (ObjectOutputStream out = new ObjectOutputStream(bos)) {
+ out.writeObject(rawOriginal);
+ }
+
+ RawFullTextSearchSplit rawDeserialized;
+ try (ObjectInputStream in =
+ new ObjectInputStream(new
ByteArrayInputStream(bos.toByteArray()))) {
+ rawDeserialized = (RawFullTextSearchSplit) in.readObject();
+ }
+
+
assertThat(rawDeserialized.rowRanges()).isEqualTo(rawOriginal.rowRanges());
+ }
+
// ====================== Helper methods ======================
private void writeDocuments(FileStoreTable table, String[] documents)
throws Exception {
@@ -567,6 +785,18 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
}
}
+ private void writePartitionedDocuments(FileStoreTable table, int
partition, String[] documents)
+ throws Exception {
+ BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = writeBuilder.newWrite();
+ BatchTableCommit commit = writeBuilder.newCommit()) {
+ for (int i = 0; i < documents.length; i++) {
+ write.write(GenericRow.of(partition, i,
BinaryString.fromString(documents[i])));
+ }
+ commit.commit(write.prepareCommit());
+ }
+ }
+
private void buildAndCommitIndex(FileStoreTable table, String[] documents)
throws Exception {
buildAndCommitIndexWithFields(
table,
@@ -649,6 +879,12 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
private void buildAndCommitIndexForColumn(
FileStoreTable table, String columnName, String[] documents)
throws Exception {
+ buildAndCommitIndexForColumn(table, columnName, documents,
BinaryRow.EMPTY_ROW);
+ }
+
+ private void buildAndCommitIndexForColumn(
+ FileStoreTable table, String columnName, String[] documents,
BinaryRow partition)
+ throws Exception {
Options options = table.coreOptions().toConfiguration();
DataField textField = table.rowType().getField(columnName);
@@ -678,11 +914,7 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
DataIncrement dataIncrement = DataIncrement.indexIncrement(indexFiles);
CommitMessage message =
new CommitMessageImpl(
- BinaryRow.EMPTY_ROW,
- 0,
- null,
- dataIncrement,
- CompactIncrement.emptyIncrement());
+ partition, 0, null, dataIncrement,
CompactIncrement.emptyIncrement());
try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
commit.commit(Collections.singletonList(message));
}
diff --git a/paimon-python/pypaimon/globalindex/global_index_coverage.py
b/paimon-python/pypaimon/globalindex/global_index_coverage.py
index 9f94f89495..d0a05cb50b 100644
--- a/paimon-python/pypaimon/globalindex/global_index_coverage.py
+++ b/paimon-python/pypaimon/globalindex/global_index_coverage.py
@@ -53,11 +53,13 @@ class GlobalIndexCoverage:
def unindexed_ranges(
self,
- fields_or_field_id: Union[List[DataField], int],
+ fields_or_field_id: Union[List[DataField], Collection[int], int],
predicate: Optional[Predicate] = None,
) -> List[Range]:
if isinstance(fields_or_field_id, int):
field_ids = {fields_or_field_id}
+ elif _is_field_id_collection(fields_or_field_id):
+ field_ids = set(fields_or_field_id)
else:
field_by_name = {f.name: f for f in fields_or_field_id}
field_ids = set()
@@ -143,3 +145,13 @@ def _global_index_search_mode(table):
if hasattr(options, "global_index_search_mode"):
return options.global_index_search_mode()
return CoreOptions(Options.from_none()).global_index_search_mode()
+
+
+def _is_field_id_collection(fields_or_field_id):
+ if isinstance(fields_or_field_id, (str, bytes)):
+ return False
+ try:
+ iterator = iter(fields_or_field_id)
+ except TypeError:
+ return False
+ return all(isinstance(field_id, int) for field_id in iterator)
diff --git a/paimon-python/pypaimon/table/source/full_text_scan.py
b/paimon-python/pypaimon/table/source/full_text_scan.py
index 1324a2830d..84968e7b65 100644
--- a/paimon-python/pypaimon/table/source/full_text_scan.py
+++ b/paimon-python/pypaimon/table/source/full_text_scan.py
@@ -21,6 +21,7 @@ from abc import ABC, abstractmethod
from collections import defaultdict
from typing import List
+from pypaimon.globalindex.global_index_coverage import GlobalIndexCoverage
from pypaimon.globalindex.tantivy.tantivy_full_text_global_index_reader import
(
TANTIVY_FULLTEXT_IDENTIFIER,
)
@@ -111,4 +112,18 @@ class FullTextScanImpl(FullTextScan):
FullTextSearchSplit(
column_name, range_key.from_, range_key.to, files))
+ if all_index_files:
+ raw_row_ranges = GlobalIndexCoverage(
+ self._table,
+ snapshot,
+ partition_filter,
+ all_index_files,
+ ).unindexed_ranges(list(text_column_ids))
+ if raw_row_ranges:
+ raise NotImplementedError(
+ "Python full-text search does not support "
+ "global-index.search-mode=full/detail for uncovered row "
+ "ranges yet. Raw full-text search requires rebuilding "
+ "temporary full-text indexes.")
+
return FullTextScanPlan(splits)
diff --git a/paimon-python/pypaimon/tests/global_index_test.py
b/paimon-python/pypaimon/tests/global_index_test.py
index 60ca559cda..78d0f9a3d7 100644
--- a/paimon-python/pypaimon/tests/global_index_test.py
+++ b/paimon-python/pypaimon/tests/global_index_test.py
@@ -126,6 +126,22 @@ class GlobalIndexCoverageTest(unittest.TestCase):
self.assertEqual([Range(5, 9)], coverage.unindexed_ranges(0))
+ def test_full_mode_accepts_multiple_field_ids(self):
+ from pypaimon.globalindex.global_index_coverage import
GlobalIndexCoverage
+
+ table = _CoverageTable("full")
+ coverage = GlobalIndexCoverage(
+ table,
+ _CoverageSnapshot(10),
+ None,
+ [
+ _coverage_index_file(0, 0, 9),
+ _coverage_index_file(1, 0, 4),
+ ],
+ )
+
+ self.assertEqual([Range(5, 9)], coverage.unindexed_ranges([0, 1]))
+
def test_full_mode_intersects_coverage_for_all_predicate_fields(self):
from pypaimon.globalindex.global_index_coverage import
GlobalIndexCoverage
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index e3ccac2f33..0e29e5c1ad 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -121,7 +121,7 @@ def _entry(partition_row, field_id, index_type, file_name,
index_file=index_file)
-def _patch_snapshot(testcase, entries):
+def _patch_snapshot(testcase, entries, snapshot=None):
"""Stub IndexFileHandler.scan + snapshot resolution."""
mock.patch.stopall()
@@ -144,7 +144,7 @@ def _patch_snapshot(testcase, entries):
testcase._scan_patch.start()
testcase._travel_patch = mock.patch(
"pypaimon.snapshot.time_travel_util.TimeTravelUtil.try_travel_to_snapshot",
- return_value=object())
+ return_value=snapshot if snapshot is not None else object())
testcase._travel_patch.start()
@@ -1121,6 +1121,31 @@ class FullTextSearchBuilderDslTest(unittest.TestCase):
["ft.index"],
[f.file_name for f in splits[0].full_text_index_files])
+ def test_full_text_scan_rejects_full_mode_raw_search(self):
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.common.options.options import Options
+ from pypaimon.table.source.full_text_scan import FullTextScanImpl
+
+ class _Options:
+ options = Options({"global-index.search-mode": "full"})
+
+ def global_index_search_mode(self_inner):
+ return
CoreOptions(self_inner.options).global_index_search_mode()
+
+ text_field = _field(1, "content", "STRING")
+ entry = _entry(
+ None, field_id=1, index_type="tantivy-fulltext",
+ file_name="ft.index", row_range_start=0, row_range_end=4)
+ table = _StubTable(fields=[text_field], entries=[entry])
+ table.options = _Options()
+ _patch_snapshot(self, [entry], types.SimpleNamespace(next_row_id=10))
+
+ with self.assertRaises(NotImplementedError) as ctx:
+ FullTextScanImpl(table, [text_field]).scan()
+
+ self.assertIn("global-index.search-mode=full/detail",
str(ctx.exception))
+ self.assertIn("rebuilding temporary full-text indexes",
str(ctx.exception))
+
class VectorSearchFilterTest(unittest.TestCase):
"""Non-partitioned wiring: scan + read + external_path plumbing."""
diff --git
a/paimon-tantivy/paimon-tantivy-index/src/main/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexerFactory.java
b/paimon-tantivy/paimon-tantivy-index/src/main/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexerFactory.java
index 641a779a81..127d22c657 100644
---
a/paimon-tantivy/paimon-tantivy-index/src/main/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexerFactory.java
+++
b/paimon-tantivy/paimon-tantivy-index/src/main/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexerFactory.java
@@ -45,10 +45,15 @@ public class TantivyFullTextGlobalIndexerFactory implements
GlobalIndexerFactory
@Override
public GlobalIndexer create(DataField field, Options options) {
+ int maxSize =
options.get(TantivyFullTextIndexOptions.SEARCHER_POOL_MAX_SIZE);
+ if (maxSize <= 0) {
+ return new TantivyFullTextGlobalIndexer(
+ new TantivySearcherPool(0),
+ new
TantivyFullTextIndexOptions(removeTantivyPrefix(options)));
+ }
if (searcherPool == null) {
synchronized (this) {
if (searcherPool == null) {
- int maxSize =
options.get(TantivyFullTextIndexOptions.SEARCHER_POOL_MAX_SIZE);
searcherPool = new TantivySearcherPool(maxSize);
}
}
diff --git
a/paimon-tantivy/paimon-tantivy-index/src/test/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexerFactoryTest.java
b/paimon-tantivy/paimon-tantivy-index/src/test/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexerFactoryTest.java
new file mode 100644
index 0000000000..3bd9bb0459
--- /dev/null
+++
b/paimon-tantivy/paimon-tantivy-index/src/test/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexerFactoryTest.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.tantivy.index;
+
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link TantivyFullTextGlobalIndexerFactory}. */
+public class TantivyFullTextGlobalIndexerFactoryTest {
+
+ @Test
+ public void testFactoryDisablesSearcherPoolForZeroMaxSize() throws
Exception {
+ TantivyFullTextGlobalIndexerFactory factory = new
TantivyFullTextGlobalIndexerFactory();
+ DataField field = new DataField(0, "text", DataTypes.STRING());
+
+ GlobalIndexer pooled = factory.create(field, new Options());
+ Options disabledOptions = new Options();
+
disabledOptions.set(TantivyFullTextIndexOptions.SEARCHER_POOL_MAX_SIZE, 0);
+ GlobalIndexer disabled = factory.create(field, disabledOptions);
+
+ assertThat(searcherPool(pooled)).isNotSameAs(searcherPool(disabled));
+ }
+
+ private static TantivySearcherPool searcherPool(GlobalIndexer indexer)
throws Exception {
+ Field field =
TantivyFullTextGlobalIndexer.class.getDeclaredField("searcherPool");
+ field.setAccessible(true);
+ return (TantivySearcherPool) field.get(indexer);
+ }
+}