Full-text search is table stakes. Semantic search — finding results by meaning rather than keyword matching — is the upgrade most applications need but rarely have. With pgvector and Laravel Scout, you can add it in an afternoon.
How Semantic Search Works
- At index time: convert each record to an embedding vector and store it alongside the record.
- At query time: convert the search query to a vector, then find the most similar stored vectors.
"Similarity" is cosine distance. Two pieces of text that mean the same thing have similar vectors even if they share no words.
Setup
Install pgvector:
CREATE EXTENSION IF NOT EXISTS vector;
Add a vector column to your table:
ALTER TABLE posts ADD COLUMN embedding vector(1024);
CREATE INDEX posts_embedding_hnsw_idx
ON posts USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
The Scout Driver
Create a custom Scout driver that writes embeddings on index and does cosine search on query:
class PgvectorEngine extends Engine
{
public function update($models): void
{
$models->each(function ($model) {
$text = $model->toSearchableArray()['_text'] ?? '';
$vector = $this->embed($text);
$model->updateQuietly(['embedding' => json_encode($vector)]);
});
}
public function search(Builder $builder, array $options = []): mixed
{
$queryVector = $this->embed($builder->query);
return DB::select(
"SELECT id, 1 - (embedding <=> ?::vector) AS score
FROM {$builder->model->getTable()}
ORDER BY embedding <=> ?::vector
LIMIT ?",
[json_encode($queryVector), json_encode($queryVector), $options['limit'] ?? 10]
);
}
private function embed(string $text): array
{
return Http::withToken(config('services.voyage.key'))
->post('https://api.voyageai.com/v1/embeddings', [
'model' => 'voyage-3',
'input' => $text,
])->json('data.0.embedding');
}
}
Model Setup
class Post extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'_text' => "{$this->title}\n\n{$this->summary}\n\n{$this->body}",
];
}
}
Hybrid Search
Pure semantic search can miss exact keyword matches. Combine both:
$semanticIds = Post::search($query)->keys();
$keywordIds = Post::where('title', 'LIKE', "%{$query}%")
->orWhere('body', 'LIKE', "%{$query}%")
->pluck('id');
$ids = $semanticIds->merge($keywordIds)->unique();
$posts = Post::whereIn('id', $ids)->get();
Reciprocal Rank Fusion is a better merge strategy for serious implementations, but the above covers 90% of use cases.
Re-indexing
# Index all posts
php artisan scout:import "App\Models\Post"
# Index a single model (useful during development)
php artisan tinker --execute 'Post::find(42)->searchable();'
Al Amin Ahamed
Senior software engineer & AI practitioner. Building things in Laravel, PHP, and TypeScript.
About me →More from the blog
← Older
TypeScript for PHP Developers
Newer →
Deploying Laravel on a VPS: Caddy, Supervisor, and Zero-Downtime Releases
One email a month. No noise.
What I shipped, what I read, occasional deep dive. Unsubscribe anytime.
Check your inbox — confirmation link sent.