| # 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 copy import copy | |
| from typing import Any, Callable, Dict, List, Optional, Tuple | |
| import pyarrow as pa | |
| from pypaimon.common.where_parser import parse_where_clause | |
| from pypaimon.multimodal.blob_read import fetch_blob_bodies | |
| from pypaimon.schema.data_types import is_array_blob_type, is_blob_type, is_map_blob_type | |
| from pypaimon.table.special_fields import SpecialFields | |
| def _select_arrow_columns(batch, columns): | |
| return batch.select(columns) | |
| 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._include_row_id = False | |
| 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 with_row_id(self): | |
| self._include_row_id = True | |
| 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 to_arrow_batch_reader(self, *, blob_parallelism=None): | |
| """Stream this scan as Arrow batches without collecting a table.""" | |
| if self._result_factory is not None: | |
| raise TypeError( | |
| "to_arrow_batch_reader is only supported on scan(), " | |
| "not search queries." | |
| ) | |
| read_builder = self._configured_read_builder() | |
| splits = read_builder.new_scan().plan().splits() | |
| return read_builder.new_read()._to_managed_arrow_batch_reader( | |
| splits, blob_parallelism=blob_parallelism) | |
| def _configured_read_builder(self, table=None): | |
| read_builder = ( | |
| self._table if table is None else table | |
| ).new_read_builder() | |
| if self._predicate is not None: | |
| read_builder = read_builder.with_filter(self._predicate) | |
| 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) | |
| 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 to_torch( | |
| self, | |
| streaming: bool = True, | |
| prefetch_concurrency: int = 1, | |
| *, | |
| batch_format: str = "row", | |
| batch_size: Optional[int] = None, | |
| to_tensor_fn: Optional[Callable] = None, | |
| shuffle: bool = False, | |
| seed: int = 0, | |
| buffer_size: int = 1000, | |
| max_buffer_input_splits: int = 10): | |
| """Read this scan as a PyTorch Dataset. | |
| BLOB columns stay as serialized descriptors so DataLoader workers can | |
| open and decode the referenced payload without materialising it in the | |
| planning process. Use :class:`VideoFrameCollator` as ``collate_fn`` to | |
| reuse one decoder session across rows that reference the same video. | |
| """ | |
| if self._result_factory is not None: | |
| raise TypeError( | |
| "to_torch is only supported on scan(), not search queries." | |
| ) | |
| from pypaimon.common.options.core_options import CoreOptions | |
| read_table = self._table.copy({ | |
| CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true" | |
| }) | |
| read_builder = self._configured_read_builder(read_table) | |
| splits = read_builder.new_scan().plan().splits() | |
| return read_builder.new_read().to_torch( | |
| splits, | |
| streaming=streaming, | |
| prefetch_concurrency=prefetch_concurrency, | |
| batch_format=batch_format, | |
| batch_size=batch_size, | |
| to_tensor_fn=to_tensor_fn, | |
| shuffle=shuffle, | |
| seed=seed, | |
| buffer_size=buffer_size, | |
| max_buffer_input_splits=max_buffer_input_splits, | |
| ) | |
| def to_contiguous_window_dataset( | |
| self, | |
| *, | |
| window_size=None, | |
| columns=None, | |
| anchor_columns=None, | |
| group_key="episode_index", | |
| order_key="frame_index", | |
| stride=1, | |
| tail=None, | |
| column_transforms=None, | |
| pad_values=None, | |
| adapter=None, | |
| blob_parallelism=64, | |
| frame_offsets=None, | |
| delta_timestamps=None, | |
| fps=None, | |
| tolerance_s=None, | |
| boundary=None): | |
| """Build snapshot-pinned training windows from this single-table scan. | |
| The Dataset indexes only ``group_key``, ``order_key``, and Paimon row | |
| IDs, then reads projected values on demand. Each column has its own | |
| relative frame offsets; unspecified columns use ``[0]``. Transforms | |
| receive padded lists, including singleton windows, before ``adapter`` | |
| receives the sample. ``order_key`` must contain non-null integers that | |
| increase by exactly one within each group. The Dataset sorts rows within | |
| each group and never creates a window across groups. | |
| Args: | |
| window_size: Legacy forward window size. Mutually exclusive with | |
| frame_offsets and delta_timestamps. Retains a single is_pad | |
| mask instead of the new per-column masks. | |
| columns: Value columns to return, excluding the group and order | |
| keys. The scan projection is used when omitted. | |
| anchor_columns: With window_size, columns using only the anchor. | |
| group_key: Column identifying an independent row sequence. | |
| order_key: Integer position column within each group. | |
| stride: Distance between anchors, starting at each group's first row. | |
| tail: Legacy boundary alias requiring window_size; cannot be used | |
| together with boundary. | |
| column_transforms: Per-column callables applied to value lists. | |
| pad_values: Per-column raw padding values; otherwise repeat the | |
| nearest endpoint. Padding happens before column transforms. | |
| adapter: Callable that converts the complete sample mapping. | |
| blob_parallelism: Maximum concurrent BLOB body reads per fetch. | |
| frame_offsets: Mapping of columns to nonempty integer offset | |
| sequences. Supports history, future, sparse and repeated frames. | |
| delta_timestamps: Alternative per-column offsets in seconds, aligned | |
| to the regular frame grid defined by fps within tolerance_s. | |
| fps: Finite positive frame rate, required with delta_timestamps. | |
| tolerance_s: Finite nonnegative seconds-conversion tolerance, | |
| defaulting to 1e-4. Only valid with delta_timestamps. | |
| boundary: Handling at both group ends: drop (default), pad, or error. | |
| Drop retains only anchors valid for every requested offset. | |
| Returns: | |
| A snapshot-pinned ``ContiguousWindowDataset``. See that class for | |
| padding, mask, transform, and adapter result semantics. | |
| """ | |
| from pypaimon.multimodal.window_dataset import ContiguousWindowDataset | |
| return ContiguousWindowDataset( | |
| self, | |
| window_size=window_size, | |
| columns=columns, | |
| anchor_columns=anchor_columns, | |
| group_key=group_key, | |
| order_key=order_key, | |
| stride=stride, | |
| tail=tail, | |
| column_transforms=column_transforms, | |
| pad_values=pad_values, | |
| adapter=adapter, | |
| blob_parallelism=blob_parallelism, | |
| frame_offsets=frame_offsets, | |
| delta_timestamps=delta_timestamps, | |
| fps=fps, | |
| tolerance_s=tolerance_s, | |
| boundary=boundary, | |
| ) | |
| def to_ray( | |
| self, | |
| *, | |
| ray_remote_args: Optional[Dict[str, Any]] = None, | |
| concurrency: Optional[int] = None, | |
| override_num_blocks: Optional[int] = None, | |
| **read_args): | |
| """Read this scan as a Ray Dataset. | |
| BLOB columns are read as serialized descriptors. Use | |
| ``table.map_with_blobs(...)`` to fetch payload bytes on Ray workers. | |
| """ | |
| if self._result_factory is not None: | |
| raise TypeError("to_ray is only supported on scan(), not search queries.") | |
| read_builder, file_io, visible_columns = self._blob_descriptor_query_read_builder() | |
| plan = read_builder.new_scan().plan() | |
| ds = read_builder.new_read().to_ray( | |
| plan.splits(), | |
| ray_remote_args=ray_remote_args, | |
| concurrency=concurrency, | |
| override_num_blocks=override_num_blocks, | |
| **read_args) | |
| if visible_columns is not None: | |
| ds = ds.map_batches( | |
| _select_arrow_columns, | |
| fn_kwargs={"columns": visible_columns}, | |
| batch_format="pyarrow") | |
| setattr(ds, "_paimon_blob_file_io", file_io) | |
| setattr(ds, "_paimon_blob_columns", self._readable_blob_columns()) | |
| map_blob_cols, array_blob_cols = self._nested_blob_columns() | |
| setattr(ds, "_paimon_map_blob_columns", map_blob_cols) | |
| setattr(ds, "_paimon_array_blob_columns", array_blob_cols) | |
| return ds | |
| def read_blobs( | |
| self, columns=None, *, parallelism: int = 64 | |
| ) -> Tuple[pa.Table, Dict[str, List[Any]]]: | |
| """Materialise BLOB, MAP BLOB or ARRAY BLOB columns with concurrent, | |
| coalesced ranged reads. Reads via blob-as-descriptor to skip the slow | |
| row-by-row blob resolution on multi-group data-evolution splits. | |
| ``columns`` picks the BLOB column(s) (default: all, intersected with | |
| ``select(...)``). Scalar BLOB values are ``bytes|None``; ARRAY BLOB rows | |
| are ``None`` or lists of these values; MAP BLOB rows are ``None`` or | |
| key-value pairs with ``bytes|None`` values. Returns a | |
| row-aligned ``(scalar_arrow_table, blobs_by_column)`` tuple. Use | |
| :meth:`stream_blobs` for a memory-bounded read. | |
| Unresolved blob-view columns are not supported and raise ``ValueError``. | |
| """ | |
| blob_cols = self._resolve_blob_columns(columns) | |
| read_builder, file_io = self._blob_descriptor_read_builder(blob_cols) | |
| arrow = read_builder.new_read().to_arrow( | |
| read_builder.new_scan().plan().splits()) | |
| map_blob_cols, array_blob_cols = self._nested_blob_columns() | |
| bodies = self._fetch_bodies( | |
| file_io, arrow.select(blob_cols).to_pydict(), blob_cols, | |
| parallelism, map_blob_cols, array_blob_cols) | |
| scalar = arrow.select(self._scalar_columns(arrow.column_names)) | |
| return scalar, bodies | |
| def stream_blobs(self, columns=None, *, parallelism: int = 64): | |
| """Memory-bounded streaming variant of :meth:`read_blobs`, with the same | |
| return shape per Arrow batch and one-batch peak memory. | |
| """ | |
| # Validate eagerly so a bad column raises here, not on the first next(). | |
| blob_cols = self._resolve_blob_columns(columns) | |
| read_builder, file_io = self._blob_descriptor_read_builder(blob_cols) | |
| return self._iter_blobs(read_builder, file_io, blob_cols, parallelism) | |
| def _iter_blobs(self, read_builder, file_io, blob_cols, parallelism): | |
| map_blob_cols, array_blob_cols = self._nested_blob_columns() | |
| with read_builder.new_read()._to_managed_arrow_batch_reader( | |
| read_builder.new_scan().plan().splits()) as reader: | |
| for batch in reader: | |
| bodies = self._fetch_bodies( | |
| file_io, batch.select(blob_cols).to_pydict(), blob_cols, | |
| parallelism, map_blob_cols, array_blob_cols) | |
| scalar = batch.select(self._scalar_columns(batch.schema.names)) | |
| yield scalar, bodies | |
| def _blob_descriptor_query_read_builder(self): | |
| from pypaimon.common.options.core_options import CoreOptions | |
| read_table = self._table.copy({ | |
| CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true" | |
| }) | |
| read_builder = read_table.new_read_builder() | |
| if self._predicate is not None: | |
| read_builder = read_builder.with_filter(self._predicate) | |
| projection = self._effective_projection() | |
| if projection is not None: | |
| internal_projection = list(projection) | |
| for name in self._predicate_fields(): | |
| if name not in internal_projection: | |
| internal_projection.append(name) | |
| read_builder = read_builder.with_projection(internal_projection) | |
| if self._limit is not None: | |
| read_builder = read_builder.with_limit(self._limit) | |
| if projection is None: | |
| return read_builder, read_table.file_io, None | |
| internal_column_names = [field.name for field in read_builder._output_fields()] | |
| visible_columns = self._projected_output_columns(read_table, projection) | |
| if visible_columns == internal_column_names: | |
| visible_columns = None | |
| return read_builder, read_table.file_io, visible_columns | |
| @staticmethod | |
| def _projected_output_columns(table, projection): | |
| builder = table.new_read_builder().with_projection(projection) | |
| return [field.name for field in builder._output_fields()] | |
| def _blob_descriptor_read_builder(self, blob_cols: List[str]): | |
| """Blob-as-descriptor read builder with this query's filter/projection/limit; | |
| returns ``(read_builder, file_io)``.""" | |
| from pypaimon.common.options.core_options import CoreOptions | |
| read_table = self._table.copy({ | |
| CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true" | |
| }) | |
| read_builder = read_table.new_read_builder() | |
| if self._predicate is not None: | |
| read_builder = read_builder.with_filter(self._predicate) | |
| projection = self._blob_read_projection(blob_cols) | |
| 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, read_table.file_io | |
| @staticmethod | |
| def _fetch_bodies( | |
| file_io, data, blob_cols, parallelism, map_blob_cols=(), array_blob_cols=()): | |
| # Decode each descriptor to a (uri, offset, length) range and read them all in | |
| # one coalesced pass on ``file_io`` -- the read table's FileIO, which already | |
| # carries the merged DLF/OSS token. Going through Blob.from_bytes here would | |
| # build a BlobRef whose uri_reader re-resolves the URI via | |
| # ``FileIO.get(uri, catalog_options)`` off the raw options (no merged token), | |
| # failing with "endpoint should be non-empty" / "Init credential failed" unless | |
| # the caller also passes fs.oss.* -- which users should not have to. | |
| return fetch_blob_bodies( | |
| file_io, data, blob_cols, parallelism, map_blob_cols, array_blob_cols) | |
| def _nested_blob_columns(self): | |
| return ( | |
| [field.name for field in self._table.fields if is_map_blob_type(field.type)], | |
| [field.name for field in self._table.fields if is_array_blob_type(field.type)], | |
| ) | |
| def _readable_blob_columns(self) -> List[str]: | |
| return [ | |
| field.name for field in self._table.fields | |
| if (is_blob_type(field.type) or is_map_blob_type(field.type) | |
| or is_array_blob_type(field.type)) | |
| ] | |
| def _resolve_blob_columns(self, columns) -> List[str]: | |
| all_blob = self._readable_blob_columns() | |
| if columns is None: | |
| selected = all_blob | |
| if self._projection is not None: | |
| selected = [c for c in all_blob if c in self._projection] | |
| if not selected: | |
| raise ValueError("No BLOB column to read; pass columns=.") | |
| return selected | |
| if isinstance(columns, str): | |
| columns = [columns] | |
| columns = list(dict.fromkeys(columns)) # de-dup, keep order | |
| for name in columns: | |
| if name not in all_blob: | |
| raise ValueError(f"Column {name!r} is not a BLOB column.") | |
| return columns | |
| def _blob_read_projection(self, blob_cols: List[str]) -> List[str]: | |
| # Base on _effective_projection() (honours with_row_id), drop BLOB | |
| # descriptors, then append the requested BLOB columns and every predicate | |
| # column -- including predicate columns that are themselves BLOB (read as | |
| # descriptor) -- so SplitRead keeps the row-level filter for where(). | |
| blob_set = set(self._readable_blob_columns()) | |
| effective = self._effective_projection() | |
| if effective is None: | |
| base = [f.name for f in self._table.fields if f.name not in blob_set] | |
| else: | |
| base = [name for name in effective if name not in blob_set] | |
| for name in self._predicate_fields(): | |
| if name not in base and name not in blob_cols: | |
| base.append(name) | |
| return base + list(blob_cols) | |
| def _scalar_columns(self, available) -> List[str]: | |
| # Non-BLOB columns to expose, based on _effective_projection() so | |
| # with_row_id() is honoured; drop BLOBs and predicate-only helpers, and | |
| # skip unknown projected names to match to_arrow()'s silent drop. | |
| blob_set = set(self._readable_blob_columns()) | |
| effective = self._effective_projection() | |
| if effective is None: | |
| return [name for name in available if name not in blob_set] | |
| available = set(available) | |
| return [name for name in effective | |
| if name in available and name not in blob_set] | |
| def _predicate_fields(self): | |
| if self._predicate is None: | |
| return () | |
| from pypaimon.read.push_down_utils import _get_all_fields | |
| return _get_all_fields(self._predicate) | |
| 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 _for_execution(self): | |
| from pypaimon.snapshot.time_travel_util import TimeTravelUtil | |
| query = copy(self) | |
| query._table = self._table._copy_with_snapshot(TimeTravelUtil.resolve_snapshot(self._table)) | |
| return query | |
| def to_arrow(self): | |
| return ScanQuery.to_arrow(self._for_execution()) | |
| def __init__( | |
| self, | |
| table, | |
| result_factory: Optional[Callable] = None, | |
| pre_filter=None): | |
| self._pre_filter = None | |
| self._score_column = None | |
| self._sort_by_score = False | |
| super().__init__(table, result_factory=result_factory) | |
| if pre_filter is not None: | |
| self.pre_filter(pre_filter) | |
| def with_score(self, column_name="_score"): | |
| """Append relevance scores (higher is better) to data-evolution results.""" | |
| self._require_row_id_scores() | |
| if not isinstance(column_name, str) or not column_name: | |
| raise ValueError("Score column name must be a nonempty string.") | |
| if (column_name in SpecialFields.SYSTEM_FIELD_NAMES | |
| or column_name in {field.name for field in self._table.fields}): | |
| raise ValueError("Score column name conflicts with a table column: %s" % column_name) | |
| self._score_column = column_name | |
| return self | |
| def order_by_score(self): | |
| """Return highest scores first, breaking ties by ascending row ID.""" | |
| self._require_row_id_scores() | |
| self._sort_by_score = True | |
| return self | |
| def _require_row_id_scores(self): | |
| if not self._table.options.data_evolution_enabled(): | |
| raise NotImplementedError("Search score output and ordering require a data-evolution table.") | |
| def _metadata_only_result(self): | |
| projection = self._effective_projection() | |
| return (bool(self._score_column or self._sort_by_score) | |
| and self._table.options.data_evolution_enabled() | |
| and not self._table.options.query_auth_enabled | |
| and self._predicate is None | |
| and projection is not None | |
| and all(name == SpecialFields.ROW_ID.name for name in projection)) | |
| def _read_global_index_result(self, result): | |
| metadata_only = self._metadata_only_result() | |
| if not (metadata_only or self._score_column or self._sort_by_score): | |
| return super()._read_global_index_result(result) | |
| row_id_name = SpecialFields.ROW_ID.name | |
| projection = self._effective_projection() | |
| added_row_id = projection is None or row_id_name not in projection | |
| if metadata_only: | |
| row_ids = pa.array(list(result.results()), type=pa.int64()) | |
| fields = [pa.field(row_id_name, pa.int64(), nullable=False)] * (len(projection) or 1) | |
| table = pa.Table.from_arrays( | |
| [row_ids] * len(fields), schema=pa.schema(fields)) | |
| else: | |
| lookup = copy(self) | |
| lookup._projection = (list(projection) if projection is not None | |
| else [field.name for field in self._table.fields]) | |
| if added_row_id: | |
| lookup._projection.append(row_id_name) | |
| table = lookup._read_search_rows(result) | |
| return self._finish_search_result(table, result, added_row_id) | |
| def _read_search_rows(self, result): | |
| projection = self._effective_projection() | |
| if len(projection) == len(set(projection)): | |
| return ScanQuery._read_global_index_result(self, result) | |
| # Row tracking requires unique names while reading. Restore repeated | |
| # output columns after reading their values once. | |
| fields = self._configured_read_builder()._output_fields() | |
| lookup = copy(self) | |
| lookup._projection = list(dict.fromkeys(projection)) | |
| table = ScanQuery._read_global_index_result(lookup, result) | |
| return table.select([table.column_names.index(field.name) for field in fields]) | |
| def _finish_search_result(self, table, result, added_row_id): | |
| if self._score_column or self._sort_by_score: | |
| row_ids = table.column(table.column_names.index(SpecialFields.ROW_ID.name)).to_pylist() | |
| scores = [] | |
| if row_ids: | |
| getter = result.score_getter() | |
| scores = [getter(row_id) for row_id in row_ids] | |
| if any(score is None for score in scores): | |
| raise ValueError("Missing score for a selected search row.") | |
| if self._score_column: | |
| if self._score_column in table.column_names: | |
| raise ValueError("Score column name conflicts with a projected column: %s" % self._score_column) | |
| table = table.append_column(self._score_column, pa.array(scores, type=pa.float64())) | |
| if self._sort_by_score: | |
| order = sorted(range(len(row_ids)), key=lambda i: (-scores[i], row_ids[i])) | |
| table = table.take(pa.array(order, type=pa.int64())) | |
| if added_row_id: | |
| table = table.select([i for i, name in enumerate(table.column_names) if name != SpecialFields.ROW_ID.name]) | |
| return table | |
| 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 | |
| # Blob reads bypass the search result_factory, so reject them (else a plain scan). | |
| def read_blobs(self, *args, **kwargs): | |
| raise TypeError("read_blobs is only supported on scan(), not search queries.") | |
| def stream_blobs(self, *args, **kwargs): | |
| raise TypeError("stream_blobs is only supported on scan(), not search queries.") | |
| def to_ray(self, *args, **kwargs): | |
| raise TypeError("to_ray is only supported on scan(), not search queries.") | |
| def to_arrow_batch_reader(self, *args, **kwargs): | |
| raise TypeError( | |
| "to_arrow_batch_reader is only supported on scan(), " | |
| "not search queries." | |
| ) | |
| def to_contiguous_window_dataset(self, *args, **kwargs): | |
| raise TypeError( | |
| "to_contiguous_window_dataset is only supported on scan(), " | |
| "not search queries." | |
| ) | |
| 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 to_arrow(self, *, execution="local", concurrency=None, ray_remote_args=None): | |
| """Execute a vector query locally or on Ray workers, returning an Arrow table. | |
| Ray execution supports data-evolution tables. ``concurrency`` bounds | |
| in-flight tasks (defaults to 4); ``ray_remote_args`` configures their | |
| resources and retries. Result lookup runs on the driver. | |
| """ | |
| if execution == "local": | |
| if concurrency is not None or ray_remote_args is not None: | |
| raise ValueError("Ray options require execution='ray'.") | |
| return super().to_arrow() | |
| if execution != "ray": | |
| raise ValueError("execution must be 'local' or 'ray'.") | |
| from pypaimon.ray.vector_search import _execute_vector_search | |
| query = self._for_execution() | |
| result = _execute_vector_search( | |
| self._vector_search_builder(query), | |
| concurrency=concurrency, ray_remote_args=ray_remote_args) | |
| return query._read_global_index_result(result) | |
| def to_pandas(self, *, execution="local", concurrency=None, ray_remote_args=None): | |
| """Execute with the same options as to_arrow and return a DataFrame.""" | |
| return self.to_arrow( | |
| execution=execution, concurrency=concurrency, | |
| ray_remote_args=ray_remote_args).to_pandas() | |
| def to_list(self, *, execution="local", concurrency=None, ray_remote_args=None) -> List[dict]: | |
| """Execute with the same options as to_arrow and return rows.""" | |
| return self.to_arrow( | |
| execution=execution, concurrency=concurrency, | |
| ray_remote_args=ray_remote_args).to_pylist() | |
| def _execute_vector(self, query): | |
| return self._vector_search_builder(query).execute_local() | |
| def _vector_search_builder(self, query): | |
| limit = query._limit if query._limit is not None else 10 | |
| builder = ( | |
| query._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 | |
| 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 = ( | |
| query._table.new_full_text_search_builder() | |
| .with_query(self._text_query["column"], self._text_query["query"]) | |
| .with_limit(limit) | |
| ) | |
| if query._pre_filter is not None: | |
| builder = builder.with_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 = ( | |
| query._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["column"], | |
| route["query"], | |
| 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, *, execution="local", concurrency=None, ray_remote_args=None): | |
| """Return one Arrow table per query, optionally searching splits on Ray. | |
| Ray execution supports data-evolution tables. ``concurrency`` bounds | |
| in-flight tasks (defaults to 4); ``ray_remote_args`` configures their | |
| resources and retries. Shared result lookup runs on the driver, | |
| using the same snapshot as all workers, including refinement tasks. | |
| """ | |
| if execution == "local": | |
| if concurrency is not None or ray_remote_args is not None: | |
| raise ValueError("Ray options require execution='ray'.") | |
| query = self._for_execution() | |
| return query._read_batch_results(query._execute_batch_vector(query)) | |
| if execution != "ray": | |
| raise ValueError("execution must be 'local' or 'ray'.") | |
| from pypaimon.ray.batch_vector_search import _execute_batch_vector_search | |
| query = self._for_execution() | |
| results = _execute_batch_vector_search( | |
| self._batch_vector_search_builder(query), | |
| concurrency=concurrency, ray_remote_args=ray_remote_args) | |
| return query._read_batch_results(results) | |
| def _read_batch_results(self, results): | |
| from pypaimon.globalindex.global_index_result import GlobalIndexResult | |
| from pypaimon.utils.roaring_bitmap import RoaringBitmap64 | |
| projection = self._effective_projection() | |
| if (len(results) <= 1 or self._metadata_only_result() | |
| or (projection and not self._configured_read_builder()._output_fields())): | |
| return [self._read_global_index_result(result) for result in results] | |
| row_ids = RoaringBitmap64() | |
| for result in results: | |
| row_ids = RoaringBitmap64.or_(row_ids, result.results()) | |
| lookup = copy(self) | |
| # Each result is already top-k. A shared read must not apply that limit | |
| # to the union; where() still filters the selected rows during lookup. | |
| lookup._limit = None | |
| lookup._projection = (list(projection) if projection is not None | |
| else [f.name for f in self._table.fields]) | |
| added_row_id = SpecialFields.ROW_ID.name not in lookup._projection | |
| if added_row_id: | |
| lookup._projection.append(SpecialFields.ROW_ID.name) | |
| fields = lookup._configured_read_builder()._output_fields() | |
| row_id_column = next(i for i, field in enumerate(fields) if field.id == SpecialFields.ROW_ID.id) | |
| table = lookup._read_search_rows(GlobalIndexResult.create(row_ids)) | |
| positions = {row_id: i for i, row_id in enumerate(table.column(row_id_column).to_pylist())} | |
| output = [] | |
| for result in results: | |
| # Keep the physical read order, rather than imposing score or row-id order. | |
| selected = sorted(positions[row_id] for row_id in result.results() if row_id in positions) | |
| if self._limit is not None: | |
| selected = selected[:self._limit] | |
| selected_table = table.take(pa.array(selected, type=pa.int64())) | |
| output.append(self._finish_search_result(selected_table, result, added_row_id)) | |
| return output | |
| def to_pandas(self, *, execution="local", concurrency=None, ray_remote_args=None): | |
| """Return one DataFrame per query, using the same options as to_arrow.""" | |
| return [table.to_pandas() for table in self.to_arrow( | |
| execution=execution, concurrency=concurrency, ray_remote_args=ray_remote_args)] | |
| def to_list(self, *, execution="local", concurrency=None, ray_remote_args=None) -> List[List[dict]]: | |
| """Return rows per query, using the same options as to_arrow.""" | |
| return [table.to_pylist() for table in self.to_arrow( | |
| execution=execution, concurrency=concurrency, ray_remote_args=ray_remote_args)] | |
| def _execute_batch_vector(self, query): | |
| return self._batch_vector_search_builder(query).execute_batch_local() | |
| def _batch_vector_search_builder(self, query): | |
| limit = query._limit if query._limit is not None else 10 | |
| builder = ( | |
| query._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 |