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-smallto 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'andmax_parallel_workers = 4inpostgresql.conffor 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.









