Skip to content

Combining Keyword and Vector Search

This recipe shows how to rank text by its keywords and by its meaning at the same time. A search by meaning alone can miss an exact term, such as a product name or an error code, that a keyword search finds at once; hybrid search runs both and merges the two rankings. The script vectorizes a table of three rows, waits for the background worker, and then calls pgedge_vectorizer.hybrid_search_simple() with a question.

These examples are for pgEdge Vectorizer 1.1.

The script prints the following output:

Chunks with keyword vectors: 3
Question: Which database stores tables?
Top match (row 3): PostgreSQL stores data in tables and answers questions written in SQL.

The vector half of the ranking depends on the embedding model and on the build of the model. The rows in this example cover subjects far enough apart, and share enough keywords with the question, that the top match should not change, but the order of the other rows can. The output above came from Ollama's nomic-embed-text model with the digest 0a109f422b47.

Preparing the Ingredients

The script needs a PostgreSQL server with the vector and pgedge_vectorizer extensions, and an embedding model that the server can reach. The Vectorizing a Table recipe lists the server settings that the examples use. Hybrid search also needs the following setting, which tells the background worker to compute a sparse BM25 vector for each chunk alongside its embedding:

pgedge_vectorizer.enable_hybrid = on

The setting must apply to the background worker as well as to the session that searches, so set it in postgresql.conf and reload the configuration, or set it on the server's command line, rather than in the session alone. Chunks that the worker embedded before the setting took effect have no sparse vector until pgedge_vectorizer.reprocess_chunks() queues them again.

Configuring psql

The script prints plain lines, and it hides the NOTICE messages that the Vectorizer functions raise. A failed step must not stop the script before the cleanup at the end, so the script turns off ON_ERROR_STOP until the cleanup starts. The following code shows the settings:

-- Print plain lines, and hide the NOTICE messages that the Vectorizer
-- functions raise as they work.
\pset format unaligned
\pset tuples_only on
SET client_min_messages = warning;

-- Keep going after a failed step, so that the cleanup at the end of the
-- script always runs; the script fails at the end instead.
\set ON_ERROR_STOP off

Vectorizing the Rows

The script creates a table with a primary key, inserts three rows, and calls enable_vectorization() for the text column, which creates the cookbook_hybrid_search_content_chunks table and queues each chunk for the background worker. The following code creates and vectorizes the table:

-- Three rows about unrelated subjects, so that the right answer is clear
-- whichever build of the embedding model is in use.
CREATE TABLE cookbook_hybrid_search (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    content text NOT NULL
);

INSERT INTO cookbook_hybrid_search (content) VALUES
    ('Knead the bread dough for ten minutes, then leave it to rise in a warm kitchen.'),
    ('Jupiter is the largest planet in the solar system, with dozens of moons.'),
    ('PostgreSQL stores data in tables and answers questions written in SQL.');

-- The function returns void, so the query selects no columns, which
-- prints nothing.
SELECT FROM pgedge_vectorizer.enable_vectorization(
    'cookbook_hybrid_search', 'content');

Waiting for the Embeddings

The script waits in a DO block that checks the queue once a second. The block stops when no item for the chunk table is pending or in progress, and raises an error if an item fails or the wait passes five minutes; a model that is not yet in memory can take some time to answer its first request. The worker computes each chunk's embedding and sparse vector together, so one wait covers both. The following code waits for the worker:

-- Wait until the background worker has embedded every queued chunk of the
-- table, and fail if an item fails or the wait takes over five minutes. A
-- cold embedding model can take a while to answer its first request.
DO $$
DECLARE
    deadline timestamptz := clock_timestamp() + interval '300 seconds';
    failure text;
BEGIN
    LOOP
        SELECT error_message INTO failure
        FROM pgedge_vectorizer.failed_items
        WHERE chunk_table = 'cookbook_hybrid_search_content_chunks'
        LIMIT 1;
        IF FOUND THEN
            RAISE EXCEPTION 'embedding failed: %', failure;
        END IF;

        EXIT WHEN NOT EXISTS (
            SELECT 1
            FROM pgedge_vectorizer.queue_status
            WHERE chunk_table = 'cookbook_hybrid_search_content_chunks'
              AND status IN ('pending', 'processing'));

        IF clock_timestamp() > deadline THEN
            -- Report the latest error that the worker recorded for the
            -- table; a request that hangs records none.
            SELECT error_message INTO failure
            FROM pgedge_vectorizer.queue
            WHERE chunk_table = 'cookbook_hybrid_search_content_chunks'
              AND error_message IS NOT NULL
            ORDER BY next_retry_at DESC NULLS LAST, id DESC
            LIMIT 1;
            RAISE EXCEPTION 'timed out waiting for the embeddings: %',
                coalesce(failure, 'no error recorded; check that '
                    || 'pgedge_vectorizer.databases lists this database '
                    || 'and that pgedge_vectorizer.api_url reaches the '
                    || 'embedding provider');
        END IF;
        PERFORM pg_sleep(1);
    END LOOP;
END
$$;

Checking the Keyword Vectors

The worker stores the sparse BM25 vector of each chunk in the sparse_embedding column of the chunk table, and leaves the column empty when enable_hybrid is off. The following code counts the chunks that have a sparse vector:

-- The worker computes a sparse BM25 vector for each chunk, as well as its
-- embedding, only when pgedge_vectorizer.enable_hybrid is on.
SELECT 'Chunks with keyword vectors: ' || count(sparse_embedding)
FROM cookbook_hybrid_search_content_chunks;

Searching by Keyword and Meaning

The hybrid_search_simple() function takes the source table rather than the chunk table, and looks up the chunk table in the extension's registry. The function ranks the chunks by the cosine distance of their embeddings from the question's embedding, and separately by their BM25 keyword score, and then merges the two lists with Reciprocal Rank Fusion (RRF). Each result carries the source row's primary key as text, the chunk, and the rrf_score, which is highest for the best match. The pgedge_vectorizer.hybrid_search() function takes the same arguments with two more, for the weight of the vector ranking and the RRF constant, and also returns each chunk's rank in both lists.

The script keeps the question in a psql variable, and psql writes the value into the query as a quoted string literal, escaping any quotes inside it. The following code runs the search:

-- psql quotes the variable as a string literal where :'question' appears,
-- escaping any quotes inside it.
\set question 'Which database stores tables?'
\echo Question: :question

-- hybrid_search_simple() finds the chunk table from the source table,
-- ranks the chunks by keyword (BM25) and by vector distance, and returns
-- them in order of the combined Reciprocal Rank Fusion (RRF) score, best
-- first. The third argument limits the number of results.
SELECT 'Top match (row ' || source_id || '): ' || chunk
FROM pgedge_vectorizer.hybrid_search_simple(
    'cookbook_hybrid_search', :'question', 3)
ORDER BY rrf_score DESC
LIMIT 1;

Cleaning Up

The disable_vectorization() function drops the triggers and, with drop_chunk_table, the chunk table and its table of keyword statistics, and it removes the table's pending items from the queue. Completed items stay in the queue; the clear_completed(0) function would remove them at once, but for every vectorized table in the database, so the script deletes only the items for its own chunk table. The script then checks psql's LAST_ERROR_SQLSTATE variable, and raises an error if an earlier step failed, so that psql exits with a non-zero status. The following code removes everything that the script created:

-- Stop at the first error from here on, so that a failed cleanup fails
-- the script.
\set ON_ERROR_STOP on

-- Drop the triggers and the chunk table, and then remove the table's
-- completed queue items, which disable_vectorization() leaves behind.
SELECT FROM pgedge_vectorizer.disable_vectorization(
    'cookbook_hybrid_search', 'content', drop_chunk_table => true);
DELETE FROM pgedge_vectorizer.queue
WHERE chunk_table = 'cookbook_hybrid_search_content_chunks';
DROP TABLE cookbook_hybrid_search;

-- Fail the script if any earlier step failed; psql keeps the SQLSTATE of
-- the most recent error, or 00000 if there was none.
SELECT :'LAST_ERROR_SQLSTATE' <> '00000' AS failed \gset
\if :failed
DO $$ BEGIN RAISE EXCEPTION 'a step of the recipe failed'; END $$;
\endif

Running the Example

The script is available in the cookbook repository. Set the PG* environment variables for your server, and then run the following command from the recipe's sql directory:

psql -X -q -v ON_ERROR_STOP=1 -f main.sql

Viewing the Complete Program

The following code shows the complete script:

-- Ellie's Postgres Cookbook
-- Combining keyword and vector search with pgEdge Vectorizer, which merges
-- a BM25 keyword ranking with a vector ranking of the same chunks.
--
-- Copyright (c) 2026, pgEdge, Inc.
-- This code is released under the PostgreSQL License.

-- Print plain lines, and hide the NOTICE messages that the Vectorizer
-- functions raise as they work.
\pset format unaligned
\pset tuples_only on
SET client_min_messages = warning;

-- Keep going after a failed step, so that the cleanup at the end of the
-- script always runs; the script fails at the end instead.
\set ON_ERROR_STOP off

-- Three rows about unrelated subjects, so that the right answer is clear
-- whichever build of the embedding model is in use.
CREATE TABLE cookbook_hybrid_search (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    content text NOT NULL
);

INSERT INTO cookbook_hybrid_search (content) VALUES
    ('Knead the bread dough for ten minutes, then leave it to rise in a warm kitchen.'),
    ('Jupiter is the largest planet in the solar system, with dozens of moons.'),
    ('PostgreSQL stores data in tables and answers questions written in SQL.');

-- The function returns void, so the query selects no columns, which
-- prints nothing.
SELECT FROM pgedge_vectorizer.enable_vectorization(
    'cookbook_hybrid_search', 'content');

-- Wait until the background worker has embedded every queued chunk of the
-- table, and fail if an item fails or the wait takes over five minutes. A
-- cold embedding model can take a while to answer its first request.
DO $$
DECLARE
    deadline timestamptz := clock_timestamp() + interval '300 seconds';
    failure text;
BEGIN
    LOOP
        SELECT error_message INTO failure
        FROM pgedge_vectorizer.failed_items
        WHERE chunk_table = 'cookbook_hybrid_search_content_chunks'
        LIMIT 1;
        IF FOUND THEN
            RAISE EXCEPTION 'embedding failed: %', failure;
        END IF;

        EXIT WHEN NOT EXISTS (
            SELECT 1
            FROM pgedge_vectorizer.queue_status
            WHERE chunk_table = 'cookbook_hybrid_search_content_chunks'
              AND status IN ('pending', 'processing'));

        IF clock_timestamp() > deadline THEN
            -- Report the latest error that the worker recorded for the
            -- table; a request that hangs records none.
            SELECT error_message INTO failure
            FROM pgedge_vectorizer.queue
            WHERE chunk_table = 'cookbook_hybrid_search_content_chunks'
              AND error_message IS NOT NULL
            ORDER BY next_retry_at DESC NULLS LAST, id DESC
            LIMIT 1;
            RAISE EXCEPTION 'timed out waiting for the embeddings: %',
                coalesce(failure, 'no error recorded; check that '
                    || 'pgedge_vectorizer.databases lists this database '
                    || 'and that pgedge_vectorizer.api_url reaches the '
                    || 'embedding provider');
        END IF;
        PERFORM pg_sleep(1);
    END LOOP;
END
$$;

-- The worker computes a sparse BM25 vector for each chunk, as well as its
-- embedding, only when pgedge_vectorizer.enable_hybrid is on.
SELECT 'Chunks with keyword vectors: ' || count(sparse_embedding)
FROM cookbook_hybrid_search_content_chunks;

-- psql quotes the variable as a string literal where :'question' appears,
-- escaping any quotes inside it.
\set question 'Which database stores tables?'
\echo Question: :question

-- hybrid_search_simple() finds the chunk table from the source table,
-- ranks the chunks by keyword (BM25) and by vector distance, and returns
-- them in order of the combined Reciprocal Rank Fusion (RRF) score, best
-- first. The third argument limits the number of results.
SELECT 'Top match (row ' || source_id || '): ' || chunk
FROM pgedge_vectorizer.hybrid_search_simple(
    'cookbook_hybrid_search', :'question', 3)
ORDER BY rrf_score DESC
LIMIT 1;

-- Stop at the first error from here on, so that a failed cleanup fails
-- the script.
\set ON_ERROR_STOP on

-- Drop the triggers and the chunk table, and then remove the table's
-- completed queue items, which disable_vectorization() leaves behind.
SELECT FROM pgedge_vectorizer.disable_vectorization(
    'cookbook_hybrid_search', 'content', drop_chunk_table => true);
DELETE FROM pgedge_vectorizer.queue
WHERE chunk_table = 'cookbook_hybrid_search_content_chunks';
DROP TABLE cookbook_hybrid_search;

-- Fail the script if any earlier step failed; psql keeps the SQLSTATE of
-- the most recent error, or 00000 if there was none.
SELECT :'LAST_ERROR_SQLSTATE' <> '00000' AS failed \gset
\if :failed
DO $$ BEGIN RAISE EXCEPTION 'a step of the recipe failed'; END $$;
\endif

Next Steps

The following resources provide more information about the topics in this recipe: