DEV Community

Marcc Atayde
Marcc Atayde

Posted on

Building Intelligent Chatbots with RAG and Vector Databases: A Developer's Practical Guide

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:

  1. Ingest — Load documents, chunk them, generate embeddings, store in a vector database
  2. Query — Embed the user's question, run a similarity search against your vector store
  3. 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"
Enter fullscreen mode Exit fullscreen mode

Add your key to .env:

OPENAI_API_KEY=sk-...
QDRANT_URL=http://localhost:6333
QDRANT_COLLECTION=knowledge_base
Enter fullscreen mode Exit fullscreen mode

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,
            ];
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
]);
Enter fullscreen mode Exit fullscreen mode

Real-World Considerations

Building this in production surfaces a few gotchas worth knowing before you deploy:

  • Embedding costs: text-embedding-3-small is 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)