Peter Van Dijck ·
RAG in Laravel 13: the document step every tutorial skips
You want to ask questions about your own documents from a Laravel app. The AI SDK that shipped with Laravel 13 handles most of it: embeddings, vector queries, agents. What it doesn't handle is the part that comes first, where a user hands you a 40-page scanned PDF and you need clean text out of it.
That's the step this tutorial covers properly. The rest of the pipeline is here too, end to end: upload, parse, chunk, embed, search, answer.
What we're building
Users upload documents. We convert each one to Markdown, split it into chunks, embed the chunks into a pgvector column, and answer questions with an agent that searches those chunks. Postgres only, no separate vector database.
You'll need Laravel 13, Postgres with the pgvector extension available, and two packages:
composer require laravel/ai parseforartisans/laravel
Full disclosure: parseforartisans/laravel is my product, Parse for Artisans. It's a hosted parsing API with a Laravel-native SDK. If all your documents are digital PDFs with a real text layer, you can swap that piece for spatie/pdf-to-text and pay nothing; I'll point out where. The hosted API earns its keep when uploads are scans, .doc files, emails, or all of the above, which in my experience is what "users upload documents" actually means.
Migrations
Two tables: the document, and its chunks with an embedding each.
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();
});
The models are small. Chunk casts the vector to an array, and Document
gets the relation the listener will use:
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);
}
}
Upload and parse
Store the upload on your disk, then point the parser at the stored path. The
SDK resolves paths like Storage does, so there's no temp-file juggling:
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.');
}
Parsing is async. ->for($document) ties the job to your model, so when the
result comes back you get your record handed to you instead of matching ids.
Scanned PDFs are OCR'd automatically; you don't tell it the file type.
The spatie alternative: replace the Parse:: line with
Pdf::getText(Storage::disk('s3')->path($path)) and do the chunking inline.
Works well until the first scan or .docx shows up, because pdftotext reads
text layers and nothing else.
Chunk and embed when parsing completes
The Markdown arrives in a ParseCompleted event. Listen for it, split the
text, and embed all chunks in one call:
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']);
}
For splitting, start dumb. Markdown gives you natural seams, so split on headings, then cap the size:
/** @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();
}
This is the crudest chunker that works, and clean Markdown input is what makes it viable. When your parser preserves headings and table structure, heading-based splits keep related content together. When your input is the word soup that OCR tools produce, no chunking strategy saves you. Garbage in, garbage embedded.
Ask questions
The AI SDK's agents can search your chunks with the built-in similarity search tool. Make an agent:
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;
The agent embeds the question, pulls the nearest chunks, and answers from them. If you'd rather control retrieval yourself, the query builder does it directly:
$chunks = Chunk::query()
->whereVectorSimilarTo('embedding', 'termination notice period')
->limit(8)
->get();
Passing a string makes the SDK generate the query embedding for you.
Scaling notes
Ten thousand files work the same way as one. Parse::files($paths) submits
a batch, and each file fires its own ParseCompleted event, so the listener
you already wrote is the whole pipeline. Queue the listener (implements ShouldQueue) so embedding runs off the request path.
One honest alternative before you build any of this: if you don't want to
own chunking and a vector column at all, the AI SDK also supports provider
vector stores. Stores::create(), add files, and give an agent the
FileSearch tool. You give up control over chunking and your data lives
with the provider, but it's less code. I like owning the Postgres side; on a
quick prototype I'd take the store.
That's the whole pipeline: a controller, a listener, an agent. The reason it stays this short is that every step downstream of parsing gets to assume clean Markdown. That assumption is the entire game in document RAG, and it's decided at the step most tutorials skip.
Parse for Artisans is a document parsing API for Laravel that converts PDF, DOCX, scans, and 20+ formats to clean Markdown. There's a free tier of 15,000 pages a month.