Practical RAG in Laravel: pgvector, Embeddings, and Retrieval Pipelines
#laravel #ai #pgvector #postgresql

Practical RAG in Laravel: pgvector, Embeddings, and Retrieval Pipelines

4 min read Mohamed Said Mohamed Said

Why RAG in Laravel Without a Dedicated Vector DB?

Pinecone, Weaviate, and Qdrant are compelling, but they add operational overhead. If you are already running PostgreSQL, the pgvector extension gives you cosine-similarity search with a single CREATE EXTENSION and a new column type. For most SaaS workloads — knowledge bases, document Q&A, semantic search — that is more than enough.

This article walks through a complete, opinionated RAG pipeline: ingestion, embedding storage, retrieval, and prompt assembly.


1. Enable pgvector and Add the Column

CREATE EXTENSION IF NOT EXISTS vector;
// database/migrations/2024_06_01_create_document_chunks_table.php
Schema::create('document_chunks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('document_id')->constrained()->cascadeOnDelete();
    $table->text('content');
    $table->vector('embedding', 1536); // OpenAI text-embedding-3-small
    $table->timestamps();
});

Laravel's Blueprint does not ship a vector() macro out of the box, so register one in a service provider:

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;

Blueprint::macro('vector', function (string $column, int $dimensions = 1536) {
    /** @var Blueprint $this */
    return $this->addColumn('vector', $column, compact('dimensions'));
});

// In AppServiceProvider::boot()
\Illuminate\Database\Schema\Grammars\PostgresGrammar::macro(
    'typeVector',
    fn ($column) => "vector({$column->dimensions})"
);

2. The Embedding Service

Keep the OpenAI HTTP call behind a thin interface so you can swap providers or stub in tests.

interface EmbeddingProvider
{
    /** @return float[] */
    public function embed(string $text): array;
}

final class OpenAiEmbeddingProvider implements EmbeddingProvider
{
    public function __construct(
        private readonly \OpenAI\Client $client,
        private readonly string $model = 'text-embedding-3-small',
    ) {}

    public function embed(string $text): array
    {
        $response = $this->client->embeddings()->create([
            'model' => $this->model,
            'input' => $text,
        ]);

        return $response->embeddings[0]->embedding;
    }
}

Bind it in the container:

$this->app->singleton(EmbeddingProvider::class, fn () =>
    new OpenAiEmbeddingProvider(app(\OpenAI\Client::class))
);

3. Ingestion Pipeline

Chunk documents before embedding — 512-token chunks with a 64-token overlap is a solid default.

final class IngestDocumentAction
{
    public function __construct(
        private readonly EmbeddingProvider $embedder,
    ) {}

    public function handle(Document $document): void
    {
        $chunks = $this->chunk($document->body, maxTokens: 512, overlap: 64);

        foreach ($chunks as $content) {
            $vector = $this->embedder->embed($content);

            DB::table('document_chunks')->insert([
                'document_id' => $document->id,
                'content'     => $content,
                'embedding'   => '[' . implode(',', $vector) . ']',
                'created_at'  => now(),
                'updated_at'  => now(),
            ]);
        }
    }

    /** @return string[] */
    private function chunk(string $text, int $maxTokens, int $overlap): array
    {
        // Naive word-boundary chunking; replace with tiktoken FFI for precision.
        $words  = explode(' ', $text);
        $chunks = [];
        $step   = max(1, $maxTokens - $overlap);

        for ($i = 0; $i < count($words); $i += $step) {
            $chunks[] = implode(' ', array_slice($words, $i, $maxTokens));
        }

        return array_filter($chunks);
    }
}

4. Retrieval: Cosine Similarity with pgvector

final class RetrievalService
{
    public function __construct(
        private readonly EmbeddingProvider $embedder,
    ) {}

    /** @return array<int, array{content: string, score: float}> */
    public function retrieve(string $query, int $topK = 5): array
    {
        $vector = '[' . implode(',', $this->embedder->embed($query)) . ']';

        return DB::select(
            "SELECT content,
                    1 - (embedding <=> ?::vector) AS score
             FROM document_chunks
             ORDER BY embedding <=> ?::vector
             LIMIT ?",
            [$vector, $vector, $topK]
        );
    }
}

The <=> operator is pgvector's cosine distance. Subtract from 1 to get similarity. Add an ivfflat index once your chunk count exceeds ~100k rows:

CREATE INDEX ON document_chunks
    USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100);

5. Prompt Assembly and Generation

final class AnswerQuestionAction
{
    public function __construct(
        private readonly RetrievalService $retrieval,
        private readonly \OpenAI\Client $client,
    ) {}

    public function handle(string $question): string
    {
        $chunks  = $this->retrieval->retrieve($question, topK: 4);
        $context = collect($chunks)
            ->pluck('content')
            ->implode("\n\n---\n\n");

        $response = $this->client->chat()->create([
            'model'    => 'gpt-4o-mini',
            'messages' => [
                ['role' => 'system', 'content' =>
                    "Answer using only the context below.\n\n{$context}"],
                ['role' => 'user', 'content' => $question],
            ],
        ]);

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

Key Takeaways

  • pgvector is production-ready for most SaaS RAG workloads; skip the dedicated vector DB until you hit scale.
  • Chunk with overlap to avoid splitting context across boundaries; tune chunk size per domain.
  • Abstract the embedding provider behind an interface — it makes testing trivial and provider swaps painless.
  • Use ivfflat indexes once chunk counts grow; lists = sqrt(row_count) is a reasonable starting heuristic.
  • Keep retrieval and generation separate actions — retrieval is deterministic and cacheable; generation is not.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need a dedicated vector database like Pinecone for RAG in Laravel?
Not for most workloads. The pgvector PostgreSQL extension supports cosine-similarity search with ivfflat indexing, which handles millions of vectors efficiently. A dedicated vector DB only becomes necessary at very large scale or when you need advanced filtering that pgvector cannot express.
Q02 How do I test the embedding and retrieval logic without hitting the OpenAI API?
Because the embedding provider is behind an interface, you can bind a fake in tests that returns a fixed float array. Retrieval tests can seed document_chunks with known vectors and assert that the correct chunks are returned for a given query vector.
Q03 What chunk size should I use for embeddings?
512 tokens with a 64-token overlap is a solid general-purpose default. For technical documentation you may go up to 1024 tokens; for conversational snippets 256 tokens often works better. Measure retrieval precision on your own dataset rather than relying on universal rules.

Continue reading

More Articles

View all