blob: 229c9cf3a5ebb14e7ebe39d014077d6f115a116c [file]
# 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 unittest.mock import patch
import pyarrow as pa
import pytest
import pypaimon.multimodal as pm
from pypaimon.globalindex.vector_search_result import DictBasedScoredIndexResult
from pypaimon.multimodal.query import ScanQuery
from pypaimon.read.table_read import TableRead
@pytest.fixture(params=[1, 3])
def docs(tmp_path, request):
schema = pa.schema([
("id", pa.int64()), ("category", pa.string()), ("embedding", pa.list_(pa.float32(), 2)),
("info", pa.struct([("label", pa.string()), ("value", pa.int32())])),
("attrs", pa.map_(pa.string(), pa.string())), ("payload", pa.large_binary()),
])
table = pm.connect(options={"warehouse": str(tmp_path)}).create_table(
"docs", schema=schema, partitioned=["category"], options={
"file.format": "parquet", "vector.file.format": "parquet",
"vector-index.search-mode": "full", "read.parallelism": str(request.param),
"read.batch-size": "2", "blob-as-descriptor": "false",
})
for start in (0, 4, 8):
table.add(pa.Table.from_pylist([{
"id": i, "category": "a" if i % 2 else "b", "embedding": [float(i), 0.0],
"info": {"label": str(i), "value": i}, "attrs": {"x": str(i)},
"payload": ("payload-%d" % i).encode(),
} for i in range(start, start + 4)], schema=schema))
return table
def configure(query, projection, with_row_id=False, post_filter=None):
if projection is not None:
query.select(projection)
if with_row_id:
query.with_row_id()
if post_filter is not None:
query.where(post_filter)
return query.limit(3)
@pytest.mark.parametrize("projection", [
None, [], ["id"], ["payload"], ["_ROW_ID", "id"], ["info", "attrs['x']"], ["missing"]])
@pytest.mark.parametrize("with_row_id", [False, True])
def test_batch_lookup_matches_individual_queries(docs, projection, with_row_id):
vectors = [[1.0, 0.0], [9.0, 0.0], [1.0, 0.0]]
expected = [configure(docs.search(vector), projection, with_row_id).to_arrow() for vector in vectors]
query = configure(docs.search_vectors(vectors), projection, with_row_id)
original = TableRead.to_arrow
reads = []
def read(table_read, splits, *args, **kwargs):
reads.append(len(splits))
return original(table_read, splits, *args, **kwargs)
with patch.object(TableRead, "to_arrow", read):
actual = query.to_arrow()
assert len(actual) == len(expected)
for left, right in zip(expected, actual):
assert left.schema == right.schema
# Unordered lookups may visit partition files in different orders.
# Keep duplicates and compare all projected values, including Blobs.
assert sorted(map(repr, left.to_pylist())) == sorted(map(repr, right.to_pylist()))
if projection == [] and not with_row_id:
assert right.num_columns == 0
assert right.num_rows == 3
# Preserve the existing zero-column behavior of invalid projections.
assert len(reads) == (3 if projection == ["missing"] and not with_row_id else 1)
def test_shared_lookup_keeps_pre_and_post_filters_and_per_query_limits(docs):
vectors = [[1.0, 0.0], [9.0, 0.0], [1.0, 0.0]]
options = {"pre_filter": "category = 'b'"}
expected = [configure(docs.search(vector, **options), ["id"], post_filter="id >= 5").to_arrow()
for vector in vectors]
actual = configure(docs.search_vectors(vectors, **options), ["id"], post_filter="id >= 5").to_arrow()
assert [result.to_pylist() for result in actual] == [result.to_pylist() for result in expected]
assert actual[0].num_rows == 0
assert actual[1].num_rows == 3
def test_empty_membership_missing_rows_and_scores_are_independent(docs):
row_ids = [row["_ROW_ID"] for row in docs.scan().select(["id"]).with_row_id().to_list()]
results = [DictBasedScoredIndexResult(values) for values in (
{row_ids[0]: 0.9, row_ids[1]: 0.7}, {}, {row_ids[0]: 0.1}, {10000: 1.0})]
query = docs.search_vectors([[0.0, 0.0]] * len(results)).select(["id"]).limit(3)._for_execution()
expected = [query._read_global_index_result(result) for result in results]
actual = query._read_batch_results(results)
assert [table.to_pylist() for table in actual] == [table.to_pylist() for table in expected]
assert all(table.schema == expected[0].schema for table in actual)
assert results[0].score_getter()(row_ids[0]) == 0.9
assert results[2].score_getter()(row_ids[0]) == 0.1
def test_all_empty_results_preserve_projection_schema(docs):
query = docs.search_vectors([[0.0, 0.0], [11.0, 0.0]], pre_filter="category = 'missing'")
actual = query.select(["id", "payload"]).with_row_id().limit(2).to_arrow()
expected = docs.search([0.0, 0.0], pre_filter="category = 'missing'").select(
["id", "payload"]).with_row_id().limit(2).to_arrow()
assert len(actual) == 2
assert all(table.equals(expected) for table in actual)
@pytest.mark.parametrize("change", ["update", "delete"])
def test_shared_lookup_uses_search_snapshot_and_query_can_be_reused(docs, change):
query = docs.search_vectors([[0.0, 0.0], [11.0, 0.0]]).select(["id", "info"]).limit(1)
expected = query.to_list()
lookup = ScanQuery._read_global_index_result
calls = []
def commit_before_lookup(execution, result):
calls.append(execution._table)
if change == "update":
docs.update("id = 0", {"info": {"label": "updated", "value": 99}})
else:
docs.delete("id = 0")
return lookup(execution, result)
with patch.object(ScanQuery, "_read_global_index_result", commit_before_lookup):
assert query.to_list() == expected
assert len(calls) == 1
assert query.to_list() != expected
@pytest.mark.parametrize("selector", ["snapshot", "tag"])
def test_shared_lookup_keeps_historical_views(docs, selector):
snapshot = docs.raw_table.snapshot_manager().get_latest_snapshot()
docs.raw_table.create_tag("before", snapshot_id=snapshot.id)
options = {"snapshot_id": snapshot.id} if selector == "snapshot" else {"tag_name": "before"}
query = docs.search_vectors([[0.0, 0.0], [11.0, 0.0]], **options).select(["id", "info"]).limit(1)
expected = query.to_list()
docs.update("id = 0", {"info": {"label": "new", "value": 99}})
if selector == "tag":
docs.raw_table.file_io.delete(docs.raw_table.snapshot_manager().get_snapshot_path(snapshot.id))
assert query.to_list() == expected
def test_single_query_keeps_single_lookup(docs):
expected = docs.search([1.0, 0.0]).select(["id"]).limit(3).to_arrow()
result = docs.search_vectors([[1.0, 0.0]]).select(["id"]).limit(3).to_arrow()
assert result[0].equals(expected)
@pytest.mark.python_plan
@pytest.mark.python_read
def test_python_nested_row_projection_still_raises(docs):
with pytest.raises(NotImplementedError, match="ROW nested-field projection"):
docs.search_vectors([[0.0, 0.0], [1.0, 0.0]]).select(["info.label"]).to_arrow()
@pytest.mark.native_plan
def test_native_nested_row_projection(docs):
docs.raw_table = docs.raw_table.copy({'read.native.enabled': 'true', 'scan.native-plan.enabled': 'true'})
results = docs.search_vectors([[0.0, 0.0], [1.0, 0.0]]).select(["info.label"]).limit(1).to_arrow()
assert [result.to_pylist() for result in results] == [[{'info_label': '0'}], [{'info_label': '1'}]]
def test_native_index_batch_uses_shared_lookup(docs):
pytest.importorskip("paimon_vindex")
docs.raw_table.copy({"deletion-vectors.enabled": "false"}).create_global_index(
"embedding", index_type="ivf-flat", options={
"ivf-flat.dimension": "2", "ivf-flat.nlist": "1", "ivf-flat.distance.metric": "l2"})
vectors = [[1.0, 0.0], [10.0, 0.0], [1.0, 0.0]]
expected = [docs.search(vector).select(["id"]).limit(3).to_list() for vector in vectors]
query = docs.search_vectors(vectors).select(["id"]).limit(3)
lookup = ScanQuery._read_global_index_result
calls = []
def read(execution, result):
calls.append(result.results().cardinality())
return lookup(execution, result)
with patch.object(ScanQuery, "_read_global_index_result", read):
actual = query.to_list()
assert [sorted(map(repr, rows)) for rows in actual] == [sorted(map(repr, rows)) for rows in expected]
assert calls == [6]