DEV Community

Cover image for Postgres Hybrid Search with pgvector, Full-Text Search, and RRF

Postgres Hybrid Search with pgvector, Full-Text Search, and RRF

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

A user searches:

password recovery token expiration
Enter fullscreen mode Exit fullscreen mode

A semantic search can understand that those ideas are related even though the wording is different.

Now imagine the user searches:

ERR_AUTH_2041
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Vector search gets an HNSW index using cosine distance:

CREATE INDEX documents_embedding_hnsw
ON documents
USING hnsw (
    embedding vector_cosine_ops
);
Enter fullscreen mode Exit fullscreen mode

Add an index for the tenant filter too:

CREATE INDEX documents_tenant_id_idx
ON documents (tenant_id);
Enter fullscreen mode Exit fullscreen mode

If category is commonly filtered:

CREATE INDEX documents_category_idx
ON documents (category);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

With cosine distance:

smaller distance = closer vector
Enter fullscreen mode Exit fullscreen mode

You can convert cosine distance to a similarity value:

1 - (embedding <=> $1::vector)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The problem is that those scores do not naturally live on the same scale.

A full-text rank of:

0.78
Enter fullscreen mode Exit fullscreen mode

does not mean the same thing as vector similarity:

0.78
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

If the document appears in several lists, add the contributions together.

With:

k = 60
Enter fullscreen mode Exit fullscreen mode

a document ranked first contributes:

1 / 61
Enter fullscreen mode Exit fullscreen mode

A document ranked tenth contributes:

1 / 70
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

This query creates two candidate lists:

top 50 lexical results
top 50 semantic results
Enter fullscreen mode Exit fullscreen mode

Then it joins them and calculates the RRF score.

A document can appear in:

lexical only
semantic only
both
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

Then add the same condition to both retrieval branches:

AND (
    p.category IS NULL
    OR d.category = p.category
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

For a single query, keep the setting local to a transaction:

BEGIN;

SET LOCAL hnsw.iterative_scan = strict_order;

-- hybrid query here

COMMIT;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create:

src/search.js
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

The embedding provider stays outside this function.

That is intentional.

The search layer only needs:

query text
query vector
tenant
Enter fullscreen mode Exit fullscreen mode

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,
  });
Enter fullscreen mode Exit fullscreen mode

embedQuery() should return the same vector dimension used by the documents.embedding column.

If the table is:

vector(1536)
Enter fullscreen mode Exit fullscreen mode

the query vector must contain:

1536 dimensions
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

with each document.

For example:

ALTER TABLE documents
ADD COLUMN embedding_model text;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

For lexical search:

EXPLAIN (
    ANALYZE,
    BUFFERS
)
SELECT
    id,
    title
FROM documents
WHERE
    search_vector @@
    websearch_to_tsquery(
        'english',
        $1
    )
LIMIT 50;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and RRF constant:

60
Enter fullscreen mode Exit fullscreen mode

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"
      ]
  }
]
Enter fullscreen mode Exit fullscreen mode

Now compare:

lexical only
vector only
hybrid RRF
Enter fullscreen mode Exit fullscreen mode

Measure something simple first:

Was an expected document
inside the top 5?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then search:

ERR_BILLING_4092
Enter fullscreen mode Exit fullscreen mode

The lexical side should strongly recover that document.

Now search:

invoice was charged twice after retry
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Query:

Do I get my credits back if the job crashes?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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)