Alibaba Cloud Elasticsearch accelerates Paimon multi-modal data lake search

Updated at:

In this tutorial, you mount an Apache Paimon table in Alibaba Cloud Elasticsearch and perform vector search, scalar filtering, and field source retrieval on data lake tables based on a Paimon global index. By the end, you will have a searchable Elasticsearch index backed by Paimon data lake storage, accessible through standard Elasticsearch Query DSL and KNN queries.

How it works

The raw data in the Paimon table remains in Object Storage Service (OSS) or other lakehouse storage. After Elasticsearch reads the Paimon table snapshot and global index metadata, it mounts the data lake table index as a queryable Elasticsearch index. You can use Elasticsearch Query DSL, KNN queries, filter conditions, and field return capabilities to access data in the data lake without synchronizing all the raw data to an online Elasticsearch index.

The implementation works as follows:

  • Build the vector columns in the Paimon table into vector indexes queryable by Elasticsearch.

  • Write scalar columns such as business IDs, tags, URI paths, timestamps, and categories into the global index for filtering and retrieval.

  • Mount the Paimon table as an Elasticsearch index by using /_paimon/mount.

  • Use Elasticsearch KNN queries for vector recall combined with filter conditions such as term, terms, and range.

  • After hitting TopK results, read fields from the Paimon table on demand to reduce redundant storage of raw data in the online index.

  • Use Spark SQL vector_search on the Paimon side to verify recall results for the same query vector.

A typical pipeline is as follows:

Business data / images / text / Embedding
  -> Write to Paimon table
  -> Build Paimon global index
  -> Elasticsearch mounts the Paimon table
  -> Query using ES Query DSL / KNN
  -> Return scalar columns or source fields on demand after hits

Scenarios

This feature is applicable to the following scenarios:

  • Multi-modal data such as images, text, and video frames are stored in Paimon/OSS, and you want to provide online similarity search services through Elasticsearch.

  • Your business requires both vector search and structured filtering, such as "similar images + category filtering" and "similar documents + tag filtering."

  • You do not want to synchronize all fields from the data lake table to Elasticsearch and only want to keep the search index and necessary return fields.

  • You need to use Elasticsearch query DSL, sorting, filtering, aggregation, Kibana, or RAG application ecosystem as the search entry for data in the data lake.

Prerequisites

The following table describes the overall requirements for using this feature. Step 1 and Step 2 in this tutorial demonstrate how to create a Paimon table from scratch and build an es-index global index by using the example tablepaimon_vector_demo. If you already have a Paimon table and an es-index global index that meet the requirements, you can start from Step 3.

TypeRequirement
Elasticsearch instanceVersion 9.4 or later. The image has built-in capabilities for Paimon mount, source, and global index reading. For OpenStore and mount verification details, see the notes below this table.
Paimon tableTable data has been written to OSS or lakehouse storage managed by Data Lake Formation (DLF), and the table contains vector columns that can be used for search.
Global indexAn es-index type global index has been built for the Paimon table.
PermissionsThe credentials used by the Elasticsearch instance or in the mount request must have permissions to read the Paimon table path, manifest, data files, and index files.
Spark environmentIf you need to build indexes or use Spark SQL for verification, you need a Spark environment that can run the Paimon Spark extension and the corresponding Paimon/ES integration JAR.
Important

If the Elasticsearch image contains the OpenStore plug-in, configure the following static setting on all nodes and restart the cluster. Dynamic settings cannot be used as a substitute. If the plug-in is not included, no configuration is required.

yaml cluster.apack.openstore.ruleout.enable: false

After you execute the mount operation, verify the physical index settings to confirm that index.store.type=paimon before performing query verification.

Limits

  • If you need to backfill fields through _source or Paimon source, you must correctly enable row-tracking.enabled=true and data-evolution.enabled=true in the table creation and write pipeline. Otherwise, data files may lack firstRowId, which prevents the source stage from establishing the mapping from rowId to data files.

  • Accompanying scalar columns that need to participate in filtering or be written to the global index must be declared together with the vector column in index_column in Spark SQL. Only placing a field in return_fields or _source does not guarantee that it can be used for filter queries such as term and range.

  • If the vector field uses dot_product as the similarity metric, both the vectors written to the table and the query vectors typically need to be L2 normalized first to ensure that scores and rankings are comparable.

  • The dimension limits, recall parameters, and performance characteristics of vector index algorithms such as HNSW and DiskBBQ are subject to the current Elasticsearch instance version.

  • If large-scale HNSW indexes use the heap mount mode, you need to evaluate JVM heap, shard count, and vector graph size in advance. For feature verification, use the mmap or hybrid mode first.

  • The query vector of Spark SQL vector_search must be written as Float/Double array literals parseable by Spark, for example array(1.234E-2D, -3.456E-2D), to avoid being parsed as DECIMAL or expressions.

Step 1: Prepare the Paimon table

The following example uses an OSS filesystem catalog. For DLF catalog scenarios, replace the catalog and table paths with your actual configurations.

Configure the Spark catalog

Add the following configuration to your Spark session:

# Use the Paimon Spark extension
spark.sql.extensions=org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions

# Configure the OSS filesystem catalog
spark.sql.catalog.oss=org.apache.paimon.spark.SparkCatalog
spark.sql.catalog.oss.metastore=filesystem
spark.sql.catalog.oss.warehouse=oss://<bucket>/<warehouse>
spark.sql.catalog.oss.fs.oss.endpoint=oss-<region>-internal.aliyuncs.com
spark.sql.catalog.oss.fs.oss.accessKeyId=${OSS_AK_ID}
spark.sql.catalog.oss.fs.oss.accessKeySecret=${OSS_AK_SECRET}

# Hadoop OSS read/write configuration
spark.hadoop.fs.oss.endpoint=oss-<region>-internal.aliyuncs.com
spark.hadoop.fs.oss.accessKeyId=${OSS_AK_ID}
spark.hadoop.fs.oss.accessKeySecret=${OSS_AK_SECRET}

# Prevent Paimon v1 function from preempting external function resolution
spark.paimon.v1Function.enabled=false

Create the example table

The following example table contains:

  • id: Business primary key.

  • label: Tag column for scalar filtering.

  • content_uri: Object path that can be returned to the business.

  • emb_norm: Normalized vector column.

Run the following SQL statement to create the table:

CREATE TABLE oss.default.paimon_vector_demo (
  id          BIGINT,
  label       BIGINT,
  content_uri STRING,
  emb         ARRAY<DOUBLE>,
  emb_norm    ARRAY<FLOAT>
) TBLPROPERTIES (
  'write-mode' = 'append-only',
  'bucket' = '-1',
  'file.format' = 'parquet',
  'row-tracking.enabled' = 'true',
  'data-evolution.enabled' = 'true',

  'global-index.es-index.fields.emb_norm.algorithm' = 'hnsw',
  'global-index.es-index.fields.emb_norm.dimension' = '768',
  'global-index.es-index.fields.emb_norm.metric' = 'dot_product'
);

If you use multi-modal data such as images and text, generate embeddings in the offline pipeline first and then write them to the Paimon table. When using dot_product, write the normalized vector column, for example emb_norm.

Write data to the table

Write source data to the example table and perform L2 normalization on the vector column. The followingsource_table is your own source data table (containing columns such as id, label, content_uri, and emb). Replace it with your actual source:

INSERT INTO oss.default.paimon_vector_demo
SELECT
  id,
  label,
  content_uri,
  emb,
  vector_l2_normalize(emb) AS emb_norm
FROM source_table;

vector_l2_normalize depends on the corresponding version of the Paimon Spark extension. If you remove the vector_l2_normalize SQL function as agreed, you must ensure the upstream generates normalized ARRAY<FLOAT>.

The following pure SQL approach can be used as an alternative:

INSERT INTO oss.default.paimon_vector_demo
SELECT
  id,
  label,
  content_uri,
  emb,
  transform(
    emb,
    x -> CAST(x / sqrt(aggregate(emb, CAST(0 AS DOUBLE), (acc, v) -> acc + v * v)) AS FLOAT)
  ) AS emb_norm
FROM source_table;

Verify the written data

After writing, check the row count, dimensions, and vector normalization results:

SELECT count(*) AS total_count
FROM oss.default.paimon_vector_demo;

SELECT
  id,
  size(emb_norm) AS dim,
  aggregate(emb_norm, CAST(0 AS FLOAT), (acc, v) -> acc + v * v) AS sumsq
FROM oss.default.paimon_vector_demo
LIMIT 10;

When normalization is correct, dim should equal the dimension set during table creation (for example, 768), and sumsq should be close to 1.0.

Step 2: Build the Paimon global index

Call the Paimon create_global_index stored procedure in EMR Serverless Spark SQL to build the es-index (in the example,oss.sys.create_global_index, where oss is the catalog name configured in Step 1).

Important

Newer versions of EMR Serverless Spark will include built-in support for the latest Paimon with this capability. Existing older versions do not update the built-in Paimon. To use this capability on older versions, you must manually package compatible Paimon, paimon-eslib, and ESLib/Lucene dependencies, and exclude and replace the built-in Paimon through job configuration. You can use the official emr-3.5 compatible JAR, or package it yourself with the latest community version.

Currently, the index type of products automatically built by managed DLF is inconsistent with the es-index required by the Elasticsearch paimon-store, and cannot directly replace this step. The DLF catalog can still be used to manage Paimon table metadata.

Configure the Spark job and run the build command:

# Older versions of EMR Serverless Spark: exclude and replace the built-in Paimon
spark.emr.serverless.excludedModules             paimon
spark.emr.serverless.user.defined.jars           oss://<bucket>/jars/<paimon-es-index-bundle>.jar
spark.sql.extensions                             org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions
-- Use Spark SQL to build the es-index
CALL oss.sys.create_global_index(
  table        => 'default.paimon_vector_demo',
  index_column => 'emb_norm,id,label,content_uri',
  index_type   => 'es-index',
  options      => 'global-index.row-count-per-shard=100000,global-index.es-index.fields.emb_norm.m=30,global-index.es-index.fields.emb_norm.ef_construction=360'
);

The Spark configuration and SQL parameter descriptions are as follows.

ParameterDescription
spark.emr.serverless.excludedModulesFor EMR Serverless Spark 5.3.1 and earlier versions, set this to paimon to exclude the built-in Paimon module and avoid class conflicts between old and new versions.
spark.emr.serverless.user.defined.jarsThe OSS address of custom JAR files. Must include Paimon compatible with the Spark version, paimon-eslib, and ESLib/Lucene dependencies. You can use the official emr-3.5 compatible JAR, or package it yourself with the latest community version.
spark.sql.extensionsMust be configured as org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions.
tableThe target Paimon table name. When calling the oss.sys procedure, specify default.paimon_vector_demo.
index_columnThe columns to be written to the same es-index. The primary vector column emb_norm must be placed first, followed by accompanying scalar columns for filtering or return.
index_typeFixed as es-index, which generates index files recognizable by the current Elasticsearch paimon-store.
optionsPass in shard row count and HNSW m, ef_construction, and other build parameters. Call parameters take priority over table properties.
RebuildingThe first call builds existing data. Subsequent calls incrementally build uncovered new data. To force a full rebuild, first call drop_global_index and then call create_global_index again.

Verify the global index

After the build is complete, check the global index files through the Paimon system table:

SELECT
  index_type,
  index_field_name,
  row_count,
  file_name
FROM oss.default.`paimon_vector_demo$table_indexes`
WHERE index_type = 'es-index';

The available columns of the paimon_vector_demo$table_indexes system table are file_name, file_size, bucket, index_type, and row_count. Use file_name to view index files, not file_path.

Step 3: Mount the Paimon table as an Elasticsearch index

Call the /_paimon/mount API to mount the Paimon table as an Elasticsearch index.

POST /_paimon/mount
{
  "auth_type": "direct",
  "table_path": "oss://<bucket>/<warehouse>/default.db/paimon_vector_demo",
  "oss_endpoint": "oss-<region>-internal.aliyuncs.com",
  "oss_bucket": "<bucket>",
  "oss_access_key_id": "${OSS_AK_ID}",
  "oss_access_key_secret": "${OSS_AK_SECRET}",
  "index_name": "paimon_vector_demo_index",
  "vector_field_name": "emb_norm",
  "storage_mode": "mmap",
  "source_enabled": true,
  "return_fields": [
    "id",
    "label",
    "content_uri"
  ],
  "timeout": "600"
}

If the call is successful, a response similar to the following is returned:

```json { "acknowledged": true, "alias": "paimon_vector_demo_index", "index": "paimon_vector_demo_index_2" }

<!-- @id="mountrespnote1" -->The mount operation creates an actual index (whose name is<!-- @id="mountrn01" --> `index_name` with a numeric suffix, for example<!-- @id="mountrn02" --> `paimon_vector_demo_index_2`), and points the alias<!-- @id="mountrn03" --> `index_name` to that index. Use the alias<!-- @id="mountrn04" --> `paimon_vector_demo_index` for subsequent queries. In Step 4,<!-- @id="mountrn05" --> `_cat/indices` displays the actual index name with the numeric suffix.

The parameter descriptions are as follows. Among these, `table_path`, `oss_endpoint`, `oss_bucket`, `oss_access_key_id`, and `oss_access_key_secret` are required parameters. The API returns a `Required fields` error if any of them is missing.

| Parameter | Required | Description |
| --- | --- | --- |
| `auth_type` | No | Authentication method. The example uses `direct`, which passes OSS access credentials in the request. For production environments, use more secure managed credentials or RAM role methods. |
| `table_path` | Yes | The Paimon table path. |
| `oss_endpoint` | Yes | The OSS endpoint. Use an internal endpoint in the same region as the Elasticsearch instance. |
| `oss_bucket` | Yes | The OSS bucket where the table is located. |
| `oss_access_key_id` | Yes | The AccessKey ID for accessing OSS. |
| `oss_access_key_secret` | Yes | The AccessKey secret for accessing OSS. |
| `index_name` | No | The name of the Elasticsearch index generated after mounting. |
| `vector_field_name` | No | The name of the field used for vector search. |
| `storage_mode` | No | The index read mode. Valid values: `mmap` (relies on OS page cache, suitable for verification and low memory usage), `hybrid` (balances memory and disk access, suitable for medium to large-scale indexes), and `heap` (best performance but consumes more JVM heap). Available values are subject to the current version. For a detailed comparison, see [How to choose between mmap, hybrid, and heap?](#how-to-choose-between-mmap-hybrid-and-heap) in the FAQ section. |
| `source_enabled` | No | Whether to enable Paimon source retrieval. |
| `return_fields` | No | The fields to be returned from the Paimon table. |
| `timeout` | No | The mount timeout period, in seconds. |

(Recommended) Use `mmap` or `hybrid` for initial verification. If you need to use the `heap` mode for performance benchmarking, evaluate the index scale and JVM heap first.

## Step 4: Verify the mount result

After the mount is complete, check the index status, document count, mapping, and settings:

```bash
GET /_cat/indices/paimon_vector_demo_index?v

GET /paimon_vector_demo_index/_count

GET /paimon_vector_demo_index/_mapping

GET /paimon_vector_demo_index/_settings?flat_settings=true

Focus on the following items:

  • The index status is green or as expected.

  • _count is consistent with the Paimon table row count.

  • The vector field mapping has the correct dimension and similarity configurations, for example dims=768 and similarity=dot_product.

  • Scalar fields id, label, and content_uri appear in the mapping.

  • Settings such as Paimon table path, snapshot ID, and storage mode are as expected.

Step 5: Query by using Elasticsearch

Pure vector query

Run the following query to perform a pure KNN vector search:

POST /paimon_vector_demo_index/_search
{
  "size": 10,
  "_source": [
    "id",
    "label",
    "content_uri"
  ],
  "knn": {
    "field": "emb_norm",
    "query_vector": [/* 768-dimensional normalized query vector */],
    "k": 10,
    "num_candidates": 100
  }
}
Important

The scalar columns of the mounted index (id,label,content_uri) are returned through_source source retrieval (corresponding toreturn_fields during mount). Use_source to specify the fields to return. These columns are not Lucene stored fields or doc_values. Using"_source": false withfields will not retrieve values (hits will only contain_index and_score). If you want to return the vector column as well, set_source totrue.

Vector + scalar filtering

Run the following query to combine KNN search with scalar filtering:

POST /paimon_vector_demo_index/_search
{
  "size": 10,
  "_source": [
    "id",
    "label",
    "content_uri"
  ],
  "knn": {
    "field": "emb_norm",
    "query_vector": [/* 768-dimensional normalized query vector */],
    "k": 10,
    "num_candidates": 100,
    "filter": {
      "term": {
        "label": 1
      }
    }
  }
}

Use terms or range filtering

Replace the knn.filter in the previous request with the following content.

Terms filter example:

{
  "terms": {
    "label": [1, 2, 3]
  }
}

Range filter example:

{
  "range": {
    "id": {
      "lt": 100000
    }
  }
}

Return Paimon table fields

If source_enabled is already enabled, you can return fields from the Paimon table:

POST /paimon_vector_demo_index/_search
{
  "size": 10,
  "_source": true,
  "knn": {
    "field": "emb_norm",
    "query_vector": [/* 768-dimensional normalized query vector */],
    "k": 10,
    "num_candidates": 100
  }
}

If _source is returning empty, check the following:

  • Whether row-tracking.enabled=true and data-evolution.enabled=true are enabled during table creation.

  • Whether the write path correctly assigns firstRowId to data files.

  • Whether return_fields includes the fields to be returned.

  • Whether the Elasticsearch instance has loaded the Paimon source capability.

Step 6: Verify query results by using Spark SQL

You can also use the same query vector in Spark SQL to verify the Paimon global index query results.

Run the following Spark SQL query:

USE oss.default;

SELECT
  id,
  label,
  content_uri,
  __paimon_search_score AS score
FROM vector_search(
  'oss.default.paimon_vector_demo',
  'emb_norm',
  array(/* 768 double literals, e.g. 1.234567E-2D */),
  10,
  map('hnsw.num_candidates', '100')
)
ORDER BY score DESC
LIMIT 10;

Vector + scalar filtering example:

SELECT
  id,
  label,
  content_uri,
  __paimon_search_score AS score
FROM vector_search(
  'oss.default.paimon_vector_demo',
  'emb_norm',
  array(/* same normalized query vector */),
  10,
  map('hnsw.num_candidates', '100')
)
WHERE label = 1
ORDER BY score DESC
LIMIT 10;

Use EXPLAIN FORMATTED to check whether the vector index is used:

EXPLAIN FORMATTED
SELECT
  id,
  label,
  __paimon_search_score AS score
FROM vector_search(
  'oss.default.paimon_vector_demo',
  'emb_norm',
  array(/* same normalized query vector */),
  10,
  map('hnsw.num_candidates', '100')
)
WHERE label = 1
ORDER BY score DESC;

The expected plan should show vector search, global index scan, or VectorSearch related information.

FAQ

Why can KNN queries return results, but scalar filtering cannot find anything?

Check whether the filter field has been added to index_column in Spark SQL. For example, to filter by label, you must include index_column => 'emb_norm,id,label,content_uri' during build time. If you only configure label in return_fields, the field can be used as a source return field but may not be available as an index filter field.

Why is _source returning empty?

A common cause is that the Paimon data files lack the mapping information from rowId to files. Verify the following:

  • row-tracking.enabled=true is enabled during table creation.

  • data-evolution.enabled=true is enabled during table creation.

  • The write pipeline uses a path that supports row tracking.

  • Old tables or old data files may need to be rewritten or rebuilt.

Should the query vector use the original vector or the normalized vector?

If the index field is the normalized vector column emb_norm and the similarity metric is dot_product, the query vector should also be generated by the same model and normalized. Otherwise, scores may not be comparable, rankings may not meet expectations, or the query may be rejected by vector dimension or value validation.

How to choose between mmap, hybrid, and heap?

  • mmap — Suitable for feature verification and low memory usage scenarios. Relies on the operating system page cache.

  • hybrid — A balance between memory and disk access. Suitable for general verification of medium to large-scale indexes.

  • heap — Suitable for performance benchmarking but consumes more JVM heap. Before using it, evaluate memory requirements based on vector count, vector dimensions, shard count, and replica count.