Mert Özen Further With Every Line

Hybrid Search and Reciprocal Rank Fusion: Combining Keyword and Vector Search

Mert Özen Nov 8, 2026 14 min 4 views
Hybrid Search and Reciprocal Rank Fusion: Combining Keyword and Vector Search

Vector search misses SKUs, ISBNs and rare terms; adding keyword search with PostgreSQL tsvector (turkish) and merging the two lists by rank rather than score, with Reciprocal Rank Fusion, closes that gap. We build it with EF Core, a single SQL query and VectorData HybridSearchAsync, and measure vector, keyword and hybrid recall on 30 questions.

Version note: The code in this post was built and run with .NET 10, Entity Framework Core 10 (Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3, Pgvector.EntityFrameworkCore 0.3.0), Dapper 2.1.89, CommunityToolkit.VectorData.PgVector 1.0.2, PostgreSQL 17 + pgvector 0.8.7 and nomic-embed-text on Ollama.

Guess First

The Bookstore product catalog has 14 products, each with a stock code (SKU, Stock Keeping Unit) such as BKS-20114. A customer asks the assistant "BKS-20114 kodlu kitabın fiyatı nedir?" (what is the price of the book with code BKS-20114?). Vector search over our 53-chunk index, embedded with nomic-embed-text, returns the five nearest chunks. Is the right product, Designing Data-Intensive Applications, among those five?

The answer: no. All five results come from the product catalog and all five contain a BKS- code, but none of them is BKS-20114. The embedding model understands "this is a question about a product code" very well; it does not care which code. In this post we catch what vector search misses with keyword search, merge the two lists with Reciprocal Rank Fusion (RRF), and measure what that actually buys us on the 30-question set used throughout the series.

What Does Vector Search Miss?

An embedding compresses the meaning of a text into a vector. That compression links "the book arrived torn" to "damaged product", something keyword search can never do. But the same compression loses rare, arbitrary strings: stock codes, ISBNs (International Standard Book Number), order numbers, proper names, version numbers. BKS-20114 and BKS-20107 sit at almost the same point in embedding space, because to the model both are "something that looks like a product code".

The real output of the first attempt:

$ dotnet run -- query "BKS-20114 kodlu kitabın fiyatı nedir?"
-- vector
1. urun-katalogu#9                0,7095  Kodlamayı Öğreniyorum: Çocuklar için Scratch (BKS-30042)
2. urun-katalogu#6                0,7021  Clean Code (BKS-20107)
3. urun-katalogu#10               0,6962  Dünya Atlası - Okul Baskısı (BKS-30077)
4. urun-katalogu#12               0,6938  Kitap Ayracı Seti - 6'lı (BKS-40011)
5. urun-katalogu#4                0,6922  Simyacı (BKS-10610)

Look at the similarity scores: only 0.017 separates 0.7095 from 0.6922. The model cannot tell the products apart; they are all in the same bag. The right chunk (urun-katalogu#7) is sixth. If you have followed the series, you recognise this table: it is the textbook case of the failure class we called "retrieval missed" in the first post.

PostgreSQL Full-Text Search: tsvector and the turkish Config

You don't need a separate search engine for the keyword side; PostgreSQL already has full-text search. The idea: convert each chunk's text once to a tsvector (normalised word stems and positions), store it, index it with GIN (Generalized Inverted Index), and match it against the query converted to a tsquery. The Npgsql provider for Entity Framework Core (EF Core) brings this into model configuration:

public sealed class DocChunk
{
    public required string Id { get; init; }
    public required string DocId { get; init; }
    public required string Title { get; init; }
    public required string Section { get; init; }
    public required string Content { get; init; }
    public required string Acl { get; init; }
    public Vector? Embedding { get; init; }
    public NpgsqlTsVector SearchVector { get; init; } = null!;
}

public sealed class RagDbContext(DbContextOptions<RagDbContext> options) : DbContext(options)
{
    public DbSet<DocChunk> Chunks => Set<DocChunk>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasPostgresExtension("vector");
        modelBuilder.Entity<DocChunk>(b =>
        {
            b.ToTable("doc_chunks");
            b.Property(c => c.Id).HasColumnName("id");
            b.Property(c => c.DocId).HasColumnName("doc_id");
            b.Property(c => c.Title).HasColumnName("title");
            b.Property(c => c.Section).HasColumnName("section");
            b.Property(c => c.Content).HasColumnName("content");
            b.Property(c => c.Acl).HasColumnName("acl");
            b.Property(c => c.Embedding).HasColumnName("embedding").HasColumnType("vector(768)");
            // STORED generated tsvector column with Turkish stemming, plus a GIN index
            b.HasGeneratedTsVectorColumn(c => c.SearchVector, "turkish", c => new { c.Content })
                .HasIndex(c => c.SearchVector)
                .HasMethod("GIN");
            b.Property(c => c.SearchVector).HasColumnName("search_vector");
        });
    }
}

db.Database.GenerateCreateScript() produces this DDL (Data Definition Language) from the model; I added only the last two parts to the existing doc_chunks table with ALTER TABLE:

CREATE TABLE doc_chunks (
    id text NOT NULL,
    ...
    embedding vector(768),
    search_vector tsvector GENERATED ALWAYS AS (to_tsvector('turkish', content)) STORED NOT NULL,
    CONSTRAINT "PK_doc_chunks" PRIMARY KEY (id)
);

CREATE INDEX "IX_doc_chunks_search_vector" ON doc_chunks USING GIN (search_vector);

Because it is a generated column, application code never writes search_vector; when ingestion updates content, PostgreSQL recomputes it. The incremental-update logic from the ingestion post comes for free here.

See Turkish Stemming with Your Own Eyes

The turkish config uses the Snowball stemmer. We want it to reduce "kitap" (book) and "kitabı" (the book, accusative) to the same stem. Here is what it really does:

bookstore_rag=# select w, to_tsvector('turkish', w)
  from unnest(array['iade','iadesi','iadeler','kitabı','kitaplar','kargom','kargosu','kartı','kartımı','BKS-20114']) w;
     w     |    to_tsvector
-----------+--------------------
 iade      | 'ia':1
 iadesi    | 'iades':1
 iadeler   | 'iade':1
 kitabı    | 'kitap':1
 kitaplar  | 'kitap':1
 kargom    | 'kargo':1
 kargosu   | 'kargos':1
 kartı     | 'kar':1
 kartımı   | 'kart':1
 BKS-20114 | '-20114':2 'bks':1
(10 rows)

"kitabı" and "kitaplar" (books) correctly reduce to kitap. But "iade" (return) goes to ia, "iadesi" to iades and "iadeler" to iade: three forms of the same word, three different stems. "kartı" (the card) becomes kar while "kartımı" (my card) becomes kart. The stemmer is rule-based and, with Turkish agglutination, it often cuts too much or too little. If the document says "iade" and the question says "iadesi", they don't match. Note the product code too: the parser splits BKS-20114 into two tokens, bks and -20114; since both the document and the query go through the same parser, matching still works.

Turning a Natural-Language Question into a tsquery

With Npgsql's LINQ (Language Integrated Query) translation, keyword search looks like this:

public async Task<List<Hit>> KeywordAsync(string query, int k, bool matchAll = false)
{
    // websearch_to_tsquery joins words with AND; for a natural-language question we turn it into OR with "or"
    string web = matchAll ? query : ToOrQuery(query);
    return await db.Chunks
        .Where(c => c.SearchVector.Matches(EF.Functions.WebSearchToTsQuery("turkish", web)))
        .OrderByDescending(c => c.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("turkish", web)))
        .Take(k)
        .Select(c => new Hit(c.Id, c.Section,
            c.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("turkish", web))))
        .ToListAsync();
}

public static string ToOrQuery(string query) => string.Join(" or ", query
    .Split(' ', StringSplitOptions.RemoveEmptyEntries)
    .Select(w => w.Trim('"', '-', '?', '!', '.', ','))
    .Where(w => w.Length > 0));

Why ToOrQuery? websearch_to_tsquery and plainto_tsquery join words with AND. The question "Bir kitabı kaç gün içinde iade edebilirim?" (within how many days can I return a book?) becomes 'bir' & 'kitap' & 'kaç' & 'gün' & 'iç' & 'ia' & 'edebilir', and no chunk contains all seven stems. Measuring makes it clear:

$ dotnet run -- eval 5 --and
recall@5 (30 questions, keyword AND): vector 25/30, keyword 3/30, hybrid 25/30

With AND, keyword search puts the right chunk in the top five for only 3 of 30 questions; most questions return nothing at all. Joining the words with or makes websearch_to_tsquery treat them as OR. According to its documentation this function never raises a syntax error on user input; we strip leading - and quotes because they would be read as the NOT and phrase operators. For ranking I use ts_rank, not ts_rank_cd: with the OR query, ts_rank_cd gave the top six chunks of the SKU question the same 0.3 score, while ts_rank separated the right product.

Reciprocal Rank Fusion: Merge Ranks, Not Scores

We now have two lists: one ordered by cosine similarity (in the 0.6–0.75 band), the other by ts_rank (in the 0.01–0.05 band). The two numbers don't measure the same thing and aren't on the same scale. RRF is the simple fix proposed by Cormack, Clarke and Büttcher in their 2009 SIGIR (Special Interest Group on Information Retrieval) paper: throw the scores away entirely and look only at ranks. A document's score is the sum of 1 / (k + rank) over every list it appears in; the paper's constant is k = 60.

// Reciprocal Rank Fusion: only the rank is used, never the raw score
public static List<Hit> Rrf(int k, int top, params List<Hit>[] rankings) =>
    WeightedRrf(k, top, rankings.Select(r => (r, 1.0)).ToArray());

public static List<Hit> WeightedRrf(int k, int top, params (List<Hit> Ranking, double Weight)[] rankings)
{
    Dictionary<string, (Hit Hit, double Score)> fused = [];
    foreach ((List<Hit> ranking, double weight) in rankings)
    {
        for (int rank = 1; rank <= ranking.Count; rank++)
        {
            Hit hit = ranking[rank - 1];
            double add = weight / (k + rank);
            fused[hit.Id] = fused.TryGetValue(hit.Id, out var e) ? (e.Hit, e.Score + add) : (hit, add);
        }
    }
    return fused.Values
        .OrderByDescending(x => x.Score)
        .Take(top)
        .Select(x => x.Hit with { Score = x.Score })
        .ToList();
}

We take 20 candidates from each list and fuse them. For the SKU question:

-- keyword
1. urun-katalogu#7                0,0405  Designing Data-Intensive Applications (BKS-20114)
2. urun-katalogu#13               0,0329  Sesli Kitap: Kürk Mantolu Madonna (BKS-50518)
3. urun-katalogu#12               0,0329  Kitap Ayracı Seti - 6'lı (BKS-40011)
...
-- hybrid (RRF k=60)
1. urun-katalogu#6                0,0318  Clean Code (BKS-20107)
2. urun-katalogu#7                0,0315  Designing Data-Intensive Applications (BKS-20114)
3. urun-katalogu#12               0,0315  Kitap Ayracı Seti - 6'lı (BKS-40011)

Keyword search put the right product first. Hybrid put it second: Clean Code is 2nd in the vector list and 4th in the keyword list, so it edges ahead in total. RRF rewards "reasonably placed in both lists"; something first in one list and sixth in the other may not win. Keep that in mind; we'll come back to it in the measurement section.

The Same Thing in a Single SQL Query

Instead of two round trips you can let the database do the fusion. With the plain SQL (Structured Query Language) approach from the Dapper series and two CTEs (Common Table Expression):

WITH semantic AS (
    SELECT id, ROW_NUMBER() OVER () AS rank
    FROM (SELECT id FROM doc_chunks ORDER BY embedding <=> @Embedding LIMIT @Candidates) v
),
keyword AS (
    SELECT id, ROW_NUMBER() OVER () AS rank
    FROM (SELECT c.id FROM doc_chunks c, websearch_to_tsquery('turkish', @Query) q
          WHERE c.search_vector @@ q
          ORDER BY ts_rank(c.search_vector, q) DESC
          LIMIT @Candidates) t
)
SELECT COALESCE(s.id, k.id) AS id, d.section,
       s.rank::int AS vectorrank, k.rank::int AS keywordrank,
       COALESCE(1.0 / (@K + s.rank), 0.0) + COALESCE(1.0 / (@K + k.rank), 0.0)::float8 AS score
FROM semantic s
FULL OUTER JOIN keyword k ON s.id = k.id
JOIN doc_chunks d ON d.id = COALESCE(s.id, k.id)
ORDER BY score DESC
LIMIT @Top
$ dotnet run -- sql "BKS-20114 kodlu kitabın fiyatı nedir?"
urun-katalogu#6              vector=2   keyword=4   0,0318
urun-katalogu#7              vector=6   keyword=1   0,0315
urun-katalogu#12             vector=4   keyword=3   0,0315
urun-katalogu#13             vector=9   keyword=2   0,0306
urun-katalogu#9              vector=1   keyword=14  0,0299

Exactly the same order as the C# version; this time you also see where each row came from in each list. The FULL OUTER JOIN is essential: a chunk that appears in only one list must still make it into the result. The hybrid example in pgvector's own README uses the same structure.

Hybrid Search with Microsoft.Extensions.VectorData

We leave the Microsoft.Extensions.VectorData abstraction itself to post 09 of the upcoming AI (Artificial Intelligence) with .NET series; here we only use its hybrid side. Providers that support hybrid search implement IKeywordHybridSearchable<TRecord>. The PostgreSQL provider now lives in the CommunityToolkit.VectorData.PgVector package; to set the full-text language per property you need a definition instead of attributes, because the default language is english:

static readonly VectorStoreCollectionDefinition Definition = new()
{
    Properties =
    [
        new VectorStoreKeyProperty(nameof(ChunkRecord.Id), typeof(string)) { StorageName = "id" },
        new VectorStoreDataProperty(nameof(ChunkRecord.Section), typeof(string)) { StorageName = "section" },
        new VectorStoreDataProperty(nameof(ChunkRecord.Content), typeof(string)) { StorageName = "content", IsFullTextIndexed = true }
            .WithFullTextSearchLanguage("turkish"),
        new VectorStoreVectorProperty(nameof(ChunkRecord.Embedding), typeof(ReadOnlyMemory<float>), 768)
            { StorageName = "embedding", DistanceFunction = DistanceFunction.CosineDistance },
    ]
};

using PostgresCollection<string, ChunkRecord> collection =
    new(dataSource, "doc_chunks", ownsDataSource: false, new PostgresCollectionOptions { Definition = Definition });

await foreach (VectorSearchResult<ChunkRecord> r in collection.HybridSearchAsync(vector, keywords, top: 5))
{
    Console.WriteLine($"{r.Record.Id,-28} {r.Score:F4}  {r.Record.Section}");
}

Passing every word of the question as keywords produced exactly the pure vector order (scores 1/61, 1/62…). I turned on PostgreSQL statement logging and looked at the generated SQL:

keyword_search AS (
    SELECT "id", RANK () OVER (ORDER BY ts_rank_cd(to_tsvector('turkish', "content"), query) DESC) AS rank
    FROM "doc_chunks", plainto_tsquery('turkish', $1) query
    WHERE to_tsvector('turkish', "content") @@ query
    ...
DETAIL:  Parameters: $1 = 'BKS-20114 kodlu kitabın fiyatı nedir', ..., $3 = '60'

The provider joins the keywords with spaces and hands them to plainto_tsquery, i.e. AND. With the whole question, no chunk matches and the keyword side stays empty. What the API (Application Programming Interface) really expects is "keywords": passing only ["BKS-20114"] puts the right product first with 0.0315. The query also computes to_tsvector('turkish', "content") on the fly for every row; it does not use our generated column. On a large table, unless you create an expression index matching that exact expression (USING GIN (to_tsvector('turkish', content))), every query re-parses all the text. The abstraction makes life easier, but you need to know what runs underneath.

The SQL Server Side: EF Core 11, FreeTextTable and FullJoin

On SQL Server the same idea combines a full-text index with vector search. EF Core 11 (with .NET 11, GA (General Availability) on 10 November 2026) translates the FreeTextTable/ContainsTable table-valued functions and .NET 11's new FullJoin LINQ operator; the hybrid example on Microsoft Learn combines both with VectorSearch() in a single query. Since the .NET 11 SDK (Software Development Kit) isn't GA yet, I didn't compile or run that code. One warning: the Learn example (as of November 2026) plugs FREETEXTTABLE's RANK value and the vector Distance into the RRF formula instead of rank positions. RANK is a relevance value from 0 to 1000 where higher means more relevant; 1.0 / (k + RANK) rewards the most relevant row the least. If you copy the example, convert both sides to positions with ROW_NUMBER() first.

Measure: Vector, Keyword, Hybrid

In the series' 30-question set every question has an expected document and section. A query counts as a hit when the expected chunk is in the top k results. I ran the same questions with each method (RRF k=60, 20 candidates per list):

Methodrecall@1recall@3recall@5recall@10
Vector only16/3024/3025/3028/30
Keyword only (OR)17/3025/3026/3028/30
Hybrid (RRF)15/3024/3026/3028/30
Hybrid (RRF, keyword weight 2)19/3024/3026/3028/30
Score-based "RRF" (1/(k+distance) + 1/(k+ts_rank))12/3021/3022/3026/30

The marketing slide's "hybrid always wins" isn't in this table. Equal-weight RRF ties keyword at recall@5 and is worse than both at recall@1. More interesting is where each method loses. Vector misses q04, q08, q10, q11 and q26 (the SKU question); keyword misses q05, q06, q11 and q30. The overlap is only q11 ("Almanya'ya kitap gönderiyor musunuz?", do you ship books to Germany?: the document never says "Germany", it has an "international shipping" section). The two methods fail in different places, and that is exactly where hybrid earns its keep: in q05 ("the book arrived torn") keyword can't match "yırtık" (torn) to "hasarlı" (damaged), vector can; in q26 it's the other way round. But in q30 ("a book that teaches my child coding") vector puts the right product first, and noise from the keyword list pushes it to fourth in hybrid. The cause is the stemmer again: the question reduces "kodlama" (coding) to kodla, the product title reduces "Kodlamayı" to kodlama.

Giving the keyword list a weight of 2 lifts recall@1 to 19/30, the best result on this set. Tuning a weight on 30 questions invites overfitting; don't take it to production without validating on your own real query logs. I tried the k constant at 1, 10, 60 and 200; recall@5 moved by at most one question (26–27), so at this size k matters little. The score-based fake RRF is clearly the worst: distance and ts_rank are tiny next to 60, so noise decides the order, and a high ts_rank is actually penalised.

Production Notes

Separate the candidate count from the final top-k. Here we took 20 candidates per list and cut to 5; more candidates raise recall but also enlarge the reranker's job in the next post. If you want to run both searches in parallel, don't share one DbContext across two threads; open two contexts or do the fusion in a single SQL query.

Think about the language config per document. The English titles in our catalog come out of the turkish stemmer as odd stems like designingi and katalok. A common fix for codes and proper names is a second tsvector column with the simple config, adding both keyword lists to RRF. RRF happily fuses as many lists as you like.

Recognise the query type. If the question contains a code such as BKS-\d{5}, do an exact lookup first; if it hits, put that chunk on top directly. Treat hybrid search as a sensible default, not a black box that solves everything.

Finally, PostgreSQL's built-in ts_rank is not BM25 (Best Matching 25); it doesn't account for document length and term rarity as well as BM25 does. If keyword quality isn't enough on a large, heterogeneous corpus, you may need an extension that offers BM25 or an engine like Elasticsearch; this series goes into ANN (Approximate Nearest Neighbor) index detail, not that side.

Common Pitfalls

Using raw scores instead of ranks in RRF. The last row of the table shows the cost: 22/30. The formula is called "rank fusion"; the moment a distance, cosine similarity or relevance score goes into it, it is no longer RRF.

Feeding a natural-language question straight into an AND query. plainto_tsquery, websearch_to_tsquery and the keyword side of HybridSearchAsync are AND by default; a long question matches almost nothing and hybrid silently degrades to vector-only. There is no error, only worse results.

Trusting Turkish stemming blindly. "iade" and "iadesi" reduce to different stems. Test both configs for critical terms (iade, kargo, fatura: return, shipping, invoice) and keep hybrid so vector can catch what keyword misses.

Mixing package versions. In my first attempt I used the old Microsoft.SemanticKernel.Connectors.PgVector 1.74.0-preview package with Microsoft.Extensions.VectorData.Abstractions 10.11.0; it compiled, then HybridSearchAsync threw MissingMethodException: Method not found: '... HybridSearchOptions`1.get_OldFilter()' at run time. Replacing the provider with one compatible with the abstraction version, CommunityToolkit.VectorData.PgVector, fixed it.

Combining ts_rank_cd with an OR query and not noticing tied scores. The order of tied rows is undefined, and RRF uses that undefined order as if it were information.

A Small Experiment

★☆☆ Add a second generated tsvector column with the simple config and compare to_tsvector('simple', 'iade') with to_tsvector('simple', 'iadesi'). Where does q30 land in the keyword list with this column?

★★☆ Build three-list RRF: vector, turkish keyword and simple keyword. Compare recall@1 and recall@5 on the 30-question set with the two-list version.

★★☆ Add an "exact match" step that captures a BKS-\d{5} code in the question with a regex and does a direct lookup first. Does the right product, second in hybrid for q26, move to first, and are other questions affected?

★★★ Pass HybridSearchAsync keywords extracted by an LLM (Large Language Model) instead of the question (ask gemma3:4b for a string[] with structured output). Compare its recall@5 on the 30 questions with our ToOrQuery approach, and note the latency of the extra LLM call.

Hybrid search enriched the candidate list, but ordering is still decided by two coarse signals: embedding similarity and word overlap. The right product dropping to fourth in q30 is the price. In the next post we talk about retrieving wide and returning narrow: passing 20–50 candidates through a reranker that reads the question and the chunk together, rewriting follow-ups like "and the return?" in a conversation, and searching with a hypothetical answer using HyDE (Hypothetical Document Embeddings).