diff --git a/packaging/pom.xml b/packaging/pom.xml
index df4d33309e31..54566635f81c 100644
--- a/packaging/pom.xml
+++ b/packaging/pom.xml
@@ -453,6 +453,11 @@
hive-standalone-metastore-rest-catalog${project.version}
+
+ org.apache.hive
+ hive-standalone-metastore-search
+ ${project.version}
+ org.apache.hadoophadoop-hdfs-client
diff --git a/standalone-metastore/metastore-search/pom.xml b/standalone-metastore/metastore-search/pom.xml
new file mode 100644
index 000000000000..948357ef80ab
--- /dev/null
+++ b/standalone-metastore/metastore-search/pom.xml
@@ -0,0 +1,219 @@
+
+
+
+
+ hive-standalone-metastore
+ org.apache.hive
+ 4.3.0-SNAPSHOT
+
+ 4.0.0
+ hive-standalone-metastore-search
+ Hive Metastore Search
+
+ ..
+ 1.16.2
+ 1.16.1-beta26
+ 10.4.0
+
+
+
+ org.apache.hive
+ hive-standalone-metastore-server
+ ${hive.version}
+
+
+ org.apache.hadoop
+ hadoop-common
+
+
+ org.slf4j
+ slf4j-log4j12
+
+
+ org.slf4j
+ slf4j-reload4j
+
+
+ ch.qos.reload4j
+ reload4j
+
+
+ commons-logging
+ commons-logging
+
+
+
+
+ org.apache.hadoop
+ hadoop-hdfs-client
+
+
+ org.slf4j
+ slf4j-log4j12
+
+
+ org.slf4j
+ slf4j-reload4j
+
+
+ ch.qos.reload4j
+ reload4j
+
+
+ commons-logging
+ commons-logging
+
+
+
+
+ org.apache.commons
+ commons-lang3
+
+
+ com.google.guava
+ guava
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ dev.langchain4j
+ langchain4j
+ ${langchain4j.version}
+
+
+ dev.langchain4j
+ langchain4j-embeddings-bge-small-en-v15
+ ${langchain4j.embeddings.version}
+
+
+ org.apache.lucene
+ lucene-core
+ ${lucene.version}
+
+
+ org.apache.lucene
+ lucene-analysis-common
+ ${lucene.version}
+
+
+ org.apache.lucene
+ lucene-queryparser
+ ${lucene.version}
+
+
+ org.slf4j
+ slf4j-api
+
+
+ junit
+ junit
+ test
+
+
+ org.apache.hive
+ hive-standalone-metastore-server
+ ${hive.version}
+ tests
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.apache.derby
+ derby
+ test
+
+
+ org.apache.hive.hcatalog
+ hive-hcatalog-server-extensions
+ ${hive.version}
+ test
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+
+
+ setup-test-dirs
+ process-test-resources
+
+ run
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setup-metastore-scripts
+ process-test-resources
+
+ run
+
+
+
+
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ true
+ false
+ -Xmx2048m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.util.concurrent.atomic=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.nio=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED --add-opens java.base/java.util.regex=ALL-UNNAMED --add-opens java.sql/java.sql=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED
+
+ ${project.build.directory}
+ true
+ ${derby.version}
+ ${test.tmp.dir}/derby.log
+ ${test.tmp.dir}
+ jdbc:derby:memory:${test.tmp.dir}/junit_metastore_db;create=true
+ false
+ ${test.tmp.dir}
+ ${test.warehouse.scheme}${test.warehouse.dir}
+ ${test.warehouse.scheme}${test.warehouse.external.dir}
+
+
+ ${project.build.testOutputDirectory}
+
+
+
+
+
+
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexConfig.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexConfig.java
new file mode 100644
index 000000000000..996683b838c2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexConfig.java
@@ -0,0 +1,156 @@
+/*
+ * 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.hive.search.config;
+
+import java.time.Duration;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.conf.Configuration;
+
+public record IndexConfig(Configuration configuration) {
+ public static final String INDEX_RAM_SIZE = "metastore.index.write.ram.size";
+ public static final int INDEX_RAM_SIZE_DEFAULT = 128 * 1024 * 1024;
+
+ public static final String FLUSH_INTERVAL_MS = "metastore.index.flush.interval.ms";
+ public static final long FLUSH_INTERVAL_MS_DEFAULT = 60 * 1000L;
+
+ public static final String INDEX_NAME = "metastore.index.name";
+ public static final String INDEX_NAME_DEFAULT = "hive_tables";
+
+ public static final String POLL_NOTIFICATION_INTERVAL = "metastore.index.poll.notification.ms";
+ public static final long POLL_NOTIFICATION_INTERVAL_DEFAULT = 1000;
+
+ public static final String INDEX_SYNC_INTERVAL = "metastore.index.sync.interval.minutes";
+ public static final long INDEX_SYNC_INTERVAL_DEFAULT = 360;
+
+ /** Force a metadata-only commit when this many events are ahead of the last committed checkpoint. */
+ public static final String FORCE_FLUSH_EVENT_GAP = "metastore.index.force.flush.event.gap";
+ public static final long FORCE_FLUSH_EVENT_GAP_DEFAULT = 3000L;
+
+ /** Tables per metastore fetch batch during leader bootstrap. */
+ public static final String BOOTSTRAP_BATCH_SIZE = "metastore.index.bootstrap.batch.size";
+ public static final int BOOTSTRAP_BATCH_SIZE_DEFAULT = 2000;
+
+ /** In-flight document batches between fetch workers and the index writer. */
+ public static final String BOOTSTRAP_QUEUE_DEPTH = "metastore.index.bootstrap.queue.depth";
+ public static final int BOOTSTRAP_QUEUE_DEPTH_DEFAULT = 16;
+
+ /** Parallel metastore fetch workers during bootstrap. */
+ public static final String BOOTSTRAP_FETCH_THREADS = "metastore.index.bootstrap.fetch.threads";
+ public static final int BOOTSTRAP_FETCH_THREADS_DEFAULT = 4;
+
+ /** Lucene commits after this many Lucene auto-flushes during incremental indexing. 0 commits immediately. */
+ public static final String COMMIT_FLUSHES = "metastore.index.commit.flushes";
+ public static final int COMMIT_FLUSHES_DEFAULT = 3;
+
+ /** Progress log interval during bootstrap. */
+ public static final String BOOTSTRAP_PROGRESS_INTERVAL_MS =
+ "metastore.index.bootstrap.progress.interval.ms";
+ public static final long BOOTSTRAP_PROGRESS_INTERVAL_MS_DEFAULT = 30_000L;
+
+ /** Max HMS events fetched per poll. */
+ public static final String EVENT_POLL_MAX = "metastore.index.event.poll.max";
+ public static final int EVENT_POLL_MAX_DEFAULT = 3000;
+
+ /** Consecutive batch failures before falling back to single-event apply. */
+ public static final String EVENT_BATCH_MAX_FAILURES =
+ "metastore.index.event.batch.max.failures";
+ public static final int EVENT_BATCH_MAX_FAILURES_DEFAULT = 3;
+
+ /** Extra sleep after a failed batch before the poller retries (ms). */
+ public static final String EVENT_FAILURE_BACKOFF_MS = "metastore.index.event.failure.backoff.ms";
+ public static final long EVENT_FAILURE_BACKOFF_MS_DEFAULT = 10_000L;
+
+ /** Skip a single poison event during individual fallback (index may diverge for that table). */
+ public static final String EVENT_SKIP_POISON = "metastore.index.event.skip.poison";
+ public static final boolean EVENT_SKIP_POISON_DEFAULT = false;
+
+ /** Poller sleep after index is marked unhealthy (ms). */
+ public static final String EVENT_UNHEALTHY_BACKOFF_MS = "metastore.index.event.unhealthy.backoff.ms";
+ public static final long EVENT_UNHEALTHY_BACKOFF_MS_DEFAULT = 10 * 60 * 1000L;
+
+ public int getWriteBufferSize() {
+ return configuration.getInt(INDEX_RAM_SIZE, INDEX_RAM_SIZE_DEFAULT);
+ }
+
+ public Duration getFlushInterval() {
+ return Duration.ofMillis(configuration.getLong(FLUSH_INTERVAL_MS, FLUSH_INTERVAL_MS_DEFAULT));
+ }
+
+ public double getWriteBufferSizeMb() {
+ return getWriteBufferSize() / (1024.0 * 1024.0);
+ }
+
+ public long getPollNotificationInterval() {
+ return configuration.getLong(POLL_NOTIFICATION_INTERVAL, POLL_NOTIFICATION_INTERVAL_DEFAULT);
+ }
+
+ public long getSyncInterval() {
+ return configuration.getLong(INDEX_SYNC_INTERVAL, INDEX_SYNC_INTERVAL_DEFAULT) * 60 * 1000;
+ }
+
+ public long getForceFlushEventGap() {
+ return configuration.getLong(FORCE_FLUSH_EVENT_GAP, FORCE_FLUSH_EVENT_GAP_DEFAULT);
+ }
+
+ public int getBootstrapBatchSize() {
+ return configuration.getInt(BOOTSTRAP_BATCH_SIZE, BOOTSTRAP_BATCH_SIZE_DEFAULT);
+ }
+
+ public int getBootstrapQueueDepth() {
+ return configuration.getInt(BOOTSTRAP_QUEUE_DEPTH, BOOTSTRAP_QUEUE_DEPTH_DEFAULT);
+ }
+
+ public int getBootstrapFetchThreads() {
+ return configuration.getInt(BOOTSTRAP_FETCH_THREADS, BOOTSTRAP_FETCH_THREADS_DEFAULT);
+ }
+
+ public int getCommitFlushes() {
+ return configuration.getInt(COMMIT_FLUSHES, COMMIT_FLUSHES_DEFAULT);
+ }
+
+ public long getBootstrapProgressIntervalMs() {
+ return configuration.getLong(BOOTSTRAP_PROGRESS_INTERVAL_MS,
+ BOOTSTRAP_PROGRESS_INTERVAL_MS_DEFAULT);
+ }
+
+ public int getEventPollMax() {
+ return configuration.getInt(EVENT_POLL_MAX, EVENT_POLL_MAX_DEFAULT);
+ }
+
+ public int getEventBatchMaxFailures() {
+ return configuration.getInt(EVENT_BATCH_MAX_FAILURES, EVENT_BATCH_MAX_FAILURES_DEFAULT);
+ }
+
+ public long getEventFailureBackoffMs() {
+ return configuration.getLong(EVENT_FAILURE_BACKOFF_MS, EVENT_FAILURE_BACKOFF_MS_DEFAULT);
+ }
+
+ public boolean isEventSkipPoison() {
+ return configuration.getBoolean(EVENT_SKIP_POISON, EVENT_SKIP_POISON_DEFAULT);
+ }
+
+ public long getEventUnhealthyBackoffMs() {
+ return configuration.getLong(EVENT_UNHEALTHY_BACKOFF_MS, EVENT_UNHEALTHY_BACKOFF_MS_DEFAULT);
+ }
+
+ public String indexName() {
+ String name = configuration.get(INDEX_NAME);
+ return StringUtils.isEmpty(name) ? INDEX_NAME_DEFAULT : name;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexStateConfig.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexStateConfig.java
new file mode 100644
index 000000000000..f30a82131cb1
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexStateConfig.java
@@ -0,0 +1,64 @@
+/*
+ * 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.hive.search.config;
+
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.exception.IndexIOException;
+
+public record IndexStateConfig(Configuration configuration, String indexName) {
+ public static final String LOCAL_PATH = "metastore.index.local.path";
+ public static final String REMOTE_URI = "metastore.index.backup.remote.uri";
+ public static final String MEMORY = "metastore.index.use.memory";
+
+ public static final Path DEFAULT_WORKDIR = Paths.get(System.getProperty("user.dir"), "indexes");
+
+ public Path getLocalPath() {
+ String path = configuration.get(LOCAL_PATH, DEFAULT_WORKDIR.toString());
+ return Path.of(path);
+ }
+
+ public String getRemoteUri() {
+ return configuration.get(REMOTE_URI, "");
+ }
+
+ public boolean hasRemote() {
+ String uri = getRemoteUri();
+ return StringUtils.isNotEmpty(uri);
+ }
+
+ public boolean useMemory() {
+ return configuration.getBoolean(MEMORY, false);
+ }
+
+ public boolean isDistributed() {
+ return hasRemote();
+ }
+
+ public static void validateRemoteUri(String uriText) throws IndexIOException {
+ try {
+ URI.create(uriText);
+ } catch (IllegalArgumentException e) {
+ throw new IndexIOException("invalid store.remote.uri: " + uriText, e);
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/InferenceConfig.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/InferenceConfig.java
new file mode 100644
index 000000000000..89104bc2e69a
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/InferenceConfig.java
@@ -0,0 +1,130 @@
+/*
+ * 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.hive.search.config;
+
+import java.io.IOException;
+import java.net.URI;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileStatus;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hive.search.exception.InitializeException;
+import org.apache.hive.search.inference.EmbeddingPrompt;
+
+public record InferenceConfig(Configuration configuration) {
+ public static final String MODEL_ONNX_FILE = "model.onnx";
+ public static final String TOKENIZER = "tokenizer.json";
+
+ public static final String MODEL_LOCAL_DIR = "metastore.inference.local.dir";
+ public static final String MODEL_LOCAL_DIR_DEFAULT = System.getProperty("java.io.tmpdir") + "/hivesearch-cache";
+ public static final String MODEL_REMOTE_DIR = "metastore.inference.remote.dir";
+
+ public static final String MODEL_NAME = "metastore.inference.embedding.name";
+ public static final String EMBEDDING_PROMPT_DOC = "metastore.inference.embedding.prompt.doc";
+ public static final String EMBEDDING_PROMPT_QUERY = "metastore.inference.embedding.prompt.query";
+
+ public static final String EMBEDDING_CACHE_ENABLED = "metastore.inference.embedding.cache.enabled";
+ public static final boolean EMBEDDING_CACHE_ENABLED_DEFAULT = true;
+
+ public static final String EMBEDDING_CACHE_MAX_ENTRIES = "metastore.inference.embedding.cache.max.entries";
+ public static final int EMBEDDING_CACHE_MAX_ENTRIES_DEFAULT = 100_000;
+
+ public EmbeddingModelSpec embedding() throws InitializeException, IOException {
+ String modelName = modelName();
+ if (StringUtils.isEmpty(modelName)) {
+ throw new InitializeException("No model configured for embedding the search");
+ }
+ URI modelPath = URI.create(configuration.get(MODEL_LOCAL_DIR, MODEL_LOCAL_DIR_DEFAULT));
+ Path lPath = new Path("file://" + modelPath.getPath());
+ if (requireConfigured(lPath, modelName)) {
+ String remotePath = configuration.get(MODEL_REMOTE_DIR);
+ String message = "Can't find the model or tokenizer in %s for " + modelName;
+ if (StringUtils.isEmpty(remotePath)) {
+ throw new InitializeException(String.format(message, lPath));
+ }
+ Path rPath = new Path(remotePath);
+ if (requireConfigured(rPath, modelName)) {
+ throw new InitializeException(String.format(message, rPath));
+ }
+ Path dest = new Path(lPath, modelName);
+ Path src = new Path(rPath, modelName);
+ lPath.getFileSystem(configuration).mkdirs(dest);
+ FileSystem fileSystem = rPath.getFileSystem(configuration);
+ fileSystem.copyToLocalFile(new Path(src, MODEL_ONNX_FILE), new Path(dest, MODEL_ONNX_FILE));
+ fileSystem.copyToLocalFile(new Path(src, TOKENIZER), new Path(dest, TOKENIZER));
+ }
+ EmbeddingPrompt prompt = new EmbeddingPrompt(configuration.get(EMBEDDING_PROMPT_DOC, "passage: "),
+ configuration.get(EMBEDDING_PROMPT_QUERY, "query: "));
+ return new EmbeddingModelSpec(modelName, new Path(lPath, modelName), prompt);
+ }
+
+ private boolean requireConfigured(Path modelPath, String modelName) {
+ try {
+ FileSystem fileSystem = modelPath.getFileSystem(configuration);
+ FileStatus[] fileStatuses = fileSystem.listStatus(new Path(modelPath, modelName), path -> {
+ String fileName = path.getName();
+ return fileName.equals(MODEL_ONNX_FILE) || fileName.equals(TOKENIZER);
+ });
+ if (fileStatuses == null || fileStatuses.length != 2) {
+ return true;
+ }
+ return fileStatuses[0].isDirectory() || fileStatuses[1].isDirectory();
+ } catch (IOException e) {
+ return true;
+ }
+ }
+
+ public String modelName() {
+ return configuration.get(MODEL_NAME);
+ }
+
+ public boolean isEmbeddingCacheEnabled() {
+ return configuration.getBoolean(EMBEDDING_CACHE_ENABLED, EMBEDDING_CACHE_ENABLED_DEFAULT);
+ }
+
+ public int getEmbeddingCacheMaxEntries() {
+ return configuration.getInt(EMBEDDING_CACHE_MAX_ENTRIES, EMBEDDING_CACHE_MAX_ENTRIES_DEFAULT);
+ }
+
+ public static class EmbeddingModelSpec {
+ private final String model;
+ private final java.nio.file.Path modelDir;
+ private final EmbeddingPrompt prompt;
+
+ public EmbeddingModelSpec (String model, Path path, EmbeddingPrompt promp) {
+ this.model = model;
+ this.prompt = promp == null ?
+ EmbeddingPrompt.none() : promp;
+ this.modelDir = java.nio.file.Path.of(path.toUri());
+ }
+
+ public String getModel() {
+ return model;
+ }
+
+ public java.nio.file.Path getModelDir() {
+ return modelDir;
+ }
+
+ public EmbeddingPrompt getPrompt() {
+ return prompt;
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/SearchConfig.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/SearchConfig.java
new file mode 100644
index 000000000000..4def067e0a93
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/SearchConfig.java
@@ -0,0 +1,86 @@
+/*
+ * 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.hive.search.config;
+
+import java.time.Duration;
+
+import org.apache.hadoop.conf.Configuration;
+
+public record SearchConfig(Configuration configuration) {
+ public static final String REFRESH_INTERVAL_SECONDS = "metastore.search.refresh.interval.seconds";
+ public static final long REFRESH_INTERVAL_SECONDS_DEFAULT = 1L;
+
+ public static final String DEFAULT_LIMIT = "metastore.search.default.limit";
+ public static final int DEFAULT_LIMIT_DEFAULT = 10;
+
+ public static final String INIT_READY_TIMEOUT_MS = "metastore.search.init.ready.timeout.ms";
+ public static final long INIT_READY_TIMEOUT_MS_DEFAULT = 300L;
+
+ public static final String HYBRID_SEMANTIC_WEIGHT = "metastore.search.hybrid.semantic.weight";
+ public static final float HYBRID_SEMANTIC_WEIGHT_DEFAULT = 0.4f;
+
+ public static final String FUSION_PRIOR = "metastore.search.fusion.prior";
+ public static final float FUSION_PRIOR_DEFAULT = 0.5f;
+
+ public static final String BAYESIAN_SAMPLES = "metastore.search.bayesian.samples";
+ public static final int BAYESIAN_SAMPLES_DEFAULT = 100;
+
+ public static final String BAYESIAN_TOKENS_PER_QUERY = "metastore.search.bayesian.tokens.per.query";
+ public static final int BAYESIAN_TOKENS_PER_QUERY_DEFAULT = 5;
+
+ public static final String BAYESIAN_SEED = "metastore.search.bayesian.seed";
+ public static final long BAYESIAN_SEED_DEFAULT = 42L;
+
+ public Duration getRefreshInterval() {
+ return Duration.ofSeconds(
+ configuration.getLong(REFRESH_INTERVAL_SECONDS, REFRESH_INTERVAL_SECONDS_DEFAULT));
+ }
+
+ public int getDefaultLimit() {
+ return configuration.getInt(DEFAULT_LIMIT, DEFAULT_LIMIT_DEFAULT);
+ }
+
+ public long getInitReadyTimeoutMs() {
+ return configuration.getLong(INIT_READY_TIMEOUT_MS, INIT_READY_TIMEOUT_MS_DEFAULT);
+ }
+
+ public float getHybridSemanticWeight() {
+ float weight = configuration.getFloat(HYBRID_SEMANTIC_WEIGHT, HYBRID_SEMANTIC_WEIGHT_DEFAULT);
+ if (weight >= 1 || weight <= 0) {
+ throw new IllegalArgumentException("metastore.search.hybrid.semantic.weight " +
+ "should be configured as between 0.0 and 1.0");
+ }
+ return weight;
+ }
+
+ public float getFusionPrior() {
+ return configuration.getFloat(FUSION_PRIOR, FUSION_PRIOR_DEFAULT);
+ }
+
+ public int getBayesianSamples() {
+ return configuration.getInt(BAYESIAN_SAMPLES, BAYESIAN_SAMPLES_DEFAULT);
+ }
+
+ public int getBayesianTokensPerQuery() {
+ return configuration.getInt(BAYESIAN_TOKENS_PER_QUERY, BAYESIAN_TOKENS_PER_QUERY_DEFAULT);
+ }
+
+ public long getBayesianSeed() {
+ return configuration.getLong(BAYESIAN_SEED, BAYESIAN_SEED_DEFAULT);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexException.java
new file mode 100644
index 000000000000..24a5dd13f801
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexException.java
@@ -0,0 +1,36 @@
+/*
+ * 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.hive.search.exception;
+
+/** Failure while building, updating, or embedding content for the search index. */
+public class IndexException extends Exception {
+ public IndexException(String message) {
+ super(message);
+ }
+
+ public IndexException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static IndexException wrap(String message, Throwable cause) {
+ if (cause instanceof IndexException indexException) {
+ return indexException;
+ }
+ return new IndexException(message, cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexIOException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexIOException.java
new file mode 100644
index 000000000000..dcb7685805b6
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexIOException.java
@@ -0,0 +1,38 @@
+/*
+ * 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.hive.search.exception;
+
+import java.io.IOException;
+
+/** Failure while reading, writing, syncing, or restoring index files and manifests. */
+public class IndexIOException extends IOException {
+ public IndexIOException(String message) {
+ super(message);
+ }
+
+ public IndexIOException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static IndexIOException wrap(Exception cause) {
+ if (cause instanceof IndexIOException indexIOException) {
+ return indexIOException;
+ }
+ return new IndexIOException(cause.getMessage(), cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotHealthyException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotHealthyException.java
new file mode 100644
index 000000000000..da1d5f2f5689
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotHealthyException.java
@@ -0,0 +1,30 @@
+/*
+ * 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.hive.search.exception;
+
+import java.io.IOException;
+
+public class IndexNotHealthyException extends IOException {
+ public IndexNotHealthyException(String message) {
+ super(message);
+ }
+
+ public IndexNotHealthyException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotReadyException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotReadyException.java
new file mode 100644
index 000000000000..518bb42d65df
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotReadyException.java
@@ -0,0 +1,30 @@
+/*
+ * 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.hive.search.exception;
+
+import java.io.IOException;
+
+public class IndexNotReadyException extends IOException {
+ public IndexNotReadyException(String message) {
+ super(message);
+ }
+
+ public IndexNotReadyException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/InitializeException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/InitializeException.java
new file mode 100644
index 000000000000..77e935b5e1c1
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/InitializeException.java
@@ -0,0 +1,36 @@
+/*
+ * 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.hive.search.exception;
+
+/** Failure while opening or bootstrapping the search service and its dependencies. */
+public class InitializeException extends Exception {
+ public InitializeException(String message) {
+ super(message);
+ }
+
+ public InitializeException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static InitializeException wrap(String message, Throwable cause) {
+ if (cause instanceof InitializeException initializeException) {
+ return initializeException;
+ }
+ return new InitializeException(message, cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/SearchException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/SearchException.java
new file mode 100644
index 000000000000..e68e1a929a7b
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/SearchException.java
@@ -0,0 +1,29 @@
+/*
+ * 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.hive.search.exception;
+
+/** Failure while executing or preparing a table search request. */
+public class SearchException extends Exception {
+ public SearchException(String message) {
+ super(message);
+ }
+
+ public SearchException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/FlushTrackingWriter.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/FlushTrackingWriter.java
new file mode 100644
index 000000000000..ff3b13620f01
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/FlushTrackingWriter.java
@@ -0,0 +1,48 @@
+/*
+ * 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.hive.search.index;
+
+import java.io.IOException;
+
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.store.Directory;
+
+/** Tracks Lucene auto-flushes so callers can gate commits separately. */
+final class FlushTrackingWriter extends IndexWriter {
+ private int flushesSinceCommit;
+
+ FlushTrackingWriter(Directory directory, IndexWriterConfig config)
+ throws IOException {
+ super(directory, config);
+ }
+
+ int flushesSinceCommit() {
+ return flushesSinceCommit;
+ }
+
+ void resetFlushTracking() {
+ flushesSinceCommit = 0;
+ }
+
+ @Override
+ protected void doAfterFlush() throws IOException {
+ super.doAfterFlush();
+ flushesSinceCommit++;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexManager.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexManager.java
new file mode 100644
index 000000000000..3fb795b840ab
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexManager.java
@@ -0,0 +1,184 @@
+/*
+ * 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.hive.search.index;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Optional;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+import org.apache.hive.search.index.manifest.IndexManifest;
+import org.apache.hive.search.exception.IndexIOException;
+import org.apache.hive.search.index.store.LocalStateClient;
+import org.apache.hive.search.index.store.IndexBackupUtils;
+import org.apache.hive.search.index.store.IndexStateClient;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.metastore.MetastoreEventListener;
+import org.apache.lucene.store.ByteBuffersDirectory;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.MMapDirectory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class IndexManager implements AutoCloseable, MetastoreEventListener {
+ private static final Logger LOG = LoggerFactory.getLogger(IndexManager.class);
+ private final IndexMapping mapping;
+ private final Directory directory;
+ private final IndexStateClient localIndex;
+ private final IndexStateClient remoteIndex;
+ private volatile IndexNotHealthyException exception;
+
+ public IndexManager(IndexMapping mapping, Directory directory, IndexStateClient localIndex,
+ IndexStateClient remoteIndex) {
+ this.mapping = mapping;
+ this.directory = directory;
+ this.localIndex = localIndex;
+ this.remoteIndex = remoteIndex;
+ }
+
+ public static IndexManager open(IndexMapping mapping, Configuration conf) throws IOException {
+ IndexStateConfig store = mapping.store();
+ Directory directory;
+ if (store.useMemory()) {
+ directory = new ByteBuffersDirectory();
+ } else {
+ directory = openDiskDirectory(mapping.indexName(), store.getLocalPath());
+ }
+
+ IndexStateClient local =
+ new LocalStateClient(directory, mapping.indexName());
+ IndexStateClient remote = null;
+ if (store.hasRemote()) {
+ remote = IndexBackupUtils.openRemote(store.getRemoteUri(), mapping.indexName(), conf);
+ }
+ return new IndexManager(mapping, directory, local, remote);
+ }
+
+ private static Directory openDiskDirectory(String indexName, Path basePath) throws IOException {
+ Path indexPath = basePath.resolve(indexName);
+ Files.createDirectories(indexPath);
+ return new MMapDirectory(indexPath);
+ }
+
+ public boolean hasBackup() {
+ return remoteIndex != null;
+ }
+
+ public boolean syncBackup() throws IOException {
+ if (remoteIndex == null) {
+ return false;
+ }
+ if (localIndex.readManifest().isEmpty()) {
+ return false;
+ }
+ return IndexBackupUtils.syncToBackup(localIndex, remoteIndex);
+ }
+
+ public boolean restoreBackup() {
+ try {
+ if (remoteIndex == null) {
+ return false;
+ }
+ return IndexBackupUtils.restoreFromBackup(localIndex, remoteIndex);
+ } catch (IOException e) {
+ LOG.warn("Cannot restore the index from remote backup", e);
+ return false;
+ }
+ }
+
+ public void resolveInterruptedRestore() {
+ try {
+ IndexBackupUtils.resolveInterruptedRestore(localIndex);
+ } catch (IOException e) {
+ LOG.warn("Cannot resolve interrupted local index restore", e);
+ }
+ }
+
+ public Optional readLocalManifest() {
+ try {
+ return localIndex.readManifest();
+ } catch (IOException e) {
+ LOG.warn("Cannot read the local index manifest", e);
+ return Optional.empty();
+ }
+ }
+
+ public Optional readRemoteManifest() {
+ try {
+ if (remoteIndex == null) {
+ return Optional.empty();
+ }
+ return remoteIndex.readManifest();
+ } catch (IOException e) {
+ LOG.warn("Cannot read the remote index manifest", e);
+ return Optional.empty();
+ }
+ }
+
+ public void clearLocalIndex() throws IOException {
+ localIndex.clear();
+ }
+
+ public void clearRemoteIndex() throws IOException {
+ if (remoteIndex != null) {
+ remoteIndex.clear();
+ }
+ }
+
+ public void notifyIndexState(boolean healthy, IndexNotHealthyException... e) {
+ if (healthy) {
+ exception = null;
+ return;
+ }
+ if (e.length > 0 && e[0] != null) {
+ exception = e[0];
+ }
+ }
+
+ public void checkIndexState() throws IndexNotHealthyException {
+ if (exception != null) {
+ throw exception;
+ }
+ }
+
+ public IndexMapping mapping() {
+ return mapping;
+ }
+
+ public Directory directory() {
+ return directory;
+ }
+
+ @Override
+ public void close() throws IOException {
+ directory.close();
+ if (remoteIndex instanceof AutoCloseable closeable) {
+ try {
+ closeable.close();
+ } catch (Exception e) {
+ if (e instanceof IOException ioe) {
+ throw ioe;
+ }
+ throw new IndexIOException("Failed to close remote index client", e);
+ }
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexSession.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexSession.java
new file mode 100644
index 000000000000..4632835f4ec0
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexSession.java
@@ -0,0 +1,122 @@
+/*
+ * 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.hive.search.index;
+
+import java.io.IOException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.exception.IndexNotReadyException;
+import org.apache.hive.search.exception.InitializeException;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hive.search.metastore.MetastoreIndexer;
+import org.apache.hive.search.metastore.MetastoreSchemas;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.apache.hive.search.search.SearchInternal;
+import org.apache.lucene.search.BayesianScoreEstimator;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.SearcherManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Colocated indexer and searcher */
+public final class IndexSession implements AutoCloseable {
+ private static final Logger LOG = LoggerFactory.getLogger(IndexSession.class);
+ private final Configuration configuration;
+ private final IndexManager indexManager;
+ private final EmbedModelRegistry modelRegistry;
+ private final SearchConfig searchConfig;
+
+ private BayesianScoreEstimator.Parameters parameters;
+ private MetastoreIndexer metastoreIndexer;
+ private SearcherManager searcherManager;
+ private Indexer indexer;
+
+ private final ScheduledExecutorService service = Executors.newScheduledThreadPool(1);
+
+ public IndexSession(Configuration configuration)
+ throws InitializeException, IOException {
+ this.configuration = configuration;
+ IndexConfig indexConfig = new IndexConfig(configuration);
+ this.searchConfig = new SearchConfig(configuration);
+ InferenceConfig inferenceConfig = new InferenceConfig(configuration);
+ this.indexManager = IndexManager.open(
+ MetastoreSchemas.defaultHiveTablesMapping(indexConfig.indexName(),
+ inferenceConfig.modelName(), configuration), configuration);
+ this.modelRegistry = EmbedModelRegistry.create(configuration);
+ }
+
+ public void maybeRefreshIndex() {
+ try {
+ searcherManager.maybeRefresh();
+ } catch (IOException e) {
+ LOG.info("Error while refreshing the index", e);
+ }
+ }
+
+ public void initialize() throws Exception {
+ indexer = new Indexer(indexManager, modelRegistry);
+ metastoreIndexer = new MetastoreIndexer(configuration, indexManager, indexer);
+ metastoreIndexer.start();
+ searcherManager = new SearcherManager(indexer.writer(), null);
+ service.scheduleAtFixedRate(this::maybeRefreshIndex, 0,
+ searchConfig.getRefreshInterval().toSeconds(), TimeUnit.SECONDS);
+ IndexSearcher searcher = searcherManager.acquire();
+ try {
+ parameters = BayesianScoreEstimator.estimate(searcher,
+ MetastoreTableMapper.FIELD_SEARCH_TEXT,
+ searchConfig.getBayesianSamples(),
+ searchConfig.getBayesianTokensPerQuery(),
+ searchConfig.getBayesianSeed());
+ LOG.info("BayesianScore alpha={} beta={} baseRate={}",
+ parameters.alpha(), parameters.beta(), parameters.baseRate());
+ } finally {
+ searcherManager.release(searcher);
+ }
+ }
+
+ public SearchInternal getSearcher() throws IOException {
+ if (parameters == null) {
+ throw new IndexNotReadyException("Index session is not ready for search requests");
+ }
+ indexManager.checkIndexState();
+ return new SearchInternal(searcherManager, indexManager,
+ modelRegistry, searchConfig, parameters);
+ }
+
+ @Override
+ public void close() throws Exception {
+ service.shutdown();
+ if (searcherManager != null) {
+ searcherManager.close();
+ }
+ if (metastoreIndexer != null) {
+ metastoreIndexer.close();
+ }
+ if (indexer != null) {
+ indexer.close();
+ }
+ indexManager.close();
+ modelRegistry.close();
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/Indexer.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/Indexer.java
new file mode 100644
index 000000000000..9082112f462e
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/Indexer.java
@@ -0,0 +1,306 @@
+/*
+ * 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.hive.search.index;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ListMultimap;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.hadoop.hive.common.DatabaseName;
+import org.apache.hadoop.hive.metastore.Batchable;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.inference.EmbedModel;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hive.search.inference.EmbeddingCache;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.mapping.field.Field;
+import org.apache.hive.search.mapping.field.TextField;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.IndexCommit;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.KeepOnlyLastCommitDeletionPolicy;
+import org.apache.lucene.index.SnapshotDeletionPolicy;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.BooleanClause;
+import org.apache.lucene.search.BooleanQuery;
+import org.apache.lucene.search.PrefixQuery;
+import org.apache.lucene.search.Query;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class Indexer implements AutoCloseable {
+ private static final Logger LOG = LoggerFactory.getLogger(Indexer.class);
+ private static final int EMBED_BATCH_SIZE = 10000;
+
+ private final IndexManager indexManager;
+ private final EmbedModelRegistry modelRegistry;
+ private final EmbeddingCache embeddingCache;
+ private SnapshotDeletionPolicy snapshotter;
+ private FlushTrackingWriter writer;
+ private int commitFlushThreshold;
+
+ public Indexer(IndexManager index, EmbedModelRegistry registry) {
+ this.indexManager = index;
+ this.modelRegistry = registry;
+ this.embeddingCache = registry.embeddingCache();
+ this.commitFlushThreshold =
+ new IndexConfig(index.mapping().configuration()).getCommitFlushes();
+ }
+
+ /** Package-private for bootstrap to apply a different commit threshold temporarily. */
+ public void setCommitFlushThreshold(int commitFlushThreshold) {
+ this.commitFlushThreshold = Math.max(0, commitFlushThreshold);
+ }
+
+ int flushesSinceCommit() {
+ return writer.flushesSinceCommit();
+ }
+
+ public void initialize() throws IOException {
+ IndexWriterConfig config =
+ new IndexWriterConfig(indexManager.mapping().analyzer())
+ .setCommitOnClose(false)
+ .setRAMBufferSizeMB(indexManager.mapping().config().getWriteBufferSizeMb());
+ SnapshotDeletionPolicy snapshotDeletionPolicy =
+ new SnapshotDeletionPolicy(new KeepOnlyLastCommitDeletionPolicy());
+ config.setIndexDeletionPolicy(snapshotDeletionPolicy);
+ this.snapshotter = snapshotDeletionPolicy;
+ this.writer = new FlushTrackingWriter(indexManager.directory(), config);
+ }
+
+ public void syncBackup() throws IOException {
+ if (!indexManager.hasBackup()) {
+ return;
+ }
+ IndexCommit snapshot = snapshotter.snapshot();
+ try {
+ indexManager.syncBackup();
+ } finally {
+ snapshotter.release(snapshot);
+ }
+ }
+
+ /** Writes already-embedded documents to Lucene. */
+ private void writeDocuments(List docs) throws IOException, IndexException {
+ List luceneDocs = new ArrayList<>();
+ List ids = new ArrayList<>();
+ for (TableDocument doc : docs) {
+ ids.add(doc.idField().value());
+ luceneDocs.addAll(doc.toDocuments());
+ }
+ delete(ids.toArray(new String[0]));
+ writer.addDocuments(luceneDocs);
+ }
+
+ public List embedDocuments(List tableDocs)
+ throws IndexException {
+ long start = System.currentTimeMillis();
+ List result = new ArrayList<>(tableDocs.size());
+ Map> modelPerTxt = new HashMap<>();
+ for (TableDocument doc : tableDocs) {
+ TableDocument newDoc = new TableDocument(doc.idField(), List.of(), indexManager.mapping());
+ result.add(newDoc);
+ for (Field field : doc.fields()) {
+ if (field instanceof org.apache.hive.search.mapping.field.IdField) {
+ continue;
+ }
+ if (field instanceof TextField text) {
+ FieldSchema schema = indexManager.mapping().fieldSchema(text.name());
+ if (schema instanceof FieldSchema.TextFieldSchema textSchema
+ && textSchema.search().semantic()) {
+ if (text.embedding() != null) {
+ newDoc.appendField(field);
+ } else {
+ String modelRef = textSchema.search().semanticModel();
+ ListMultimap multimapText =
+ modelPerTxt.computeIfAbsent(modelRef, s -> ArrayListMultimap.create());
+ multimapText.put(text, newDoc);
+ }
+ } else {
+ newDoc.appendField(field);
+ }
+ } else {
+ newDoc.appendField(field);
+ }
+ }
+ }
+ int totalFields = modelPerTxt.values().stream().mapToInt(ListMultimap::size).sum();
+ long cacheHitsBefore = embeddingCache.hits();
+ long cacheMissesBefore = embeddingCache.misses();
+ for (Map.Entry> entry : modelPerTxt.entrySet()) {
+ embedInBatch(entry.getKey(), modelRegistry.get(entry.getKey()), entry.getValue());
+ }
+ if (totalFields > 0) {
+ long cacheHits = embeddingCache.hits() - cacheHitsBefore;
+ long cacheMisses = embeddingCache.misses() - cacheMissesBefore;
+ LOG.info("Embedded {} semantic field(s) across {} document(s) in {}ms"
+ + " (embedding cache hits={}, misses={})",
+ totalFields, tableDocs.size(), System.currentTimeMillis() - start, cacheHits, cacheMisses);
+ }
+ return result;
+ }
+
+ private void embedInBatch(String modelRef, EmbedModel embedModel,
+ ListMultimap textDocs) throws IndexException {
+ int uniqueTexts = textDocs.keySet().stream()
+ .map(TextField::value)
+ .collect(java.util.stream.Collectors.toSet())
+ .size();
+ long start = System.currentTimeMillis();
+ try {
+ Batchable.runBatched(EMBED_BATCH_SIZE, new ArrayList<>(textDocs.keySet()),
+ new Batchable() {
+ @Override
+ public List run(List batchFields) throws IndexException {
+ long batchStart = System.currentTimeMillis();
+ ListMultimap valueToTxt = ArrayListMultimap.create();
+ batchFields.forEach(f -> valueToTxt.put(f.value(), f));
+ List missTexts = new ArrayList<>();
+ for (String text : valueToTxt.keySet()) {
+ Optional cached =
+ embeddingCache.get(modelRef, EmbedModel.TaskType.DOCUMENT, text);
+ if (cached.isPresent()) {
+ applyEmbedding(valueToTxt, textDocs, text, cached.get());
+ } else {
+ missTexts.add(text);
+ }
+ }
+ if (!missTexts.isEmpty()) {
+ String[] texts = missTexts.toArray(new String[0]);
+ float[][] embeddings = embedModel.encodeBatch(EmbedModel.TaskType.DOCUMENT, texts);
+ for (int i = 0; i < texts.length; i++) {
+ String text = texts[i];
+ float[] embedding = embeddings[i];
+ embeddingCache.put(modelRef, EmbedModel.TaskType.DOCUMENT, text, embedding);
+ applyEmbedding(valueToTxt, textDocs, text, embedding);
+ }
+ }
+ LOG.debug("Model '{}' embedded batch of {} unique text(s), {} cache miss(es) in {}ms",
+ modelRef, valueToTxt.keySet().size(), missTexts.size(),
+ System.currentTimeMillis() - batchStart);
+ return List.of();
+ }
+ });
+ } catch (Exception e) {
+ throw IndexException.wrap("Error while embedding the documents with model '" + modelRef + "'",
+ e);
+ }
+ LOG.info("Model '{}' embedded {} field(s) from {} unique text(s) in {}ms",
+ modelRef, textDocs.size(), uniqueTexts, System.currentTimeMillis() - start);
+ }
+
+ private void applyEmbedding(ListMultimap valueToTxt,
+ ListMultimap textDocs, String text, float[] embedding) {
+ for (TextField tf : valueToTxt.get(text)) {
+ for (TableDocument document : textDocs.get(tf)) {
+ document.appendField(tf.withEmbedding(embedding));
+ }
+ }
+ }
+
+ public void addDocuments(List docs) throws IOException, IndexException {
+ writeDocuments(embedDocuments(docs));
+ }
+
+ public IndexWriter writer() {
+ return writer;
+ }
+
+ public EmbeddingCache embeddingCache() {
+ return embeddingCache;
+ }
+
+ /**
+ * Commits the index when forced or after enough Lucene auto-flushes.
+ * Lucene continues to flush segments automatically based on RAM buffer settings.
+ */
+ public boolean flush(long lastEventId, boolean force)
+ throws IOException {
+ if (!force && (!hasPendingChanges() || !shouldCommit())) {
+ return false;
+ }
+ String model = indexManager.mapping().inference().modelName();
+ Map metadata = Map.of(
+ "nid", lastEventId + "",
+ "model", model,
+ "commit_time", String.valueOf(System.currentTimeMillis())
+ );
+ writer.setLiveCommitData(metadata.entrySet());
+ long seqnum = writer.commit();
+ if (seqnum < 0) {
+ return false;
+ }
+ writer.resetFlushTracking();
+ return true;
+ }
+
+ private boolean shouldCommit() {
+ return commitFlushThreshold <= 0 || writer.flushesSinceCommit() >= commitFlushThreshold;
+ }
+
+ private boolean hasPendingChanges() {
+ return writer.hasUncommittedChanges()
+ || writer.numRamDocs() > 0
+ || writer.hasDeletions();
+ }
+
+ public int delete(String... docIds) throws IOException {
+ if (docIds == null || docIds.length == 0) {
+ return 0;
+ }
+ int before = writer.getDocStats().numDocs;
+ Term[] terms = Arrays.stream(docIds)
+ .map(id -> new Term("_id" + TableDocument.FILTER_SUFFIX, id))
+ .toArray(Term[]::new);
+ writer.deleteDocuments(terms);
+ int after = writer.getDocStats().numDocs;
+ return before - after;
+ }
+
+ public int deleteDatabases(DatabaseName... databases)
+ throws IOException {
+ if (databases == null || databases.length == 0) {
+ return 0;
+ }
+ int before = writer.getDocStats().numDocs;
+ BooleanQuery.Builder builder = new BooleanQuery.Builder();
+ for (DatabaseName database : databases) {
+ Query query = new PrefixQuery(new Term("_id" + TableDocument.FILTER_SUFFIX,
+ database.getCat() + "." + database.getDb() + "."));
+ builder.add(query, BooleanClause.Occur.SHOULD);
+ }
+ writer.deleteDocuments(builder.build());
+ int after = writer.getDocStats().numDocs;
+ return before - after;
+ }
+
+ @Override
+ public void close() throws IOException {
+ writer.close();
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/manifest/IndexManifest.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/manifest/IndexManifest.java
new file mode 100644
index 000000000000..c70ecc5d353c
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/manifest/IndexManifest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.hive.search.index.manifest;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record IndexManifest(String indexName, List files, String modelName, long lastEventId) {
+ public static final String MANIFEST_FILE_NAME = "index.json";
+ /** Local-only staging copy of the remote manifest while a restore is in progress. */
+ public static final String STAGING_MANIFEST_FILE_NAME = ".index.json";
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ public record IndexFile(String name, long size) {}
+
+ public static IndexManifest create(String indexName, List files, String modelName, long lastEventId) {
+ return new IndexManifest(indexName, List.copyOf(files), modelName, lastEventId);
+ }
+
+ public byte[] toJsonBytes() throws IOException {
+ return JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(this);
+ }
+
+ public static IndexManifest fromJson(byte[] bytes) throws IOException {
+ return JSON.readValue(bytes, IndexManifest.class);
+ }
+
+ public boolean sameFilesAs(IndexManifest other) {
+ return other != null && toMap(files).equals(toMap(other.files()));
+ }
+
+ public List diff(IndexManifest target) {
+ Map sourceMap = toMap(files);
+ Map destMap = target == null ? Map.of() : toMap(target.files());
+ HashSet keys = new HashSet<>();
+ keys.addAll(sourceMap.keySet());
+ keys.addAll(destMap.keySet());
+
+ List ops = new ArrayList<>();
+ for (String key : keys) {
+ Long sourceSize = sourceMap.get(key);
+ Long destSize = destMap.get(key);
+ if (sourceSize != null && sourceSize.equals(destSize)) {
+ continue;
+ }
+ if (sourceSize != null) {
+ ops.add(ChangedFileOp.add(key, sourceSize));
+ } else if (destSize != null) {
+ ops.add(ChangedFileOp.del(key));
+ }
+ }
+ return ops;
+ }
+
+ private static Map toMap(List files) {
+ HashMap map = new HashMap<>();
+ for (IndexFile file : files) {
+ map.put(file.name(), file.size());
+ }
+ return map;
+ }
+
+ public interface ChangedFileOp {
+ record Add(String fileName, Long size) implements ChangedFileOp {}
+
+ record Del(String fileName) implements ChangedFileOp {}
+
+ static Add add(String fileName, Long size) {
+ return new Add(fileName, size);
+ }
+
+ static Del del(String fileName) {
+ return new Del(fileName);
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexBackupUtils.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexBackupUtils.java
new file mode 100644
index 000000000000..697e0f22f2ca
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexBackupUtils.java
@@ -0,0 +1,121 @@
+/*
+ * 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.hive.search.index.store;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.exception.IndexIOException;
+import org.apache.hive.search.index.manifest.IndexManifest;
+
+public final class IndexBackupUtils {
+ private IndexBackupUtils() {}
+
+ /** Push local index state to the backup (remote) location. */
+ public static boolean syncToBackup(IndexStateClient local, IndexStateClient remote)
+ throws IOException {
+ Optional localManifest = local.readManifest();
+ if (localManifest.isEmpty()) {
+ return false;
+ }
+ Optional remoteManifest = remote.readManifest();
+ if (remoteManifest.isPresent()
+ && remoteManifest.get().lastEventId() >= localManifest.get().lastEventId()) {
+ return false;
+ }
+ applyDiff(local, remote, localManifest.get().diff(remoteManifest.orElse(null)));
+ return remote.writeManifest(localManifest.get());
+ }
+
+ /**
+ * If a previous restore left a staging manifest, finalize it when files already match,
+ * or keep it in place so {@link #restoreFromBackup} can resume.
+ */
+ public static void resolveInterruptedRestore(IndexStateClient local)
+ throws IOException {
+ Optional staging = local.readStagingManifest();
+ if (staging.isEmpty()) {
+ local.clearStagingManifest();
+ return;
+ }
+ IndexManifest target = staging.get();
+ if (target.sameFilesAs(local.readLocalFileManifest())) {
+ local.validateRestoredIndex(target);
+ local.clearStagingManifest();
+ }
+ }
+
+ /** Pull backup (remote) index state into the local directory. */
+ public static boolean restoreFromBackup(IndexStateClient local, IndexStateClient remote)
+ throws IOException {
+ Optional remoteManifest = remote.readManifest();
+ if (remoteManifest.isEmpty()) {
+ return false;
+ }
+ IndexManifest target = remoteManifest.get();
+ Optional localManifest = local.readManifest();
+ if (localManifest.isPresent()
+ && localManifest.get().lastEventId() >= target.lastEventId()) {
+ return false;
+ }
+ prepareStagingManifest(local, target);
+ IndexManifest localFiles = local.readLocalFileManifest();
+ applyDiff(remote, local, target.diff(localFiles));
+ local.validateRestoredIndex(target);
+ local.clearStagingManifest();
+ return true;
+ }
+
+ private static void prepareStagingManifest(IndexStateClient local, IndexManifest target)
+ throws IOException {
+ Optional staging = local.readStagingManifest();
+ if (staging.isPresent()
+ && staging.get().lastEventId() == target.lastEventId()
+ && staging.get().modelName().equals(target.modelName())) {
+ return;
+ }
+ local.clearStagingManifest();
+ local.writeStagingManifest(target);
+ }
+
+ private static void applyDiff(IndexStateClient source, IndexStateClient dest,
+ List ops) throws IOException {
+ for (IndexManifest.ChangedFileOp op : ops) {
+ switch (op) {
+ case IndexManifest.ChangedFileOp.Add add -> {
+ try (InputStream in = source.read(add.fileName())) {
+ dest.write(add.fileName(), in);
+ }
+ }
+ case IndexManifest.ChangedFileOp.Del del -> dest.delete(del.fileName());
+ default -> throw new IndexIOException("Unexpected file operation during backup sync: " + op);
+ }
+ }
+ }
+
+ public static IndexStateClient openRemote(String remoteUri, String indexName, Configuration conf)
+ throws IOException {
+ IndexStateConfig.validateRemoteUri(remoteUri);
+ return new RemoteStateClient(URI.create(remoteUri), conf, indexName);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexStateClient.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexStateClient.java
new file mode 100644
index 000000000000..bf69dda471bb
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexStateClient.java
@@ -0,0 +1,65 @@
+/*
+ * 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.hive.search.index.store;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Optional;
+
+import org.apache.hive.search.exception.IndexIOException;
+import org.apache.hive.search.index.manifest.IndexManifest;
+
+public interface IndexStateClient {
+
+ Optional readManifest() throws IOException;
+
+ InputStream read(String fileName) throws IOException;
+
+ void write(String fileName, InputStream stream) throws IOException;
+
+ void delete(String fileName) throws IOException;
+
+ default boolean writeManifest(IndexManifest manifest) throws IOException {
+ write(IndexManifest.MANIFEST_FILE_NAME, new ByteArrayInputStream(manifest.toJsonBytes()));
+ return true;
+ }
+
+ default Optional readStagingManifest() throws IOException {
+ return Optional.empty();
+ }
+
+ default void writeStagingManifest(IndexManifest manifest) throws IOException {
+
+ }
+
+ default void clearStagingManifest() throws IOException {
+
+ }
+
+ /** Snapshot of index files currently on disk, excluding staging metadata */
+ default IndexManifest readLocalFileManifest() throws IOException {
+ return readManifest().orElse(null);
+ }
+
+ default void validateRestoredIndex(IndexManifest expected) throws IndexIOException {
+
+ }
+
+ void clear() throws IOException;
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/LocalStateClient.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/LocalStateClient.java
new file mode 100644
index 000000000000..15801da921e2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/LocalStateClient.java
@@ -0,0 +1,259 @@
+/*
+ * 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.hive.search.index.store;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.NoSuchFileException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hive.search.exception.IndexIOException;
+import org.apache.hive.search.index.manifest.IndexManifest;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.SegmentInfos;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.IOContext;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.store.IndexOutput;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public record LocalStateClient(Directory directory, String indexName)
+ implements IndexStateClient {
+ private static final Logger LOG = LoggerFactory.getLogger(LocalStateClient.class);
+
+ public static boolean isStagingFile(String fileName) {
+ return IndexManifest.STAGING_MANIFEST_FILE_NAME.equals(fileName);
+ }
+
+ @Override
+ public Optional readManifest() throws IOException {
+ if (hasStagingManifest()) {
+ return Optional.empty();
+ }
+ if (!DirectoryReader.indexExists(directory)) {
+ return Optional.empty();
+ }
+ SegmentInfos segmentInfos = SegmentInfos.readLatestCommit(directory);
+ Map userData = segmentInfos.getUserData();
+ String nid = userData.get("nid");
+ if (StringUtils.isEmpty(nid)) {
+ return Optional.empty();
+ }
+ String modelName = userData.get("model");
+ if (StringUtils.isEmpty(modelName)) {
+ return Optional.empty();
+ }
+ long eventId;
+ try {
+ eventId = Long.parseLong(nid);
+ } catch (NumberFormatException e) {
+ throw new IndexIOException("Invalid commit checkpoint in index metadata: nid=" + nid, e);
+ }
+ List files = new ArrayList<>();
+ for (String file : segmentInfos.files(true)) {
+ files.add(new IndexManifest.IndexFile(file, fileLength(file)));
+ }
+ return Optional.of(IndexManifest.create(indexName, files, modelName, eventId));
+ }
+
+ @Override
+ public Optional readStagingManifest() throws IOException {
+ if (!hasStagingManifest()) {
+ return Optional.empty();
+ }
+ try (IndexInput in = directory.openInput(IndexManifest.STAGING_MANIFEST_FILE_NAME,
+ IOContext.DEFAULT)) {
+ byte[] bytes = new byte[(int) in.length()];
+ in.readBytes(bytes, 0, bytes.length);
+ return Optional.of(IndexManifest.fromJson(bytes));
+ } catch (IOException e) {
+ LOG.warn("Failed to read the staging manifest", e);
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public void writeStagingManifest(IndexManifest manifest) throws IOException {
+ write(IndexManifest.STAGING_MANIFEST_FILE_NAME,
+ new ByteArrayInputStream(manifest.toJsonBytes()));
+ }
+
+ @Override
+ public void clearStagingManifest() throws IOException {
+ delete(IndexManifest.STAGING_MANIFEST_FILE_NAME);
+ }
+
+ public boolean hasStagingManifest() throws IOException {
+ return Arrays.asList(directory.listAll())
+ .contains(IndexManifest.STAGING_MANIFEST_FILE_NAME);
+ }
+
+ @Override
+ public IndexManifest readLocalFileManifest() throws IOException {
+ List files = new ArrayList<>();
+ for (String file : directory.listAll()) {
+ if (isStagingFile(file)) {
+ continue;
+ }
+ files.add(new IndexManifest.IndexFile(file, fileLength(file)));
+ }
+ return IndexManifest.create(indexName, files, "", -1);
+ }
+
+ public boolean isIndexReadable() throws IOException {
+ return DirectoryReader.indexExists(directory);
+ }
+
+ @Override
+ public void validateRestoredIndex(IndexManifest expected) throws IndexIOException {
+ try {
+ if (!expected.sameFilesAs(readLocalFileManifest())) {
+ throw new IndexIOException("Restored index files do not match staging manifest");
+ }
+ if (!isIndexReadable()) {
+ throw new IndexIOException("Restored index is not readable by Lucene");
+ }
+ SegmentInfos segmentInfos = SegmentInfos.readLatestCommit(directory);
+ Map userData = segmentInfos.getUserData();
+ String nid = userData.get("nid");
+ if (StringUtils.isEmpty(nid)) {
+ throw new IndexIOException("Restored index is missing commit checkpoint");
+ }
+ String modelName = userData.get("model");
+ if (StringUtils.isEmpty(modelName)) {
+ throw new IndexIOException("Restored index is missing embedding model metadata");
+ }
+ long eventId;
+ try {
+ eventId = Long.parseLong(nid);
+ } catch (NumberFormatException e) {
+ throw new IndexIOException("Restored index has invalid commit checkpoint: nid=" + nid, e);
+ }
+ if (eventId != expected.lastEventId()) {
+ throw new IndexIOException(
+ "Restored index checkpoint mismatch: expected nid=" + expected.lastEventId()
+ + " but found nid=" + eventId);
+ }
+ if (!expected.modelName().equals(modelName)) {
+ throw new IndexIOException("Restored index embedding model mismatch");
+ }
+ } catch (IOException e) {
+ throw IndexIOException.wrap(e);
+ }
+ }
+
+ @Override
+ public InputStream read(String fileName) throws IOException {
+ return new DirectoryInputStream(directory, fileName);
+ }
+
+ @Override
+ public void write(String fileName, InputStream stream) throws IOException {
+ if (Arrays.asList(directory.listAll()).contains(fileName)) {
+ directory.deleteFile(fileName);
+ }
+ try (IndexOutput out = directory.createOutput(fileName, IOContext.DEFAULT)) {
+ stream.transferTo(new OutputStreamAdapter(out));
+ }
+ }
+
+ @Override
+ public void delete(String fileName) throws IOException {
+ if (Arrays.asList(directory.listAll()).contains(fileName)) {
+ directory.deleteFile(fileName);
+ }
+ }
+
+ @Override
+ public void clear() throws IOException {
+ for (String file : directory.listAll()) {
+ directory.deleteFile(file);
+ }
+ }
+
+ private long fileLength(String fileName) throws IOException {
+ try (IndexInput input = directory.openInput(fileName, IOContext.DEFAULT)) {
+ return input.length();
+ }
+ }
+
+ private static final class DirectoryInputStream extends InputStream {
+ private final IndexInput input;
+
+ DirectoryInputStream(Directory directory, String fileName) throws IOException {
+ if (!Arrays.asList(directory.listAll()).contains(fileName)) {
+ throw new NoSuchFileException(fileName);
+ }
+ this.input = directory.openInput(fileName, IOContext.DEFAULT);
+ }
+
+ @Override
+ public int read() throws IOException {
+ if (input.getFilePointer() >= input.length()) {
+ return -1;
+ }
+ return Byte.toUnsignedInt(input.readByte());
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int remaining = (int) Math.min(len, input.length() - input.getFilePointer());
+ if (remaining <= 0) {
+ return -1;
+ }
+ input.readBytes(b, off, remaining);
+ return remaining;
+ }
+
+ @Override
+ public void close() throws IOException {
+ input.close();
+ }
+ }
+
+ private static final class OutputStreamAdapter extends OutputStream {
+ private final IndexOutput output;
+
+ OutputStreamAdapter(IndexOutput output) {
+ this.output = output;
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ output.writeByte((byte) b);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ output.writeBytes(b, off, len);
+ }
+
+ @Override
+ public void close() throws IOException {
+ output.close();
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/RemoteStateClient.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/RemoteStateClient.java
new file mode 100644
index 000000000000..4067d7e4c5f4
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/RemoteStateClient.java
@@ -0,0 +1,88 @@
+/*
+ * 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.hive.search.index.store;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.util.Optional;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hive.search.index.manifest.IndexManifest;
+
+/** Remote index backup target backed by Hadoop {@link FileSystem}. */
+public final class RemoteStateClient implements IndexStateClient {
+ private final FileSystem fs;
+ private final Path root;
+
+ public RemoteStateClient(URI baseUri, Configuration conf, String indexName)
+ throws IOException {
+ this.fs = FileSystem.get(baseUri, conf);
+ this.root = resolveRoot(baseUri, indexName);
+ fs.mkdirs(root);
+ }
+
+ private static Path resolveRoot(URI baseUri, String indexName) {
+ return new Path(new Path(baseUri), indexName);
+ }
+
+ @Override
+ public Optional readManifest() throws IOException {
+ Path manifestPath = new Path(root, IndexManifest.MANIFEST_FILE_NAME);
+ if (!fs.exists(manifestPath)) {
+ return Optional.empty();
+ }
+ try (InputStream in = fs.open(manifestPath)) {
+ return Optional.of(IndexManifest.fromJson(in.readAllBytes()));
+ }
+ }
+
+ @Override
+ public InputStream read(String fileName) throws IOException {
+ return fs.open(new Path(root, fileName));
+ }
+
+ @Override
+ public void write(String fileName, InputStream stream) throws IOException {
+ Path target = new Path(root, fileName);
+ Path parent = target.getParent();
+ if (parent != null && !fs.exists(parent)) {
+ fs.mkdirs(parent);
+ }
+ try (OutputStream out = fs.create(target, true)) {
+ stream.transferTo(out);
+ }
+ }
+
+ @Override
+ public void delete(String fileName) throws IOException {
+ Path target = new Path(root, fileName);
+ if (fs.exists(target)) {
+ fs.delete(target, false);
+ }
+ }
+
+ @Override
+ public void clear() throws IOException {
+ fs.delete(root, true);
+ fs.mkdirs(root);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModel.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModel.java
new file mode 100644
index 000000000000..5a7bf3ac9f2d
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModel.java
@@ -0,0 +1,39 @@
+/*
+ * 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.hive.search.inference;
+
+import org.apache.hive.search.exception.IndexException;
+
+public interface EmbedModel extends AutoCloseable {
+ enum TaskType {
+ DOCUMENT,
+ QUERY
+ }
+
+ String name();
+
+ float[] encode(TaskType task, String text) throws IndexException;
+
+ default float[][] encodeBatch(TaskType task, String[] texts) throws IndexException {
+ float[][] result = new float[texts.length][];
+ for (int i = 0; i < texts.length; i++) {
+ result[i] = encode(task, texts[i]);
+ }
+ return result;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModelRegistry.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModelRegistry.java
new file mode 100644
index 000000000000..ea61af45890c
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModelRegistry.java
@@ -0,0 +1,78 @@
+/*
+ * 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.hive.search.inference;
+
+import java.io.IOException;
+import java.util.Map;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.exception.InitializeException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public record EmbedModelRegistry(Map models, EmbeddingCache embeddingCache)
+ implements AutoCloseable {
+ private static final Logger LOG = LoggerFactory.getLogger(EmbedModelRegistry.class);
+
+ public EmbedModelRegistry(Map models) {
+ this(models, EmbeddingCache.disabled());
+ }
+
+ public EmbedModelRegistry(Map models, EmbeddingCache embeddingCache) {
+ this.models = Map.copyOf(models);
+ this.embeddingCache = embeddingCache;
+ }
+
+ public static EmbedModelRegistry create(Configuration configuration)
+ throws InitializeException, IOException {
+ InferenceConfig inference = new InferenceConfig(configuration);
+ EmbeddingCache embeddingCache = EmbeddingCache.create(configuration);
+ long start = System.currentTimeMillis();
+ InferenceConfig.EmbeddingModelSpec embeddingConfig = inference.embedding();
+ String modelName = inference.modelName();
+ EmbedModel embedModel = new LocalOnnxEmbeddingModel(modelName, embeddingConfig.getModelDir(),
+ embeddingConfig.getPrompt());
+ long warmupStart = System.currentTimeMillis();
+ try {
+ embedModel.encode(EmbedModel.TaskType.QUERY, "warmup");
+ } catch (IndexException e) {
+ throw new InitializeException("Failed to warm up embedding model '" + modelName + "'", e);
+ }
+ LOG.info("Loaded embedding model '{}' from {} in {}ms (warmup {}ms)",
+ modelName, embeddingConfig.getModelDir(), System.currentTimeMillis() - start,
+ System.currentTimeMillis() - warmupStart);
+ return new EmbedModelRegistry(Map.of(modelName, embedModel), embeddingCache);
+ }
+
+ public EmbedModel get(String modelRef) throws IndexException {
+ EmbedModel model = models.get(modelRef);
+ if (model == null) {
+ throw new IndexException("Embedding model '" + modelRef + "' is not configured");
+ }
+ return model;
+ }
+
+ @Override
+ public void close() throws Exception {
+ for (EmbedModel model : models.values()) {
+ model.close();
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingCache.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingCache.java
new file mode 100644
index 000000000000..e3f3757c9693
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingCache.java
@@ -0,0 +1,99 @@
+/*
+ * 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.hive.search.inference;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.config.InferenceConfig;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.hash.Hashing;
+
+/** In-memory LRU cache of document embeddings keyed by model, task, and text hash. */
+public final class EmbeddingCache {
+ private final Cache cache;
+ private final AtomicLong hits = new AtomicLong();
+ private final AtomicLong misses = new AtomicLong();
+
+ private EmbeddingCache(Cache cache) {
+ this.cache = cache;
+ }
+
+ public static EmbeddingCache create(Configuration configuration) {
+ InferenceConfig inference = new InferenceConfig(configuration);
+ if (!inference.isEmbeddingCacheEnabled()) {
+ return disabled();
+ }
+ return new EmbeddingCache(CacheBuilder.newBuilder()
+ .maximumSize(inference.getEmbeddingCacheMaxEntries())
+ .build());
+ }
+
+ public static EmbeddingCache disabled() {
+ return new EmbeddingCache(null);
+ }
+
+ public Optional get(String modelName, EmbedModel.TaskType task, String text) {
+ if (cache == null) {
+ misses.incrementAndGet();
+ return Optional.empty();
+ }
+ CacheKey key = CacheKey.of(modelName, task, text);
+ float[] cached = cache.getIfPresent(key);
+ if (cached == null) {
+ misses.incrementAndGet();
+ return Optional.empty();
+ }
+ hits.incrementAndGet();
+ return Optional.of(Arrays.copyOf(cached, cached.length));
+ }
+
+ public void put(String modelName, EmbedModel.TaskType task, String text, float[] embedding) {
+ if (cache == null) {
+ return;
+ }
+ cache.put(CacheKey.of(modelName, task, text), Arrays.copyOf(embedding, embedding.length));
+ }
+
+ public long hits() {
+ return hits.get();
+ }
+
+ public long misses() {
+ return misses.get();
+ }
+
+ public boolean enabled() {
+ return cache != null;
+ }
+
+ private record CacheKey(String modelName, EmbedModel.TaskType task, long textHash,
+ String text) {
+ static CacheKey of(String modelName, EmbedModel.TaskType task, String text) {
+ long hash = Hashing.murmur3_128()
+ .hashString(text, StandardCharsets.UTF_8)
+ .asLong();
+ return new CacheKey(modelName, task, hash, text);
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingPrompt.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingPrompt.java
new file mode 100644
index 000000000000..41f5689aa2b2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingPrompt.java
@@ -0,0 +1,36 @@
+/*
+ * 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.hive.search.inference;
+
+/** Model-specific text prefixes applied before tokenization. */
+public record EmbeddingPrompt(String documentPrefix, String queryPrefix) {
+ public static EmbeddingPrompt e5() {
+ return new EmbeddingPrompt("passage: ", "query: ");
+ }
+
+ public static EmbeddingPrompt none() {
+ return new EmbeddingPrompt("", "");
+ }
+
+ public String prefixFor(EmbedModel.TaskType task) {
+ return switch (task) {
+ case DOCUMENT -> documentPrefix;
+ case QUERY -> queryPrefix;
+ };
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/LocalOnnxEmbeddingModel.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/LocalOnnxEmbeddingModel.java
new file mode 100644
index 000000000000..e115e18132ba
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/LocalOnnxEmbeddingModel.java
@@ -0,0 +1,106 @@
+/*
+ * 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.hive.search.inference;
+
+import dev.langchain4j.data.embedding.Embedding;
+import dev.langchain4j.data.segment.TextSegment;
+import dev.langchain4j.model.embedding.onnx.OnnxEmbeddingModel;
+import dev.langchain4j.model.embedding.onnx.PoolingMode;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.exception.IndexException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class LocalOnnxEmbeddingModel implements EmbedModel {
+ private static final Logger LOG = LoggerFactory.getLogger(LocalOnnxEmbeddingModel.class);
+
+ private final String name;
+ private final OnnxEmbeddingModel model;
+ private final EmbeddingPrompt prompt;
+ private final ExecutorService inferExecutor;
+
+ public LocalOnnxEmbeddingModel(String name, Path modelDir, EmbeddingPrompt prompt) {
+ this.name = name;
+ this.prompt = prompt == null ? EmbeddingPrompt.none() : prompt;
+ this.inferExecutor = Executors.newSingleThreadExecutor(r -> {
+ Thread thread = new Thread(r, "EmbedModel-" + name);
+ thread.setDaemon(true);
+ return thread;
+ });
+ this.model = new OnnxEmbeddingModel(
+ modelDir.resolve(InferenceConfig.MODEL_ONNX_FILE),
+ modelDir.resolve(InferenceConfig.TOKENIZER),
+ PoolingMode.MEAN,
+ inferExecutor);
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public float[] encode(TaskType task, String text) throws IndexException {
+ try {
+ return model.embed(prompt.prefixFor(task) + text).content().vector();
+ } catch (RuntimeException e) {
+ throw IndexException.wrap("Failed to encode text with model '" + name + "'", e);
+ }
+ }
+
+ @Override
+ public float[][] encodeBatch(TaskType task, String[] texts) throws IndexException {
+ try {
+ String prefix = prompt.prefixFor(task);
+ List segments = new ArrayList<>(texts.length);
+ for (String text : texts) {
+ segments.add(TextSegment.from(prefix + text));
+ }
+ List embeddings = model.embedAll(segments).content();
+ float[][] vectors = new float[texts.length][];
+ for (int i = 0; i < texts.length; i++) {
+ vectors[i] = embeddings.get(i).vector();
+ }
+ return vectors;
+ } catch (RuntimeException e) {
+ throw IndexException.wrap("Failed to encode batch with model '" + name + "'", e);
+ }
+ }
+
+ @Override
+ public void close() {
+ inferExecutor.shutdown();
+ try {
+ if (!inferExecutor.awaitTermination(30, TimeUnit.SECONDS)) {
+ inferExecutor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ inferExecutor.shutdownNow();
+ }
+ LOG.debug("Closed embedding model '{}'", name);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/FieldSchema.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/FieldSchema.java
new file mode 100644
index 000000000000..065429fa5bc9
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/FieldSchema.java
@@ -0,0 +1,34 @@
+/*
+ * 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.hive.search.mapping;
+
+public interface FieldSchema {
+ String name();
+
+ boolean store();
+
+ boolean filter();
+
+ record TextFieldSchema(
+ String name, SearchParams search, boolean store, boolean filter)
+ implements FieldSchema {
+ public TextFieldSchema(String name, SearchParams search) {
+ this(name, search, true, false);
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/IndexMapping.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/IndexMapping.java
new file mode 100644
index 000000000000..97f803dc9fbe
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/IndexMapping.java
@@ -0,0 +1,83 @@
+/*
+ * 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.hive.search.mapping;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.core.KeywordAnalyzer;
+import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper;
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+
+public record IndexMapping(
+ String indexName, Configuration configuration, Map fields) {
+
+ public IndexConfig config() {
+ return new IndexConfig(configuration);
+ }
+
+ public IndexStateConfig store() {
+ return new IndexStateConfig(configuration, indexName);
+ }
+
+ public InferenceConfig inference() {
+ return new InferenceConfig(configuration);
+ }
+
+ public SearchConfig search() {
+ return new SearchConfig(configuration);
+ }
+
+ public FieldSchema fieldSchema(String fieldName) {
+ return fields.get(fieldName);
+ }
+
+ public List hybridFields() {
+ List result = new ArrayList<>();
+ for (Map.Entry entry : fields.entrySet()) {
+ if (entry.getValue() instanceof FieldSchema.TextFieldSchema text && text.search().hybrid()) {
+ result.add(entry.getKey());
+ }
+ }
+ return result;
+ }
+
+ public Optional soleHybridField() {
+ List hybridFields = hybridFields();
+ return hybridFields.size() == 1 ? Optional.of(hybridFields.getFirst()) : Optional.empty();
+ }
+
+ public Analyzer analyzer() {
+ Map fieldAnalyzers = new HashMap<>();
+ for (Map.Entry entry : fields.entrySet()) {
+ if (entry.getValue() instanceof FieldSchema.TextFieldSchema text && text.search().lexical()) {
+ fieldAnalyzers.put(entry.getKey(), new StandardAnalyzer());
+ }
+ }
+ return new PerFieldAnalyzerWrapper(new KeywordAnalyzer(), fieldAnalyzers);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/SearchParams.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/SearchParams.java
new file mode 100644
index 000000000000..89c324320c34
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/SearchParams.java
@@ -0,0 +1,40 @@
+/*
+ * 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.hive.search.mapping;
+
+import org.apache.commons.lang3.StringUtils;
+
+public record SearchParams(boolean lexical, String semanticModel, VectorDistance distance) {
+
+ public static SearchParams disabled() {
+ return new SearchParams(false, null, VectorDistance.COSINE);
+ }
+
+ public boolean semantic() {
+ return StringUtils.isNotEmpty(semanticModel);
+ }
+
+ public boolean hybrid() {
+ return lexical && semantic();
+ }
+
+ public enum VectorDistance {
+ COSINE,
+ DOT
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/TableDocument.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/TableDocument.java
new file mode 100644
index 000000000000..860f60efcc13
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/TableDocument.java
@@ -0,0 +1,117 @@
+/*
+ * 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.hive.search.mapping;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.mapping.field.Field;
+import org.apache.hive.search.mapping.field.IdField;
+import org.apache.hive.search.mapping.field.TextField;
+import org.apache.lucene.document.BinaryDocValuesField;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.KnnFloatVectorField;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.index.VectorSimilarityFunction;
+import org.apache.lucene.util.BytesRef;
+
+public class TableDocument {
+ private static final int MAX_FIELD_SEARCH_SIZE = 32_768;
+
+ private final List fields;
+ private final Document document;
+ private final IndexMapping indexMapping;
+ private final IdField idField;
+ public static final String FILTER_SUFFIX = ".filter";
+
+ public TableDocument(IdField idf, List otherFields, IndexMapping mapping) {
+ idField = idf;
+ indexMapping = mapping;
+ fields = new ArrayList<>(1 + otherFields.size());
+ fields.add(idField);
+ fields.addAll(otherFields);
+ document = new Document();
+ }
+
+ public void fill(TextField field, FieldSchema.TextFieldSchema schema)
+ throws IndexException {
+ String trimmed = trim(field.value(), MAX_FIELD_SEARCH_SIZE);
+
+ if (schema.filter()) {
+ document.add(
+ new StringField(field.name() + FILTER_SUFFIX, field.value(),
+ org.apache.lucene.document.Field.Store.NO));
+ }
+ if (schema.search().lexical()) {
+ org.apache.lucene.document.Field.Store store =
+ schema.store() ? org.apache.lucene.document.Field.Store.YES
+ : org.apache.lucene.document.Field.Store.NO;
+ document.add(new org.apache.lucene.document.TextField(field.name(), trimmed, store));
+ } else if (schema.store()) {
+ document.add(new StoredField(field.name(), field.value()));
+ }
+ if (schema.search().semantic()) {
+ if (field.embedding() == null) {
+ throw new IndexException("semantic field '" + field.name() + "' requires embedding");
+ }
+ VectorSimilarityFunction similarity = schema.search().distance() == SearchParams.VectorDistance.DOT ?
+ VectorSimilarityFunction.DOT_PRODUCT : VectorSimilarityFunction.COSINE;
+ document.add(new KnnFloatVectorField(field.name(), field.embedding(), similarity));
+ }
+ }
+
+ private static String trim(String value, int max) {
+ return value.length() > max ? value.substring(0, max) : value;
+ }
+
+ public List toDocuments() throws IndexException {
+ String id = idField().value();
+ document.add(new BinaryDocValuesField("_id" + FILTER_SUFFIX, new BytesRef(id)));
+ document.add(new StoredField("_id", id));
+ document.add(new StringField("_id" + FILTER_SUFFIX, id,
+ org.apache.lucene.document.Field.Store.NO));
+ for (Field field : fields) {
+ if (field instanceof IdField) {
+ continue;
+ }
+ FieldSchema schema = indexMapping.fieldSchema(field.name());
+ if (schema == null) {
+ continue;
+ }
+ if (schema instanceof FieldSchema.TextFieldSchema text
+ && field instanceof TextField tf) {
+ fill(tf, text);
+ }
+ }
+ return List.of(document);
+ }
+
+ public void appendField(Field field) {
+ fields.add(field);
+ }
+
+ public IdField idField() {
+ return idField;
+ }
+
+ public List fields() {
+ return fields;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/Field.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/Field.java
new file mode 100644
index 000000000000..df997b485639
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/Field.java
@@ -0,0 +1,23 @@
+/*
+ * 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.hive.search.mapping.field;
+
+/** A single field value within a {@lin}. */
+public interface Field {
+ String name();
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/IdField.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/IdField.java
new file mode 100644
index 000000000000..5fdcfcded2c6
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/IdField.java
@@ -0,0 +1,20 @@
+/*
+ * 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.hive.search.mapping.field;
+
+public record IdField(String name, String value) implements Field {}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/TextField.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/TextField.java
new file mode 100644
index 000000000000..3eac83620d5c
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/TextField.java
@@ -0,0 +1,49 @@
+/*
+ * 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.hive.search.mapping.field;
+
+import java.util.Objects;
+
+public record TextField(String name, String value, float[] embedding) implements Field {
+ public TextField(String name, String value) {
+ this(name, value, null);
+ }
+
+ public TextField {
+ if ("_id".equals(name)) {
+ throw new IllegalArgumentException("use IdField for _id, not TextField");
+ }
+ }
+
+ public TextField withEmbedding(float[] newEmbedding) {
+ return new TextField(name, value, newEmbedding);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass())
+ return false;
+ TextField textField = (TextField) o;
+ return Objects.equals(name, textField.name) && Objects.equals(value, textField.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, value);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/BootstrapIndexer.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/BootstrapIndexer.java
new file mode 100644
index 000000000000..304dc0a64bdd
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/BootstrapIndexer.java
@@ -0,0 +1,277 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.RetryingMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.index.Indexer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+final class BootstrapIndexer {
+ private static final Logger LOG = LoggerFactory.getLogger(BootstrapIndexer.class);
+ private static final List END_OF_STREAM = List.of();
+ private static final TableBatch END_OF_WORK = new TableBatch(null, List.of());
+
+ private final Configuration configuration;
+ private final IndexConfig indexConfig;
+ private final IndexMapping mapping;
+ private final Indexer indexer;
+ private final IMetaStoreClient client;
+ private final boolean shareFetchClient;
+
+ /** Package-private for unit tests: fetch workers reuse the injected client. */
+ BootstrapIndexer(Configuration configuration,
+ IndexMapping mapping, Indexer indexer,
+ IMetaStoreClient client, boolean shareFetchClient) {
+ this.configuration = configuration;
+ this.indexConfig = new IndexConfig(configuration);
+ this.mapping = mapping;
+ this.indexer = indexer;
+ this.client = client;
+ this.shareFetchClient = shareFetchClient;
+ }
+
+ void run(long notificationId) throws Exception {
+ long start = System.currentTimeMillis();
+ List databases = client.getAllDatabases();
+ BatchPlan plan = planBatches(databases);
+ LOG.info("Bootstrap planned {} table(s) in {} batch(es) across {} database(s)",
+ plan.expectedTableCount(), plan.batches().size(), databases.size());
+
+ int fetchThreads = indexConfig.getBootstrapFetchThreads();
+ BlockingQueue workQueue =
+ new ArrayBlockingQueue<>(Math.max(fetchThreads * 2, 8));
+ BlockingQueue> indexQueue =
+ new ArrayBlockingQueue<>(indexConfig.getBootstrapQueueDepth());
+ AtomicReference failure = new AtomicReference<>();
+ AtomicLong indexedTables = new AtomicLong();
+ AtomicInteger completedBatches = new AtomicInteger();
+ CountDownLatch fetchDone = new CountDownLatch(plan.batches().size());
+
+ Thread indexThread = startIndexConsumer(indexQueue, failure, indexedTables, completedBatches);
+ ExecutorService fetchPool = Executors.newFixedThreadPool(fetchThreads, r -> {
+ Thread thread = new Thread(r, "Index-Bootstrap-Fetch");
+ thread.setDaemon(true);
+ return thread;
+ });
+ try {
+ for (int i = 0; i < fetchThreads; i++) {
+ fetchPool.submit(() -> fetchTableWorker(plan, workQueue, indexQueue, failure, fetchDone));
+ }
+ enqueueBatches(plan.batches(), workQueue, failure, fetchDone);
+ awaitFetchCompletion(fetchDone, failure);
+ indexQueue.put(END_OF_STREAM);
+ indexThread.join();
+ rethrowFailure(failure);
+ validateBootstrapTableCount(plan.expectedTableCount().get());
+ indexer.flush(notificationId, true);
+ indexer.syncBackup();
+ long elapsed = System.currentTimeMillis() - start;
+ LOG.info("Built index for {} tables in {} batch(es) over {}ms",
+ plan.expectedTableCount(), plan.batches().size(), elapsed);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IndexException("Bootstrap indexing interrupted", e);
+ } finally {
+ workQueue.offer(END_OF_WORK);
+ fetchPool.shutdownNow();
+ indexThread.interrupt();
+ indexThread.join(TimeUnit.SECONDS.toMillis(30));
+ }
+ }
+
+ private void enqueueBatches(List batches, BlockingQueue workQueue,
+ AtomicReference failure, CountDownLatch fetchDone) throws InterruptedException {
+ int enqueued = 0;
+ try {
+ for (TableBatch batch : batches) {
+ if (failure.get() != null) {
+ break;
+ }
+ workQueue.put(batch);
+ enqueued++;
+ }
+ } finally {
+ for (int i = enqueued; i < batches.size(); i++) {
+ fetchDone.countDown();
+ }
+ workQueue.put(END_OF_WORK);
+ }
+ }
+
+ private BatchPlan planBatches(List databases) throws Exception {
+ int batchSize = indexConfig.getBootstrapBatchSize();
+ List planned = new ArrayList<>();
+ for (String db : databases) {
+ List tableNames = client.getAllTables(db);
+ for (int idx = 0; idx < tableNames.size(); idx += batchSize) {
+ int end = Math.min(idx + batchSize, tableNames.size());
+ planned.add(new TableBatch(db, List.copyOf(tableNames.subList(idx, end))));
+ }
+ }
+ return new BatchPlan(new AtomicInteger(0), planned);
+ }
+
+ private void fetchTableWorker(BatchPlan plan, BlockingQueue workQueue,
+ BlockingQueue> indexQueue, AtomicReference failure,
+ CountDownLatch fetchDone) {
+ if (shareFetchClient) {
+ runFetchLoop(client, plan, workQueue, indexQueue, failure, fetchDone);
+ return;
+ }
+ try (IMetaStoreClient fetchClient = RetryingMetaStoreClient.getProxy(configuration, true)) {
+ runFetchLoop(fetchClient, plan, workQueue, indexQueue, failure, fetchDone);
+ } catch (Exception e) {
+ recordFailure(failure, e);
+ }
+ }
+
+ private void runFetchLoop(IMetaStoreClient fetchClient, BatchPlan plan,
+ BlockingQueue workQueue, BlockingQueue> indexQueue,
+ AtomicReference failure, CountDownLatch fetchDone) {
+ try {
+ while (true) {
+ TableBatch batch = workQueue.take();
+ if (batch == END_OF_WORK) {
+ workQueue.offer(END_OF_WORK);
+ return;
+ }
+ try {
+ if (failure.get() == null) {
+ List
tables =
+ fetchClient.getTableObjectsByName(batch.database(), batch.tableNames());
+ plan.expectedTableCount().addAndGet(tables.size());
+ List documents = new ArrayList<>(tables.size());
+ for (Table table : tables) {
+ documents.add(MetastoreTableMapper.fromTable(table, mapping));
+ }
+ indexQueue.put(documents);
+ }
+ } catch (Exception e) {
+ recordFailure(failure, e);
+ } finally {
+ fetchDone.countDown();
+ }
+ }
+ } catch (Exception e) {
+ recordFailure(failure, e);
+ }
+ }
+
+ private Thread startIndexConsumer(BlockingQueue> indexQueue,
+ AtomicReference failure, AtomicLong indexedTables,
+ AtomicInteger completedBatches) {
+ Thread indexThread = new Thread(() -> {
+ long lastProgressLog = System.currentTimeMillis();
+ try {
+ while (true) {
+ List documents = indexQueue.take();
+ if (documents == END_OF_STREAM) {
+ break;
+ }
+ if (failure.get() != null) {
+ break;
+ }
+ indexer.addDocuments(documents);
+ long indexed = indexedTables.addAndGet(documents.size());
+ int done = completedBatches.incrementAndGet();
+ indexer.flush(-1000000L, false);
+ long now = System.currentTimeMillis();
+ if (now - lastProgressLog >= indexConfig.getBootstrapProgressIntervalMs()) {
+ LOG.info("Bootstrap progress: indexed {} table(s) in {} batch(es)", indexed, done);
+ lastProgressLog = now;
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ recordFailure(failure, e);
+ } catch (Exception e) {
+ recordFailure(failure, e);
+ }
+ }, "Index-Bootstrap-Writer");
+ indexThread.setDaemon(true);
+ indexThread.start();
+ return indexThread;
+ }
+
+ private static void awaitFetchCompletion(CountDownLatch fetchDone,
+ AtomicReference failure) throws Exception {
+ while (fetchDone.getCount() > 0) {
+ if (failure.get() != null) {
+ break;
+ }
+ fetchDone.await(1, TimeUnit.SECONDS);
+ }
+ rethrowFailure(failure);
+ }
+
+ private void validateBootstrapTableCount(int expectedTableCount) throws IndexException {
+ int indexed = indexer.writer().getDocStats().numDocs;
+ if (indexed != expectedTableCount) {
+ throw new IndexException(
+ "Bootstrap index doc count mismatch: indexed " + indexed
+ + " documents but metastore has " + expectedTableCount + " tables");
+ }
+ }
+
+ private static void recordFailure(AtomicReference failure, Exception error) {
+ failure.compareAndSet(null, error);
+ LOG.error("Bootstrap indexing failed", error);
+ }
+
+ private static void rethrowFailure(AtomicReference failure) throws Exception {
+ Exception error = failure.get();
+ if (error != null) {
+ if (error instanceof IndexException indexException) {
+ throw indexException;
+ }
+ if (error instanceof IOException ioException) {
+ throw ioException;
+ }
+ if (error instanceof InterruptedException interruptedException) {
+ Thread.currentThread().interrupt();
+ throw interruptedException;
+ }
+ throw new IndexException("Bootstrap indexing failed", error);
+ }
+ }
+
+ private record BatchPlan(AtomicInteger expectedTableCount, List batches) {}
+
+ private record TableBatch(String database, List tableNames) {}
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreCluster.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreCluster.java
new file mode 100644
index 000000000000..686e4c1e784e
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreCluster.java
@@ -0,0 +1,79 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.leader.LeaderElection;
+import org.apache.hadoop.hive.metastore.leader.LeaderException;
+import org.apache.hadoop.hive.metastore.leader.LeaseLeaderElection;
+
+@SuppressWarnings("rawtypes, unchecked")
+public class MetastoreCluster implements AutoCloseable {
+ private static final String CLUSTER_ELECTION_METHOD = "metastore.index.election.method";
+ private static final Map> CACHED = new ConcurrentHashMap<>();
+
+ static {
+ try {
+ CACHED.put("default", Pair.of(new LeaseLeaderElection(),
+ new TableName("__INDEX_CATALOG__", "__INDEX_DATABASE__", "__INDEX_TABLE__")));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private final LeaderElection election;
+
+ public MetastoreCluster(Configuration configuration,
+ LeaderElection.LeadershipStateListener... listeners) throws IOException, LeaderException {
+ String methodName = configuration.get(CLUSTER_ELECTION_METHOD, "default");
+ Pair leo = CACHED.get(methodName);
+ if (leo == null) {
+ throw new IOException("No elector configured for " + methodName);
+ }
+ this.election = leo.getKey();
+ if (listeners != null) {
+ Arrays.stream(listeners).forEach(this.election::addStateListener);
+ }
+
+ this.election.tryBeLeader(configuration, leo.getValue());
+ }
+
+ boolean isLeader() {
+ return this.election.isLeader();
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (election != null) {
+ election.close();
+ }
+ }
+
+ public static void injectElection(Configuration configuration,
+ String methodName, LeaderElection election, T mutex) {
+ configuration.set(CLUSTER_ELECTION_METHOD, methodName);
+ CACHED.put(methodName, Pair.of(election, mutex));
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventHandler.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventHandler.java
new file mode 100644
index 000000000000..5aad15051fc0
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventHandler.java
@@ -0,0 +1,382 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.common.DatabaseName;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.RetryingMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.NotificationEvent;
+import org.apache.hadoop.hive.metastore.api.NotificationEventRequest;
+import org.apache.hadoop.hive.metastore.api.NotificationEventResponse;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hadoop.hive.metastore.messaging.AlterTableMessage;
+import org.apache.hadoop.hive.metastore.messaging.CreateTableMessage;
+import org.apache.hadoop.hive.metastore.messaging.MessageBuilder;
+import org.apache.hadoop.hive.metastore.messaging.MessageDeserializer;
+import org.apache.hadoop.hive.metastore.messaging.MessageFactory;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.thrift.TException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Polls HMS notification events, coalesces them into batches, and dispatches index mutations.
+ *
+ *
On repeated batch failure the handler falls back to single-event apply so one poison event
+ * does not block the entire batch forever. If a poison event still cannot be applied, the index
+ * is marked unhealthy and search is blocked until the root cause is fixed.
+ */
+public class MetastoreEventHandler implements AutoCloseable {
+ private static final Logger LOG = LoggerFactory.getLogger(MetastoreEventHandler.class);
+ private final AtomicBoolean stopped = new AtomicBoolean(false);
+ private final List listeners = Collections.synchronizedList(new ArrayList<>());
+ private final IndexConfig indexConfig;
+ private final IMetaStoreClient client;
+ private final MessageDeserializer deserializer;
+ private Thread metaRefresher;
+ private long lastEventId;
+
+ /** Consecutive batch failures at {@link #failedBatchStartId}. */
+ private int consecutiveBatchFailures;
+ private long failedBatchStartId = -1;
+
+ private MetastoreEventHandler(Configuration configuration) throws TException {
+ this(configuration, RetryingMetaStoreClient.getProxy(configuration, true));
+ }
+
+ /** Package-private for unit tests with a stub {@link IMetaStoreClient}. */
+ MetastoreEventHandler(Configuration configuration, IMetaStoreClient client) {
+ Configuration conf = new Configuration(Objects.requireNonNull(configuration));
+ this.indexConfig = new IndexConfig(conf);
+ this.client = Objects.requireNonNull(client);
+ this.deserializer = MessageFactory.getDefaultInstance(conf).getDeserializer();
+ }
+
+ public static MetastoreEventHandler of(Configuration conf, MetastoreEventListener... listeners)
+ throws TException {
+ MetastoreEventHandler catalogService = new MetastoreEventHandler(conf);
+ return catalogService.addListeners(listeners);
+ }
+
+ public MetastoreEventHandler addListeners(MetastoreEventListener... listeners) {
+ return addListeners(Arrays.asList(listeners));
+ }
+
+ public MetastoreEventHandler addListeners(Collection listeners) {
+ this.listeners.addAll(listeners);
+ return this;
+ }
+
+ public MetastoreEventHandler start(long nid) throws Exception {
+ this.lastEventId = nid;
+ final long sleepInterval = indexConfig.getPollNotificationInterval();
+ final int pollMax = indexConfig.getEventPollMax();
+ final long unhealthyBackoffMs = indexConfig.getEventUnhealthyBackoffMs();
+ this.metaRefresher = new Thread(() -> {
+ while (!stopped.get()) {
+ try {
+ int processed = getNextMetastoreEvents(pollMax);
+ if (processed <= 0) {
+ Thread.sleep(sleepInterval);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ } catch (IndexNotHealthyException e) {
+ LOG.error("Incremental index update halted: {}", e.getMessage(), e);
+ notifyListenersStatus(false, e);
+ try {
+ Thread.sleep(unhealthyBackoffMs);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }
+ });
+ this.metaRefresher.setName("Metastore-Event-Poller");
+ this.metaRefresher.start();
+ return this;
+ }
+
+ @Override
+ public void close() throws Exception {
+ stopped.set(true);
+ if (metaRefresher != null) {
+ metaRefresher.interrupt();
+ metaRefresher.join();
+ }
+ if (client != null) {
+ client.close();
+ }
+ }
+
+ public int getNextMetastoreEvents(int eventCount) throws IndexNotHealthyException {
+ try {
+ NotificationEventRequest request = new NotificationEventRequest(lastEventId);
+ request.setMaxEvents(eventCount);
+ NotificationEventResponse resp = client.getNextNotification(request, true, null);
+ if (resp == null || resp.getEvents() == null || resp.getEvents().isEmpty()) {
+ LOG.debug("No event found since the last event id: {}", lastEventId);
+ return 0;
+ }
+ List events = resp.getEvents();
+ long batchStartId = events.get(0).getEventId();
+
+ MetastoreEventListener.IndexTask task;
+ try {
+ task = buildTask(events);
+ } catch (Exception parseError) {
+ LOG.warn(
+ "Failed to build notification batch starting at event {}; falling back to single-event apply",
+ batchStartId,
+ parseError);
+ int applied = applyEventsIndividually(events);
+ if (applied > 0) {
+ notifyListenersStatus(true);
+ resetBatchFailureState();
+ return applied;
+ }
+ backoffAfterFailure();
+ return 0;
+ }
+ try {
+ notifyListeners(task);
+ lastEventId = task.lastEventId;
+ notifyListenersStatus(true);
+ resetBatchFailureState();
+ return events.size();
+ } catch (Exception batchError) {
+ recordBatchFailure(batchStartId, task, batchError);
+ if (consecutiveBatchFailures >= indexConfig.getEventBatchMaxFailures()) {
+ LOG.warn(
+ "Batch apply failed {} time(s) at event {}; falling back to single-event apply",
+ consecutiveBatchFailures,
+ failedBatchStartId);
+ int applied = applyEventsIndividually(events);
+ if (applied > 0) {
+ notifyListenersStatus(true);
+ resetBatchFailureState();
+ return applied;
+ }
+ }
+ backoffAfterFailure();
+ return 0;
+ }
+ } catch (IndexNotHealthyException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.warn("Failed to fetch or parse notification events after lastEventId={}", lastEventId, e);
+ backoffAfterFailure();
+ }
+ return 0;
+ }
+
+ private int applyEventsIndividually(List events)
+ throws IndexNotHealthyException {
+ int applied = 0;
+ for (NotificationEvent event : events) {
+ if (lastEventId >= event.getEventId()) {
+ continue;
+ }
+ MetastoreEventListener.IndexTask task =
+ new MetastoreEventListener.IndexTask();
+ task.firstEventId = event.getEventId();
+ try {
+ dispatchEvent(event, task);
+ task.lastEventId = event.getEventId();
+ notifyListeners(task);
+ lastEventId = event.getEventId();
+ applied++;
+ } catch (Exception e) {
+ long poisonEventId = event.getEventId();
+ String poisonEventType = event.getEventType();
+ if (indexConfig.isEventSkipPoison()) {
+ LOG.error(
+ "Skipping poison notification event {} (type={}); index may be stale for this object",
+ poisonEventId,
+ poisonEventType,
+ e);
+ lastEventId = event.getEventId();
+ applied++;
+ } else {
+ LOG.error(
+ "Stuck on notification event {} (type={}); committed lastEventId={}",
+ poisonEventId,
+ poisonEventType,
+ lastEventId,
+ e);
+ String progress = applied > 0
+ ? applied + " prior event(s) in batch were applied; "
+ : "";
+ throw new IndexNotHealthyException(
+ "Cannot apply notification event "
+ + poisonEventId
+ + " (type="
+ + poisonEventType
+ + "); "
+ + progress
+ + "committed lastEventId="
+ + lastEventId
+ + ". Fix root cause, set "
+ + IndexConfig.EVENT_SKIP_POISON
+ + "=true to skip, or rebuild the index.",
+ e);
+ }
+ }
+ }
+ if (applied > 0) {
+ LOG.info("Applied {} notification event(s) individually; lastEventId={}", applied, lastEventId);
+ }
+ return applied;
+ }
+
+ private MetastoreEventListener.IndexTask buildTask(List events)
+ throws Exception {
+ MetastoreEventListener.IndexTask task = new MetastoreEventListener.IndexTask();
+ for (int i = 0; i < events.size(); i++) {
+ NotificationEvent event = events.get(i);
+ if (lastEventId >= event.getEventId()) {
+ throw new IllegalStateException(
+ "Out-of-order metastore notification event: lastEventId=" + lastEventId
+ + ", eventId=" + event.getEventId());
+ }
+ if (i == 0) {
+ task.firstEventId = event.getEventId();
+ }
+ dispatchEvent(event, task);
+ task.lastEventId = event.getEventId();
+ }
+ return task;
+ }
+
+ private void notifyListeners(MetastoreEventListener.IndexTask task) throws Exception {
+ for (MetastoreEventListener listener : listeners) {
+ listener.notifyIndexTask(task);
+ }
+ }
+
+ private void notifyListenersStatus(boolean healthy, IndexNotHealthyException... errors) {
+ for (MetastoreEventListener listener : listeners) {
+ listener.notifyIndexState(healthy, errors);
+ }
+ }
+
+ private void recordBatchFailure(
+ long batchStartId, MetastoreEventListener.IndexTask task, Exception e) {
+ if (failedBatchStartId != batchStartId) {
+ failedBatchStartId = batchStartId;
+ consecutiveBatchFailures = 1;
+ } else {
+ consecutiveBatchFailures++;
+ }
+ LOG.warn(
+ "Failed to apply notification batch (attempt {}/{}, committed lastEventId={}, "
+ + "batch event range {}-{}, upserts={}, tableDrops={}, dbDrops={})",
+ consecutiveBatchFailures,
+ indexConfig.getEventBatchMaxFailures(),
+ lastEventId,
+ task.firstEventId,
+ task.lastEventId,
+ task.tablesToAdd.size(),
+ task.tablesToDrop.size(),
+ task.databasesToDrop.size(),
+ e);
+ }
+
+ private void resetBatchFailureState() {
+ consecutiveBatchFailures = 0;
+ failedBatchStartId = -1;
+ }
+
+ private void backoffAfterFailure() {
+ long backoffMs = indexConfig.getEventFailureBackoffMs();
+ if (backoffMs <= 0) {
+ return;
+ }
+ try {
+ Thread.sleep(backoffMs);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void dispatchEvent(
+ NotificationEvent event,
+ MetastoreEventListener.IndexTask task)
+ throws Exception {
+ String message = event.getMessage();
+ switch (event.getEventType()) {
+ case MessageBuilder.CREATE_TABLE_EVENT -> {
+ CreateTableMessage createTableMessage = deserializer.getCreateTableMessage(message);
+ Table table = createTableMessage.getTableObj();
+ TableName tableName = new TableName(table.getCatName(), table.getDbName(), table.getTableName());
+ task.tablesToAdd.put(tableName, table);
+ }
+ case MessageBuilder.DROP_TABLE_EVENT -> {
+ deserializer.getDropTableMessage(message);
+ TableName tableName = new TableName(event.getCatName(), event.getDbName(), event.getTableName());
+ if (task.tablesToAdd.containsKey(tableName)) {
+ task.tablesToAdd.remove(tableName);
+ } else {
+ task.tablesToDrop.add(tableName);
+ }
+ }
+ case MessageBuilder.DROP_DATABASE_EVENT -> {
+ DatabaseName databaseName = new DatabaseName(event.getCatName(), event.getDbName());
+ task.databasesToDrop.add(databaseName);
+ for (TableName tableName : new ArrayList<>(task.tablesToAdd.keySet())) {
+ if (databaseName.equals(new DatabaseName(tableName.getCat(), tableName.getDb()))) {
+ task.tablesToAdd.remove(tableName);
+ }
+ }
+ task.tablesToDrop.removeIf(
+ tableName -> databaseName.equals(new DatabaseName(tableName.getCat(), tableName.getDb())));
+ }
+ case MessageBuilder.ALTER_TABLE_EVENT -> {
+ AlterTableMessage alterTableMessage = deserializer.getAlterTableMessage(message);
+ Table tableAfter = alterTableMessage.getTableObjAfter();
+ Table tableBefore = alterTableMessage.getTableObjBefore();
+ TableName tblNameBefore = new TableName(tableBefore.getCatName(),
+ tableBefore.getDbName(), tableBefore.getTableName());
+ TableName tblNameAfter = new TableName(tableAfter.getCatName(),
+ tableAfter.getDbName(), tableAfter.getTableName());
+ if (task.tablesToAdd.containsKey(tblNameBefore)) {
+ task.tablesToAdd.remove(tblNameBefore);
+ } else {
+ task.tablesToDrop.add(tblNameBefore);
+ }
+ task.tablesToAdd.put(tblNameAfter, tableAfter);
+ }
+ default -> {
+ // Ignored event types still advance the notification cursor when applied individually.
+ }
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventListener.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventListener.java
new file mode 100644
index 000000000000..dcc8a04787a2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventListener.java
@@ -0,0 +1,52 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.hadoop.hive.common.DatabaseName;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+
+public interface MetastoreEventListener {
+
+ /**
+ * Apply a coalesced batch of index mutations. Implementations must either complete all
+ * mutations or throw; partial success must remain safe to retry idempotently.
+ */
+ default void notifyIndexTask(IndexTask task) throws IndexException, java.io.IOException {
+
+ }
+
+ default void notifyIndexState(boolean healthy, IndexNotHealthyException... e) {
+
+ }
+
+ class IndexTask {
+ public long firstEventId;
+ public long lastEventId;
+ public Set tablesToDrop = new LinkedHashSet<>();
+ public Set databasesToDrop = new LinkedHashSet<>();
+ public Map tablesToAdd = new LinkedHashMap<>();
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreIndexer.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreIndexer.java
new file mode 100644
index 000000000000..3cb7d317a1d3
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreIndexer.java
@@ -0,0 +1,355 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.common.DatabaseName;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.RetryingMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.NotificationEventRequest;
+import org.apache.hadoop.hive.metastore.leader.LeaderElection;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.index.Indexer;
+import org.apache.hive.search.index.IndexManager;
+import org.apache.hive.search.index.manifest.IndexManifest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class MetastoreIndexer implements AutoCloseable {
+ private static final Logger LOG = LoggerFactory.getLogger(MetastoreIndexer.class);
+
+ private final IMetaStoreClient client;
+ private final long lastEventId;
+ private final Indexer indexer;
+ private final MetastoreCluster cluster;
+ private final MetastoreEventHandler handler;
+ private final IndexManager indexManager;
+ private final FlushIndexListener flushIndexListener;
+ private final boolean shareBootstrapFetchClient;
+
+ public MetastoreIndexer(Configuration configuration, IndexManager indexManager, Indexer indexer)
+ throws Exception {
+ this(configuration, indexManager, indexer,
+ RetryingMetaStoreClient.getProxy(configuration, true), false);
+ }
+
+ MetastoreIndexer(Configuration configuration, IndexManager indexManager, Indexer indexer,
+ IMetaStoreClient client) throws Exception {
+ this(configuration, indexManager, indexer, client, true);
+ }
+
+ MetastoreIndexer(Configuration configuration, IndexManager indexManager, Indexer indexer,
+ IMetaStoreClient client, boolean shareBootstrapFetchClient) throws Exception {
+ this.client = client;
+ this.shareBootstrapFetchClient = shareBootstrapFetchClient;
+ this.indexer = indexer;
+ this.indexManager = indexManager;
+ this.flushIndexListener = new FlushIndexListener(configuration);
+ this.handler = new MetastoreEventHandler(configuration, client);
+ this.handler.addListeners(flushIndexListener, indexManager);
+ this.cluster = new MetastoreCluster(configuration, flushIndexListener);
+ this.lastEventId = initialize();
+ }
+
+ private boolean rebuildIndex() throws Exception {
+ indexManager.resolveInterruptedRestore();
+ if (isIndexValid(indexManager.readLocalManifest().orElse(null))) {
+ return false;
+ }
+ if (isIndexValid(indexManager.readRemoteManifest().orElse(null))) {
+ if (indexManager.restoreBackup()) {
+ return false;
+ }
+ LOG.warn("Failed to restore index from remote backup; clearing partial local index");
+ indexManager.clearLocalIndex();
+ return true;
+ }
+ indexManager.clearLocalIndex();
+ return true;
+ }
+
+ private boolean isIndexValid(IndexManifest indexManifest)
+ throws Exception {
+ if (indexManifest == null) {
+ return false;
+ }
+ String currentModelName = indexManager.mapping().inference().modelName();
+ String modelName = indexManifest.modelName();
+ if (!currentModelName.equals(modelName)) {
+ return false;
+ }
+ long notificationId = indexManifest.lastEventId();
+ return canCatchUp(notificationId);
+ }
+
+ private boolean canCatchUp(long notificationId) throws Exception {
+ if (notificationId < 0) {
+ return false;
+ }
+ try {
+ NotificationEventRequest request = new NotificationEventRequest(notificationId);
+ request.setMaxEvents(10);
+ client.getNextNotification(request, false, null);
+ return true;
+ } catch (IllegalStateException ignored) {
+ LOG.debug("Current index lags too behind, will rebuild");
+ }
+ return false;
+ }
+
+ private long initialize() throws Exception {
+ boolean rebuild = rebuildIndex();
+ if (rebuild && !cluster.isLeader()) {
+ long notificationId = waitForRemoteBackup();
+ indexer.initialize();
+ if (notificationId >= 0) {
+ return notificationId;
+ }
+ return rebuildLeaderIndex();
+ }
+ indexer.initialize();
+ if (rebuild) {
+ return rebuildLeaderIndex();
+ }
+ return indexManager.readLocalManifest().get().lastEventId();
+ }
+
+ private long rebuildLeaderIndex() throws Exception {
+ indexManager.clearRemoteIndex();
+ long notificationId = client.getCurrentNotificationEventId().getEventId();
+ new BootstrapIndexer(
+ indexManager.mapping().configuration(),
+ indexManager.mapping(),
+ indexer,
+ client,
+ shareBootstrapFetchClient).run(notificationId);
+ return notificationId;
+ }
+
+ public void start() throws Exception {
+ handler.start(lastEventId);
+ flushIndexListener.start(lastEventId);
+ }
+
+ /** Package-private for integration tests. */
+ int pollEvents(int count) throws IndexNotHealthyException {
+ return handler.getNextMetastoreEvents(count);
+ }
+
+ /** Package-private for integration tests. */
+ void flushCheckpoint() throws IOException {
+ try {
+ indexer.flush(client.getCurrentNotificationEventId().getEventId(), true);
+ } catch (Exception e) {
+ if (e instanceof IOException ioException) {
+ throw ioException;
+ }
+ throw new IOException("Failed to flush index checkpoint", e);
+ }
+ }
+
+ /** Package-private for integration tests. */
+ void syncBackup() throws IOException {
+ indexer.syncBackup();
+ }
+
+ /**
+ * Wait for a follower-ready remote backup and restore it before the IndexWriter is opened.
+ *
+ * @return restored notification id, or -1 if this instance became leader while waiting
+ */
+ private long waitForRemoteBackup() throws Exception {
+ String currentModelName = indexManager.mapping().inference().modelName();
+ for (; !cluster.isLeader(); Thread.sleep(3000)) {
+ Optional indexManifest = indexManager.readRemoteManifest();
+ if (indexManifest.isEmpty()) {
+ continue;
+ }
+ IndexManifest manifest = indexManifest.get();
+ if (!currentModelName.equals(manifest.modelName())) {
+ LOG.debug("Remote index model {} does not match configured model {}, waiting",
+ manifest.modelName(), currentModelName);
+ continue;
+ }
+ long notificationId = manifest.lastEventId();
+ if (notificationId > 0 && canCatchUp(notificationId)) {
+ if (indexManager.restoreBackup()) {
+ return notificationId;
+ }
+ LOG.warn("Failed to restore the index from remote directory, will retry");
+ }
+ }
+ indexManager.clearLocalIndex();
+ return -1;
+ }
+
+ @Override
+ public void close() throws Exception {
+ try {
+ cluster.close();
+ handler.close();
+ } finally {
+ flushIndexListener.close();
+ client.close();
+ }
+ }
+
+ private class FlushIndexListener
+ implements MetastoreEventListener,
+ LeaderElection.LeadershipStateListener,
+ AutoCloseable {
+ private volatile long eventId;
+ private volatile long lastCommittedEventId;
+ private volatile boolean isLeader;
+ private volatile Thread replicateThread;
+ private volatile boolean started = false;
+ private final Thread commitThread;
+ private final IndexConfig indexConfig;
+
+ public FlushIndexListener(Configuration configuration) {
+ this.indexConfig = new IndexConfig(configuration);
+ this.commitThread = getIndexCommitThread();
+ }
+
+ public void start(long initialEventId) {
+ this.eventId = initialEventId;
+ this.lastCommittedEventId = initialEventId;
+ this.commitThread.start();
+ this.started = true;
+ }
+
+ private Thread getIndexCommitThread() {
+ Thread commitThread = new Thread(() -> {
+ while (!Thread.currentThread().isInterrupted()) {
+ try {
+ Thread.sleep(indexConfig.getFlushInterval());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ if (eventId <= lastCommittedEventId) {
+ continue;
+ }
+ try {
+ if (indexer.flush(eventId, false)) {
+ lastCommittedEventId = eventId;
+ } else if (eventId - lastCommittedEventId > indexConfig.getForceFlushEventGap()) {
+ // Many events processed with no Lucene changes; advance checkpoint metadata.
+ if (indexer.flush(eventId, true)) {
+ lastCommittedEventId = eventId;
+ }
+ }
+ } catch (IOException e) {
+ LOG.warn("Error flushing the index", e);
+ }
+ }
+ });
+ commitThread.setName("Index-Commit");
+ commitThread.setDaemon(true);
+ return commitThread;
+ }
+
+ private Thread getIndexReplicateThread() {
+ Thread replicateThread = new Thread(() -> {
+ while (isLeader && !Thread.currentThread().isInterrupted()) {
+ long interval = 10000;
+ if (started) {
+ try {
+ indexer.syncBackup();
+ interval = indexConfig.getSyncInterval();
+ } catch (IOException e) {
+ LOG.warn("Error replicating the index to remote directory", e);
+ }
+ }
+ try {
+ Thread.sleep(interval);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ });
+ replicateThread.setDaemon(true);
+ replicateThread.setName("Index-Replica");
+ return replicateThread;
+ }
+
+ @Override
+ public void takeLeadership(LeaderElection leaderElection) {
+ isLeader = true;
+ if (indexManager.hasBackup()) {
+ if (replicateThread != null && replicateThread.isAlive()) {
+ return;
+ }
+ replicateThread = getIndexReplicateThread();
+ replicateThread.start();
+ }
+ }
+
+ @Override
+ public void lossLeadership(LeaderElection leaderElection) {
+ isLeader = false;
+ if (replicateThread != null) {
+ replicateThread.interrupt();
+ replicateThread = null;
+ }
+ }
+
+ @Override
+ public void notifyIndexTask(IndexTask task) throws IndexException, IOException {
+ if (!task.databasesToDrop.isEmpty()) {
+ indexer.deleteDatabases(task.databasesToDrop.toArray(new DatabaseName[0]));
+ }
+ if (!task.tablesToDrop.isEmpty()) {
+ String[] docIds = task.tablesToDrop.stream()
+ .map(MetastoreTableMapper::tableId).toList().toArray(new String[0]);
+ indexer.delete(docIds);
+ }
+ if (!task.tablesToAdd.isEmpty()) {
+ List newDocs = task.tablesToAdd.values().stream()
+ .map(t -> MetastoreTableMapper.fromTable(t, indexManager.mapping())).toList();
+ indexer.addDocuments(newDocs);
+ }
+ eventId = task.lastEventId;
+ }
+
+ @Override
+ public void close() throws Exception {
+ isLeader = false;
+ try {
+ if (replicateThread != null) {
+ replicateThread.interrupt();
+ replicateThread = null;
+ }
+ commitThread.interrupt();
+ } finally {
+ if (eventId > lastCommittedEventId) {
+ indexer.flush(eventId, true);
+ lastCommittedEventId = eventId;
+ }
+ }
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreSchemas.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreSchemas.java
new file mode 100644
index 000000000000..040795309b6e
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreSchemas.java
@@ -0,0 +1,79 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.SearchParams;
+
+public final class MetastoreSchemas {
+
+ private MetastoreSchemas() {}
+
+ public static IndexMapping defaultHiveTablesMapping(String indexName,
+ String semanticModel, Configuration conf) {
+ Map fields = new LinkedHashMap<>();
+ fields.put(MetastoreTableMapper.FIELD_DB, filterText(MetastoreTableMapper.FIELD_DB));
+ fields.put(MetastoreTableMapper.FIELD_TABLE, tableNameText(MetastoreTableMapper.FIELD_TABLE));
+ fields.put(MetastoreTableMapper.FIELD_OWNER, filterText(MetastoreTableMapper.FIELD_OWNER));
+ fields.put(MetastoreTableMapper.FIELD_TABLE_TYPE, filterText(MetastoreTableMapper.FIELD_TABLE_TYPE));
+ fields.put(MetastoreTableMapper.FIELD_LOCATION, storedText(MetastoreTableMapper.FIELD_LOCATION));
+ fields.put(MetastoreTableMapper.FIELD_COMMENT, lexicalText(MetastoreTableMapper.FIELD_COMMENT));
+ fields.put(MetastoreTableMapper.FIELD_COLUMNS, storedText(MetastoreTableMapper.FIELD_COLUMNS));
+ fields.put(
+ MetastoreTableMapper.FIELD_COLUMN_NAMES,
+ lexicalText(MetastoreTableMapper.FIELD_COLUMN_NAMES));
+ fields.put(
+ MetastoreTableMapper.FIELD_COLUMN_COMMENTS,
+ lexicalText(MetastoreTableMapper.FIELD_COLUMN_COMMENTS));
+ fields.put(
+ MetastoreTableMapper.FIELD_SEARCH_TEXT,
+ hybridText(MetastoreTableMapper.FIELD_SEARCH_TEXT, semanticModel));
+ return new IndexMapping(indexName, conf, fields);
+ }
+
+ private static FieldSchema.TextFieldSchema filterText(String name) {
+ return new FieldSchema.TextFieldSchema(name, SearchParams.disabled(), true, true);
+ }
+
+ private static FieldSchema.TextFieldSchema storedText(String name) {
+ return new FieldSchema.TextFieldSchema(name, SearchParams.disabled(), true, false);
+ }
+
+ private static FieldSchema.TextFieldSchema lexicalText(String name) {
+ return new FieldSchema.TextFieldSchema(
+ name, new SearchParams(true, null, SearchParams.VectorDistance.COSINE), true, false);
+ }
+
+ private static FieldSchema.TextFieldSchema tableNameText(String name) {
+ return new FieldSchema.TextFieldSchema(
+ name, new SearchParams(true, null, SearchParams.VectorDistance.COSINE), true, true);
+ }
+
+ private static FieldSchema.TextFieldSchema hybridText(String name, String semanticModel) {
+ return new FieldSchema.TextFieldSchema(
+ name,
+ new SearchParams(true, semanticModel, SearchParams.VectorDistance.COSINE),
+ false,
+ false);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreTableMapper.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreTableMapper.java
new file mode 100644
index 000000000000..917755572a97
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreTableMapper.java
@@ -0,0 +1,197 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.util.ArrayList;
+import java.util.Locale;
+import java.util.List;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.api.FieldSchema;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.mapping.field.Field;
+import org.apache.hive.search.mapping.field.IdField;
+import org.apache.hive.search.mapping.field.TextField;
+
+public final class MetastoreTableMapper {
+ public static final String FIELD_DB = "db";
+ public static final String FIELD_TABLE = "table_name";
+ public static final String FIELD_OWNER = "owner";
+ public static final String FIELD_TABLE_TYPE = "table_type";
+ public static final String FIELD_LOCATION = "location";
+ public static final String FIELD_COMMENT = "comment";
+ public static final String FIELD_COLUMNS = "columns";
+ public static final String FIELD_COLUMN_NAMES = "column_names";
+ public static final String FIELD_COLUMN_COMMENTS = "column_comments";
+ public static final String FIELD_SEARCH_TEXT = "search_text";
+ /** Relative boosts for {@code table_keyword} ranking: table name > column name > comment. */
+ public static final float KEYWORD_BOOST_TABLE_NAME = 3.0f;
+ public static final float KEYWORD_BOOST_COLUMN_NAME = 2.0f;
+ public static final float KEYWORD_BOOST_COMMENT = 1.0f;
+ /** Lexical fields used for table keyword search, highest boost first. */
+ public static final List KEYWORD_SEARCH_FIELDS = List.of(
+ new KeywordSearchField(FIELD_TABLE, KEYWORD_BOOST_TABLE_NAME),
+ new KeywordSearchField(FIELD_COLUMN_NAMES, KEYWORD_BOOST_COLUMN_NAME),
+ new KeywordSearchField(FIELD_COMMENT, KEYWORD_BOOST_COMMENT),
+ new KeywordSearchField(FIELD_COLUMN_COMMENTS, KEYWORD_BOOST_COMMENT));
+ /** Max commented data columns included in search-oriented column fields. */
+ static final int MAX_SEARCH_COLUMNS = 100;
+
+ public record KeywordSearchField(String field, float boost) {}
+
+ private MetastoreTableMapper() {}
+
+ public static String tableId(String catalog, String db, String table) {
+ return catalog + "." + db + "." + table;
+ }
+
+ public static String tableId(TableName tableName) {
+ return tableId(tableName.getCat(), tableName.getDb(), tableName.getTable());
+ }
+
+ public static TableDocument fromTable(Table table, IndexMapping indexMapping) {
+ String db = table.getDbName();
+ String name = table.getTableName();
+ String catalog = table.getCatName();
+ String id = tableId(catalog, db, name);
+ String owner = nullToEmpty(table.getOwner());
+ String tableType = nullToEmpty(table.getTableType());
+ String location =
+ table.getSd() == null ? "" : nullToEmpty(table.getSd().getLocation());
+ String comment = "";
+ if (table.getParameters() != null && table.getParameters().get("comment") != null) {
+ comment = nullToEmpty(table.getParameters().get("comment"));
+ }
+ String columns = formatColumnsForStorage(table);
+ String columnNames = formatColumnNamesForSearch(table);
+ String columnComments = formatColumnCommentsForSearch(table);
+ String searchText = buildSearchText(name, comment, formatColumnsForSearch(table));
+
+ List fields = new ArrayList<>(10);
+ fields.add(new TextField(FIELD_DB, db));
+ fields.add(new TextField(FIELD_TABLE, name.toLowerCase(Locale.ROOT)));
+ fields.add(new TextField(FIELD_OWNER, owner));
+ fields.add(new TextField(FIELD_TABLE_TYPE, tableType));
+ fields.add(new TextField(FIELD_LOCATION, location));
+ fields.add(new TextField(FIELD_COMMENT, comment));
+ fields.add(new TextField(FIELD_COLUMNS, columns));
+ fields.add(new TextField(FIELD_COLUMN_NAMES, columnNames));
+ fields.add(new TextField(FIELD_COLUMN_COMMENTS, columnComments));
+ fields.add(new TextField(FIELD_SEARCH_TEXT, searchText));
+ return new TableDocument(new IdField("_id", id), fields, indexMapping);
+ }
+
+ private static String buildSearchText(String tableName, String comment, String searchColumns) {
+ String normalizedTableName = tableName.toLowerCase(Locale.ROOT);
+ if (comment.isEmpty()) {
+ if (searchColumns.isEmpty()) {
+ return normalizedTableName;
+ }
+ return normalizedTableName + " " + searchColumns;
+ }
+ if (searchColumns.isEmpty()) {
+ return normalizedTableName + " " + comment;
+ }
+ return normalizedTableName + " " + comment + " " + searchColumns;
+ }
+
+ private static String formatColumnsForStorage(Table table) {
+ if (table.getSd() == null || table.getSd().getCols() == null) {
+ return "";
+ }
+ List parts = new ArrayList<>(table.getSd().getCols().size());
+ for (FieldSchema column : table.getSd().getCols()) {
+ parts.add(formatColumnForStorage(column));
+ }
+ return String.join("; ", parts);
+ }
+
+ private static String formatColumnNamesForSearch(Table table) {
+ if (table.getSd() == null || table.getSd().getCols() == null) {
+ return "";
+ }
+ List names = new ArrayList<>(table.getSd().getCols().size());
+ for (FieldSchema column : table.getSd().getCols()) {
+ names.add(column.getName().toLowerCase(Locale.ROOT));
+ }
+ return String.join(" ", names);
+ }
+
+ private static String formatColumnCommentsForSearch(Table table) {
+ if (table.getSd() == null || table.getSd().getCols() == null) {
+ return "";
+ }
+ List commented = new ArrayList<>();
+ for (FieldSchema column : table.getSd().getCols()) {
+ if (StringUtils.isNotEmpty(column.getComment())) {
+ commented.add(column);
+ }
+ }
+ int limit = Math.min(commented.size(), MAX_SEARCH_COLUMNS);
+ List parts = new ArrayList<>(limit);
+ for (int i = 0; i < limit; i++) {
+ parts.add(commented.get(i).getComment());
+ }
+ String formatted = String.join("; ", parts);
+ if (commented.size() > MAX_SEARCH_COLUMNS) {
+ return formatted + "; ... (+" + (commented.size() - MAX_SEARCH_COLUMNS) + " more)";
+ }
+ return formatted;
+ }
+
+ private static String formatColumnsForSearch(Table table) {
+ if (table.getSd() == null || table.getSd().getCols() == null) {
+ return "";
+ }
+ List commented = new ArrayList<>();
+ for (FieldSchema column : table.getSd().getCols()) {
+ if (StringUtils.isNotEmpty(column.getComment())) {
+ commented.add(column);
+ }
+ }
+ int limit = Math.min(commented.size(), MAX_SEARCH_COLUMNS);
+ List parts = new ArrayList<>(limit);
+ for (int i = 0; i < limit; i++) {
+ parts.add(formatColumnForSearch(commented.get(i)));
+ }
+ String formatted = String.join("; ", parts);
+ if (commented.size() > MAX_SEARCH_COLUMNS) {
+ return formatted + "; ... (+" + (commented.size() - MAX_SEARCH_COLUMNS) + " more)";
+ }
+ return formatted;
+ }
+
+ private static String formatColumnForStorage(FieldSchema column) {
+ String base = column.getName() + " " + column.getType();
+ if (StringUtils.isNotEmpty(column.getComment())) {
+ return base + " " + column.getComment();
+ }
+ return base;
+ }
+
+ private static String formatColumnForSearch(FieldSchema column) {
+ return column.getName() + " " + column.getComment();
+ }
+
+ private static String nullToEmpty(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/HybridSearch.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/HybridSearch.java
new file mode 100644
index 000000000000..74fb99d18b78
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/HybridSearch.java
@@ -0,0 +1,187 @@
+/*
+ * 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.hive.search.search;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.exception.SearchException;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+
+/** Builds RRF queries that fuse lexical and semantic retrieval on hybrid fields. */
+public final class HybridSearch {
+ private HybridSearch() {}
+
+ public record ParsedHybridQuery(
+ String field,
+ String queryText,
+ Float semanticWeight) {}
+
+ public static ParsedHybridQuery parse(Object hybridBody, IndexMapping mapping)
+ throws SearchException {
+ if (hybridBody instanceof String queryText) {
+ String field = requireHybridField(mapping, null);
+ return new ParsedHybridQuery(field, queryText, null);
+ }
+ if (!(hybridBody instanceof Map, ?> body)) {
+ throw new SearchException("hybrid query must be a string or object");
+ }
+ @SuppressWarnings("unchecked")
+ Map hybridMap = (Map) body;
+
+ HybridWeights weights = parseHybridWeights(hybridMap);
+
+ if (hybridMap.containsKey("field") && hybridMap.containsKey("query")) {
+ String field = hybridMap.get("field").toString();
+ validateHybridField(mapping, field);
+ return new ParsedHybridQuery(
+ field, hybridMap.get("query").toString(), weights.getSemanticWeight());
+ }
+
+ if (hybridMap.containsKey("query")) {
+ String field = requireHybridField(mapping, null);
+ return new ParsedHybridQuery(
+ field, hybridMap.get("query").toString(), weights.getSemanticWeight());
+ }
+
+ List> fieldEntries =
+ hybridMap.entrySet().stream()
+ .filter(entry -> !isHybridOption(entry.getKey()))
+ .filter(entry -> entry.getValue() != null)
+ .toList();
+ if (fieldEntries.size() == 1) {
+ Map.Entry entry = fieldEntries.getFirst();
+ validateHybridField(mapping, entry.getKey());
+ return new ParsedHybridQuery(
+ entry.getKey(), entry.getValue().toString(), weights.getSemanticWeight());
+ }
+
+ throw new SearchException(
+ "hybrid query must be a string, {field:, query:}, {query:}, or {: text}");
+ }
+
+ public static SearchInternal.FusionRequest toFusionRequest(
+ ParsedHybridQuery hybrid, int defaultSize, SearchConfig searchConfig) {
+ float semanticWeight = hybrid.semanticWeight() != null ?
+ hybrid.semanticWeight() : searchConfig.getHybridSemanticWeight();
+ float matchWeight = 1.0f - semanticWeight;
+ Map lexicalQuery = lexicalQueryForHybrid(hybrid);
+ List retrievers =
+ List.of(
+ new SearchInternal.RetrieverSpec(lexicalQuery, matchWeight, "match"),
+ new SearchInternal.RetrieverSpec(
+ Map.of("semantic", Map.of(hybrid.field(), hybrid.queryText())), semanticWeight, "semantic"));
+ return new SearchInternal.FusionRequest(retrievers, defaultSize);
+ }
+
+ private static Map lexicalQueryForHybrid(ParsedHybridQuery hybrid) {
+ if (MetastoreTableMapper.FIELD_SEARCH_TEXT.equals(hybrid.field())) {
+ return Map.of("table_keyword", hybrid.queryText());
+ }
+ return Map.of("match", Map.of(hybrid.field(), hybrid.queryText()));
+ }
+
+ private record HybridWeights(Float match, Float semantic) {
+ HybridWeights validate() {
+ if (!validate(match)) {
+ throw new IllegalArgumentException();
+ }
+ if (!validate(semantic)) {
+ throw new IllegalArgumentException();
+ }
+ if (match != null && semantic != null && (match + semantic) > 1.0f) {
+ throw new IllegalArgumentException();
+ }
+ return this;
+ }
+
+ boolean validate(Float weight) {
+ if (weight == null) {
+ return true;
+ }
+ if (weight >= 1f || weight <= 0.0f) {
+ return false;
+ }
+ return true;
+ }
+
+ float getSemanticWeight() {
+ if (semantic != null) {
+ return semantic;
+ } else if (match != null) {
+ return 1.0f - match;
+ }
+ return 0.4f;
+ }
+ }
+
+ private static HybridWeights parseHybridWeights(Map hybridMap) {
+ Float match = null;
+ Float semantic = null;
+ if (hybridMap.get("match_weight") instanceof Number matchWeight) {
+ match = matchWeight.floatValue();
+ }
+ if (hybridMap.get("semantic_weight") instanceof Number semanticWeight) {
+ semantic = semanticWeight.floatValue();
+ }
+ if (hybridMap.get("weights") instanceof Map, ?> weights) {
+ if (weights.get("match") instanceof Number matchValue) {
+ match = matchValue.floatValue();
+ }
+ if (weights.get("semantic") instanceof Number semanticValue) {
+ semantic = semanticValue.floatValue();
+ }
+ }
+ return new HybridWeights(match, semantic).validate();
+ }
+
+ private static boolean isHybridOption(String key) {
+ return "match_weight".equals(key)
+ || "semantic_weight".equals(key)
+ || "weights".equals(key);
+ }
+
+ private static String requireHybridField(IndexMapping mapping, String field)
+ throws SearchException {
+ if (StringUtils.isNotEmpty(field)) {
+ validateHybridField(mapping, field);
+ return field;
+ }
+ return mapping
+ .soleHybridField()
+ .orElseThrow(
+ () ->
+ new SearchException(
+ "hybrid query requires field when index has "
+ + mapping.hybridFields().size()
+ + " hybrid field(s): "
+ + mapping.hybridFields()));
+ }
+
+ private static void validateHybridField(IndexMapping mapping, String field)
+ throws SearchException {
+ FieldSchema schema = mapping.fieldSchema(field);
+ if (!(schema instanceof FieldSchema.TextFieldSchema text) || !text.search().hybrid()) {
+ throw new SearchException("field '" + field + "' is not configured for hybrid search");
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/LuceneSearchBackend.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/LuceneSearchBackend.java
new file mode 100644
index 000000000000..e16173ab62b8
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/LuceneSearchBackend.java
@@ -0,0 +1,151 @@
+/*
+ * 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.hive.search.search;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.exception.IndexNotReadyException;
+import org.apache.hive.search.exception.InitializeException;
+import org.apache.hive.search.exception.SearchException;
+import org.apache.hive.search.index.IndexSession;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Phase 1 {@link SearchBackend} backed by an in-process Lucene index. */
+public final class LuceneSearchBackend implements SearchBackend {
+ private static final Logger LOG = LoggerFactory.getLogger(LuceneSearchBackend.class);
+ private IndexSession session;
+ private Future future;
+ private SearchConfig searchConfig;
+
+ @Override
+ public void initialize(Configuration configuration)
+ throws InitializeException, IOException {
+ searchConfig = new SearchConfig(configuration);
+ session = new IndexSession(configuration);
+ ExecutorService initThread = Executors.newFixedThreadPool(1);
+ future = initThread.submit(() -> {
+ long start = System.currentTimeMillis();
+ session.initialize();
+ LOG.info("The in-process Lucene search backend is ready for request now, time taken: {}ms",
+ System.currentTimeMillis() - start);
+ return null;
+ });
+ initThread.shutdown();
+ }
+
+ @Override
+ public boolean isReady() throws IndexNotReadyException {
+ if (future == null) {
+ throw new IndexNotReadyException("The in-process Lucene search backend hasn't been initialized yet");
+ }
+ try {
+ future.get(searchConfig.getInitReadyTimeoutMs(), TimeUnit.MILLISECONDS);
+ return true;
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ throw new IndexNotReadyException("Search backend initialization failed", cause);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IndexNotReadyException("Search backend initialization interrupted", e);
+ } catch (TimeoutException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public TableSearchResult search(SearchQuery query)
+ throws SearchException, IOException {
+ if (!isReady()) {
+ throw new IndexNotReadyException("Search index is not ready");
+ }
+ List fields = buildReturnFields(query);
+ int limit = query.limit() > 0 ? query.limit() : searchConfig.getDefaultLimit();
+ try (SearchInternal searcher = session.getSearcher()) {
+ SearchReqResp.Response response = searcher.search(SearchReqResp.Request.validated(
+ toInternalQuery(query),
+ fields,
+ limit,
+ query.catalogName(),
+ query.databaseName()));
+ List hits = new ArrayList<>();
+ for (Map raw : response.hits()) {
+ hits.add(toHit(raw, query.catalogName()));
+ }
+ return new TableSearchResult(hits, response.total());
+ }
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (session != null) {
+ session.close();
+ }
+ }
+
+ private static List buildReturnFields(SearchQuery query) {
+ if (!query.returnFields().isEmpty()) {
+ return query.returnFields();
+ }
+ return List.of(
+ MetastoreTableMapper.FIELD_DB,
+ MetastoreTableMapper.FIELD_TABLE,
+ MetastoreTableMapper.FIELD_OWNER,
+ MetastoreTableMapper.FIELD_COMMENT);
+ }
+
+ private static Map toInternalQuery(SearchQuery query) {
+ String text = query.queryText();
+ return switch (query.mode()) {
+ case KEYWORD -> Map.of("table_keyword", text);
+ case SEMANTIC -> Map.of("semantic",
+ Map.of(MetastoreTableMapper.FIELD_SEARCH_TEXT, text));
+ case HYBRID -> Map.of("hybrid", text);
+ };
+ }
+
+ private static TableSearchHit toHit(Map raw, String defaultCatalog) {
+ TableName tableName = TableName.fromString(raw.get("_id").toString(), defaultCatalog, "default");
+ float score =
+ raw.get("_score") instanceof Number number ? number.floatValue() : 0f;
+ Map fields = new LinkedHashMap<>();
+ for (Map.Entry entry : raw.entrySet()) {
+ if ("_score".equals(entry.getKey()) || "_id".equals(entry.getKey())) {
+ continue;
+ }
+ if (entry.getValue() != null) {
+ fields.put(entry.getKey(), entry.getValue().toString());
+ }
+ }
+ return new TableSearchHit(tableName, score, fields);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchBackend.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchBackend.java
new file mode 100644
index 000000000000..d85e8788f1e2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchBackend.java
@@ -0,0 +1,44 @@
+/*
+ * 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.hive.search.search;
+
+import java.io.IOException;
+
+import org.apache.hadoop.conf.Configuration;
+
+import org.apache.hive.search.exception.IndexNotReadyException;
+import org.apache.hive.search.exception.InitializeException;
+import org.apache.hive.search.exception.SearchException;
+
+/**
+ * Pluggable search storage and query engine. Phase 1 uses embedded Lucene; Phase 3 may add
+ * OpenSearch or Elasticsearch without changing the mapping contract or ingest path.
+ */
+public interface SearchBackend extends AutoCloseable {
+
+ void initialize(Configuration configuration) throws InitializeException, IOException;
+
+ /** Whether this instance can serve search (index opened and bootstrap completed). */
+ boolean isReady() throws IndexNotReadyException;
+
+ TableSearchResult search(SearchQuery query)
+ throws SearchException, InitializeException, IOException;
+
+ @Override
+ void close() throws Exception;
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchInternal.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchInternal.java
new file mode 100644
index 000000000000..f2ad024272cf
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchInternal.java
@@ -0,0 +1,342 @@
+/*
+ * 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.hive.search.search;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.exception.SearchException;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.apache.hive.search.index.IndexManager;
+import org.apache.hive.search.inference.EmbedModel;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.BayesianScoreEstimator;
+import org.apache.lucene.search.BayesianScoreQuery;
+import org.apache.lucene.search.BooleanClause;
+import org.apache.lucene.search.BooleanQuery;
+import org.apache.lucene.search.BoostQuery;
+import org.apache.lucene.search.ConstantScoreQuery;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.KnnFloatVectorQuery;
+import org.apache.lucene.search.LogOddsFusionQuery;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.SearcherManager;
+import org.apache.lucene.search.TermQuery;
+import org.apache.lucene.search.PrefixQuery;
+import org.apache.lucene.search.TopDocs;
+
+public final class SearchInternal implements AutoCloseable {
+ private final EmbedModelRegistry modelRegistry;
+ private final IndexSearcher searcher;
+ private final SearcherManager searcherManager;
+ private final IndexMapping mapping;
+ private final SearchConfig searchConfig;
+ private final BayesianScoreEstimator.Parameters parameters;
+
+ public SearchInternal(SearcherManager manager,
+ IndexManager indexManager,
+ EmbedModelRegistry registry,
+ SearchConfig searchConfig,
+ BayesianScoreEstimator.Parameters parameters) throws IOException {
+ this.searcherManager = manager;
+ this.searcher = manager.acquire();
+ this.mapping = indexManager.mapping();
+ this.searchConfig = searchConfig;
+ this.parameters = parameters;
+ this.modelRegistry = Objects.requireNonNull(registry, "Model registry");
+ }
+
+ public record RetrieverSpec(Map query, float weight, String name) {
+ }
+
+ public record FusionRequest(List retrievers, int size) {}
+
+ public SearchReqResp.Response search(SearchReqResp.Request request)
+ throws SearchException, IOException {
+ TopDocs topDocs = executeQuery(request);
+ List