CrownChu commented on code in PR #8000:
URL: https://github.com/apache/paimon/pull/8000#discussion_r3527883256
##########
paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java:
##########
@@ -102,16 +103,34 @@ public Plan scan() {
.map(IndexManifestEntry::indexFile)
.collect(Collectors.toList());
- // Group full-text index files by column and row range.
+ // A searched text column can be covered by more than one
full-text-capable global index
+ // (e.g. a dedicated full-text index whose primary field is the
column, and a vector index
+ // that carries the column as an extra field). These are different
index definitions and
+ // must not be merged into one reader input. Pick exactly one index
definition per column,
+ // preferring the one where the column is the primary indexFieldId
(dedicated full-text)
+ // over an extra-field match, so every split carries files from a
single index identity.
+ Map<String, IndexIdentity> chosenByColumn =
+ chooseIndexPerColumn(allIndexFiles, textColumnIds, idToColumn);
+
+ // Group full-text index files by column and row range. A multi-column
index serves a text
+ // column through either its primary indexFieldId or its
extraFieldIds, and one file may
+ // cover more than one searched text column.
Map<String, Map<Range, List<IndexFileMeta>>> byColumnAndRange = new
HashMap<>();
for (IndexFileMeta indexFile : allIndexFiles) {
GlobalIndexMeta meta = checkNotNull(indexFile.globalIndexMeta());
- String columnName =
checkNotNull(idToColumn.get(meta.indexFieldId()));
+ IndexIdentity identity = IndexIdentity.of(indexFile);
Range range = new Range(meta.rowRangeStart(), meta.rowRangeEnd());
- byColumnAndRange
- .computeIfAbsent(columnName, k -> new HashMap<>())
- .computeIfAbsent(range, k -> new ArrayList<>())
- .add(indexFile);
+ for (int columnId : matchedTextColumnIds(meta, textColumnIds)) {
+ String columnName = checkNotNull(idToColumn.get(columnId));
+ if (!identity.equals(chosenByColumn.get(columnName))) {
Review Comment:
thanks. The selection was per-column, so a range covered only by the
non-chosen identity was dropped while the raw-coverage fallback (computed from
all index files) still counted it as indexed, leaving those rows unsearchable.
Switched to selecting the serving identity per (column, row range) in
chooseIndexPerColumnAndRange, so each range's covering index is emitted as its
own split. This also required building the reader per split in
FullTextReadImpl, since a column can now legitimately carry splits from
multiple identities (reusing the first split's config would misread the
others). Added a regression test that places a dedicated full-text index over
rows [0,1] and an extra-field index over [2,3] and asserts a query matches
rows in both ranges. Fixed in c2e1f85.
##########
paimon-eslib/src/main/java/org/apache/paimon/eslib/index/ESIndexOptions.java:
##########
@@ -0,0 +1,284 @@
+/*
+ * 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.eslib.index;
+
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypeRoot;
+import org.apache.paimon.types.VectorType;
+
+import org.elasticsearch.eslib.api.model.BuiltinAnalyzer;
+import org.elasticsearch.eslib.api.model.FieldIndexConfig;
+import org.elasticsearch.eslib.api.model.ScalarFieldType;
+import org.elasticsearch.eslib.api.model.VectorAlgorithm;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Parses Paimon Options into ESLib FieldIndexConfig for each field.
+ *
+ * <p>Keys are read from the table options using the persisted {@code
global-index.es-index.*}
+ * convention (same keys the table schema stores and the ES mount reads). A
field-level key {@code
+ * global-index.es-index.fields.<field>.<key>} takes precedence over the
index-type-level key {@code
+ * global-index.es-index.<key>}.
+ *
+ * <pre>
+ * global-index.es-index.metric = cosine #
index-type-level default
+ * global-index.es-index.fields.vector_field.algorithm = diskbbq #
field-level override
+ * global-index.es-index.fields.vector_field.dimension = 128
+ * global-index.es-index.fields.text_field.analyzer = standard
+ * global-index.es-index.fields.id_field.type = keyword
+ * </pre>
+ */
+public class ESIndexOptions {
+
+ /**
+ * Index-type-level key prefix (e.g. {@code
global-index.es-index.metric}); overridden by
+ * field-level keys. Aligned with the persisted table-property / ES-mount
convention ({@code
+ * global-index.<indexType>.*}) so build, read and mount all read the same
keys. The {@code
+ * es-index} part still comes from {@link
ESIndexGlobalIndexerFactory#IDENTIFIER}.
+ */
+ private static final String INDEX_TYPE_PREFIX =
+ "global-index." + ESIndexGlobalIndexerFactory.IDENTIFIER + ".";
+
+ /** Field-level key prefix, e.g. {@code
global-index.es-index.fields.<field>.algorithm}. */
+ private static final String FIELDS_PREFIX = INDEX_TYPE_PREFIX + "fields.";
+
+ /**
+ * Suffix of the keyword multi-field sub-field of a FULLTEXT column
(mirrors ES text.keyword).
+ */
+ public static final String KEYWORD_SUBFIELD_SUFFIX = ".keyword";
+
+ private final Map<String, FieldIndexConfig> fieldConfigs;
+
+ public ESIndexOptions(List<DataField> fields, Options options) {
+ this.fieldConfigs = new LinkedHashMap<>();
+ for (DataField field : fields) {
+ FieldIndexConfig config = parseFieldConfig(field, options);
+ fieldConfigs.put(field.name(), config);
+ // Multi-field: a FULLTEXT column also gets a keyword sub-field
(content.keyword) so
+ // exact filters (=, IN, prefix, ...) work alongside full-text
match. Enabled by
+ // default; disable with fields.<name>.keyword_subfield=false.
+ if (config.indexType() == FieldIndexConfig.IndexType.FULLTEXT
+ && Boolean.parseBoolean(
+ resolve(options, field.name(), "keyword_subfield",
"true"))) {
+ String subField = field.name() + KEYWORD_SUBFIELD_SUFFIX;
+ fieldConfigs.put(
+ subField,
+ FieldIndexConfig.builder(subField,
FieldIndexConfig.IndexType.KEYWORD)
+ .scalarType(ScalarFieldType.KEYWORD)
+ .build());
+ }
+ }
+ }
+
+ public Map<String, FieldIndexConfig> getFieldConfigs() {
+ return fieldConfigs;
+ }
+
+ public FieldIndexConfig getConfig(String fieldName) {
+ return fieldConfigs.get(fieldName);
+ }
+
+ /**
+ * Returns the keyword multi-field sub-field name for {@code fieldName} if
one exists (i.e. the
+ * field is FULLTEXT and the keyword sub-field is enabled), otherwise
{@code null}.
+ */
+ public String keywordSubField(String fieldName) {
+ String subField = fieldName + KEYWORD_SUBFIELD_SUFFIX;
+ return fieldConfigs.containsKey(subField) ? subField : null;
+ }
+
+ /**
+ * Resolves a config value for {@code fieldName}/{@code key}: the
field-level key {@code
+ * fields.<field>.<key>} takes precedence over the index-type-level key
{@code es-index.<key>};
+ * returns {@code defaultValue} when neither is set.
+ */
+ private static String resolve(
+ Options options, String fieldName, String key, String
defaultValue) {
+ String value = options.getString(FIELDS_PREFIX + fieldName + "." +
key, null);
+ if (value == null) {
+ value = options.getString(INDEX_TYPE_PREFIX + key, null);
+ }
+ return value == null ? defaultValue : value;
+ }
+
+ private FieldIndexConfig parseFieldConfig(DataField field, Options
options) {
+ DataType dataType = field.type();
+
+ // Explicit type override takes priority
+ String explicitType = resolve(options, field.name(), "type", null);
+ if (explicitType != null) {
+ return parseExplicitType(field.name(), explicitType, dataType,
options);
+ }
+
+ if (isVectorType(dataType)) {
+ return parseVectorConfig(field.name(), dataType, options);
+ } else if (isTextType(dataType)) {
+ // String fields: check if analyzer is set → FULLTEXT; otherwise →
KEYWORD
+ String analyzer = resolve(options, field.name(), "analyzer", null);
+ if (analyzer != null) {
+ return FieldIndexConfig.builder(field.name(),
FieldIndexConfig.IndexType.FULLTEXT)
+ .analyzer(BuiltinAnalyzer.fromName(analyzer))
+ .build();
+ }
+ return FieldIndexConfig.builder(field.name(),
FieldIndexConfig.IndexType.KEYWORD)
+ .scalarType(ScalarFieldType.KEYWORD)
+ .build();
+ } else if (isTimestampType(dataType)) {
+ return FieldIndexConfig.builder(field.name(),
FieldIndexConfig.IndexType.DATE)
+ .scalarType(ScalarFieldType.DATE)
+ .build();
+ } else {
+ return FieldIndexConfig.builder(field.name(),
FieldIndexConfig.IndexType.SCALAR)
+ .scalarType(mapScalarType(dataType))
+ .build();
+ }
+ }
+
+ private FieldIndexConfig parseExplicitType(
+ String fieldName, String typeName, DataType dataType, Options
options) {
+ switch (typeName.toLowerCase()) {
+ case "fulltext":
+ String analyzer = resolve(options, fieldName, "analyzer",
"standard");
+ return FieldIndexConfig.builder(fieldName,
FieldIndexConfig.IndexType.FULLTEXT)
+ .analyzer(BuiltinAnalyzer.fromName(analyzer))
+ .build();
+ case "keyword":
+ return FieldIndexConfig.builder(fieldName,
FieldIndexConfig.IndexType.KEYWORD)
+ .scalarType(ScalarFieldType.KEYWORD)
+ .build();
+ case "geo_point":
+ return FieldIndexConfig.builder(fieldName,
FieldIndexConfig.IndexType.GEO_POINT)
+ .scalarType(ScalarFieldType.GEO_POINT)
+ .build();
+ case "date":
+ return FieldIndexConfig.builder(fieldName,
FieldIndexConfig.IndexType.DATE)
+ .scalarType(ScalarFieldType.DATE)
+ .build();
+ case "vector":
+ return parseVectorConfig(fieldName, dataType, options);
+ default:
+ return FieldIndexConfig.builder(fieldName,
FieldIndexConfig.IndexType.SCALAR)
+ .scalarType(mapScalarType(dataType))
+ .build();
+ }
+ }
+
+ private FieldIndexConfig parseVectorConfig(
+ String fieldName, DataType dataType, Options options) {
+ String algorithm = resolve(options, fieldName, "algorithm", "hnsw");
+ String dimStr = resolve(options, fieldName, "dimension", null);
+ int dimension = dimStr != null ? Integer.parseInt(dimStr) :
inferDimension(dataType);
+ String metric = resolve(options, fieldName, "metric", "cosine");
+
+ Map<String, String> params = new LinkedHashMap<>();
+ String mStr = resolve(options, fieldName, "m", null);
+ if (mStr != null) {
+ params.put("m", mStr);
+ }
+ String efStr = resolve(options, fieldName, "ef_construction", null);
+ if (efStr != null) {
+ params.put("ef_construction", efStr);
+ }
+ String vpcStr = resolve(options, fieldName, "vectors_per_cluster",
null);
+ if (vpcStr != null) {
+ params.put("vectors_per_cluster", vpcStr);
+ }
+
+ return FieldIndexConfig.builder(fieldName,
FieldIndexConfig.IndexType.VECTOR)
+ .algorithm(VectorAlgorithm.fromName(algorithm))
+ .dimension(dimension)
+ .metric(metric)
+ .algorithmParams(params)
+ .build();
+ }
+
+ private static ScalarFieldType mapScalarType(DataType type) {
+ if (type instanceof ArrayType) {
+ return mapArrayScalarType((ArrayType) type);
+ }
+ switch (type.getTypeRoot()) {
+ case INTEGER:
+ case SMALLINT:
+ case TINYINT:
+ return ScalarFieldType.INT;
+ case BIGINT:
+ return ScalarFieldType.LONG;
+ case FLOAT:
+ return ScalarFieldType.FLOAT;
+ case DOUBLE:
+ return ScalarFieldType.DOUBLE;
+ default:
+ return ScalarFieldType.KEYWORD;
Review Comment:
Fixed by failing fast, mirroring mapArrayScalarType: mapScalarType now maps
CHAR/VARCHAR to KEYWORD explicitly and throws IllegalArgumentException for
unsupported scalar roots (BOOLEAN, DECIMAL, ...), instead of defaulting them
to KEYWORD and blowing up later in ESIndexGlobalIndexWriter.extractScalar with
a ClassCastException. Added a test asserting a BOOLEAN companion column is
rejected up front at option-parse time. Fixed in c2e1f85.
--
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]