Skip to main content

Hybrid Search

Hybrid retrieval combines a lexical ranking (full-text @@ matching on the content column, scored with BM25) and a dense ranking (vector ANN distance) into a single relevance order. Because one SereneDB inverted index can cover both a text column and a vector column, the integration does this in one query rather than two round trips — see Hybrid Search for the SQL-level picture and Reciprocal Rank Fusion for the fusion maths.

Two separate concerns are configured with two separate classes, so it is clear which belongs where:

ClassConcernWhere it goes
HybridIndexConfigBuild time — the text search dictionary the content column is analyzed withinit_vectorstore_table(), or apply_hybrid_search_index()
HybridSearchConfigQuery time — fusion strategy, weights, per-branch windows, scorer, tsquery functionThe store factory

They are independent: you can rebuild the index without touching how queries are fused, and change fusion without rebuilding. Neither needs to be passed twice, and the types make it impossible to hand one to the wrong side.

Setting it up

from langchain_serenedb import (
FusionStrategy,
HybridIndexConfig,
HybridSearchConfig,
SereneDBEngine,
SereneDBVectorStore,
)

engine = SereneDBEngine.from_connection_string(
"host=127.0.0.1 port=7890 user=postgres dbname=postgres"
)

# Build time: creates the text search dictionary and the combined
# content + embedding index alongside the table.
engine.init_vectorstore_table("my_docs", 768, hybrid_index_config=HybridIndexConfig())

# Query time: how each search fuses the two branches.
store = SereneDBVectorStore.create_sync(
engine,
embeddings,
"my_docs",
hybrid_search_config=HybridSearchConfig(
fusion=FusionStrategy.RRF, primary_top_k=20, secondary_top_k=20
),
)

store.add_texts(docs, metadatas=metas)

results = store.similarity_search("how do I configure the index?", k=5)

Both defaults are usable as-is, so HybridIndexConfig() and HybridSearchConfig() with no arguments are a valid starting point.

If the table already exists, build the combined index from the store instead:

store.apply_hybrid_search_index()                                     # default dictionary
store.apply_hybrid_search_index(index_config=HybridIndexConfig(...)) # or a custom one
caution

A hybrid query has no fallback. Both branches select from the combined index by name, because BM25 needs the index's tableoid and @@ only resolves against an indexed column. If the combined index does not exist, the query fails — unlike a dense search, which quietly falls back to an exact table scan.

So the one thing still worth checking is that the two sides agree in intent: a store carrying a HybridSearchConfig needs a combined index to have been built for its table. Giving the store a search config does not create one.

HybridIndexConfig

The build-time half: which text search dictionary the content column is analyzed with when the combined index is created. It is not consulted at query time — the @@ predicate resolves through the index's own analyzer.

ParameterTypeDefaultMeaning
dictionary_namestr"langchain_fts_dict"Name of the dictionary created for the content column.
dictionary_optionsstr"template = 'segmentation', case = 'lower', frequency = true, position = true, norm = true"Options for CREATE TEXT SEARCH DICTIONARY. See The text search dictionary.

Pass it to init_vectorstore_table() as hybrid_index_config=, or to apply_hybrid_search_index() as index_config=. Both default to HybridIndexConfig() when a combined index is requested without one.

HybridSearchConfig

The query-time half: how each search windows, scores and fuses the two branches. Every field has a default, so HybridSearchConfig() is valid. It carries no index or dictionary settings, and the full-text query is not a field either — that is per call, see Choosing the query text.

ParameterTypeDefaultMeaning
fusionFusionStrategyRRFHow the two rankings are combined. See FusionStrategy.
rrf_kint60The RRF k constant. Higher values flatten the advantage of top ranks. RRF only.
primary_results_weightfloat0.5Weight of the vector branch. NORMALIZED and WEIGHTED_SUM only.
secondary_results_weightfloat0.5Weight of the lexical branch. NORMALIZED and WEIGHTED_SUM only.
primary_top_kint4Per-branch window: rows the vector branch contributes to fusion.
secondary_top_kint4Per-branch window: rows the lexical branch contributes to fusion.
scorerstr"BM25"Relevance scorer. Any SereneDB scorer name — BM25, TFIDF, dfi, …
tsquery_functionstr"plainto_tsquery"Query constructor: plainto_tsquery, to_tsquery, phraseto_tsquery or websearch_to_tsquery.
note

The default per-branch windows are only 4 rows each, and a document has to reach the top 4 of one branch to enter fusion at all. The two windows therefore contribute at most 8 distinct documents, so a request for k=10 against the defaults can never return more than 8 results. Raise primary_top_k and secondary_top_k to at least a few times your k.

scorer and tsquery_function are interpolated into the SQL as identifiers, not bound as parameters. Pass function names, not user input.

FusionStrategy

A str enum, so the raw strings "rrf", "normalized" and "weighted_sum" are accepted wherever a member is.

MemberCombinesUsesChoose it when
RRFRanks: sum(1 / (rrf_k + rank)) over both branchesrrf_kConsensus should win. Scale-free, so BM25 scores and vector distances fuse with no tuning — a document both signals like beats one only a single signal likes. This is the default and the usual right answer.
NORMALIZEDMin-max normalizes each branch to [0, 1] (the vector branch inverted so nearer is higher), then takes a weighted sumboth weightsScore margins matter — a decisive win in one branch should outrank lukewarm presence in both. The cost is that one outlier rescales its whole branch.
WEIGHTED_SUMA weighted sum of the raw branch scoresboth weightsThe branches are already on comparable scales. BM25 magnitudes and raw vector distances usually are not, so reach for this only when you have measured your own.

Query shape

The generated SQL follows SereneDB's standard fusion patterns, see Reciprocal Rank FusionHow it works for the formula, Template for the WITH fused AS (...) skeleton the store emits, and Another RRF strategy: normalized scores for what NORMALIZED does.

The store fills that skeleton in as follows:

In the patternHere
Branch 1Lexical: content @@ tsquery_function(...), ranked by scorer
Branch 2Vector: the distance strategy's operator against the query embedding
Per-branch LIMITsecondary_top_k for the lexical branch, primary_top_k for the vector one
RRF krrf_k
Final LIMITThe search's k

Both branches read from the combined index by name, and the outer query joins the base table back to project the content and metadata columns.

Two details the pattern does not cover: a metadata filter is applied inside both branches, so it narrows the candidate set before fusion rather than trimming the result afterwards; and ties in the fused score are broken by id, which makes result order deterministic.

Choosing the query text

The lexical branch needs a query string, and it is per call — the config holds no default for it. Resolution is simple:

  1. An explicit fts_query= keyword argument on the search call, if given.
  2. Otherwise, for similarity_search(), the query text itself, filled in automatically.

If neither applies, no lexical branch runs and the search is a plain dense query. That is what happens with similarity_search_by_vector() and the MMR methods, which have no query text to borrow — pass fts_query yourself if you want fusion:

store.similarity_search_by_vector(embedding, k=5, fts_query="index configuration")
note

The scored methods are dense-only by design and reject fts_query, so it is available on similarity_search() and similarity_search_by_vector() but not on similarity_search_with_score().

A different config can also be supplied per call, which is handy for A/B testing fusion strategies against one store:

store.similarity_search(
"index configuration",
k=5,
hybrid_search_config=HybridSearchConfig(fusion=FusionStrategy.NORMALIZED),
)

The text search dictionary

The lexical branch scores with BM25, which needs term frequencies recorded in the index. That comes from the content column's text search dictionary, created for you from the HybridIndexConfig in the table's schema — it has to live there, because an index in a non-public schema cannot resolve a dictionary from elsewhere.

Because this is settled when the index is built, changing it later means rebuilding: drop the dictionary, then re-create the index with the new config.

If you customize dictionary_options, keep these:

OptionNeeded for
frequency = trueAny relevance scoring at all
position = truePhrase and proximity queries
norm = trueThe language-model scorers

See Text Analysis for the available templates and options, and Scoring for the scorers.

note

drop_table() removes the table and its index but leaves the dictionary behind. It is created with IF NOT EXISTS, so recreating the table reuses it — including its old options. Drop it by hand if you change dictionary_options.

Rankings, not scores

Hybrid fusion produces a ranking, not a distance. That is why the scored methods are dense-only: similarity_search_with_score() and similarity_search_with_score_by_vector() always return a vector distance, never a fused score, so anything that interprets that number — the relevance helpers, the similarity_score_threshold retriever — stays well-defined.

Hybrid ranking is available on the unscored path, similarity_search(), and through as_retriever() with the default "similarity" search type. There is no API that hands you a fused score.

The fused value is neither a distance nor a BM25 score — it is something the fusion step constructs, and what it is made of depends on the strategy:

fusionThe number isRangeBM25 magnitude survives?
RRFSUM(1 / (rrf_k + rank)) over the branches a document appears in0 to 2 / (rrf_k + 1) — about 0.033 at the default rrf_k = 60No. Each branch's scores are used only to compute RANK(), then discarded
NORMALIZEDEach branch's score min-max rescaled to [0, 1] within its own window (the vector branch inverted), times that branch's weight, summed0 to primary_results_weight + secondary_results_weight1.0 by defaultNo. Only a document's relative position inside the returned window
WEIGHTED_SUMsecondary_results_weight * bm25 + primary_results_weight * (-distance)Unbounded, and can be negativeYes — this is the only strategy where raw BM25 reaches the output

Under RRF, then, the number says nothing about how close anything actually was — it is a function of ranks, rrf_k and how many branches matched, so two result sets with very different similarity can fuse to identical values. It orders results correctly and means nothing on its own scale. That is exactly why it is not exposed as a score, and why feeding it to a distance-based relevance function would be wrong in two ways at once: the polarity is backwards, and the input was never a distance.

Asking for a scored hybrid search

Because the two are incompatible, requesting fusion on a scored path is refused rather than silently approximated. Passing fts_query or hybrid_search_config to similarity_search_with_score() or similarity_search_with_score_by_vector() raises NotImplementedError:

Scored search does not support hybrid fusion: fused scores are rankings, not
distances. Use similarity_search()/as_retriever() for hybrid ranking, or drop
fts_query/hybrid_search_config for distance scores.

A store-level hybrid_search_config is not an error on these methods — it is simply bypassed, and you get a dense query with real distances. So on a hybrid store:

CallPathSecond tuple element
similarity_search(query)Hybrid
similarity_search_by_vector(emb, fts_query="…")Hybrid
similarity_search_with_score(query)DenseVector distance
similarity_search_with_score(query, fts_query="…")Raises
max_marginal_relevance_search(query)Dense

The consequence to keep in mind is that a scored call on a hybrid store answers a different question than an unscored one: it ranks by vector distance alone. If you need the fused ordering, use the unscored method.

Separately, max_marginal_relevance_search() always runs the dense path and ignores the lexical branch, so its scores really are distances — but its results are not hybrid.

See also