Building a Hybrid Search System with Laravel, OpenAI, and PostgreSQL

February 28, 2025

development

I've been working through a few ways to build site search with Laravel. One uses Elasticsearch. This approach keeps the searchable text and vector embeddings together in PostgreSQL.

The question is pretty simple: can I find pages that match the words someone typed and pages about the same subject when they use different words?

PostgreSQL already has text search. Add pgvector and the same page record can also hold an embedding. Laravel coordinates the crawling, indexing, and queries. The extra service in this version is the API that generates those embeddings.

Two ways to find a page

Text search is useful when someone knows the term they're looking for. Vector search gives you another signal based on an embedding of the query and an embedding of the page.

Say someone types “protecting my online store.” An article titled “E-commerce security” is a plausible candidate even if it doesn't use that exact phrasing. Whether it should rank highly depends on the article and the other results. An embedding gives you something to compare; it doesn't certify that the page answers the question.

I want both signals available, rather than making every query depend on one of them.

Preparing the records

For an existing pages table, the search data needs somewhere to live:

CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE pages ADD COLUMN embedding vector(1536);
ALTER TABLE pages ADD COLUMN searchable_text tsvector;
CREATE INDEX pages_searchable_text_idx
  ON pages USING GIN (searchable_text);

The database server needs pgvector installed before the extension can be enabled. The 1536 dimensions belong to the embedding model used in the original implementation, text-embedding-ada-002. Another model may need a different column size. Query and page embeddings need to use the same model and dimensions.

The crawler extracts a page's title, description, and text. The indexing step generates its embedding and builds the text-search representation. Titles can receive more weight than body content when preparing the tsvector; PostgreSQL's text-search documentation covers that weighting.

Those derived fields need to be updated when the source content changes. A stale embedding is still an embedding of the old page.

Ranking the results

The initial approach combined cosine distance with a text-search rank. Here's a simplified Laravel query showing that arithmetic, assuming the fields have already been populated and $queryEmbedding is a pgvector-compatible vector string:

$results = DB::select(
    "SELECT id, title, url
     FROM pages
     WHERE site_id = ?
       AND embedding IS NOT NULL
       AND searchable_text IS NOT NULL
     ORDER BY (
         (1 - (embedding <=> CAST(? AS vector)) / 2) * 0.5
         + ts_rank_cd(
             searchable_text,
             plainto_tsquery('english', ?),
             32
         ) * 0.5
     ) DESC
     LIMIT 10",
    [$siteId, $queryEmbedding, $query]
);

This illustrates a starting formula, not a relevance percentage. Equal coefficients don't establish that the two signals have equal influence on your results.

There is another important limitation: the query scores every eligible page. It doesn't restrict candidates to text matches or promise that a nearest-neighbor index will accelerate the combined expression. For a larger collection, retrieve text and vector candidates separately and compare ways to combine them. pgvector's hybrid search guidance points to reciprocal rank fusion as one option.

If you add a vector index, its distance operator needs to match the query. This query uses cosine distance (<=>), not L2 distance (<->). That's an easy detail to miss when copying an index example.

The work around the query

Embeddings add an API call to indexing and to queries. Queues and retries handle the indexing work. Caching an embedding can avoid repeating the call for identical input, but the cache needs to account for the model too.

Large pages may need to be split into chunks. Combining all of a page's subjects into one vector is a different choice from retrieving individual passages. It's worth checking which one fits the content you actually want to find.

There are also records without embeddings, failed API calls, and language-specific text-search settings to account for. Keeping a text-only search path available gives the application something to use when vector retrieval isn't available.

The thing to test is the result order. Pick queries people actually make, inspect what each approach returns, and see where combining them helps or hurts. Changing a weight is easy. Knowing whether it improved the search takes more work.

For a more conventional Laravel integration, I also wrote about Meilisearch and Scout. The interesting part of this approach is having both retrieval signals in the database and being able to work on how they interact.