{"slug": "laravel-13-vector-search-semantic-queries-without-a-search-service", "title": "Laravel 13 Vector Search: Semantic Queries Without a Search Service", "summary": "Laravel 13 ships vector columns, an Eloquent `AsVector` cast and a `whereVectorSimilarTo` query builder method in the framework itself, letting developers run semantic search inside PostgreSQL with the pgvector extension or MariaDB 11.7 and later instead of adding a dedicated vector database or hosted search service. The feature depends on the first-party Laravel AI SDK to generate embeddings, typically 1,536-float arrays for OpenAI's `text-embedding-3-small`, and `index()` on a vector column creates an HNSW index using cosine distance. Queries filter with `minSimilarity` between 0.0 and 1.0 and return closest matches first, with Laravel embedding a plain string query through the configured provider.", "body_md": "A user types \"cheap places to stay near the beach\" into your help center search, and your `LIKE '%beach%'` query returns an article about beach volleyball rules. Meanwhile, the article titled \"Budget Coastal Rentals\" sits unfound because none of its words match. Full-text search helps with stemming, but it still matches words, not meaning.\n\nThe usual fix has been to bolt on a dedicated vector database or a hosted search service, then write a sync pipeline to keep it in step with your models. Laravel 13 removes most of that work. Vector columns, an Eloquent cast, and a `whereVectorSimilarTo` query builder method now ship in the framework, so semantic search can live in the database you already run.\n\n## What you need before you start \n\nVector search in Laravel 13 depends on two things. First, a database that can store and compare vectors: PostgreSQL with the `pgvector` extension or MariaDB 11.7 and later (MongoDB works through the Laravel MongoDB package). Second, the first-party [Laravel AI SDK](https://laravel.com/docs/13.x/ai-sdk), which handles calling an embedding provider and turning text into arrays of numbers.\n\nAn embedding is a long array of floats, often 1,536 of them, that encodes what a piece of text means. Texts with similar meanings land close together in that space, so \"budget coastal rentals\" and \"cheap places near the beach\" end up neighbors even with zero shared words.\n\nThe migration side is short:\n\n``` php\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nSchema::ensureVectorExtensionExists();\n\nSchema::create('articles', function (Blueprint $table) {\n    $table->id();\n    $table->foreignId('team_id')->constrained();\n    $table->string('title');\n    $table->text('body');\n    $table->vector('embedding', dimensions: 1536)->nullable()->index();\n    $table->timestamps();\n});\n```\n\n`ensureVectorExtensionExists()` enables `pgvector` if it is not already on, and calling `index()` on a vector column creates an HNSW index using cosine distance. The `dimensions` value has to match your embedding model exactly, so 1536 for OpenAI's `text-embedding-3-small`. Swap models later and you are re-embedding every row, so pick deliberately.\n\nOn the model, the `AsVector` cast converts between PHP arrays and the database vector format:\n\n``` php\nuse Illuminate\\Database\\Eloquent\\Casts\\AsVector;\n\nprotected function casts(): array\n{\n    return [\n        'embedding' => AsVector::class,\n    ];\n}\n```\n\n## Generating and storing embeddings \n\nEmbedding generation is a network call to a paid API, so it does not belong in a request cycle or a model `saving` hook. Push it to a queued job, and batch where you can. The AI SDK's `Embeddings` class sends many inputs in one API call:\n\n``` php\nuse App\\Models\\Article;\nuse Laravel\\Ai\\Embeddings;\n\nArticle::whereNull('embedding')\n    ->chunkById(100, function ($articles) {\n        $response = Embeddings::for(\n            $articles->map(fn ($a) => $a->title . \"\\n\\n\" . $a->body)->all()\n        )->generate();\n\n        foreach ($articles->values() as $i => $article) {\n            $article->update(['embedding' => $response->embeddings[$i]]);\n        }\n    });\n```\n\nEmbedding the title together with the body gives the vector more signal for short articles. For long documents, you will get better matches by splitting the content into chunks with their own rows and embeddings, because one vector has to summarize everything it covers.\n\n## Querying by meaning \n\nWith embeddings stored, the search itself is one method:\n\n``` php\n$results = Article::query()\n    ->where('team_id', $user->team_id)\n    ->whereVectorSimilarTo('embedding', $request->input('q'), minSimilarity: 0.4)\n    ->limit(10)\n    ->get();\n```\n\n`whereVectorSimilarTo` compares stored vectors using cosine similarity, drops anything below `minSimilarity` (a value from 0.0 to 1.0), and orders results with the closest match first. When you pass a plain string, Laravel embeds it for you with your configured provider, so the user's query goes straight in.\n\nThat convenience has a cost: every search is now an embedding API call, with the latency and per-token billing that comes with it. Turn on embedding caching in `config/ai.php` (`caching.embeddings.cache`), which keys results on provider, model, dimensions, and input and keeps them for 30 days. Popular queries then skip the network entirely.\n\nIf you need the raw numbers, the lower-level methods expose them:\n\n``` php\n$results = Article::query()\n    ->select('*')\n    ->selectVectorDistance('embedding', $queryEmbedding, as: 'distance')\n    ->whereVectorDistanceLessThan('embedding', $queryEmbedding, maxDistance: 0.3)\n    ->orderByVectorDistance('embedding', $queryEmbedding)\n    ->limit(10)\n    ->get();\n```\n\nSelecting the distance is handy while you tune thresholds. Log a few real queries with their distances and you will quickly see where relevant results stop and noise starts. A threshold copied from a blog post (including this one) is a starting guess, not a setting.\n\n## When vector search is the wrong tool \n\nSemantic search is great at intent and bad at exactness. A user searching for an order number, a SKU, or an error code wants an exact match, and embeddings will happily return \"similar-looking\" codes. Keep `whereFullText` or a plain `where` for those cases, and consider routing queries that look like identifiers away from the vector path.\n\nAlso watch the combination of filters and approximate indexes. An HNSW index finds nearest neighbors first and your `where` clauses filter after, so a narrow filter like a single small team can return fewer than ten results. Newer `pgvector` releases add iterative index scans to help, but test with realistic data.\n\nLaravel's [search documentation](https://laravel.com/docs/13.x/search) also covers a hybrid pattern worth knowing: retrieve 50 candidates with fast full-text search, then reorder them with the AI SDK's `rerank` collection macro. For many apps that beats pure vector search on both cost and relevance. Start with the version that needs the least infrastructure, measure it against real queries, and only add moving parts when the logs say you need them.", "url": "https://wpnews.pro/news/laravel-13-vector-search-semantic-queries-without-a-search-service", "canonical_source": "https://danholloran.me/posts/laravel-13-vector-search-semantic-queries-without-a-search-service", "published_at": "2026-11-09 10:15:12+00:00", "updated_at": "2026-11-10 14:51:23.535320+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-search", "artificial-intelligence"], "entities": ["Laravel", "Laravel 13", "Laravel AI SDK", "PostgreSQL", "pgvector", "MariaDB", "MongoDB", "OpenAI"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/laravel-13-vector-search-semantic-queries-without-a-search-service", "markdown": "https://wpnews.pro/news/laravel-13-vector-search-semantic-queries-without-a-search-service.md", "text": "https://wpnews.pro/news/laravel-13-vector-search-semantic-queries-without-a-search-service.txt", "jsonld": "https://wpnews.pro/news/laravel-13-vector-search-semantic-queries-without-a-search-service.jsonld"}}