FlowKit

Hybrid search for RAG in n8n: combining keywords (BM25) and vectors

Published 27 July 2026 · 7 min read

A RAG assistant plugged into Supabase answers "how do I cancel my subscription?" correctly but draws a blank on "what does error E-4012 mean?" — even though the page documenting that exact code sits in the knowledge base. The culprit is neither the LLM nor the document chunking: it's vector search itself, which has no idea what "E-4012" represents and surfaces chunks vaguely related to errors in general. Hybrid search fixes this blind spot by combining two complementary engines — a keyword search (BM25-style) and a vector search — whose results are merged into a single list. Here is how to set it up in an n8n RAG pipeline with Supabase/Postgres, without adding a single external service.

Why pure vector search misses exact queries

An embedding model encodes the meaning of a text, not its characters. That's its strength for paraphrases — and its weakness for anything with no linguistic meaning: product references (REF-2024-118), error codes (E-4012), legal article numbers, rare proper nouns, internal jargon. Those tokens are nearly absent from the embedding model's training data, and their vectors carry almost no discriminative information. The result: the chunk containing exactly E-4012 has no particular reason to rank above ten chunks discussing errors in general terms.

This isn't an implementation anecdote — it's a documented limitation. The founding paper of modern dense retrieval, "Dense Passage Retrieval for Open-Domain Question Answering" by Karpukhin et al. (EMNLP 2020 — see it on Google Scholar), shows that dense retrieval beats BM25 on average on question-answering benchmarks, while observing that the two approaches remain complementary: the authors note cases where BM25 keeps the upper hand, precisely when the query hinges on exact overlap of salient terms — and find that combining both signals improves results further still.

And why pure full-text misses paraphrases

The reverse problem is just as real. A lexical engine like BM25 scores a document based on the exact words it shares with the query, weighted by their rarity in the corpus and the document's length — the theoretical framework is laid out in "The Probabilistic Relevance Framework: BM25 and Beyond" by Robertson and Zaragoza (Foundations and Trends in Information Retrieval, 2009 — see it on Google Scholar). It's formidably effective on exact terms, but blind to synonyms: "I can't log in anymore" shares no meaningful word with a chunk titled "authentication failure", and a user who says "invoice" will never find the document that only says "credit note".

A production RAG receives both kinds of queries, often within the same day. Hence the simple idea behind hybrid search: run both searches in parallel and merge the results, so that each engine covers the other's blind spot.

Merging the two lists with Reciprocal Rank Fusion (RRF)

The classic trap when merging: the two engines' scores are not comparable. A cosine similarity between 0 and 1 and an unbounded lexical score don't add up cleanly. Reciprocal Rank Fusion sidesteps the problem by ignoring scores entirely and keeping only the ranks: each document receives, for every list it appears in, a score of 1 / (k + rank), and the scores are summed. The constant k (typically 50 or 60) dampens the gap between the very top positions.

Example with k = 50: a chunk ranked 3rd in full-text and 7th in vector search gets 1/53 + 1/57 ≈ 0.0364. A chunk ranked 1st in vector search but absent from full-text gets 1/51 ≈ 0.0196. The first one wins: a document judged relevant by both engines beats a document championed by only one. That's exactly the behavior you want, obtained with a one-line formula requiring no normalization and no training.

Implementation with Supabase/Postgres in n8n

The advantage of the Supabase + pgvector stack: Postgres already knows how to do everything. Full-text search is native (tsvector), vector search comes from pgvector, and a single SQL function can run both queries and fuse them with RRF — leaving n8n with a single call to make.

1. Add the full-text column and its index

On the documents table that already holds your chunks and their embeddings, add an automatically generated tsvector column and a GIN index:

alter table documents
  add column fts tsvector
  generated always as (to_tsvector('english', content)) stored;

create index documents_fts_idx on documents using gin (fts);

The 'english' configuration applies English stemming ("connect", "connecting", and "connected" all match). Since the column is generated, every newly ingested chunk gets indexed without touching the ingestion workflow. Postgres does not implement BM25 in the strict sense — its ts_rank_cd is a simpler cousin — but the lexical principle is identical and is plenty for a RAG's keyword stage. On the vector side, the HNSW or IVFFlat index on the embedding column remains the one described in our pgvector guide.

2. The SQL function that queries and fuses

A single RPC function runs both searches and applies RRF:

create or replace function hybrid_search(
  query_text text,
  query_embedding vector(1536),
  match_count int default 10,
  rrf_k int default 50
)
returns setof documents
language sql
as $$
with full_text as (
  select id, row_number() over (
    order by ts_rank_cd(fts, websearch_to_tsquery('english', query_text)) desc
  ) as rank_ix
  from documents
  where fts @@ websearch_to_tsquery('english', query_text)
  limit match_count * 2
),
semantic as (
  select id, row_number() over (
    order by embedding <=> query_embedding
  ) as rank_ix
  from documents
  limit match_count * 2
)
select d.*
from full_text
full outer join semantic on full_text.id = semantic.id
join documents d on d.id = coalesce(full_text.id, semantic.id)
order by
  coalesce(1.0 / (rrf_k + full_text.rank_ix), 0.0) +
  coalesce(1.0 / (rrf_k + semantic.rank_ix), 0.0)
  desc
limit match_count;
$$;

The full outer join is the detail that matters: a document present in only one of the two lists remains a candidate (its score from the other list is simply 0), while a document present in both accumulates both scores and mechanically climbs the ranking.

3. Calling it from n8n

In the question-answering workflow, the sequence replaces the usual match_documents call:

  1. Generate the question's embedding — with the same model used at ingestion time, via the Embeddings node or an HTTP Request to the embeddings API.
  2. Call the hybrid function — two equivalent options:
    • HTTP Request node pointed at the RPC endpoint auto-generated by Supabase: POST https://<project>.supabase.co/rest/v1/rpc/hybrid_search, with the apikey and Authorization: Bearer <key> headers (see our n8n-to-Supabase connection guide) and a JSON body {"query_text": "...", "query_embedding": [...], "match_count": 20}.
    • Postgres node with a direct connection: select id, content, metadata from hybrid_search($1, $2, 20); — useful if your n8n instance already has Postgres access and you'd rather skip the REST layer.
  3. Build the prompt — inject the returned passages along with their source metadata, as in the RAG question-answering API workflow.

n8n's native Supabase node covers table operations (select, insert, update) but not arbitrary function calls: for a custom RPC, the HTTP Request to /rest/v1/rpc/ or the Postgres node are the two routes to remember.

When hybrid search is worth the cost

The overhead is modest — one column, one index, one function — but it isn't zero: it's SQL to maintain and one more parameter to pass from n8n. The gain is clear-cut in the following cases:

  • Technical knowledge bases: product documentation with error codes, part references, version numbers, support tickets. Exact-term queries are frequent there, and vector search alone fails visibly.
  • Catalogs: product search where users type both "lightweight running shoe" (semantic) and "SKU 8842-B" (exact).
  • Legal or regulatory corpora: article numbers, standard references, official titles.
  • Agent memory: an AI agent's long-term memory benefits from the same pattern, since an agent needs to retrieve both "what the user said about their preferences" (semantic) and the exact mention of a project name.

Conversely, on a small, homogeneous, editorial corpus (an FAQ, documentation with no technical identifiers), where queries are natural-language questions, vector search alone already surfaces the right passages: the lexical stage would add next to nothing. Start simple, and add hybrid search once your logs show failures on exact terms.

Where does reranking fit in?

Hybrid search and reranking don't operate at the same stage. Hybrid improves the recall of the first stage: it raises the odds that the right passage lands in the candidate list, whatever the query's style. Reranking improves the precision of the second stage: it finely re-orders those candidates with a cross-encoder that reads the question and each passage together. The two stack naturally: hybrid search to pull 20 to 30 candidates, reranking to keep only the best 5 before the prompt. On a large technical corpus, it's this combination — not either one in isolation — that yields the most reliable answers.

Going further

The RAG Assistant Pack ($119) ships the ingestion, chatbot-with-citations, and question-answering API workflows built on Supabase pgvector: the hybrid_search function above slots in as a drop-in replacement for the bundled match_documents call, without touching the rest of the pipeline. If your assistant consistently fails on product references or error codes even though the documents exist, this is the first fix to try — before switching embedding models or reworking your entire chunking strategy. And if your document base is made of contracts and reports rather than web pages, our guide to RAG over PDFs with n8n covers the extraction and OCR stage that comes before this whole pipeline.

FAQ

Frequently asked questions

Does hybrid search replace reranking?

No, they complement each other. Hybrid search improves recall at the first stage: it increases the odds that the right passage makes it into the candidate list, especially for queries containing exact terms (product references, error codes). Reranking then kicks in to finely re-order those candidates. A complete pipeline chains hybrid search (20-30 candidates) followed by reranking (top 5).

Do you need a dedicated engine like Elasticsearch to do BM25 in an n8n RAG?

No. For a typical RAG, Postgres's native full-text search (a tsvector column plus a GIN index) is more than enough as the lexical stage. Its ts_rank isn't strictly BM25, but it relies on the same keyword-matching principles. The advantage is decisive: everything stays in the same Supabase/Postgres database as pgvector, and a single SQL function runs both queries and the fusion.

What value should k take in Reciprocal Rank Fusion?

The historical value is k = 60, and Supabase examples often use 50. In practice, results are not very sensitive to this parameter as long as it stays in that range: a higher k smooths out differences between ranks, a lower k gives more weight to the very top results of each list. Start at 50-60 and only tune it if you measure an actual bias.

When is vector search alone enough?

When the document base is small, homogeneous, and user questions are natural-language paraphrases with no exact terms to match (a product FAQ, editorial documentation). As soon as queries contain product references, error codes, rare proper nouns, or very specific business jargon, the lexical stage of hybrid search pays for itself.

Bundle FlowKit Complet

€269