Laravel 13 whereVectorSimilarTo() — Native Semantic Search in the Query Builder

Add native semantic search to Laravel 13 with whereVectorSimilarTo, pgvector and the AI SDK. Covers migrations, embeddings, queued backfill, and hybrid search.

Steven Richardson
Steven Richardson
· 9 min read

A user typed "best wineries in Napa Valley" into the search box on a knowledge base I built last quarter. The article they wanted was titled "Top Vineyards to Visit." A LIKE '%winer%' query found nothing. Even a Postgres full-text index missed it — there's no shared keyword. That mismatch is what semantic search exists to solve, and until Laravel 13 it meant standing up a separate vector pipeline: a Python service to embed, a vector store to query, and glue code to reconcile results back to Eloquent.

Laravel 13 collapses all of that into one query builder method. whereVectorSimilarTo() accepts a plain string, embeds it through the Laravel AI SDK, runs cosine similarity against a pgvector column, and gives you back an Eloquent collection ranked by meaning. Here's the full setup from composer require to a working hybrid search.

Install the Laravel AI SDK#

whereVectorSimilarTo() ships with Laravel 13's core query builder, but it leans on the AI SDK for the embedding step. The SDK is a separate package — install it first, publish the config and migration, and run the database migrations. If you're still on Laravel 12, the Laravel 12 to 13 upgrade guide walks through the breaking changes you'll hit before any of this works.

composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

Set your provider key in .env so the embedding calls have somewhere to go:

OPENAI_API_KEY=sk-...
AI_EMBEDDINGS_PROVIDER=openai

The SDK supports OpenAI, Gemini, Cohere, and Jina for embeddings out of the box. Swap providers later by changing one env var — the query builder API doesn't care which model produced the vector as long as the dimensions line up.

Enable pgvector and add a vector column#

Vector search needs the pgvector extension. Laravel Cloud Serverless Postgres ships with it enabled, but on self-hosted Postgres you need either superuser access or a one-time migration that calls Schema::ensureVectorExtensionExists(). That helper issues CREATE EXTENSION IF NOT EXISTS vector for you, so it's safe to keep at the top of the migration that creates the table.

Generate a migration and define a vector column with the dimensions that match your embedding model — 1536 for OpenAI's text-embedding-3-small, 768 for nomic-embed-text, etc. Adding ->index() builds an HNSW index that keeps similarity queries fast as the table grows.

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::ensureVectorExtensionExists();

        Schema::create('knowledge_articles', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('body');
            $table->vector('embedding', dimensions: 1536)->nullable()->index();
            $table->timestamps();
        });
    }
};

The column is nullable deliberately — you'll create rows before their embedding exists, and the backfill job (next-but-one step) fills it in. Without nullable, every insert would need an embedding upfront, which couples writes to a paid API call.

Configure the embeddings provider#

Open config/ai.php and confirm the embeddings stanza points at the model you want. The dimensions value here must match the migration above — if they drift, the insert will fail with a Postgres dimension mismatch and you'll spend an hour confused.

'embeddings' => [
    'default' => env('AI_EMBEDDINGS_PROVIDER', 'openai'),

    'providers' => [
        'openai' => [
            'driver' => 'openai',
            'model' => 'text-embedding-3-small',
            'dimensions' => 1536,
        ],
    ],
],

text-embedding-3-small is a sensible default at $0.02 per million tokens. If you need higher quality and can absorb the cost, switch to text-embedding-3-large — but remember to bump the migration's dimensions to 3072 and rebackfill, because OpenAI's models do not produce comparable vectors across sizes.

Cast the vector column on your model#

Add the embedding cast on the Eloquent model so Laravel handles the conversion between PHP arrays and the on-disk vector(1536) format. Without the cast, the attribute comes back as a pgvector literal string and your code has to parse it by hand.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class KnowledgeArticle extends Model
{
    protected $guarded = [];

    protected function casts(): array
    {
        return [
            'embedding' => 'array',
        ];
    }
}

That's all the plumbing. From here the model behaves like any other Eloquent record — $article->embedding reads back as a plain PHP array, and assigning an array of floats to it persists correctly.

Backfill embeddings for existing records#

If the table already contains rows when you ship this feature, you need to generate embeddings for them. Doing it inline in a migration is a trap — it ties the deploy to the embedding API's availability and rate limits, and a flaky moment locks the database. Use a queued job instead and chunk the rows. The pattern below batches inputs to the API (one HTTP call per chunk instead of one per row), which is both faster and cheaper.

namespace App\Jobs;

use App\Models\KnowledgeArticle;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Laravel\Ai\Embeddings;

class GenerateArticleEmbeddings implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * @param  array<int, int>  $articleIds
     */
    public function __construct(public array $articleIds) {}

    public function handle(): void
    {
        $articles = KnowledgeArticle::whereIn('id', $this->articleIds)->get();

        $inputs = $articles
            ->map(fn (KnowledgeArticle $a) => $a->title.' '.$a->body)
            ->all();

        $response = Embeddings::for($inputs)->generate();

        foreach ($articles as $i => $article) {
            $article->embedding = $response->embeddings[$i];
            $article->save();
        }
    }
}

Dispatch the job from a one-off Artisan command, fanning the rows out in chunks of 100:

KnowledgeArticle::whereNull('embedding')
    ->select('id')
    ->chunkById(100, function ($chunk) {
        GenerateArticleEmbeddings::dispatch($chunk->pluck('id')->all());
    });

For a large table, run this on a dedicated queue with rate-limited workers — embedding APIs all enforce per-minute quotas. If you don't already have queue workers tuned for production, the breakdown in scaling Laravel queues in production covers Horizon configuration and graceful restart strategies.

For new rows going forward, dispatch a single-row version of this job from a model observer's created and updated hooks. Keep the embedding generation off the request thread.

Run a similarity search with whereVectorSimilarTo#

This is the payoff. Pass the vector column name and the user's query string, set a minSimilarity threshold, and you get back a relevance-sorted Eloquent collection. The threshold is a cosine similarity between 0.0 and 1.0 — 1.0 is identical, 0.0 is unrelated. In practice 0.3–0.5 is the useful range; any higher and you'll filter out near-misses that users would still find relevant.

$results = KnowledgeArticle::query()
    ->whereVectorSimilarTo('embedding', request('q'), minSimilarity: 0.4)
    ->limit(5)
    ->get();

Behind the scenes, Laravel calls Embeddings::for() with the string, runs embedding <=> :query cosine distance against the indexed column, filters by the threshold, and orders by similarity. If you already have a vector you generated elsewhere — say a profile vector for "more like this" recommendations — pass it as an array instead and the embedding step is skipped:

$similarToProfile = KnowledgeArticle::query()
    ->whereVectorSimilarTo('embedding', $profile->embedding, minSimilarity: 0.5)
    ->limit(20)
    ->get();

You can mix vector similarity with regular where clauses to scope the search. This is the pattern I reach for most often — semantic search restricted to the current team or category:

$results = KnowledgeArticle::query()
    ->where('team_id', auth()->user()->team_id)
    ->where('status', 'published')
    ->whereVectorSimilarTo('embedding', request('q'), minSimilarity: 0.4)
    ->limit(10)
    ->get();

Combine vector similarity with full-text search for hybrid results#

Pure semantic search occasionally surprises you — it returns a thematically similar article when the user typed an exact product code. Pure full-text search misses meaning. The "retrieve then rerank" pattern uses full-text to grab a generous candidate pool quickly, then has the AI SDK rerank them by semantic relevance. You get keyword precision and meaning-aware ordering in one query.

$results = KnowledgeArticle::query()
    ->whereFullText('body', request('q'))
    ->limit(50)
    ->get()
    ->rerank('body', request('q'), limit: 10);

The rerank collection macro is shipped with the AI SDK and uses your configured reranking provider (Cohere or Jina at time of writing) to reorder the candidates. It's two API calls — embedding for full-text, rerank for ordering — but each is on a small candidate set, so latency stays bounded.

For a deeper end-to-end RAG flow that pairs this retrieval with answer generation, the walkthrough in building a RAG pipeline with the Laravel AI SDK and pgvector shows how to feed the retrieved chunks back into a chat completion.

Gotchas and edge cases#

A few snags I've hit moving this into production:

Dimension drift. If you change embedding models, every previously stored vector becomes incomparable garbage — cosine similarity against vectors from a different model returns nonsense scores. Treat the model as part of the schema and bake it into the migration filename. Re-embedding 100k rows takes hours.

MySQL has no equivalent. whereVectorSimilarTo() is Postgres-only. There's no compatibility shim. If your app runs on MySQL and you need semantic search, either move the searchable corpus to a Postgres replica, or use Laravel Prism with an external vector store. The introduction in Getting Started With Laravel Prism covers that escape hatch.

HNSW index build time. Creating the index on a table with millions of rows can take minutes and locks writes during the build. On a live system, build the index CONCURRENTLY via a raw statement instead of letting the migration block: CREATE INDEX CONCURRENTLY ... USING hnsw (embedding vector_cosine_ops). Then drop and reissue the index in maintenance windows when you change m or ef_construction parameters.

The threshold is data-dependent. Don't ship minSimilarity: 0.4 because a tutorial says so. Generate a few hundred query/result pairs, score them by hand, and pick the threshold where the precision/recall trade-off matches your product. A FAQ search wants high precision (0.5+); a discovery feed wants recall (0.3).

Cost per query. Every search embeds the query string. At 100 searches/second with text-embedding-3-small you're looking at roughly $5 per million queries — small but not zero. Cache embeddings for repeated query strings if you have a hot tail; the AI SDK's cache driver makes this a config flag.

Wrapping Up#

whereVectorSimilarTo() is the smallest API surface for semantic search I've used in any framework — one method call, plain strings in, ranked rows out. The hard parts are operational: embedding model lock-in, index tuning, and threshold calibration. The query builder doesn't help with those, but it gets the integration cost down to a single afternoon.

If you want to go deeper, the natural next step is wiring this into a full retrieval-augmented generation flow with Laravel AI SDK and pgvector. The complete AI SDK guide covers the surrounding agent, image, and audio APIs you'll likely want next.

FAQ#

What is whereVectorSimilarTo() in Laravel 13?

whereVectorSimilarTo() is a query builder method introduced in Laravel 13 that performs vector similarity search against a pgvector column. It accepts a column name and either a plain string (which Laravel embeds for you) or a pre-computed embedding array, runs cosine similarity in Postgres, filters by a minSimilarity threshold, and auto-orders results by relevance. It works on the Eloquent query builder and the base DB::table() builder.

How does Laravel 13 generate embeddings under the hood?

When you pass a plain string to whereVectorSimilarTo(), Laravel calls the AI SDK's Embeddings::for() API with your configured provider (OpenAI, Gemini, Cohere, or Jina) and uses the returned vector for the similarity comparison. The same pipeline drives Str::of('...')->toEmbeddings() for one-off embeddings and Embeddings::for([...])->generate() for batched generation. The provider, model, and dimensions are configured in config/ai.php.

Do I need pgvector enabled to use whereVectorSimilarTo?

Yes. whereVectorSimilarTo() compiles to a pgvector cosine distance query and only works against a Postgres connection with the extension enabled. Laravel Cloud Serverless Postgres includes pgvector by default. On self-hosted Postgres, run Schema::ensureVectorExtensionExists() from a migration or CREATE EXTENSION vector; once with a superuser to enable it.

Can I use whereVectorSimilarTo with MySQL?

No. MySQL has no equivalent vector type or cosine distance operator, so the query builder method has no MySQL implementation. If your application is on MySQL and you need semantic search, the practical options are running a read replica or sidecar Postgres database for vector data, or using a third-party vector store like Pinecone or Qdrant via the Laravel AI SDK or Laravel Prism.

How do I limit semantic search results by similarity threshold?

Pass a minSimilarity argument between 0.0 and 1.0 — 1.0 is identical, 0.0 is unrelated, and 0.3–0.5 is the typical useful range. For example, ->whereVectorSimilarTo('embedding', $query, minSimilarity: 0.4) excludes any row whose cosine similarity with the query embedding is below 0.4. Pick the threshold empirically against a labeled set of queries; a FAQ surface usually needs higher precision than a discovery feed.

Does whereVectorSimilarTo work with Eloquent scopes?

Yes. whereVectorSimilarTo() is a query builder method, so you can wrap it in a local scope, chain it after global scopes, and combine it with other where clauses or eager loading like any other constraint. A common pattern is exposing a scopeSemanticSearch($query, string $term) method on the model that calls whereVectorSimilarTo() with your team's tuned threshold, so callers don't need to remember the right value.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.