Skip to main content

Hybrid Search

Hybrid search queries multiple scored routes in one request and ranks the results before reading table rows. A route can be a vector search route or a full-text search route, so hybrid search can combine multiple vector indexes, multiple full-text indexes, or a mix of vector and full-text indexes. This is useful when one table stores several searchable representations for the same record, such as title embeddings, body embeddings, and content text.

Vector and full-text routes return scored row IDs; a ranker merges candidates and selects the final rows to read.

Create the Example Table​

This example uses a separate table with two three-dimensional vector columns. In a configured Spark Paimon catalog, select the existing db database and run:

CREATE TABLE hybrid_documents (
id INT,
title_embedding ARRAY<FLOAT>,
body_embedding ARRAY<FLOAT>,
content STRING
) TBLPROPERTIES (
'row-tracking.enabled' = 'true',
'data-evolution.enabled' = 'true'
);

INSERT INTO hybrid_documents VALUES
(1, array(1.0f, 0.0f, 0.0f), array(0.0f, 1.0f, 0.0f), 'paimon hybrid search'),
(2, array(0.9f, 0.1f, 0.0f), array(0.1f, 0.9f, 0.0f), 'paimon lake format'),
(3, array(0.0f, 1.0f, 0.0f), array(1.0f, 0.0f, 0.0f), 'vector search tutorial');

The Java and Python examples query this same table through their catalog. Use matching dimensions and model preprocessing for stored and query vectors in a real pipeline. The sample vectors here only illustrate route configuration.

Build the Route Indexes​

Before running hybrid search, create a global index for every vector or text column used by the query:

CALL sys.create_global_index(
table => 'db.hybrid_documents',
index_column => 'title_embedding',
index_type => 'ivf-flat',
options => 'ivf-flat.dimension=3,ivf-flat.distance.metric=cosine,ivf-flat.nlist=1'
);

CALL sys.create_global_index(
table => 'db.hybrid_documents',
index_column => 'body_embedding',
index_type => 'ivf-flat',
options => 'ivf-flat.dimension=3,ivf-flat.distance.metric=cosine,ivf-flat.nlist=1'
);

CALL sys.create_global_index(
table => 'db.hybrid_documents',
index_column => 'content',
index_type => 'full-text'
);

Alternatively, after creating and populating the table, build the same indexes with Python. Install pypaimon[vindex,full-text] for the native index dependencies:

table = catalog.get_table("db.hybrid_documents")
for column in ("title_embedding", "body_embedding"):
table.create_global_index(column, index_type="ivf-flat", options={
"ivf-flat.dimension": "3",
"ivf-flat.distance.metric": "cosine",
"ivf-flat.nlist": "1",
})
table.create_global_index("content", index_type="full-text")

The sample uses one IVF cluster and queries it with ivf.nprobe=1. Larger indexes can use different build and search options.

Choose a Ranker​

For Spark SQL, use the hybrid_search(table_name, vector_routes, full_text_routes, limit[, ranker]) table-valued function. The fourth argument is the final number of ranked results to return. The optional fifth argument selects the ranker. Supported rankers are rrf, weighted_score, and mrr; the default is rrf.

Rankers combine route scores differently:

RankerBest ForDescription
rrfCombining routes with different score scalesReciprocal rank fusion uses each route's rank order, so vector and full-text scores do not need to be normalized.
weighted_scoreWeighting routes by normalized score, not just rankMin-max normalizes each route's scores to [0, 1], then sums them weighted by route weight, so weights (not raw score magnitude) control each route's influence. The exposed __paimon_search_score is therefore a per-query relative value in [0, sum of weights], not a raw similarity or BM25 score.
mrrEmphasizing top-ranked hits from each routeWeighted reciprocal-rank fusion sums weight / rank for each row returned by a route, where rank starts at 1.

Configure Routes​

The second argument is an array of vector route configs created by named_struct:

FieldRequiredDefaultDescription
field or vector_columnYesN/AVector column to search.
query_vectorYesN/AQuery vector for this route.
limitNoFinal limitTop K results to retrieve from this vector column before ranking.
weightNo1.0Weight for this route when ranking results.
optionsNoEmpty mapRoute-specific vector search options such as ivf.nprobe and diskann.l_search.

The third argument is an array of full-text route configs created by named_struct:

FieldRequiredDefaultDescription
columnYesN/AText column to search.
queryYesN/AFull-text query string for this route.
limitNoFinal limitTop K results to retrieve from this text column before ranking.
weightNo1.0Weight for this route when ranking results.
optionsNoEmpty mapReserved for future full-text search options. Only an empty map is accepted.

Within each route array, every named_struct should use the same fields because Spark requires array elements to have the same struct type. Full-text route options is currently a reserved field; pass map() when the field is needed for struct-type consistency.

Use route limit values larger than the final limit when each route should contribute enough candidates for ranking. For example, with a final limit of 10, route limits such as 50 or 100 give the ranker more candidates to merge.

Route limits and the final limit are upper bounds. The three-row sample table returns at most three rows even when a route asks for 50 and the final limit is 10.

Vector and full-text routes can be searched together:

SELECT id, __paimon_search_score
FROM hybrid_search(
'hybrid_documents',
array(
named_struct(
'field', 'title_embedding',
'query_vector', array(1.0f, 0.0f, 0.0f),
'limit', 50,
'weight', 2.0f,
'options', map('ivf.nprobe', '1'))),
array(
named_struct(
'column', 'content',
'query', '{"match":{"query":"paimon search"}}',
'limit', 50,
'weight', 1.0f,
'options', map())),
10,
'rrf')
ORDER BY __paimon_search_score DESC, id;

To search two vector indexes without a full-text route, pass array() as the third argument:

SELECT id, __paimon_search_score
FROM hybrid_search(
'hybrid_documents',
array(
named_struct(
'field', 'title_embedding',
'query_vector', array(1.0f, 0.0f, 0.0f),
'limit', 50,
'weight', 2.0f,
'options', map('ivf.nprobe', '1')),
named_struct(
'field', 'body_embedding',
'query_vector', array(0.0f, 1.0f, 0.0f),
'limit', 50,
'weight', 1.0f,
'options', map('ivf.nprobe', '1'))),
array(),
10,
'weighted_score')
ORDER BY __paimon_search_score DESC, id;

Use mrr when you want weighted reciprocal-rank fusion:

SELECT id, __paimon_search_score
FROM hybrid_search(
'hybrid_documents',
array(
named_struct(
'field', 'title_embedding',
'query_vector', array(1.0f, 0.0f, 0.0f),
'limit', 50,
'weight', 2.0f,
'options', map('ivf.nprobe', '1'))),
array(),
10,
'mrr')
ORDER BY __paimon_search_score DESC, id;

Spark SQL adds __paimon_search_score to expose the ranked score. Use ORDER BY when the displayed row order must follow that score.

Coverage and Candidate Limits​

Each route depends on its own index coverage and search mode. Review Coverage and Freshness when ingesting or updating data. The ranker can only combine candidates returned by the routes; increase route limits when a small candidate set restricts final recall.

For Java and Python, the subsequent table scan does not preserve ranking order. See Read Scored Results to access scores by row ID.

Row Filters​

A row filter given to a hybrid search (WHERE on the Spark hybrid_search result, or withFilter on the Java builder) is applied to every route before its own top-k, so vector and full-text candidates are drawn from the same filtered row set before the ranker merges them. Full-text routes follow the same rules as a standalone full-text search; see Full-Text Row Filters. In particular, under scalar-index.search-mode=fast a full-text route whose filter columns have no scalar index contributes no candidates, so the fused result comes from the other routes only.