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 | shWhat is hybrid search, and what does each half miss?
Hybrid search runs a keyword search and a vector search over the same records and merges the two rankings into one. BM25 catches exact terms, codes and names; vector similarity catches the same meaning in other words; each misses what the other finds. Try two queries on one table of eight support articles, hover a row to find it in the other lists, and change how the rankings are combined. The vectors are three hand-written numbers so the distances stay readable; every score below came from SereneDB 26.11.2.
- 1Card declined at checkout✓ relevant2.228
- 2Card declined at checkout✓ relevant0.141
- 1Payment fails after the bank page✓ relevant0.071
- 3Refund a captured payment—0.283
- 4Retry policy for payment webhooks—0.424
- 1Card declined at checkout✓ relevant0.0325
- 2Payment fails after the bank page✓ relevant0.0164
- 3Refund a captured payment—0.0159
- 4Retry policy for payment webhooks—0.0156
Both lists rank with RANK(), so rows that tie on score share a rank, and each row scores Σ 1/(k + rank) over the lists it is in, here with k = 60. A row near the top of both lists beats a row at the top of one, and BM25 scores and distances never need a common scale.
card declined: Keyword search finds only Card declined at checkout: Payment fails after the bank page shares no word with the query. Vector search ranks that article first. RRF puts both relevant articles on top, 0.0325 and 0.0164.
PAY-402: Vector search ranks Card declined at checkout fifth: these hand-written vectors put the error code near generic text. The phrase finds both articles that contain PAY-402 with the same BM25 score, so both rank 1 in the keyword list, and RRF ranks them first and second, 0.0328 and 0.0318.
The predicate in WHERE is a hard filter, and distance orders what survives. It is the right shape when the filter is a requirement: a category, a tenant, a required term.
card declined: The term card is a hard filter, so Payment fails after the bank page, which never says card, cannot come back however close its vector is. Card declined at checkout and Refund a captured payment remain, nearest first.
PAY-402: The phrase keeps the two articles that contain PAY-402, and distance orders them: Error code reference, then Card declined at checkout.
Each branch is scaled to 0–1 (the nearest vector scores 1), then the two are added with weights w and 1 − w. A decisive win in one branch counts, and one outlier rescales its whole branch.
card declined: On this query the order holds at every weight: Card declined at checkout leads up to w = 0.95, where it ties Payment fails after the bank page at 0.950.
PAY-402: From w = 0.75, Session expired during checkout outranks Card declined at checkout, and at w = 0.95 Card declined at checkout drops out of the top 4: the vector signal alone does not know the code.
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;
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 @@ ts_phrase('PAY-402') ORDER BY s DESC LIMIT 100) lex UNION ALL SELECT id, RANK() OVER (ORDER BY dist) AS rank FROM ( SELECT id, emb <-> [0.40, 0.35, 0.25]::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;
SELECT id, title FROM articles_idx WHERE body @@ 'card' ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3], id LIMIT 4;
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 4;
WITH hits AS ( SELECT 'lex' AS branch, id, s FROM ( SELECT id, BM25(articles_idx.tableoid) AS s FROM articles_idx WHERE body @@ plainto_tsquery('card declined') ORDER BY s DESC LIMIT 100) l UNION ALL SELECT 'vec' AS branch, id, -dist AS s FROM ( SELECT id, emb <-> [1.0, 0.0, 0.0]::FLOAT[3] AS dist FROM articles_idx ORDER BY dist LIMIT 100) v ), normed AS ( SELECT id, branch, CASE WHEN MAX(s) OVER w = MIN(s) OVER w THEN 1.0 ELSE (s - MIN(s) OVER w) / (MAX(s) OVER w - MIN(s) OVER w) END AS ns FROM hits WINDOW w AS (PARTITION BY branch) ) SELECT id, round(SUM(CASE branch WHEN 'vec' THEN 0.50 ELSE 0.50 END * ns)::numeric, 3) AS score FROM normed GROUP BY id ORDER BY score DESC, id LIMIT 4;
WITH hits AS ( SELECT 'lex' AS branch, id, s FROM ( SELECT id, BM25(articles_idx.tableoid) AS s FROM articles_idx WHERE body @@ ts_phrase('PAY-402') ORDER BY s DESC LIMIT 100) l UNION ALL SELECT 'vec' AS branch, id, -dist AS s FROM ( SELECT id, emb <-> [0.40, 0.35, 0.25]::FLOAT[3] AS dist FROM articles_idx ORDER BY dist LIMIT 100) v ), normed AS ( SELECT id, branch, CASE WHEN MAX(s) OVER w = MIN(s) OVER w THEN 1.0 ELSE (s - MIN(s) OVER w) / (MAX(s) OVER w - MIN(s) OVER w) END AS ns FROM hits WINDOW w AS (PARTITION BY branch) ) SELECT id, round(SUM(CASE branch WHEN 'vec' THEN 0.50 ELSE 0.50 END * ns)::numeric, 3) AS score FROM normed GROUP BY id ORDER BY score DESC, id LIMIT 4;
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.
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.
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.
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.
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.
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.
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.
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.
Four ways to combine the two lists, all in SQL
not a config keyRanks, 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)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)α·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₂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 <->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.
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] );
CREATE INDEX articles_idx ON articles USING inverted (id, title kb_en, body kb_en, topic, emb ivf (metric = 'l2'));
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;
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;
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;
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.
Vector search →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;
| id | name | brand |
|---|---|---|
| 1 | Waterproof trail running shoe | Northpeak |
| 3 | Waterproof hiking boot | Northpeak |
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.
RAG database →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;
| id | title | rrf |
|---|---|---|
| 1 | Billing runbook | 0.0325 |
| 2 | Billing runbook | 0.0164 |
| 3 | Webhook guide | 0.0159 |
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.
Real-time analytics →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;
| topic | hits |
|---|---|
| payments | 4 |
| accounts | 1 |
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 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.