Skip to content

Semantic Search with pgvector and Laravel Scout

A

Al Amin Ahamed

Senior Engineer

9 min read
𝕏 in

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

  1. At index time: convert each record to an embedding vector and store it alongside the record.
  2. 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}", ]; } }

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();'
Share 𝕏 in
A

Al Amin Ahamed

Senior software engineer & AI practitioner. Building things in Laravel, PHP, and TypeScript.

About me →

One email a month. No noise.

What I shipped, what I read, occasional deep dive. Unsubscribe anytime.