De PDF a datos estructurados: pipeline de extracción con Laravel, PostgreSQL pgvector y embeddings para RAG interno
De PDF a datos estructurados: pipeline de extracción con Laravel, PostgreSQL pgvector y embeddings para RAG interno
Los documentos PDF siguen siendo el formato estándar para contratos, informes y documentación técnica en la mayoría de las empresas. El problema: están diseñados para visualización, no para consulta programática. Cuando necesitamos que nuestros sistemas "entiendan" este contenido —para responder preguntas, encontrar cláusulas específicas o alimentar un sistema de retrieval-augmented generation (RAG)— necesitamos un pipeline robusto de extracción, chunking, embedding y almacenamiento vectorial.
En este artículo construimos un pipeline completo usando Laravel como orquestador, PostgreSQL con pgvector como base de datos vectorial, y embeddings de OpenAI para habilitar búsqueda semántica interna. El objetivo final: un sistema RAG que permita a los equipos consultar documentación corporativa en lenguaje natural.
Arquitectura del pipeline
El flujo de trabajo consta de cinco etapas bien definidas:
- Ingesta: recepción de archivos PDF mediante API o comando artisan
- Extracción de texto: conversión del contenido binario a texto plano
- Chunking inteligente: división en fragmentos con contexto preservado
- Generación de embeddings: vectorización mediante API de embeddings
- Almacenamiento vectorial: persistencia en PostgreSQL con pgvector
Preparación del entorno
Instalación de pgvector
La extensión pgvector convierte PostgreSQL en una base de datos vectorial de producción. Instalación mediante Docker:
docker run -d \
--name postgres-vector \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=rag_documents \
-p 5432:5432 \
ankane/pgvector:latest
Activamos la extensión en la base de datos:
CREATE EXTENSION IF NOT EXISTS vector;
Configuración del modelo de tabla en Laravel
Definimos la migración con el tipo vector específico de pgvector:
<?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_chunks', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained()->onDelete('cascade');
$table->integer('chunk_index');
$table->text('content');
$table->text('context_window')->nullable();
$table->integer('token_count');
// Vector de 1536 dimensiones para embeddings de OpenAI text-embedding-3-small
$table->vector('embedding', 1536);
$table->jsonb('metadata')->default('{}');
$table->timestamps();
$table->index('document_id');
$table->unique(['document_id', 'chunk_index']);
});
// Índice IVFFlat para búsqueda aproximada de vecinos
DB::statement('CREATE INDEX ON document_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);');
}
public function down(): void
{
Schema::dropIfExists('document_chunks');
}
};
La elección de IVFFlat con 100 listas es un compromiso entre velocidad y precisión para conjuntos de 10K-100K vectores. Para escalado mayor, considerar vector_hnsw_ops con HNSW.
Extracción de texto desde PDF
Modelo Eloquent para documentos
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Document extends Model
{
protected $fillable = [
'filename',
'original_path',
'total_pages',
'extracted_text',
'processing_status',
'language_detected'
];
protected $casts = [
'metadata' => 'array',
'total_pages' => 'integer'
];
public function chunks(): HasMany
{
return $this->hasMany(DocumentChunk::class);
}
}
Service de extracción con Smalot/PdfParser
Implementamos un extractor que preserva estructura y detecta saltos de página:
<?php
namespace App\Services\Extraction;
use App\Models\Document;
use Illuminate\Support\Facades\Storage;
use Smalot\PdfParser\Parser;
class PdfExtractor
{
private Parser $parser;
public function __construct()
{
$this->parser = new Parser();
}
public function extract(string $path): Document
{
$absolutePath = Storage::disk('documents')->path($path);
$pdf = $this->parser->parseFile($absolutePath);
$pages = $pdf->getPages();
$fullText = '';
$pageTexts = [];
foreach ($pages as $index => $page) {
$pageText = $page->getText();
$pageTexts[] = [
'number' => $index + 1,
'text' => $this->cleanText($pageText),
'words' => str_word_count($pageText)
];
$fullText .= "\n\n--- PAGE BREAK ---\n\n" . $pageText;
}
return Document::create([
'filename' => basename($path),
'original_path' => $path,
'total_pages' => count($pages),
'extracted_text' => $this->cleanText($fullText),
'processing_status' => 'extracted',
'metadata' => [
'pages' => $pageTexts,
'total_words' => array_sum(array_column($pageTexts, 'words'))
]
]);
}
private function cleanText(string $text): string
{
// Elimina headers/footers comunes y normaliza espacios
$text = preg_replace('/\s+/', ' ', $text);
$text = preg_replace('/(Página \d+ de \d+|Page \d+ of \d+)/i', '', $text);
return trim($text);
}
}
La marca --- PAGE BREAK --- permite al chunker respetar límites de página cuando tiene sentido semántico.
Estrategia de chunking con solapamiento
El chunking trivial (cada N caracteres) destruye contexto. Implementamos chunking por párrafos con ventana deslizante:
<?php
namespace App\Services\Chunking;
use App\Models\Document;
class SemanticChunker
{
private int $targetTokens;
private int $overlapTokens;
private TokenEstimator $estimator;
public function __construct(
int $targetTokens = 500,
int $overlapTokens = 50
) {
$this->targetTokens = $targetTokens;
$this->overlapTokens = $overlapTokens;
$this->estimator = new TokenEstimator();
}
public function chunk(Document $document): array
{
$paragraphs = $this->splitIntoParagraphs($document->extracted_text);
$chunks = [];
$currentChunk = [];
$currentTokens = 0;
foreach ($paragraphs as $paragraph) {
$paraTokens = $this->estimator->estimate($paragraph);
if ($currentTokens + $paraTokens > $this->targetTokens && !empty($currentChunk)) {
$chunks[] = $this->finalizeChunk($currentChunk, $chunks);
$currentChunk = $this->getOverlapTail($currentChunk);
$currentTokens = $this->estimator->estimate(implode("\n", $currentChunk));
}
$currentChunk[] = $paragraph;
$currentTokens += $paraTokens;
}
if (!empty($currentChunk)) {
$chunks[] = $this->finalizeChunk($currentChunk, $chunks);
}
return $chunks;
}
private function splitIntoParagraphs(string $text): array
{
// Preserva los separadores de página como delimitadores fuertes
$sections = preg_split('/--- PAGE BREAK ---/', $text);
$paragraphs = [];
foreach ($sections as $section) {
$sectionParagraphs = array_filter(
preg_split('/\n\s*\n/', trim($section)),
fn($p) => strlen(trim($p)) > 20 // Filtra fragmentos muy cortos
);
$paragraphs = array_merge($paragraphs, $sectionParagraphs);
}
return $paragraphs;
}
private function getOverlapTail(array $previousChunk): array
{
$text = implode("\n", $previousChunk);
$tokens = $this->estimator->estimate($text);
if ($tokens <= $this->overlapTokens) {
return [$text];
}
$words = explode(' ', $text);
$ratio = $this->overlapTokens / $tokens;
$overlapWords = (int) (count($words) * $ratio);
return [implode(' ', array_slice($words, -$overlapWords))];
}
private function finalizeChunk(array $paragraphs, array $previousChunks): array
{
$content = implode("\n\n", $paragraphs);
return [
'content' => $content,
'token_count' => $this->estimator->estimate($content),
'chunk_index' => count($previousChunks),
'starts_at_page' => $this->detectPage($content)
];
}
}
El TokenEstimator es una aproximación rápida (1 token ≈ 0.75 palabras en español). Para precisión máxima, usar la librería tiktoken.
Generación y almacenamiento de embeddings
Service de embeddings con batching
<?php
namespace App\Services\Embeddings;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Collection;
class OpenAIEmbedder
{
private string $model;
private int $dimensions;
private int $batchSize;
public function __construct(
string $model = 'text-embedding-3-small',
int $dimensions = 1536,
int $batchSize = 100
) {
$this->model = $model;
$this->dimensions = $dimensions;
$this->batchSize = $batchSize;
}
public function embedChunks(array $chunks): array
{
$texts = array_column($chunks, 'content');
$batches = array_chunk($texts, $this->batchSize);
$allEmbeddings = [];
foreach ($batches as $batch) {
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.openai.api_key'),
'Content-Type' => 'application/json',
])->post('https://api.openai.com/v1/embeddings', [
'model' => $this->model,
'input' => $batch,
'dimensions' => $this->dimensions,
]);
$response->throw();
$embeddings = collect($response->json('data'))
->sortBy('index')
->pluck('embedding')
->toArray();
$allEmbeddings = array_merge($allEmbeddings, $embeddings);
}
return $this->combineWithChunks($chunks, $allEmbeddings);
}
private function combineWithChunks(array $chunks, array $embeddings): array
{
return array_map(
fn($chunk, $embedding) => array_merge($chunk, [
'embedding' => $this->formatVector($embedding)
]),
$chunks,
$embeddings
);
}
private function formatVector(array $embedding): string
{
// Formato requerido por pgvector: [1.2, 3.4, ...]
return '[' . implode(',', $embedding) . ']';
}
}
Inserción eficiente con raw SQL
Para volúmenes grandes, evitamos Eloquent en la inserción de vectores:
<?php
namespace App\Services\Storage;
use App\Models\Document;
use Illuminate\Support\Facades\DB;
class VectorStore
{
public function storeChunks(Document $document, array $chunkedWithEmbeddings): void
{
$records = array_map(fn($chunk) => [
'document_id' => $document->id,
'chunk_index' => $chunk['chunk_index'],
'content' => $chunk['content'],
'context_window' => $this->buildContextWindow($chunk, $chunkedWithEmbeddings),
'token_count' => $chunk['token_count'],
'embedding' => DB::raw("'{$chunk['embedding']}'::vector"),
'metadata' => json_encode([
'starts_at_page' => $chunk['starts_at_page'] ?? null,
'model' => config('services.openai.embedding_model')
]),
'created_at' => now(),
'updated_at' => now(),
], $chunkedWithEmbeddings);
// Inserción masiva por batches de 1000
foreach (array_chunk($records, 1000) as $batch) {
DB::table('document_chunks')->insert($batch);
}
$document->update(['processing_status' => 'indexed']);
}
private function buildContextWindow(array $currentChunk, array $allChunks): ?string
{
$index = $currentChunk['chunk_index'];
$context = [];
if ($index > 0) {
$context[] = 'ANTERIOR: ' . substr($allChunks[$index - 1]['content'], 0, 200);
}
if (isset($allChunks[$index + 1])) {
$context[] = 'SIGUIENTE: ' . substr($allChunks[$index + 1]['content'], 0, 200);
}
return empty($context) ? null : implode(' | ', $context);
}
}
Búsqueda semántica para RAG
El corazón del sistema: recuperar chunks relevantes dada una consulta en lenguaje natural.
<?php
namespace App\Services\Retrieval;
use App\Services\Embeddings\OpenAIEmbedder;
use Illuminate\Support\Facades\DB;
class SemanticRetriever
{
private OpenAIEmbedder $embedder;
public function __construct()
{
$this->embedder = new OpenAIEmbedder();
}
public function retrieve(string $query, array $filters = [], int $topK = 5): array
{
$queryEmbedding = $this->embedder->embedChunks([
['content' => $query]
])[0]['embedding'];
$builder = DB::table('document_chunks')
->select([
'document_chunks.id',
'document_chunks.content',
'document_chunks.context_window',
'document_chunks.metadata',
'documents.filename',
DB::raw("1 - (embedding <=> '{$queryEmbedding}'::vector) as cosine_similarity")
])
->join('documents', 'documents.id', '=', 'document_chunks.document_id')
->where('documents.processing_status', '=', 'indexed')
->orderByRaw("embedding <=> '{$queryEmbedding}'::vector")
->limit($topK);
// Filtros aplicados antes de la búsqueda vectorial (post-filtering)
foreach ($filters as $column => $value) {
$builder->where("documents.{$column}", $value);
}
$results = $builder->get();
return $results->map(fn($row) => [
'content' => $row->content,
'source' => $row->filename,
'page' => json_decode($row->metadata)->starts_at_page ?? null,
'similarity' => round((float) $row->cosine_similarity, 4),
'context' => $row->context_window,
])->toArray();
}
}
El operador <=> es distancia euclídean normalizada (1 - cosine similarity). Alternativas: <#> para producto interno, <~> para distancia L1.
Orquestación completa con Job de Laravel
<?php
namespace App\Jobs;
use App\Models\Document;
use App\Services\Chunking\SemanticChunker;
use App\Services\Embeddings\OpenAIEmbedder;
use App\Services\Extraction\PdfExtractor;
use App\Services\Storage\VectorStore;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class ProcessDocument implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public string $path;
public int $tries = 3;
public int $timeout = 600; // 10 minutos para documentos grandes
public function __construct(string $path)
{
$this->path = $path;
}
public function handle(
PdfExtractor $extractor,
SemanticChunker $chunker,
OpenAIEmbedder $embedder,
VectorStore $store
): void {
logger()->info("Iniciando procesamiento: {$this->path}");
// 1. Extracción
$document = $extractor->extract($this->path);
// 2. Chunking
$chunks = $chunker->chunk($document);
if (empty($chunks)) {
$document->update(['processing_status' => 'failed_empty']);
return;
}
logger()->info("Chunks generados: " . count($chunks));
// 3. Embeddings
$chunksWithEmbeddings = $embedder->embedChunks($chunks);
// 4. Almacenamiento vectorial
$store->storeChunks($document, $chunksWithEmbeddings);
logger()->info("Documento indexado: ID {$document->id}");
}
public function failed(Throwable $exception): void
{
logger()->error("Procesamiento fallido: {$this->path}", [
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString()
]);
}
}
Consideraciones de producción
- Optimización de índices: recrear IVFFlat cuando los datos crecen 10x. El parámetro
listsdebe ser aproximadamentesqrt(n_vectors). - Rate limiting: implementar backoff exponencial para la API de embeddings. Un PDF de 100 páginas puede generar 50-100 chunks.
- Validación de calidad: almacenar métricas de cobertura (tokens por chunk vs. total) para detectar extracciones fallidas.
- Seguridad: los documentos pueden contener PII. Considerar anonimización antes del embedding o restricción de acceso mediante RLS de PostgreSQL.
- Costos: con
text-embedding-3-small, procesar 1M tokens cuesta ~$0.02. Un contrato de 50 páginas ≈ $0.05 USD.
Conclusión
Este pipeline demuestra que no se necesita infraestructura exótica para implementar RAG interno. PostgreSQL con pgvector es suficientemente potente para miles de documentos, y Laravel proporciona la orquestación robusta que los equipos ya conocen. La clave está en el chunking semántico: preservar contexto entre fragmentos mejora drásticamente la calidad de recuperación, que es el cuello de botella de cualquier sistema RAG.
El código presentado es adaptable a otros formatos (Word, HTML) modificando únicamente la capa de extracción. Para escalar más allá de millones de vectores, evalúa Pinecone o Weaviate, pero no subestimes hasta dónde puede llegar una arquitectura simple bien ejecutada.