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 9af77b0a7c [lumina] Support per-field vector index options (#8260)
9af77b0a7c is described below
commit 9af77b0a7c43d120c69784fac8a42909d4e9c58f
Author: jerry <[email protected]>
AuthorDate: Wed Jun 17 20:20:08 2026 +0800
[lumina] Support per-field vector index options (#8260)
`paimon-lumina` only allowed Lumina vector index options to be
configured globally via `lumina.*` table options. A table with multiple
vector columns could therefore not configure each column independently
(e.g. a different `distance.metric` or `index.dimension`).
This PR adds per-field configuration, **following the same convention as
#8239** (`paimon-vector-index`).
---
.../index/LuminaVectorGlobalIndexerFactory.java | 3 +-
.../lumina/index/LuminaVectorIndexOptions.java | 67 +++++++++++++++++++
.../lumina/index/LuminaVectorOptionsTest.java | 75 ++++++++++++++++++++++
3 files changed, 144 insertions(+), 1 deletion(-)
diff --git
a/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexerFactory.java
b/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexerFactory.java
index 7638fa6098..7d9c062feb 100644
---
a/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexerFactory.java
+++
b/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexerFactory.java
@@ -35,6 +35,7 @@ public class LuminaVectorGlobalIndexerFactory implements
GlobalIndexerFactory {
@Override
public GlobalIndexer create(DataField field, Options options) {
- return new LuminaVectorGlobalIndexer(field.type(), options);
+ Options fieldOptions =
LuminaVectorIndexOptions.resolveFieldOptions(field.name(), options);
+ return new LuminaVectorGlobalIndexer(field.type(), fieldOptions);
}
}
diff --git
a/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorIndexOptions.java
b/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorIndexOptions.java
index 80c6b8e28e..843e5c3813 100644
---
a/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorIndexOptions.java
+++
b/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorIndexOptions.java
@@ -18,14 +18,17 @@
package org.apache.paimon.lumina.index;
+import org.apache.paimon.CoreOptions;
import org.apache.paimon.options.ConfigOption;
import org.apache.paimon.options.ConfigOptions;
import org.apache.paimon.options.Options;
import java.util.Arrays;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/** Options for the Lumina vector index. */
public class LuminaVectorIndexOptions {
@@ -124,6 +127,54 @@ public class LuminaVectorIndexOptions {
this.luminaOptions = buildLuminaOptions(options);
}
+ /**
+ * Resolves per-field Lumina options for {@code fieldName} into an
effective {@link Options}.
+ *
+ * <p>Following the convention shared with {@code paimon-vector-index} (PR
#8239), a field-level
+ * option is written {@code fields.<fieldName>.<option>} — <b>without</b>
the {@code lumina.}
+ * index-type prefix — and overrides the column-agnostic {@code
lumina.<option>} for that field
+ * only. For example {@code fields.embed.distance.metric} overrides {@code
+ * lumina.distance.metric} for column {@code embed}. The {@code <option>}
suffix is exactly the
+ * key used after {@code lumina.} at the table level.
+ *
+ * <p>Only recognized Lumina options (the keys in {@code
FIELD_OVERRIDABLE_KEYS}) are accepted;
+ * any other {@code fields.<fieldName>.*} key (e.g. a merge/aggregation
option) is left
+ * untouched, mirroring how {@code paimon-vector-index} ignores keys it
does not recognize.
+ *
+ * <p>Each recognized field option is flattened back to its plain {@code
lumina.*} form, so the
+ * rest of this class still sees only {@code lumina.*} keys and the
metadata produced from these
+ * options keeps the exact same native-key shape as before (no {@code
fields.*} keys ever reach
+ * the meta) — only the resolved value changes. This is what lets the
reader consume the meta
+ * unchanged.
+ *
+ * <p>The same resolution runs on both the write and read paths, since
both build the indexer
+ * through {@link LuminaVectorGlobalIndexerFactory#create}.
+ */
+ public static Options resolveFieldOptions(String fieldName, Options
options) {
+ String fieldPrefix = CoreOptions.FIELDS_PREFIX + "." + fieldName + ".";
+ Map<String, String> tableOptions = options.toMap();
+ Map<String, String> result = new LinkedHashMap<>();
+ // Base: table-level options, including the column-agnostic lumina.*
options. Drop all
+ // fields.* keys; this field's recognized options are re-added below
as lumina.* keys.
+ for (Map.Entry<String, String> entry : tableOptions.entrySet()) {
+ if (!entry.getKey().startsWith(CoreOptions.FIELDS_PREFIX + ".")) {
+ result.put(entry.getKey(), entry.getValue());
+ }
+ }
+ // Overlay this field's options. fields.<field>.<option> (no lumina.
prefix) overrides the
+ // column-agnostic lumina.<option>. Only recognized Lumina options are
taken.
+ for (Map.Entry<String, String> entry : tableOptions.entrySet()) {
+ String key = entry.getKey();
+ if (key.startsWith(fieldPrefix)) {
+ String option = key.substring(fieldPrefix.length());
+ if (FIELD_OVERRIDABLE_KEYS.contains(option)) {
+ result.put(LUMINA_PREFIX + option, entry.getValue());
+ }
+ }
+ }
+ return Options.fromMap(result);
+ }
+
/**
* Returns all {@code lumina.*} options with the prefix stripped,
producing native Lumina keys.
* For example, {@code lumina.diskann.build.ef_construction} becomes {@code
@@ -186,6 +237,22 @@ public class LuminaVectorIndexOptions {
ENCODING_PQ_M,
SEARCH_PARALLEL_NUMBER);
+ /**
+ * Native Lumina keys (the {@code lumina.} prefix stripped) that may be
overridden per field via
+ * {@code fields.<fieldName>.<key>}. Any {@code fields.<fieldName>.*} key
outside this set is
+ * ignored, so unrelated per-field options do not leak into the Lumina
index metadata.
+ */
+ private static final Set<String> FIELD_OVERRIDABLE_KEYS =
buildFieldOverridableKeys();
+
+ private static Set<String> buildFieldOverridableKeys() {
+ Set<String> keys = new HashSet<>();
+ for (ConfigOption<?> opt : ALL_OPTIONS) {
+ keys.add(toLuminaKey(opt));
+ }
+ keys.add(toLuminaKey(DISKANN_SEARCH_LIST_SIZE));
+ return keys;
+ }
+
/**
* Builds native Lumina options by first populating all known
ConfigOptions (with defaults) and
* then overlaying any user-specified {@code lumina.*} options. This
ensures that required keys
diff --git
a/paimon-lumina/src/test/java/org/apache/paimon/lumina/index/LuminaVectorOptionsTest.java
b/paimon-lumina/src/test/java/org/apache/paimon/lumina/index/LuminaVectorOptionsTest.java
index 7ea5088780..326b89fb9b 100644
---
a/paimon-lumina/src/test/java/org/apache/paimon/lumina/index/LuminaVectorOptionsTest.java
+++
b/paimon-lumina/src/test/java/org/apache/paimon/lumina/index/LuminaVectorOptionsTest.java
@@ -18,6 +18,8 @@
package org.apache.paimon.lumina.index;
+import org.apache.paimon.options.Options;
+
import org.junit.jupiter.api.Test;
import java.util.HashMap;
@@ -49,4 +51,77 @@ public class LuminaVectorOptionsTest {
.containsEntry("index.dimension", "4")
.containsEntry("hnsw.ef_search", "128");
}
+
+ @Test
+ public void testFieldOptionsOverrideGlobal() {
+ Map<String, String> tableOptions = new HashMap<>();
+ tableOptions.put("lumina.distance.metric", "l2");
+ tableOptions.put("lumina.index.dimension", "128");
+ // Field-level keys follow the #8239 convention:
fields.<field>.<option>, no lumina. prefix.
+ tableOptions.put("fields.embed.distance.metric", "inner_product");
+ tableOptions.put("fields.embed.index.dimension", "256");
+
+ Map<String, String> meta = metaFor("embed", tableOptions);
+
+ // The per-field value wins over the global one.
+ assertThat(meta)
+ .containsEntry("distance.metric", "inner_product")
+ .containsEntry("index.dimension", "256");
+ // The meta keeps the native key shape; no fields.* keys ever leak
into it.
+ assertThat(meta.keySet()).noneMatch(k -> k.startsWith("fields"));
+ }
+
+ @Test
+ public void testForeignFieldOptionsAreIgnored() {
+ Map<String, String> globalOnly = new HashMap<>();
+ globalOnly.put("lumina.distance.metric", "l2");
+ globalOnly.put("lumina.index.dimension", "128");
+
+ Map<String, String> withForeign = new HashMap<>(globalOnly);
+ // A per-field option that is not a recognized Lumina key (e.g. a
merge/agg option) must be
+ // ignored, never flattened into the index meta as a bogus native key.
+ withForeign.put("fields.embed.aggregate-function", "sum");
+
+ Map<String, String> meta = metaFor("embed", withForeign);
+ assertThat(meta).isEqualTo(metaFor("embed", globalOnly));
+ assertThat(meta).doesNotContainKey("aggregate-function");
+ }
+
+ @Test
+ public void testFieldOptionsForOtherFieldDoNotAffectMeta() {
+ Map<String, String> globalOnly = new HashMap<>();
+ globalOnly.put("lumina.distance.metric", "l2");
+ globalOnly.put("lumina.index.dimension", "128");
+
+ Map<String, String> withOtherField = new HashMap<>(globalOnly);
+ // Per-field config for a different column must not influence "embed".
+ withOtherField.put("fields.other.distance.metric", "inner_product");
+ withOtherField.put("fields.other.index.dimension", "256");
+
+ // The meta for "embed" is byte-for-byte the same as before per-field
support existed.
+ assertThat(metaFor("embed",
withOtherField)).isEqualTo(metaFor("embed", globalOnly));
+ }
+
+ @Test
+ public void testResolvedFieldMetaIsReadable() {
+ Map<String, String> tableOptions = new HashMap<>();
+ tableOptions.put("lumina.distance.metric", "l2");
+ tableOptions.put("lumina.index.dimension", "128");
+ tableOptions.put("fields.embed.distance.metric", "inner_product");
+ tableOptions.put("fields.embed.index.dimension", "256");
+
+ // The reader reconstructs everything it needs from the serialized
meta.
+ LuminaIndexMeta meta = new LuminaIndexMeta(metaFor("embed",
tableOptions));
+ assertThat(meta.dim()).isEqualTo(256);
+ assertThat(meta.distanceMetric()).isEqualTo("inner_product");
+ assertThat(meta.metric()).isEqualTo(LuminaVectorMetric.INNER_PRODUCT);
+ }
+
+ /** Builds the native lumina meta map (what gets serialized into the index
file) for a field. */
+ private static Map<String, String> metaFor(String fieldName, Map<String,
String> tableOptions) {
+ Options resolved =
+ LuminaVectorIndexOptions.resolveFieldOptions(
+ fieldName, Options.fromMap(tableOptions));
+ return new LuminaVectorIndexOptions(resolved).toLuminaOptions();
+ }
}