← Volver al blog

De PDF a datos estructurados: pipeline de extracción con Laravel, PostgreSQL pgvector y embeddings para RAG interno

2026-07-19

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:

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

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.