View of the entire near-infrared sky

From Keywords to Concepts: Semantic Search in Laravel with pgvector

This is the post version of the talk I gave at PHP Tek this year: how to retrofit semantic search into an existing Laravel application without a rewrite, a vector database, or a machine-learning team. Everything below is running in production today on DailyMedToday, my daily-meditation app.

The architecture

The core idea is that I store an embedding alongside each row in PostgreSQL using the pgvector extension. Then, at search time, I also embed the user’s query and let Postgres rank rows by vector distance.

The database is already the content’s system of record; pgvector lets it be the system of record for the content’s meaning, too. No second datastore to keep in sync, no new operational surface, no unnecessary complexity.

Setting up pgvector

CREATE EXTENSION IF NOT EXISTS vector;

Then a migration adds the column. The dimension must match the embedding model’s output — 1,024 in my case:

<?php
Schema::table('meditations', function (Blueprint $table) {
    $table->vector('embedding', dimensions: 1024)->nullable();
});

DB::statement(
    'CREATE INDEX meditations_embedding_idx ON meditations
     USING hnsw (embedding vector_cosine_ops)'
);

The HNSW index is what keeps nearest-neighbor queries fast as the table grows. Without it, every search is a sequential scan over every vector.

Generating embeddings on write

Content gets a new new embedding when it’s created or updated — a queued job keeps this operation off the request path for speed:

<?php
class EmbedMeditation implements ShouldQueue
{
    public function handle(Embedder $embedder): void
    {
        $vector = $embedder->embed(
            $this->meditation->title . "\n\n" . $this->meditation->body
        );

        $this->meditation->update(['embedding' => $vector]);
    }
}

Behind that Embedder interface you can put a cloud API or a local model — my initial target was a fast and free cloud-hosted model. The important discipline is that you must use one model for everything. Queries and documents must share an embedding space, so version the model name alongside the vectors and plan a re-embed job for any model change.

Querying by meaning

At search time, embed the query and order by cosine distance (pgvector’s <=> operator):

<?php
$queryVector = $embedder->embed($request->input('q'));

$results = Meditation::query()
    ->select('*')
    ->selectRaw('embedding <=> ? AS distance', [$queryVector])
    ->whereNotNull('embedding')
    ->orderBy('distance')
    ->limit(10)
    ->get();

A user types “I’m struggling with patience at work” and gets meditations about patience — but also perseverance, trusting God’s timing, and workplace frustration. None of those needed the word “patience” in them. The connection is conceptual, and the geometry carries it.

Hybrid search: don’t throw away keywords

Semantic search has a known weak spot: exact identifiers. Product SKUs, scripture references, proper nouns — a user searching “Psalm 23” wants Psalm 23, not things that feel pastorally similar to it. Production search should be hybrid: run Postgres full-text search and vector search in parallel and blend the rankings (reciprocal rank fusion is simple and works well). Keywords catch the literal; vectors catch the conceptual; the blend beats either alone.

Costs and gotchas from production

  • Embedding is the slow part, not search. Queries against tens of thousands of vectors return in single-digit milliseconds with HNSW. Generating the query embedding dominates latency, which is yet another argument for local models.
  • Chunk long content. Embedding models have context limits, and one vector for a 5,000-word document averages its meaning into mush. Embed sections then link them back to the parent row.
  • Normalize your text first. Strip markup and boilerplate before embedding. The model embeds what you give it, navigation chrome included.

Next up: those 1,024-dimensional vectors are impossible to look at — until you flatten them. Dimensionality reduction via UMAP makes things a bit more understandable and provides a first glimpse of what a content archive looks like as a map.