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 cc4cd0dd28 [python] Add row-id APIs to multimodal table (#8442)
cc4cd0dd28 is described below
commit cc4cd0dd28b7cd36fcf26d811b07ba65809359d0
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Jul 3 16:01:46 2026 +0800
[python] Add row-id APIs to multimodal table (#8442)
Adds row-id manifest support to PyPaimon multimodal APIs so applications
can return `_ROW_ID` from scans/searches and fetch selected rows later
with `take_row_ids`. Persisted manifests can now be consumed against a
pinned source snapshot or tag.
---
docs/docs/pypaimon/multimodal-api.mdx | 123 +++++++++++++++
paimon-python/pypaimon/multimodal/query.py | 22 ++-
paimon-python/pypaimon/multimodal/table.py | 93 +++++++++--
.../pypaimon/tests/multimodal_table_test.py | 170 +++++++++++++++++++++
4 files changed, 394 insertions(+), 14 deletions(-)
diff --git a/docs/docs/pypaimon/multimodal-api.mdx
b/docs/docs/pypaimon/multimodal-api.mdx
index 83d13ae345..ebd1427367 100644
--- a/docs/docs/pypaimon/multimodal-api.mdx
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -332,6 +332,129 @@ result = (
)
```
+## Row IDs
+
+Multimodal tables enable `row-tracking.enabled` by default, so each row has a
+Paimon system column named `_ROW_ID`. Use `_ROW_ID` as an internal coordination
+key for retrieval, reranking, inference, and training jobs. Keep user-visible
+document IDs, object keys, or primary keys in normal columns.
+
+Use `with_row_id()` to include `_ROW_ID` in scan or search results. The method
+appends `_ROW_ID` to the current projection; if no projection is set, it
returns
+all table columns plus `_ROW_ID`.
+
+```python
+candidates = (
+ docs.search([0.1, 0.2, 0.3], column="embedding")
+ .where("category = 'lake'")
+ .select(["id", "content"])
+ .limit(100)
+ .with_row_id()
+ .to_pandas()
+)
+
+row_ids = candidates["_ROW_ID"].tolist()
+```
+
+When a row-id manifest is persisted beyond a short-lived latest-snapshot
+workflow, record the source snapshot or tag and pass it back to the read API.
+`scan`, `search`, `search_vectors`, `search_hybrid`, and `take_row_ids` accept
+`snapshot_id` or `tag_name`. These two options are mutually exclusive.
+
+Use `take_row_ids` to fetch rows selected by an earlier scan, vector search,
+full-text search, hybrid search, sampler, or split manifest. Results are not
+guaranteed to follow the input row-id order. Include `_ROW_ID` and reorder on
+the client if order matters.
+
+```python
+payload = (
+ docs.take_row_ids(row_ids, snapshot_id=source_snapshot_id)
+ .select(["id", "content", "image"])
+ .with_row_id()
+ .to_arrow()
+ .to_pylist()
+)
+
+payload_by_row_id = {row["_ROW_ID"]: row for row in payload}
+ordered_payload = [payload_by_row_id[row_id] for row_id in row_ids]
+```
+
+This pattern keeps broad candidate generation cheap: first search or filter to
+produce a compact row-id manifest, then fetch only the columns needed by the
+next stage.
+
+```python
+# Stage 1: broad retrieval with a narrow projection.
+manifest = (
+ docs.search(
+ query_vector,
+ column="embedding",
+ snapshot_id=source_snapshot_id,
+ )
+ .select(["id"])
+ .limit(500)
+ .with_row_id()
+ .to_pandas()
+)
+
+# Stage 2: expensive reranking payload, fetched only for candidates.
+rerank_payload = (
+ docs.take_row_ids(
+ manifest["_ROW_ID"].tolist(),
+ snapshot_id=source_snapshot_id,
+ )
+ .select(["id", "title", "body"])
+ .with_row_id()
+ .to_list()
+)
+
+payload_by_row_id = {row["_ROW_ID"]: row for row in rerank_payload}
+rerank_inputs = [
+ {
+ "row_id": row_id,
+ "candidate_rank": rank,
+ "doc": payload_by_row_id[row_id],
+ }
+ for rank, row_id in enumerate(manifest["_ROW_ID"])
+]
+```
+
+For offline inference, feature backfills, or training splits, store row IDs in
a
+work queue or manifest table together with the source table snapshot or tag
used
+to produce them. Workers can then read row-id batches and fetch only the
columns
+they need:
+
+```python
+def run_inference_worker(docs, row_id_batch, source_snapshot_id):
+ rows = (
+ docs.take_row_ids(row_id_batch, snapshot_id=source_snapshot_id)
+ .select(["id", "content"])
+ .with_row_id()
+ .to_list()
+ )
+
+ outputs = []
+ for row in rows:
+ outputs.append(
+ {
+ "id": row["id"],
+ "source_row_id": row["_ROW_ID"],
+ "prediction": model_predict(row["content"]),
+ }
+ )
+ return outputs
+```
+
+Best practices:
+
+- Treat `_ROW_ID` as an internal row handle, not a business identifier.
+- Store the source snapshot or tag with persisted row-id manifests.
+- Pass the stored `snapshot_id` or `tag_name` when consuming persisted row-id
+ manifests.
+- Use business keys or application columns when writing inference or training
+ outputs back to a table.
+- Reorder `take_row_ids` results client-side when input order matters.
+
## Create Index
Use `create_index` to create the global indexes used by search APIs. The
diff --git a/paimon-python/pypaimon/multimodal/query.py
b/paimon-python/pypaimon/multimodal/query.py
index be0d4fb065..86012b9608 100644
--- a/paimon-python/pypaimon/multimodal/query.py
+++ b/paimon-python/pypaimon/multimodal/query.py
@@ -18,6 +18,7 @@
from typing import Callable, List, Optional
from pypaimon.common.where_parser import parse_where_clause
+from pypaimon.table.special_fields import SpecialFields
class ScanQuery:
@@ -31,6 +32,7 @@ class ScanQuery:
self._predicate = None
self._projection = None
self._limit = None
+ self._include_row_id = False
self._result_factory = result_factory
def where(self, predicate):
@@ -45,6 +47,10 @@ class ScanQuery:
self._projection = list(columns)
return self
+ def with_row_id(self):
+ self._include_row_id = True
+ return self
+
def limit(self, limit: int):
self._limit = limit
return self
@@ -62,12 +68,24 @@ class ScanQuery:
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)
+ projection = self._effective_projection()
+ if projection is not None:
+ read_builder = read_builder.with_projection(projection)
if self._limit is not None:
read_builder = read_builder.with_limit(self._limit)
return read_builder
+ def _effective_projection(self):
+ if self._projection is None:
+ if not self._include_row_id:
+ return None
+ projection = [field.name for field in self._table.fields]
+ else:
+ projection = list(self._projection)
+ if self._include_row_id and SpecialFields.ROW_ID.name not in
projection:
+ projection.append(SpecialFields.ROW_ID.name)
+ return projection
+
def _read_global_index_result(self, result):
read_builder = self._configured_read_builder()
scan = read_builder.new_scan().with_global_index_result(result)
diff --git a/paimon-python/pypaimon/multimodal/table.py
b/paimon-python/pypaimon/multimodal/table.py
index 12de790060..a00f8d1156 100644
--- a/paimon-python/pypaimon/multimodal/table.py
+++ b/paimon-python/pypaimon/multimodal/table.py
@@ -21,6 +21,7 @@ from typing import Dict, Mapping, Optional, Sequence
import pyarrow as pa
+from pypaimon.common.options.core_options import CoreOptions
from pypaimon.globalindex.full_text_query import FullTextQuery
from pypaimon.multimodal.query import (
BatchVectorQuery,
@@ -35,6 +36,7 @@ from pypaimon.table.data_evolution_merge_into import (
WhenNotMatched,
source_col as _source_col,
)
+from pypaimon.table.special_fields import SpecialFields
_ALL_SOURCE_COLUMNS = object()
@@ -155,8 +157,32 @@ class MultimodalTable:
def merge(self, on):
return _MergeBuilder(self, on)
- def scan(self):
- return ScanQuery(self.raw_table)
+ def scan(
+ self,
+ *,
+ snapshot_id: Optional[int] = None,
+ tag_name: Optional[str] = None):
+ return ScanQuery(_time_travel_table(
+ self.raw_table, snapshot_id=snapshot_id, tag_name=tag_name))
+
+ def take_row_ids(
+ self,
+ row_ids,
+ *,
+ snapshot_id: Optional[int] = None,
+ tag_name: Optional[str] = None):
+ row_ids = _coerce_row_ids(row_ids)
+ read_table = _time_travel_table(
+ self.raw_table, snapshot_id=snapshot_id, tag_name=tag_name)
+ read_builder = read_table.new_read_builder().with_projection(
+ [field.name for field in read_table.fields]
+ + [SpecialFields.ROW_ID.name]
+ )
+ predicate = read_builder.new_predicate_builder().is_in(
+ SpecialFields.ROW_ID.name, row_ids)
+ query = ScanQuery(read_table)
+ query._predicate = predicate
+ return query
def blobs(self, *, column: Optional[str] = None, key_column: Optional[str]
= None):
from pypaimon.multimodal.blob_store import BlobStore
@@ -168,11 +194,15 @@ class MultimodalTable:
*,
column: Optional[str] = None,
options: Optional[Dict[str, str]] = None,
- pre_filter=None):
- schema = _target_schema(self.raw_table)
+ pre_filter=None,
+ snapshot_id: Optional[int] = None,
+ tag_name: Optional[str] = None):
+ read_table = _time_travel_table(
+ self.raw_table, snapshot_id=snapshot_id, tag_name=tag_name)
+ schema = _target_schema(read_table)
if isinstance(query, str):
return TextQuery(
- self.raw_table,
+ read_table,
text_query=_coerce_full_text_query(query, "search", schema),
pre_filter=pre_filter,
)
@@ -182,7 +212,7 @@ class MultimodalTable:
"search() accepts a single query; use search_vectors() for "
"multiple vectors.")
return VectorQuery(
- self.raw_table,
+ read_table,
vector=vector,
vector_column=column or _infer_vector_column(schema, "column"),
vector_options=options,
@@ -195,12 +225,16 @@ class MultimodalTable:
*,
column: Optional[str] = None,
options: Optional[Dict[str, str]] = None,
- pre_filter=None):
- schema = _target_schema(self.raw_table)
+ pre_filter=None,
+ snapshot_id: Optional[int] = None,
+ tag_name: Optional[str] = None):
+ read_table = _time_travel_table(
+ self.raw_table, snapshot_id=snapshot_id, tag_name=tag_name)
+ schema = _target_schema(read_table)
vectors = _coerce_vectors(vectors)
vector_column = column or _infer_vector_column(schema, "column")
return BatchVectorQuery(
- self.raw_table,
+ read_table,
vectors=vectors,
vector_column=vector_column,
vector_options=options,
@@ -213,8 +247,12 @@ class MultimodalTable:
*,
ranker: str = "rrf",
route_limit: Optional[int] = None,
- pre_filter=None):
- schema = _target_schema(self.raw_table)
+ pre_filter=None,
+ snapshot_id: Optional[int] = None,
+ tag_name: Optional[str] = None):
+ read_table = _time_travel_table(
+ self.raw_table, snapshot_id=snapshot_id, tag_name=tag_name)
+ schema = _target_schema(read_table)
vector_routes, text_routes = _normalize_hybrid_routes(
schema,
routes=routes,
@@ -224,7 +262,7 @@ class MultimodalTable:
raise ValueError(
"search_hybrid requires at least one route.")
return HybridQuery(
- self.raw_table,
+ read_table,
vector_routes=vector_routes,
text_routes=text_routes,
ranker=ranker,
@@ -338,6 +376,37 @@ def _to_arrow_table(data, target_schema=None):
return _align_to_schema(table, target_schema)
+def _coerce_row_ids(row_ids):
+ if row_ids is None or isinstance(row_ids, (str, bytes)):
+ raise ValueError("row_ids must be an iterable of row id integers.")
+ try:
+ iterator = iter(row_ids)
+ except TypeError:
+ raise ValueError("row_ids must be an iterable of row id integers.")
+
+ coerced = []
+ for row_id in iterator:
+ if hasattr(row_id, "as_py"):
+ row_id = row_id.as_py()
+ coerced.append(int(row_id))
+ return coerced
+
+
+def _time_travel_table(table, snapshot_id=None, tag_name=None):
+ if snapshot_id is not None and tag_name is not None:
+ raise ValueError(
+ "snapshot_id and tag_name cannot be set at the same time")
+ if snapshot_id is not None:
+ return table.copy({
+ CoreOptions.SCAN_SNAPSHOT_ID.key(): str(snapshot_id),
+ })
+ if tag_name is not None:
+ return table.copy({
+ CoreOptions.SCAN_TAG_NAME.key(): tag_name,
+ })
+ return table
+
+
def _target_schema(table):
return PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index 14c11e0040..f880c888e0 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -513,6 +513,137 @@ class MultimodalTableTest(unittest.TestCase):
self.assertEqual(1, result.num_rows)
self.assertEqual([1], result["id"].to_pylist())
+ def test_scan_with_row_id_returns_system_column(self):
+ users = self.conn.create_table(
+ "users",
+ data=[
+ {"id": 1, "name": "Alice"},
+ {"id": 2, "name": "Bob"},
+ ],
+ schema=_schema({
+ "id": pa.int32(),
+ "name": pa.string(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+
+ result = (
+ users.scan()
+ .with_row_id()
+ .select(["id"])
+ .to_arrow()
+ )
+
+ self.assertEqual(["id", "_ROW_ID"], result.column_names)
+ self.assertEqual([1, 2], result["id"].to_pylist())
+ self.assertEqual([0, 1], result["_ROW_ID"].to_pylist())
+
+ def test_take_row_ids_reads_projected_rows(self):
+ docs = self.conn.create_table(
+ "docs",
+ data=[
+ {"id": 1, "content": "alpha"},
+ {"id": 2, "content": "beta"},
+ {"id": 3, "content": "gamma"},
+ ],
+ schema=_schema({
+ "id": pa.int32(),
+ "content": pa.string(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ manifest = {
+ row["id"]: row["_ROW_ID"]
+ for row in docs.scan().select(["id"]).with_row_id().to_list()
+ }
+
+ rows = sorted(
+ docs.take_row_ids([manifest[3], manifest[1]])
+ .select(["id", "content"])
+ .with_row_id()
+ .to_list(),
+ key=lambda row: row["id"],
+ )
+
+ self.assertEqual(
+ [
+ {"id": 1, "content": "alpha", "_ROW_ID": manifest[1]},
+ {"id": 3, "content": "gamma", "_ROW_ID": manifest[3]},
+ ],
+ rows,
+ )
+
+ def test_take_row_ids_accepts_empty_manifest(self):
+ docs = self.conn.create_table(
+ "docs",
+ data=[{"id": 1}],
+ schema=_schema({"id": pa.int32()}),
+ options=_PARQUET_OPTIONS,
+ )
+
+ self.assertEqual([], docs.take_row_ids([]).select(["id"]).to_list())
+
+ def test_take_row_ids_supports_snapshot_and_tag_time_travel(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": "first", "embedding": [1.0, 0.0, 0.0]},
+ ])
+ snapshot_id =
docs.raw_table.snapshot_manager().get_latest_snapshot().id
+ docs.raw_table.create_tag("v1", snapshot_id=snapshot_id)
+ row_id = (
+ docs.scan(snapshot_id=snapshot_id)
+ .select(["id"])
+ .with_row_id()
+ .to_list()[0]["_ROW_ID"]
+ )
+
+ docs.delete(where="id = 1")
+
+ self.assertEqual(
+ [],
+ docs.take_row_ids([row_id]).select(["id"]).to_list(),
+ )
+ self.assertEqual(
+ [{"id": 1, "_ROW_ID": row_id}],
+ docs.take_row_ids([row_id], snapshot_id=snapshot_id)
+ .select(["id"])
+ .with_row_id()
+ .to_list(),
+ )
+ self.assertEqual(
+ [{"id": 1}],
+ docs.take_row_ids([row_id], tag_name="v1")
+ .select(["id"])
+ .to_list(),
+ )
+ self.assertEqual(
+ snapshot_id,
+ docs.search(
+ [1.0, 0.0, 0.0],
+ column="embedding",
+ snapshot_id=snapshot_id,
+ )._table.options.scan_snapshot_id(),
+ )
+
+ def test_time_travel_rejects_snapshot_id_and_tag_name_together(self):
+ docs = self.conn.create_table(
+ "docs",
+ data=[{"id": 1}],
+ schema=_schema({"id": pa.int32()}),
+ options=_PARQUET_OPTIONS,
+ )
+
+ with self.assertRaisesRegex(ValueError, "cannot be set at the same
time"):
+ docs.take_row_ids([0], snapshot_id=1, tag_name="v1")
+
def test_overwrite_replaces_unpartitioned_table(self):
users = self.conn.create_table(
"users",
@@ -1178,6 +1309,45 @@ class MultimodalTableTest(unittest.TestCase):
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_with_row_id_returns_system_column(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]}])
+
+ 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 execute_local(self):
+ return GlobalIndexResult.from_range(Range(0, 0))
+
+ docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder()
+
+ result = (
+ docs.search([1.0, 0.0, 0.0], column="embedding")
+ .select(["id"])
+ .with_row_id()
+ .limit(1)
+ .to_list()
+ )
+
+ self.assertEqual([{"id": 1, "_ROW_ID": 0}], result)
+
def test_search_rejects_batch_vectors(self):
docs = self.conn.create_table(
"docs",