Skip to content

Extracción de PDF para pipelines RAG en Python

Convierte PDFs en Markdown estructurado para tu pipeline RAG:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("paper.pdf")
md = doc.to_markdown_all(detect_headings=True, include_images=False)
# Divide en chunks, genera embeddings y guarda en tu base vectorial

WASM

import { WasmPdfDocument } from "pdf-oxide-wasm";

const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdownAll();
// Divide en chunks, genera embeddings y guarda en tu base vectorial
doc.free();

Rust

use pdf_oxide::PdfDocument;

let mut doc = PdfDocument::open("paper.pdf")?;
let md = doc.to_markdown_all(true)?;
// Divide en chunks, genera embeddings y guarda en tu base vectorial

Go

package main

import (
    "log"
    pdfoxide "github.com/yfedoseev/pdf_oxide/go"
)

func main() {
    doc, err := pdfoxide.Open("paper.pdf")
    if err != nil { log.Fatal(err) }
    defer doc.Close()

    md, _ := doc.ToMarkdownAll()
    _ = md // Divide en chunks, genera embeddings y guarda en tu base vectorial
}

C#

using PdfOxide;

using var doc = PdfDocument.Open("paper.pdf");
var md = doc.ToMarkdownAll();
// Divide en chunks, genera embeddings y guarda en tu base vectorial

PDF Oxide procesa 3.830 PDFs en 3,1 segundos a 0,8 ms por página con una tasa de éxito del 100 %. Cero documentos perdidos en tu índice.

Por qué la calidad de la extracción es clave para el RAG

Tu sistema de recuperación es tan bueno como lo sea la extracción previa:

  • Texto faltante = respuestas faltantes. Una biblioteca con 98,4 % de éxito (pypdf) pierde en silencio 61 documentos en un corpus de 3.823 archivos. PDF Oxide pasa el 100 %.
  • Estructura perdida = chunking deficiente. El texto plano pierde encabezados, tablas y formato, que son justo lo que habilita el chunking semántico. Markdown los conserva.
  • Extracción lenta = cuello de botella del pipeline. A 12,1 ms por página (pypdf) o 23,2 ms (pdfplumber), procesar 100 000 páginas lleva minutos. A 0,8 ms, 80 segundos.

Instalación

pip install pdf_oxide

Inicio rápido: del PDF a la base vectorial

from pdf_oxide import PdfDocument, PdfError
from pathlib import Path

def extract_documents(pdf_dir: str) -> list[dict]:
    """Extraer todos los PDFs de un directorio en chunks estructurados."""
    documents = []
    for pdf_path in Path(pdf_dir).glob("*.pdf"):
        try:
            doc = PdfDocument(str(pdf_path))
            for i in range(doc.page_count()):
                md = doc.to_markdown(i,
                    detect_headings=True,
                    include_images=False
                )
                if md.strip():
                    documents.append({
                        "content": md,
                        "source": pdf_path.name,
                        "page": i,
                    })
        except PdfError as e:
            print(f"Se omitió {pdf_path.name}: {e}")
    return documents

docs = extract_documents("research-papers/")
print(f"Se extrajeron {len(docs)} chunks de los PDFs")
# Pasa docs a tu modelo de embeddings y a tu almacén vectorial

Estrategias de chunking

Por encabezado (chunking semántico)

Divide la salida Markdown por encabezados para obtener chunks semánticamente significativos:

Python

import re
from pdf_oxide import PdfDocument

doc = PdfDocument("paper.pdf")
md = doc.to_markdown_all(detect_headings=True, include_images=False)

# Dividir en encabezados ##
chunks = re.split(r'\n(?=## )', md)
chunks = [c.strip() for c in chunks if c.strip()]

WASM

const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdownAll();

// Dividir en encabezados ##
const chunks = md.split(/\n(?=## )/).filter(c => c.trim());
doc.free();

Rust

let mut doc = PdfDocument::open("paper.pdf")?;
let md = doc.to_markdown_all(true)?;

let chunks: Vec<&str> = md.split("\n## ")
    .map(|c| c.trim())
    .filter(|c| !c.is_empty())
    .collect();

Go

doc, _ := pdfoxide.Open("paper.pdf")
defer doc.Close()

md, _ := doc.ToMarkdownAll()

var chunks []string
for _, c := range strings.Split(md, "\n## ") {
    c = strings.TrimSpace(c)
    if c != "" { chunks = append(chunks, c) }
}

C#

using var doc = PdfDocument.Open("paper.pdf");
var md = doc.ToMarkdownAll();

var chunks = md.Split("\n## ")
    .Select(c => c.Trim())
    .Where(c => c.Length > 0)
    .ToList();

Por página

Un chunk por página — simple y conserva el contexto a nivel de página:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("manual.pdf")
chunks = []
for i in range(doc.page_count()):
    md = doc.to_markdown(i, detect_headings=True, include_images=False)
    if md.strip():
        chunks.append({"content": md, "page": i})

WASM

const doc = new WasmPdfDocument(bytes);
const chunks = [];
for (let i = 0; i < doc.pageCount(); i++) {
    const md = doc.toMarkdown(i);
    if (md.trim()) {
        chunks.push({ content: md, page: i });
    }
}
doc.free();

Rust

let mut doc = PdfDocument::open("manual.pdf")?;
let mut chunks = Vec::new();
for i in 0..doc.page_count()? {
    let md = doc.to_markdown(i, true)?;
    if !md.trim().is_empty() {
        chunks.push((i, md));
    }
}

Go

doc, _ := pdfoxide.Open("manual.pdf")
defer doc.Close()

type Chunk struct{ Page int; Content string }
var chunks []Chunk

n, _ := doc.PageCount()
for i := 0; i < n; i++ {
    md, _ := doc.ToMarkdown(i)
    if strings.TrimSpace(md) != "" {
        chunks = append(chunks, Chunk{Page: i, Content: md})
    }
}

C#

using var doc = PdfDocument.Open("manual.pdf");
var chunks = Enumerable.Range(0, doc.PageCount)
    .Select(i => new { Page = i, Content = doc.ToMarkdown(i) })
    .Where(c => !string.IsNullOrWhiteSpace(c.Content))
    .ToList();

Tamaño fijo con solapamiento

Divide textos largos en chunks de tamaño fijo con solapamiento:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("book.pdf")
full_text = doc.to_markdown_all(detect_headings=True, include_images=False)

chunk_size = 1000  # caracteres
overlap = 200
chunks = []

for start in range(0, len(full_text), chunk_size - overlap):
    chunk = full_text[start:start + chunk_size]
    if chunk.strip():
        chunks.append(chunk)

WASM

const doc = new WasmPdfDocument(bytes);
const fullText = doc.toMarkdownAll();

const chunkSize = 1000;
const overlap = 200;
const chunks = [];

for (let start = 0; start < fullText.length; start += chunkSize - overlap) {
    const chunk = fullText.slice(start, start + chunkSize);
    if (chunk.trim()) chunks.push(chunk);
}
doc.free();

Rust

let mut doc = PdfDocument::open("book.pdf")?;
let full_text = doc.to_markdown_all(true)?;

let chunk_size = 1000;
let overlap = 200;
let mut chunks = Vec::new();
let mut start = 0;

while start < full_text.len() {
    let end = (start + chunk_size).min(full_text.len());
    let chunk = &full_text[start..end];
    if !chunk.trim().is_empty() {
        chunks.push(chunk.to_string());
    }
    start += chunk_size - overlap;
}

Go

doc, _ := pdfoxide.Open("book.pdf")
defer doc.Close()

full, _ := doc.ToMarkdownAll()

const chunkSize, overlap = 1000, 200
var chunks []string
for start := 0; start < len(full); start += chunkSize - overlap {
    end := start + chunkSize
    if end > len(full) { end = len(full) }
    chunk := full[start:end]
    if strings.TrimSpace(chunk) != "" {
        chunks = append(chunks, chunk)
    }
}

C#

using var doc = PdfDocument.Open("book.pdf");
var full = doc.ToMarkdownAll();

const int chunkSize = 1000, overlap = 200;
var chunks = new List<string>();
for (int start = 0; start < full.Length; start += chunkSize - overlap)
{
    var end = Math.Min(start + chunkSize, full.Length);
    var chunk = full[start..end];
    if (!string.IsNullOrWhiteSpace(chunk))
        chunks.Add(chunk);
}

Procesamiento por lotes de miles de PDFs

A 0,8 ms por página, PDF Oxide recorre corpus grandes en un suspiro:

from pdf_oxide import PdfDocument, PdfError
from pathlib import Path

pdf_files = list(Path("corpus/").glob("**/*.pdf"))
print(f"Procesando {len(pdf_files)} PDFs...")

all_chunks = []
errors = 0

for pdf_path in pdf_files:
    try:
        doc = PdfDocument(str(pdf_path))
        md = doc.to_markdown_all(
            detect_headings=True,
            include_images=False
        )
        if md.strip():
            all_chunks.append({
                "content": md,
                "source": str(pdf_path),
                "pages": doc.page_count(),
            })
    except PdfError:
        errors += 1

print(f"Se extrajeron {len(all_chunks)} documentos, {errors} errores")

Gestionar PDFs escaneados en tu pipeline

En cualquier corpus habrá PDFs que son imágenes escaneadas. Usa OCR como alternativa:

from pdf_oxide import PdfDocument

doc = PdfDocument("mixed-corpus-file.pdf")
text = doc.extract_text(0)

if len(text.strip()) < 50:
    # Probablemente es una página escaneada — usar OCR
    text = doc.extract_text_ocr(0)

Consulta la guía de OCR para los detalles de configuración.

Por qué Markdown en lugar de texto plano

Característica Texto plano Markdown
Jerarquía de encabezados Se pierde Se conserva (#, ##, ###)
Tablas Se aplanan Sintaxis de tabla GFM
Negrita/cursiva Se pierde **negrita**, *cursiva*
Chunking semántico Difícil Dividir por encabezados
Comprensión del LLM Menor Mayor (entrada estructurada)

Markdown le da a tu LLM más contexto sobre la estructura del documento, lo que se traduce en mejor calidad de recuperación y generación.

Rendimiento a gran escala

Tamaño del corpus PDF Oxide pypdf pdfplumber
1.000 páginas 0,8 s 12,1 s 23,2 s
10.000 páginas 8 s 121 s 232 s
100.000 páginas 80 s 1.210 s 2.320 s
Tasa de éxito 100 % 98,4 % 98,8 %

Con un 100 % de éxito, nunca tienes que investigar a mano por qué faltan documentos en tu índice.

Páginas relacionadas