Traditional SQL LIKE %query% searches and full-text indexes match exact keywords. If a user searches for "portable computing device", traditional search will completely miss products labeled "ultralight laptop" unless those exact words appear in the description.
Semantic search solves this problem by comparing the underlying meaning and context of words rather than exact string matches. By converting text into high-dimensional vector embeddings and indexing them in a vector database like Qdrant, your Laravel application can understand user intent effortlessly.
In this guide, we will step through integrating Qdrant with Laravel Eloquent to generate embeddings, sync data asynchronously, and perform real-time similarity searches.
1. How Semantic Vector Search Works
The vector search workflow consists of two core phases:
- Indexing: When an Eloquent record (e.g., an Article or Product) is created or updated, we send its text to an embedding model (like OpenAI
text-embedding-3-smallor Gemini Embeddings). The model returns an array of floating-point numbers (vectors). We store these vectors in Qdrant mapped to the Eloquent model ID. - Querying: When a user enters a search phrase, we generate a vector for that query, send it to Qdrant to find the nearest matching vectors using Cosine Similarity, and fetch the corresponding Eloquent records from MySQL.
2. Running Qdrant Locally
You can spin up a local instance of Qdrant in seconds using Docker:
docker run -d -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage:z \
qdrant/qdrantAdd the connection parameters to your .env file:
QDRANT_HOST=http://localhost:6333
QDRANT_API_KEY=null
OPENAI_API_KEY=your_openai_api_key_here3. Creating the Vector Service in Laravel
Let's create a dedicated service class to handle embedding generation and Qdrant HTTP API interactions:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class VectorSearchService
{
protected string $qdrantUrl;
protected string $collection = 'articles';
public function __construct()
{
$this->qdrantUrl = env('QDRANT_HOST', 'http://localhost:6333');
}
/**
* Generate 1536-dimension embeddings via OpenAI API
*/
public function generateEmbedding(string $text): array
{
$response = Http::withToken(env('OPENAI_API_KEY'))
->post('https://api.openai.com/v1/embeddings', [
'model' => 'text-embedding-3-small',
'input' => $text,
])->throw()->json();
return $response['data'][0]['embedding'];
}
/**
* Ensure the collection exists in Qdrant with Cosine Distance
*/
public function createCollectionIfNotExists(): void
{
Http::put("{$this->qdrantUrl}/collections/{$this->collection}", [
'vectors' => [
'size' => 1536,
'distance' => 'Cosine',
],
]);
}
/**
* Upsert a point into Qdrant
*/
public function upsertPoint(int $id, array $vector, array $payload = []): void
{
Http::put("{$this->qdrantUrl}/collections/{$this->collection}/points", [
'points' => [
[
'id' => $id,
'vector' => $vector,
'payload' => $payload,
],
],
]);
}
/**
* Search nearest points
*/
public function search(array $queryVector, int $limit = 5): array
{
$response = Http::post("{$this->qdrantUrl}/collections/{$this->collection}/points/search", [
'vector' => $queryVector,
'limit' => $limit,
'with_payload' => true,
])->json();
return $response['result'] ?? [];
}
}4. Synchronizing Eloquent Models with Observers
To avoid blocking HTTP requests during database writes, we dispatch a queued job from an Eloquent Observer whenever a record is created or updated.
Creating the Sync Job
<?php
namespace App\Jobs;
use App\Models\Article;
use App\Services\VectorSearchService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SyncArticleToVectorDb implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public Article $article) {}
public function handle(VectorSearchService $vectorService): void
{
$contentToIndex = $this->article->title . " " . $this->article->content;
$vector = $vectorService->generateEmbedding($contentToIndex);
$vectorService->upsertPoint(
id: $this->article->id,
vector: $vector,
payload: [
'category' => $this->article->category,
'slug' => $this->article->slug,
]
);
}
}Hooking up the Observer
<?php
namespace App\Observers;
use App\Jobs\SyncArticleToVectorDb;
use App\Models\Article;
class ArticleObserver
{
public function saved(Article $article): void
{
dispatch(new SyncArticleToVectorDb($article));
}
}5. Executing Semantic Search in the Controller
Now, let's create a controller endpoint that accepts a natural language search query, queries Qdrant, and returns the Eloquent collection ordered by relevance:
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use App\Services\VectorSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SearchController extends Controller
{
public function __invoke(Request $request, VectorSearchService $vectorService): JsonResponse
{
$query = $request->input('q');
if (empty($query)) {
return response()->json([]);
}
// Step 1: Convert search query into an embedding vector
$queryVector = $vectorService->generateEmbedding($query);
// Step 2: Query Qdrant for top 5 nearest vector matches
$results = $vectorService->search($queryVector, limit: 5);
// Extract IDs and similarity scores
$matchedIds = collect($results)->pluck('id')->toArray();
if (empty($matchedIds)) {
return response()->json([]);
}
// Step 3: Fetch Eloquent records and preserve similarity ranking
$articles = Article::whereIn('id', $matchedIds)
->get()
->sortBy(function ($article) use ($matchedIds) {
return array_search($article->id, $matchedIds);
})
->values();
return response()->json([
'query' => $query,
'results' => $articles,
]);
}
}6. Advantages of this Architecture
- Decoupled Scalability: Heavy vector distance math is completely offloaded to Qdrant's Rust-powered engine, keeping MySQL fast and responsive.
- Asynchronous Syncing: Users do not experience latency when saving articles because vector generation runs in background queue workers.
- True Concept Matching: Queries like "troubleshoot web server errors" will accurately return articles about "Nginx 502 Bad Gateway fixes" even without overlapping keywords.
Conclusion
Integrating vector databases like Qdrant into Laravel transforms standard search bars into intelligent, context-aware discovery tools. With modern embedding APIs and Eloquent observers, adding production-grade AI search capabilities to your Laravel apps requires minimal operational friction.