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
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.
SELECT id, name, round((emb <-> [1.0, 0.0, 0.0]::FLOAT[3])::numeric, 3) AS distanceFROM items_idxORDER BY distance, idLIMIT 3; id | name | distance----+--------------------+---------- 1 | trail running shoe | 0.000 5 | marathon racer | 0.071 2 | road running shoe | 0.141Where 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.
SELECT id, name FROM items_idx WHERE name @@ 'running' ORDER BY id; id | name----+-------------------- 1 | trail running shoe 2 | road running shoeFilter 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.
SELECT id, nameFROM items_idxWHERE name @@ 'shoe'ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3], idLIMIT 2; id | name----+-------------------- 1 | trail running shoe 2 | road running shoeFuse 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.
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; id---- 1 2 5 3See also
- Vector Search guide: building
ivfindexes, choosing a metric and tuning recall - Hybrid Search guide: the filtered-ANN and fusion strategies in depth
- Reciprocal Rank Fusion: the fusion pattern on its own, for blending any two ranked queries
- Vector functions reference:
l2_distance,cosine_distanceand the distance operators