Вы хотите задавать вопросы по собственным документам прямо из приложения на Laravel. AI SDK, поставляемый с Laravel 13, берет на себя большую часть задач: эмбеддинги, векторные запросы, агенты. Что он не умеет делать, так это самую первую часть: когда пользователь загружает отсканированный 40-страничный PDF, а вам нужно получить из него чистый текст.

Именно этот этап мы подробно разберем в данном туториале. Впрочем, весь остальной пайплайн здесь тоже есть, от начала и до конца: загрузка, парсинг, чанкинг, создание эмбеддингов, поиск и генерация ответа.

Что мы строим

Пользователи загружают документы. Мы конвертируем каждый из них в Markdown, разбиваем на чанки, сохраняем эмбеддинги чанков в колонку pgvector и отвечаем на вопросы с помощью агента, который ищет по этим чанкам. Используется только Postgres, никакой отдельной векторной базы данных.

Вам понадобятся Laravel 13, Postgres с доступным расширением pgvector и два пакета:

 composer require laravel/ai parseforartisans/laravel
 

Дисклеймер: parseforartisans/laravel — это мой продукт, Parse for Artisans. Это облачный API для парсинга с нативным SDK под Laravel. Если все ваши документы — это цифровые PDF с полноценным текстовым слоем, вы можете заменить этот компонент на spatie/pdf-to-text и ничего не платить; я укажу, где именно. Облачный API оправдывает себя, когда загружаются сканы, файлы .doc, электронные письма или всё перечисленное вместе — что, по моему опыту, обычно и подразумевается под фразой «пользователи загружают документы».

Миграции

Две таблицы: сам документ и его чанки, каждый со своим эмбеддингом.

 use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::ensureVectorExtensionExists();
Schema::create('documents', function (Blueprint $table) {
    $table->id();
    $table->string('source_path');
    $table->string('status')->default('pending');
    $table->timestamps();
});
Schema::create('chunks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('document_id')->constrained()->cascadeOnDelete();
    $table->text('content');
    $table->vector('embedding', dimensions: 1536)->index();
    $table->timestamps();
});
 

Модели минималистичны. Chunk кастит вектор в массив, а Document получает связь, которую будет использовать слушатель событий:

 class Chunk extends Model
{
    protected $guarded = [];
    protected function casts(): array

{
        return ['embedding' => 'array'];
    }
}
class Document extends Model
{
    protected $guarded = [];
    public function chunks(): HasMany

{
        return $this->hasMany(Chunk::class);
    }
}
 

Загрузка и парсинг

Сохраните загруженный файл на диск, а затем передайте путь парсеру. SDK резолвит пути так же, как Storage, поэтому работать со временными файлами вручную не придется:

 use App\Models\Document;
use Illuminate\Http\Request;
use ParseForArtisans\Facades\Parse;
public function store(Request $request)
{
    $request->validate([
        'document' => ['required', 'file', 'mimes:pdf,doc,docx,xlsx,pptx,msg,eml', 'max:51200'],
    ]);
    $path = $request->file('document')->store('uploads', 's3');
    $document = Document::create(['source_path' => $path]);
    Parse::disk('s3')->file($path)->for($document)->parse();
    return back()->with('status', 'Processing.');
}
 

Парсинг происходит асинхронно. Вызов ->for($document) привязывает задачу к вашей модели, поэтому при получении результата вам передается готовая запись, и сопоставлять ID вручную не нужно. Отсканированные PDF автоматически распознаются через OCR; указывать тип файла не требуется.

Альтернатива от Spatie: замените строку с Parse:: на Pdf::getText(Storage::disk('s3')->path($path)) и сделайте чанкинг прямо на месте. Это отлично работает до первого скана или файла .docx, так как pdftotext читает только текстовый слой и ничего больше.

Чанкинг и генерация эмбеддингов после завершения парсинга

Markdown приходить в событии ParseCompleted. Подпишитесь на него, разбейте текст и сгенерируйте эмбеддинги для всех чанков за один вызов:

 use Laravel\Ai\Embeddings;
use ParseForArtisans\Events\ParseCompleted;
public function handle(ParseCompleted $event): void
{
    $document = $event->request->parsable;   // your Document model, typed
    $markdown = $event->request->markdown();
    $chunks = $this->split($markdown);
    $embeddings = Embeddings::for($chunks)->generate()->embeddings;
    foreach ($chunks as $i => $content) {
        $document->chunks()->create([
            'content' => $content,
            'embedding' => $embeddings[$i],
        ]);
    }
    $document->update(['status' => 'ready']);
}
 

Для разделения текста начните с самого простого способа. Разметка Markdown дает естественные границы, поэтому разделяйте по заголовкам, а затем ограничьте размер:

 /** @return string[] */
private function split(string $markdown, int $maxLength = 2000): array
{
    $sections = preg_split('/^(?=#{1,3} )/m', $markdown);
    return collect($sections)
        ->flatMap(fn ($s) => str_split(trim($s), $maxLength))
        ->filter(fn ($s) => strlen($s) > 50)
        ->values()
        ->all();
}
 

Это самый топорный чанкер из всех рабочих, и именно чистый входной Markdown делает его жизнеспособным. Когда ваш парсер сохраняет заголовки и структуру таблиц, разделение по заголовкам позволяет удерживать связанный контент вместе. Если же на входе «каша из слов», созданная сырыми OCR-инструментами, ни одна стратегия чанкинга вас не спасет. Мусор на входе — мусор в эмбеддингах.

Задаем вопросы

Агенты из AI SDK могут искать по вашим чанкам с помощью встроенного инструмента векторного поиска (similarity search). Создайте агента:

 php artisan make:agent DocumentAssistant

namespace App\Ai\Agents;
use App\Models\Chunk;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
use Laravel\Ai\Tools\SimilaritySearch;
class DocumentAssistant implements Agent, HasTools
{
    use Promptable;
    public function instructions(): string

{
        return 'Answer using the document excerpts you retrieve. '
            .'If the excerpts do not contain the answer, say so.';
    }
    public function tools(): iterable

{
        return [
            SimilaritySearch::usingModel(Chunk::class, 'embedding', limit: 8),
        ];
    }
}

$response = (new DocumentAssistant)->prompt(
    'What termination notice period does the supplier contract require?'
);
echo $response;
 

Агент превращает вопрос в эмбеддинг, извлекает ближайшие чанки и формирует ответ на их основе. Если вы предпочитаете контролировать выборку вручную, query builder позволяет сделать это напрямую:

 $chunks = Chunk::query()
    ->whereVectorSimilarTo('embedding', 'termination notice period')
    ->limit(8)
    ->get();
 

Передача строки заставляет SDK сгенерировать вектор поискового запроса за вас.

Заметки по масштабированию

Десять тысяч файлов обрабатываются точно так же, как и один. Parse::files($paths) отправляет пачку задач, и каждый файл генерирует свое собственное событие ParseCompleted, поэтому уже написанный вами слушатель обрабатывает весь пайплайн. Отправляйте слушатель в очередь (implements ShouldQueue), чтобы генерация эмбеддингов выполнялась фоном вне контекста HTTP-запроса.

Альтернативный вариант до того, как вы начнете всё это писать: если вы вообще не хотите заниматься чанкингом и управлять колонкой с векторами, AI SDK также поддерживает векторные хранилища провайдеров (provider vector stores). Создаете хранилище через Stores::create(), добавляете файлы и даете агенту инструмент FileSearch. Вы теряете контроль над чанкингом, а ваши данные хранятся у провайдера, но зато кода становится еще меньше. Мне нравится контролировать всё на стороне Postgres; однако для быстрого прототипа я бы выбрал векторное хранилище провайдера.

Вот и весь пайплайн: контроллер, слушатель, агент. Причина, по которой он получается таким коротким, заключается в том, что каждый шаг после парсинга рассчитывает на чистый Markdown. Это допущение — ключевой фактор в RAG по документам, и решение принимается на том самом шаге, который большинство туториалов просто пропускают.

Parse for Artisans — это API парсинга документов для Laravel, который конвертирует PDF, DOCX, сканы и еще более 20 форматов в чистый Markdown. Доступен бесплатный лимит в 15 000 страниц в месяц.