This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new db7a979832 [python] Add multimodal API for data-evolution tables
(#8421)
db7a979832 is described below
commit db7a9798321d5dd2cdb17ecf101655b03bfe9efb
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Jul 2 13:08:27 2026 +0800
[python] Add multimodal API for data-evolution tables (#8421)
Add a high-level `pypaimon.multimodal` API for local Python applications
on top of data-evolution tables. The facade wraps a catalog plus default
database and exposes table helpers for ingestion, merge, scan, indexing,
vector search, full-text search, batched vector search, and hybrid
search.
---
docs/docs/pypaimon/multimodal-api.mdx | 428 ++++++++
docs/sidebars.js | 1 +
paimon-python/pypaimon/cli/cli_table.py | 6 +-
.../pypaimon/{cli => common}/where_parser.py | 2 +-
paimon-python/pypaimon/multimodal/__init__.py | 45 +
paimon-python/pypaimon/multimodal/connection.py | 200 ++++
paimon-python/pypaimon/multimodal/query.py | 254 +++++
paimon-python/pypaimon/multimodal/table.py | 682 ++++++++++++
.../pypaimon/tests/multimodal_table_test.py | 1132 ++++++++++++++++++++
paimon-python/pypaimon/tests/where_parser_test.py | 2 +-
10 files changed, 2747 insertions(+), 5 deletions(-)
diff --git a/docs/docs/pypaimon/multimodal-api.mdx
b/docs/docs/pypaimon/multimodal-api.mdx
new file mode 100644
index 0000000000..df91f5d120
--- /dev/null
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -0,0 +1,428 @@
+---
+title: "Multimodal API"
+sidebar_position: 3
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+<!--
+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.
+-->
+
+# Multimodal API
+
+`pypaimon.multimodal` provides a high-level API for local Python applications
+on top of Paimon data-evolution tables. It does not replace the lower-level
+`Catalog`, `Database`, or `Table` APIs. Instead, a multimodal connection wraps
+a Paimon catalog plus a default database, and exposes a compact table API for
+scalar, text, vector, and blob data.
+
+Creating a multimodal table enables `row-tracking.enabled`,
+`data-evolution.enabled`, and `deletion-vectors.enabled` by default. Global
+index scans use full fallback coverage through `global-index.search-mode` set
+to `FULL`. Data files use Vortex by default through `file.format = vortex`, and
+vector columns are stored in dedicated Vortex files through
+`vector.file.format` set to `vortex`. Install the Vortex extra when writing or
+reading default multimodal tables from Python:
+
+```shell
+pip install pypaimon[vortex]
+```
+
+## Connect
+
+`connect` creates a multimodal connection from Paimon catalog options. The
+`options` argument is forwarded to `CatalogFactory.create`.
+
+<Tabs groupId="pypaimon-multimodal-connect">
+
+<TabItem value="filesystem" label="Filesystem catalog" default>
+
+```python
+import pypaimon.multimodal as pm
+
+conn = pm.connect(
+ database="default",
+ options={
+ "warehouse": "file:///tmp/warehouse",
+ },
+)
+```
+
+</TabItem>
+
+<TabItem value="rest" label="REST catalog">
+
+```python
+import pypaimon.multimodal as pm
+
+conn = pm.connect(
+ database="default",
+ options={
+ "metastore": "rest",
+ "uri": "http://localhost:8080",
+ "warehouse": "catalog_name",
+ # Optional authentication and storage options go here.
+ # "token.provider": "...",
+ },
+)
+```
+
+REST catalog options use the same keys as the lower-level PyPaimon catalog API.
+See [REST](../concepts/rest/) for server-side catalog configuration.
+
+</TabItem>
+
+</Tabs>
+
+Table names without a database are resolved against the connection's default
+database. Fully-qualified names such as `"analytics.docs"` are also accepted.
+Use `get_table` to open an existing multimodal table. The table must be a
+data-evolution table without primary keys.
+
+```python
+docs = conn.get_table("docs")
+```
+
+## Create
+
+<Tabs groupId="pypaimon-multimodal-create-table">
+
+<TabItem value="schema" label="Create from schema" default>
+
+```python
+import pyarrow as pa
+
+docs = conn.create_table(
+ "docs",
+ schema=pa.schema([
+ pa.field("id", pa.int64()),
+ pa.field("content", pa.string()),
+ pa.field("embedding", pa.list_(pa.float32(), 3)),
+ pa.field("image", pa.large_binary()),
+ pa.field("category", pa.string()),
+ ]),
+ ignore_if_exists=True,
+)
+```
+
+</TabItem>
+
+<TabItem value="data" label="Create from data">
+
+```python
+docs = conn.create_table(
+ "docs",
+ data=[
+ {
+ "id": 1,
+ "content": "Apache Paimon supports mutable lakehouse tables.",
+ "category": "lake",
+ }
+ ],
+)
+```
+
+</TabItem>
+
+<TabItem value="partitioned" label="Create partitioned table">
+
+```python
+import pyarrow as pa
+
+docs = conn.create_table(
+ "docs_by_day",
+ schema=pa.schema([
+ pa.field("id", pa.int64()),
+ pa.field("content", pa.string()),
+ pa.field("embedding", pa.list_(pa.float32(), 3)),
+ pa.field("category", pa.string()),
+ pa.field("dt", pa.string()),
+ ]),
+ partitioned=["dt"],
+ options={
+ "snapshot.time-retained": "7 d",
+ },
+)
+```
+
+</TabItem>
+
+</Tabs>
+
+Partition keys can be specified with `partitioned`, and table options can be
+specified with `options`. The multimodal API accepts `pyarrow.Schema` objects;
+it does not accept field dictionaries or `pypaimon.Schema`. Use Arrow
fixed-size
+list types for vector columns, and Arrow binary or large-binary types for blob
+columns. When creating from data, pass an explicit schema if you need exact
+vector or blob types.
+
+## Add
+
+`add` accepts `pyarrow.Table`, `pyarrow.RecordBatch`, a list of dictionaries, a
+dictionary of arrays, or a pandas DataFrame. Input columns are aligned and cast
+to the Paimon table schema before writing.
+
+```python
+docs.add([
+ {
+ "id": 2,
+ "content": "Paimon stores mutable lakehouse tables.",
+ "embedding": [0.4, 0.5, 0.6],
+ "category": "lake",
+ }
+])
+```
+
+## Update
+
+`update` modifies rows matched by a SQL-like predicate.
+
+```python
+docs.update(
+ where="id = 2",
+ values={"category": "docs"},
+)
+```
+
+## Merge
+
+Use `merge` for idempotent ingestion. The builder delegates to Paimon's local
+`MERGE INTO` implementation.
+
+`execute` takes the source rows for the merge. It accepts the same input
formats
+as `add`, including `pyarrow.Table`, `pyarrow.RecordBatch`, a list of
+dictionaries, a dictionary of arrays, or a pandas DataFrame. A list of
+dictionaries is convenient for small batches. `when_matched_update()` updates
+the columns present in the source data and leaves omitted target columns
+unchanged. `when_not_matched_insert()` inserts the columns present in the
source
+data and fills omitted target columns with null.
+
+```python
+from pypaimon.multimodal import source_col
+
+docs.merge("id") \
+ .when_matched_update(
+ where="source.category != target.category",
+ ) \
+ .when_not_matched_insert() \
+ .execute([
+ {
+ "id": 2,
+ "content": "Updated text",
+ "embedding": [0.7, 0.8, 0.9],
+ "category": "docs",
+ }
+ ])
+```
+
+Clause predicates use `where`. Refer to source rows with `source.<column>` and
+target rows with `target.<column>`. Install `pypaimon[sql]` when using merge
+clause predicates.
+
+When source and target key names differ, pass a mapping from target column to
+source column:
+
+```python
+docs.merge({"id": "doc_id"}) \
+ .when_matched_update({"category": source_col("new_category")}) \
+ .when_not_matched_insert({
+ "id": source_col("doc_id"),
+ "content": source_col("content"),
+ "category": source_col("new_category"),
+ }) \
+ .execute([
+ {"doc_id": 3, "content": "New doc", "new_category": "search"},
+ ])
+```
+
+## Scan
+
+Use `scan()` for ordinary table reads. `where()` accepts SQL-like predicate
+strings; it does not accept lower-level `Predicate` objects.
+
+```python
+result = (
+ docs.scan()
+ .where("category = 'lake'")
+ .select(["id", "content"])
+ .limit(10)
+ .to_pandas()
+)
+```
+
+## Create Index
+
+Use `create_index` to create the global indexes used by search APIs. The
+`index_type` argument is required; the multimodal API does not choose a default
+index implementation. The `full-text` alias is normalized to
+`tantivy-fulltext`.
+
+```python
+docs.create_index("embedding", index_type="ivf-pq")
+docs.create_index("content", index_type="full-text")
+```
+
+## Search
+
+Use `search` for one vector query or one full-text query.
+
+If the table has exactly one vector column, `column` can be omitted for vector
+search. A string query is shorthand for a full-text match query when the table
+has exactly one text column. To target a specific text column, pass a
+`FullTextQuery` object.
+
+Use `pre_filter` to prune search candidates before ranking. Use `where()` to
+filter the rows read from the search result. Both `pre_filter` and `where()`
+accept SQL-like predicate strings. For full-text search, `pre_filter` must only
+reference partition columns.
+
+```python
+neighbors = (
+ docs.search(
+ [0.1, 0.2, 0.3],
+ column="embedding",
+ pre_filter="category = 'lake'",
+ )
+ .limit(10)
+ .to_arrow()
+)
+
+matches = (
+ docs.search("paimon vector")
+ .limit(10)
+ .to_pandas()
+)
+```
+
+```python
+from pypaimon.globalindex.full_text_query import FullTextQuery
+
+content_query = FullTextQuery.from_dict({
+ "match": {
+ "column": "content",
+ "terms": "paimon vector",
+ "operator": "And",
+ },
+})
+
+matches = docs.search(content_query).limit(10).to_list()
+```
+
+## Search Hybrid
+
+Use `search_hybrid` to combine vector and full-text routes, then rerank the
+merged candidates. Create the indexes required by the routes you use.
+
+Pass route specs to `search_hybrid`. Each route can set its own candidate
+`limit`, `weight`, and vector index `options`. String text routes infer the
+text column when the table has exactly one text column. To target a specific
+text column, pass a `FullTextQuery` object to `pm.text_route`.
+
+`pre_filter` is applied before ranking. It accepts a SQL-like predicate string.
+When a hybrid query has a full-text route, `pre_filter` must only reference
+partition columns.
+
+```python
+# This example assumes the table is partitioned by dt.
+hybrid = (
+ docs.search_hybrid(
+ [
+ pm.vector_route("embedding", query_vector, limit=50),
+ pm.text_route("paimon vector", weight=0.2),
+ ],
+ pre_filter="dt = '2026-07-01'",
+ )
+ .rerank("rrf")
+ .limit(10)
+ .to_list()
+)
+```
+
+For multiple vector fields, add multiple vector routes:
+
+```python
+hybrid = (
+ docs.search_hybrid(
+ [
+ pm.vector_route(
+ "image_embedding",
+ image_vector,
+ weight=0.7,
+ limit=50,
+ options={"nprobe": "8"},
+ ),
+ pm.vector_route(
+ "text_embedding",
+ text_vector,
+ weight=0.3,
+ limit=50,
+ ),
+ pm.text_route("paimon vector", weight=0.2),
+ ],
+ ranker="weighted_score",
+ )
+ .limit(10)
+ .to_list()
+)
+```
+
+Dictionary route specs are also accepted:
+
+```python
+hybrid = docs.search_hybrid(
+ [
+ {
+ "column": "image_embedding",
+ "vector": image_vector,
+ "weight": 0.7,
+ "limit": 50,
+ "options": {"nprobe": "8"},
+ },
+ {
+ "column": "text_embedding",
+ "vector": text_vector,
+ "weight": 0.3,
+ "limit": 50,
+ },
+ ],
+)
+```
+
+Dictionary vector routes also accept `anns_field`, `data`, and `param` aliases.
+
+## Search Vectors
+
+Use `search_vectors` for multiple query vectors against one vector column. It
+returns one result set for each input vector, preserving input order.
+
+```python
+batch_neighbors = (
+ docs.search_vectors(
+ [
+ [0.1, 0.2, 0.3],
+ [0.4, 0.5, 0.6],
+ ],
+ column="embedding",
+ pre_filter="category = 'lake'",
+ )
+ .limit(10)
+ .to_list()
+)
+```
diff --git a/docs/sidebars.js b/docs/sidebars.js
index e0558a3a21..62a0f3ec03 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -187,6 +187,7 @@ const sidebars = {
},
"items": [
"pypaimon/python-api",
+ "pypaimon/multimodal-api",
"pypaimon/manage-tags",
"pypaimon/ray-data",
"pypaimon/daft",
diff --git a/paimon-python/pypaimon/cli/cli_table.py
b/paimon-python/pypaimon/cli/cli_table.py
index daee51db8c..6b9d633e21 100644
--- a/paimon-python/pypaimon/cli/cli_table.py
+++ b/paimon-python/pypaimon/cli/cli_table.py
@@ -86,7 +86,7 @@ def cmd_table_read(args):
# When both select and where are specified, ensure where-referenced fields
# are included in the projection so the filter can work correctly.
if user_columns and where_clause:
- from pypaimon.cli.where_parser import extract_fields_from_where
+ from pypaimon.common.where_parser import extract_fields_from_where
where_fields = extract_fields_from_where(where_clause,
available_fields)
user_column_set = set(user_columns)
extra_where_columns = [f for f in where_fields if f not in
user_column_set]
@@ -97,7 +97,7 @@ def cmd_table_read(args):
# Apply where filter if specified
if where_clause:
- from pypaimon.cli.where_parser import parse_where_clause
+ from pypaimon.common.where_parser import parse_where_clause
try:
predicate = parse_where_clause(where_clause,
table.table_schema.fields)
if predicate:
@@ -191,7 +191,7 @@ def cmd_table_explain(args):
where_clause = getattr(args, 'where', None)
if where_clause:
- from pypaimon.cli.where_parser import parse_where_clause
+ from pypaimon.common.where_parser import parse_where_clause
try:
predicate = parse_where_clause(where_clause,
table.table_schema.fields)
if predicate:
diff --git a/paimon-python/pypaimon/cli/where_parser.py
b/paimon-python/pypaimon/common/where_parser.py
similarity index 99%
rename from paimon-python/pypaimon/cli/where_parser.py
rename to paimon-python/pypaimon/common/where_parser.py
index 1690f5b16d..3286c81a5c 100644
--- a/paimon-python/pypaimon/cli/where_parser.py
+++ b/paimon-python/pypaimon/common/where_parser.py
@@ -16,7 +16,7 @@
# under the License.
"""
-SQL WHERE clause parser for Paimon CLI.
+SQL WHERE clause parser for PyPaimon.
Parses simple SQL-like WHERE expressions into Predicate objects.
diff --git a/paimon-python/pypaimon/multimodal/__init__.py
b/paimon-python/pypaimon/multimodal/__init__.py
new file mode 100644
index 0000000000..2f957630d4
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/__init__.py
@@ -0,0 +1,45 @@
+# 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.
+
+"""High-level APIs for mutable multimodal Paimon tables."""
+
+from pypaimon.multimodal.connection import MultimodalConnection, connect
+from pypaimon.multimodal.table import (
+ MultimodalTable,
+ TextRoute,
+ VectorRoute,
+ text_route,
+ vector_route,
+)
+from pypaimon.table.data_evolution_merge_into import (
+ lit,
+ source_col,
+ target_col,
+)
+
+__all__ = [
+ "MultimodalConnection",
+ "MultimodalTable",
+ "TextRoute",
+ "VectorRoute",
+ "connect",
+ "lit",
+ "source_col",
+ "target_col",
+ "text_route",
+ "vector_route",
+]
diff --git a/paimon-python/pypaimon/multimodal/connection.py
b/paimon-python/pypaimon/multimodal/connection.py
new file mode 100644
index 0000000000..b06940e620
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/connection.py
@@ -0,0 +1,200 @@
+# 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.
+
+from typing import Dict, Iterable, Optional
+
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema as PaimonSchema
+from pypaimon.catalog.catalog import Catalog
+from pypaimon.catalog.catalog_exception import (
+ DatabaseNotExistException,
+ TableAlreadyExistException,
+ TableNotExistException,
+)
+from pypaimon.multimodal.table import MultimodalTable, _to_arrow_table
+
+_DEFAULT_OPTIONS = {
+ "row-tracking.enabled": "true",
+ "data-evolution.enabled": "true",
+ "deletion-vectors.enabled": "true",
+ "file.format": "vortex",
+ "global-index.search-mode": "full",
+ "vector.file.format": "vortex",
+}
+
+_DEFAULT_DATABASE = Catalog.DEFAULT_DATABASE
+
+
+def connect(
+ *,
+ database: str = _DEFAULT_DATABASE,
+ options: Optional[Dict[str, str]] = None):
+ """Connect to a Paimon catalog through the multimodal facade."""
+ return MultimodalConnection(
+ _resolve_catalog(options),
+ database=database,
+ )
+
+
+class MultimodalConnection:
+ """High-level entry point backed by a Paimon catalog and database."""
+
+ def __init__(self, catalog, database: str = _DEFAULT_DATABASE):
+ if not database:
+ raise ValueError("database is required.")
+ self.catalog = catalog
+ self.database = database
+
+ def create_table(
+ self,
+ name: str,
+ data=None,
+ schema=None,
+ options: Optional[Dict[str, str]] = None,
+ partitioned: Optional[Iterable[str]] = None,
+ ignore_if_exists: bool = False):
+ """Create a multimodal table and optionally add initial data."""
+ identifier = self._identifier(name)
+ already_exists = _table_exists(self.catalog, identifier)
+ paimon_schema = _to_paimon_schema(schema, data, options, partitioned)
+
+ self._create_database_for(identifier)
+ try:
+ self.catalog.create_table(
+ identifier, paimon_schema, ignore_if_exists)
+ except TableAlreadyExistException:
+ if not ignore_if_exists:
+ raise
+
+ table = self.get_table(name)
+ if data is not None and not already_exists:
+ table.add(data)
+ return table
+
+ def get_table(self, name: str):
+ identifier = self._identifier(name)
+ raw_table = self.catalog.get_table(identifier)
+ _validate_multimodal_table(raw_table, identifier)
+ return MultimodalTable(
+ self.catalog,
+ identifier,
+ raw_table,
+ )
+
+ def drop_table(self, name: str, ignore_if_not_exists: bool = False):
+ self.catalog.drop_table(
+ self._identifier(name),
+ ignore_if_not_exists=ignore_if_not_exists,
+ )
+
+ def _identifier(self, name: str) -> str:
+ return _resolve_identifier(name, self.database)
+
+ def _create_database_for(self, identifier: str):
+ database_name = _database_name(identifier)
+ if database_name is not None:
+ self.catalog.create_database(database_name, ignore_if_exists=True)
+
+
+def _resolve_catalog(options):
+ resolved_options = {}
+ if options:
+ resolved_options.update(options)
+ if not resolved_options:
+ raise ValueError("options is required.")
+ return CatalogFactory.create(resolved_options)
+
+
+def _table_exists(catalog, identifier: str) -> bool:
+ try:
+ catalog.get_table(identifier)
+ return True
+ except (DatabaseNotExistException, TableNotExistException):
+ return False
+
+
+def _validate_multimodal_table(table, identifier: str):
+ table_schema = table.table_schema
+ options = table_schema.options
+ if str(options.get("data-evolution.enabled", "false")).lower() != "true":
+ raise ValueError(
+ "Table %s is not a data-evolution table; "
+ "data-evolution.enabled must be true." % identifier)
+ if table_schema.primary_keys:
+ raise ValueError(
+ "Table %s has primary keys %s; multimodal tables must not have "
+ "primary keys." % (identifier, table_schema.primary_keys))
+
+
+def _resolve_identifier(name: str, database: str) -> str:
+ if not name:
+ raise ValueError("table name is required.")
+ if "." in name:
+ return name
+ return "%s.%s" % (database, name)
+
+
+def _to_paimon_schema(schema, data, options, partitioned):
+ if schema is None:
+ if data is None:
+ raise ValueError("schema or data is required.")
+ pa_schema, inferred_options = _infer_pyarrow_schema(data)
+ else:
+ pa_schema = _to_pyarrow_schema(schema)
+ inferred_options = {}
+ return PaimonSchema.from_pyarrow_schema(
+ pa_schema,
+ partition_keys=_normalize_partitioned(partitioned),
+ options=_merge_options(inferred_options, options),
+ )
+
+
+def _merge_options(*option_groups):
+ table_options = dict(_DEFAULT_OPTIONS)
+ for options in option_groups:
+ if options:
+ table_options.update({str(k): str(v) for k, v in options.items()})
+ return table_options
+
+
+def _normalize_partitioned(partitioned):
+ if partitioned is None:
+ return []
+ if isinstance(partitioned, str):
+ return [partitioned]
+ return list(partitioned)
+
+
+def _to_pyarrow_schema(schema):
+ if isinstance(schema, pa.Schema):
+ return schema
+ raise ValueError("schema must be a pyarrow.Schema.")
+
+
+def _infer_pyarrow_schema(data):
+ table = _to_arrow_table(data)
+ if table.num_columns == 0:
+ raise ValueError("Cannot infer schema from empty data.")
+ return table.schema, {}
+
+
+def _database_name(identifier: str):
+ parts = identifier.split(".")
+ if len(parts) == 2 and parts[0]:
+ return parts[0]
+ return None
diff --git a/paimon-python/pypaimon/multimodal/query.py
b/paimon-python/pypaimon/multimodal/query.py
new file mode 100644
index 0000000000..be0d4fb065
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/query.py
@@ -0,0 +1,254 @@
+# 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.
+
+from typing import Callable, List, Optional
+
+from pypaimon.common.where_parser import parse_where_clause
+
+
+class ScanQuery:
+ """Chainable scan wrapper for MultimodalTable."""
+
+ def __init__(
+ self,
+ table,
+ result_factory: Optional[Callable] = None):
+ self._table = table
+ self._predicate = None
+ self._projection = None
+ self._limit = None
+ self._result_factory = result_factory
+
+ def where(self, predicate):
+ predicate = self._coerce_predicate(predicate, "where()")
+ if predicate is not None:
+ self._predicate = self._and_predicate(self._predicate, predicate)
+ return self
+
+ def select(self, columns):
+ if isinstance(columns, str):
+ columns = [columns]
+ self._projection = list(columns)
+ return self
+
+ def limit(self, limit: int):
+ self._limit = limit
+ return self
+
+ def to_arrow(self):
+ if self._result_factory is not None:
+ return self._read_global_index_result(self._result_factory(self))
+
+ read_builder = self._configured_read_builder()
+ scan = read_builder.new_scan()
+ plan = scan.plan()
+ return read_builder.new_read().to_arrow(plan.splits())
+
+ def _configured_read_builder(self):
+ read_builder = self._table.new_read_builder()
+ if self._predicate is not None:
+ read_builder = read_builder.with_filter(self._predicate)
+ if self._projection is not None:
+ read_builder = read_builder.with_projection(self._projection)
+ if self._limit is not None:
+ read_builder = read_builder.with_limit(self._limit)
+ return read_builder
+
+ def _read_global_index_result(self, result):
+ read_builder = self._configured_read_builder()
+ scan = read_builder.new_scan().with_global_index_result(result)
+ plan = scan.plan()
+ return read_builder.new_read().to_arrow(plan.splits())
+
+ def to_pandas(self):
+ return self.to_arrow().to_pandas()
+
+ def to_list(self) -> List[dict]:
+ return self.to_arrow().to_pylist()
+
+ def _coerce_predicate(self, predicate, method):
+ if predicate is None:
+ return None
+ if isinstance(predicate, str):
+ return parse_where_clause(predicate, self._table.fields)
+ raise ValueError("%s expects a SQL-like string." % method)
+
+ def _and_predicate(self, left, right):
+ if left is None:
+ return right
+ from pypaimon.common.predicate_builder import PredicateBuilder
+ return PredicateBuilder.and_predicates([left, right])
+
+
+class _PreFilterQuery(ScanQuery):
+
+ def __init__(
+ self,
+ table,
+ result_factory: Optional[Callable] = None,
+ pre_filter=None):
+ self._pre_filter = None
+ super().__init__(table, result_factory=result_factory)
+ if pre_filter is not None:
+ self.pre_filter(pre_filter)
+
+ def pre_filter(self, predicate):
+ predicate = self._coerce_predicate(predicate, "pre_filter()")
+ if predicate is not None:
+ self._pre_filter = self._and_predicate(self._pre_filter, predicate)
+ return self
+
+
+class VectorQuery(_PreFilterQuery):
+ """Chainable query wrapper for vector global-index search."""
+
+ def __init__(
+ self,
+ table,
+ vector,
+ vector_column,
+ vector_options=None,
+ pre_filter=None):
+ self._vector = vector
+ self._vector_column = vector_column
+ self._vector_options = dict(vector_options or {})
+ super().__init__(
+ table, result_factory=self._execute_vector, pre_filter=pre_filter)
+
+ def _execute_vector(self, query):
+ limit = query._limit if query._limit is not None else 10
+ builder = (
+ self._table.new_vector_search_builder()
+ .with_vector_column(self._vector_column)
+ .with_query_vector(self._vector)
+ .with_limit(limit)
+ .with_options(self._vector_options)
+ )
+ if query._pre_filter is not None:
+ builder = builder.with_filter(query._pre_filter)
+ return builder.execute_local()
+
+
+class TextQuery(_PreFilterQuery):
+ """Chainable query wrapper for full-text global-index search."""
+
+ def __init__(self, table, text_query, pre_filter=None):
+ self._text_query = text_query
+ super().__init__(
+ table, result_factory=self._execute_fts, pre_filter=pre_filter)
+
+ def _execute_fts(self, query):
+ limit = query._limit if query._limit is not None else 10
+ builder = (
+ self._table.new_full_text_search_builder()
+ .with_query(self._text_query)
+ .with_limit(limit)
+ )
+ if query._pre_filter is not None:
+ builder = builder.with_partition_filter(query._pre_filter)
+ return builder.execute_local()
+
+
+class HybridQuery(_PreFilterQuery):
+ """Chainable query wrapper for hybrid global-index search."""
+
+ def __init__(
+ self,
+ table,
+ vector_routes=None,
+ text_routes=None,
+ ranker="rrf",
+ route_limit=None,
+ pre_filter=None):
+ self._vector_routes = list(vector_routes or [])
+ self._text_routes = list(text_routes or [])
+ self._ranker = ranker
+ self._route_limit = route_limit
+ super().__init__(
+ table, result_factory=self._execute_hybrid, pre_filter=pre_filter)
+
+ def rerank(self, ranker):
+ self._ranker = ranker
+ return self
+
+ def _execute_hybrid(self, query):
+ final_limit = query._limit if query._limit is not None else 10
+ route_limit = self._route_limit or final_limit
+ builder = (
+ self._table.new_hybrid_search_builder()
+ .with_limit(final_limit)
+ .with_ranker(self._ranker)
+ )
+ for route in self._vector_routes:
+ builder = builder.add_vector_route(
+ route["column"],
+ route["vector"],
+ limit=route.get("limit") or route_limit,
+ weight=route["weight"],
+ options=route["options"],
+ )
+ for route in self._text_routes:
+ builder = builder.add_full_text_route(
+ route["query"].to_json(),
+ limit=route.get("limit") or route_limit,
+ weight=route["weight"],
+ options=route["options"],
+ )
+ if query._pre_filter is not None:
+ builder = builder.with_filter(query._pre_filter)
+ return builder.execute_local()
+
+
+class BatchVectorQuery(_PreFilterQuery):
+ """Chainable query wrapper for batch vector global-index search."""
+
+ def __init__(
+ self,
+ table,
+ vectors,
+ vector_column,
+ vector_options=None,
+ pre_filter=None):
+ self._vectors = vectors
+ self._vector_column = vector_column
+ self._vector_options = dict(vector_options or {})
+ super().__init__(table, pre_filter=pre_filter)
+
+ def to_arrow(self):
+ return [
+ self._read_global_index_result(result)
+ for result in self._execute_batch_vector(self)
+ ]
+
+ def to_pandas(self):
+ return [table.to_pandas() for table in self.to_arrow()]
+
+ def to_list(self) -> List[List[dict]]:
+ return [table.to_pylist() for table in self.to_arrow()]
+
+ def _execute_batch_vector(self, query):
+ limit = query._limit if query._limit is not None else 10
+ builder = (
+ self._table.new_batch_vector_search_builder()
+ .with_vector_column(self._vector_column)
+ .with_query_vectors(self._vectors)
+ .with_limit(limit)
+ .with_options(self._vector_options)
+ )
+ if query._pre_filter is not None:
+ builder = builder.with_filter(query._pre_filter)
+ return builder.execute_batch_local()
diff --git a/paimon-python/pypaimon/multimodal/table.py
b/paimon-python/pypaimon/multimodal/table.py
new file mode 100644
index 0000000000..e88d9af8c0
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/table.py
@@ -0,0 +1,682 @@
+# 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.
+
+import re
+from dataclasses import dataclass
+from typing import Dict, Mapping, Optional, Sequence
+
+import pyarrow as pa
+
+from pypaimon.globalindex.full_text_query import FullTextQuery
+from pypaimon.multimodal.query import (
+ BatchVectorQuery,
+ HybridQuery,
+ ScanQuery,
+ TextQuery,
+ VectorQuery,
+)
+from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.table.data_evolution_merge_into import (
+ WhenMatched,
+ WhenNotMatched,
+ source_col as _source_col,
+)
+
+
+_ALL_SOURCE_COLUMNS = object()
+_MERGE_REF_PATTERN = re.compile(r'\b(source|target)\.(\w+)\b')
+_STRING_LITERAL_PATTERN = re.compile(r"'(?:[^']|'')*'")
+
+
+@dataclass(frozen=True)
+class VectorRoute:
+ """Vector route spec for hybrid search."""
+
+ column: Optional[str]
+ vector: object
+ weight: float = 1.0
+ limit: Optional[int] = None
+ options: Optional[Dict[str, str]] = None
+
+
+@dataclass(frozen=True)
+class TextRoute:
+ """Full-text route spec for hybrid search."""
+
+ query: object
+ weight: float = 1.0
+ limit: Optional[int] = None
+ options: Optional[Dict[str, str]] = None
+
+
+def vector_route(column, vector, *, weight: float = 1.0,
+ limit: Optional[int] = None,
+ options: Optional[Dict[str, str]] = None) -> VectorRoute:
+ return VectorRoute(
+ column=column,
+ vector=vector,
+ weight=weight,
+ limit=limit,
+ options=dict(options or {}),
+ )
+
+
+def text_route(query, *, weight: float = 1.0,
+ limit: Optional[int] = None,
+ options: Optional[Dict[str, str]] = None) -> TextRoute:
+ return TextRoute(
+ query=query,
+ weight=weight,
+ limit=limit,
+ options=dict(options or {}),
+ )
+
+
+class MultimodalTable:
+ """High-level table facade for mutable multimodal application data."""
+
+ def __init__(self, catalog, identifier: str, raw_table):
+ self.catalog = catalog
+ self.identifier = identifier
+ self.name = identifier
+ self.raw_table = raw_table
+ self.table = raw_table
+
+ def add(self, data):
+ arrow_table = _to_arrow_table(data, _target_schema(self.raw_table))
+ write_builder = self.raw_table.new_batch_write_builder()
+ table_write = write_builder.new_write()
+ table_commit = write_builder.new_commit()
+ try:
+ table_write.write_arrow(arrow_table)
+ table_commit.commit(table_write.prepare_commit())
+ finally:
+ table_write.close()
+ table_commit.close()
+ return self
+
+ def update(self, where, values):
+ query = self.scan().where(where)
+ predicate = query._predicate
+ write_builder = self.raw_table.new_batch_write_builder()
+ table_update = write_builder.new_update()
+ table_commit = write_builder.new_commit()
+ try:
+ messages = table_update.update_by_predicate(predicate, values)
+ table_commit.commit(messages)
+ finally:
+ table_commit.close()
+ return self
+
+ def merge(self, on):
+ return _MergeBuilder(self, on)
+
+ def scan(self):
+ return ScanQuery(self.raw_table)
+
+ def search(
+ self,
+ query,
+ *,
+ column: Optional[str] = None,
+ options: Optional[Dict[str, str]] = None,
+ pre_filter=None):
+ schema = _target_schema(self.raw_table)
+ if isinstance(query, str):
+ return TextQuery(
+ self.raw_table,
+ text_query=_coerce_full_text_query(query, "search", schema),
+ pre_filter=pre_filter,
+ )
+ vector = _coerce_vector(query, "search")
+ if _looks_like_batch_vectors(vector):
+ raise ValueError(
+ "search() accepts a single query; use search_vectors() for "
+ "multiple vectors.")
+ return VectorQuery(
+ self.raw_table,
+ vector=vector,
+ vector_column=column or _infer_vector_column(schema, "column"),
+ vector_options=options,
+ pre_filter=pre_filter,
+ )
+
+ def search_vectors(
+ self,
+ vectors,
+ *,
+ column: Optional[str] = None,
+ options: Optional[Dict[str, str]] = None,
+ pre_filter=None):
+ schema = _target_schema(self.raw_table)
+ vectors = _coerce_vectors(vectors)
+ vector_column = column or _infer_vector_column(schema, "column")
+ return BatchVectorQuery(
+ self.raw_table,
+ vectors=vectors,
+ vector_column=vector_column,
+ vector_options=options,
+ pre_filter=pre_filter,
+ )
+
+ def search_hybrid(
+ self,
+ routes,
+ *,
+ ranker: str = "rrf",
+ route_limit: Optional[int] = None,
+ pre_filter=None):
+ schema = _target_schema(self.raw_table)
+ vector_routes, text_routes = _normalize_hybrid_routes(
+ schema,
+ routes=routes,
+ method="search_hybrid",
+ )
+ if not vector_routes and not text_routes:
+ raise ValueError(
+ "search_hybrid requires at least one route.")
+ return HybridQuery(
+ self.raw_table,
+ vector_routes=vector_routes,
+ text_routes=text_routes,
+ ranker=ranker,
+ route_limit=route_limit,
+ pre_filter=pre_filter,
+ )
+
+ def create_index(self, column, index_type, options=None):
+ return self.raw_table.create_global_index(
+ column, index_type=_normalize_index_type(index_type),
options=options)
+
+ def _merge(
+ self,
+ source,
+ on,
+ when_matched: Sequence[WhenMatched],
+ when_not_matched: Sequence[WhenNotMatched],
+ operation: str):
+ target_cols, source_cols = _normalize_merge_on(on, operation)
+ target_schema = _target_schema(self.raw_table)
+ source_table = _to_arrow_table(source)
+ _validate_merge_on(
+ target_schema, source_table, target_cols, source_cols, operation)
+ source_table = _cast_merge_on_columns(
+ source_table, target_schema, target_cols, source_cols)
+ when_matched, when_not_matched = _resolve_merge_clauses(
+ when_matched,
+ when_not_matched,
+ target_schema,
+ source_table,
+ dict(zip(target_cols, source_cols)),
+ )
+
+ write_builder = self.raw_table.new_batch_write_builder()
+ table_update = write_builder.new_update()
+ table_commit = write_builder.new_commit()
+ try:
+ messages = table_update.merge_into(
+ source_table,
+ on=dict(zip(target_cols, source_cols)),
+ when_matched=list(when_matched),
+ when_not_matched=list(when_not_matched),
+ )
+ table_commit.commit(messages)
+ finally:
+ table_commit.close()
+ return self
+
+
+class _MergeBuilder:
+ """Builder for idempotent merge writes."""
+
+ def __init__(self, table: MultimodalTable, on):
+ self._table = table
+ self._on = on
+ self._when_matched = []
+ self._when_not_matched = []
+
+ def when_matched_update(self, values=None, where: Optional[str] = None):
+ self._when_matched.append(
+ WhenMatched(
+ update=_ALL_SOURCE_COLUMNS if values is None else values,
+ condition=_normalize_merge_where(where),
+ ))
+ return self
+
+ def when_not_matched_insert(self, values=None, where: Optional[str] =
None):
+ self._when_not_matched.append(
+ WhenNotMatched(
+ insert=_ALL_SOURCE_COLUMNS if values is None else values,
+ condition=_normalize_merge_where(where),
+ ))
+ return self
+
+ def execute(self, data):
+ if not self._when_matched and not self._when_not_matched:
+ raise ValueError(
+ "merge requires at least one matched or not-matched clause.")
+ return self._table._merge(
+ data,
+ self._on,
+ when_matched=self._when_matched,
+ when_not_matched=self._when_not_matched,
+ operation="merge",
+ )
+
+
+def _to_arrow_table(data, target_schema=None):
+ if isinstance(data, pa.Table):
+ table = data
+ elif isinstance(data, pa.RecordBatch):
+ table = pa.Table.from_batches([data])
+ elif isinstance(data, list):
+ table = pa.Table.from_pylist(data)
+ elif isinstance(data, dict):
+ table = pa.Table.from_pydict(data)
+ elif hasattr(data, "__dataframe__") or
data.__class__.__module__.startswith("pandas"):
+ table = pa.Table.from_pandas(data, preserve_index=False)
+ else:
+ raise ValueError("Unsupported multimodal data type: %r" % type(data))
+
+ if target_schema is None:
+ return table
+ return _align_to_schema(table, target_schema)
+
+
+def _target_schema(table):
+ return PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+
+
+def _align_to_schema(
+ table: pa.Table,
+ schema: pa.Schema,
+ column_mapping: Optional[Dict[str, str]] = None) -> pa.Table:
+ column_mapping = column_mapping or {}
+ arrays = []
+ for field in schema:
+ source_name = column_mapping.get(field.name, field.name)
+ if source_name in table.column_names:
+ array = table[source_name]
+ if array.type != field.type:
+ array = array.cast(field.type)
+ else:
+ array = pa.nulls(table.num_rows, type=field.type)
+ arrays.append(array)
+ return pa.Table.from_arrays(arrays, schema=schema)
+
+
+def _normalize_merge_on(on, operation: str):
+ if isinstance(on, Mapping):
+ target_cols = list(on.keys())
+ source_cols = list(on.values())
+ elif isinstance(on, str):
+ target_cols = [on]
+ source_cols = [on]
+ else:
+ target_cols = list(on)
+ source_cols = list(on)
+ if not target_cols:
+ raise ValueError("%s requires at least one ON column." % operation)
+ if len(target_cols) != len(source_cols):
+ raise ValueError(
+ "%s ON target/source column counts do not match." % operation)
+ return target_cols, source_cols
+
+
+def _validate_merge_on(
+ target_schema,
+ source_table,
+ target_cols,
+ source_cols,
+ operation: str):
+ target_names = set(target_schema.names)
+ source_names = set(source_table.column_names)
+ missing_targets = [col for col in target_cols if col not in target_names]
+ if missing_targets:
+ raise ValueError(
+ "%s ON target columns are not in table schema: %s"
+ % (operation, missing_targets))
+ missing_sources = [col for col in source_cols if col not in source_names]
+ if missing_sources:
+ raise ValueError(
+ "%s ON source columns are not in source data: %s"
+ % (operation, missing_sources))
+
+
+def _cast_merge_on_columns(source_table, target_schema, target_cols,
source_cols):
+ result = source_table
+ for target_col, source_col_name in zip(target_cols, source_cols):
+ target_type = target_schema.field(target_col).type
+ index = result.schema.get_field_index(source_col_name)
+ if index < 0:
+ continue
+ array = result[source_col_name]
+ if array.type == target_type:
+ continue
+ source_field = result.schema.field(index)
+ result = result.set_column(
+ index,
+ pa.field(
+ source_col_name,
+ target_type,
+ nullable=source_field.nullable,
+ metadata=source_field.metadata,
+ ),
+ array.cast(target_type),
+ )
+ return result
+
+
+def _resolve_merge_clauses(
+ when_matched,
+ when_not_matched,
+ target_schema,
+ source_table,
+ on_map):
+ target_names = list(target_schema.names)
+ source_names = set(source_table.column_names)
+ return (
+ [
+ WhenMatched(
+ update=_resolve_merge_set_spec(
+ clause.update, target_names, source_names, on_map),
+ condition=clause.condition,
+ )
+ for clause in when_matched
+ ],
+ [
+ WhenNotMatched(
+ insert=_resolve_merge_set_spec(
+ clause.insert, target_names, source_names, on_map),
+ condition=clause.condition,
+ )
+ for clause in when_not_matched
+ ],
+ )
+
+
+def _resolve_merge_set_spec(spec, target_names, source_names, on_map):
+ if spec is not _ALL_SOURCE_COLUMNS:
+ return spec
+ return {
+ target_name: _source_col(source_name)
+ for target_name in target_names
+ for source_name in [on_map.get(target_name, target_name)]
+ if source_name in source_names
+ }
+
+
+def _normalize_merge_where(where):
+ if where is None:
+ return None
+ if not isinstance(where, str):
+ raise ValueError("merge where must be a string.")
+ parts, last = [], 0
+ for match in _STRING_LITERAL_PATTERN.finditer(where):
+ parts.append(_rewrite_merge_refs(where[last:match.start()]))
+ parts.append(match.group())
+ last = match.end()
+ parts.append(_rewrite_merge_refs(where[last:]))
+ return ''.join(parts)
+
+
+def _rewrite_merge_refs(text):
+ return _MERGE_REF_PATTERN.sub(
+ lambda m: "%s.%s" % (
+ "s" if m.group(1) == "source" else "t",
+ m.group(2),
+ ),
+ text,
+ )
+
+
+def _normalize_index_type(index_type):
+ if not isinstance(index_type, str):
+ return index_type
+ normalized = index_type.strip().lower().replace("_", "-")
+ if normalized in ("full-text", "fulltext"):
+ return "tantivy-fulltext"
+ return index_type
+
+
+def _coerce_vector(value, method):
+ if value is None or isinstance(value, str):
+ raise ValueError("%s requires a vector query." % method)
+ if hasattr(value, "tolist"):
+ value = value.tolist()
+ try:
+ return list(value)
+ except TypeError as e:
+ raise ValueError("%s requires a vector query." % method) from e
+
+
+def _coerce_vectors(values):
+ method = "search_vectors"
+ if values is None or isinstance(values, str):
+ raise ValueError("%s requires query vectors." % method)
+ if hasattr(values, "tolist"):
+ values = values.tolist()
+ try:
+ vectors = list(values)
+ except TypeError as e:
+ raise ValueError("%s requires query vectors." % method) from e
+ if not vectors:
+ raise ValueError("%s requires at least one vector query." % method)
+ return [_coerce_vector(vector, method) for vector in vectors]
+
+
+def _looks_like_batch_vectors(value):
+ if not value:
+ return False
+ first = value[0]
+ return not isinstance(first, (int, float)) and hasattr(first, "__iter__")
+
+
+def _normalize_hybrid_routes(
+ schema,
+ routes,
+ method):
+ vector_routes, text_routes = [], []
+ for route in _route_specs(routes):
+ if _is_text_route_spec(route):
+ text_routes.append(_normalize_text_route(route, method, schema))
+ else:
+ vector_routes.append(
+ _normalize_vector_route(route, method, schema))
+ return vector_routes, text_routes
+
+
+def _route_specs(routes):
+ if routes is None:
+ return []
+ if isinstance(routes, (VectorRoute, TextRoute, Mapping)):
+ return [routes]
+ if _is_column_vector_pair(routes):
+ return [routes]
+ return list(routes)
+
+
+def _is_text_route_spec(route):
+ if isinstance(route, TextRoute):
+ return True
+ if isinstance(route, VectorRoute):
+ return False
+ if isinstance(route, Mapping):
+ route_type = route.get("type") or route.get("route_type")
+ if route_type is not None:
+ return str(route_type).replace("-", "_").lower() in (
+ "text", "full_text", "fulltext")
+ return (
+ "query" in route
+ or "text" in route
+ or "terms" in route
+ or "full_text_query" in route
+ ) and not _has_vector_query(route)
+ return False
+
+
+def _normalize_vector_route(route, method, schema):
+ if isinstance(route, VectorRoute):
+ column = route.column
+ vector = route.vector
+ weight = route.weight
+ limit = route.limit
+ options = route.options
+ elif isinstance(route, Mapping):
+ column = (
+ route.get("column")
+ or route.get("vector_column")
+ or route.get("field")
+ or route.get("anns_field")
+ )
+ vector = _mapping_vector(route)
+ weight = route.get("weight", 1.0)
+ limit = route.get("limit")
+ options = _mapping_options(route)
+ elif _is_column_vector_pair(route):
+ column, vector = route
+ weight = 1.0
+ limit = None
+ options = None
+ else:
+ raise ValueError(
+ "%s vector routes require a route spec with a column and vector."
+ % method)
+ if not column:
+ raise ValueError("%s vector routes require a column." % method)
+ return {
+ "column": column,
+ "vector": _coerce_vector(vector, method),
+ "weight": weight,
+ "limit": limit,
+ "options": dict(options or {}),
+ }
+
+
+def _normalize_text_route(route, method, schema):
+ if isinstance(route, TextRoute):
+ query = route.query
+ weight = route.weight
+ limit = route.limit
+ options = route.options
+ elif isinstance(route, Mapping):
+ column = route.get("column") or route.get("text_column") or
route.get("field")
+ if column is not None:
+ raise ValueError(
+ "%s text routes do not accept a column; use a full-text "
+ "query DSL to target a column." % method)
+ query = (
+ route.get("query")
+ or route.get("text")
+ or route.get("terms")
+ or route.get("full_text_query")
+ )
+ weight = route.get("weight", 1.0)
+ limit = route.get("limit")
+ options = route.get("options")
+ else:
+ raise ValueError(
+ "%s text routes require a route spec with a query."
+ % method)
+ return {
+ "query": _coerce_full_text_query(query, method, schema),
+ "weight": weight,
+ "limit": limit,
+ "options": dict(options or {}),
+ }
+
+
+def _has_vector_query(route):
+ return any(key in route for key in ("vector", "query_vector", "data"))
+
+
+def _mapping_vector(route):
+ if "vector" in route:
+ return route["vector"]
+ if "query_vector" in route:
+ return route["query_vector"]
+ if "data" in route:
+ data = route["data"]
+ if hasattr(data, "tolist"):
+ data = data.tolist()
+ values = list(data)
+ if len(values) == 1 and hasattr(values[0], "__iter__"):
+ return values[0]
+ return values
+ raise ValueError("Vector route requires vector, query_vector, or data.")
+
+
+def _mapping_options(route):
+ if "options" in route:
+ return route["options"]
+ if "param" in route:
+ param = route["param"]
+ if isinstance(param, Mapping) and isinstance(param.get("params"),
Mapping):
+ return param["params"]
+ return param
+ if "params" in route:
+ return route["params"]
+ return None
+
+
+def _is_column_vector_pair(route):
+ return (
+ isinstance(route, (list, tuple))
+ and len(route) == 2
+ and isinstance(route[0], str)
+ )
+
+
+def _coerce_full_text_query(query, method, schema):
+ if isinstance(query, FullTextQuery):
+ return query
+ if isinstance(query, str):
+ return FullTextQuery.from_dict({
+ "match": {
+ "column": _infer_text_column(schema, "text"),
+ "terms": query,
+ },
+ })
+ raise ValueError("%s requires a text string." % method)
+
+
+def _infer_vector_column(schema: pa.Schema, parameter: str = "vector_column"):
+ columns = [
+ field.name
+ for field in schema
+ if pa.types.is_fixed_size_list(field.type)
+ ]
+ return _infer_single_column(columns, "vector", parameter)
+
+
+def _infer_text_column(schema: pa.Schema, parameter: str = "text_column"):
+ columns = [
+ field.name
+ for field in schema
+ if pa.types.is_string(field.type) or
pa.types.is_large_string(field.type)
+ ]
+ return _infer_single_column(columns, "text", parameter)
+
+
+def _infer_single_column(columns, kind, parameter):
+ if len(columns) == 1:
+ return columns[0]
+ if not columns:
+ raise ValueError("No %s column found; pass %s." % (kind, parameter))
+ raise ValueError(
+ "Multiple %s columns found %s; pass %s."
+ % (kind, columns, parameter))
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py
b/paimon-python/pypaimon/tests/multimodal_table_test.py
new file mode 100644
index 0000000000..1afe8debea
--- /dev/null
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -0,0 +1,1132 @@
+# 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.
+
+import json
+import os
+import shutil
+import tempfile
+import unittest
+
+import pyarrow as pa
+import pypaimon.multimodal as pmm
+from pypaimon.multimodal import source_col
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon import Schema as PaimonSchema
+from pypaimon.globalindex.global_index_result import GlobalIndexResult
+from pypaimon.utils.range import Range
+
+
+_PARQUET_OPTIONS = {
+ "row-tracking.enabled": "true",
+ "data-evolution.enabled": "true",
+ "deletion-vectors.enabled": "true",
+ "file.format": "parquet",
+ "vector.file.format": "parquet",
+}
+
+
+def _schema(fields):
+ return pa.schema([
+ pa.field(name, field_type)
+ for name, field_type in fields.items()
+ ])
+
+
+def _vector(dim):
+ return pa.list_(pa.float32(), dim)
+
+
+def _raw_schema(options=None, primary_keys=None):
+ return PaimonSchema.from_pyarrow_schema(
+ _schema({"id": pa.int32(), "name": pa.string()}),
+ primary_keys=list(primary_keys or []),
+ options=dict(options or {"file.format": "parquet"}),
+ )
+
+
+class MultimodalTableTest(unittest.TestCase):
+
+ def setUp(self):
+ self.temp_dir = tempfile.mkdtemp(prefix="pypaimon_mm_")
+ self.warehouse = os.path.join(self.temp_dir, "warehouse")
+ self.conn = pmm.connect(options={"warehouse": self.warehouse})
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
+
+ def test_connect_accepts_options(self):
+ conn = pmm.connect(
+ database="analytics",
+ options={
+ "warehouse": os.path.join(self.temp_dir, "warehouse_options"),
+ },
+ )
+
+ table = conn.create_table(
+ "docs",
+ schema=_schema({"id": pa.int32()}),
+ options=_PARQUET_OPTIONS,
+ )
+
+ self.assertEqual("analytics.docs", table.identifier)
+
+ def test_connect_rejects_positional_warehouse(self):
+ with self.assertRaises(TypeError):
+ pmm.connect(self.warehouse)
+
+ def test_create_table_defaults_data_evolution_options(self):
+ table = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "content": pa.string(),
+ "embedding": _vector(3),
+ "payload": pa.large_binary(),
+ }),
+ )
+
+ options = table.raw_table.table_schema.options
+ self.assertEqual("true", options["row-tracking.enabled"])
+ self.assertEqual("true", options["data-evolution.enabled"])
+ self.assertEqual("true", options["deletion-vectors.enabled"])
+ self.assertNotIn("data-evolution.row-sidecar.enabled", options)
+ self.assertEqual("vortex", options["file.format"])
+ self.assertEqual("full", options["global-index.search-mode"])
+ self.assertEqual("vortex", options["vector.file.format"])
+ self.assertEqual("default.docs",
self.conn.get_table("docs").identifier)
+ self.assertEqual(["id", "content", "embedding", "payload"],
+ [field.name for field in table.raw_table.fields])
+
+ def test_create_table_uses_options_and_partitioned(self):
+ table = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "embedding": _vector(3),
+ "dt": pa.string(),
+ }),
+ options=dict(_PARQUET_OPTIONS, **{
+ "deletion-vectors.enabled": "false",
+ }),
+ partitioned=["dt"],
+ )
+
+ options = table.raw_table.table_schema.options
+ self.assertEqual(["dt"], table.raw_table.table_schema.partition_keys)
+ self.assertEqual("false", options["deletion-vectors.enabled"])
+ self.assertEqual("parquet", options["file.format"])
+ self.assertEqual("parquet", options["vector.file.format"])
+
+ def test_create_table_rejects_non_arrow_schema(self):
+ with self.assertRaisesRegex(ValueError, "pyarrow.Schema"):
+ self.conn.create_table("docs", schema={"id": pa.int32()})
+
+ def test_create_table_accepts_pyarrow_schema_types(self):
+ table = self.conn.create_table(
+ "typed",
+ schema=pa.schema([
+ pa.field("flag", pa.bool_(), nullable=False),
+ pa.field("tiny", pa.int8()),
+ pa.field("small", pa.int16()),
+ pa.field("id", pa.int64()),
+ pa.field("score", pa.float32()),
+ pa.field("ratio", pa.float64()),
+ pa.field("title", pa.string()),
+ pa.field("payload", pa.binary()),
+ pa.field("hash", pa.binary(16)),
+ pa.field("blob", pa.large_binary()),
+ pa.field("amount", pa.decimal128(12, 2)),
+ pa.field("dt", pa.date32()),
+ pa.field("created_at", pa.timestamp("us")),
+ pa.field("created_ltz", pa.timestamp("us", tz="UTC")),
+ pa.field("tags", pa.list_(pa.string())),
+ pa.field("attrs", pa.map_(pa.string(), pa.int32())),
+ pa.field("meta", pa.struct([pa.field("rank", pa.int32())])),
+ pa.field("embedding", _vector(3)),
+ ]),
+ )
+
+ types_by_name = {
+ field.name: str(field.type) for field in table.raw_table.fields
+ }
+ self.assertEqual("BOOLEAN NOT NULL", types_by_name["flag"])
+ self.assertEqual("TINYINT", types_by_name["tiny"])
+ self.assertEqual("SMALLINT", types_by_name["small"])
+ self.assertEqual("BIGINT", types_by_name["id"])
+ self.assertEqual("FLOAT", types_by_name["score"])
+ self.assertEqual("DOUBLE", types_by_name["ratio"])
+ self.assertEqual("STRING", types_by_name["title"])
+ self.assertEqual("BYTES", types_by_name["payload"])
+ self.assertEqual("BINARY(16)", types_by_name["hash"])
+ self.assertEqual("BLOB", types_by_name["blob"])
+ self.assertEqual("DECIMAL(12, 2)", types_by_name["amount"])
+ self.assertEqual("DATE", types_by_name["dt"])
+ self.assertEqual("TIMESTAMP(6)", types_by_name["created_at"])
+ self.assertEqual("TIMESTAMP_LTZ(6)", types_by_name["created_ltz"])
+ self.assertEqual("ARRAY<STRING>", types_by_name["tags"])
+ self.assertEqual("MAP<STRING, INT>", types_by_name["attrs"])
+ self.assertEqual("ROW<rank: INT>", types_by_name["meta"])
+ self.assertEqual("VECTOR<FLOAT, 3>", types_by_name["embedding"])
+
+ def test_drop_table_can_ignore_missing_table(self):
+ self.conn.drop_table("missing", ignore_if_not_exists=True)
+
+ def test_get_table_rejects_non_data_evolution_table(self):
+ self.conn.catalog.create_database("default", ignore_if_exists=True)
+ self.conn.catalog.create_table(
+ "default.raw",
+ _raw_schema(),
+ False,
+ )
+
+ with self.assertRaisesRegex(ValueError, "data-evolution.enabled"):
+ self.conn.get_table("raw")
+
+ def test_get_table_rejects_primary_key_table(self):
+ self.conn.catalog.create_database("default", ignore_if_exists=True)
+ self.conn.catalog.create_table(
+ "default.pk",
+ _raw_schema(
+ options=dict(_PARQUET_OPTIONS, **{"bucket": "1"}),
+ primary_keys=["id"],
+ ),
+ False,
+ )
+
+ with self.assertRaisesRegex(ValueError, "primary keys"):
+ self.conn.get_table("pk")
+
+ def test_create_table_can_add_initial_data_and_get_by_short_name(self):
+ self.conn.create_table(
+ "users",
+ data=[
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": "Bob", "age": 25},
+ ],
+ schema=_schema({
+ "id": pa.int32(),
+ "name": pa.string(),
+ "age": pa.int32(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ users = self.conn.get_table("users")
+ result = users.scan().select(["id", "name"]).to_arrow()
+
+ self.assertEqual(["id", "name"], result.column_names)
+ self.assertEqual([1, 2], result["id"].to_pylist())
+
+ def test_add_scan_where_select_limit(self):
+ users = self.conn.create_table(
+ "users",
+ schema=_schema({
+ "id": pa.int32(),
+ "name": pa.string(),
+ "age": pa.int32(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ users.add([
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": "Bob", "age": 25},
+ {"id": 3, "name": "Carol", "age": 40},
+ ])
+
+ result = (
+ users.scan()
+ .where("age >= 30")
+ .select(["id", "name"])
+ .limit(1)
+ .to_arrow()
+ )
+
+ self.assertEqual(["id", "name"], result.column_names)
+ self.assertEqual(1, result.num_rows)
+ self.assertEqual([1], result["id"].to_pylist())
+
+ def test_scan_does_not_expose_pre_filter(self):
+ users = self.conn.create_table(
+ "users",
+ schema=_schema({"id": pa.int32()}),
+ options=_PARQUET_OPTIONS,
+ )
+
+ self.assertFalse(hasattr(users.scan(), "pre_filter"))
+
+ def test_where_rejects_predicate_object(self):
+ users = self.conn.create_table(
+ "users",
+ schema=_schema({"id": pa.int32()}),
+ options=_PARQUET_OPTIONS,
+ )
+ predicate = PredicateBuilder(users.raw_table.fields).equal("id", 1)
+
+ with self.assertRaisesRegex(ValueError, "SQL-like string"):
+ users.scan().where(predicate)
+
+ def test_update_by_filter(self):
+ users = self.conn.create_table(
+ "users",
+ data=[
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": "Bob", "age": 25},
+ ],
+ schema=_schema({
+ "id": pa.int32(),
+ "name": pa.string(),
+ "age": pa.int32(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ users.update(where="id = 2", values={"age": 26})
+
+ rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
+ self.assertEqual(
+ [
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": "Bob", "age": 26},
+ ],
+ rows,
+ )
+
+ def test_merge_updates_matches_and_inserts_new_rows(self):
+ users = self.conn.create_table(
+ "users",
+ data=[
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": "Bob", "age": 25},
+ ],
+ schema=_schema({
+ "id": pa.int32(),
+ "name": pa.string(),
+ "age": pa.int32(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ users.merge("id") \
+ .when_matched_update() \
+ .when_not_matched_insert() \
+ .execute([
+ {"id": 2, "name": "Bob_v2", "age": 26},
+ {"id": 3, "name": "Carol", "age": 40},
+ ])
+
+ rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
+ self.assertEqual(
+ [
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": "Bob_v2", "age": 26},
+ {"id": 3, "name": "Carol", "age": 40},
+ ],
+ rows,
+ )
+
+ def test_merge_all_uses_only_source_columns(self):
+ users = self.conn.create_table(
+ "users",
+ data=[
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": "Bob", "age": 25},
+ ],
+ schema=_schema({
+ "id": pa.int32(),
+ "name": pa.string(),
+ "age": pa.int32(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ users.merge("id") \
+ .when_matched_update() \
+ .when_not_matched_insert() \
+ .execute([
+ {"id": 2, "age": 26},
+ {"id": 3, "age": 40},
+ ])
+
+ rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
+ self.assertEqual(
+ [
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": "Bob", "age": 26},
+ {"id": 3, "name": None, "age": 40},
+ ],
+ rows,
+ )
+
+ def test_merge_supports_source_key_mapping(self):
+ users = self.conn.create_table(
+ "users",
+ data=[
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": "Bob", "age": 25},
+ ],
+ schema=_schema({
+ "id": pa.int32(),
+ "name": pa.string(),
+ "age": pa.int32(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ users.merge({"id": "source_id"}) \
+ .when_matched_update({"age": source_col("age")}) \
+ .when_not_matched_insert() \
+ .execute([
+ {"source_id": 2, "name": "Bob_v2", "age": 26},
+ {"source_id": 3, "name": "Carol", "age": 40},
+ ])
+
+ rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
+ self.assertEqual(
+ [
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": "Bob", "age": 26},
+ {"id": 3, "name": "Carol", "age": 40},
+ ],
+ rows,
+ )
+
+ def test_merge_where_uses_source_and_target_aliases(self):
+ users = self.conn.create_table(
+ "users",
+ schema=_schema({
+ "id": pa.int32(),
+ "name": pa.string(),
+ "age": pa.int32(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ calls = {}
+
+ class FakeUpdate:
+ def merge_into(
+ self,
+ source,
+ on,
+ when_matched=None,
+ when_not_matched=None):
+ calls["on"] = on
+ calls["matched"] = list(when_matched or [])
+ calls["not_matched"] = list(when_not_matched or [])
+ return []
+
+ class FakeCommit:
+ def commit(self, messages):
+ calls["messages"] = messages
+
+ def close(self):
+ pass
+
+ class FakeWriteBuilder:
+ def new_update(self):
+ return FakeUpdate()
+
+ def new_commit(self):
+ return FakeCommit()
+
+ users.raw_table.new_batch_write_builder = lambda: FakeWriteBuilder()
+
+ (
+ users.merge("id")
+ .when_matched_update(
+ {"age": source_col("age")},
+ where="source.age > target.age and source.name !=
'target.name'",
+ )
+ .when_not_matched_insert(where="source.age > 0")
+ .execute([
+ {"id": 1, "name": "Alice", "age": 31},
+ ])
+ )
+
+ self.assertEqual({"id": "id"}, calls["on"])
+ self.assertEqual(
+ "s.age > t.age and s.name != 'target.name'",
+ calls["matched"][0].condition,
+ )
+ self.assertEqual("s.age > 0", calls["not_matched"][0].condition)
+ self.assertEqual([], calls["messages"])
+
+ def test_merge_validates_on_columns(self):
+ users = self.conn.create_table(
+ "users",
+ schema=_schema({"id": pa.int32(), "name": pa.string()}),
+ options=_PARQUET_OPTIONS,
+ )
+
+ with self.assertRaisesRegex(ValueError, "source columns"):
+ users.merge({"id": "missing"}) \
+ .when_matched_update() \
+ .execute([{"id": 1, "name": "Alice"}])
+
+ def test_create_index_normalizes_full_text_alias(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "content": pa.string(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ calls = []
+
+ def create_global_index(column, index_type, options=None):
+ calls.append((column, index_type, options))
+ return index_type
+
+ docs.raw_table.create_global_index = create_global_index
+
+ options = {"tokenizer": "default"}
+ self.assertEqual(
+ "tantivy-fulltext",
+ docs.create_index("content", index_type="full-text",
+ options=options),
+ )
+ self.assertEqual(
+ "tantivy-fulltext",
+ docs.create_index("content", index_type="full_text"),
+ )
+ self.assertEqual(
+ "tantivy-fulltext",
+ docs.create_index("content", index_type="fulltext"),
+ )
+
+ self.assertEqual(
+ [
+ ("content", "tantivy-fulltext", options),
+ ("content", "tantivy-fulltext", None),
+ ("content", "tantivy-fulltext", None),
+ ],
+ calls,
+ )
+
+ def test_create_index_requires_index_type(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "content": pa.string(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ def create_global_index(column, index_type, options=None):
+ return index_type
+
+ docs.raw_table.create_global_index = create_global_index
+
+ with self.assertRaises(TypeError):
+ docs.create_index("content")
+
+ def test_table_does_not_expose_predicate_builder(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({"id": pa.int32()}),
+ options=_PARQUET_OPTIONS,
+ )
+
+ self.assertFalse(hasattr(docs, "predicate_builder"))
+
+ def test_search_reads_vector_matching_rows(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "content": pa.string(),
+ "embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ docs.add([
+ {"id": 1, "content": "a", "embedding": [1.0, 0.0, 0.0]},
+ {"id": 2, "content": "b", "embedding": [0.0, 1.0, 0.0]},
+ {"id": 3, "content": "c", "embedding": [0.0, 0.0, 1.0]},
+ ])
+
+ calls = {}
+
+ class FakeVectorBuilder:
+ def with_vector_column(self, column):
+ calls["column"] = column
+ return self
+
+ def with_query_vector(self, vector):
+ calls["vector"] = vector
+ return self
+
+ def with_limit(self, limit):
+ calls["limit"] = limit
+ return self
+
+ def with_options(self, options):
+ calls["options"] = options
+ return self
+
+ def with_filter(self, predicate):
+ calls["filter"] = predicate
+ return self
+
+ def execute_local(self):
+ return GlobalIndexResult.from_range(Range(1, 1))
+
+ docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder()
+
+ result = (
+ docs.search([0.0, 1.0, 0.0])
+ .where("id >= 1")
+ .limit(5)
+ .to_arrow()
+ )
+
+ self.assertEqual("embedding", calls["column"])
+ self.assertEqual([0.0, 1.0, 0.0], calls["vector"])
+ self.assertEqual(5, calls["limit"])
+ self.assertEqual([2], result["id"].to_pylist())
+
+ def test_search_applies_pre_filter_to_vector_builder(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "category": pa.string(),
+ "embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ docs.add([
+ {"id": 1, "category": "lake", "embedding": [1.0, 0.0, 0.0]},
+ {"id": 2, "category": "city", "embedding": [0.0, 1.0, 0.0]},
+ ])
+
+ calls = {}
+
+ class FakeVectorBuilder:
+ def with_vector_column(self, column):
+ return self
+
+ def with_query_vector(self, vector):
+ return self
+
+ def with_limit(self, limit):
+ return self
+
+ def with_options(self, options):
+ return self
+
+ def with_filter(self, predicate):
+ calls["pre_filter"] = predicate
+ return self
+
+ def execute_local(self):
+ return GlobalIndexResult.from_range(Range(0, 0))
+
+ docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder()
+
+ docs.search(
+ [1.0, 0.0, 0.0],
+ pre_filter="category = 'lake'",
+ ).limit(1).to_list()
+
+ self.assertEqual("equal", calls["pre_filter"].method)
+ self.assertEqual("category", calls["pre_filter"].field)
+ self.assertEqual(["lake"], calls["pre_filter"].literals)
+
+ def test_search_pre_filter_rejects_predicate_object(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ predicate = PredicateBuilder(docs.raw_table.fields).equal("id", 1)
+
+ with self.assertRaisesRegex(ValueError, "SQL-like string"):
+ docs.search([1.0, 0.0, 0.0]).pre_filter(predicate)
+
+ def test_search_accepts_generator_vector(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ docs.add([{"id": 1, "embedding": [1.0, 0.0, 0.0]}])
+
+ calls = {}
+
+ class FakeVectorBuilder:
+ def with_vector_column(self, column):
+ calls["column"] = column
+ return self
+
+ def with_query_vector(self, vector):
+ calls["vector"] = vector
+ return self
+
+ def with_limit(self, limit):
+ calls["limit"] = limit
+ return self
+
+ def with_options(self, options):
+ calls["options"] = options
+ return self
+
+ def execute_local(self):
+ return GlobalIndexResult.from_range(Range(0, 0))
+
+ docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder()
+
+ result = docs.search((v for v in [1.0, 0.0, 0.0])).limit(1).to_list()
+
+ self.assertEqual("embedding", calls["column"])
+ self.assertEqual([1.0, 0.0, 0.0], calls["vector"])
+ self.assertEqual([{"id": 1, "embedding": [1.0, 0.0, 0.0]}], result)
+
+ def test_search_rejects_batch_vectors(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ with self.assertRaisesRegex(ValueError, "use search_vectors"):
+ docs.search([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
+
+ def test_search_vectors_reads_one_result_set_per_query_vector(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "content": pa.string(),
+ "embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ docs.add([
+ {"id": 1, "content": "a", "embedding": [1.0, 0.0, 0.0]},
+ {"id": 2, "content": "b", "embedding": [0.0, 1.0, 0.0]},
+ {"id": 3, "content": "c", "embedding": [0.0, 0.0, 1.0]},
+ ])
+
+ calls = {}
+
+ class FakeBatchVectorBuilder:
+ def with_vector_column(self, column):
+ calls["column"] = column
+ return self
+
+ def with_query_vectors(self, vectors):
+ calls["vectors"] = vectors
+ return self
+
+ def with_limit(self, limit):
+ calls["limit"] = limit
+ return self
+
+ def with_options(self, options):
+ calls["options"] = options
+ return self
+
+ def with_filter(self, predicate):
+ calls["filter"] = predicate
+ return self
+
+ def execute_batch_local(self):
+ return [
+ GlobalIndexResult.from_range(Range(0, 0)),
+ GlobalIndexResult.from_range(Range(2, 2)),
+ ]
+
+ docs.raw_table.new_batch_vector_search_builder = (
+ lambda: FakeBatchVectorBuilder())
+
+ result = (
+ docs.search_vectors(
+ [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
+ options={"nprobe": "8"},
+ pre_filter="content = 'a'",
+ )
+ .where("id >= 1")
+ .select(["id"])
+ .limit(2)
+ .to_list()
+ )
+
+ self.assertEqual("embedding", calls["column"])
+ self.assertEqual(
+ [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
+ calls["vectors"],
+ )
+ self.assertEqual(2, calls["limit"])
+ self.assertEqual({"nprobe": "8"}, calls["options"])
+ self.assertEqual("content", calls["filter"].field)
+ self.assertEqual(["a"], calls["filter"].literals)
+ self.assertEqual([[{"id": 1}], [{"id": 3}]], result)
+
+ def test_search_vectors_rejects_text_parameter(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "content": pa.string(),
+ "embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ with self.assertRaises(TypeError):
+ docs.search_vectors([[1.0, 0.0, 0.0]], text="paimon")
+
+ def test_search_reads_text_query(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "content": pa.string(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ docs.add([
+ {"id": 1, "content": "paimon vector"},
+ {"id": 2, "content": "lakehouse"},
+ ])
+
+ calls = {}
+
+ class FakeFullTextBuilder:
+ def with_query(self, query):
+ calls["query"] = query.to_dict()
+ return self
+
+ def with_limit(self, limit):
+ calls["limit"] = limit
+ return self
+
+ def execute_local(self):
+ return GlobalIndexResult.from_range(Range(0, 0))
+
+ docs.raw_table.new_full_text_search_builder = lambda:
FakeFullTextBuilder()
+
+ result = (
+ docs.search("paimon vector")
+ .limit(1)
+ .to_arrow()
+ )
+
+ self.assertEqual(1, calls["limit"])
+ self.assertEqual("content", calls["query"]["match"]["column"])
+ self.assertEqual("paimon vector", calls["query"]["match"]["terms"])
+ self.assertEqual("Or", calls["query"]["match"]["operator"])
+ self.assertEqual([1], result["id"].to_pylist())
+
+ def test_search_string_requires_unambiguous_text_column(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "title": pa.string(),
+ "content": pa.string(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ with self.assertRaisesRegex(ValueError, "Multiple text columns"):
+ docs.search("paimon")
+
+ def test_search_hybrid_rejects_shorthand_arguments(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ with self.assertRaises(TypeError):
+ docs.search_hybrid(vector=[1.0, 0.0, 0.0])
+
+ def test_hybrid_route_helpers_use_route_specific_arguments(self):
+ with self.assertRaises(TypeError):
+ pmm.vector_route([1.0, 0.0, 0.0])
+ route = pmm.text_route("paimon")
+ self.assertFalse(hasattr(route, "column"))
+ self.assertEqual("paimon", route.query)
+
+ def test_search_can_build_hybrid_routes(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "content": pa.string(),
+ "embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ docs.add([
+ {"id": 1, "content": "paimon", "embedding": [1.0, 0.0, 0.0]},
+ {"id": 2, "content": "vector", "embedding": [0.0, 1.0, 0.0]},
+ ])
+
+ calls = {}
+
+ class FakeHybridBuilder:
+ def with_limit(self, limit):
+ calls["limit"] = limit
+ return self
+
+ def with_ranker(self, ranker):
+ calls["ranker"] = ranker
+ return self
+
+ def add_vector_route(
+ self, column, vector, limit, weight=1.0, options=None):
+ calls["vector"] = (column, vector, limit, weight, options)
+ return self
+
+ def add_full_text_route(
+ self, query_json, limit, weight=1.0, options=None):
+ calls["text"] = (json.loads(query_json), limit, weight,
options)
+ return self
+
+ def with_filter(self, predicate):
+ calls["filter"] = predicate
+ return self
+
+ def execute_local(self):
+ return GlobalIndexResult.from_range(Range(0, 1))
+
+ docs.raw_table.new_hybrid_search_builder = lambda: FakeHybridBuilder()
+
+ result = (
+ docs.search_hybrid(
+ [
+ pmm.vector_route("embedding", [1.0, 0.0, 0.0]),
+ pmm.text_route("paimon"),
+ ],
+ )
+ .rerank("rrf")
+ .limit(2)
+ .to_arrow()
+ )
+
+ self.assertEqual(2, calls["limit"])
+ self.assertEqual("rrf", calls["ranker"])
+ self.assertEqual(
+ ("embedding", [1.0, 0.0, 0.0], 2, 1.0, {}),
+ calls["vector"],
+ )
+ self.assertEqual("content", calls["text"][0]["match"]["column"])
+ self.assertEqual("paimon", calls["text"][0]["match"]["terms"])
+ self.assertEqual([1, 2], result["id"].to_pylist())
+
+ def test_search_hybrid_applies_pre_filter_to_vector_routes(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "category": pa.string(),
+ "embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ docs.add([
+ {"id": 1, "category": "lake", "embedding": [1.0, 0.0, 0.0]},
+ {"id": 2, "category": "city", "embedding": [0.0, 1.0, 0.0]},
+ ])
+
+ calls = {}
+
+ class FakeHybridBuilder:
+ def with_limit(self, limit):
+ calls["limit"] = limit
+ return self
+
+ def with_ranker(self, ranker):
+ calls["ranker"] = ranker
+ return self
+
+ def add_vector_route(
+ self, column, vector, limit, weight=1.0, options=None):
+ calls["vector"] = (column, vector, limit, weight, options)
+ return self
+
+ def with_filter(self, predicate):
+ calls["pre_filter"] = predicate
+ return self
+
+ def execute_local(self):
+ return GlobalIndexResult.from_range(Range(0, 0))
+
+ docs.raw_table.new_hybrid_search_builder = lambda: FakeHybridBuilder()
+
+ result = (
+ docs.search_hybrid(
+ [pmm.vector_route("embedding", [1.0, 0.0, 0.0])],
+ pre_filter="category = 'lake'",
+ )
+ .limit(1)
+ .to_list()
+ )
+
+ self.assertEqual(1, calls["limit"])
+ self.assertEqual(
+ ("embedding", [1.0, 0.0, 0.0], 1, 1.0, {}),
+ calls["vector"],
+ )
+ self.assertEqual("category", calls["pre_filter"].field)
+ self.assertEqual(["lake"], calls["pre_filter"].literals)
+ self.assertEqual(
+ [{"id": 1, "category": "lake", "embedding": [1.0, 0.0, 0.0]}],
+ result,
+ )
+
+ def test_module_does_not_export_vectors_route(self):
+ self.assertFalse(hasattr(pmm, "vectors_route"))
+ self.assertFalse(hasattr(pmm, "VectorsRoute"))
+
+ def test_search_hybrid_can_build_multiple_vector_routes(self):
+ docs = self.conn.create_table(
+ "docs",
+ schema=_schema({
+ "id": pa.int32(),
+ "content": pa.string(),
+ "image_embedding": _vector(3),
+ "text_embedding": _vector(3),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ docs.add([
+ {
+ "id": 1,
+ "content": "paimon",
+ "image_embedding": [1.0, 0.0, 0.0],
+ "text_embedding": [0.0, 1.0, 0.0],
+ },
+ {
+ "id": 2,
+ "content": "vector",
+ "image_embedding": [0.0, 1.0, 0.0],
+ "text_embedding": [1.0, 0.0, 0.0],
+ },
+ ])
+
+ calls = {"vector_routes": [], "text_routes": []}
+
+ class FakeHybridBuilder:
+ def with_limit(self, limit):
+ calls["limit"] = limit
+ return self
+
+ def with_ranker(self, ranker):
+ calls["ranker"] = ranker
+ return self
+
+ def add_vector_route(
+ self, column, vector, limit, weight=1.0, options=None):
+ calls["vector_routes"].append(
+ (column, vector, limit, weight, options))
+ return self
+
+ def add_full_text_route(
+ self, query_json, limit, weight=1.0, options=None):
+ calls["text_routes"].append(
+ (json.loads(query_json), limit, weight, options))
+ return self
+
+ def execute_local(self):
+ return GlobalIndexResult.from_range(Range(0, 0))
+
+ docs.raw_table.new_hybrid_search_builder = lambda: FakeHybridBuilder()
+
+ result = (
+ docs.search_hybrid(
+ [
+ pmm.vector_route(
+ "image_embedding",
+ [1.0, 0.0, 0.0],
+ weight=0.7,
+ limit=6,
+ options={"nprobe": "8"},
+ ),
+ pmm.vector_route(
+ "text_embedding",
+ [0.0, 1.0, 0.0],
+ weight=0.3,
+ limit=4,
+ options={"nprobe": "4"},
+ ),
+ pmm.text_route("paimon", weight=0.2),
+ ],
+ ranker="weighted_score",
+ route_limit=4,
+ )
+ .limit(2)
+ .to_list()
+ )
+
+ self.assertEqual(2, calls["limit"])
+ self.assertEqual("weighted_score", calls["ranker"])
+ self.assertEqual(
+ [
+ (
+ "image_embedding",
+ [1.0, 0.0, 0.0],
+ 6,
+ 0.7,
+ {"nprobe": "8"},
+ ),
+ (
+ "text_embedding",
+ [0.0, 1.0, 0.0],
+ 4,
+ 0.3,
+ {"nprobe": "4"},
+ ),
+ ],
+ calls["vector_routes"],
+ )
+ self.assertEqual("content",
calls["text_routes"][0][0]["match"]["column"])
+ self.assertEqual("paimon",
calls["text_routes"][0][0]["match"]["terms"])
+ self.assertEqual(4, calls["text_routes"][0][1])
+ self.assertEqual(0.2, calls["text_routes"][0][2])
+ self.assertEqual(
+ [{
+ "id": 1,
+ "content": "paimon",
+ "image_embedding": [1.0, 0.0, 0.0],
+ "text_embedding": [0.0, 1.0, 0.0],
+ }],
+ result,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/where_parser_test.py
b/paimon-python/pypaimon/tests/where_parser_test.py
index 04385243d9..c5ba552ac7 100644
--- a/paimon-python/pypaimon/tests/where_parser_test.py
+++ b/paimon-python/pypaimon/tests/where_parser_test.py
@@ -17,7 +17,7 @@
import unittest
-from pypaimon.cli.where_parser import parse_where_clause, _tokenize,
_cast_literal
+from pypaimon.common.where_parser import parse_where_clause, _tokenize,
_cast_literal
from pypaimon.schema.data_types import ArrayType, AtomicType, DataField