{"slug": "rag-in-laravel-embeddings-and-pgvector-for-a-knowledge-base-bot", "title": "RAG in Laravel: Embeddings and pgvector for a Knowledge-Base Bot", "summary": "A developer built a retrieval-augmented generation (RAG) pipeline in Laravel 11 using PostgreSQL and the pgvector extension, enabling a knowledge-base chatbot without external vector database services. The system stores document embeddings locally and retrieves relevant chunks for each query, addressing the problem of AI models lacking access to custom data. The developer also notes that for small knowledge bases (under 2,000 tokens), embedding the entire corpus in the system prompt with prompt caching is simpler and more effective.", "body_md": "In the [last post](https://dev.to/adityakdevin) we streamed AI responses over SSE. Now let's fix the problem every chatbot hits in week one: the model doesn't know *your* data. Ask it about your refund policy or your API docs and it either hallucinates or shrugs.\n\nRAG — retrieval-augmented generation — fixes that. Instead of hoping the model knows your content, you store your documents as embeddings, retrieve the few chunks relevant to each question, and hand only those to the model. Here's the whole pipeline in Laravel 11 with PostgreSQL and pgvector — no vector-database SaaS required.\n\nI recently shipped a chatbot for my own portfolio site. Total knowledge base: about 2,000 tokens of facts and FAQ. I didn't build RAG for it — I put the *entire corpus in the system prompt* and let prompt caching make it cheap. Simpler code, zero retrieval bugs, and the model sees everything on every question.\n\nThe rule of thumb I use with clients:\n\nIf you're in the first bucket, close this tab and go ship. Still here? Let's build.\n\n`pgvector`\n\nextension`openai-php/laravel`\n\nfor embeddings + chat`pgvector/pgvector-php`\n\nfor the Eloquent cast\n\n```\ncomposer require openai-php/laravel pgvector/pgvector\nphp artisan vendor:publish --provider=\"OpenAI\\Laravel\\ServiceProvider\"\n```\n\nOn most managed Postgres (RDS, Supabase, Neon, Laravel Forge boxes) pgvector is one statement away:\n\n```\nCREATE EXTENSION IF NOT EXISTS vector;\n```\n\nOne table stores chunks of your documents plus their embedding vectors. `text-embedding-3-small`\n\nproduces 1536 dimensions — good quality, cheap ($0.02 per million tokens).\n\n``` php\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        DB::statement('CREATE EXTENSION IF NOT EXISTS vector');\n\n        Schema::create('document_chunks', function ($table) {\n            $table->id();\n            $table->string('source');          // e.g. \"refund-policy.md\"\n            $table->unsignedInteger('position'); // chunk order within the source\n            $table->text('content');\n            $table->timestamps();\n        });\n\n        DB::statement('ALTER TABLE document_chunks ADD COLUMN embedding vector(1536)');\n\n        // HNSW index makes similarity search fast past ~10k rows\n        DB::statement('CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops)');\n    }\n};\n```\n\nThe model:\n\n``` php\nnamespace App\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Pgvector\\Laravel\\Vector;\n\nclass DocumentChunk extends Model\n{\n    protected $fillable = ['source', 'position', 'content', 'embedding'];\n\n    protected $casts = ['embedding' => Vector::class];\n}\n```\n\nChunking strategy matters more than people admit. My default: split on headings/paragraphs, target ~500 tokens per chunk, and never split mid-sentence. Fancy overlap windows can wait until you have evidence you need them.\n\n``` php\nnamespace App\\Console\\Commands;\n\nuse App\\Models\\DocumentChunk;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Facades\\File;\nuse OpenAI\\Laravel\\Facades\\OpenAI;\n\nclass IngestDocs extends Command\n{\n    protected $signature = 'rag:ingest {path : Directory of markdown files}';\n\n    public function handle(): int\n    {\n        foreach (File::files($this->argument('path')) as $file) {\n            $chunks = $this->chunk($file->getContents());\n\n            // One API call per file, not per chunk — batch input is supported\n            $response = OpenAI::embeddings()->create([\n                'model' => 'text-embedding-3-small',\n                'input' => $chunks,\n            ]);\n\n            DocumentChunk::where('source', $file->getFilename())->delete();\n\n            foreach ($response->embeddings as $i => $embedding) {\n                DocumentChunk::create([\n                    'source' => $file->getFilename(),\n                    'position' => $i,\n                    'content' => $chunks[$i],\n                    'embedding' => $embedding->embedding,\n                ]);\n            }\n\n            $this->info(\"{$file->getFilename()}: \" . count($chunks) . ' chunks');\n        }\n\n        return self::SUCCESS;\n    }\n\n    /** @return string[] */\n    private function chunk(string $text, int $targetChars = 2000): array\n    {\n        $paragraphs = preg_split('/\\n{2,}/', $text);\n        $chunks = [''];\n\n        foreach ($paragraphs as $p) {\n            $current = array_key_last($chunks);\n            if (strlen($chunks[$current]) + strlen($p) > $targetChars && $chunks[$current] !== '') {\n                $chunks[] = $p;\n            } else {\n                $chunks[$current] .= \"\\n\\n\" . $p;\n            }\n        }\n\n        return array_map('trim', $chunks);\n    }\n}\n```\n\nRun it: `php artisan rag:ingest storage/docs`\n\n. Re-running replaces a file's chunks, so ingestion is idempotent — wire it to your deploy or a scheduled job and content stays fresh.\n\npgvector's `<=>`\n\noperator is cosine distance. Lower = more similar. Embed the user's question, sort by distance, take the top handful:\n\n``` php\nnamespace App\\Services;\n\nuse App\\Models\\DocumentChunk;\nuse OpenAI\\Laravel\\Facades\\OpenAI;\n\nclass Retriever\n{\n    /** @return array{content: string, source: string}[] */\n    public function search(string $question, int $limit = 5): array\n    {\n        $embedding = OpenAI::embeddings()->create([\n            'model' => 'text-embedding-3-small',\n            'input' => $question,\n        ])->embeddings[0]->embedding;\n\n        return DocumentChunk::query()\n            ->selectRaw('content, source, embedding <=> ? AS distance', [json_encode($embedding)])\n            ->orderBy('distance')\n            ->limit($limit)\n            ->get()\n            ->filter(fn ($c) => $c->distance < 0.55) // relevance floor — tune on real queries\n            ->map(fn ($c) => ['content' => $c->content, 'source' => $c->source])\n            ->all();\n    }\n}\n```\n\nThat distance floor is your hallucination guard. If nothing scores under the threshold, the honest answer is \"I don't know\" — and you should say exactly that instead of feeding the model junk context.\n\n``` php\nnamespace App\\Services;\n\nuse OpenAI\\Laravel\\Facades\\OpenAI;\n\nclass KnowledgeBot\n{\n    public function __construct(private Retriever $retriever) {}\n\n    public function answer(string $question): string\n    {\n        $chunks = $this->retriever->search($question);\n\n        if ($chunks === []) {\n            return \"I couldn't find that in our documentation — try rephrasing, or contact support.\";\n        }\n\n        $context = collect($chunks)\n            ->map(fn ($c) => \"[{$c['source']}]\\n{$c['content']}\")\n            ->implode(\"\\n\\n---\\n\\n\");\n\n        $response = OpenAI::chat()->create([\n            'model' => 'gpt-4o-mini',\n            'max_tokens' => 500,\n            'messages' => [\n                ['role' => 'system', 'content' => <<<PROMPT\n                    Answer using ONLY the context below. If the context doesn't\n                    contain the answer, say you don't know — never invent facts.\n                    Cite the source file name when you answer.\n\n                    Context:\n                    {$context}\n                    PROMPT],\n                ['role' => 'user', 'content' => $question],\n            ],\n        ]);\n\n        return $response->choices[0]->message->content;\n    }\n}\n```\n\nWire `KnowledgeBot::answer()`\n\ninto the streaming controller from part 2 of this series and you have a knowledge-base bot that streams grounded answers.\n\nRetrieval makes the model *knowledgeable*. The next step makes it *capable*: in part 4 we'll build AI agents in PHP — giving the model tools it can call (search orders, create tickets, query your database) and handling the tool-calling loop safely in Laravel. That's where chatbots turn into products.\n\n*I'm Aditya Kumar ( adityakdevin) — Tech Lead & full-stack developer building AI-powered web products with Laravel, Vue, and LLM APIs. Find me at adityadev.in.*", "url": "https://wpnews.pro/news/rag-in-laravel-embeddings-and-pgvector-for-a-knowledge-base-bot", "canonical_source": "https://dev.to/adityakdevin/rag-in-laravel-embeddings-and-pgvector-for-a-knowledge-base-bot-3l2g", "published_at": "2026-07-16 21:09:06+00:00", "updated_at": "2026-07-16 21:36:37.287802+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "developer-tools", "ai-tools"], "entities": ["Laravel", "PostgreSQL", "pgvector", "OpenAI", "Supabase", "Neon", "Laravel Forge"], "alternates": {"html": "https://wpnews.pro/news/rag-in-laravel-embeddings-and-pgvector-for-a-knowledge-base-bot", "markdown": "https://wpnews.pro/news/rag-in-laravel-embeddings-and-pgvector-for-a-knowledge-base-bot.md", "text": "https://wpnews.pro/news/rag-in-laravel-embeddings-and-pgvector-for-a-knowledge-base-bot.txt", "jsonld": "https://wpnews.pro/news/rag-in-laravel-embeddings-and-pgvector-for-a-knowledge-base-bot.jsonld"}}