blob: 54d1c98f09b0f879ce084ce387209dac37da0c3c [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.multimodal.query import ScanQuery
@pytest.fixture
def docs(tmp_path):
schema = pa.schema([("id", pa.int64()), ("text", pa.string()),
("embedding", pa.list_(pa.float32(), 2))])
table = pm.connect(options={"warehouse": str(tmp_path)}).create_table(
"docs", schema=schema, options={"file.format": "parquet", "vector.file.format": "parquet",
"vector-index.search-mode": "full", "read.batch-size": "1",
"deletion-vectors.enabled": "true"})
table.add(pa.table({"id": [0, 1, 2, 3], "text": ["paimon long document", "paimon", "other", "paimon"],
"embedding": [[2., 1.], [0., 1.], [1., 1.], [0., 1.]]}, schema=schema))
return table
def query(docs, batch=False, **kwargs):
return (docs.search_vectors([[0., 1.], [2., 1.]], **kwargs) if batch else
docs.search([0., 1.], **kwargs)).limit(4)
@pytest.mark.parametrize("batch", [False, True])
@pytest.mark.parametrize("indexed", [False, True])
def test_scores_align_with_rows_and_ordering_is_opt_in(docs, batch, indexed):
if indexed:
pytest.importorskip("paimon_vindex")
docs.raw_table.copy({"deletion-vectors.enabled": "false"}).create_global_index(
"embedding", "ivf-flat", options={"ivf-flat.nlist": "1", "ivf-flat.distance.metric": "l2"})
plain = query(docs, batch).select(["id"]).to_arrow()
scored = query(docs, batch).select(["id"]).with_score().to_arrow()
ordered = query(docs, batch).select(["id"]).with_score("relevance").order_by_score().to_arrow()
plain, scored, ordered = ([plain], [scored], [ordered]) if not batch else (plain, scored, ordered)
expected = [[.2, 1., .5, 1.], [1., .2, .5, .2]]
for i, (before, after, ranked) in enumerate(zip(plain, scored, ordered)):
assert before.equals(after.select(["id"]))
assert after.column("_score").type == pa.float64()
expected_scores = [expected[i][row] for row in after["id"].to_pylist()]
assert after.column("_score").to_pylist() == pytest.approx(expected_scores)
ids = ranked["id"].to_pylist()
assert ids == sorted(range(4), key=lambda row: (-expected[i][row], row))
assert ranked.column_names == ["id", "relevance"]
@pytest.mark.parametrize("batch", [False, True])
@pytest.mark.parametrize("projection", [[], ["_ROW_ID"]])
@pytest.mark.parametrize("indexed", [False, True])
def test_metadata_only_skips_final_lookup(docs, batch, projection, indexed):
if indexed:
pytest.importorskip("paimon_vindex")
docs.raw_table.copy({"deletion-vectors.enabled": "false"}).create_global_index(
"embedding", "ivf-flat", options={"ivf-flat.nlist": "1", "ivf-flat.distance.metric": "l2"})
with patch.object(ScanQuery, "_read_global_index_result", side_effect=AssertionError("final lookup")):
actual = query(docs, batch).select(projection).with_score().order_by_score().to_arrow()
for result in actual if batch else [actual]:
assert result.num_rows == 4
assert result.column_names == projection + ["_score"]
assert result["_score"].to_pylist() == sorted(result["_score"].to_pylist(), reverse=True)
def test_ordering_does_not_require_score_projection(docs):
actual = query(docs).select(["id"]).order_by_score().to_list()
assert actual == [{"id": 1}, {"id": 3}, {"id": 2}, {"id": 0}]
@pytest.mark.parametrize("batch", [False, True])
def test_score_only_with_post_filter(docs, batch):
actual = query(docs, batch).select([]).where("id >= 2").with_score().order_by_score().to_arrow()
for i, table in enumerate(actual if batch else [actual]):
expected = [1., .5] if i == 0 else [.5, .2]
assert table.column_names == ["_score"]
assert table["_score"].to_pylist() == pytest.approx(expected)
@pytest.mark.parametrize("batch", [False, True])
@pytest.mark.parametrize("column", ["id", "_ROW_ID"])
def test_duplicate_projections_keep_scores(docs, batch, column):
actual = query(docs, batch).select([column, column]).with_score().order_by_score().to_arrow()
for table in actual if batch else [actual]:
assert table.column_names == [column, column, "_score"]
assert table.column(0).equals(table.column(1))
if column == "_ROW_ID":
with_lookup = query(docs, batch).select([column, column]).where("id >= 0").with_score().to_arrow()
assert (with_lookup[0] if batch else with_lookup).schema == (actual[0] if batch else actual).schema
@pytest.mark.parametrize("batch", [False, True])
def test_post_filter_and_deleted_rows_keep_score_alignment(docs, batch):
docs.delete("id = 1")
actual = query(docs, batch).select(["id"]).where("id >= 2").with_score().order_by_score().to_arrow()
tables = actual if batch else [actual]
assert tables[0].to_pylist() == [{"id": 3, "_score": 1.}, {"id": 2, "_score": .5}]
with patch.object(ScanQuery, "_read_global_index_result", wraps=None) as lookup:
lookup.side_effect = RuntimeError("post filter needs lookup")
with pytest.raises(RuntimeError, match="post filter needs lookup"):
query(docs, batch).select(["_ROW_ID"]).where("id >= 2").with_score().to_arrow()
def test_query_authorization_keeps_final_lookup(docs):
from pypaimon.catalog.filesystem_catalog import FileSystemCatalog
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
from pypaimon.globalindex.vector_search_result import DictBasedScoredIndexResult
from pypaimon.tests.table_query_auth_test import _simple_filter_json
docs.raw_table = docs.raw_table.copy({"query-auth.enabled": "true"})
result = DictBasedScoredIndexResult({0: .2, 1: 1., 2: .5, 3: 1.})
auth = TableQueryAuthResult([_simple_filter_json("text", "paimon")], None)
search = query(docs).select(["_ROW_ID"]).with_score().order_by_score()._for_execution()
with patch.object(FileSystemCatalog, "auth_table_query", return_value=auth):
assert search._read_global_index_result(result).to_pylist() == [
{"_ROW_ID": 1, "_score": 1.}, {"_ROW_ID": 3, "_score": 1.}]
@pytest.mark.parametrize("batch", [False, True])
def test_empty_result_keeps_score_schema(docs, batch):
actual = query(docs, batch, pre_filter="id < 0").select(["id"]).with_score().order_by_score().to_arrow()
for result in actual if batch else [actual]:
assert result.num_rows == 0
assert result.schema == pa.schema([("id", pa.int64()), ("_score", pa.float64())])
def test_batch_keeps_one_lookup_and_query_specific_scores(docs):
original = ScanQuery._read_global_index_result
calls = []
def lookup(query, result):
calls.append(True)
return original(query, result)
with patch.object(ScanQuery, "_read_global_index_result", lookup):
actual = query(docs, True).select(["id"]).with_score().order_by_score().to_arrow()
assert calls == [True]
assert actual[0].to_pylist()[0] == {"id": 1, "_score": 1.}
assert actual[1].to_pylist()[0] == {"id": 0, "_score": 1.}
@pytest.mark.parametrize("selector", ["snapshot", "tag"])
def test_metadata_only_keeps_historical_deletions(docs, selector):
snapshot = docs.raw_table.snapshot_manager().get_latest_snapshot()
docs.raw_table.create_tag("saved", snapshot_id=snapshot.id)
options = {"snapshot_id": snapshot.id} if selector == "snapshot" else {"tag_name": "saved"}
before = query(docs, **options).select(["_ROW_ID"]).with_score().order_by_score()
expected = before.to_list()
docs.delete("id = 1")
assert before.to_list() == expected
assert query(docs).select(["_ROW_ID"]).with_score().to_arrow().num_rows == 3
def test_commit_between_search_and_lookup_keeps_one_snapshot(docs):
search = query(docs).select(["id", "text"]).with_score().order_by_score()
expected = search.to_list()
original = ScanQuery._read_global_index_result
def lookup(execution, result):
docs.update("id = 1", {"text": "changed"})
return original(execution, result)
with patch.object(ScanQuery, "_read_global_index_result", lookup):
assert search.to_list() == expected
assert search.to_list()[0]["text"] == "changed"
@pytest.mark.parametrize("name", [None, "", 1, "id", "_ROW_ID"])
def test_score_alias_validation(docs, name):
with pytest.raises(ValueError, match="Score column name"):
query(docs).with_score(name)
def test_scores_require_data_evolution(docs):
docs.raw_table = docs.raw_table.copy({"data-evolution.enabled": "false"})
with pytest.raises(NotImplementedError, match="data-evolution"):
query(docs).with_score()
with pytest.raises(NotImplementedError, match="data-evolution"):
query(docs).order_by_score()
def test_full_text_and_hybrid_scores(docs):
pytest.importorskip("paimon_ftindex")
docs.raw_table.copy({"deletion-vectors.enabled": "false"}).create_global_index("text", "full-text")
for search in [docs.search("paimon", column="text"), docs.search_hybrid([
pm.vector_route("embedding", [0., 1.]), pm.text_route("paimon", column="text")])]:
result = search.select(["id"]).with_score().order_by_score().limit(3).to_arrow()
assert result.num_rows == 3
assert result["_score"].to_pylist() == sorted(result["_score"].to_pylist(), reverse=True)
assert all(score > 0 for score in result["_score"].to_pylist())