Tech Verse Logo
Enable dark mode
Building a Production RAG Pipeline in PHP 8.3

Building a Production RAG Pipeline in PHP 8.3

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

Most developers reach for Python when building Retrieval-Augmented Generation (RAG) applications, assuming PHP lacks the tooling. That's a mistake. With PHP 8.3, Laravel 12, and PostgreSQL with the pgvector extension, you can run a fast, maintainable RAG pipeline inside your primary web application without managing a separate Python microservice.

The Core RAG Pipeline Flow

A functional RAG pipeline handles four tasks in sequence: splitting source text into chunks, generating vector embeddings for those chunks, storing the embeddings in a vector database, and querying the database during user requests to build context for an LLM prompt. Doing this directly in PHP keeps your queue workers, database migrations, and domain models together in one repository.

1. Chunking Text without Breaking Sentences

Naive chunking splits text purely on character length. If you chop a paragraph at exactly 500 characters, you risk cutting word boundaries and sentence clauses in half, which degrades the accuracy of your embedding models. A better approach uses recursive character splitting that prioritizes paragraph breaks, sentence breaks, and word spaces while enforcing a target token size with overlap.

Here is a production-ready chunker written for PHP 8.3 using mb_string functions to safely handle UTF-8 text boundaries.

<?php

namespace App\Services\RAG;

readonly class TextChunker
{
    public function __construct(
        private int $chunkSize = 1000,
        private int $chunkOverlap = 150
    ) {}

    /**
     * @return array<int, string>
     */
    public function chunk(string $text): array
    {
        $text = trim(preg_replace('/\r\n|\r/', "\n", $text));
        if (mb_strlen($text) <= $this->chunkSize) {
            return [$text];
        }

        $paragraphs = explode("\n\n", $text);
        $chunks = [];
        $currentChunk = '';

        foreach ($paragraphs as $paragraph) {
            $paragraph = trim($paragraph);
            if (empty($paragraph)) {
                continue;
            }

            if (mb_strlen($currentChunk) + mb_strlen($paragraph) + 2 <= $this->chunkSize) {
                $currentChunk .= (empty($currentChunk) ? '' : "\n\n") . $paragraph;
            } else {
                if (!empty($currentChunk)) {
                    $chunks[] = $currentChunk;
                    $overlapOffset = max(0, mb_strlen($currentChunk) - $this->chunkOverlap);
                    $currentChunk = mb_substr($currentChunk, $overlapOffset);
                }

                if (mb_strlen($paragraph) > $this->chunkSize) {
                    $subChunks = $this->splitBySentence($paragraph);
                    foreach ($subChunks as $sub) {
                        $chunks[] = $sub;
                    }
                } else {
                    $currentChunk .= (empty($currentChunk) ? '' : "\n\n") . $paragraph;
                }
            }
        }

        if (!empty(trim($currentChunk))) {
            $chunks[] = trim($currentChunk);
        }

        return $chunks;
    }

    private function splitBySentence(string $text): array
    {
        $sentences = preg_split('/(?<=[.?!])\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
        $subChunks = [];
        $buffer = '';

        foreach ($sentences as $sentence) {
            if (mb_strlen($buffer) + mb_strlen($sentence) + 1 <= $this->chunkSize) {
                $buffer .= (empty($buffer) ? '' : ' ') . $sentence;
            } else {
                if (!empty($buffer)) {
                    $subChunks[] = $buffer;
                }
                $buffer = $sentence;
            }
        }

        if (!empty($buffer)) {
            $subChunks[] = $buffer;
        }

        return $subChunks;
    }
}

2. Generating Vector Embeddings

Once you have your text chunks, you need to turn them into floating-point vectors using an embedding model like OpenAI's text-embedding-3-small. Sending single API requests for every single chunk is slow. If you have a document with 100 chunks, 100 round-trip HTTP requests can take up to 25 seconds.

Batching drops that overhead significantly. By sending array payloads of up to 100 text strings per request, total processing time drops down to roughly 450ms for the entire batch. Using the openai-php/client package (version 1.10), here is how to structure embedding generation inside a Laravel job.

<?php

namespace App\Services\RAG;

use OpenAI\Client;

class EmbeddingGenerator
{
    public function __construct(
        private Client $openai
    ) {}

    /**
     * @param array<int, string> $chunks
     * @return array<int, array<float>>
     */
    public function generateBatch(array $chunks): array
    {
        if (empty($chunks)) {
            return [];
        }

        $response = $this->openai->embeddings()->create([
            'model' => 'text-embedding-3-small',
            'input' => $chunks,
        ]);

        $embeddings = [];
        foreach ($response->embeddings as $object) {
            $embeddings[$object->index] = $object->embedding;
        }

        return $embeddings;
    }
}

3. Vector Storage in PostgreSQL with pgvector

Dedicated vector databases like Pinecone or Qdrant add operational complexity. If you already use PostgreSQL, adding pgvector gives you vector search capability without running extra server infrastructure.

In Laravel 12, start by creating a migration that enables the vector extension and sets up your document storage table.

<?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_chunks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('document_id')->constrained()->cascadeOnDelete();
            $table->text('content');
            $table->integer('chunk_index');
            $table->timestamps();
        });

        DB::statement('ALTER TABLE document_chunks ADD COLUMN embedding vector(1536);');
        DB::statement('CREATE INDEX document_chunks_embedding_hnsw_idx ON document_chunks USING hnsw (embedding vector_cosine_ops);');
    }

    public function down(): void
    {
        Schema::dropIfExists('document_chunks');
    }
};

Notice the index type used: HNSW (Hierarchical Navigable Small World) with vector_cosine_ops. Older tutorials suggest IVFFlat, but HNSW provides higher recall performance and doesn't require pre-training the index on populated data.

4. Cosine Similarity Vector Search in Laravel 12

When searching for context matching a user prompt, generate an embedding for the input text using the same model, then query PostgreSQL using the cosine distance operator <=>.

The cosine distance returned by pgvector ranges from 0.0 (identical) to 2.0 (opposite). Convert distance to a similarity score using 1 - distance to enforce strict quality cutoffs. Results with a cosine similarity below 0.72 generally introduce irrelevant noise into your context window.

<?php

namespace App\Services\RAG;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;

class VectorSearchService
{
    public function __construct(
        private EmbeddingGenerator $embeddingGenerator
    ) {}

    /**
     * @return Collection<int, object>
     */
    public function search(string $userQuery, int $limit = 5, float $minScore = 0.72): Collection
    {
        $queryEmbedding = $this->embeddingGenerator->generateBatch([$userQuery])[0];
        $vectorString = '[' . implode(',', $queryEmbedding) . ']';

        $results = DB::table('document_chunks')
            ->select([
                'id',
                'content',
                'document_id',
                DB::raw("1 - (embedding <=> '{$vectorString}') as similarity")
            ])
            ->whereRaw("1 - (embedding <=> '{$vectorString}') >= ?", [$minScore])
            ->orderByRaw("embedding <=> '{$vectorString}' ASC")
            ->limit($limit)
            ->get();

        return $results;
    }
}

5. Prompt Assembly and Response Generation

The final step connects the retrieved context to your prompt. Never dump retrieved text chunks into the system prompt directly without formatting. Structure context using clear delimiters like XML tags or Markdown blocks so the LLM clearly separates prompt instructions from raw context data.

<?php

namespace App\Services\RAG;

use OpenAI\Client;

class RAGProcessor
{
    public function __construct(
        private VectorSearchService $searchService,
        private Client $openai
    ) {}

    public function answerQuestion(string $userPrompt): string
    {
        $contextChunks = $this->searchService->search($userPrompt, limit: 4, minScore: 0.70);

        if ($contextChunks->isEmpty()) {
            return "I don't have enough background information to answer that question accurately.";
        }

        $formattedContext = $contextChunks->map(fn ($chunk, $i) => "<doc id='{$i}'>{$chunk->content}</doc>")->implode("\n\n");

        $systemPrompt = <<<TEXT
You are a precise support assistant. Answer the user's question using ONLY the context provided below.
If the answer cannot be determined from the context, state that you do not know.

<context>
{$formattedContext}
</context>
TEXT;

        $response = $this->openai->chat()->create([
            'model' => 'gpt-4o-mini',
            'temperature' => 0.1,
            'messages' => [
                ['role' => 'system', 'content' => $systemPrompt],
                ['role' => 'user', 'content' => $userPrompt],
            ],
        ]);

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

Common Production Traps

Three specific gotchas break PHP RAG pipelines in production:

  • Inconsistent embedding models: Changing your embedding model from text-embedding-3-small to another model invalidates all stored vectors. Dimensions won't match, causing Postgres query exceptions.

  • Forgetting pgvector memory settings: The default PostgreSQL memory settings will slow down HNSW index creation. Set maintenance_work_mem = '256MB' and max_parallel_workers = 4 in postgresql.conf for large datasets.

  • Queue timeout issues: Chunking large PDF documents synchronously inside an HTTP controller causes timeout errors. Offload chunking and vector generation to asynchronous Laravel Queue workers using Horizon.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3

    Laravel LLM Integration: Queues and Real-Time Frontend UI

    Laravel LLM Integration: Queues and Real-Time Frontend UI