Laravel AI SDK ile RAG Destekli Destek Botu
Bir onceki yazida Laravel AI SDK'nin temellerini, basit chat completion ve streaming yapilarini incelemistik. Simdi isi bir seviye ileri tasiyoruz: Tool/function calling, conversation memory ve RAG (Retrieval-Augmented Generation) birlestirerek gercek anlamda ise yarayan bir destek botu yapacagiz.
Bu yazida yapacaklarimiz:
- Embedding uretimi ve pgvector'de saklama
- RAG pipeline: kullanici sorusu -> benzer dokumanlari bul -> context olarak ekle -> LLM'e gonder
- Tool/function calling ile bot'a yetenekler kazandirma
- Conversation memory ile veritabani tabanli sohbet hafizasi
- Hepsini birlestiren multi-step agent workflow
Oncelikle: RAG Nedir ve Neden Lazim?
LLM'ler cok zeki ama bir sorunlari var: sadece egitildikleri veriyi biliyorlar. Sirketinizin urun dokumantasyonunu, SSS yanitlarini, destek gecmisini bilmiyorlar. RAG tam burada devreye giriyor.
RAG kisaca soyle calisiyor:
- Dokumanlari kucuk parcalara bol (chunking)
- Her parcayi embedding vektorune cevir
- Vektor veritabaninda sakla
- Kullanici soru sordugunda, soruyu da embedding'e cevir
- En benzer dokuman parcalarini bul
- Bulunan parcalari LLM'e context olarak ver
- LLM, bu context'e dayanarak cevap uretsin
Gerekli Paketler ve Kurulum
composer require laravel/ai
composer require pgvector/pgvector
# PostgreSQL pgvector extension'i aktif et
php artisan db:seed --class=PgVectorSetupSeeder
PostgreSQL'de pgvector extension'ini aktif edelim:
// database/migrations/2026_01_15_000001_enable_pgvector.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
}
public function down(): void
{
DB::statement('DROP EXTENSION IF EXISTS vector');
}
};
Dokuman Embedding Tablosu
// database/migrations/2026_01_15_000002_create_document_embeddings_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('document_embeddings', function (Blueprint $table) {
$table->id();
$table->string('source'); // hangi dokumantan geldi
$table->text('content'); // chunk icerigi
$table->vector('embedding', 1536); // OpenAI ada-002 1536 boyut
$table->json('metadata')->nullable();
$table->timestamps();
});
// HNSW index - benzerlik aramalari icin
DB::statement(
'CREATE INDEX document_embeddings_idx ON document_embeddings
USING hnsw (embedding vector_cosine_ops)'
);
}
};
Conversation Memory Tablosu
Bot'un sohbet hafizasini veritabaninda tutacagiz. Boylece kullanici sayfayi yenilese bile konusma devam eder.
// database/migrations/2026_01_15_000003_create_conversations_table.php
return new class extends Migration
{
public function up(): void
{
Schema::create('conversations', function (Blueprint $table) {
$table->id();
$table->string('session_id')->unique();
$table->foreignId('user_id')->nullable()->constrained();
$table->string('title')->nullable();
$table->timestamps();
});
Schema::create('conversation_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
$table->enum('role', ['user', 'assistant', 'system', 'tool']);
$table->text('content');
$table->json('tool_calls')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
};
Embedding Uretimi ve Saklama
Laravel AI SDK'nin Str::toEmbeddings macro'su ile embedding uretmek cok basit:
// app/Services/EmbeddingService.php
namespace App\Services;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use App\Models\DocumentEmbedding;
class EmbeddingService
{
/**
* Bir dokumani chunk'lara bolup embedding'lerini olustur
*/
public function indexDocument(string $content, string $source, array $metadata = []): int
{
$chunks = $this->chunkText($content, maxTokens: 500, overlap: 50);
$count = 0;
foreach (array_chunk($chunks, 20) as $batch) {
// Laravel AI SDK ile toplu embedding
$embeddings = Str::toEmbeddings($batch, model: 'text-embedding-ada-002');
foreach ($embeddings as $i => $embedding) {
DocumentEmbedding::create([
'source' => $source,
'content' => $batch[$i],
'embedding' => $embedding,
'metadata' => $metadata,
]);
$count++;
}
}
return $count;
}
/**
* Metni overlapping chunk'lara bol
*/
private function chunkText(string $text, int $maxTokens = 500, int $overlap = 50): array
{
$sentences = preg_split('/(?<=[.!?])\s+/', $text);
$chunks = [];
$currentChunk = '';
$currentTokens = 0;
foreach ($sentences as $sentence) {
$sentenceTokens = (int) ceil(mb_strlen($sentence) / 4); // kaba token tahmini
if ($currentTokens + $sentenceTokens > $maxTokens && $currentChunk !== '') {
$chunks[] = trim($currentChunk);
// Overlap: son birkaç cumleyi sonraki chunk'a tasi
$words = explode(' ', $currentChunk);
$overlapWords = array_slice($words, -$overlap);
$currentChunk = implode(' ', $overlapWords) . ' ' . $sentence;
$currentTokens = (int) ceil(mb_strlen($currentChunk) / 4);
} else {
$currentChunk .= ' ' . $sentence;
$currentTokens += $sentenceTokens;
}
}
if (trim($currentChunk) !== '') {
$chunks[] = trim($currentChunk);
}
return $chunks;
}
/**
* Soruya en benzer dokuman parcalarini bul
*/
public function search(string $query, int $limit = 5, float $threshold = 0.7): array
{
$queryEmbedding = Str::toEmbeddings([$query], model: 'text-embedding-ada-002')[0];
$results = DB::select("
SELECT id, source, content, metadata,
1 - (embedding <=> ?) as similarity
FROM document_embeddings
WHERE 1 - (embedding <=> ?) > ?
ORDER BY embedding <=> ?
LIMIT ?
", [$queryEmbedding, $queryEmbedding, $threshold, $queryEmbedding, $limit]);
return collect($results)->map(fn ($row) => [
'content' => $row->content,
'source' => $row->source,
'similarity' => round($row->similarity, 4),
'metadata' => json_decode($row->metadata, true),
])->toArray();
}
}
Tool/Function Calling
Bot'umuza yetenekler kazandiralim. Mesela siparis durumu sorgulama, fiyat bilgisi alma gibi:
// app/Services/SupportBotTools.php
namespace App\Services;
use Laravel\AI\Tools\Tool;
use Laravel\AI\Tools\Parameter;
use App\Models\Order;
use App\Models\Product;
class SupportBotTools
{
#[Tool('Musteri siparis durumunu sorgular')]
public function checkOrderStatus(
#[Parameter('Siparis numarasi')] string $orderNumber,
): string {
$order = Order::where('order_number', $orderNumber)->first();
if (! $order) {
return json_encode([
'found' => false,
'message' => 'Siparis bulunamadi',
]);
}
return json_encode([
'found' => true,
'order_number' => $order->order_number,
'status' => $order->status,
'status_text' => $order->status_text,
'created_at' => $order->created_at->format('d.m.Y H:i'),
'estimated_delivery' => $order->estimated_delivery?->format('d.m.Y'),
'tracking_number' => $order->tracking_number,
]);
}
#[Tool('Urun fiyat ve stok bilgisi sorgular')]
public function getProductInfo(
#[Parameter('Urun adi veya SKU')] string $query,
): string {
$products = Product::where('name', 'ILIKE', "%{$query}%")
->orWhere('sku', $query)
->limit(3)
->get(['name', 'sku', 'price', 'stock_quantity']);
return json_encode([
'found' => $products->isNotEmpty(),
'products' => $products->toArray(),
]);
}
#[Tool('Destek talebi olusturur')]
public function createSupportTicket(
#[Parameter('Talep basligi')] string $subject,
#[Parameter('Talep detayi')] string $description,
#[Parameter('Oncelik: low, medium, high')] string $priority = 'medium',
): string {
// Burada gercek ticket olusturma mantigi olacak
$ticketNumber = 'TK-' . now()->format('Ymd') . '-' . rand(1000, 9999);
return json_encode([
'success' => true,
'ticket_number' => $ticketNumber,
'message' => "Destek talebi {$ticketNumber} numarasiyla olusturuldu.",
]);
}
}
Conversation Memory Service
// app/Services/ConversationMemory.php
namespace App\Services;
use App\Models\Conversation;
use App\Models\ConversationMessage;
class ConversationMemory
{
public function getOrCreateConversation(string $sessionId, ?int $userId = null): Conversation
{
return Conversation::firstOrCreate(
['session_id' => $sessionId],
['user_id' => $userId, 'title' => 'Yeni Konusma']
);
}
public function addMessage(Conversation $conversation, string $role, string $content, array $extra = []): ConversationMessage
{
return $conversation->messages()->create([
'role' => $role,
'content' => $content,
'tool_calls' => $extra['tool_calls'] ?? null,
'metadata' => $extra['metadata'] ?? null,
]);
}
/**
* Son N mesaji al (sliding window)
*/
public function getHistory(Conversation $conversation, int $limit = 20): array
{
return $conversation->messages()
->orderBy('created_at', 'desc')
->limit($limit)
->get()
->reverse()
->map(fn ($msg) => [
'role' => $msg->role,
'content' => $msg->content,
])
->values()
->toArray();
}
/**
* Konusma ozetini olustur (uzun konusmalar icin)
*/
public function summarizeIfNeeded(Conversation $conversation): ?string
{
$messageCount = $conversation->messages()->count();
if ($messageCount < 30) {
return null;
}
// Eski mesajlari ozetle, yenilerini koru
$oldMessages = $conversation->messages()
->orderBy('created_at')
->limit($messageCount - 10)
->get();
$context = $oldMessages->map(fn ($m) => "{$m->role}: {$m->content}")->implode("\n");
$summary = AI::chat()
->model('gpt-4o-mini')
->system('Bu konusmayi kisa ve onemli noktalari kaybetmeden ozetle.')
->send($context);
return $summary->content;
}
}
Ana Bot Service: Hepsini Birlestiriyoruz
Simdi geldik en guzel kisma. Tool calling + memory + RAG hepsini tek bir service'te birlestiriyoruz:
// app/Services/SupportBot.php
namespace App\Services;
use Laravel\AI\Facades\AI;
use Illuminate\Support\Facades\Log;
class SupportBot
{
private string $systemPrompt = <<<'PROMPT'
Sen bir destek asistanisin. Turkce konusuyorsun. Kibar ve yardimci ol.
KURALLAR:
- Bilmedigin konularda "Bilmiyorum, destek ekibine yonlendireyim" de
- Siparis sorgulama icin checkOrderStatus tool'unu kullan
- Urun bilgisi icin getProductInfo tool'unu kullan
- Cozulemeyen sorunlarda createSupportTicket ile talep olustur
- Context olarak verilen dokumanlardan faydalanarak cevap ver
- Dokumanda yoksa ve tool ile bulamiyorsan, uydir degil, duzgunce belirt
PROMPT;
public function __construct(
private EmbeddingService $embeddingService,
private ConversationMemory $memory,
private SupportBotTools $tools,
) {}
public function chat(string $sessionId, string $userMessage, ?int $userId = null): string
{
// 1. Konusmayi al veya olustur
$conversation = $this->memory->getOrCreateConversation($sessionId, $userId);
// 2. RAG: Ilgili dokumanlari bul
$relevantDocs = $this->embeddingService->search($userMessage, limit: 3);
$ragContext = $this->buildRagContext($relevantDocs);
// 3. Konusma gecmisini al
$history = $this->memory->getHistory($conversation, limit: 15);
// 4. Ozet gerekiyorsa ekle
$summary = $this->memory->summarizeIfNeeded($conversation);
// 5. System prompt'u RAG context ile zenginlestir
$enrichedSystemPrompt = $this->systemPrompt;
if ($ragContext) {
$enrichedSystemPrompt .= "\n\nILGILI DOKUMANLAR:\n" . $ragContext;
}
if ($summary) {
$enrichedSystemPrompt .= "\n\nONCEKI KONUSMA OZETI:\n" . $summary;
}
// 6. Kullanici mesajini kaydet
$this->memory->addMessage($conversation, 'user', $userMessage);
// 7. AI'a gonder (tool'larla birlikte)
$response = AI::chat()
->model('gpt-4o')
->system($enrichedSystemPrompt)
->tools([$this->tools])
->messages([
...$history,
['role' => 'user', 'content' => $userMessage],
])
->send();
// 8. Tool call loop - agent birden fazla tool cagirabilir
$maxIterations = 5;
$iteration = 0;
while ($response->hasToolCalls() && $iteration < $maxIterations) {
$toolResults = [];
foreach ($response->toolCalls as $toolCall) {
Log::info("Tool called: {$toolCall->name}", $toolCall->arguments);
$result = $toolCall->execute();
$toolResults[] = [
'tool_call_id' => $toolCall->id,
'content' => $result,
];
}
// Tool sonuclarini tekrar AI'a gonder
$response = AI::chat()
->model('gpt-4o')
->system($enrichedSystemPrompt)
->tools([$this->tools])
->messages([
...$history,
['role' => 'user', 'content' => $userMessage],
['role' => 'assistant', 'tool_calls' => $response->toolCalls],
...array_map(fn ($r) => ['role' => 'tool', ...$r], $toolResults),
])
->send();
$iteration++;
}
$assistantMessage = $response->content;
// 9. Asistan cevabini kaydet
$this->memory->addMessage($conversation, 'assistant', $assistantMessage);
return $assistantMessage;
}
private function buildRagContext(array $docs): string
{
if (empty($docs)) {
return '';
}
return collect($docs)->map(function ($doc, $i) {
$num = $i + 1;
return "[Dokumar {$num} - Kaynak: {$doc['source']}, Benzerlik: {$doc['similarity']}]\n{$doc['content']}";
})->implode("\n\n");
}
}
Controller ve Route
// app/Http/Controllers/SupportBotController.php
namespace App\Http\Controllers;
use App\Services\SupportBot;
use Illuminate\Http\Request;
class SupportBotController extends Controller
{
public function __construct(private SupportBot $bot) {}
public function chat(Request $request)
{
$request->validate([
'message' => 'required|string|max:2000',
'session_id' => 'required|string|max:64',
]);
$response = $this->bot->chat(
sessionId: $request->session_id,
userMessage: $request->message,
userId: $request->user()?->id,
);
return response()->json([
'response' => $response,
'session_id' => $request->session_id,
]);
}
}
// routes/api.php
Route::post('/support/chat', [SupportBotController::class, 'chat'])
->middleware(['throttle:60,1']);
Dokuman Indexleme Artisan Komutu
// app/Console/Commands/IndexDocuments.php
namespace App\Console\Commands;
use App\Services\EmbeddingService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class IndexDocuments extends Command
{
protected $signature = 'support:index-docs {path : Dokuman klasor yolu}';
protected $description = 'Destek dokumanlarini RAG icin indexle';
public function handle(EmbeddingService $service): int
{
$path = $this->argument('path');
if (! File::isDirectory($path)) {
$this->error("Klasor bulunamadi: {$path}");
return self::FAILURE;
}
$files = File::glob("{$path}/*.{md,txt,html}", GLOB_BRACE);
$this->info(count($files) . ' dosya bulundu.');
$bar = $this->output->createProgressBar(count($files));
$totalChunks = 0;
foreach ($files as $file) {
$content = File::get($file);
$filename = basename($file);
$chunks = $service->indexDocument($content, $filename, [
'file_path' => $file,
'indexed_at' => now()->toIso8601String(),
]);
$totalChunks += $chunks;
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Toplam {$totalChunks} chunk indexlendi.");
return self::SUCCESS;
}
}
Kullanimi:
php artisan support:index-docs storage/app/docs
Test Yazalim
// tests/Feature/SupportBotTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Services\SupportBot;
use App\Services\EmbeddingService;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
class SupportBotTest extends TestCase
{
use RefreshDatabase;
public function test_bot_can_answer_from_rag_context(): void
{
// Dokumanlar indexlendigini varsayalim
$embeddingService = app(EmbeddingService::class);
$embeddingService->indexDocument(
'Iade sureci 14 gun icerisinde basvuru yapilmalidir. Urun acilmamis olmalidir.',
'iade-politikasi.md'
);
$bot = app(SupportBot::class);
$response = $bot->chat('test-session-1', 'Iade sureci nasil isliyor?');
$this->assertStringContainsString('14', $response);
}
public function test_bot_uses_tool_for_order_query(): void
{
Order::factory()->create([
'order_number' => 'ORD-123456',
'status' => 'shipped',
'tracking_number' => 'TR123456789',
]);
$bot = app(SupportBot::class);
$response = $bot->chat('test-session-2', 'ORD-123456 numarali siparisim nerede?');
$this->assertStringContainsString('TR123456789', $response);
}
public function test_conversation_memory_persists(): void
{
$bot = app(SupportBot::class);
$bot->chat('test-session-3', 'Benim adim Ali');
$response = $bot->chat('test-session-3', 'Benim adim neydi?');
$this->assertStringContainsString('Ali', $response);
}
}
Performans ve Iyilestirme Ipuclari
Birkaç onemli nokta var burada:
Embedding Cache: Ayni soruyu tekrar tekrar embedding'e cevirmeyin. Redis cache kullanin:
public function searchWithCache(string $query, int $limit = 5): array
{
$cacheKey = 'embedding:' . md5($query);
$embedding = Cache::remember($cacheKey, 3600, function () use ($query) {
return Str::toEmbeddings([$query])[0];
});
// ... geri kalan arama kodu
}
Chunk Boyutu: 500 token civarinda tutun. Cok kucuk chunk'lar baglam kaybeder, cok buyukleri embedding kalitesini dusurur.
Hybrid Search: pgvector'un full-text search ile birlestirin:
SELECT *,
(0.7 * (1 - (embedding <=> $1))) +
(0.3 * ts_rank(to_tsvector('turkish', content), plainto_tsquery('turkish', $2)))
as combined_score
FROM document_embeddings
ORDER BY combined_score DESC
LIMIT 5;
Sonuc
Bu yapiyla elinizde gercekten akilli bir destek botu var:
- RAG sayesinde sirketinizin dokumanlarini biliyor
- Tool calling ile siparis sorgulama gibi aksiyonlar alabiliyor
- Conversation memory ile konusma baglamini kaybetmiyor
- Multi-step agent loop ile karmasik senaryolari cozebiliyor
Bir sonraki adim olarak streaming response ekleyip frontend'de gercek zamanli yazdirma yapabilirsiniz. Livewire veya Inertia ile entegrasyonu da ayri bir yazi konusu olabilir.
ProjeMan olarak bu yapilari production'da kullaniyoruz ve ciddi anlamda destek ekibinin yukunu azaltiyor. Siz de deneyin, sonuclari gorunce sasirabilirsiniz.