Most chatbots are broken in a predictable way. You ask something slightly outside their training data, and they either hallucinate confidently or give you a useless "I don't know." The problem isn't the language model — it's the architecture. Retrieval-Augmented Generation (RAG) fixes this by grounding your chatbot in real, up-to-date knowledge rather than relying purely on what the model memorized during training.
In this guide, we'll build an intelligent chatbot using RAG, a vector database, and Laravel as the backend. By the end, you'll understand the full pipeline: from chunking documents and generating embeddings, to querying a vector store and feeding retrieved context into an LLM response.
What Is RAG and Why Does It Matter?
RAG stands for Retrieval-Augmented Generation. Instead of asking a language model to answer purely from memory, you first retrieve relevant documents from a knowledge base, then pass those documents as context to the model alongside the user's question.
The result: answers that are accurate, grounded in your own data, and far less likely to hallucinate.
The pipeline looks like this:
- Ingest — Load documents, chunk them, generate embeddings, store in a vector database
- Query — Embed the user's question, run a similarity search against your vector store
- Generate — Feed retrieved chunks + question into an LLM, return the response
Setting Up the Stack
For this guide we'll use:
- Laravel as the application backend
-
OpenAI for embeddings (
text-embedding-3-small) and chat completions (gpt-4o) - Qdrant as the vector database (self-hosted or cloud)
- openai-php/laravel client package
composer require openai-php/laravel
php artisan vendor:publish --provider="OpenAI\Laravel\ServiceProvider"
Add your key to .env:
OPENAI_API_KEY=sk-...
QDRANT_URL=http://localhost:6333
QDRANT_COLLECTION=knowledge_base
Step 1: Ingesting and Chunking Documents
The quality of your RAG system lives or dies by how well you chunk your documents. Chunks that are too large lose precision; too small and you lose context. A sliding window of ~500 tokens with ~50-token overlap tends to work well in practice.
// app/Services/DocumentIngestionService.php
namespace App\Services;
use OpenAI\Laravel\Facades\OpenAI;
use Illuminate\Support\Collection;
class DocumentIngestionService
{
public function chunkText(string $text, int $chunkSize = 500, int $overlap = 50): array
{
$words = explode(' ', $text);
$chunks = [];
$start = 0;
while ($start < count($words)) {
$chunk = array_slice($words, $start, $chunkSize);
$chunks[] = implode(' ', $chunk);
$start += ($chunkSize - $overlap);
}
return $chunks;
}
public function embedChunks(array $chunks): Collection
{
return collect($chunks)->map(function (string $chunk) {
$response = OpenAI::embeddings()->create([
'model' => 'text-embedding-3-small',
'input' => $chunk,
]);
return [
'text' => $chunk,
'embedding' => $response->embeddings[0]->embedding,
];
});
}
}
Step 2: Storing Embeddings in Qdrant
Qdrant is a purpose-built vector database with a clean REST API. We'll use Laravel's HTTP client to interact with it directly — no SDK needed.
// app/Services/VectorStoreService.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class VectorStoreService
{
protected string $baseUrl;
protected string $collection;
public function __construct()
{
$this->baseUrl = config('services.qdrant.url');
$this->collection = config('services.qdrant.collection');
}
public function upsertPoints(array $points): void
{
$payload = collect($points)->map(fn($point) => [
'id' => (string) Str::uuid(),
'vector' => $point['embedding'],
'payload' => ['text' => $point['text']],
])->values()->all();
Http::put("{$this->baseUrl}/collections/{$this->collection}/points", [
'points' => $payload,
]);
}
public function search(array $queryEmbedding, int $topK = 5): array
{
$response = Http::post("{$this->baseUrl}/collections/{$this->collection}/points/search", [
'vector' => $queryEmbedding,
'limit' => $topK,
'with_payload' => true,
]);
return collect($response->json('result'))
->pluck('payload.text')
->all();
}
}
Step 3: The RAG Query Pipeline
This is where it all comes together. We embed the user's question, retrieve the most relevant chunks, then construct a prompt that grounds the LLM in that context.
// app/Services/ChatbotService.php
namespace App\Services;
use OpenAI\Laravel\Facades\OpenAI;
class ChatbotService
{
public function __construct(
protected VectorStoreService $vectorStore
) {}
public function answer(string $userQuestion): string
{
// 1. Embed the question
$embeddingResponse = OpenAI::embeddings()->create([
'model' => 'text-embedding-3-small',
'input' => $userQuestion,
]);
$queryVector = $embeddingResponse->embeddings[0]->embedding;
// 2. Retrieve relevant context
$contextChunks = $this->vectorStore->search($queryVector, topK: 5);
$context = implode("\n\n", $contextChunks);
// 3. Build grounded prompt
$systemPrompt = <<<PROMPT
You are a helpful assistant. Answer the user's question using ONLY the context provided below.
If the answer is not in the context, say "I don't have enough information to answer that."
Context:
{$context}
PROMPT;
// 4. Generate response
$completion = OpenAI::chat()->create([
'model' => 'gpt-4o',
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userQuestion],
],
]);
return $completion->choices[0]->message->content;
}
}
Wiring It Up with a Livewire Component
For real-time UX, a Livewire component handles streaming the response without a full page reload:
// app/Livewire/Chatbot.php
namespace App\Livewire;
use App\Services\ChatbotService;
use Livewire\Component;
class Chatbot extends Component
{
public string $question = '';
public string $answer = '';
public bool $loading = false;
public function ask(ChatbotService $chatbot): void
{
$this->validate(['question' => 'required|string|max:500']);
$this->loading = true;
$this->answer = $chatbot->answer($this->question);
$this->loading = false;
}
public function render()
{
return view('livewire.chatbot');
}
}
Improving Retrieval Quality
Once the basic pipeline works, a few techniques can significantly improve answer quality:
Hybrid Search
Combine vector similarity search with keyword (BM25) search. Qdrant supports sparse + dense vector search natively. This prevents edge cases where exact keyword matches score poorly in pure embedding space.
Re-ranking
After your initial top-K retrieval, run a cross-encoder re-ranker (e.g., via Cohere's Rerank API) to re-order results by true relevance to the question before constructing the prompt.
Metadata Filtering
Tag your chunks with metadata — document type, date, category — and pre-filter before the vector search. This is especially useful for multi-tenant systems where users should only see their own data.
// Example: filter by tenant in Qdrant search
Http::post("{$this->baseUrl}/collections/{$this->collection}/points/search", [
'vector' => $queryEmbedding,
'limit' => $topK,
'filter' => [
'must' => [
['key' => 'tenant_id', 'match' => ['value' => auth()->id()]],
],
],
'with_payload' => true,
]);
Real-World Considerations
Building this in production surfaces a few gotchas worth knowing before you deploy:
-
Embedding costs:
text-embedding-3-smallis cheap, but large document libraries still add up. Cache embeddings aggressively and only re-embed on document change. - Chunk freshness: When source documents update, you need a strategy to re-ingest the affected chunks and delete stale vectors by document ID.
- Latency: Two API calls (embed + generate) plus a vector search adds 1–3 seconds. Use Laravel queues and optimistic UI patterns to keep the experience snappy.
- Evaluation: Build a test set of question/answer pairs and track retrieval recall + answer faithfulness. Without measurement, you're flying blind. Tools like RAGAS can automate this.
For teams exploring how these patterns integrate into client projects — whether it's document Q&A, internal knowledge bases, or customer support bots — there's genuinely useful material to check it out across different AI integration approaches.
Conclusion
RAG isn't a silver bullet, but it's the most practical architecture for building chatbots that actually know what they're talking about. The core pattern — embed, retrieve, generate — is straightforward in Laravel once you have the scaffolding in place. The real engineering work is in the details: chunk strategy, hybrid search, metadata filtering, and evaluation.
Start simple: get one document ingested and a basic similarity search working end-to-end. Then layer on the improvements. A working RAG pipeline you can iterate on beats a theoretically perfect one you never ship.
Top comments (0)