Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
Hybrid search combines two retrieval strategies in a single query:
- BM25 full-text search with Full-text search with pg_textsearch in Azure HorizonDB (Preview) - strong on exact terms, product codes, error messages, named entities, and any query where the user typed words that should literally appear in the result.
- Vector similarity search with Implement vector search in Azure HorizonDB using the pgvector extension (Preview) and Scalable vector indexing with DiskANN (Preview) - strong on synonyms, paraphrases, and semantic intent where the right document doesn't share the user's exact words.
Used alone, each method has blind spots. Used together, they cover for each other. Hybrid search is the default retrieval pattern for production AI applications on Azure HorizonDB - agentic apps, knowledge bases, recommendation engines, support search, and RAG over enterprise content.
This article shows you how to build hybrid search end to end inside HorizonDB, without copying data to a separate search service.
Article outline
- Why hybrid wins
- How hybrid search works on HorizonDB
- Reciprocal Rank Fusion (RRF)
- Prerequisites
- Set up the table and indexes
- Generate embeddings in SQL
- Run a hybrid search query
- Combine hybrid search with metadata filters
- Add a semantic reranker for the final accuracy bump
- When not to use hybrid search
- Performance notes
- Related content
Why hybrid wins
A query like "connection timeout error PG-4012" has two signals:
- The literal token
PG-4012is a precise identifier - vector search likely misses it because the embedding model never saw it. - The phrase
"connection timeout error"is semantic - BM25 might match the words, but a different document phrased as"the database stopped responding after the network dropped"is a better answer that BM25 doesn't surface.
Pure vector search misses the first signal. Pure BM25 misses the second. Hybrid search returns a single ranked list that surfaces both. The boost is largest exactly where customers care most: enterprise-specific terminology, product names, error codes, multitenant filtered queries, and any corpus where a phrase can mean two different things.
How hybrid search works on HorizonDB
A hybrid query has three logical steps, all of which run inside HorizonDB:
Run BM25 with
pg_textsearchto get the top-N keyword matches.Run vector search with
pgvector(using DiskANN as the index) to get the top-N semantic matches.Fuse the two ranked lists into a single ordered result set.
(Optional) Re-rank the top-K of the fused list with a cross-encoder model using
azure_ai.rank()to provide a final accuracy bump on the documents that actually shows to the user.
Everything happens in a single SQL query. There's no copy-syncing to an external search index, no application-side join, and no separate vector database.
Reciprocal Rank Fusion (RRF)
The standard way to combine BM25 and vector results is Reciprocal Rank Fusion. RRF ignores the raw score of each ranker (which are on incompatible scales) and uses only the rank each document achieves in each list.
The constant k (commonly 60) prevents the top-1 document from dominating. Documents that appear high in both rankers naturally float to the top. Documents that appear in only one ranker still contribute, just with a lower combined score.
RRF is the right default because:
- It needs no tuning -
k = 60is a good choice. - It's robust to score-scale differences across rankers.
- It composes naturally with more than two rankers (for example, graph search and structured filters).
Prerequisites
Enable the extensions you need on your database:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_diskann CASCADE;
CREATE EXTENSION IF NOT EXISTS pg_textsearch;
CREATE EXTENSION IF NOT EXISTS azure_ai;
For instructions on enabling extensions at the instance level, see Allow extensions in Azure HorizonDB (Preview).
Set up the table and indexes
Use a single table for both retrieval methods. Storing embeddings, content, and metadata together is a key advantage of doing this in HorizonDB instead of a separate search service - you keep transactional consistency and avoid sync drift.
CREATE TABLE products (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
embedding public.vector(1536)
);
-- BM25 index over the searchable text columns
CREATE INDEX idx_products_bm25
ON products
USING bm25 (name, description)
WITH (text_config = 'english');
-- DiskANN vector index for semantic search
CREATE INDEX idx_products_vec
ON products
USING diskann (embedding vector_cosine_ops);
Tip
DiskANN is the recommended vector index for hybrid search workloads because it supports advanced filtering - you can combine WHERE clauses on metadata with vector similarity without losing recall.
Generate embeddings in SQL
You can generate embeddings inside Postgres using the AI functions in the azure_ai extension for Azure HorizonDB (Preview). This approach eliminates the embedding pipeline entirely - no external service calls in your application code.
If you enable AI Model Management (limited preview), you can omit the model parameter - the function automatically uses the default-embedding model (text-embedding-3-small). If you're using your own model, pass your registered model alias as the first argument. See AI functions in the azure_ai extension for Azure HorizonDB (Preview) for details on registering models.
-- Backfill embeddings for existing rows
UPDATE products
SET embedding = azure_openai.create_embeddings(
'default-embedding', -- model alias (omit if using AI Model Management)
name || ' ' || description
)::vector
WHERE embedding IS NULL;
Note
AI Model Management users: You can simplify the call to azure_openai.create_embeddings(input => name || ' ' || description) - the default-embedding model is used automatically.
For an end-to-end embedding workflow including durable batch processing for large tables, see Generate vector embeddings using the create_embeddings() AI function (Preview) and Implement durable AI pipelines in Azure HorizonDB (Preview).
Run a hybrid search query
The complete hybrid search query: BM25 + vector search + RRF fusion in a single statement.
WITH
-- Embed the query once and reuse it
query AS (
SELECT
'wireless noise cancelling headphones' AS q_text,
azure_openai.create_embeddings(
'my-embedding', -- model alias (omit if using AI Model Management)
'wireless noise cancelling headphones'
)::vector AS q_vec
),
-- Top-N BM25 results, ranked by relevance
bm25 AS (
SELECT p.id,
ROW_NUMBER() OVER (
ORDER BY p.description <@> to_bm25query(query.q_text, 'idx_products_bm25')
) AS bm25_rank
FROM products p, query
ORDER BY p.description <@> to_bm25query(query.q_text, 'idx_products_bm25')
LIMIT 50
),
-- Top-N vector results, ranked by cosine distance
vec AS (
SELECT p.id,
ROW_NUMBER() OVER (
ORDER BY p.embedding <=> query.q_vec
) AS vec_rank
FROM products p, query
ORDER BY p.embedding <=> query.q_vec
LIMIT 50
)
-- Reciprocal Rank Fusion
SELECT p.id, p.name, p.description,
(1.0 / (60 + COALESCE(b.bm25_rank, 1000))) +
(1.0 / (60 + COALESCE(v.vec_rank, 1000))) AS rrf_score
FROM products p
LEFT JOIN bm25 b ON b.id = p.id
LEFT JOIN vec v ON v.id = p.id
WHERE b.id IS NOT NULL OR v.id IS NOT NULL
ORDER BY rrf_score DESC
LIMIT 10;
A few things to note:
- The query embedding is generated once in the
queryCTE and reused - saving you a round-trip per ranker. - Each ranker pulls its own top-50; RRF then keeps the top-10 of the fusion. Tune the inner
LIMIT(the candidate pool) and the outerLIMIT(the result count) independently. LEFT JOINkeeps documents that appear in only one ranker. TheCOALESCE(..., 1000)gives them a low contribution rather than dropping them.- The constant
60is the standard RRFk. You rarely need to change it.
Combine hybrid search with metadata filters
Because everything is in HorizonDB, you can apply a structured WHERE filter at the same time as similarity ranking. With DiskANN advanced filtering, the predicate is pushed into the vector index - so adding WHERE category = 'audio' doesn't collapse recall.
WITH query AS (
SELECT
'noise cancelling for travel' AS q_text,
azure_openai.create_embeddings(
'default-embedding', -- model alias (omit if using AI Model Management)
'noise cancelling for travel'
)::vector AS q_vec
),
bm25 AS (
SELECT p.id,
ROW_NUMBER() OVER (
ORDER BY p.description <@> to_bm25query(query.q_text, 'idx_products_bm25')
) AS bm25_rank
FROM products p, query
WHERE category = 'audio'
ORDER BY p.description <@> to_bm25query(query.q_text, 'idx_products_bm25')
LIMIT 50
),
vec AS (
SELECT p.id, ROW_NUMBER() OVER (ORDER BY p.embedding <=> query.q_vec) AS vec_rank
FROM products p, query
WHERE p.category = 'audio'
ORDER BY p.embedding <=> query.q_vec
LIMIT 50
)
SELECT p.id, p.name,
(1.0 / (60 + COALESCE(b.bm25_rank, 1000))) +
(1.0 / (60 + COALESCE(v.vec_rank, 1000))) AS rrf_score
FROM products p
LEFT JOIN bm25 b ON b.id = p.id
LEFT JOIN vec v ON v.id = p.id
WHERE (b.id IS NOT NULL OR v.id IS NOT NULL)
ORDER BY rrf_score DESC
LIMIT 10;
For more on filtered vector search, see Filter your search with advanced filtering.
Add a semantic reranker for the final accuracy bump
RRF provides a strong ranked list cheaply. For the documents that are actually shown to a user or fed to an LLM, a cross-encoder reranker pushes relevance higher still, at the cost of one model call per candidate.
The pattern:
- Run hybrid search and take the top 50 from RRF.
- Pass those 50 candidates plus the original query through a reranker model.
- Return the reranker's top 10.
For the full reranking pattern using the azure_ai.rank() function, see Semantic reranking with the rank() function (Preview).
When not to use hybrid search
Hybrid search adds two index scans and a fusion step. It's not free. Reach for pure BM25 or pure vector search when:
- Your queries are unambiguously keyword (product code lookup, log search, code search). Pure BM25 is faster and as accurate.
- Your queries are unambiguously semantic over a small homogeneous corpus (FAQ retrieval over a few thousand answers). Pure vector search with DiskANN is enough.
- Your application latency budget is below the combined cost of two index scans and you can validate that one ranker alone meets your relevance bar.
For mixed real-world queries - which is most production retrieval - hybrid is the right default.
Performance notes
- Latency. Each ranker runs an independent index scan. When DiskANN and
pg_textsearchboth retrieve 50 candidates, hybrid search typically lands in the low double-digit milliseconds on millions of rows. The reranker step adds tens to low hundreds of milliseconds depending on the candidate pool. - Candidate pool size. Pulling 50 candidates from each ranker and keeping 10 after RRF is a good default. Increasing the inner
LIMITimproves recall at a small latency cost. Raise it before tuning RRF'sk. - Index updates. Both
pg_textsearchand DiskANN apply inserts and updates in place. There's no cron job or refresh step. - Embeddings. Generate query embeddings once per request and reuse them across rankers, as shown in the preceding SQL example.