RAG with pgvector and Laravel Practical Setup

Khimananda Oli 10 min read AI and Machine Learning
RAG with pgvector and Laravel Practical Setup

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.

User QueryNatural LanguageLaravel AppEmbed + Searchpgvector QueryPostgreSQLpgvector ExtensionDocs + EmbeddingsLLM APIGenerate AnswerRAG with pgvector and Laravel Practical Setup ArchitectureSingle Database • ACID Compliant • No External Vector Store
End-to-end RAG with pgvector and Laravel practical setup architecture keeping embeddings inside PostgreSQL

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.

Ingestion PhaseChunk TextCall Embed APIStore VectorBuild HNSW IndexQuery PhaseEmbed QueryCosine SearchTop-K ResultsSend to LLMKey Configuration Values• Chunk Size: 512–1024 tokens• Overlap: 50–100 tokens• Top-K: 5–10 results• Distance Threshold: ≤ 0.3• HNSW ef_search: 100–200Tune based on recall vs latency tradeoffs
Ingestion and query phases for RAG with pgvector and Laravel practical setup with key tuning parameters

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.

Criteriapgvector (PostgreSQL)Dedicated Vector DB (Pinecone/Weaviate)
Operational ComplexityLow — uses existing PG backups, replication, monitoringHigh — separate service, sync pipelines, dual monitoring
Data ConsistencyACID transactions, foreign keys, joins with relational dataEventual consistency, manual sync, no referential integrity
Max Scale (Practical)~10M vectors per table before sharding neededBillions of vectors, auto-sharding, global distribution
Query Latency (<1M vectors)10–50ms with HNSW index5–20ms (optimized for vector-only workloads)
Cost at Moderate ScaleIncluded in existing PG hosting ($20–200/mo)$70–500+/mo separate billing
Compliance & ResidencySame controls as your primary databaseVendor-dependent, may complicate SOC 2 / data residency
Hybrid SearchNative JOINs + full-text + vector in one queryRequires 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 LIMIT returns 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.

Choose pgvector When< 10M vectors totalAlready running PostgreSQLNeed ACID + relational joinsBudget-constrained teamData residency / complianceHybrid search requirementsChoose Dedicated DB When> 10M vectors, global scaleSub-10ms latency requiredMulti-region replicationManaged ops preferredAdvanced filtering / metadataTeam lacks PostgreSQL expertise
Decision framework for choosing pgvector versus dedicated vector databases in Laravel RAG projects

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.

Frequently Asked Questions

PostgreSQL 15 or newer is required. Version 16+ offers better HNSW indexing performance. Verify with SELECT version() before installing the extension.

Add postgresql-16-pgvector to your Dockerfile apt-get install command. Then run CREATE EXTENSION vector; via a Laravel migration. Rebuild and restart containers to apply changes.

Use pgvector-laravel v0.4+. It provides Eloquent casting, migration blueprints, and query scopes specifically designed for vector similarity search within Laravel applications.

No. SQLite lacks native vector support. Use Docker with PostgreSQL locally to match production behavior exactly during development and testing phases.

Match your embedding model output. OpenAI text-embedding-3-small uses 1536 dimensions. Configure this in your migration column definition to prevent storage errors.

Use Laravel's HTTP client to call your embedding API. Store results using the Vector cast attribute. Batch processing prevents rate limits during large document ingestion workflows.

HNSW indexes outperform IVFFlat for most Laravel RAG workloads under one million vectors. Create them concurrently to avoid locking production tables during setup.

Check distance operator consistency. L2 distance uses while cosine uses . Mixing operators with mismatched embedding models causes poor retrieval accuracy and ranking issues.

Add WHERE clauses before ordering by distance. This enables index filtering. Also set hnsw.ef_search session parameter dynamically based on latency versus recall requirements.

Yes, up to several million vectors per node. Beyond that, consider partitioning or dedicated vector databases. Monitor index memory usage and query latency continuously.

Standard PostgreSQL permissions apply. Encrypt connections with SSL. Restrict schema access. Embeddings themselves are not human-readable but treat them as sensitive derived data.

AWS RDS db.r6g.large costs roughly $180 monthly in 2026. Memory is the primary cost driver since HNSW indexes reside entirely in RAM for performance.

pgvector eliminates separate infrastructure and reduces operational complexity. Pinecone offers better horizontal scaling. Choose pgvector when data already lives in PostgreSQL.

Yes. Update individual rows normally. HNSW indexes handle incremental updates efficiently. Schedule periodic REINDEX CONCURRENTLY only after massive bulk deletions or significant data drift.

Mismatched dimensions, wrong distance operators, missing indexes, and insufficient shared_buffers allocation. Always validate embedding consistency and monitor pg_stat_user_indexes for scan efficiency.