Search in Polecat, Part 3: Hybrid search and reciprocal rank fusion

Search in Polecat, Part 3: Hybrid search and reciprocal rank fusion

Part 1 built keyword search, Part 2 vector search. Keyword search finds a fault code like E-142 exactly, and misses the limescale article when you typed hard water. Vector search does the reverse.

They fail in different places, which is the argument for fusing them: a document both legs rank well beats either leg’s favourite.

Polecat 5.29.0, SQL Server 2025, same corpus as the first two parts.

The legs disagree

The document, carrying both the text and its vector:

/// <summary>The same article, now carrying the vector Polecat searches by.</summary>
public class Article
{
    public Guid Id { get; set; }
    public required string Title { get; set; }
    public required string Body { get; set; }
    public float[]? Embedding { get; set; }
}

Hybrid search needs both indexes declared on it:

// Hybrid search reads both: the full-text index for its text leg, the vector index for
// its vector leg.
opts.Schema.For<Article>()
    .FullTextIndex(x => x.Body)
    .VectorIndex(x => x.Embedding, dimensions: Embeddings.Dimensions);
var fused = await session.HybridSearchWithScoresAsync<Article>(
    x => x.Embedding, question, queryVector, limit: 5);

Each leg on its own, for the question hard water:

FullTextSearchWithScoresAsync(x => x.Body, "hard water", limit: 5)

rankBM25 scorearticle
12.5458Descaling in hard water areas
21.3885Water hardness and machine lifetime

VectorSearchWithScoresAsync(x => x.Embedding, queryVector, limit: 5)

rankdistancearticle
10.0194Water hardness and machine lifetime
20.0572Descaling in hard water areas
30.7868Monthly maintenance schedule
41.0000Grinder burr replacement
51.0000Steam wand will not froth

BM25 puts Descaling in hard water areas first: it literally contains both words. The vector leg puts Water hardness and machine lifetime first, as the article most about the subject. Each leg’s favourite is the other’s runner-up.

HybridSearchWithScoresAsync(x => x.Embedding, "hard water", queryVector, limit: 5)

rankRRF scorearticle
10.0325Descaling in hard water areas
20.0325Water hardness and machine lifetime
30.0159Monthly maintenance schedule
40.0156Grinder burr replacement
50.0154Steam wand will not froth

Both reach the top with the same score. Each is 1st on one leg and 2nd on the other, so each scores 1/(60+1) + 1/(60+2) = 0.0325. Monthly maintenance schedule was found by the vector leg alone, at rank 3, so it scores 1/63 = 0.0159.

Only the ranks are fused, never the scores

Reciprocal rank fusion sums 1 / (k + rank) over the legs that found a document.

A BM25 score and a vector distance are not on one scale. BM25’s magnitude depends on the corpus; a cosine distance is bounded and a euclidean distance is not. Min-max normalising either over the candidate set makes the top result’s score depend on how bad the worst candidate happened to be.

RRF needs nothing but the ordering each leg already produces, which is also the honest reading of its output: a hybrid score says how the legs agreed, and nothing about similarity in absolute terms. Order by it or use it as a relative floor; do not store it.

Both legs read through FullTextSearchAsync and VectorSearchAsync, so tenancy scoping, soft deletes and every refusal from the previous two posts apply unchanged.

Options

var tuned = await session.HybridSearchWithScoresAsync<Article>(
    x => x.Embedding, question, queryVector, limit: 3,
    options: new HybridSearchOptions(K: k, CandidateDepth: 50));

K is RRF’s smoothing constant, conventionally 60. Larger flattens the difference between ranks; smaller lets the top of each leg dominate.

the same search at three values of K, showing the top three RRF scores

K1st2nd3rd
10.83330.83330.2500
100.17420.17420.0769
600.03250.03250.0159

At K=1 the top two score 1/2 + 1/3 = 0.8333 against the third’s 0.25, a ratio over three. At K=60 that ratio is two. The order does not move; the spread compresses.

CandidateDepth is how deep each leg is read before fusing, defaulting to max(limit × 4, 50). It must exceed limit, which is the point of it: a document ranked 40th by one leg and 1st by the other is what hybrid search exists to surface, and reading only limit from each leg would never see it.

Distance overrides the vector index’s declared metric. Null means the metric the index declared, which is almost always right.

TextStyle picks the text leg’s operator, Plain or Phrase. Plain reads through FullTextSearchAsync and arrives in BM25 order; Phrase has no ranking of its own.

Which member the text leg searches

The portable overload takes the vector member and infers the text member when the type declares exactly one. When a type declares several, name both:

// The portable overload infers the text member when a type declares exactly one. When it
// declares several, name both.
var named = await session.HybridSearchWithScoresAsync<Manual>(
    x => x.Body, x => x.Embedding, question, queryVector, limit: 3);

This form exists only as HybridSearchWithScoresAsync, so select m => m.Document when you want documents alone.

What it refuses

a candidate depth below the limit
    ArgumentOutOfRangeException: CandidateDepth (5) is below limit (10), so the fusion would have fewer candidates than it is asked to return. It exists to read DEEPER than limit: a document ranked low by one leg and first by the other is what hybrid search is for. (Parameter 'options')

K below 1
    ArgumentOutOfRangeException: K must be at least 1. It is reciprocal rank fusion's smoothing constant, and 0 would make the top-ranked document of either leg score infinitely. (Parameter 'options')

a type declaring more than one full-text member
    InvalidOperationException: 'Manual' declares 2 full-text members (Title, Body), so this overload cannot tell which one the text leg should search. Use the overload that names both members explicitly.

Each refusal names what was declared, so the fix is obvious. That matters more in search than elsewhere: nothing throws when a query quietly stops matching, and an empty result looks exactly like a correct one.

Reference