Vector search is good at finding text with a similar meaning.
Full-text search is good at finding the words a user actually typed.
A search feature often needs both.
Imagine a developer searches:
JWT token expired after password reset
Vector search may find documents about authentication sessions, credential expiry, or reset flows even when those exact words are missing.
PostgreSQL full-text search can strongly match a document containing:
JWT
token
password reset
expired
Those are different strengths.
Instead of choosing one, we can retrieve candidates from both and combine their rankings.
That is hybrid search.
In this guide, we will build it with:
- PostgreSQL full-text search
- pgvector
- a GIN text index
- an HNSW vector index
- Reciprocal Rank Fusion (RRF)
- tenant and metadata filters
- a Node.js query function
EXPLAIN ANALYZE- a small search-quality test set
The final path looks like this:
user query
↓
┌─────────────────┬─────────────────┐
│ keyword search │ semantic search │
│ PostgreSQL FTS │ pgvector │
└─────────────────┴─────────────────┘
↓
candidate ranks
↓
RRF
↓
final result list
This guide uses PostgreSQL 16 syntax and pgvector 0.8.6.
pgvector currently supports PostgreSQL 13 and newer.
Why combine keyword and vector search?
Suppose the stored document says:
Reset links are invalid after fifteen minutes.
A user searches:
password recovery token expiration
A semantic search can understand that those ideas are related even though the wording is different.
Now imagine the user searches:
ERR_AUTH_2041
An embedding may understand very little about that identifier.
Full-text search can match it directly.
The same thing happens with:
- product names
- error codes
- invoice numbers
- API names
- acronyms
- customer terminology
- exact feature names
Hybrid search lets both retrieval methods contribute.
1. Enable pgvector
Enable the extension once in the database:
CREATE EXTENSION IF NOT EXISTS vector;
pgvector stores embeddings in a normal PostgreSQL column.
We will use cosine distance for this example.
2. Create the documents table
Create a table that keeps the text, metadata, embedding, and full-text representation together.
CREATE TABLE documents (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL,
title text NOT NULL,
content text NOT NULL,
category text,
published_at timestamptz,
embedding vector(1536) NOT NULL,
search_vector tsvector
GENERATED ALWAYS AS (
setweight(
to_tsvector(
'english',
coalesce(title, '')
),
'A'
)
||
setweight(
to_tsvector(
'english',
coalesce(content, '')
),
'B'
)
) STORED,
created_at timestamptz
NOT NULL
DEFAULT now()
);
The 1536 dimension is an example.
Use the exact dimension returned by your embedding model.
The generated search_vector gives the title more weight than the body:
title → weight A
content → weight B
That can help a document with the query in its title rank above a document that mentions the same terms deep inside the body.
3. Add the indexes
Full-text search gets a GIN index:
CREATE INDEX documents_search_vector_gin
ON documents
USING gin (search_vector);
Vector search gets an HNSW index using cosine distance:
CREATE INDEX documents_embedding_hnsw
ON documents
USING hnsw (
embedding vector_cosine_ops
);
Add an index for the tenant filter too:
CREATE INDEX documents_tenant_id_idx
ON documents (tenant_id);
If category is commonly filtered:
CREATE INDEX documents_category_idx
ON documents (category);
At this point we have two independent search systems inside one database.
4. Build the lexical search
PostgreSQL gives us several ways to turn text into a search query.
For a normal product search box, websearch_to_tsquery is convenient because it accepts user-friendly input and understands quoted phrases, OR, and minus-style exclusion.
Start with:
SELECT
id,
title,
ts_rank_cd(
search_vector,
websearch_to_tsquery(
'english',
'password reset token'
)
) AS lexical_score
FROM documents
WHERE
search_vector @@
websearch_to_tsquery(
'english',
'password reset token'
)
ORDER BY lexical_score DESC
LIMIT 10;
This gives us keyword relevance.
PostgreSQL also offers ts_rank.
I am using ts_rank_cd here because it can consider how closely matching terms occur together.
5. Build the semantic search
Assume we already have an embedding for the user's query.
The semantic query is much smaller:
SELECT
id,
title,
embedding <=> $1::vector AS distance
FROM documents
WHERE tenant_id = $2
ORDER BY embedding <=> $1::vector
LIMIT 10;
With cosine distance:
smaller distance = closer vector
You can convert cosine distance to a similarity value:
1 - (embedding <=> $1::vector)
For hybrid ranking, we will not need to compare that raw value with the text-search score.
That is one reason RRF is convenient.
6. Why not add the two scores together?
You may be tempted to do this:
final score =
lexical score
+
vector similarity
The problem is that those scores do not naturally live on the same scale.
A full-text rank of:
0.78
does not mean the same thing as vector similarity:
0.78
You can normalize and tune both scores, but then the ranking system becomes sensitive to those normalization choices.
Reciprocal Rank Fusion uses rank positions instead.
That makes it much easier to combine two retrieval systems with different score scales.
7. Reciprocal Rank Fusion in one sentence
RRF gives each document points based on where it appears in each ranked list.
A common form is:
RRF score = 1 / (k + rank)
If the document appears in several lists, add the contributions together.
With:
k = 60
a document ranked first contributes:
1 / 61
A document ranked tenth contributes:
1 / 70
The exact constant can be tuned.
The useful property is that a document appearing near the top of both retrieval methods receives a stronger combined score.
8. Combine Postgres FTS and pgvector with RRF
Here is the complete hybrid query.
WITH
params AS (
SELECT
websearch_to_tsquery(
'english',
$1
) AS text_query,
$2::vector AS query_embedding,
$3::bigint AS tenant_id
),
lexical AS (
SELECT
d.id,
row_number() OVER (
ORDER BY
ts_rank_cd(
d.search_vector,
p.text_query
) DESC
) AS rank
FROM documents d
CROSS JOIN params p
WHERE
d.tenant_id = p.tenant_id
AND
d.search_vector @@ p.text_query
ORDER BY
ts_rank_cd(
d.search_vector,
p.text_query
) DESC
LIMIT 50
),
semantic AS (
SELECT
d.id,
row_number() OVER (
ORDER BY
d.embedding <=>
p.query_embedding
) AS rank
FROM documents d
CROSS JOIN params p
WHERE
d.tenant_id = p.tenant_id
ORDER BY
d.embedding <=>
p.query_embedding
LIMIT 50
),
fused AS (
SELECT
coalesce(
lexical.id,
semantic.id
) AS id,
coalesce(
1.0 / (
60 + lexical.rank
),
0.0
)
+
coalesce(
1.0 / (
60 + semantic.rank
),
0.0
)
AS rrf_score
FROM lexical
FULL OUTER JOIN semantic
ON lexical.id = semantic.id
)
SELECT
d.id,
d.title,
d.content,
d.category,
d.published_at,
fused.rrf_score
FROM fused
JOIN documents d
ON d.id = fused.id
ORDER BY
fused.rrf_score DESC
LIMIT 10;
This query creates two candidate lists:
top 50 lexical results
top 50 semantic results
Then it joins them and calculates the RRF score.
A document can appear in:
lexical only
semantic only
both
Documents that rank strongly in both lists tend to move upward.
9. Add a category filter
Now imagine the same table stores:
documentation
support
blog
product
policy
Add the category to params:
WITH
params AS (
SELECT
websearch_to_tsquery(
'english',
$1
) AS text_query,
$2::vector AS query_embedding,
$3::bigint AS tenant_id,
$4::text AS category
)
Then add the same condition to both retrieval branches:
AND (
p.category IS NULL
OR d.category = p.category
)
Do this in both the lexical and semantic queries.
You do not want keyword search to respect a filter while vector search quietly ignores it.
10. Be careful with filters and HNSW
This is one place where a query that looks correct can return fewer vector results than expected.
With an approximate HNSW index, filtering can happen after candidates are pulled from the vector index.
Suppose:
HNSW candidate pool = 40
only 10% belong to this tenant/category
You may end up with only a few usable rows.
pgvector 0.8.0 and newer supports iterative index scans to help with filtered approximate search.
For strict ordering:
SET hnsw.iterative_scan = strict_order;
For a single query, keep the setting local to a transaction:
BEGIN;
SET LOCAL hnsw.iterative_scan = strict_order;
-- hybrid query here
COMMIT;
Also keep normal indexes on selective filter columns.
For strong tenant separation at larger scale, pgvector's documentation also suggests options such as partitioning or separate tables.
Do not treat vector filtering as an authorization boundary by itself.
The application still needs to enforce tenant access before returning results.
11. Call the hybrid query from Node.js
Install pg:
npm install pg
Create:
src/search.js
import pg from "pg";
const { Pool } = pg;
const pool = new Pool({
connectionString:
process.env.DATABASE_URL,
});
function toVectorLiteral(values) {
if (!Array.isArray(values)) {
throw new TypeError(
"queryEmbedding must be an array"
);
}
if (
values.some(
(value) =>
typeof value !== "number" ||
!Number.isFinite(value)
)
) {
throw new TypeError(
"queryEmbedding contains an invalid value"
);
}
return `[${values.join(",")}]`;
}
const HYBRID_SEARCH_SQL = `
WITH
params AS (
SELECT
websearch_to_tsquery(
'english',
$1
) AS text_query,
$2::vector AS query_embedding,
$3::bigint AS tenant_id
),
lexical AS (
SELECT
d.id,
row_number() OVER (
ORDER BY
ts_rank_cd(
d.search_vector,
p.text_query
) DESC
) AS rank
FROM documents d
CROSS JOIN params p
WHERE
d.tenant_id = p.tenant_id
AND
d.search_vector @@ p.text_query
ORDER BY
ts_rank_cd(
d.search_vector,
p.text_query
) DESC
LIMIT 50
),
semantic AS (
SELECT
d.id,
row_number() OVER (
ORDER BY
d.embedding <=>
p.query_embedding
) AS rank
FROM documents d
CROSS JOIN params p
WHERE
d.tenant_id = p.tenant_id
ORDER BY
d.embedding <=>
p.query_embedding
LIMIT 50
),
fused AS (
SELECT
coalesce(
l.id,
s.id
) AS id,
coalesce(
1.0 / (60 + l.rank),
0.0
)
+
coalesce(
1.0 / (60 + s.rank),
0.0
)
AS rrf_score
FROM lexical l
FULL OUTER JOIN semantic s
ON l.id = s.id
)
SELECT
d.id,
d.title,
d.content,
d.category,
fused.rrf_score
FROM fused
JOIN documents d
ON d.id = fused.id
ORDER BY
fused.rrf_score DESC
LIMIT $4;
`;
export async function hybridSearch({
queryText,
queryEmbedding,
tenantId,
limit = 10,
}) {
const vector =
toVectorLiteral(
queryEmbedding
);
const result =
await pool.query(
HYBRID_SEARCH_SQL,
[
queryText,
vector,
tenantId,
limit,
]
);
return result.rows;
}
The embedding provider stays outside this function.
That is intentional.
The search layer only needs:
query text
query vector
tenant
You can change the embedding provider later without rewriting the SQL fusion logic.
12. Add the query embedding
Your application flow becomes:
const queryText =
"password reset token expired";
const queryEmbedding =
await embedQuery(queryText);
const results =
await hybridSearch({
queryText,
queryEmbedding,
tenantId: 42,
limit: 10,
});
embedQuery() should return the same vector dimension used by the documents.embedding column.
If the table is:
vector(1536)
the query vector must contain:
1536 dimensions
Validate this at the application boundary.
A mismatch should fail before the database query.
13. Keep document and query embeddings compatible
Do not silently change the embedding model for new documents while old rows still contain vectors from another model.
That can leave one table containing vectors from different embedding spaces.
A simple approach is to store:
embedding_model
embedding_version
with each document.
For example:
ALTER TABLE documents
ADD COLUMN embedding_model text;
Then a migration to another model can be explicit.
Depending on the product, you might:
- re-embed every document
- keep separate indexes
- migrate in batches
- create a new table/version
The search layer should know which embedding space it is querying.
14. Check whether the indexes are actually used
A query being correct does not mean it is fast.
Use:
EXPLAIN (
ANALYZE,
BUFFERS
)
SELECT
id,
title
FROM documents
ORDER BY
embedding <=>
$1::vector
LIMIT 50;
For lexical search:
EXPLAIN (
ANALYZE,
BUFFERS
)
SELECT
id,
title
FROM documents
WHERE
search_vector @@
websearch_to_tsquery(
'english',
$1
)
LIMIT 50;
Look for the expected index behavior.
Do this with a dataset large enough to make index use meaningful.
On a tiny development table, PostgreSQL may correctly choose a sequential scan because reading the whole table is cheaper.
15. Do not tune RRF by intuition alone
The candidate count:
50
and RRF constant:
60
are starting points.
They are not universal values.
Create a small evaluation set.
For example:
[
{
"query":
"password recovery link expired",
"expected":
[
"doc_104",
"doc_219"
]
},
{
"query":
"ERR_AUTH_2041",
"expected":
[
"doc_442"
]
},
{
"query":
"customer cannot access account after reset",
"expected":
[
"doc_104",
"doc_331"
]
}
]
Now compare:
lexical only
vector only
hybrid RRF
Measure something simple first:
Was an expected document
inside the top 5?
Then try:
- different candidate counts
- different RRF constants
- title/body weights
- another embedding model
- category filters
- reranking
Search quality should be tuned against queries users actually make.
16. A useful failure test: exact identifiers
Create a document containing:
ERR_BILLING_4092
Then search:
ERR_BILLING_4092
The lexical side should strongly recover that document.
Now search:
invoice was charged twice after retry
The vector side may find the same document even when those exact words are missing.
A healthy hybrid system should handle both styles.
That is the point.
17. Another failure test: semantic wording with weak keyword overlap
Document:
Usage is returned when processing fails before completion.
Query:
Do I get my credits back if the job crashes?
A pure keyword search may struggle if your text does not share enough vocabulary.
A semantic result may connect:
credits back
↔
usage returned
job crashes
↔
processing fails
The lexical branch still helps when exact product terminology appears.
RRF lets both contribute without forcing their raw scores onto one scale.
18. Keep retrieval separate from generation
If this search feeds a RAG workflow, keep the stages visible:
user query
↓
hybrid retrieval
↓
top documents
↓
optional reranker
↓
LLM context
↓
generated answer
Do not judge retrieval quality only by whether the final LLM answer sounds good.
The model can sometimes produce a convincing answer from weak retrieval.
Log the documents returned by the search layer.
That gives you somewhere to investigate when an answer fails.
19. A production checklist
Before shipping this search path, I would check these areas.
Text search
Is the tsvector built from the fields that actually matter?
Are titles weighted differently from body text where that helps?
Vector search
Are document vectors and query vectors created with the same embedding model and dimension?
Indexes
Are GIN and HNSW being used on production-sized data?
Filters
Do tenant, category, or permission filters apply to both retrieval branches?
HNSW filtering
Do selective filters reduce the number of returned candidates?
Would iterative scans, a filter index, or partitioning help?
RRF
Was the candidate count and fusion constant tested against a small labeled query set?
Access
Can a search query ever retrieve another tenant's documents?
Search ranking must not become the access-control system.
Observability
Can you log:
query
lexical candidate IDs
semantic candidate IDs
final IDs
latency
without storing sensitive user text unnecessarily?
Evaluation
Do you have several queries where the expected documents are known?
Run them when changing:
embedding model
text configuration
RRF parameters
index settings
document chunking
Why this setup survives beyond one RAG project
Hybrid retrieval is useful because user searches are messy.
Sometimes they know the exact term.
Sometimes they remember the idea.
Sometimes they paste an error code.
Sometimes they describe the problem in completely different words from the documentation.
PostgreSQL already gives us strong lexical search.
pgvector adds semantic retrieval without requiring a separate vector database.
RRF gives the two ranked lists a simple way to meet.
For products already using PostgreSQL, that can be a very practical starting point for:
- documentation search
- internal knowledge search
- support retrieval
- SaaS help centers
- AI retrieval workflows
- policy and document search
- product search
Start with a small evaluation set.
See where lexical search wins.
See where vector search wins.
Then make the hybrid ranking earn its place.
Sources
- pgvector: Hybrid search, HNSW, filtering, and iterative scans
- pgvector 0.8.6 changelog
- PostgreSQL: Controlling Text Search
- PostgreSQL: Text Search Functions and Operators
Editorial note
The PostgreSQL functions, pgvector operators, current pgvector version, HNSW behavior, filtering guidance, and hybrid-search recommendations were checked against the current PostgreSQL and pgvector documentation before publication. The SQL and Node.js examples are implementation examples and should be tested against the PostgreSQL version, embedding model, schema, and workload used by the application.
Top comments (0)