Hybrid Search with pgvector and tsvector in PostgreSQL

postgresql
pgvector
search
TIL
How to combine dense vector search and full-text search in PostgreSQL using pgvector, tsvector, and Reciprocal Rank Fusion (RRF)
Author

Mahamadi NIKIEMA

Published

March 16, 2026

What are pgvector and tsvector?

Neither dense nor sparse searches are sufficient on their own. Dense searches miss exact keywords, and sparse searches miss synonyms. PostgreSQL offers native full-text search with the tsvector feature and dense vector search with the pgvector extension.

pgvector is a PostgreSQL extension that adds a vector column type and similarity search operators. It enables storing and querying dense embeddings (from models like sentence-transformers) directly in Postgres.

tsvector is a built-in type for sparse textual data used in full-text search.

Setup

Start a local Postgres instance with pgvector using Docker:

docker run --name test-db \
  -e POSTGRES_USER=user \
  -e POSTGRES_PASSWORD=password \
  -e POSTGRES_DB=test-db \
  -p 5433:5432 \
  -d pgvector/pgvector:pg16

We will use psycopg2 for direct interaction with PostgreSQL, polars for efficient DataFrame operations, and connectorx, which Polars relies on for fast database access.

You can install it if needed as follows:

pip install polars psycopg2-binary connectorx

The database URI is as follows:

uri = "postgresql://user:password@localhost:5433/test-db"
import polars as pl
from contextlib import contextmanager
import psycopg2

We use two approaches throughout this post: psycopg2 for DDL statements (schema creation, extensions) and ingestion, and pl.read_database_uri for all SELECT queries. Polars’ write_database() could handle plain inserts, but it requires sqlalchemy as an additional dependency and does not support custom type casts like ::vector or to_tsvector() in the INSERT template, so we use psycopg2 directly for ingestion.

We define a helper function with a context manager to handle DDL and writes:

@contextmanager
def get_cursor(uri):
    conn = psycopg2.connect(uri)
    cur = conn.cursor()
    try:
        yield cur
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        cur.close()
        conn.close()

def execute(query: str, uri: str):
    with get_cursor(uri=uri) as cursor:
        return cursor.execute(query)

Then enable the extension for dense embedding:

from functools import partial
pg_cursor = partial(execute, uri=uri)
pg_cursor(query="CREATE EXTENSION IF NOT EXISTS vector;")

Schema

Store both dense and sparse representations in the same table:

  • id for the document
  • the date of creation created_at
  • document for storing the document
  • dense_vector to store the dense embedding
  • sparse_vector to store the sparse representation of the document
pg_cursor("""
DROP TABLE IF EXISTS vector_store;
CREATE TABLE vector_store (
  id           bigint GENERATED ALWAYS AS IDENTITY,
  created_at   timestamp with time zone DEFAULT now(),
  document     text,
  dense_vector  vector,
  sparse_vector tsvector
);
""")

Document ingestion

from psycopg2.extras import execute_values

data = [
    {"document": "First document",  "dense_vector": [0.1, 0.2, 0.3]},
    {"document": "Second document", "dense_vector": [0.4, 0.5, 0.6]},
]
with get_cursor(uri) as cur:
    execute_values(
        cur,
        """
        INSERT INTO vector_store (document, dense_vector, sparse_vector)
        VALUES %s
        """,
        [(d["document"], d["dense_vector"], d["document"]) for d in data],
        template="(%s, %s::vector, to_tsvector(%s))"
    )

to_tsvector handles the sparse vector conversion - more on this later.

Let us check the database elements:

df = pl.read_database_uri(
    "SELECT id, created_at, document, dense_vector, sparse_vector::text FROM vector_store",
    uri
)
df
shape: (2, 5)
id created_at document dense_vector sparse_vector
i64 datetime[μs, UTC] str list[f32] str
1 2026-03-16 17:05:18.862610 UTC "First document" [0.1, 0.2, 0.3] "'document':2 'first':1"
2 2026-03-16 17:05:18.862610 UTC "Second document" [0.4, 0.5, 0.6] "'document':2 'second':1"

3. Hybrid Search with Reciprocal Rank Fusion (RRF)

RRF merges ranked lists from dense and sparse searches into a single score, without needing to tune weights:

score_rrf = 1 / (alpha + rank)

Each document’s final score is the sum of its RRF scores across both lists. Documents appearing in both lists are naturally boosted.

For this, we define a function rrf_results to compute RRF results and a function to encapsulate the hybrid search called hybrid_search.

def rrf_results(dense_df, sparse_df, alpha=60):
    dense_df  = dense_df.with_columns((1 / (alpha + pl.col("rank"))).alias("score"))
    sparse_df = sparse_df.with_columns((1 / (alpha + pl.col("rank"))).alias("score"))

    combined = pl.concat([
        dense_df.select(["id", "document", "score"]),
        sparse_df.select(["id", "document", "score"]),
    ])

    return (
        combined
        .group_by("id")
        .agg(pl.col("score").sum(), pl.col("document").first())
        .sort("score", descending=True)
    )

def hybrid_search(query: str, query_vector: list[float], n: int = 5, alpha: int = 60):
    dense_df  = dense_search(query_vector, n=n)
    sparse_df = sparse_search(query, n=n)
    return rrf_results(dense_df, sparse_df, alpha=alpha)

We can then run the hybrid search as follows:

hybrid_search("Second document", [0.4, 0.5, 0.6])
shape: (2, 3)
id score document
i64 f64 str
2 0.032787 "Second document"
1 0.016129 "First document"

Key Takeaways

  • Dense search (pgvector) captures semantic similarity and is good for paraphrases and concepts.
  • Sparse search (tsvector) captures exact keyword matches and is good for proper nouns and rare terms.
  • Hybrid search (RRF) combines the best of both worlds.

References