Skip to main content

Semantic and Hybrid Search

Keyword search matches the words a shopper typed. Vector search matches what they meant. SereneDB stores both a text field and its embedding in one inverted index, so you can rank by meaning, filter by keyword and fuse the two without leaving the query. This recipe walks from a plain nearest-neighbor search up to a hybrid that catches results keyword search alone would miss.

Five products sit in an items table, each with a name and a three-dimensional emb vector. In production you fill emb by running the text through an embedding model and storing the vector it returns, usually hundreds of dimensions wide. The three numbers here are written by hand so the distances stay easy to read: the first axis is roughly "made for running". emb is indexed with ivf on the l2 metric, so <-> gives Euclidean distance and smaller means closer.

Schema and sample data
Query
CREATE TEXT SEARCH DICTIONARY en (    template = 'text',    locale = 'en_US.UTF-8',    case = 'lower',    stemming = false,    accent = false,    frequency = true,    position = true);
CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR, emb FLOAT[3]);
CREATE INDEX items_idx ON items    USING inverted (id, name en, emb ivf (metric = 'l2'));
INSERT INTO items VALUES(1, 'trail running shoe', [1.0, 0.0, 0.0]::FLOAT[3]),(2, 'road running shoe',  [0.9, 0.1, 0.0]::FLOAT[3]),(3, 'leather dress shoe', [0.0, 1.0, 0.0]::FLOAT[3]),(4, 'wireless earbuds',   [0.0, 0.0, 1.0]::FLOAT[3]),(5, 'marathon racer',     [0.95, 0.05, 0.0]::FLOAT[3]);
VACUUM (REFRESH_TABLE) items;

Rank by meaning

emb <-> query is the distance from each row to your query vector. Order by it, take the top k and you have semantic search. Notice "marathon racer" ranks second even though it shares no words with a running query, because its embedding sits right next to the running shoes.

Query
SELECT id, name,       round((emb <-> [1.0, 0.0, 0.0]::FLOAT[3])::numeric, 3) AS distanceFROM items_idxORDER BY distance, idLIMIT 3;
Result
 id | name               | distance----+--------------------+----------  1 | trail running shoe |    0.000  5 | marathon racer     |    0.071  2 | road running shoe  |    0.141

Where keyword search falls short

Search the word "running" and you only get the rows that literally contain it. "Marathon racer" is exactly what the shopper wants and it is nowhere to be found.

Query
SELECT id, name FROM items_idx WHERE name @@ 'running' ORDER BY id;
Result
 id | name----+--------------------  1 | trail running shoe  2 | road running shoe

Filter by keyword, rank by vector

The simplest hybrid uses the keyword match as a hard filter and the vector as the sort. Great when the keyword is a firm requirement and you just want the closest matches ordered by meaning.

Query
SELECT id, nameFROM items_idxWHERE name @@ 'shoe'ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3], idLIMIT 2;
Result
 id | name----+--------------------  1 | trail running shoe  2 | road running shoe

Fuse both with reciprocal rank fusion

When neither signal should be a gate, run both and blend the rankings. Reciprocal rank fusion scores each result by 1 / (60 + rank) in each list and sums them, so a row that ranks well in either branch rises. This is what finally surfaces "marathon racer": the keyword branch never sees it, but the vector branch ranks it high enough to make the cut.

Query
WITH fused AS (    SELECT id, ROW_NUMBER() OVER (ORDER BY s DESC) AS rank FROM (        SELECT id, BM25(items_idx.tableoid) AS s        FROM items_idx        WHERE name @@ 'running'        ORDER BY s DESC LIMIT 100    ) lex    UNION ALL    SELECT id, ROW_NUMBER() OVER (ORDER BY dist) AS rank FROM (        SELECT id, emb <-> [1.0, 0.0, 0.0]::FLOAT[3] AS dist        FROM items_idx        ORDER BY dist LIMIT 100    ) vec)SELECT idFROM fusedGROUP BY idORDER BY SUM(1.0 / (60 + rank)) DESC, idLIMIT 4;
Result
 id----  1  2  5  3

See also