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 b5abd4329c [python] Add search result scores and explicit relevance
ordering (#10005)
b5abd4329c is described below
commit b5abd4329c2aa708d9910e8695f2261b5ee19ddf
Author: chaoyang <[email protected]>
AuthorDate: Mon Sep 21 11:34:05 2026 +0800
[python] Add search result scores and explicit relevance ordering (#10005)
---
docs/docs/pypaimon/multimodal-search.md | 51 +++++
paimon-python/pypaimon/multimodal/query.py | 99 +++++++++-
.../tests/ray_search_result_metadata_test.py | 57 ++++++
.../pypaimon/tests/search_result_metadata_test.py | 206 +++++++++++++++++++++
4 files changed, 407 insertions(+), 6 deletions(-)
diff --git a/docs/docs/pypaimon/multimodal-search.md
b/docs/docs/pypaimon/multimodal-search.md
index 8c98e2e331..05d38be456 100644
--- a/docs/docs/pypaimon/multimodal-search.md
+++ b/docs/docs/pypaimon/multimodal-search.md
@@ -109,6 +109,57 @@ matches = (
)
```
+## Scores and Result Ordering
+
+On data-evolution tables, use `with_score()` to append a `float64` relevance
+column and `order_by_score()` to sort by descending score, with ascending
+`_ROW_ID` for ties. Both methods are optional: `with_score()` alone preserves
+the existing result order, and `order_by_score()` does not require projecting
+scores. Without either method, result behavior is unchanged.
+
+```python
+neighbors = (
+ docs.search([0.1, 0.2, 0.3], column="embedding")
+ .select(["id", "content"])
+ .with_score("relevance")
+ .order_by_score()
+ .limit(10)
+ .to_arrow()
+)
+```
+
+The default score column is `_score`. A custom name must not conflict with a
+table column or a system field. Scores use the search engine's existing
+higher-is-better convention: L2 uses `1 / (1 + squared_distance)`, cosine uses
+cosine similarity, and inner product uses the dot product. Full-text results
+expose BM25 scores; hybrid results expose the selected ranker's fusion scores.
+Scores from different metrics or rankers are not directly comparable.
+
+These methods also work with full-text, hybrid, and batch vector queries, and
+with local or Ray vector execution. Batch output retains input-query order and
+each row receives its score for that query. `where()` still filters selected
+rows during lookup, so it can return fewer than the requested number of hits.
+
+When only row IDs and scores are needed, explicitly project `_ROW_ID`:
+
+```python
+hits = (
+ docs.search([0.1, 0.2, 0.3], column="embedding")
+ .select(["_ROW_ID"])
+ .with_score()
+ .order_by_score()
+ .limit(10)
+ .to_arrow()
+)
+```
+
+Use `select([]).with_score()` for scores alone. When either score method is
+enabled and the explicit projection contains only `_ROW_ID` or is empty, the
+query skips final row lookup if there is no `where()` and query authorization
+is disabled. Raw search, prefiltering, and vector refinement can still read
+data. Historical snapshot and deletion semantics remain the same. Plain
+`select([])` without either method retains its existing behavior.
+
## Distributed Vector Search
Use `execution="ray"` to execute vector queries across Ray workers and return
diff --git a/paimon-python/pypaimon/multimodal/query.py
b/paimon-python/pypaimon/multimodal/query.py
index af2a983f9a..a7c8a569a2 100644
--- a/paimon-python/pypaimon/multimodal/query.py
+++ b/paimon-python/pypaimon/multimodal/query.py
@@ -483,10 +483,96 @@ class _PreFilterQuery(ScanQuery):
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().read_type()
+ 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:
@@ -697,7 +783,8 @@ class BatchVectorQuery(_PreFilterQuery):
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
- if len(results) <= 1 or not
self._configured_read_builder().read_type():
+ if (len(results) <= 1 or self._metadata_only_result()
+ or not self._configured_read_builder().read_type()):
return [self._read_global_index_result(result) for result in
results]
row_ids = RoaringBitmap64()
@@ -709,23 +796,23 @@ class BatchVectorQuery(_PreFilterQuery):
# to the union; where() still filters the selected rows during lookup.
lookup._limit = None
projection = self._effective_projection()
- lookup._projection = list(projection) if projection else [f.name for f
in self._table.fields]
+ lookup._projection = (list(projection) if projection is not None and (
+ projection or self._score_column or self._sort_by_score) 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().read_type()
row_id_column = next(i for i, field in enumerate(fields) if field.id
== SpecialFields.ROW_ID.id)
- table =
lookup._read_global_index_result(GlobalIndexResult.create(row_ids))
+ 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())}
- if added_row_id:
- table = table.select(list(range(table.num_columns - 1)))
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]
- output.append(table.take(pa.array(selected, type=pa.int64())))
+ 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):
diff --git a/paimon-python/pypaimon/tests/ray_search_result_metadata_test.py
b/paimon-python/pypaimon/tests/ray_search_result_metadata_test.py
new file mode 100644
index 0000000000..570bfc3e86
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_search_result_metadata_test.py
@@ -0,0 +1,57 @@
+# 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 pytest
+
+pytest.importorskip("ray")
+
+from pypaimon.multimodal.query import ScanQuery
+from pypaimon.tests import ray_vector_search_test as ray_fixtures
+from pypaimon.tests import search_result_metadata_test as fixtures
+
+ray_cluster = ray_fixtures.ray_cluster
+docs = fixtures.docs
+
+
[email protected]("batch", [False, True])
[email protected]("indexed", [False, True])
[email protected]("metadata_only", [False, True])
+def test_ray_metadata_and_ordering_match_local(docs, ray_cluster, batch,
indexed, metadata_only):
+ 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"})
+ search = fixtures.query(docs, batch, options={"ivf-flat.refine-factor":
"2"})
+ search.select(["_ROW_ID"] if metadata_only else
["id"]).with_score().order_by_score()
+ expected = search.to_arrow()
+ 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 = search.to_arrow(execution="ray", concurrency=2)
+ assert len(calls) == (0 if metadata_only else 1)
+ expected_rows = [table.to_pylist() for table in expected] if batch else
expected.to_pylist()
+ actual_rows = [table.to_pylist() for table in actual] if batch else
actual.to_pylist()
+ assert actual_rows == expected_rows
+ as_list = search.to_list(execution="ray", concurrency=2)
+ assert as_list == expected_rows
diff --git a/paimon-python/pypaimon/tests/search_result_metadata_test.py
b/paimon-python/pypaimon/tests/search_result_metadata_test.py
new file mode 100644
index 0000000000..36fb34f413
--- /dev/null
+++ b/paimon-python/pypaimon/tests/search_result_metadata_test.py
@@ -0,0 +1,206 @@
+# 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
+
+
[email protected]
+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"})
+ 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)
+
+
[email protected]("batch", [False, True])
[email protected]("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"]
+
+
[email protected]("batch", [False, True])
[email protected]("projection", [[], ["_ROW_ID"]])
[email protected]("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}]
+
+
[email protected]("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)
+
+
[email protected]("batch", [False, True])
[email protected]("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
+
+
[email protected]("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.}]
+
+
[email protected]("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.}
+
+
[email protected]("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"
+
+
[email protected]("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())