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
- PDF a Markdown — detalles de la conversión a Markdown
- Procesamiento por lotes — patrones de procesamiento en paralelo
- OCR de PDFs escaneados — configuración y uso de OCR
- Extraer texto de PDF — extracción de texto plano
- Benchmarks de rendimiento — resultados completos de benchmarks