Available for Q3 2026 projects — Laravel, AI agents & automation
Build With Abdallah logo Build With Abdallah Software · AI · Automation
AI Agents 7 min read Jun 01, 2026

Build an AI-Powered Knowledge Base in Laravel: A Complete RAG Tutorial

Every Laravel application eventually needs to search documents. Users want to ask questions in natural language. Businesses want to search contracts, invoices, and reports without building a search en...

A
Abdallah Mohamed
Senior Full-Stack Engineer

Build an AI-Powered Knowledge Base in Laravel: A Complete RAG Tutorial

Every Laravel application eventually needs to search documents. Users want to ask questions in natural language. Businesses want to search contracts, invoices, and reports without building a search engine from scratch.

Retrieval Augmented Generation (RAG) is the practical way to do this with Laravel's new AI SDK. This tutorial shows you how to build a working knowledge base that searches your documents using natural language.


What You Will Build

A Laravel application that:

  • Accepts PDF and text documents
  • Stores them in a PostgreSQL database with vector embeddings
  • Searches documents using natural language queries
  • Returns relevant excerpts with AI-generated answers
  • Provides a Livewire search interface

Prerequisites

  • Laravel 13+
  • PostgreSQL 15+ with pgvector extension
  • Laravel AI SDK installed (composer require laravel/ai)
  • OpenAI or Ollama API key (Ollama recommended for cost)

Step 1: Set Up pgvector

First, install the pgvector extension for PostgreSQL.

# On Ubuntu/Debian
sudo apt-get install postgresql-15-pgvector

# Enable the extension
psql -d your_database -c "CREATE EXTENSION IF NOT EXISTS vector;"

Add the vector column to your documents table. The dimension must match your embedding model:

// Migration
Schema::create('documents', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('content');
    // OpenAI text-embedding-3-small = 1536 dimensions
    // Ollama nomic-embed-text = 768 dimensions
    $table->vector('embedding', 1536);
    $table->timestamps();
});

Step 2: Document Processing

Create a service that processes uploaded documents:

use Illuminate\Http\UploadedFile;

class DocumentProcessor
{
    protected EmbeddingService $embeddingService;
    
    public function __construct(EmbeddingService $embeddingService)
    {
        $this->embeddingService = $embeddingService;
    }
    
    public function process(UploadedFile $file): Document
    {
        $text = $this->extractText($file);
        $chunks = $this->chunkText($text);
        
        foreach ($chunks as $chunk) {
            $embedding = $this->embeddingService->generate($chunk);
            Document::create([
                'title' => $file->getClientOriginalName(),
                'content' => $chunk,
                'embedding' => $embedding,
            ]);
        }
    }
    
    private function extractText(UploadedFile $file): string
    {
        return match($file->extension()) {
            'pdf' => $this->extractPdf($file),
            'txt', 'md' => file_get_contents($file->path()),
            default => throw new \InvalidArgumentException("Unsupported file type: {$file->extension()}"),
        };
    }
    
    private function extractPdf(UploadedFile $file): string
    {
        // Use a PDF extraction library like spatie/pdf-to-text or smalot/pdfparser
        // This is a stub — implement with your preferred library
        throw new \RuntimeException('PDF extraction not implemented. Install spatie/pdf-to-text or similar.');
    }
    
    private function chunkText(string $text, int $chunkSize = 1000): array
    {
        $chunks = [];
        $overlap = 200;
        
        for ($i = 0; $i < mb_strlen($text); $i += ($chunkSize - $overlap)) {
            $chunks[] = mb_substr($text, $i, $chunkSize);
        }
        
        return $chunks;
    }
}

Step 3: Generate Embeddings

Use the Laravel AI SDK to generate embeddings:

use Laravel\Ai\Embeddings;

class EmbeddingService
{
    public function generate(string $text): array
    {
        $response = Embeddings::for([$text])->generate();
        
        // Returns the first (and only) embedding vector
        return $response->first();
    }
}

For Ollama (free, self-hosted):

use Laravel\Ai\Embeddings;
use Laravel\Ai\Enums\Lab;

class EmbeddingService
{
    public function generate(string $text): array
    {
        $response = Embeddings::for([$text])
            ->dimensions(768) // nomic-embed-text outputs 768 dimensions
            ->generate(Lab::Ollama);
        
        return $response->first();
    }
}

The Embeddings::for() method accepts an array of strings and returns an EmbeddingsResponse. Use ->first() to get the first embedding vector, or iterate ->embeddings for multiple inputs.


Step 4: Vector Search

Implement similarity search using pgvector's cosine distance:

class DocumentSearchService
{
    protected EmbeddingService $embeddingService;
    
    public function __construct(EmbeddingService $embeddingService)
    {
        $this->embeddingService = $embeddingService;
    }
    
    public function search(string $query, int $limit = 5, float $threshold = 0.7): Collection
    {
        $queryEmbedding = $this->embeddingService->generate($query);
        
        return Document::query()
            ->selectRaw('documents.*, 1 - (embedding <=> ?::vector) as similarity', [$this->formatVector($queryEmbedding)])
            ->havingRaw('1 - (embedding <=> ?::vector) > ?', [$this->formatVector($queryEmbedding), $threshold])
            ->orderByRaw('embedding <=> ?::vector', [$this->formatVector($queryEmbedding)])
            ->limit($limit)
            ->get();
    }
    
    /**
     * Format array as PostgreSQL vector literal.
     * Some drivers accept raw arrays; others need string serialization.
     */
    protected function formatVector(array $vector): string
    {
        return '[' . implode(',', $vector) . ']';
    }
}

The <=> operator calculates cosine distance. Lower values mean higher similarity. The HAVING clause filters out results below the similarity threshold.

Laravel-Native Alternative

Laravel 13 provides a whereVectorSimilarTo query builder method for vector search:

Document::query()
    ->whereVectorSimilarTo('embedding', $queryEmbedding)
    ->orderBy('similarity', 'desc')
    ->limit(5)
    ->get();

This handles the vector formatting and distance calculation automatically.


Step 5: Build the Search Interface

Create a Livewire component for the search UI:

use Livewire\Component;
use Illuminate\Support\Collection;

class DocumentSearch extends Component
{
    public string $query = '';
    public Collection $results;
    
    public function search()
    {
        $this->results = app(DocumentSearchService::class)->search($this->query);
    }
    
    public function render()
    {
        return view('livewire.document-search');
    }
}

Blade view:

<div>
    <input wire:model="query" wire:keydown.enter="search" type="text" placeholder="Search documents...">
    
    @if($results)
        <div class="results">
            @foreach($results as $result)
                <div class="result" style="--similarity: {{ $result->similarity }}">
                    <h3>{{ $result->title }}</h3>
                    <p>{{ Str::limit($result->content, 200) }}</p>
                    <span class="similarity">{{ round($result->similarity * 100, 1) }}% match</span>
                </div>
            @endforeach
        </div>
    @endif
</div>

Step 6: Add AI-Powered Answers

Enhance search results with AI-generated answers using an Agent:

use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Promptable;
use Stringable;

class KnowledgeBaseAgent implements Agent, Conversational
{
    use Promptable;
    
    protected DocumentSearchService $documentSearch;
    
    public function __construct(DocumentSearchService $documentSearch)
    {
        $this->documentSearch = $documentSearch;
    }
    
    public function instructions(): Stringable|string
    {
        return 'You are a knowledgeable assistant. Answer questions based on the provided documents. If unsure, say so.';
    }
    
    public function messages(): iterable
    {
        return [];
    }
    
    public function ask(string $question): array
    {
        // Retrieve relevant documents
        $documents = $this->documentSearch->search($question, 3);
        
        // Build context from top results
        $context = $documents->pluck('content')->implode("\n---\n");
        
        // Generate answer using the agent
        $response = $this->prompt("Context:\n{$context}\n\nQuestion: {$question}");
        
        return [
            'answer' => $response,
            'sources' => $documents,
        ];
    }
}

Register the agent in a service provider:

// In a controller or service
$agent = new KnowledgeBaseAgent(app(DocumentSearchService::class));
$result = $agent->ask('What is Laravel?');

echo $result['answer']; // AI-generated answer

Step 7: Testing

Write tests for your RAG pipeline:

class DocumentSearchTest extends TestCase
{
    public function test_can_search_documents()
    {
        $embedding = app(EmbeddingService::class)->generate('Laravel PHP framework');
        
        Document::factory()->create([
            'content' => 'Laravel is a PHP framework for web artisans',
            'embedding' => $embedding,
        ]);
        
        $results = app(DocumentSearchService::class)->search('What is Laravel?');
        
        $this->assertCount(1, $results);
        $this->assertGreaterThan(0.7, $results->first()->similarity);
    }
    
    public function test_filters_irrelevant_results()
    {
        $embedding = app(EmbeddingService::class)->generate('Laravel PHP framework');
        $irrelevantEmbedding = app(EmbeddingService::class)->generate('Python data science machine learning');
        
        Document::factory()->create([
            'content' => 'Laravel is a PHP framework for web artisans',
            'embedding' => $embedding,
        ]);
        
        Document::factory()->create([
            'content' => 'Python is great for data science',
            'embedding' => $irrelevantEmbedding,
        ]);
        
        $results = app(DocumentSearchService::class)->search('something completely unrelated to Laravel');
        
        $this->assertEmpty($results);
    }
}

Step 8: Production Considerations

Indexing Strategy

Create an index for faster searches:

CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

Queueing Document Processing

Process documents asynchronously to avoid timeouts:

// Dispatch job
ProcessDocument::dispatch($file);

// Job class
class ProcessDocument implements ShouldQueue
{
    public function handle(DocumentProcessor $processor)
    {
        $processor->process($this->file);
    }
}

Handling Duplicate Source Documents

Since the documents table stores chunks (one row per chunk), you may want to deduplicate by source file:

class DocumentSearchService
{
    public function search(string $query, int $limit = 5, float $threshold = 0.7): Collection
    {
        $results = $this->rawSearch($query, $limit * 2, $threshold);
        
        // Deduplicate by source file, keeping highest similarity
        return $results->groupBy('title')
            ->map(fn ($chunks) => $chunks->sortByDesc('similarity')->first())
            ->sortByDesc('similarity')
            ->take($limit)
            ->values();
    }
}

Cost Comparison

Approach Cost/Month Privacy Setup Complexity
OpenAI API $20-100 Low Low
Ollama Local $0 High Medium
Mixed (Ollama embed, OpenAI chat) $5-20 Medium Medium

When to Use RAG

Use RAG when:

  • You need to search private documents
  • Users ask questions in natural language
  • Document count is under 100,000
  • You need context-aware answers

Don't use RAG when:

  • Documents change every minute (use streaming instead)
  • You only need keyword search (use Scout)
  • You have millions of documents (use Elasticsearch + RAG hybrid)

Source Links