SereneDB

Hybrid search: keyword precision and vector recall in one SQL query

SereneDB runs hybrid search in one SQL statement: BM25 keyword search and vector similarity over one inverted index on one table. Filter with one signal and rank with the other, or fuse both rankings with Reciprocal Rank Fusion, from psql or any PostgreSQL-compatible client.

curl https://install.serenedb.com | sh
one query, two signalsbm25annfused
An animation of two hybrid queries over a table of eight support articles. For card declined, BM25 finds one article, Card declined at checkout, and the vector ranking puts Payment fails after the bank page first, Card declined at checkout second and Refund a captured payment third. Reciprocal Rank Fusion with k = 60 ranks Card declined at checkout first at 0.0325, because both lists hold it, then Payment fails after the bank page at 0.0164 and Refund a captured payment at 0.0159, which only the vector list holds. For the phrase PAY-402, BM25 finds Card declined at checkout and Error code reference with the same score, so both share BM25 rank 1, and the vector ranking puts Error code reference, Session expired during checkout and Retry policy for payment webhooks first. Fusion ranks Error code reference first at 0.0328 (rank 1 in both lists), Card declined at checkout second at 0.0318 (BM25 rank 1, vector rank 5) and Session expired during checkout third at 0.0161.

How it works

How hybrid search works in SereneDB: one index, two query paths

One CREATE INDEX … USING inverted lists the text columns with a text search dictionary for BM25, a verbatim column for exact filters and the embedding column with ivf or hnsw for nearest-neighbor search. Both query paths read that one index, and the fused result is an ordinary SQL row set you can join, filter or aggregate. Hover a stage, or a column inside the index, to find its line.

01 · table
articles
title · body topic emb FLOAT[3]
02 · dictionary
kb_en
split_text | normalize_tokens
02 · embeddings
ai_embed()
or vectors you load
03 · inverted index
articles_idx
title kb_enbm25
body kb_enbm25
topicverbatim
emb ivf (metric = 'l2')ann
one index over one table
04 · strategy 1
Filtered ANN
WHERE body @@ … ORDER BY emb <-> …
05 · strategy 2
RRF
two ranked branches Σ 1/(k + rank)
06 · result
Fused top-k
one row set: JOIN, GROUP BY
> stage 01 · table

Text, topic and embedding in one row

The text, a topic and the embedding are columns of the same row, so one INSERT makes an article searchable both ways, and no sync job copies it into a second store. A vector column is a fixed-size FLOAT[N]; these examples use three dimensions so the distances stay readable.

sql
CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title VARCHAR,
body VARCHAR,
topic VARCHAR,
emb FLOAT[3]
);
> stage 02 · dictionary

The dictionary decides how text becomes terms

In this text search dictionary split_text splits the text into lower-cased words and normalize_tokens folds accent marks away. WITH lists what the index records: frequency gives BM25 its term statistics, and position lets ts_phrase('PAY-402') match pay followed by 402. The same dictionary analyzes the query, so the index and the query agree on every term.

sql
CREATE TEXT SEARCH DICTIONARY kb_en AS
split_text(case := 'lower') | normalize_tokens('en_US.UTF-8', accent := false)
WITH (frequency, position);
SELECT ts_lexize('kb_en', 'The API returned PAY-402.');
-- {the,api,returned,pay,402}
> stage 02 · embeddings

Vectors from a model, at write time or at query time

Load vectors you computed elsewhere, or call ai_embed() from SQL. It reaches any OpenAI-compatible endpoint named in a secret, such as OpenAI, Google Gemini or a local Ollama server. Cast its result to FLOAT[N] to store it, or embed the search text inline in ORDER BY.

sql
CREATE SECRET local_ai (TYPE openai, base_url 'http://localhost:11434');
CREATE TABLE kb (id INTEGER PRIMARY KEY, body VARCHAR, emb FLOAT[384]);
CREATE INDEX kb_idx ON kb USING inverted (id, body kb_en, emb ivf (metric = 'l2'));
-- write time: the model fills the vector column
INSERT INTO kb
SELECT id, body, ai_embed(body, 'all-minilm', 'local_ai')::FLOAT[384]
FROM articles;
VACUUM (REFRESH_TABLE) kb;
-- query time: the search text is embedded inline
SELECT id FROM kb_idx
ORDER BY emb <-> ai_embed('card declined', 'all-minilm', 'local_ai')::FLOAT[384]
LIMIT 3;
> stage 03 · inverted index

Keyword, filter and vector columns in one index

The inverted index holds the analyzed title and body for BM25, the verbatim topic for exact filters and the vector for nearest-neighbor search. ivf partitions the vectors into clusters and hnsw links them into a graph; both take metric l2, cosine, ip or l1. With hnsw in place of ivf, the walkthrough below returns the same rows.

sql · hover a line
CREATE INDEX articles_idx ON articles
USING inverted (
id, -- key
title kb_en, -- bm25
body kb_en, -- bm25
topic, -- verbatim
emb ivf (metric = 'l2') -- ann
);
> strategy 1 · filtered ann

One signal filters, the other ranks

A full-text or verbatim predicate in WHERE limits the candidates, and the distance in ORDER BY orders the survivors. EXPLAIN shows one IRESEARCH_SCAN with the phrase as its index filter and the L2 distance as its score. A kNN query returns k rows whenever at least k rows pass the filter, and with hnsw the filter is applied inside the graph walk, not after it.

sql
SELECT id, title FROM articles_idx
WHERE body @@ ts_phrase('PAY-402')
ORDER BY emb <-> [0.40, 0.35, 0.25]::FLOAT[3], id
LIMIT 3;
-- 3 Error code reference
-- 1 Card declined at checkout
> strategy 2 · rrf

Both signals rank, and positions are fused

Each branch ranks on its own scale and keeps its top 100, the window. RANK() gives rows that tie on score the same rank, so neither wins by row order. The outer query sums 1/(60 + rank) per row, so BM25 scores and distances never need calibrating, and a row both branches find rises. k = 60 is the published default; Reciprocal Rank Fusion covers k and the window size.

sql
WITH fused AS (
SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (
SELECT id, BM25(articles_idx.tableoid) AS s FROM articles_idx
WHERE body @@ plainto_tsquery('card declined')
ORDER BY s DESC LIMIT 100) lex
UNION ALL
SELECT id, RANK() OVER (ORDER BY dist) AS rank FROM (
SELECT id, emb <-> [1.0, 0.0, 0.0]::FLOAT[3] AS dist FROM articles_idx
ORDER BY dist LIMIT 100) vec
)
SELECT a.id, a.title, round(SUM(1.0 / (60 + f.rank)), 4) AS rrf
FROM fused f JOIN articles a ON a.id = f.id
GROUP BY a.id, a.title
ORDER BY rrf DESC, a.id
LIMIT 4;
-- 1 Card declined at checkout 0.0325
-- 2 Payment fails after the bank page 0.0164
-- 4 Refund a captured payment 0.0159
-- 8 Retry policy for payment webhooks 0.0156
> stage 06 · result

One row set, ready to join or count

Fusion ends in plain SQL, so the next step can be a JOIN to the source table or a GROUP BY over the hits: here, the topics the five best matches for card declined fall under.

sql
WITH fused AS (
SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (
SELECT id, BM25(articles_idx.tableoid) AS s FROM articles_idx
WHERE body @@ plainto_tsquery('card declined')
ORDER BY s DESC LIMIT 100) lex
UNION ALL
SELECT id, RANK() OVER (ORDER BY dist) AS rank FROM (
SELECT id, emb <-> [1.0, 0.0, 0.0]::FLOAT[3] AS dist FROM articles_idx
ORDER BY dist LIMIT 100) vec
), top5 AS (
SELECT id, SUM(1.0 / (60 + rank)) AS rrf
FROM fused GROUP BY id ORDER BY rrf DESC, id LIMIT 5
)
SELECT a.topic, count(*) AS hits
FROM top5 JOIN articles a USING (id)
GROUP BY a.topic
ORDER BY hits DESC, a.topic;
-- payments 4
-- accounts 1
A diagram in six stages. The articles table feeds the kb_en text search dictionary for its title and body columns, and its embeddings come from ai_embed or from vectors you load. One inverted index, articles_idx, holds title and body for BM25, topic as a verbatim filter and emb as an ivf vector column for nearest-neighbor search. Two strategies read that index: filtered ANN, a WHERE predicate with ORDER BY distance, and Reciprocal Rank Fusion, two ranked branches summed as 1/(k + rank). Both end in one row set that can be joined or grouped.

Four ways to combine the two lists, all in SQL

not a config key
rrf
Reciprocal Rank Fusion

Ranks, not scores. Choose it when agreement between the signals should win. k = 60 is the published default and langchain-serenedb's rrf_k default.

Σ 1 / (k + rank)
normalized
Normalized scores

Min–max each branch to 0–1, then add them with weights. Choose it when a decisive win in one branch should count; one outlier rescales its branch.

(s − min) / (max − min)
weighted sum
Weighted sum

α·s₁ + β·s₂ on the raw scores. Choose it only when both branches share a scale: BM25 magnitudes and raw distances usually do not.

α·s₁ + β·s₂
filtered ann
Filter, then rank

A predicate in WHERE, distance in ORDER BY. Choose it when the filter is a hard requirement: a category, a tenant, a permission.

WHERE … ORDER BY <->

Build it

Hybrid search in SQL, in five statements

From an empty database to fused results, over psql or any PostgreSQL-compatible client. Every statement and result below ran on SereneDB 26.11.2 on 2026-11-03.

step 01 / 05 · sql
CREATE TEXT SEARCH DICTIONARY kb_en AS
    split_text(case := 'lower') | normalize_tokens('en_US.UTF-8', accent := false)
    WITH (frequency, position);

CREATE TABLE articles (
    id INTEGER PRIMARY KEY,
    title VARCHAR,
    body VARCHAR,
    topic VARCHAR,
    emb FLOAT[3]
);
frequency gives BM25 its statistics; position makes phrase queries such as ts_phrase('PAY-402') possible.
step 02 / 05 · sql
CREATE INDEX articles_idx ON articles
    USING inverted (id, title kb_en, body kb_en, topic, emb ivf (metric = 'l2'));
Swap ivf for hnsw (metric = 'l2') and steps 4 and 5 return the same rows.
step 03 / 05 · sql
INSERT INTO articles VALUES
(1, 'Card declined at checkout', 'The issuer declined the card and the API returned PAY-402.', 'payments', [0.90, 0.10, 0.00]::FLOAT[3]),
(2, 'Payment fails after the bank page', 'The customer finishes 3-D Secure, comes back to the store, and the order stays unpaid.', 'payments', [0.95, 0.00, 0.05]::FLOAT[3]),
(3, 'Error code reference', 'PAY-402 issuer refusal. AUTH-401 expired session. DEP-503 deploy timeout.', 'reference', [0.30, 0.30, 0.40]::FLOAT[3]),
(4, 'Refund a captured payment', 'Refunds go back to the original card within five business days.', 'payments', [0.80, 0.00, 0.20]::FLOAT[3]),
(5, 'Reset a forgotten password', 'Send a reset link; it expires after one hour.', 'accounts', [0.00, 1.00, 0.00]::FLOAT[3]),
(6, 'Session expired during checkout', 'AUTH-401 appears when the login session times out before payment.', 'accounts', [0.50, 0.50, 0.00]::FLOAT[3]),
(7, 'Roll back a failed deploy', 'DEP-503 means the health check timed out; roll back to the last release.', 'platform', [0.00, 0.00, 1.00]::FLOAT[3]),
(8, 'Retry policy for payment webhooks', 'Webhooks retry with backoff for 24 hours.', 'payments', [0.70, 0.00, 0.30]::FLOAT[3]);

VACUUM (REFRESH_TABLE) articles;
INSERT 0 8. The index is eventually consistent: VACUUM (REFRESH_TABLE) publishes the rows at once instead of waiting for the background refresh (refresh_interval, 1000 ms). With a model, ai_embed() fills emb.
step 04 / 05 · sql
SELECT id, title FROM articles_idx
WHERE body @@ ts_phrase('PAY-402')
ORDER BY emb <-> [0.40, 0.35, 0.25]::FLOAT[3], id
LIMIT 3;
Error code reference, then Card declined at checkout. Vector search alone ranks Card declined at checkout fifth.
step 05 / 05 · sql
WITH fused AS (
  SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (
    SELECT id, BM25(articles_idx.tableoid) AS s FROM articles_idx
    WHERE body @@ plainto_tsquery('card declined')
    ORDER BY s DESC LIMIT 100) lex
  UNION ALL
  SELECT id, RANK() OVER (ORDER BY dist) AS rank FROM (
    SELECT id, emb <-> [1.0, 0.0, 0.0]::FLOAT[3] AS dist FROM articles_idx
    ORDER BY dist LIMIT 100) vec
)
SELECT a.id, a.title, round(SUM(1.0 / (60 + f.rank)), 4) AS rrf
FROM fused f JOIN articles a ON a.id = f.id
GROUP BY a.id, a.title
ORDER BY rrf DESC, a.id
LIMIT 4;
Card declined at checkout first (0.0325, in both lists), then Payment fails after the bank page (0.0164), which shares no word with the query: the vector branch ranks it first, and keyword search never sees it.

Use cases

What teams build with hybrid search

Product search with hard filters

A shopper wants waterproof shoes. The category and the required word are filters, and vector closeness orders what is left, so a boot that passes both filters can rank below the closest shoe while a closer racer without the word stays out.

indexed columns
name kb_enbrandcategoryemb ivf
Vector search →
example · filtered ann
waterproof shoes, closest to a trail runner first
SELECT id, name, brand FROM products_idx
WHERE category @@ 'shoes' AND name @@ 'waterproof'
ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3], id
LIMIT 3;
result · 5 sample products
idnamebrand
1Waterproof trail running shoeNorthpeak
3Waterproof hiking bootNorthpeak
The jacket fails the category and the road racer the word. By distance alone, Lightweight road racer would rank second.

Retrieval for LLM answers, per tenant

Chunks carry their text, an embedding and a tenant. The tenant filter sits inside both branches, so fusion ranks only chunks the caller may see, and the result joins back to the source document. langchain-serenedb runs the same fusion behind its LangChain vector store, with HybridSearchConfig choosing RRF, normalized scores or a weighted sum.

indexed columns
tenantbody kb_enemb ivf
RAG database →
example · rrf per tenant
card declined, for tenant acme
WITH fused AS (
  SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (
    SELECT id, BM25(chunks_idx.tableoid) AS s FROM chunks_idx
    WHERE tenant @@ 'acme' AND body @@ 'card declined'
    ORDER BY s DESC LIMIT 50) lex
  UNION ALL
  SELECT id, RANK() OVER (ORDER BY dist) AS rank FROM (
    SELECT id, emb <-> [1.0, 0.0, 0.0]::FLOAT[3] AS dist FROM chunks_idx
    WHERE tenant @@ 'acme'
    ORDER BY dist LIMIT 50) vec
)
SELECT c.id, d.title, round(SUM(1.0 / (60 + f.rank)), 4) AS rrf
FROM fused f JOIN chunks c ON c.id = f.id JOIN docs d ON d.id = c.doc_id
GROUP BY c.id, d.title
ORDER BY rrf DESC, c.id
LIMIT 3;
result · 4 sample chunks, 2 tenants
idtitlerrf
1Billing runbook0.0325
2Billing runbook0.0164
3Webhook guide0.0159
Tenant globex has a chunk about a declined card too. Without the tenant predicate it would lead the keyword branch (BM25 1.375 against 0.502); with it in both branches, it never enters the ranking.

Exact codes, similar problems, and counts

Agents paste an error code or describe the symptom. BM25 finds the code, vectors find the same problem in other words, and the fused hits are counted by topic in the same statement, which shows where the tickets pile up.

indexed columns
title kb_enbody kb_entopicemb ivf
Real-time analytics →
example · search + group by
card declined: which topics?
WITH fused AS (
  SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (
    SELECT id, BM25(articles_idx.tableoid) AS s FROM articles_idx
    WHERE body @@ plainto_tsquery('card declined')
    ORDER BY s DESC LIMIT 100) lex
  UNION ALL
  SELECT id, RANK() OVER (ORDER BY dist) AS rank FROM (
    SELECT id, emb <-> [1.0, 0.0, 0.0]::FLOAT[3] AS dist FROM articles_idx
    ORDER BY dist LIMIT 100) vec
), top5 AS (
  SELECT id, SUM(1.0 / (60 + rank)) AS rrf
  FROM fused GROUP BY id ORDER BY rrf DESC, id LIMIT 5
)
SELECT a.topic, count(*) AS hits
FROM top5 JOIN articles a USING (id)
GROUP BY a.topic
ORDER BY hits DESC, a.topic;
result · fused top 5 of the 8 articles
topichits
payments4
accounts1
Search, fusion and GROUP BY in one statement.

FAQ

Frequently asked questions

Go to docs →

Hybrid search runs a keyword search (BM25 over an inverted index) and a vector search (the nearest neighbors of an embedding) on the same data and merges the two into one ranking. Keywords bring precision on exact terms, codes and names; vectors bring recall on meaning. Together they find what either one alone misses.

No. BM25 is the keyword half: it scores documents by the query terms they contain, weighing rare terms more. Hybrid search adds a vector ranking and combines the two. For BM25 on its own, see full-text search.

BM25 matches the term itself, which is what an error code, a SKU or a name needs. A vector ranking orders rows by closeness of meaning, so a row that holds the exact code can sit below rows that only sound similar: in the sample above, vector search ranks the article that returned PAY-402 fifth, and the phrase query finds it. Hybrid search keeps both signals.

The usual method is Reciprocal Rank Fusion: each result scores 1/(k + rank) in every list it appears in, and the sums decide, so BM25 scores and distances never need a common scale. Normalized scores, a weighted sum, or a keyword filter with vector ordering are the alternatives, each one plain SQL. Reciprocal Rank Fusion covers k and the window size.

Yes. In SereneDB the keyword branch, the vector branch and the fusion are one SQL statement, and SereneDB speaks the PostgreSQL wire protocol, so psql or any PostgreSQL-compatible client connects. The five statements above ran as written on SereneDB 26.11.2. Coming from PostgreSQL's own text search? See Postgres full-text search.

No. In SereneDB one inverted index holds the text terms and the vectors of the same table, so there is no second store and no pipeline that keeps two copies in sync. One INSERT makes a row searchable by keyword and by vector. If your vectors live in Qdrant or Pinecone, SereneDB vs Qdrant and SereneDB vs Pinecone map their hybrid queries to SQL.

Yes. Put the predicate in WHERE; in a fused query, put it in both branches so it narrows the candidates before fusion. A kNN query returns k rows whenever at least k rows pass the filter, and with an hnsw index the filter is applied inside the graph walk.

Fused scores only order the results; they are not relevance percentages. When you need a relevance judgment, rerank the fused top candidates with ai_rerank(query, document), which asks a chat model from SQL and returns a score from 0, not relevant, to 1, answers the query exactly.

RAG retrieves passages and hands them to an LLM to write an answer. Hybrid search is a way to do the retrieval step, and it surfaces both the passages that hold the exact terms and the ones that say the same thing in other words; see RAG database.

Yes. ai_embed() calls an embedding model from SQL through any OpenAI-compatible endpoint, such as OpenAI, Google Gemini or a local Ollama server, when you write the row or inline in ORDER BY. Cast the result to FLOAT[N] to store it in a vector column.

Yes. langchain-serenedb is a LangChain vector store backed by SereneDB with hybrid search: HybridSearchConfig picks the fusion strategy (RRF by default, NORMALIZED or WEIGHTED_SUM), the RRF k and the per-branch windows.

> get started

Get started with SereneDB

Start SereneDB with one command, put the text and the embedding in one inverted index, then filter with one signal and rank with the other, or fuse both, in one SQL statement.