Xây dựng RAG với Laravel: Chat với Dữ Liệu của bạn (Chi tiết)

· 8 min read

Xây dựng RAG với Laravel: Chat với Dữ Liệu của bạn

Các mô hình AI tạo sinh (Generative AI) như GPT-4 cực kỳ mạnh mẽ, nhưng chúng có một điểm yếu chí mạng: chúng không biết dữ liệu riêng tư của bạn. Chúng không thể trả lời câu hỏi về các file PDF nội bộ, lịch sử giao dịch của user, hay tài liệu kỹ thuật đặc thù của công ty bạn.

Retrieval-Augmented Generation (RAG) giải quyết vấn đề này bằng cách "bơm" dữ liệu liên quan vào prompt (câu nhắc) trước khi AI đưa ra câu trả lời.

Trong bài viết chuyên sâu này, chúng ta sẽ xây dựng một hệ thống hoàn chỉnh "Chat với tài liệu" sử dụng Laravel, OpenAI, và PostgreSQL với pgvector.

Kiến trúc của RAG

RAG không phải là một công nghệ đơn lẻ; nó là một pipeline (quy trình).

  1. Ingestion (Thu thập): Đọc file (PDF, Markdown, HTML).
  2. Chunking (Cắt nhỏ): Chia văn bản thành các mảnh nhỏ dễ quản lý.
  3. Embedding (Mã hóa vector): Chuyển đổi các mảnh văn bản thành mảng vector (ví dụ ([0.12, -0.98, ...] ).
  4. Storage (Lưu trữ): Lưu vector vào cơ sở dữ liệu.
  5. Retrieval (Truy xuất): Tìm các vector tương đồng nhất với câu hỏi của người dùng.
  6. Synthesis (Tổng hợp): Tạo câu trả lời.

Bước 1: Cài đặt Database với pgvector

Chúng ta sẽ sử dụng extension pgvector của PostgreSQL. Nó cho phép lưu trữ vector trực tiếp trong database quan hệ, giúp việc join bảng và quản lý dữ liệu cực kỳ đơn giản.

Đầu tiên, bật extension và tạo migration.

// database/migrations/2026_02_27_000000_create_document_chunks_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        // Bật pgvector extension
        DB::statement('CREATE EXTENSION IF NOT EXISTS vector');

        Schema::create('documents', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('path');
            $table->timestamps();
        });

        Schema::create('document_chunks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('document_id')->constrained()->cascadeOnDelete();
            $table->text('content'); // Nội dung văn bản thực tế
            $table->vector('embedding', 1536); // OpenAI text-embedding-3-small dùng 1536 chiều
            $table->timestamps();
        });
    }
};

Bước 2: Chiến lược Chunking Thông minh

Đây là nơi hầu hết các hệ thống RAG thất bại. Nếu bạn cắt quá nhỏ, bạn mất ngữ cảnh. Nếu bạn cắt quá lớn, bạn làm hệ thống tìm kiếm bị nhiễu.

Cách ngây thơ: Cắt mỗi 1000 ký tự. Cách tốt hơn: Cắt theo đoạn văn (paragraph) hoặc theo các phần ngữ nghĩa (semantic sections).

Hãy triển khai một Chunking Service mạnh mẽ.

// app/Services/ChunkingService.php

namespace App\Services;

class ChunkingService
{
    public function chunk(string $text, int $maxTokens = 500): array
    {
        // Cài đặt đơn giản: tách theo 2 lần xuống dòng (đoạn văn)
        $paragraphs = explode("\n\n", $text);
        $chunks = [];
        $currentChunk = "";

        foreach ($paragraphs as $paragraph) {
            // Ước lượng thô: 1 token ~= 4 ký tự
            if (strlen($currentChunk . $paragraph) / 4 > $maxTokens) {
                if (!empty($currentChunk)) {
                    $chunks[] = trim($currentChunk);
                }
                $currentChunk = $paragraph;
            } else {
                $currentChunk .= "\n\n" . $paragraph;
            }
        }

        if (!empty($currentChunk)) {
            $chunks[] = trim($currentChunk);
        }

        return $chunks;
    }
}

Bước 3: Embedding & Lưu trữ

Chúng ta cần một service để giao tiếp với OpenAI Embedding API.

// app/Services/EmbeddingService.php

namespace App\Services;

use Illuminate\Support\Facades\Http;

class EmbeddingService
{
    public function generate(string $text): array
    {
        $response = Http::withToken(config('services.openai.key'))
            ->post('https://api.openai.com/v1/embeddings', [
                'input' => str_replace("\n", " ", $text), // Xóa xuống dòng để embedding tốt hơn
                'model' => 'text-embedding-3-small',
            ]);

        return $response->json('data.0.embedding');
    }
}

Bây giờ là Ingestion Job (Job xử lý nhập liệu):

// app/Jobs/ProcessDocument.php

public function handle(ChunkingService $chunker, EmbeddingService $embedder)
{
    $text = file_get_contents($this->document->path);
    $chunks = $chunker->chunk($text);

    foreach ($chunks as $chunk) {
        $vector = $embedder->generate($chunk);

        $this->document->chunks()->create([
            'content' => $chunk,
            'embedding' => $vector, // Laravel pgvector tự xử lý chuyển đổi mảng
        ]);
    }
}

Bước 4: Tìm kiếm Ngữ nghĩa (Phần "Retrieval")

Khi user đặt câu hỏi, chúng ta không tìm kiếm từ khóa (keywords). Chúng ta tìm kiếm ý nghĩa (meaning).

  1. Embedding câu hỏi của user.
  2. Tính khoảng cách Cosine (<=>) giữa vector câu hỏi và tất cả vector trong DB.
  3. Lấy Top K kết quả gần nhất.
// app/Services/SearchService.php

use App\Models\DocumentChunk;

class SearchService
{
    public function search(string $query, int $limit = 5): \Illuminate\Database\Eloquent\Collection
    {
        $embeddingService = new EmbeddingService();
        $queryVector = $embeddingService->generate($query);

        // Chuyển mảng thành chuỗi cho raw SQL nếu cần
        // Nhưng các thư viện phổ biến thường tự xử lý. Đây là logic SQL thuần:
        $vectorString = '[' . implode(',', $queryVector) . ']';

        return DocumentChunk::query()
            ->select('*')
            ->selectRaw('embedding <=> ? as distance', [$vectorString])
            ->orderByRaw('distance ASC') // Khoảng cách càng nhỏ = càng giống nhau
            ->limit($limit)
            ->get();
    }
}

Bước 5: Giai đoạn "Generation" (Tạo câu trả lời)

Cuối cùng, chúng ta xây dựng prompt. Bước này thường gọi là "Grounding" (Tạo cơ sở thực tế).

// app/Controllers/ChatController.php

public function ask(Request $request, SearchService $searcher)
{
    $question = $request->input('question');
    
    // 1. Truy xuất ngữ cảnh (Context)
    $relevantChunks = $searcher->search($question);
    $context = $relevantChunks->pluck('content')->implode("\n---\n");

    // 2. Xây dựng System Prompt
    $systemPrompt = <<<EOT
Bạn là một trợ lý ảo hữu ích cho tài liệu của chúng tôi.
Sử dụng các đoạn ngữ cảnh sau để trả lời câu hỏi ở cuối.
Nếu bạn không biết câu trả lời, hãy nói là không biết, đừng cố bịa ra câu trả lời.

Ngữ cảnh:
$context
EOT;

    // 3. Gọi LLM
    $response = Http::withToken(config('services.openai.key'))
        ->post('https://api.openai.com/v1/chat/completions', [
            'model' => 'gpt-4o',
            'messages' => [
                ['role' => 'system', 'content' => $systemPrompt],
                ['role' => 'user', 'content' => $question],
            ],
        ]);

    return response()->json([
        'answer' => $response->json('choices.0.message.content'),
        'sources' => $relevantChunks->pluck('document.title')->unique()
    ]);
}

Mẹo Nâng Cao

  1. Hybrid Search: Vector thường kém khi tìm kiếm chính xác (như mã số SKU, tên người cụ thể). Hãy kết hợp vector search với full-text search truyền thống (ví dụ: WHERE content LIKE '%...%') để có kết quả tốt nhất.
  2. Reranking: Lấy 20 chunks từ vector search, sau đó dùng một "Reranker Model" (như Cohere Rerank) để sắp xếp lại chính xác top 5 đoạn liên quan nhất trước khi gửi cho GPT.
  3. Metadata Filtering: Nếu user hỏi về "Tài chính", hãy lọc query database category_id = 'finance' trước khi thực hiện vector search để tăng tốc độ.

Kết luận

Xây dựng RAG trong Laravel rất mạnh mẽ vì bạn tận dụng được các model và logic dữ liệu hiện có. Bạn không cần một microservice Python tách biệt. Với pgvector, trí nhớ của AI nằm ngay bên cạnh dữ liệu giao dịch của bạn.

Bình luận