Skip to content

Practical RAG in Laravel: pgvector, Embeddings, and Retrieval-Augmented Generation

Build a production-ready RAG pipeline in Laravel using pgvector, OpenAI embeddings, and a clean retrieval layer — without reaching for a dedicated vector database.

Published · Updated · 4 min read

Practical RAG in Laravel: pgvector, Embeddings, and Retrieval-Augmented Generation

Why RAG Instead of Fine-Tuning?

Fine-tuning is expensive and goes stale. Retrieval-augmented generation (RAG) keeps your knowledge base in a database you already control, lets you update it without retraining, and gives you full auditability over what context the model sees. If you are already running PostgreSQL, the pgvector extension turns it into a capable vector store — no extra infrastructure required.

Enabling pgvector in a Laravel Migration

// database/migrations/2024_01_01_000000_create_document_chunks_table.php
public function up(): void
{
    DB::statement('CREATE EXTENSION IF NOT EXISTS vector');

    Schema::create('document_chunks', function (Blueprint $table) {
        $table->id();
        $table->foreignId('document_id')->constrained()->cascadeOnDelete();
        $table->text('content');
        $table->integer('token_count');
        // 1536 dimensions for text-embedding-3-small
        $table->string('embedding', 8192)->nullable(); // stored as text, cast in queries
        $table->timestamps();
    });

    DB::statement(
        'ALTER TABLE document_chunks ADD COLUMN embedding_vec vector(1536)'
    );

    // IVFFlat index — tune lists to ~sqrt(row_count)
    DB::statement(
        'CREATE INDEX ON document_chunks USING ivfflat (embedding_vec vector_cosine_ops) WITH (lists = 100)'
    );
}

Using a raw ALTER TABLE keeps the migration readable while sidestepping Blueprint's lack of native vector type support.

Generating and Storing Embeddings

Wrap the OpenAI call in a dedicated action so it is easy to swap providers later.

namespace App\AI;

use App\Models\DocumentChunk;
use OpenAI\Laravel\Facades\OpenAI;

final class EmbedChunk
{
    public function handle(DocumentChunk $chunk): void
    {
        $response = OpenAI::embeddings()->create([
            'model' => 'text-embedding-3-small',
            'input' => $chunk->content,
        ]);

        $vector = $response->embeddings[0]->embedding; // float[]

        // pgvector expects '[0.1,0.2,...]' literal syntax
        $literal = '[' . implode(',', $vector) . ']';

        $chunk->updateQuietly(['embedding_vec' => DB::raw("'$literal'::vector")]);
    }
}

Dispatch this inside a queued job after chunking a document so the HTTP cycle stays fast.

namespace App\AI;

use App\Models\DocumentChunk;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

final class RetrieveChunks
{
    public function __construct(private EmbedQuery $embedQuery) {}

    /** @return Collection<int, DocumentChunk> */
    public function topK(string $query, int $k = 5): Collection
    {
        $vector = $this->embedQuery->forText($query);
        $literal = '[' . implode(',', $vector) . ']';

        return DocumentChunk::query()
            ->select(['id', 'document_id', 'content', 'token_count'])
            ->selectRaw(
                'embedding_vec <=> ?::vector AS distance',
                [$literal]
            )
            ->orderBy('distance')
            ->limit($k)
            ->get();
    }
}

The <=> operator is cosine distance. Use <#> for inner product or <-> for L2 depending on how your embeddings were normalised.

Assembling the Prompt

final class RagPipeline
{
    public function __construct(
        private RetrieveChunks $retriever,
    ) {}

    public function answer(string $question): string
    {
        $chunks = $this->retriever->topK($question, k: 5);

        $context = $chunks
            ->map(fn ($c) => "---\n" . $c->content)
            ->implode("\n");

        $response = OpenAI::chat()->create([
            'model' => 'gpt-4o-mini',
            'messages' => [
                ['role' => 'system', 'content' =>
                    'Answer using only the context below. If unsure, say so.'],
                ['role' => 'user', 'content' =>
                    "Context:\n$context\n\nQuestion: $question"],
            ],
            'max_tokens' => 512,
        ]);

        return $response->choices[0]->message->content;
    }
}

Chunking Strategy Matters

A naive split on paragraph breaks produces uneven chunks. Prefer a sliding-window approach: 512-token chunks with a 64-token overlap so context is not lost at boundaries. Count tokens with tiktoken-php before persisting.

$chunks = collect($splitter->split($text, chunkSize: 512, overlap: 64));

Production Considerations

  • Index warm-up: IVFFlat requires SET ivfflat.probes = 10 at query time for better recall at the cost of speed. Tune per workload.
  • Embedding cache: Hash the input text and cache the vector in Redis to avoid redundant API calls during re-indexing.
  • Tenant isolation: Add a tenant_id column and include it in the WHERE clause before the vector search; pgvector will filter first if the planner cooperates.
  • Batch embedding: Use the embeddings endpoint's array input to embed up to 2048 strings per request and cut API round-trips dramatically.

Takeaways

  • pgvector + PostgreSQL is a viable production vector store for most Laravel apps — no separate service needed.
  • Keep embedding generation in queued jobs; keep retrieval in a dedicated class injectable via the service container.
  • The <=> cosine operator, IVFFlat index, and probes setting are the three knobs that control recall vs. latency.
  • Chunking quality directly determines answer quality — invest time in your splitter before tuning the model.
  • Cache embeddings aggressively; the embedding API is the main cost driver in a RAG pipeline.

Frequently asked questions

Do I need a dedicated vector database like Pinecone or Weaviate for RAG in Laravel?

Not for most applications. If you are already running PostgreSQL, the pgvector extension provides cosine, L2, and inner-product similarity search with IVFFlat or HNSW indexes. A dedicated vector database makes sense only when you have hundreds of millions of vectors or need features like multi-tenancy at the vector-store level.

How do I keep embeddings up to date when document content changes?

Listen for the Eloquent `updated` event on your Document model, delete the old chunks, re-chunk the new content, and dispatch fresh embedding jobs. Wrapping this in a database transaction ensures you never serve stale vectors mid-update.

What is the difference between IVFFlat and HNSW indexes in pgvector?

IVFFlat partitions vectors into lists and searches a subset of them; it is faster to build but requires a `probes` setting to balance recall vs. speed. HNSW builds a hierarchical graph and generally delivers better recall at query time with no probe tuning, but takes longer to build and uses more memory. For most Laravel apps starting out, IVFFlat with a sensible `lists` value is the pragmatic choice.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project