<!--
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.
-->

# paimon-eslib: ES Index (Multi-Column Global Index)

`paimon-eslib` provides the `es-index` global index: a **Lucene-backed, multi-column, multi-modal**
index. A single `es-index` file indexes a **primary column** (typically a vector) together with
optional **companion columns** — full-text, keyword, scalar, and date — so vector search and
full-text search can be served from the same index, and companion columns stay available for scoring
and filtering inside one index definition.

Unlike the single-purpose vector and full-text global indexes — each of which indexes one column with
one dedicated engine — `es-index` groups a primary column plus optional companion columns into one
index. The primary column determines the main modality (usually a vector); companion columns are
indexed according to their data type and per-field options.

> **Plugin required.** `es-index` is provided by this `paimon-eslib` module. Make sure `paimon-eslib`
> is on the classpath of your Spark or Flink job (and of any reader) before building or querying an
> `es-index`. The module and its ESLib/Lucene dependencies require Java 11 or newer. It is excluded
> from the default Maven reactor; build it explicitly with `-Ppaimon-eslib` on JDK 11 or newer.

`paimon-eslib` is distributed as a thin JAR and does not embed ESLib or Lucene classes. Maven and
Gradle resolve these dependencies transitively. When installing JARs manually, place
`eslib-core-lucene9` and `eslib-simdvec-lucene9` 1.0.7, together with their Lucene 9.12
dependencies, in the same runtime classloader as `paimon-eslib`.

## ESLib source and artifacts

- ESLib source code: [CrownChu/es-paimon-lib](https://github.com/CrownChu/es-paimon-lib)
- Published ESLib JARs:
  [CrownChu/es-paimon-lib-releases](https://github.com/CrownChu/es-paimon-lib-releases)

The ESLib source and binary repositories are maintained independently of the Apache Software
Foundation. The exact ESLib artifact versions used by this module are declared in
[`pom.xml`](pom.xml).

See the general [Global Index](../docs/docs/multimodal-table/global-index.mdx) documentation for the
required Data Evolution table properties, coverage/freshness behavior, and shared build options.

## How columns map to index types

For each indexed column, `es-index` decides the sub-index type from the column data type, unless you
override it with `fields.<field>.type`:

| Column data type | Default sub-index | Override with `type` |
|---|---|---|
| `VECTOR<FLOAT>` / `ARRAY<FLOAT>` | Vector (ANN) | `vector` |
| `STRING` | Full-text primary + keyword sub-field | `fulltext` or `keyword` |
| `TIMESTAMP` / `DATE` | Long scalar | `date` |
| supported numeric scalars | Scalar | — |

Text columns always provide both capabilities. A `fulltext` primary field gets a `<column>.keyword`
exact-match sub-field; a `keyword` primary field gets a `<column>.fulltext` analyzed sub-field.

## Prerequisites

Create a Data Evolution table with the required properties:

```sql
CREATE TABLE my_table (
    id INT,
    embedding ARRAY<FLOAT>,
    content STRING,
    category STRING,
    price INT
) TBLPROPERTIES (
    'bucket' = '-1',
    'row-tracking.enabled' = 'true',
    'data-evolution.enabled' = 'true',
    'global-index.enabled' = 'true'
);
```

## Build ES Index

Build the index with `sys.create_global_index` and `index_type => 'es-index'`. To index several
columns in one `es-index`, pass a **comma-separated** `index_column` list: the **first** column is
the primary column, the rest are companion columns. Per-column behavior is configured through options
under the `global-index.es-index.` prefix.

### Spark SQL

```sql
-- Single vector column with an HNSW index
CALL sys.create_global_index(
    table => 'db.my_table',
    index_column => 'embedding',
    index_type => 'es-index',
    options => 'global-index.es-index.fields.embedding.algorithm=hnsw,global-index.es-index.fields.embedding.dimension=768,global-index.es-index.fields.embedding.metric=cosine'
);

-- Multi-column: vector (primary) + full-text + keyword + scalar in one index.
-- 'content' becomes full-text because an analyzer is set; 'category' stays keyword; 'price' scalar.
CALL sys.create_global_index(
    table => 'db.my_table',
    index_column => 'embedding,content,category,price',
    index_type => 'es-index',
    options => 'global-index.es-index.fields.embedding.algorithm=diskbbq,global-index.es-index.fields.embedding.dimension=768,global-index.es-index.fields.embedding.metric=cosine,global-index.es-index.fields.content.analyzer=standard,global-index.es-index.fields.category.type=keyword'
);
```

### Flink SQL

```sql
-- Single vector column with an HNSW index
CALL sys.create_global_index(
    `table` => 'db.my_table',
    index_column => 'embedding',
    index_type => 'es-index',
    options => 'global-index.es-index.fields.embedding.algorithm=hnsw,global-index.es-index.fields.embedding.dimension=768,global-index.es-index.fields.embedding.metric=cosine'
);

-- Multi-column: vector (primary) + full-text + keyword + scalar in one index.
CALL sys.create_global_index(
    `table` => 'db.my_table',
    index_column => 'embedding,content,category,price',
    index_type => 'es-index',
    options => 'global-index.es-index.fields.embedding.algorithm=diskbbq,global-index.es-index.fields.embedding.dimension=768,global-index.es-index.fields.embedding.metric=cosine,global-index.es-index.fields.content.analyzer=standard,global-index.es-index.fields.category.type=keyword'
);
```

You can also set the same options in `TBLPROPERTIES` at table-creation time instead of passing them
per build. Options are resolved with field-level keys taking precedence over index-type-level keys:
`global-index.es-index.fields.<field>.<key>` overrides `global-index.es-index.<key>`.

## Options

Index-type-level keys use the `global-index.es-index.<key>` prefix and apply to every column of that
kind; field-level keys use `global-index.es-index.fields.<field>.<key>` and override them for one
column.

| Key | Applies to | Default | Description |
|---|---|---|---|
| `type` | any column | inferred from data type | Force the sub-index type: `vector`, `fulltext`, `keyword`, `date`. |
| `algorithm` | vector | `hnsw` | Vector algorithm: `hnsw`, `int8_hnsw`, or `diskbbq`. `native` is not supported by paimon-eslib. |
| `dimension` | vector | inferred for `VECTOR<FLOAT>` | Vector dimension (1–4096). Required for `ARRAY<FLOAT>`; taken from the type for `VECTOR<FLOAT>`. |
| `metric` | vector | `cosine` | Distance metric (for example `cosine`, `l2`, `dot_product`). Use the same metric at build and query time. |
| `m` | vector (`hnsw`, `int8_hnsw`) | engine default | HNSW graph out-degree; valid range: 1–512. |
| `ef_construction` | vector (`hnsw`, `int8_hnsw`) | engine default | HNSW construction search width; valid range: 1–3200. |
| `vectors_per_cluster` | vector (`diskbbq`) | engine default | Target vectors per cluster for DiskBBQ; valid range: 64–65536. |
| `centroids_per_parent_cluster` | vector (`diskbbq`) | engine default | Number of child centroids per parent cluster for DiskBBQ; valid range: 2–384. |
| `analyzer` | string | `standard` | Text analyzer used by the full-text primary field or `.fulltext` sub-field: `standard`, `whitespace`, `simple`, or `keyword`. |

## Query

`es-index` implements the same search API as the other global indexes, so querying does not depend on
which engine built the index — only the build step differs.

### Spark SQL

```sql
-- Vector search: top-5 nearest neighbors on the primary vector column
SELECT * FROM vector_search('my_table', 'embedding', array(1.0f, 2.0f, 3.0f), 5);

-- Full-text search on a companion full-text column, using the JSON query DSL
SELECT * FROM full_text_search(
    'my_table',
    'content',
    '{"match":{"query":"paimon lake format"}}',
    10
);
```

Vector routes and full-text routes over an `es-index` can also be combined with the
`hybrid_search(...)` table-valued function.

### Flink SQL

Flink exposes vector search as a `CALL` procedure. The procedure returns JSON-serialized rows as
strings.

```sql
-- Vector search: top-5 nearest neighbors on the primary vector column
CALL sys.vector_search(
    `table` => 'db.my_table',
    vector_column => 'embedding',
    query_vector => '1.0,2.0,3.0',
    top_k => 5
);

-- Optionally restrict the returned columns
CALL sys.vector_search(
    `table` => 'db.my_table',
    vector_column => 'embedding',
    query_vector => '1.0,2.0,3.0',
    top_k => 5,
    projection => 'id,content'
);
```

> **Note.** Flink SQL currently exposes vector search only. For full-text search and hybrid search
> over an `es-index`, use Spark SQL (the `full_text_search` / `hybrid_search` table-valued functions)
> or the Java API (`Table.newFullTextSearchBuilder()` / `Table.newHybridSearchBuilder()`).

## Drop ES Index

### Spark SQL

```sql
CALL sys.drop_global_index(
    table => 'db.my_table',
    index_column => 'embedding',
    index_type => 'es-index'
);
```

### Flink SQL

```sql
CALL sys.drop_global_index(
    `table` => 'db.my_table',
    index_column => 'embedding',
    index_type => 'es-index'
);
```
