This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 3484fb0  feat(datafusion): expose hybrid search scores (#519)
3484fb0 is described below

commit 3484fb0ee823bfe9d5c73153988cb6da449914bd
Author: QuakeWang <[email protected]>
AuthorDate: Tue Jul 14 11:23:35 2026 +0800

    feat(datafusion): expose hybrid search scores (#519)
---
 .../integrations/datafusion/src/hybrid_search.rs   | 168 +++++++++++--
 .../datafusion/src/physical_plan/mod.rs            |   2 +
 .../datafusion/src/physical_plan/search_score.rs   | 273 +++++++++++++++++++++
 crates/integrations/datafusion/src/table/mod.rs    |   8 +
 .../integrations/datafusion/tests/read_tables.rs   | 243 +++++++++++++++++-
 docs/src/sql.md                                    |  21 +-
 6 files changed, 691 insertions(+), 24 deletions(-)

diff --git a/crates/integrations/datafusion/src/hybrid_search.rs 
b/crates/integrations/datafusion/src/hybrid_search.rs
index f5c0e7a..b37e4ca 100644
--- a/crates/integrations/datafusion/src/hybrid_search.rs
+++ b/crates/integrations/datafusion/src/hybrid_search.rs
@@ -33,7 +33,9 @@ use std::sync::Arc;
 
 use async_trait::async_trait;
 use datafusion::arrow::array::Array;
-use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
+use datafusion::arrow::datatypes::{
+    DataType as ArrowDataType, Field, Schema, SchemaRef as ArrowSchemaRef,
+};
 use datafusion::catalog::{Session, TableFunctionImpl};
 use datafusion::common::{project_schema, ScalarValue};
 use datafusion::datasource::{TableProvider, TableType};
@@ -43,17 +45,22 @@ use datafusion::physical_plan::empty::EmptyExec;
 use datafusion::physical_plan::ExecutionPlan;
 use datafusion::prelude::SessionContext;
 use paimon::catalog::Catalog;
-use paimon::table::{HybridSearchRanker, HybridSearchRoute};
+use paimon::spec::{
+    BigIntType, CoreOptions, DataField, DataType, ROW_ID_FIELD_ID, 
ROW_ID_FIELD_NAME,
+};
+use paimon::table::{HybridSearchRanker, HybridSearchRoute, Table};
 
 use crate::error::to_datafusion_error;
+use crate::physical_plan::{SearchScoreExec, SearchScoreOutputColumn};
 use crate::runtime::{await_with_runtime, block_on_with_runtime};
-use crate::table::{PaimonScanBuilder, PaimonTableProvider};
+use crate::table::{datafusion_read_fields, PaimonScanBuilder, 
PaimonTableProvider};
 use crate::table_function_args::{
     extract_int_literal, extract_string_literal, parse_table_identifier,
 };
 use crate::table_loader::load_data_table_for_read;
 
 const FUNCTION_NAME: &str = "hybrid_search";
+const SEARCH_SCORE_COLUMN: &str = "__paimon_search_score";
 
 pub fn register_hybrid_search(
     ctx: &SessionContext,
@@ -128,27 +135,71 @@ impl TableFunctionImpl for HybridSearchFunction {
             "hybrid_search: catalog access thread panicked",
         )?;
 
-        Ok(Arc::new(HybridSearchTableProvider {
-            inner: PaimonTableProvider::try_new(table)?,
+        Ok(Arc::new(HybridSearchTableProvider::try_new(
+            PaimonTableProvider::try_new(table)?,
             routes,
-            limit: limit as usize,
+            limit as usize,
             ranker,
-        }))
+        )?))
     }
 }
 
 #[derive(Debug)]
 struct HybridSearchTableProvider {
     inner: PaimonTableProvider,
+    schema: ArrowSchemaRef,
     routes: Vec<HybridSearchRoute>,
     limit: usize,
     ranker: String,
 }
 
+impl HybridSearchTableProvider {
+    fn try_new(
+        inner: PaimonTableProvider,
+        routes: Vec<HybridSearchRoute>,
+        limit: usize,
+        ranker: String,
+    ) -> DFResult<Self> {
+        let inner_schema = inner.schema();
+        if inner_schema
+            .fields()
+            .iter()
+            .any(|field| field.name() == SEARCH_SCORE_COLUMN)
+        {
+            return Err(DataFusionError::Plan(format!(
+                "hybrid_search: table already contains reserved column 
{SEARCH_SCORE_COLUMN}"
+            )));
+        }
+
+        let mut fields = inner_schema
+            .fields()
+            .iter()
+            .map(|field| field.as_ref().clone())
+            .collect::<Vec<_>>();
+        fields.push(Field::new(
+            SEARCH_SCORE_COLUMN,
+            ArrowDataType::Float32,
+            true,
+        ));
+        let schema = Arc::new(Schema::new_with_metadata(
+            fields,
+            inner_schema.metadata().clone(),
+        ));
+
+        Ok(Self {
+            inner,
+            schema,
+            routes,
+            limit,
+            ranker,
+        })
+    }
+}
+
 #[async_trait]
 impl TableProvider for HybridSearchTableProvider {
     fn schema(&self) -> ArrowSchemaRef {
-        self.inner.schema()
+        self.schema.clone()
     }
 
     fn table_type(&self) -> TableType {
@@ -160,11 +211,11 @@ impl TableProvider for HybridSearchTableProvider {
         state: &dyn Session,
         projection: Option<&Vec<usize>>,
         _filters: &[Expr],
-        limit: Option<usize>,
+        _limit: Option<usize>,
     ) -> DFResult<Arc<dyn ExecutionPlan>> {
         let table = self.inner.table();
 
-        let row_ranges = await_with_runtime(async {
+        let search_result = await_with_runtime(async {
             let mut builder = table.new_hybrid_search_builder();
             for route in self.routes.clone() {
                 builder.add_route(route);
@@ -173,37 +224,91 @@ impl TableProvider for HybridSearchTableProvider {
                 .with_limit(self.limit)
                 .with_ranker(&self.ranker)
                 .map_err(to_datafusion_error)?;
-            builder.execute().await.map_err(to_datafusion_error)
+            builder.execute_scored().await.map_err(to_datafusion_error)
         })
         .await?;
 
+        let row_ranges = 
search_result.to_row_ranges().map_err(to_datafusion_error)?;
+
         if row_ranges.is_empty() {
-            let schema = project_schema(&self.schema(), projection)?;
+            let schema = project_schema(&self.schema, projection)?;
             return Ok(Arc::new(EmptyExec::new(schema)));
         }
 
-        let mut read_builder = table.new_read_builder();
-        if let Some(limit) = limit {
-            read_builder.with_limit(limit);
-        }
-        let scan = read_builder.new_scan().with_row_ranges(row_ranges);
+        // Raw row-range scans can include non-matching rows from selected 
files,
+        // so the outer limit must remain above the row-ID filter.
+        let scan = table
+            .new_read_builder()
+            .new_scan()
+            .with_row_ranges(row_ranges);
         let plan = await_with_runtime(scan.plan())
             .await
             .map_err(to_datafusion_error)?;
 
-        PaimonScanBuilder {
+        let inner_schema = self.inner.schema();
+        let score_index = inner_schema.fields().len();
+        let input_read_fields = search_read_fields(table)?;
+        let input_schema = 
paimon::arrow::build_target_arrow_schema(&input_read_fields)
+            .map_err(to_datafusion_error)?;
+        let row_id_table_index = input_read_fields
+            .iter()
+            .position(|field| field.name() == ROW_ID_FIELD_NAME)
+            .expect("search read fields contain _ROW_ID");
+        let projected_indices = projection
+            .cloned()
+            .unwrap_or_else(|| (0..self.schema.fields().len()).collect());
+        let mut input_projection = projected_indices
+            .iter()
+            .copied()
+            .filter(|index| *index != score_index)
+            .collect::<Vec<_>>();
+        if !input_projection.contains(&row_id_table_index) {
+            input_projection.push(row_id_table_index);
+        }
+        let row_id_input_index = input_projection
+            .iter()
+            .position(|index| *index == row_id_table_index)
+            .expect("row ID was added to the input projection");
+        let output_columns = projected_indices
+            .iter()
+            .map(|index| {
+                if *index == score_index {
+                    SearchScoreOutputColumn::Score
+                } else {
+                    let input_index = input_projection
+                        .iter()
+                        .position(|input_index| input_index == index)
+                        .expect("projected table column exists in the input 
projection");
+                    SearchScoreOutputColumn::Input(input_index)
+                }
+            })
+            .collect();
+        let output_schema = project_schema(&self.schema, projection)?;
+        let input = PaimonScanBuilder {
             table,
-            schema: &self.schema(),
+            schema: &input_schema,
             plan: &plan,
             scan_trace: None,
-            projection,
+            projection: Some(&input_projection),
             pushed_predicate: None,
-            limit,
+            limit: None,
             target_partitions: 
state.config_options().execution.target_partitions,
             filter_exact: false,
             case_sensitive: true,
         }
-        .build()
+        .build_with_read_fields(input_read_fields)?;
+        let scores = search_result
+            .row_ids
+            .into_iter()
+            .zip(search_result.scores)
+            .collect();
+        Ok(Arc::new(SearchScoreExec::new(
+            input,
+            output_schema,
+            row_id_input_index,
+            output_columns,
+            Arc::new(scores),
+        )))
     }
 
     fn supports_filters_pushdown(
@@ -217,6 +322,25 @@ impl TableProvider for HybridSearchTableProvider {
     }
 }
 
+fn search_read_fields(table: &Table) -> DFResult<Vec<DataField>> {
+    let mut fields = datafusion_read_fields(table);
+    if fields.iter().any(|field| field.name() == ROW_ID_FIELD_NAME) {
+        return Ok(fields);
+    }
+    if !CoreOptions::new(table.schema().options()).row_tracking_enabled() {
+        return Err(DataFusionError::Plan(
+            "hybrid_search: cannot materialize search results because _ROW_ID 
is not available"
+                .to_string(),
+        ));
+    }
+    fields.push(DataField::new(
+        ROW_ID_FIELD_ID,
+        ROW_ID_FIELD_NAME.to_string(),
+        DataType::BigInt(BigIntType::with_nullable(true)),
+    ));
+    Ok(fields)
+}
+
 fn parse_vector_routes(expr: &Expr, default_limit: usize) -> 
DFResult<Vec<HybridSearchRoute>> {
     if let Some(routes) = extract_literal_array_values(expr, "vector_routes")? 
{
         return routes
diff --git a/crates/integrations/datafusion/src/physical_plan/mod.rs 
b/crates/integrations/datafusion/src/physical_plan/mod.rs
index 2fa35bf..2d19050 100644
--- a/crates/integrations/datafusion/src/physical_plan/mod.rs
+++ b/crates/integrations/datafusion/src/physical_plan/mod.rs
@@ -16,7 +16,9 @@
 // under the License.
 
 pub(crate) mod scan;
+mod search_score;
 pub(crate) mod sink;
 
 pub use scan::PaimonTableScan;
+pub(crate) use search_score::{SearchScoreExec, SearchScoreOutputColumn};
 pub use sink::PaimonDataSink;
diff --git a/crates/integrations/datafusion/src/physical_plan/search_score.rs 
b/crates/integrations/datafusion/src/physical_plan/search_score.rs
new file mode 100644
index 0000000..39d01ce
--- /dev/null
+++ b/crates/integrations/datafusion/src/physical_plan/search_score.rs
@@ -0,0 +1,273 @@
+// 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.
+
+use std::collections::HashMap;
+use std::fmt;
+use std::sync::Arc;
+
+use datafusion::arrow::array::{
+    Array, ArrayRef, Float32Array, Int64Array, RecordBatch, 
RecordBatchOptions, UInt32Array,
+};
+use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
+use datafusion::common::stats::Precision;
+use datafusion::common::{internal_err, DataFusionError, Statistics};
+use datafusion::error::Result as DFResult;
+use datafusion::execution::{SendableRecordBatchStream, TaskContext};
+use datafusion::physical_expr::EquivalenceProperties;
+use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
+use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
+use datafusion::physical_plan::{
+    DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, 
Partitioning,
+    PlanProperties,
+};
+use futures::StreamExt;
+
+#[derive(Clone, Copy, Debug)]
+pub(crate) enum SearchScoreOutputColumn {
+    Input(usize),
+    Score,
+}
+
+/// Filters Paimon rows by search result row ID and optionally exposes their 
scores.
+#[derive(Debug, Clone)]
+pub(crate) struct SearchScoreExec {
+    input: Arc<dyn ExecutionPlan>,
+    output_schema: ArrowSchemaRef,
+    row_id_index: usize,
+    output_columns: Arc<[SearchScoreOutputColumn]>,
+    scores: Arc<HashMap<u64, f32>>,
+    plan_properties: Arc<PlanProperties>,
+}
+
+impl SearchScoreExec {
+    pub(crate) fn new(
+        input: Arc<dyn ExecutionPlan>,
+        output_schema: ArrowSchemaRef,
+        row_id_index: usize,
+        output_columns: Vec<SearchScoreOutputColumn>,
+        scores: Arc<HashMap<u64, f32>>,
+    ) -> Self {
+        let partition_count = input.output_partitioning().partition_count();
+        let plan_properties = Arc::new(PlanProperties::new(
+            EquivalenceProperties::new(output_schema.clone()),
+            Partitioning::UnknownPartitioning(partition_count),
+            EmissionType::Incremental,
+            Boundedness::Bounded,
+        ));
+        Self {
+            input,
+            output_schema,
+            row_id_index,
+            output_columns: output_columns.into(),
+            scores,
+            plan_properties,
+        }
+    }
+
+    fn filter_and_project(&self, batch: RecordBatch) -> DFResult<RecordBatch> {
+        let row_ids = batch
+            .column(self.row_id_index)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .ok_or_else(|| {
+                DataFusionError::Internal(
+                    "_ROW_ID must be Int64 when materializing hybrid search 
results".to_string(),
+                )
+            })?;
+
+        let mut input_indices = Vec::with_capacity(batch.num_rows());
+        let mut scores = Vec::with_capacity(batch.num_rows());
+        for row in 0..batch.num_rows() {
+            if row_ids.is_null(row) {
+                return internal_err!("_ROW_ID cannot be null in hybrid search 
results");
+            }
+            let row_id = u64::try_from(row_ids.value(row)).map_err(|_| {
+                DataFusionError::Internal(format!(
+                    "negative _ROW_ID {} in hybrid search results",
+                    row_ids.value(row)
+                ))
+            })?;
+            if let Some(score) = self.scores.get(&row_id) {
+                input_indices.push(row as u32);
+                scores.push(*score);
+            }
+        }
+        let matched_row_count = input_indices.len();
+        let scores: ArrayRef = Arc::new(Float32Array::from(scores));
+        // Raw row-range scans may conservatively return non-matching rows 
from a
+        // selected file. The scored row IDs are the authoritative result set.
+        let input_indices =
+            (input_indices.len() != batch.num_rows()).then(|| 
UInt32Array::from(input_indices));
+
+        let columns = self
+            .output_columns
+            .iter()
+            .map(|column| -> DFResult<ArrayRef> {
+                match column {
+                    SearchScoreOutputColumn::Input(index) => match 
&input_indices {
+                        Some(indices) => Ok(arrow_select::take::take(
+                            batch.column(*index).as_ref(),
+                            indices,
+                            None,
+                        )?),
+                        None => Ok(Arc::clone(batch.column(*index))),
+                    },
+                    SearchScoreOutputColumn::Score => Ok(Arc::clone(&scores)),
+                }
+            })
+            .collect::<DFResult<Vec<_>>>()?;
+        let options = 
RecordBatchOptions::new().with_row_count(Some(matched_row_count));
+        RecordBatch::try_new_with_options(self.output_schema.clone(), columns, 
&options)
+            .map_err(DataFusionError::from)
+    }
+}
+
+impl DisplayAs for SearchScoreExec {
+    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) -> 
fmt::Result {
+        write!(f, "SearchScoreExec")
+    }
+}
+
+impl ExecutionPlan for SearchScoreExec {
+    fn name(&self) -> &str {
+        "SearchScoreExec"
+    }
+
+    fn properties(&self) -> &Arc<PlanProperties> {
+        &self.plan_properties
+    }
+
+    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+        vec![&self.input]
+    }
+
+    fn with_new_children(
+        self: Arc<Self>,
+        mut children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> DFResult<Arc<dyn ExecutionPlan>> {
+        if children.len() != 1 {
+            return internal_err!("SearchScoreExec expects one child");
+        }
+        Ok(Arc::new(Self::new(
+            children.remove(0),
+            Arc::clone(&self.output_schema),
+            self.row_id_index,
+            self.output_columns.to_vec(),
+            Arc::clone(&self.scores),
+        )))
+    }
+
+    fn execute(
+        &self,
+        partition: usize,
+        context: Arc<TaskContext>,
+    ) -> DFResult<SendableRecordBatchStream> {
+        let input = self.input.execute(partition, context)?;
+        let exec = self.clone();
+        let stream = input.map(move |batch| batch.and_then(|batch| 
exec.filter_and_project(batch)));
+        Ok(Box::pin(RecordBatchStreamAdapter::new(
+            self.output_schema.clone(),
+            Box::pin(stream),
+        )))
+    }
+
+    fn partition_statistics(&self, _partition: Option<usize>) -> 
DFResult<Arc<Statistics>> {
+        Ok(Arc::new(Statistics {
+            num_rows: Precision::Absent,
+            total_byte_size: Precision::Absent,
+            column_statistics: Statistics::unknown_column(&self.output_schema),
+        }))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use datafusion::arrow::array::Int32Array;
+    use datafusion::arrow::datatypes::{DataType, Field, Schema};
+    use datafusion::physical_plan::empty::EmptyExec;
+
+    #[test]
+    fn 
test_filter_and_project_by_row_id_filters_non_matches_and_reorders_columns() {
+        let input_schema = Arc::new(Schema::new(vec![
+            Field::new("id", DataType::Int32, false),
+            Field::new("_ROW_ID", DataType::Int64, true),
+        ]));
+        let output_schema = Arc::new(Schema::new(vec![
+            Field::new("__paimon_search_score", DataType::Float32, true),
+            Field::new("id", DataType::Int32, false),
+        ]));
+        let exec = SearchScoreExec::new(
+            Arc::new(EmptyExec::new(input_schema.clone())),
+            output_schema,
+            1,
+            vec![
+                SearchScoreOutputColumn::Score,
+                SearchScoreOutputColumn::Input(0),
+            ],
+            Arc::new(HashMap::from([(7, 0.25), (3, 0.75)])),
+        );
+        let batch = RecordBatch::try_new(
+            input_schema,
+            vec![
+                Arc::new(Int32Array::from(vec![70, 40, 30])),
+                Arc::new(Int64Array::from(vec![7, 4, 3])),
+            ],
+        )
+        .expect("input batch");
+
+        let output = exec.filter_and_project(batch).expect("filter and 
project");
+        let scores = output
+            .column(0)
+            .as_any()
+            .downcast_ref::<Float32Array>()
+            .expect("score column");
+        let ids = output
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .expect("id column");
+        assert_eq!(scores.values(), &[0.25, 0.75]);
+        assert_eq!(ids.values(), &[70, 30]);
+    }
+
+    #[test]
+    fn test_filter_and_project_preserves_row_count_without_output_columns() {
+        let input_schema = Arc::new(Schema::new(vec![Field::new(
+            "_ROW_ID",
+            DataType::Int64,
+            true,
+        )]));
+        let output_schema = Arc::new(Schema::empty());
+        let exec = SearchScoreExec::new(
+            Arc::new(EmptyExec::new(input_schema.clone())),
+            output_schema,
+            0,
+            Vec::new(),
+            Arc::new(HashMap::from([(7, 0.25), (3, 0.75)])),
+        );
+        let batch = RecordBatch::try_new(
+            input_schema,
+            vec![Arc::new(Int64Array::from(vec![7, 4, 3]))],
+        )
+        .expect("input batch");
+
+        let output = exec.filter_and_project(batch).expect("filter and 
project");
+        assert_eq!(output.num_columns(), 0);
+        assert_eq!(output.num_rows(), 2);
+    }
+}
diff --git a/crates/integrations/datafusion/src/table/mod.rs 
b/crates/integrations/datafusion/src/table/mod.rs
index cc152f3..c84a48e 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -292,6 +292,14 @@ impl PaimonScanBuilder<'_> {
     /// Build a [`PaimonTableScan`] from the configured parameters.
     pub(crate) fn build(self) -> DFResult<Arc<dyn ExecutionPlan>> {
         let read_fields = datafusion_read_fields(self.table);
+        self.build_with_read_fields(read_fields)
+    }
+
+    /// Build using an internal read schema which may contain hidden system 
fields.
+    pub(crate) fn build_with_read_fields(
+        self,
+        read_fields: Vec<DataField>,
+    ) -> DFResult<Arc<dyn ExecutionPlan>> {
         let (projected_schema, read_type) = if let Some(indices) = 
self.projection {
             let fields: Vec<Field> = indices
                 .iter()
diff --git a/crates/integrations/datafusion/tests/read_tables.rs 
b/crates/integrations/datafusion/tests/read_tables.rs
index df3ed0f..5ee8709 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -2149,7 +2149,9 @@ mod vector_search_tests {
 mod hybrid_search_tests {
     use std::sync::Arc;
 
-    use datafusion::arrow::array::Int32Array;
+    #[cfg(feature = "fulltext")]
+    use datafusion::arrow::array::{Array, Int64Array};
+    use datafusion::arrow::array::{Float32Array, Int32Array};
     use paimon::catalog::Identifier;
     use paimon::table::BranchManager;
     use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
@@ -2202,6 +2204,245 @@ mod hybrid_search_tests {
         ids
     }
 
+    fn extract_scored_ids(
+        batches: &[datafusion::arrow::record_batch::RecordBatch],
+    ) -> Vec<(i32, f32)> {
+        let mut rows = Vec::new();
+        for batch in batches {
+            let id_array = batch
+                .column_by_name("id")
+                .and_then(|column| 
column.as_any().downcast_ref::<Int32Array>())
+                .expect("Expected Int32Array for id");
+            let score_array = batch
+                .column_by_name("__paimon_search_score")
+                .and_then(|column| 
column.as_any().downcast_ref::<Float32Array>())
+                .expect("Expected Float32Array for __paimon_search_score");
+            for row in 0..batch.num_rows() {
+                rows.push((id_array.value(row), score_array.value(row)));
+            }
+        }
+        rows
+    }
+
+    fn extract_scores(batches: 
&[datafusion::arrow::record_batch::RecordBatch]) -> Vec<f32> {
+        let mut scores = Vec::new();
+        for batch in batches {
+            assert_eq!(batch.num_columns(), 1, "only the score column was 
selected");
+            let score_array = batch
+                .column_by_name("__paimon_search_score")
+                .and_then(|column| 
column.as_any().downcast_ref::<Float32Array>())
+                .expect("Expected Float32Array for __paimon_search_score");
+            scores.extend((0..batch.num_rows()).map(|row| 
score_array.value(row)));
+        }
+        scores
+    }
+
+    #[tokio::test]
+    async fn test_hybrid_search_exposes_ranked_scores() {
+        let (ctx, _catalog, _tmp) = create_hybrid_search_context().await;
+        let batches = ctx
+            .sql(
+                "SELECT __paimon_search_score, id FROM hybrid_search( \
+                 'paimon.default.test_java_vindex_vector', \
+                 array(named_struct( \
+                   'field', 'embedding', \
+                   'query_vector', array(1.0, 0.0, 0.0, 0.0), \
+                   'limit', 3, \
+                   'weight', 1.0)), \
+                 array(), \
+                 3, \
+                 'rrf') \
+                 ORDER BY __paimon_search_score DESC",
+            )
+            .await
+            .expect("hybrid_search score SQL should parse")
+            .collect()
+            .await
+            .expect("hybrid_search score query should execute");
+
+        let rows = extract_scored_ids(&batches);
+        assert_eq!(rows.len(), 3);
+        for ((id, score), (expected_id, expected_score)) in
+            rows.into_iter()
+                .zip([(0, 1.0 / 61.0), (1, 1.0 / 62.0), (2, 1.0 / 63.0)])
+        {
+            assert_eq!(id, expected_id);
+            assert!(
+                (score - expected_score).abs() < 1e-6,
+                "score for id {id}: expected {expected_score}, got {score}"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_hybrid_search_score_only_and_empty_results() {
+        let (ctx, _catalog, _tmp) = create_hybrid_search_context().await;
+        let score_batches = ctx
+            .sql(
+                "SELECT __paimon_search_score FROM hybrid_search( \
+                 'paimon.default.test_java_vindex_vector', \
+                 array(named_struct( \
+                   'field', 'embedding', \
+                   'query_vector', array(1.0, 0.0, 0.0, 0.0), \
+                   'limit', 3, \
+                   'weight', 1.0)), \
+                 array(), \
+                 3, \
+                 'rrf') \
+                 ORDER BY __paimon_search_score DESC",
+            )
+            .await
+            .expect("score-only hybrid_search SQL should parse")
+            .collect()
+            .await
+            .expect("score-only hybrid_search query should execute");
+
+        let scores = extract_scores(&score_batches);
+        assert_eq!(scores.len(), 3);
+        for (score, expected) in scores.into_iter().zip([1.0 / 61.0, 1.0 / 
62.0, 1.0 / 63.0]) {
+            assert!((score - expected).abs() < 1e-6);
+        }
+
+        let empty_batches = ctx
+            .sql(
+                "SELECT __paimon_search_score FROM hybrid_search( \
+                 'paimon.default.test_java_vindex_vector', \
+                 array(named_struct( \
+                   'field', 'missing_embedding', \
+                   'query_vector', array(1.0), \
+                   'limit', 3, \
+                   'weight', 1.0)), \
+                 array(), \
+                 3, \
+                 'rrf')",
+            )
+            .await
+            .expect("empty hybrid_search score SQL should parse")
+            .collect()
+            .await
+            .expect("empty hybrid_search score query should execute");
+        assert_eq!(
+            empty_batches
+                .iter()
+                .map(|batch| batch.num_rows())
+                .sum::<usize>(),
+            0
+        );
+    }
+
+    #[cfg(feature = "fulltext")]
+    #[tokio::test]
+    async fn test_hybrid_search_score_with_row_tracking_only_fulltext() {
+        let (_tmp, ctx) = super::common::setup_sql_context().await;
+        super::common::exec(
+            &ctx,
+            "CREATE TABLE paimon.test_db.hybrid_raw_fulltext (id INT, content 
STRING) \
+             WITH ( \
+               'row-tracking.enabled' = 'true', \
+               'global-index.search-mode' = 'full')",
+        )
+        .await;
+        super::common::exec(
+            &ctx,
+            "INSERT INTO paimon.test_db.hybrid_raw_fulltext VALUES \
+             (1, 'paimon search'), (2, 'other'), (3, 'paimon table')",
+        )
+        .await;
+
+        const SEARCH: &str = "hybrid_search( \
+             'paimon.test_db.hybrid_raw_fulltext', \
+             array(), \
+             array(named_struct( \
+               'column', 'content', \
+               'query', 'paimon', \
+               'limit', 10, \
+               'weight', 1.0)), \
+             10, \
+             'rrf')";
+        let explicit_sql = format!("SELECT id, __paimon_search_score FROM 
{SEARCH} ORDER BY id");
+        let explicit_batches = ctx
+            .sql(&explicit_sql)
+            .await
+            .expect("row-tracking-only score SQL should parse")
+            .collect()
+            .await
+            .expect("row-tracking-only score query should execute");
+
+        let mut ids = Vec::new();
+        for batch in &explicit_batches {
+            let id_array = batch
+                .column_by_name("id")
+                .and_then(|column| 
column.as_any().downcast_ref::<Int32Array>())
+                .expect("id column");
+            let score_array = batch
+                .column_by_name("__paimon_search_score")
+                .and_then(|column| 
column.as_any().downcast_ref::<Float32Array>())
+                .expect("score column");
+            assert_eq!(score_array.null_count(), 0);
+            for row in 0..batch.num_rows() {
+                ids.push(id_array.value(row));
+                assert!(score_array.value(row) > 0.0);
+            }
+        }
+        assert_eq!(ids, vec![1, 3]);
+
+        let limited_sql = format!("SELECT id, __paimon_search_score FROM 
{SEARCH} LIMIT 2");
+        let limited_batches = ctx
+            .sql(&limited_sql)
+            .await
+            .expect("row-tracking-only limited SQL should parse")
+            .collect()
+            .await
+            .expect("row-tracking-only limited query should execute");
+        assert_eq!(extract_scored_ids(&limited_batches).len(), 2);
+
+        let projected_sql = format!("SELECT id FROM {SEARCH} ORDER BY id");
+        let projected_batches = ctx
+            .sql(&projected_sql)
+            .await
+            .expect("row-tracking-only projected SQL should parse")
+            .collect()
+            .await
+            .expect("row-tracking-only projected query should execute");
+        assert_eq!(extract_ids(&projected_batches), vec![1, 3]);
+
+        let count_sql = format!("SELECT COUNT(*) AS matches FROM {SEARCH}");
+        let count_batches = ctx
+            .sql(&count_sql)
+            .await
+            .expect("row-tracking-only aggregate SQL should parse")
+            .collect()
+            .await
+            .expect("row-tracking-only aggregate query should execute");
+        let matches = count_batches
+            .first()
+            .and_then(|batch| batch.column_by_name("matches"))
+            .and_then(|column| column.as_any().downcast_ref::<Int64Array>())
+            .expect("matches column");
+        assert_eq!(matches.value(0), 2);
+
+        let star_sql = format!("SELECT * FROM {SEARCH}");
+        let star_batches = ctx
+            .sql(&star_sql)
+            .await
+            .expect("row-tracking-only SELECT * SQL should parse")
+            .collect()
+            .await
+            .expect("row-tracking-only SELECT * query should execute");
+        assert_eq!(
+            star_batches
+                .iter()
+                .map(|batch| batch.num_rows())
+                .sum::<usize>(),
+            2
+        );
+        for batch in &star_batches {
+            assert_eq!(batch.num_columns(), 3);
+            assert!(batch.column_by_name("__paimon_search_score").is_some());
+            assert!(batch.column_by_name("_ROW_ID").is_none());
+        }
+    }
+
     #[tokio::test]
     async fn test_hybrid_search_multiple_vector_routes_spark_shape() {
         let (ctx, _catalog, _tmp) = create_hybrid_search_context().await;
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 587603e..0207062 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1391,7 +1391,26 @@ FROM hybrid_search(
 );
 ```
 
-The function searches each route independently, merges route results with the 
selected ranker, and returns the top-k matching rows from the target table. The 
current DataFusion table function returns table rows only; it does not expose a 
metadata score column.
+The function searches each route independently, merges route results with the 
selected ranker, and returns the top-k matching rows from the target table. Its 
output also includes a nullable `FLOAT` metadata column named 
`__paimon_search_score`, which contains the final score produced by the 
selected ranker:
+
+```sql
+SELECT id, __paimon_search_score
+FROM hybrid_search(
+    'paimon.my_db.docs',
+    array(
+        named_struct(
+            'field', 'embedding',
+            'query_vector', array(1.0, 0.0, 0.0, 0.0)
+        )
+    ),
+    array(),
+    10,
+    'rrf'
+)
+ORDER BY __paimon_search_score DESC;
+```
+
+The metadata column is part of the DataFusion table function schema, so 
`SELECT *` includes it. Use an explicit `ORDER BY __paimon_search_score DESC` 
when result ranking order matters; scan output order is not an ordering 
guarantee.
 
 ## Full-Text Search
 

Reply via email to