
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Adding semantic search to a PHP application often triggers assumptions about needing specialized infrastructure like Pinecone or Weaviate. For teams already running PostgreSQL, this introduces unnecessary operational complexity and data synchronization headaches. A proper RAG with pgvector and Laravel practical setup lets you store embeddings directly alongside your relational data, maintaining ACID compliance while enabling AI-powered retrieval without managing a separate vector database cluster.
How do you configure RAG with pgvector and Laravel practical setup?
Before writing any PHP code, your PostgreSQL instance must have the vector extension enabled and your Laravel environment configured to handle high-dimensional data types. Many developers skip the database-level verification and encounter cryptic errors when migrations run. If you are managing your own database rather than using a managed service like AWS RDS or Supabase, verify the extension is available by connecting via psql and running CREATE EXTENSION IF NOT EXISTS vector;. For production environments, review PostgreSQL administration essentials to ensure shared_preload_libraries includes pgvector if required by your version.
Create the migration with correct vector dimensions
The dimension parameter must match your embedding model exactly. OpenAI's text-embedding-3-small uses 1536 dimensions, while many open-source models like bge-m3 use 1024. Mismatched dimensions cause insertion failures that are difficult to debug mid-pipeline.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
Schema::create('document_embeddings', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained()->cascadeOnDelete();
$table->text('chunk_content');
$table->string('source_url')->nullable();
// 1536 for text-embedding-3-small, adjust for your model
$table->unsignedSmallInteger('chunk_index');
$table->timestamps();
});
// Add vector column separately (Laravel schema builder lacks native support)
DB::statement('ALTER TABLE document_embeddings ADD COLUMN embedding vector(1536)');
// Create HNSW index for fast approximate nearest neighbor search
DB::statement('CREATE INDEX ON document_embeddings USING hnsw (embedding vector_cosine_ops)');
}
public function down(): void
{
Schema::dropIfExists('document_embeddings');
DB::statement('DROP EXTENSION IF EXISTS vector');
}
}; The HNSW index creation can take minutes on large tables. In production, run this as a separate maintenance migration during low-traffic windows. For initial development, the index is optional but becomes mandatory once you exceed 10,000 vectors to maintain sub-100ms query latency.
How do you generate and store embeddings efficiently in Laravel?
Embedding generation is the most expensive operation in your RAG pipeline, both in cost and latency. Never generate embeddings synchronously during user requests. Instead, decouple ingestion from retrieval using Laravel's queue system. This aligns with patterns discussed in Laravel queues and jobs background processing, ensuring your web workers remain responsive.
Build a chunking and embedding job
Raw documents rarely fit into embedding context windows. Split content into overlapping chunks of 512–1024 tokens with 50–100 token overlap to preserve semantic continuity across boundaries. Store metadata (source URL, chunk index) alongside each vector for citation and debugging.
<?php
namespace App\Jobs;
use App\Models\DocumentEmbedding;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class GenerateDocumentEmbeddings implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
private int $documentId,
private string $content,
private ?string $sourceUrl = null
) {}
public function handle(): void
{
$chunks = $this->chunkText($this->content, 800, 100);
foreach ($chunks as $index => $chunk) {
$response = Http::withToken(config('services.openai.key'))
->timeout(30)
->post('https://api.openai.com/v1/embeddings', [
'model' => 'text-embedding-3-small',
'input' => $chunk,
]);
if (!$response->successful()) {
$this->fail("Embedding API failed: " . $response->body());
return;
}
$embedding = $response->json('data.0.embedding');
DocumentEmbedding::create([
'document_id' => $this->documentId,
'chunk_content' => $chunk,
'chunk_index' => $index,
'source_url' => $this->sourceUrl,
'embedding' => '[' . implode(',', $embedding) . ']',
]);
}
}
private function chunkText(string $text, int $maxChars, int $overlap): array
{
$chunks = [];
$start = 0;
while ($start < strlen($text)) {
$end = min($start + $maxChars, strlen($text));
$chunks[] = substr($text, $start, $end - $start);
$start += $maxChars - $overlap;
}
return array_filter($chunks);
}
} Store embeddings as bracketed comma-separated strings. The pgvector extension casts this format automatically. Avoid base64 encoding—it adds 33% storage overhead and requires decoding on every query.
How do you perform similarity search with pgvector in Laravel?
Retrieval is where your RAG system earns its value. pgvector provides three distance operators: <-> (Euclidean), <#> (inner product), and <=> (cosine). Cosine distance is the standard for text embeddings because it measures angular similarity independent of vector magnitude. Always use cosine unless your embedding model documentation specifies otherwise.
Write the retrieval query with Eloquent
Laravel's query builder doesn't natively support pgvector operators. Use raw expressions wrapped in safe bindings to prevent SQL injection. Always set an ef_search parameter at query time to control the accuracy-speed tradeoff—higher values improve recall but increase latency.
<?php
namespace App\Services;
use App\Models\DocumentEmbedding;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
class RagRetrievalService
{
public function retrieve(string $query, int $topK = 5, float $threshold = 0.3): array
{
// 1. Embed the user query
$embedding = $this->embedQuery($query);
$vectorLiteral = '[' . implode(',', $embedding) . ']';
// 2. Set HNSW search parameter for this session
DB::statement("SET LOCAL hnsw.ef_search = 150");
// 3. Run cosine similarity search
$results = DocumentEmbedding::query()
->select([
'chunk_content',
'source_url',
'chunk_index',
DB::raw("embedding <=> '{$vectorLiteral}' AS distance"),
])
->orderByRaw("embedding <=> '{$vectorLiteral}'")
->limit($topK)
->get()
->filter(fn($row) => $row->distance <= $threshold)
->values();
return $results->map(fn($row) => [
'content' => $row->chunk_content,
'source' => $row->source_url,
'score' => round(1 - $row->distance, 4),
])->toArray();
}
private function embedQuery(string $text): array
{
$response = Http::withToken(config('services.openai.key'))
->timeout(15)
->post('https://api.openai.com/v1/embeddings', [
'model' => 'text-embedding-3-small',
'input' => $text,
]);
return $response->json('data.0.embedding');
}
} The distance threshold filter is critical. Without it, your LLM receives irrelevant context that degrades answer quality and wastes tokens. Start with 0.3 for cosine distance and adjust based on evaluation results. Log discarded results to identify whether your threshold is too aggressive.
How does pgvector compare to dedicated vector databases for Laravel?
Choosing between pgvector and a managed vector database depends on your team's operational capacity, data volume, and compliance requirements. For most Laravel applications under 10 million vectors, pgvector eliminates an entire category of infrastructure. For larger scales or specialized needs, dedicated services offer advantages. Understanding these tradeoffs prevents costly rearchitecture later. Teams evaluating options should also review vector databases for RAG pgvector vs Pinecone for deeper benchmarking data.
| Criteria | pgvector (PostgreSQL) | Dedicated Vector DB (Pinecone/Weaviate) |
|---|---|---|
| Operational Complexity | Low — uses existing PG backups, replication, monitoring | High — separate service, sync pipelines, dual monitoring |
| Data Consistency | ACID transactions, foreign keys, joins with relational data | Eventual consistency, manual sync, no referential integrity |
| Max Scale (Practical) | ~10M vectors per table before sharding needed | Billions of vectors, auto-sharding, global distribution |
| Query Latency (<1M vectors) | 10–50ms with HNSW index | 5–20ms (optimized for vector-only workloads) |
| Cost at Moderate Scale | Included in existing PG hosting ($20–200/mo) | $70–500+/mo separate billing |
| Compliance & Residency | Same controls as your primary database | Vendor-dependent, may complicate SOC 2 / data residency |
| Hybrid Search | Native JOINs + full-text + vector in one query | Requires application-level merging or vendor-specific features |
For Nepal-based teams or startups operating on NPR budgets, pgvector's cost advantage is substantial. You avoid currency conversion premiums and vendor lock-in while keeping data within your existing compliance boundary. Only migrate to a dedicated vector database when you've empirically proven pgvector cannot meet your latency or scale requirements—not as a preemptive optimization.
What are common performance pitfalls in pgvector Laravel setups?
I've audited multiple Laravel RAG implementations where retrieval worked correctly in development but degraded catastrophically in production. These issues share root causes that are preventable with disciplined engineering.
- Missing HNSW index: Without the index, pgvector performs brute-force sequential scans. At 100K vectors, queries jump from 20ms to 2+ seconds. Always create the index before loading production data.
- Unbounded result sets: Forgetting
LIMITreturns all rows sorted by distance. This exhausts PHP memory and times out HTTP requests. Always cap at 10–20 results maximum. - Synchronous embedding calls: Generating embeddings during user-facing requests adds 200–800ms latency per chunk. Offload to queues exclusively. Reserve synchronous calls only for real-time query embedding.
- No distance threshold: Returning low-similarity results pollutes LLM context. Implement filtering and log threshold misses to calibrate over time.
- Ignoring connection pooling: Each pgvector query holds a database connection. Under load, unpoolled connections exhaust PostgreSQL's max_connections. Use PgBouncer or Supavisor in front of your database.
- Stale embeddings after content updates: When source documents change, old embeddings persist. Implement a re-embedding job triggered on document update, or use soft deletes with periodic cleanup.
Monitor your retrieval latency using the same observability stack you use for application metrics. If you haven't instrumented your Laravel app yet, start with instrumenting an app with OpenTelemetry to capture embedding generation duration, search latency, and cache hit rates as first-class metrics.
Start Building Your RAG Pipeline Today
A working RAG with pgvector and Laravel practical setup gives you AI-powered semantic search without introducing new infrastructure dependencies or compliance surface area. Start with the migration and retrieval service shown above, validate recall against a labeled test set, and tune your chunk size and distance threshold before scaling ingestion. Monitor embedding costs and query latency from day one—these are your leading indicators for when to optimize or reconsider architecture. If your team needs help designing a production-grade RAG system that passes security audits and handles real traffic, reach out to discuss your specific requirements.