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 cfa30ca Add hybrid search support for DataFusion (#473)
cfa30ca is described below
commit cfa30cad84ce122ea6a10d1f269c578e9293618f
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jul 7 20:22:02 2026 +0800
Add hybrid search support for DataFusion (#473)
---
.../integrations/datafusion/src/hybrid_search.rs | 761 +++++++++++++++++++++
crates/integrations/datafusion/src/lib.rs | 2 +
crates/integrations/datafusion/src/sql_context.rs | 1 +
.../integrations/datafusion/tests/read_tables.rs | 87 +++
crates/paimon/src/lib.rs | 4 +
.../paimon/src/table/full_text_search_builder.rs | 15 +-
crates/paimon/src/table/hybrid_search_builder.rs | 505 ++++++++++++++
crates/paimon/src/table/mod.rs | 11 +
crates/paimon/src/table/vector_search_builder.rs | 40 +-
crates/paimon/src/vector_search.rs | 8 +
docs/src/sql.md | 95 ++-
11 files changed, 1519 insertions(+), 10 deletions(-)
diff --git a/crates/integrations/datafusion/src/hybrid_search.rs
b/crates/integrations/datafusion/src/hybrid_search.rs
new file mode 100644
index 0000000..e608366
--- /dev/null
+++ b/crates/integrations/datafusion/src/hybrid_search.rs
@@ -0,0 +1,761 @@
+// 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.
+
+//! `hybrid_search` table-valued function for DataFusion.
+//!
+//! Spark-compatible shape:
+//! ```sql
+//! SELECT * FROM hybrid_search(
+//! 'table_name',
+//! array(named_struct('field', 'embedding', 'query_vector', array(1.0,
0.0))),
+//! array(named_struct('column', 'content', 'query', 'paimon')),
+//! 10,
+//! 'rrf')
+//! ```
+
+use std::collections::HashMap;
+use std::fmt::Debug;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use datafusion::arrow::array::Array;
+use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
+use datafusion::catalog::{Session, TableFunctionImpl};
+use datafusion::common::{project_schema, ScalarValue};
+use datafusion::datasource::{TableProvider, TableType};
+use datafusion::error::{DataFusionError, Result as DFResult};
+use datafusion::logical_expr::{Expr, TableProviderFilterPushDown};
+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 crate::error::to_datafusion_error;
+use crate::runtime::{await_with_runtime, block_on_with_runtime};
+use crate::table::{PaimonScanBuilder, PaimonTableProvider};
+use crate::table_function_args::{
+ extract_int_literal, extract_string_literal, parse_table_identifier,
+};
+
+const FUNCTION_NAME: &str = "hybrid_search";
+
+pub fn register_hybrid_search(
+ ctx: &SessionContext,
+ catalog: Arc<dyn Catalog>,
+ default_database: &str,
+) {
+ ctx.register_udf(
+ datafusion::functions_nested::make_array::make_array_udf()
+ .as_ref()
+ .clone()
+ .with_aliases(["array"]),
+ );
+ ctx.register_udtf(
+ FUNCTION_NAME,
+ Arc::new(HybridSearchFunction::new(catalog, default_database)),
+ );
+}
+
+pub struct HybridSearchFunction {
+ catalog: Arc<dyn Catalog>,
+ default_database: String,
+}
+
+impl Debug for HybridSearchFunction {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("HybridSearchFunction")
+ .field("default_database", &self.default_database)
+ .finish()
+ }
+}
+
+impl HybridSearchFunction {
+ pub fn new(catalog: Arc<dyn Catalog>, default_database: &str) -> Self {
+ Self {
+ catalog,
+ default_database: default_database.to_string(),
+ }
+ }
+}
+
+impl TableFunctionImpl for HybridSearchFunction {
+ fn call(&self, args: &[Expr]) -> DFResult<Arc<dyn TableProvider>> {
+ if args.len() != 4 && args.len() != 5 {
+ return Err(DataFusionError::Plan(
+ "hybrid_search requires 4 or 5 arguments: (table_name,
vector_routes, full_text_routes, limit[, ranker])".to_string(),
+ ));
+ }
+
+ let table_name = extract_string_literal(FUNCTION_NAME, &args[0],
"table_name")?;
+ let limit = extract_int_literal(FUNCTION_NAME, &args[3], "limit")?;
+ if limit <= 0 {
+ return Err(DataFusionError::Plan(
+ "hybrid_search: limit must be positive".to_string(),
+ ));
+ }
+
+ let ranker = if args.len() == 5 {
+ extract_string_literal(FUNCTION_NAME, &args[4], "ranker")?
+ } else {
+ HybridSearchRanker::RRF.to_string()
+ };
+ HybridSearchRanker::parse(&ranker).map_err(to_datafusion_error)?;
+
+ let mut routes = parse_vector_routes(&args[1], limit as usize)?;
+ routes.extend(parse_full_text_routes(&args[2], limit as usize)?);
+
+ let identifier =
+ parse_table_identifier(FUNCTION_NAME, &table_name,
&self.default_database)?;
+ let catalog = Arc::clone(&self.catalog);
+ let table = block_on_with_runtime(
+ async move { catalog.get_table(&identifier).await },
+ "hybrid_search: catalog access thread panicked",
+ )
+ .map_err(to_datafusion_error)?;
+
+ Ok(Arc::new(HybridSearchTableProvider {
+ inner: PaimonTableProvider::try_new(table)?,
+ routes,
+ limit: limit as usize,
+ ranker,
+ }))
+ }
+}
+
+#[derive(Debug)]
+struct HybridSearchTableProvider {
+ inner: PaimonTableProvider,
+ routes: Vec<HybridSearchRoute>,
+ limit: usize,
+ ranker: String,
+}
+
+#[async_trait]
+impl TableProvider for HybridSearchTableProvider {
+ fn schema(&self) -> ArrowSchemaRef {
+ self.inner.schema()
+ }
+
+ fn table_type(&self) -> TableType {
+ TableType::Base
+ }
+
+ async fn scan(
+ &self,
+ state: &dyn Session,
+ projection: Option<&Vec<usize>>,
+ _filters: &[Expr],
+ limit: Option<usize>,
+ ) -> DFResult<Arc<dyn ExecutionPlan>> {
+ let table = self.inner.table();
+
+ let row_ranges = await_with_runtime(async {
+ let mut builder = table.new_hybrid_search_builder();
+ for route in self.routes.clone() {
+ builder.add_route(route);
+ }
+ builder
+ .with_limit(self.limit)
+ .with_ranker(&self.ranker)
+ .map_err(to_datafusion_error)?;
+ builder.execute().await.map_err(to_datafusion_error)
+ })
+ .await?;
+
+ if row_ranges.is_empty() {
+ 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);
+ let plan = await_with_runtime(scan.plan())
+ .await
+ .map_err(to_datafusion_error)?;
+
+ PaimonScanBuilder {
+ table,
+ schema: &self.schema(),
+ plan: &plan,
+ scan_trace: None,
+ projection,
+ pushed_predicate: None,
+ limit,
+ target_partitions:
state.config_options().execution.target_partitions,
+ filter_exact: false,
+ }
+ .build()
+ }
+
+ fn supports_filters_pushdown(
+ &self,
+ filters: &[&Expr],
+ ) -> DFResult<Vec<TableProviderFilterPushDown>> {
+ Ok(vec![
+ TableProviderFilterPushDown::Unsupported;
+ filters.len()
+ ])
+ }
+}
+
+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
+ .iter()
+ .map(|route| parse_vector_route_scalar(route, default_limit))
+ .collect();
+ }
+
+ extract_array_elements(expr, "vector_routes")?
+ .into_iter()
+ .map(|route| parse_vector_route(route, default_limit))
+ .collect()
+}
+
+fn parse_full_text_routes(expr: &Expr, default_limit: usize) ->
DFResult<Vec<HybridSearchRoute>> {
+ if let Some(routes) = extract_literal_array_values(expr,
"full_text_routes")? {
+ return routes
+ .iter()
+ .map(|route| parse_full_text_route_scalar(route, default_limit))
+ .collect();
+ }
+
+ extract_array_elements(expr, "full_text_routes")?
+ .into_iter()
+ .map(|route| parse_full_text_route(route, default_limit))
+ .collect()
+}
+
+fn parse_vector_route(expr: &Expr, default_limit: usize) ->
DFResult<HybridSearchRoute> {
+ let fields = extract_named_struct_fields(expr, "vector route")?;
+ let field_name = optional_field(&fields, &["field", "vector_column"])
+ .ok_or_else(|| {
+ DataFusionError::Plan(
+ "hybrid_search: vector route must define field or
vector_column".to_string(),
+ )
+ })
+ .and_then(|expr| extract_string_literal(FUNCTION_NAME, expr, "vector
route field"))?;
+ let vector = required_field(&fields, "query_vector")
+ .and_then(|expr| extract_float_array(expr, "query_vector"))?;
+ let limit = optional_field(&fields, &["limit"])
+ .map(|expr| extract_positive_usize(expr, "vector route limit"))
+ .transpose()?
+ .unwrap_or(default_limit);
+ let weight = optional_field(&fields, &["weight"])
+ .map(|expr| extract_positive_f32(expr, "weight"))
+ .transpose()?
+ .unwrap_or(1.0);
+ let options = optional_field(&fields, &["options"])
+ .map(extract_options)
+ .transpose()?
+ .unwrap_or_default();
+
+ HybridSearchRoute::vector(field_name, vector, limit, weight, options)
+ .map_err(to_datafusion_error)
+}
+
+fn parse_vector_route_scalar(
+ scalar: &ScalarValue,
+ default_limit: usize,
+) -> DFResult<HybridSearchRoute> {
+ let fields = extract_struct_scalar_fields(scalar, "vector route")?;
+ let field_name = optional_scalar_field(&fields, &["field",
"vector_column"])
+ .ok_or_else(|| {
+ DataFusionError::Plan(
+ "hybrid_search: vector route must define field or
vector_column".to_string(),
+ )
+ })
+ .and_then(|scalar| scalar_to_string(scalar, "vector route field"))?;
+ let vector = required_scalar_field(&fields, "query_vector")
+ .and_then(|scalar| scalar_to_float_array(scalar, "query_vector"))?;
+ let limit = optional_scalar_field(&fields, &["limit"])
+ .map(|scalar| scalar_to_positive_usize(scalar, "vector route limit"))
+ .transpose()?
+ .unwrap_or(default_limit);
+ let weight = optional_scalar_field(&fields, &["weight"])
+ .map(|scalar| scalar_to_positive_f32(scalar, "weight"))
+ .transpose()?
+ .unwrap_or(1.0);
+ let options = optional_scalar_field(&fields, &["options"])
+ .map(scalar_to_options)
+ .transpose()?
+ .unwrap_or_default();
+
+ HybridSearchRoute::vector(field_name, vector, limit, weight, options)
+ .map_err(to_datafusion_error)
+}
+
+fn parse_full_text_route(expr: &Expr, default_limit: usize) ->
DFResult<HybridSearchRoute> {
+ let fields = extract_named_struct_fields(expr, "full-text route")?;
+ let column = required_field(&fields, "column")
+ .and_then(|expr| extract_string_literal(FUNCTION_NAME, expr,
"full-text route column"))?;
+ let query = required_field(&fields, "query")
+ .and_then(|expr| extract_string_literal(FUNCTION_NAME, expr,
"full-text route query"))?;
+ let limit = optional_field(&fields, &["limit"])
+ .map(|expr| extract_positive_usize(expr, "full-text route limit"))
+ .transpose()?
+ .unwrap_or(default_limit);
+ let weight = optional_field(&fields, &["weight"])
+ .map(|expr| extract_positive_f32(expr, "weight"))
+ .transpose()?
+ .unwrap_or(1.0);
+ let options = optional_field(&fields, &["options"])
+ .map(extract_options)
+ .transpose()?
+ .unwrap_or_default();
+
+ HybridSearchRoute::full_text(column, query, limit, weight,
options).map_err(to_datafusion_error)
+}
+
+fn parse_full_text_route_scalar(
+ scalar: &ScalarValue,
+ default_limit: usize,
+) -> DFResult<HybridSearchRoute> {
+ let fields = extract_struct_scalar_fields(scalar, "full-text route")?;
+ let column = required_scalar_field(&fields, "column")
+ .and_then(|scalar| scalar_to_string(scalar, "full-text route
column"))?;
+ let query = required_scalar_field(&fields, "query")
+ .and_then(|scalar| scalar_to_string(scalar, "full-text route query"))?;
+ let limit = optional_scalar_field(&fields, &["limit"])
+ .map(|scalar| scalar_to_positive_usize(scalar, "full-text route
limit"))
+ .transpose()?
+ .unwrap_or(default_limit);
+ let weight = optional_scalar_field(&fields, &["weight"])
+ .map(|scalar| scalar_to_positive_f32(scalar, "weight"))
+ .transpose()?
+ .unwrap_or(1.0);
+ let options = optional_scalar_field(&fields, &["options"])
+ .map(scalar_to_options)
+ .transpose()?
+ .unwrap_or_default();
+
+ HybridSearchRoute::full_text(column, query, limit, weight,
options).map_err(to_datafusion_error)
+}
+
+fn extract_array_elements<'a>(expr: &'a Expr, name: &str) -> DFResult<Vec<&'a
Expr>> {
+ match expr {
+ Expr::ScalarFunction(function)
+ if is_function(function.name(), &["make_array", "array"]) =>
+ {
+ Ok(function.args.iter().collect())
+ }
+ _ => Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be array(...), got: {expr}"
+ ))),
+ }
+}
+
+fn extract_literal_array_values(expr: &Expr, name: &str) ->
DFResult<Option<Vec<ScalarValue>>> {
+ let Expr::Literal(scalar, _) = expr else {
+ return Ok(None);
+ };
+ scalar_array_values(scalar, name).map(Some)
+}
+
+fn scalar_array_values(scalar: &ScalarValue, name: &str) ->
DFResult<Vec<ScalarValue>> {
+ let values = match scalar {
+ ScalarValue::List(array) => array.value(0),
+ ScalarValue::LargeList(array) => array.value(0),
+ ScalarValue::ListView(array) => array.value(0),
+ ScalarValue::LargeListView(array) => array.value(0),
+ ScalarValue::FixedSizeList(array) => array.value(0),
+ _ => {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be an array, got: {scalar}"
+ )));
+ }
+ };
+
+ (0..values.len())
+ .map(|index| ScalarValue::try_from_array(values.as_ref(), index))
+ .collect()
+}
+
+fn extract_named_struct_fields<'a>(
+ expr: &'a Expr,
+ name: &str,
+) -> DFResult<Vec<(String, &'a Expr)>> {
+ let Expr::ScalarFunction(function) = expr else {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be named_struct(...), got: {expr}"
+ )));
+ };
+ if !is_function(function.name(), &["named_struct"]) {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be named_struct(...), got: {expr}"
+ )));
+ }
+ if function.args.len() % 2 != 0 {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must contain key/value pairs"
+ )));
+ }
+
+ let mut fields = Vec::with_capacity(function.args.len() / 2);
+ for pair in function.args.chunks_exact(2) {
+ let key = extract_string_literal(FUNCTION_NAME, &pair[0], "route field
name")?;
+ fields.push((key, &pair[1]));
+ }
+ Ok(fields)
+}
+
+fn extract_struct_scalar_fields(
+ scalar: &ScalarValue,
+ name: &str,
+) -> DFResult<Vec<(String, ScalarValue)>> {
+ let ScalarValue::Struct(array) = scalar else {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be named_struct(...), got: {scalar}"
+ )));
+ };
+ if array.is_null(0) {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} cannot be null"
+ )));
+ }
+
+ array
+ .fields()
+ .iter()
+ .zip(array.columns())
+ .map(|(field, column)| {
+ Ok((
+ field.name().clone(),
+ ScalarValue::try_from_array(column.as_ref(), 0)?,
+ ))
+ })
+ .collect()
+}
+
+fn required_field<'a>(fields: &'a [(String, &'a Expr)], name: &str) ->
DFResult<&'a Expr> {
+ optional_field(fields, &[name])
+ .ok_or_else(|| DataFusionError::Plan(format!("hybrid_search: route
must define {name}")))
+}
+
+fn optional_field<'a>(fields: &'a [(String, &'a Expr)], names: &[&str]) ->
Option<&'a Expr> {
+ fields
+ .iter()
+ .find(|(field_name, _)| names.iter().any(|name| field_name == name))
+ .map(|(_, expr)| *expr)
+}
+
+fn required_scalar_field<'a>(
+ fields: &'a [(String, ScalarValue)],
+ name: &str,
+) -> DFResult<&'a ScalarValue> {
+ optional_scalar_field(fields, &[name])
+ .ok_or_else(|| DataFusionError::Plan(format!("hybrid_search: route
must define {name}")))
+}
+
+fn optional_scalar_field<'a>(
+ fields: &'a [(String, ScalarValue)],
+ names: &[&str],
+) -> Option<&'a ScalarValue> {
+ fields
+ .iter()
+ .find(|(field_name, _)| names.iter().any(|name| field_name == name))
+ .map(|(_, scalar)| scalar)
+}
+
+fn extract_positive_usize(expr: &Expr, name: &str) -> DFResult<usize> {
+ let value = extract_int_literal(FUNCTION_NAME, expr, name)?;
+ if value <= 0 {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be positive"
+ )));
+ }
+ Ok(value as usize)
+}
+
+fn extract_float_array(expr: &Expr, name: &str) -> DFResult<Vec<f32>> {
+ if let Ok(json) = extract_string_literal(FUNCTION_NAME, expr, name) {
+ let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
+ DataFusionError::Plan(format!(
+ "hybrid_search: {name} string must be a JSON array of floats:
{e}"
+ ))
+ })?;
+ if vector.is_empty() {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} cannot be empty"
+ )));
+ }
+ return Ok(vector);
+ }
+
+ let elements = extract_array_elements(expr, name)?;
+ if elements.is_empty() {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} cannot be empty"
+ )));
+ }
+ elements
+ .into_iter()
+ .map(|expr| scalar_to_f32(expr, name))
+ .collect()
+}
+
+fn extract_positive_f32(expr: &Expr, name: &str) -> DFResult<f32> {
+ let value = scalar_to_f32(expr, name)?;
+ if !value.is_finite() || value <= 0.0 {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be finite and positive, got: {value}"
+ )));
+ }
+ Ok(value)
+}
+
+fn scalar_to_f32(expr: &Expr, name: &str) -> DFResult<f32> {
+ let Expr::Literal(scalar, _) = expr else {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be a numeric literal, got: {expr}"
+ )));
+ };
+ match scalar {
+ ScalarValue::Float32(Some(value)) => Ok(*value),
+ ScalarValue::Float64(Some(value)) => Ok(*value as f32),
+ ScalarValue::Int8(Some(value)) => Ok(*value as f32),
+ ScalarValue::Int16(Some(value)) => Ok(*value as f32),
+ ScalarValue::Int32(Some(value)) => Ok(*value as f32),
+ ScalarValue::Int64(Some(value)) => Ok(*value as f32),
+ ScalarValue::UInt8(Some(value)) => Ok(*value as f32),
+ ScalarValue::UInt16(Some(value)) => Ok(*value as f32),
+ ScalarValue::UInt32(Some(value)) => Ok(*value as f32),
+ ScalarValue::UInt64(Some(value)) => Ok(*value as f32),
+ ScalarValue::Utf8(Some(value)) => value.parse::<f32>().map_err(|e| {
+ DataFusionError::Plan(format!(
+ "hybrid_search: {name} string must be a float, got '{value}':
{e}"
+ ))
+ }),
+ _ => Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be a numeric literal, got: {expr}"
+ ))),
+ }
+}
+
+fn scalar_to_string(scalar: &ScalarValue, name: &str) -> DFResult<String> {
+ match scalar {
+ ScalarValue::Utf8(Some(value))
+ | ScalarValue::Utf8View(Some(value))
+ | ScalarValue::LargeUtf8(Some(value)) => Ok(value.clone()),
+ _ => Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be a string literal, got: {scalar}"
+ ))),
+ }
+}
+
+fn scalar_to_positive_usize(scalar: &ScalarValue, name: &str) ->
DFResult<usize> {
+ let value = match scalar {
+ ScalarValue::Int8(Some(value)) => *value as i64,
+ ScalarValue::Int16(Some(value)) => *value as i64,
+ ScalarValue::Int32(Some(value)) => *value as i64,
+ ScalarValue::Int64(Some(value)) => *value,
+ ScalarValue::UInt8(Some(value)) => *value as i64,
+ ScalarValue::UInt16(Some(value)) => *value as i64,
+ ScalarValue::UInt32(Some(value)) => *value as i64,
+ ScalarValue::UInt64(Some(value)) => i64::try_from(*value).map_err(|_| {
+ DataFusionError::Plan(format!("hybrid_search: {name} value exceeds
i64 range"))
+ })?,
+ _ => {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be an integer literal, got:
{scalar}"
+ )));
+ }
+ };
+ if value <= 0 {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be positive"
+ )));
+ }
+ Ok(value as usize)
+}
+
+fn scalar_value_to_f32(scalar: &ScalarValue, name: &str) -> DFResult<f32> {
+ match scalar {
+ ScalarValue::Float16(Some(value)) => Ok(value.to_f32()),
+ ScalarValue::Float32(Some(value)) => Ok(*value),
+ ScalarValue::Float64(Some(value)) => Ok(*value as f32),
+ ScalarValue::Int8(Some(value)) => Ok(*value as f32),
+ ScalarValue::Int16(Some(value)) => Ok(*value as f32),
+ ScalarValue::Int32(Some(value)) => Ok(*value as f32),
+ ScalarValue::Int64(Some(value)) => Ok(*value as f32),
+ ScalarValue::UInt8(Some(value)) => Ok(*value as f32),
+ ScalarValue::UInt16(Some(value)) => Ok(*value as f32),
+ ScalarValue::UInt32(Some(value)) => Ok(*value as f32),
+ ScalarValue::UInt64(Some(value)) => Ok(*value as f32),
+ ScalarValue::Utf8(Some(value))
+ | ScalarValue::Utf8View(Some(value))
+ | ScalarValue::LargeUtf8(Some(value)) =>
value.parse::<f32>().map_err(|e| {
+ DataFusionError::Plan(format!(
+ "hybrid_search: {name} string must be a float, got '{value}':
{e}"
+ ))
+ }),
+ _ => Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be a numeric literal, got: {scalar}"
+ ))),
+ }
+}
+
+fn scalar_to_positive_f32(scalar: &ScalarValue, name: &str) -> DFResult<f32> {
+ let value = scalar_value_to_f32(scalar, name)?;
+ if !value.is_finite() || value <= 0.0 {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} must be finite and positive, got: {value}"
+ )));
+ }
+ Ok(value)
+}
+
+fn scalar_to_float_array(scalar: &ScalarValue, name: &str) ->
DFResult<Vec<f32>> {
+ let values = scalar_array_values(scalar, name)?;
+ if values.is_empty() {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: {name} cannot be empty"
+ )));
+ }
+ values
+ .iter()
+ .map(|value| scalar_value_to_f32(value, name))
+ .collect()
+}
+
+fn scalar_to_options(scalar: &ScalarValue) -> DFResult<HashMap<String,
String>> {
+ if matches!(scalar, ScalarValue::Null) {
+ return Ok(HashMap::new());
+ }
+
+ if let Ok(json) = scalar_to_string(scalar, "options") {
+ if json.trim().is_empty() {
+ return Ok(HashMap::new());
+ }
+ return serde_json::from_str(&json).map_err(|e| {
+ DataFusionError::Plan(format!(
+ "hybrid_search: options string must be a JSON object: {e}"
+ ))
+ });
+ }
+
+ let ScalarValue::Map(array) = scalar else {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: options must be map(...), got: {scalar}"
+ )));
+ };
+ if array.is_null(0) {
+ return Ok(HashMap::new());
+ }
+
+ let entries = array.value(0);
+ let keys = entries.column(0);
+ let values = entries.column(1);
+ (0..entries.len())
+ .map(|index| {
+ Ok((
+ scalar_to_string(
+ &ScalarValue::try_from_array(keys.as_ref(), index)?,
+ "options key",
+ )?,
+ scalar_to_string(
+ &ScalarValue::try_from_array(values.as_ref(), index)?,
+ "options value",
+ )?,
+ ))
+ })
+ .collect()
+}
+
+fn extract_options(expr: &Expr) -> DFResult<HashMap<String, String>> {
+ if let Ok(json) = extract_string_literal(FUNCTION_NAME, expr, "options") {
+ if json.trim().is_empty() {
+ return Ok(HashMap::new());
+ }
+ return serde_json::from_str(&json).map_err(|e| {
+ DataFusionError::Plan(format!(
+ "hybrid_search: options string must be a JSON object: {e}"
+ ))
+ });
+ }
+
+ let Expr::ScalarFunction(function) = expr else {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: options must be map(...), got: {expr}"
+ )));
+ };
+ if !is_function(function.name(), &["map", "make_map"]) {
+ return Err(DataFusionError::Plan(format!(
+ "hybrid_search: options must be map(...), got: {expr}"
+ )));
+ }
+ if function.args.is_empty() {
+ return Ok(HashMap::new());
+ }
+
+ if function.args.len() == 2
+ && is_array_expr(&function.args[0])
+ && is_array_expr(&function.args[1])
+ {
+ let keys = extract_array_elements(&function.args[0], "options keys")?;
+ let values = extract_array_elements(&function.args[1], "options
values")?;
+ if keys.len() != values.len() {
+ return Err(DataFusionError::Plan(
+ "hybrid_search: options keys and values must have the same
length".to_string(),
+ ));
+ }
+ return keys
+ .into_iter()
+ .zip(values)
+ .map(|(key, value)| {
+ Ok((
+ extract_string_literal(FUNCTION_NAME, key, "options key")?,
+ extract_string_literal(FUNCTION_NAME, value, "options
value")?,
+ ))
+ })
+ .collect();
+ }
+
+ if function.args.len() % 2 != 0 {
+ return Err(DataFusionError::Plan(
+ "hybrid_search: options map must contain key/value
pairs".to_string(),
+ ));
+ }
+
+ function
+ .args
+ .chunks_exact(2)
+ .map(|pair| {
+ Ok((
+ extract_string_literal(FUNCTION_NAME, &pair[0], "options
key")?,
+ extract_string_literal(FUNCTION_NAME, &pair[1], "options
value")?,
+ ))
+ })
+ .collect()
+}
+
+fn is_array_expr(expr: &Expr) -> bool {
+ matches!(
+ expr,
+ Expr::ScalarFunction(function) if is_function(function.name(),
&["make_array", "array"])
+ )
+}
+
+fn is_function(actual: &str, expected: &[&str]) -> bool {
+ expected
+ .iter()
+ .any(|expected| actual.eq_ignore_ascii_case(expected))
+}
diff --git a/crates/integrations/datafusion/src/lib.rs
b/crates/integrations/datafusion/src/lib.rs
index ec838c0..c4b79b5 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -43,6 +43,7 @@ mod error;
mod filter_pushdown;
#[cfg(feature = "fulltext")]
mod full_text_search;
+mod hybrid_search;
mod lateral_vector_search;
mod merge_into;
mod physical_plan;
@@ -72,6 +73,7 @@ pub use catalog::{PaimonCatalogProvider,
PaimonSchemaProvider};
pub use error::to_datafusion_error;
#[cfg(feature = "fulltext")]
pub use full_text_search::{register_full_text_search, FullTextSearchFunction};
+pub use hybrid_search::{register_hybrid_search, HybridSearchFunction};
pub use physical_plan::PaimonTableScan;
pub use relation_planner::PaimonRelationPlanner;
pub use sql_context::SQLContext;
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index ecfa252..babd650 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -2582,6 +2582,7 @@ fn register_table_functions(
crate::vector_search::register_vector_search(ctx, Arc::clone(catalog),
default_database);
#[cfg(feature = "fulltext")]
crate::full_text_search::register_full_text_search(ctx,
Arc::clone(catalog), default_database);
+ crate::hybrid_search::register_hybrid_search(ctx, Arc::clone(catalog),
default_database);
}
#[cfg(test)]
diff --git a/crates/integrations/datafusion/tests/read_tables.rs
b/crates/integrations/datafusion/tests/read_tables.rs
index 13c9f12..718a1cd 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -1824,3 +1824,90 @@ mod vector_search_tests {
);
}
}
+
+// ======================= Hybrid Search Tests =======================
+
+mod hybrid_search_tests {
+ use std::sync::Arc;
+
+ use datafusion::arrow::array::Int32Array;
+ use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
+ use paimon_datafusion::SQLContext;
+
+ fn extract_test_warehouse(archive_name: &str) -> (tempfile::TempDir,
String) {
+ let archive_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("testdata")
+ .join(archive_name);
+ let file = std::fs::File::open(&archive_path)
+ .unwrap_or_else(|e| panic!("Failed to open {}: {e}",
archive_path.display()));
+ let decoder = flate2::read::GzDecoder::new(file);
+ let mut archive = tar::Archive::new(decoder);
+
+ let tmp = tempfile::tempdir().expect("Failed to create temp dir");
+ let db_dir = tmp.path().join("default.db");
+ std::fs::create_dir_all(&db_dir).unwrap();
+ archive.unpack(&db_dir).unwrap();
+
+ let warehouse = format!("file://{}", tmp.path().display());
+ (tmp, warehouse)
+ }
+
+ async fn create_hybrid_search_context() -> (SQLContext, tempfile::TempDir)
{
+ let (tmp, warehouse) =
extract_test_warehouse("test_java_vindex_vector.tar.gz");
+ let mut options = Options::new();
+ options.set(CatalogOptions::WAREHOUSE, warehouse);
+ let catalog = FileSystemCatalog::new(options).expect("Failed to create
catalog");
+ let catalog: Arc<dyn Catalog> = Arc::new(catalog);
+
+ let mut ctx = SQLContext::new();
+ ctx.register_catalog("paimon", catalog.clone())
+ .await
+ .expect("Failed to register catalog");
+ (ctx, tmp)
+ }
+
+ fn extract_ids(batches: &[datafusion::arrow::record_batch::RecordBatch])
-> Vec<i32> {
+ let mut ids = Vec::new();
+ for batch in batches {
+ let id_array = batch
+ .column_by_name("id")
+ .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
+ .expect("Expected Int32Array for id");
+ for i in 0..batch.num_rows() {
+ ids.push(id_array.value(i));
+ }
+ }
+ ids.sort();
+ ids
+ }
+
+ #[tokio::test]
+ async fn test_hybrid_search_multiple_vector_routes_spark_shape() {
+ let (ctx, _tmp) = create_hybrid_search_context().await;
+ let batches = ctx
+ .sql(
+ "SELECT 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), \
+ named_struct( \
+ 'field', 'embedding', \
+ 'query_vector', array(0.9, 0.1, 0.0, 0.0), \
+ 'limit', 3, \
+ 'weight', 1.0)), \
+ array(), \
+ 3, \
+ 'rrf')",
+ )
+ .await
+ .expect("hybrid_search SQL should parse")
+ .collect()
+ .await
+ .expect("hybrid_search query should execute");
+
+ assert_eq!(extract_ids(&batches), vec![0, 1, 2]);
+ }
+}
diff --git a/crates/paimon/src/lib.rs b/crates/paimon/src/lib.rs
index da34c7d..d9cd290 100644
--- a/crates/paimon/src/lib.rs
+++ b/crates/paimon/src/lib.rs
@@ -51,3 +51,7 @@ pub use table::{
RenamingSnapshotCommit, RowRange, ScanTrace, SnapshotCommit,
SnapshotManager, Table,
TableCommit, TableRead, TableScan, TableUpdate, TableWrite, TagManager,
WriteBuilder,
};
+
+pub use table::{
+ HybridSearchBuilder, HybridSearchRanker, HybridSearchRoute,
HybridSearchRouteKind,
+};
diff --git a/crates/paimon/src/table/full_text_search_builder.rs
b/crates/paimon/src/table/full_text_search_builder.rs
index 0b92674..042a9e8 100644
--- a/crates/paimon/src/table/full_text_search_builder.rs
+++ b/crates/paimon/src/table/full_text_search_builder.rs
@@ -96,6 +96,10 @@ impl<'a> FullTextSearchBuilder<'a> {
///
/// Reference: `FullTextSearchBuilder.executeLocal()`
pub async fn execute(&self) -> crate::Result<Vec<RowRange>> {
+ Ok(self.execute_scored().await?.to_row_ranges())
+ }
+
+ pub async fn execute_scored(&self) -> crate::Result<SearchResult> {
// Fail closed: returns data-derived row ranges outside
`TableScan`/`TableRead`.
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
let text_column =
@@ -123,7 +127,7 @@ impl<'a> FullTextSearchBuilder<'a> {
let snapshot = match snapshot_manager.get_latest_snapshot().await? {
Some(s) => s,
- None => return Ok(Vec::new()),
+ None => return Ok(SearchResult::empty()),
};
let index_entries = match snapshot.index_manifest() {
@@ -168,14 +172,14 @@ async fn evaluate_full_text_search(
evaluation: FullTextSearchEvaluation<'_>,
index_entries: &[IndexManifestEntry],
search: &FullTextSearch,
-) -> crate::Result<Vec<RowRange>> {
+) -> crate::Result<SearchResult> {
let table_path = evaluation.table_path.trim_end_matches('/');
let core_options = CoreOptions::new(evaluation.table_options);
let search_mode = core_options.global_index_search_mode()?;
let field_id = match find_field_id_by_name(evaluation.schema_fields,
&search.field_name) {
Some(id) => id,
- None => return Ok(Vec::new()),
+ None => return Ok(SearchResult::empty()),
};
// Collect tantivy fulltext entries for the target field.
@@ -192,7 +196,7 @@ async fn evaluate_full_text_search(
.collect();
if fulltext_entries.is_empty() && search_mode ==
GlobalIndexSearchMode::Fast {
- return Ok(Vec::new());
+ return Ok(SearchResult::empty());
}
let deleted_row_index = if core_options.data_evolution_enabled() {
@@ -280,8 +284,7 @@ async fn evaluate_full_text_search(
Ok(merged
.without_deleted_row_ranges(deleted_row_index.as_ref())?
- .top_k(search.limit)
- .to_row_ranges())
+ .top_k(search.limit))
}
fn is_tantivy_fulltext_index_file(index_file: &IndexFileMeta) -> bool {
diff --git a/crates/paimon/src/table/hybrid_search_builder.rs
b/crates/paimon/src/table/hybrid_search_builder.rs
new file mode 100644
index 0000000..11b7a02
--- /dev/null
+++ b/crates/paimon/src/table/hybrid_search_builder.rs
@@ -0,0 +1,505 @@
+// 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.
+
+//! Hybrid search builder for combining multiple search routes.
+//!
+//! Reference: `org.apache.paimon.table.source.HybridSearchBuilder`.
+
+use std::collections::HashMap;
+
+use crate::spec::CoreOptions;
+use crate::table::{RowRange, Table};
+use crate::vector_search::SearchResult;
+
+const RRF_K: f32 = 60.0;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum HybridSearchRanker {
+ Rrf,
+ WeightedScore,
+ Mrr,
+}
+
+impl HybridSearchRanker {
+ pub const RRF: &'static str = "rrf";
+ pub const WEIGHTED_SCORE: &'static str = "weighted_score";
+ pub const MRR: &'static str = "mrr";
+
+ pub fn parse(ranker: &str) -> crate::Result<Self> {
+ match ranker.trim().to_ascii_lowercase().as_str() {
+ "" | Self::RRF => Ok(Self::Rrf),
+ Self::WEIGHTED_SCORE => Ok(Self::WeightedScore),
+ Self::MRR => Ok(Self::Mrr),
+ _ => Err(crate::Error::ConfigInvalid {
+ message: format!("Unsupported hybrid ranker: {ranker}"),
+ }),
+ }
+ }
+
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Rrf => Self::RRF,
+ Self::WeightedScore => Self::WEIGHTED_SCORE,
+ Self::Mrr => Self::MRR,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum HybridSearchRouteKind {
+ Vector,
+ FullText,
+}
+
+#[derive(Clone, Debug)]
+pub struct HybridSearchRoute {
+ kind: HybridSearchRouteKind,
+ field_name: String,
+ vector: Option<Vec<f32>>,
+ full_text_query: Option<String>,
+ limit: usize,
+ weight: f32,
+ options: HashMap<String, String>,
+}
+
+impl HybridSearchRoute {
+ pub fn vector(
+ field_name: impl Into<String>,
+ vector: Vec<f32>,
+ limit: usize,
+ weight: f32,
+ options: HashMap<String, String>,
+ ) -> crate::Result<Self> {
+ let field_name = field_name.into();
+ Self::validate_common(&field_name, limit, weight)?;
+ if vector.is_empty() {
+ return Err(crate::Error::DataInvalid {
+ message: "Search vector cannot be empty".to_string(),
+ source: None,
+ });
+ }
+ Ok(Self {
+ kind: HybridSearchRouteKind::Vector,
+ field_name,
+ vector: Some(vector),
+ full_text_query: None,
+ limit,
+ weight,
+ options,
+ })
+ }
+
+ pub fn full_text(
+ field_name: impl Into<String>,
+ query: impl Into<String>,
+ limit: usize,
+ weight: f32,
+ options: HashMap<String, String>,
+ ) -> crate::Result<Self> {
+ if !options.is_empty() {
+ return Err(crate::Error::ConfigInvalid {
+ message: "Full-text hybrid route options are not supported
yet".to_string(),
+ });
+ }
+
+ let field_name = field_name.into();
+ let query = query.into();
+ Self::validate_common(&field_name, limit, weight)?;
+ if query.is_empty() {
+ return Err(crate::Error::ConfigInvalid {
+ message: "Full-text route query cannot be empty".to_string(),
+ });
+ }
+
+ Ok(Self {
+ kind: HybridSearchRouteKind::FullText,
+ field_name,
+ vector: None,
+ full_text_query: Some(query),
+ limit,
+ weight,
+ options,
+ })
+ }
+
+ fn validate_common(field_name: &str, limit: usize, weight: f32) ->
crate::Result<()> {
+ if field_name.is_empty() {
+ return Err(crate::Error::DataInvalid {
+ message: "Field name cannot be null or empty".to_string(),
+ source: None,
+ });
+ }
+ if limit == 0 {
+ return Err(crate::Error::ConfigInvalid {
+ message: "Limit must be positive".to_string(),
+ });
+ }
+ if !weight.is_finite() || weight <= 0.0 {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!("Weight must be finite and positive, got:
{weight}"),
+ });
+ }
+ Ok(())
+ }
+
+ pub fn kind(&self) -> HybridSearchRouteKind {
+ self.kind
+ }
+
+ pub fn field_name(&self) -> &str {
+ &self.field_name
+ }
+
+ pub fn vector_value(&self) -> Option<&[f32]> {
+ self.vector.as_deref()
+ }
+
+ pub fn full_text_query(&self) -> Option<&str> {
+ self.full_text_query.as_deref()
+ }
+
+ pub fn limit(&self) -> usize {
+ self.limit
+ }
+
+ pub fn weight(&self) -> f32 {
+ self.weight
+ }
+
+ pub fn options(&self) -> &HashMap<String, String> {
+ &self.options
+ }
+}
+
+pub struct HybridSearchBuilder<'a> {
+ table: &'a Table,
+ routes: Vec<HybridSearchRoute>,
+ limit: Option<usize>,
+ ranker: HybridSearchRanker,
+}
+
+impl<'a> HybridSearchBuilder<'a> {
+ pub(crate) fn new(table: &'a Table) -> Self {
+ Self {
+ table,
+ routes: Vec::new(),
+ limit: None,
+ ranker: HybridSearchRanker::Rrf,
+ }
+ }
+
+ pub fn add_route(&mut self, route: HybridSearchRoute) -> &mut Self {
+ self.routes.push(route);
+ self
+ }
+
+ pub fn add_vector_route(
+ &mut self,
+ field_name: &str,
+ vector: Vec<f32>,
+ limit: usize,
+ weight: f32,
+ options: HashMap<String, String>,
+ ) -> crate::Result<&mut Self> {
+ self.routes.push(HybridSearchRoute::vector(
+ field_name, vector, limit, weight, options,
+ )?);
+ Ok(self)
+ }
+
+ pub fn add_full_text_route(
+ &mut self,
+ field_name: &str,
+ query: &str,
+ limit: usize,
+ weight: f32,
+ options: HashMap<String, String>,
+ ) -> crate::Result<&mut Self> {
+ self.routes.push(HybridSearchRoute::full_text(
+ field_name, query, limit, weight, options,
+ )?);
+ Ok(self)
+ }
+
+ pub fn with_limit(&mut self, limit: usize) -> &mut Self {
+ self.limit = Some(limit);
+ self
+ }
+
+ pub fn with_ranker(&mut self, ranker: &str) -> crate::Result<&mut Self> {
+ self.ranker = HybridSearchRanker::parse(ranker)?;
+ Ok(self)
+ }
+
+ pub fn with_rrf_ranker(&mut self) -> &mut Self {
+ self.ranker = HybridSearchRanker::Rrf;
+ self
+ }
+
+ pub fn with_weighted_score_ranker(&mut self) -> &mut Self {
+ self.ranker = HybridSearchRanker::WeightedScore;
+ self
+ }
+
+ pub fn with_mrr_ranker(&mut self) -> &mut Self {
+ self.ranker = HybridSearchRanker::Mrr;
+ self
+ }
+
+ pub async fn execute(&self) -> crate::Result<Vec<RowRange>> {
+ self.execute_scored().await?.to_row_ranges()
+ }
+
+ pub async fn execute_scored(&self) -> crate::Result<SearchResult> {
+
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+ let limit = self.limit.ok_or_else(|| crate::Error::ConfigInvalid {
+ message: "Limit must be set via with_limit()".to_string(),
+ })?;
+ if self.routes.is_empty() {
+ return Err(crate::Error::ConfigInvalid {
+ message: "Routes cannot be empty".to_string(),
+ });
+ }
+
+ let mut route_results = Vec::with_capacity(self.routes.len());
+ for route in &self.routes {
+ let result = match route.kind {
+ HybridSearchRouteKind::Vector => {
+ let mut builder = self.table.new_vector_search_builder();
+ builder
+ .with_vector_column(&route.field_name)
+
.with_query_vector(route.vector.clone().expect("validated vector route"))
+ .with_limit(route.limit)
+ .with_options(route.options.clone());
+ builder.execute_scored().await?
+ }
+ HybridSearchRouteKind::FullText => {
+ execute_full_text_route(self.table, route).await?
+ }
+ };
+ if !result.is_empty() {
+ route_results.push(WeightedRouteResult {
+ result,
+ weight: route.weight,
+ });
+ }
+ }
+
+ Ok(rank_results(self.ranker, &route_results, limit))
+ }
+}
+
+#[cfg(feature = "fulltext")]
+async fn execute_full_text_route(
+ table: &Table,
+ route: &HybridSearchRoute,
+) -> crate::Result<SearchResult> {
+ let mut builder = table.new_full_text_search_builder();
+ builder
+ .with_text_column(&route.field_name)
+ .with_query_text(
+ route
+ .full_text_query
+ .as_deref()
+ .expect("validated full-text route"),
+ )
+ .with_limit(route.limit);
+ let result = builder.execute_scored().await?;
+ Ok(SearchResult::new(result.row_ids, result.scores))
+}
+
+#[cfg(not(feature = "fulltext"))]
+async fn execute_full_text_route(
+ _table: &Table,
+ _route: &HybridSearchRoute,
+) -> crate::Result<SearchResult> {
+ Err(crate::Error::ConfigInvalid {
+ message: "Full-text hybrid routes require the fulltext
feature".to_string(),
+ })
+}
+
+struct WeightedRouteResult {
+ result: SearchResult,
+ weight: f32,
+}
+
+fn rank_results(
+ ranker: HybridSearchRanker,
+ route_results: &[WeightedRouteResult],
+ limit: usize,
+) -> SearchResult {
+ match ranker {
+ HybridSearchRanker::Rrf => rrf(route_results, limit),
+ HybridSearchRanker::WeightedScore => weighted_score(route_results,
limit),
+ HybridSearchRanker::Mrr => mrr(route_results, limit),
+ }
+}
+
+fn rrf(route_results: &[WeightedRouteResult], limit: usize) -> SearchResult {
+ let mut scores = HashMap::new();
+ for route_result in route_results {
+ for (rank, (row_id, _score)) in
ranked_row_ids(&route_result.result).iter().enumerate() {
+ let contribution = route_result.weight / (RRF_K + rank as f32 +
1.0);
+ add_score(&mut scores, *row_id, contribution);
+ }
+ }
+ top_k(scores, limit)
+}
+
+fn mrr(route_results: &[WeightedRouteResult], limit: usize) -> SearchResult {
+ let mut scores = HashMap::new();
+ for route_result in route_results {
+ for (rank, (row_id, _score)) in
ranked_row_ids(&route_result.result).iter().enumerate() {
+ let contribution = route_result.weight / (rank as f32 + 1.0);
+ add_score(&mut scores, *row_id, contribution);
+ }
+ }
+ top_k(scores, limit)
+}
+
+fn weighted_score(route_results: &[WeightedRouteResult], limit: usize) ->
SearchResult {
+ let mut scores = HashMap::new();
+ for route_result in route_results {
+ let ranked = ranked_row_ids(&route_result.result);
+ if ranked.is_empty() {
+ continue;
+ }
+
+ let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
+ for (_row_id, score) in &ranked {
+ min = min.min(*score);
+ max = max.max(*score);
+ }
+ let range = max - min;
+
+ for (row_id, score) in ranked {
+ let normalized = if range > 0.0 {
+ (score - min) / range
+ } else {
+ 1.0
+ };
+ add_score(&mut scores, row_id, route_result.weight * normalized);
+ }
+ }
+ top_k(scores, limit)
+}
+
+fn ranked_row_ids(result: &SearchResult) -> Vec<(u64, f32)> {
+ let mut best_scores = HashMap::new();
+ for (&row_id, &score) in result.row_ids.iter().zip(&result.scores) {
+ best_scores
+ .entry(row_id)
+ .and_modify(|old: &mut f32| {
+ if score > *old {
+ *old = score;
+ }
+ })
+ .or_insert(score);
+ }
+
+ let mut ranked: Vec<_> = best_scores.into_iter().collect();
+ ranked.sort_by(|(left_id, left_score), (right_id, right_score)| {
+ right_score
+ .partial_cmp(left_score)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ .then_with(|| left_id.cmp(right_id))
+ });
+ ranked
+}
+
+fn add_score(scores: &mut HashMap<u64, f32>, row_id: u64, score: f32) {
+ scores
+ .entry(row_id)
+ .and_modify(|old_score| *old_score += score)
+ .or_insert(score);
+}
+
+fn top_k(scores: HashMap<u64, f32>, limit: usize) -> SearchResult {
+ if scores.is_empty() || limit == 0 {
+ return SearchResult::empty();
+ }
+
+ let mut entries: Vec<_> = scores.into_iter().collect();
+ entries.sort_by(|(left_id, left_score), (right_id, right_score)| {
+ right_score
+ .partial_cmp(left_score)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ .then_with(|| left_id.cmp(right_id))
+ });
+ entries.truncate(limit);
+
+ let (row_ids, scores): (Vec<_>, Vec<_>) = entries.into_iter().unzip();
+ SearchResult::new(row_ids, scores)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn route_result(row_ids: Vec<u64>, scores: Vec<f32>, weight: f32) ->
WeightedRouteResult {
+ WeightedRouteResult {
+ result: SearchResult::new(row_ids, scores),
+ weight,
+ }
+ }
+
+ #[test]
+ fn test_rrf_prefers_overlap() {
+ let ranked = rank_results(
+ HybridSearchRanker::Rrf,
+ &[
+ route_result(vec![1, 2], vec![0.9, 0.8], 1.0),
+ route_result(vec![2, 3], vec![0.95, 0.1], 1.0),
+ ],
+ 1,
+ );
+
+ assert_eq!(ranked.row_ids, vec![2]);
+ }
+
+ #[test]
+ fn test_weighted_score_min_max_normalizes_per_route() {
+ let ranked = rank_results(
+ HybridSearchRanker::WeightedScore,
+ &[
+ route_result(vec![1, 2, 3], vec![10.0, 5.0, 0.0], 2.0),
+ route_result(vec![1, 2, 3], vec![100.0, 50.0, 0.0], 1.0),
+ ],
+ 3,
+ );
+
+ let scores: HashMap<_, _> =
ranked.row_ids.into_iter().zip(ranked.scores).collect();
+ assert!((scores[&1] - 3.0).abs() < 1e-6);
+ assert!((scores[&2] - 1.5).abs() < 1e-6);
+ assert!((scores[&3] - 0.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn test_mrr_uses_reciprocal_rank_without_constant() {
+ let ranked = rank_results(
+ HybridSearchRanker::Mrr,
+ &[
+ route_result(vec![1, 2], vec![0.9, 0.8], 1.0),
+ route_result(vec![2, 3], vec![0.95, 0.1], 1.0),
+ ],
+ 2,
+ );
+
+ assert_eq!(ranked.row_ids[0], 2);
+ assert!(ranked.scores[0] > ranked.scores[1]);
+ }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 459e48a..930f9ef 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -38,6 +38,7 @@ mod data_file_writer;
#[cfg(feature = "fulltext")]
mod full_text_search_builder;
pub(crate) mod global_index_scanner;
+mod hybrid_search_builder;
mod kv_file_reader;
mod kv_file_writer;
mod lumina_index_build_builder;
@@ -76,6 +77,9 @@ pub use data_evolution_writer::{DataEvolutionDeleteWriter,
DataEvolutionWriter};
#[cfg(feature = "fulltext")]
pub use full_text_search_builder::FullTextSearchBuilder;
use futures::stream::BoxStream;
+pub use hybrid_search_builder::{
+ HybridSearchBuilder, HybridSearchRanker, HybridSearchRoute,
HybridSearchRouteKind,
+};
pub use lumina_index_build_builder::LuminaIndexBuildBuilder;
pub use read_builder::ReadBuilder;
pub use rest_env::RESTEnv;
@@ -185,6 +189,13 @@ impl Table {
FullTextSearchBuilder::new(self)
}
+ /// Create a hybrid search builder.
+ ///
+ /// Reference:
[HybridSearchBuilderImpl](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/table/source/HybridSearchBuilderImpl.java)
+ pub fn new_hybrid_search_builder(&self) -> HybridSearchBuilder<'_> {
+ HybridSearchBuilder::new(self)
+ }
+
pub fn new_vector_search_builder(&self) -> VectorSearchBuilder<'_> {
VectorSearchBuilder::new(self)
}
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 33003c4..2384ce9 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -70,6 +70,7 @@ pub struct VectorSearchBuilder<'a> {
vector_column: Option<String>,
query_vector: Option<Vec<f32>>,
limit: Option<usize>,
+ options: HashMap<String, String>,
}
pub struct BatchVectorSearchBuilder<'a> {
@@ -77,6 +78,7 @@ pub struct BatchVectorSearchBuilder<'a> {
vector_column: Option<String>,
query_vectors: Option<Vec<Vec<f32>>>,
limit: Option<usize>,
+ options: HashMap<String, String>,
}
impl<'a> VectorSearchBuilder<'a> {
@@ -86,6 +88,7 @@ impl<'a> VectorSearchBuilder<'a> {
vector_column: None,
query_vector: None,
limit: None,
+ options: HashMap::new(),
}
}
@@ -104,7 +107,16 @@ impl<'a> VectorSearchBuilder<'a> {
self
}
+ pub fn with_options(&mut self, options: HashMap<String, String>) -> &mut
Self {
+ self.options = options;
+ self
+ }
+
pub async fn execute(&self) -> crate::Result<Vec<RowRange>> {
+ self.execute_scored().await?.to_row_ranges()
+ }
+
+ pub async fn execute_scored(&self) -> crate::Result<SearchResult> {
// Fail closed: returns data-derived row ranges outside
`TableScan`/`TableRead`.
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
let vector_column =
@@ -128,11 +140,12 @@ impl<'a> VectorSearchBuilder<'a> {
.with_vector_column(vector_column)
.with_query_vectors(vec![query_vector.clone()])
.with_limit(limit)
+ .with_options(self.options.clone())
.execute()
.await?;
debug_assert_eq!(results.len(), 1);
- results.remove(0).to_row_ranges()
+ Ok(results.remove(0))
}
}
@@ -143,6 +156,7 @@ impl<'a> BatchVectorSearchBuilder<'a> {
vector_column: None,
query_vectors: None,
limit: None,
+ options: HashMap::new(),
}
}
@@ -161,6 +175,11 @@ impl<'a> BatchVectorSearchBuilder<'a> {
self
}
+ pub fn with_options(&mut self, options: HashMap<String, String>) -> &mut
Self {
+ self.options = options;
+ self
+ }
+
pub async fn execute(&self) -> crate::Result<Vec<SearchResult>> {
let vector_column =
self.vector_column
@@ -192,7 +211,10 @@ impl<'a> BatchVectorSearchBuilder<'a> {
let vector_searches = query_vectors
.iter()
- .map(|vector| VectorSearch::new(vector.clone(), limit,
vector_column.to_string()))
+ .map(|vector| {
+ VectorSearch::new(vector.clone(), limit,
vector_column.to_string())
+ .map(|search| search.with_options(self.options.clone()))
+ })
.collect::<crate::Result<Vec<_>>>()?;
let snapshot_manager = SnapshotManager::new(
@@ -282,6 +304,17 @@ async fn evaluate_batch_vector_search(
source: None,
});
}
+ let search_options = vector_searches[0].options.clone();
+ if vector_searches
+ .iter()
+ .any(|vector_search| vector_search.options != search_options)
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "Batch vector search requires all query vectors to use
the same options"
+ .to_string(),
+ source: None,
+ });
+ }
let field_id = match find_field_id_by_name(evaluation.schema_fields,
field_name) {
Some(id) => id,
@@ -346,7 +379,8 @@ async fn evaluate_batch_vector_search(
for vector_search in &mut vector_searches {
vector_search.limit = index_limit;
}
- let options = evaluation.table_options.clone();
+ let mut options = evaluation.table_options.clone();
+ options.extend(search_options.clone());
let input = evaluation.file_io.new_input(&path);
async move {
let input = input?;
diff --git a/crates/paimon/src/vector_search.rs
b/crates/paimon/src/vector_search.rs
index 28b6500..c96edbf 100644
--- a/crates/paimon/src/vector_search.rs
+++ b/crates/paimon/src/vector_search.rs
@@ -22,6 +22,7 @@ pub struct VectorSearch {
pub vector: Vec<f32>,
pub limit: usize,
pub field_name: String,
+ pub options: HashMap<String, String>,
pub include_row_ids: Option<roaring::RoaringTreemap>,
}
@@ -49,10 +50,16 @@ impl VectorSearch {
vector,
limit,
field_name,
+ options: HashMap::new(),
include_row_ids: None,
})
}
+ pub fn with_options(mut self, options: HashMap<String, String>) -> Self {
+ self.options = options;
+ self
+ }
+
pub fn with_include_row_ids(mut self, include_row_ids:
roaring::RoaringTreemap) -> Self {
self.include_row_ids = Some(include_row_ids);
self
@@ -248,6 +255,7 @@ mod tests {
assert_eq!(cloned.vector, vector_search.vector);
assert_eq!(cloned.limit, vector_search.limit);
assert_eq!(cloned.field_name, vector_search.field_name);
+ assert_eq!(cloned.options, vector_search.options);
assert_eq!(cloned.include_row_ids.as_ref(), Some(&include_row_ids));
}
diff --git a/docs/src/sql.md b/docs/src/sql.md
index e983274..70b8099 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -78,7 +78,7 @@ async fn example() -> Result<(), Box<dyn std::error::Error>> {
}
```
-`SQLContext::new` creates a session context with the Paimon relation planner
pre-registered. Use `register_catalog(...).await` to add one or more Paimon
catalogs; registering a catalog also registers the built-in table-valued
functions (`vector_search`, `full_text_search`) against it. It also manages
session-scoped dynamic options internally for `SET`/`RESET` support.
+`SQLContext::new` creates a session context with the Paimon relation planner
pre-registered. Use `register_catalog(...).await` to add one or more Paimon
catalogs; registering a catalog also registers the built-in table-valued
functions (`vector_search`, `hybrid_search`, and `full_text_search` when the
`fulltext` feature is enabled) against it. It also manages session-scoped
dynamic options internally for `SET`/`RESET` support.
## Data Types
@@ -852,6 +852,99 @@ Vector index behavior is configured via table options
prefixed with `lumina.`:
The Lumina native library must be available at runtime. Set the
`LUMINA_LIB_PATH` environment variable to the path of the shared library, or
place it in the platform default location.
+## Hybrid Search
+
+Paimon supports hybrid search by combining multiple search routes and ranking
the merged results. The `hybrid_search` table-valued function is registered as
a UDTF on the DataFusion session context.
+
+Hybrid search does not require the `fulltext` feature when all routes are
vector routes. Enable `fulltext` only when you include full-text routes.
+
+### Registration
+
+When you use a `SQLContext`, `hybrid_search` is registered automatically for
every catalog you register — no extra setup is needed.
+
+With a raw DataFusion `SessionContext`, register it explicitly:
+
+```rust
+use paimon_datafusion::register_hybrid_search;
+
+register_hybrid_search(&ctx, catalog.clone(), "default");
+```
+
+### Usage
+
+```sql
+SELECT * FROM hybrid_search(
+ 'table_name',
+ vector_routes,
+ full_text_routes,
+ limit,
+ 'ranker'
+)
+```
+
+| Argument | Type | Description |
+|---|---|---|
+| `table_name` | STRING | Table name, fully qualified (`catalog.db.table`) or
short form |
+| `vector_routes` | ARRAY | Vector route definitions; use `array()` when no
vector route is needed |
+| `full_text_routes` | ARRAY | Full-text route definitions; use `array()` for
vector-only hybrid search |
+| `limit` | INT | Maximum number of merged results (top-k) |
+| `ranker` | STRING | Optional ranker: `rrf` (default), `weighted_score`, or
`mrr` |
+
+Route definitions use Spark-compatible `array(named_struct(...))` syntax. A
vector route accepts `field` (or `vector_column`), `query_vector`, optional
`limit`, optional `weight`, and optional `options`:
+
+```sql
+SELECT *
+FROM hybrid_search(
+ 'paimon.my_db.items',
+ array(
+ named_struct(
+ 'field', 'title_embedding',
+ 'query_vector', array(1.0, 0.0, 0.0, 0.0),
+ 'limit', 20,
+ 'weight', 1.0
+ ),
+ named_struct(
+ 'field', 'body_embedding',
+ 'query_vector', array(0.9, 0.1, 0.0, 0.0),
+ 'limit', 20,
+ 'weight', 0.7
+ )
+ ),
+ array(),
+ 10,
+ 'rrf'
+);
+```
+
+A full-text route accepts `column`, `query`, optional `limit`, and optional
`weight`. Full-text routes require the `fulltext` feature:
+
+```sql
+SELECT *
+FROM hybrid_search(
+ 'paimon.my_db.docs',
+ array(
+ named_struct(
+ 'field', 'embedding',
+ 'query_vector', array(1.0, 0.0, 0.0, 0.0),
+ 'limit', 20,
+ 'weight', 1.0
+ )
+ ),
+ array(
+ named_struct(
+ 'column', 'content',
+ 'query', 'paimon search',
+ 'limit', 20,
+ 'weight', 0.8
+ )
+ ),
+ 10,
+ 'weighted_score'
+);
+```
+
+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.
+
## Full-Text Search
Paimon supports full-text search via the Tantivy search engine. The
`full_text_search` table-valued function is registered as a UDTF on the
DataFusion session context.