Hybrid search (HybridRank)

Hybrid search fuses fulltext and vector ranking into a single result. A hybrid query is a SELECT over the base table whose ORDER BY key is a single HybridRank call:

PRAGMA ydb.KMeansTreeSearchTopSize = "10";

$queryText = "machine learning";
$queryVector = Knn::ToBinaryStringFloat([0.1, 0.2, 0.3, 0.4]);

SELECT id, title
FROM documents
ORDER BY HybridRank(
    FullTextScore(text, $queryText),
    Knn::CosineDistance(embedding, $queryVector))
LIMIT 10;

Note

A hybrid query reads through the base table and does not use VIEW IndexName: each branch's index is resolved from the corresponding HybridRank argument.

The query requires both a fulltext_relevance index over the column passed to FullTextScore and a (non-prefixed) vector_kmeans_tree index over the column passed to Knn. The recall of the vector branch is controlled by KMeansTreeSearchTopSize.

HybridRank

The general form of the function:

HybridRank(
    score1, score2 [, ... scoreN]        -- 2+ scoring expressions: FullTextScore(...) or Knn::<Distance|Similarity>(...)
    [, "rrf" | "linear"  AS Mode]        -- fusion method (default "rrf")
    [, (w1, w2, ...)     AS Weights]     -- per-branch weights (default 1.0 each)
    [, k                 AS K]           -- RRF constant (default 60.0)
    [, true | false      AS Normalize]   -- "linear" mode only: min-max normalize (default true)
    [, (idx1, idx2, ...) AS Indexes]     -- per-branch index names (default: auto-detect)
    [, (lim1, lim2, ...) AS Limits]      -- per-branch candidate-pool sizes (default: LIMIT * 10)
    [, ($x) -> {...}     AS RankLambda]  -- custom fusion over per-branch ranks (replaces built-in Mode)
    [, ($x) -> {...}     AS ScoreLambda] -- custom fusion over per-branch raw scores (replaces built-in Mode)
)

HybridRank may only appear as the entire sort key of an ORDER BY clause. It takes two or more scoring expressions (positional), followed by optional named arguments.

Each scoring expression is one branch of the fusion, classified by its form:

  • a FullTextScore(text, query) expression — a fulltext branch, resolved against a fulltext_relevance index over the text column;
  • a Knn distance or similarity over a column (for example Knn::CosineDistance(embedding, $queryVector) or Knn::CosineSimilarity(embedding, $queryVector)) — a vector branch, resolved against a vector_kmeans_tree index over that column.

The argument order is not significant, and there may be more than two branches (for example a fulltext branch plus two vector branches). Each branch retrieves its own candidate pool and contributes one term to every document's fused score.

Fusion modes

Two fusion methods are available via the Mode argument:

  • rrf (default) — Reciprocal Rank Fusion. Each branch contributes weight / (K + rank), where rank is the document's position within that branch. Because RRF uses ranks rather than raw scores, the (non-comparable) magnitudes of the branch scores do not matter.
  • linear — a weighted sum of the per-branch scores: weight * score summed over branches. By default the per-branch scores are min-max normalized to [0, 1] before summing; set Normalize to false to fuse the raw scores.

In both modes a document that is absent from a branch's candidate pool simply does not receive that branch's contribution.

Custom fusion lambda

Instead of the built-in Mode (rrf/linear), you can supply a custom fusion lambda that computes the fused score from the per-branch values yourself. Two mutually exclusive lambda kinds are available:

  • RankLambda — the lambda receives the document's per-branch ranks as a Dict<Int64, Int64> (branch index → 1-based rank within that branch). A branch the document is absent from has no entry, so $ranks[i] is NULL.
  • ScoreLambda — the lambda receives the document's per-branch raw scores as a Dict<Int64, Double> (branch index → the branch's score value: the fulltext relevance, or the vector distance/similarity). A branch the document is absent from has no entry, so $scores[i] is NULL.

The lambda takes a single argument (the dict) and must return a numeric value (Double is recommended); larger values rank higher. The lambda replaces the built-in fusion, so it cannot be combined with Mode, Weights, K, or Normalize — fold any weights and constants into the lambda body.

SELECT id, title
FROM documents
ORDER BY HybridRank(
    FullTextScore(text, $queryText),
    Knn::CosineDistance(embedding, $queryVector),
    ($ranks) -> {
        RETURN 1.0 / (60 + COALESCE($ranks[0], 100000))
             + 1.0 / (60 + COALESCE($ranks[1], 100000));
    } AS RankLambda)
LIMIT 10;

Named arguments

All optional parameters are passed as named arguments. The Weights, Limits, and Indexes arguments are tuples positionally parallel to the scoring expressions — they must have exactly one entry per branch.

Argument Type Description
Mode String Fusion method: "rrf" (default) or "linear". Case-insensitive ("RRF" / "Linear" are accepted).
Weights Tuple of numbers Per-branch weight, one per scoring argument. Default 1.0 for every branch. A weight of 0 disables a branch.
K Double The RRF constant (rrf mode only). Default 60.0. Larger values flatten the influence of top ranks.
Normalize Bool linear mode only. When true (default), per-branch scores are min-max normalized before summing; when false, raw scores are fused.
Indexes Tuple of strings Explicit index name per branch, overriding auto-detection. Required when a branch's column is covered by more than one matching index.
Limits Tuple of positive integers Explicit candidate-pool size per branch. By default each branch retrieves LIMIT * HybridSearchFactor (factor default 10) candidates.
RankLambda Lambda Custom fusion over per-branch ranks. The lambda receives a Dict<Int64, Int64> (branch index → 1-based rank; absent branches have no entry) and returns a numeric score. Replaces the built-in Mode; cannot be combined with Mode, Weights, K, or Normalize.
ScoreLambda Lambda Custom fusion over per-branch raw scores. The lambda receives a Dict<Int64, Double> (branch index → raw branch score; absent branches have no entry) and returns a numeric score. Replaces the built-in Mode; cannot be combined with Mode, Weights, K, or Normalize.

Examples

Weighted RRF — bias the ranking toward the vector branch:

$queryText = "machine learning";
$queryVector = Knn::ToBinaryStringFloat([0.1, 0.2, 0.3, 0.4]);

SELECT id, title
FROM documents
ORDER BY HybridRank(
    FullTextScore(text, $queryText),
    Knn::CosineDistance(embedding, $queryVector),
    (1, 2) AS Weights)
LIMIT 10;

Three-branch RRF — combine text relevance with two vector branches over different embedding columns:

$queryText = "machine learning";
$titleVector = Knn::ToBinaryStringFloat([0.1, 0.2, 0.3, 0.4]);
$bodyVector = Knn::ToBinaryStringFloat([0.7, 0.8, 0.9, 1.0]);

SELECT id, title
FROM documents
ORDER BY HybridRank(
    FullTextScore(text, $queryText),
    Knn::CosineDistance(title_embedding, $titleVector),
    Knn::CosineDistance(body_embedding, $bodyVector),
    (1, 2, 3) AS Weights)
LIMIT 10;

Linear fusion over normalized scores:

$queryText = "machine learning";
$queryVector = Knn::ToBinaryStringFloat([0.1, 0.2, 0.3, 0.4]);

SELECT id, title
FROM documents
ORDER BY HybridRank(
    FullTextScore(text, $queryText),
    Knn::CosineDistance(embedding, $queryVector),
    "linear" AS Mode)
LIMIT 10;

Explicit index names and candidate-pool sizes. Because the per-branch pool sizes are given by Limits, the query's own LIMIT no longer needs to be a literal and may be a parameter:

DECLARE $limit AS Uint64;

$queryText = "machine learning";
$queryVector = Knn::ToBinaryStringFloat([0.1, 0.2, 0.3, 0.4]);

SELECT id, title
FROM documents
ORDER BY HybridRank(
    FullTextScore(text, $queryText),
    Knn::CosineDistance(embedding, $queryVector),
    ("ft_idx", "vec_idx") AS Indexes,
    (100, 200) AS Limits)
LIMIT $limit;

Custom RankLambda — spell out RRF by hand, weighting the vector branch 100× more than the text branch:

$queryText = "machine learning";
$queryVector = Knn::ToBinaryStringFloat([0.1, 0.2, 0.3, 0.4]);

SELECT id, title
FROM documents
ORDER BY HybridRank(
    FullTextScore(text, $queryText),
    Knn::CosineDistance(embedding, $queryVector),
    ($ranks) -> {
        RETURN   1.0 / (60 + COALESCE($ranks[0], 100000))
             + 100.0 / (60 + COALESCE($ranks[1], 100000));
    } AS RankLambda)
LIMIT 10;

Custom ScoreLambda — combine the raw fulltext relevance and the negated vector distance (closer = larger score):

$queryText = "machine learning";
$queryVector = Knn::ToBinaryStringFloat([0.1, 0.2, 0.3, 0.4]);

SELECT id, title
FROM documents
ORDER BY HybridRank(
    FullTextScore(text, $queryText),
    Knn::CosineDistance(embedding, $queryVector),
    ($scores) -> {
        RETURN COALESCE($scores[0], 0.0) - COALESCE($scores[1], 1000000.0);
    } AS ScoreLambda)
LIMIT 10;

Limitations

  • HybridRank(...) must be the entire ORDER BY key; it cannot be negated, nested in a larger expression, or combined with other sort keys.
  • At least two scoring arguments are required — a single branch is not a hybrid query.
  • LIMIT must be a literal (it sizes the per-branch candidate pools). Use an explicit AS Limits to allow a parameterized LIMIT.
  • A custom fusion lambda (RankLambda or ScoreLambda) replaces the built-in fusion and cannot be combined with Mode, Weights, K, or Normalize. At most one of RankLambda or ScoreLambda may be specified.
  • Prefixed vector indexes are not supported yet.