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)