Вы хотите задавать вопросы по собственным документам прямо из приложения на 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 страниц в месяц.
Комментарии (0)
Пока нет комментариев — будьте первым.