Empezar aquí
Índice de la sección
Lección 1.2 · práctica

Indexar bien: limpieza, troceado y metadatos

Cómo convertir documentos reales en fragmentos limpios, con estructura, IDs estables y metadatos que sirven para filtrar, citar y reindexar.

Publicado 55 min
Al terminar podrás
  • Limpiar y deduplicar documentos antes de trocearlos.
  • Implementar un troceado por estructura que conserva la ruta de encabezados de cada fragmento.
  • Diseñar un esquema de metadatos que permita filtrar, citar y reindexar de forma incremental.

Requiere: Anatomía de un RAG · Chunking: cortar sin romper el sentido

Al terminar tendrás un indexador que limpia documentos, los trocea siguiendo su estructura, da a cada fragmento un id que no cambia entre ejecuciones y guarda metadatos con los que luego podrás filtrar y citar. En el roadmap viste el troceado con solapamiento; aquí vas un paso más allá: el troceado empieza antes de cortar y termina después.

La idea

La mayor parte de la calidad de un RAG se gana o se pierde en la indexación, y es la etapa que menos se mira. Un fragmento bueno cumple tres condiciones:

  1. Se entiende solo. Quien lo lea sin el resto del documento sabe de qué habla.
  2. Contiene una idea completa. No corta una tabla, una lista de pasos o una definición por la mitad.
  3. Sabe de dónde viene. Lleva fuente, sección, versión y fecha, para filtrar y para citar.

El orden de trabajo es: extraer texto con estructura, limpiar, deduplicar, trocear por estructura, enriquecer con metadatos y escribir en el índice solo lo que cambió.

flowchart LR
  E[Extraer con estructura] --> C[Limpiar]
  C --> D[Deduplicar]
  D --> T[Trocear por secciones]
  T --> M[Metadatos + id estable]
  M --> U[Upsert incremental]

Extraer y limpiar

Extrae conservando la estructura: encabezados, listas y tablas. Si tu extractor de PDF devuelve texto plano sin saltos de sección, pierdes la información que necesitas para trocear bien. Convierte todo a un formato intermedio común; Markdown funciona bien porque mantiene encabezados y tablas de forma legible para el modelo.

Limpia lo que no aporta y se repite:

  • cabeceras y pies de página, números de página, avisos legales repetidos;
  • menús, migas de pan y barras laterales de páginas web;
  • guiones de partición de palabras al final de línea en PDF (recupera-\nción);
  • espacios y caracteres invisibles (espacios de anchura cero, saltos raros).

Las tablas merecen trato aparte. Una tabla aplanada en una fila de números es irrecuperable. Mantenla en Markdown si es pequeña o conviértela a frases («Plan Pro: 20 usuarios, soporte 24 h») si la consulta típica pregunta por una celda.

Deduplicar

Los corpus reales repiten contenido: la misma página en dos URLs, versiones sucesivas de un manual, plantillas copiadas. Los duplicados ocupan posiciones en el ranking y desplazan resultados distintos. Para duplicados exactos basta un hash del texto normalizado. Para casi duplicados (versiones) decide una política: indexar solo la vigente, o indexar todas con metadato version y filtrar en la consulta.

Trocear por estructura

El troceado por número fijo de tokens es una base razonable. Mejora mucho si primero cortas por secciones y solo subdivides las que son demasiado largas. Además, cada fragmento hereda la ruta de encabezados («Facturación > Reembolsos > Plazos»), que le da contexto aunque el párrafo no repita el tema.

import hashlib, re, unicodedata
from dataclasses import dataclass, field

MAX_PALABRAS = 300      # aproximación barata a tokens; ajusta con tu tokenizador
SOLAPE = 40

@dataclass
class Fragmento:
    id: str
    texto: str
    metadatos: dict = field(default_factory=dict)

def limpiar(md: str) -> str:
    md = unicodedata.normalize("NFC", md)
    md = re.sub(r"[​‌‍]", "", md)       # caracteres invisibles
    md = re.sub(r"(\w)-\n(\w)", r"\1\2", md)                   # guiones de partición
    md = re.sub(r"^\s*Página \d+( de \d+)?\s*$", "", md, flags=re.M)
    return re.sub(r"\n{3,}", "\n\n", md).strip()

def secciones(md: str):
    """Devuelve (ruta_de_encabezados, texto) por cada sección del Markdown."""
    ruta, buffer = [], []
    for linea in md.splitlines():
        m = re.match(r"^(#{1,6})\s+(.*)", linea)
        if m:
            if buffer:
                yield list(ruta), "\n".join(buffer).strip()
                buffer = []
            nivel = len(m.group(1))
            ruta = ruta[: nivel - 1] + [m.group(2).strip()]
        else:
            buffer.append(linea)
    if buffer:
        yield list(ruta), "\n".join(buffer).strip()

def partir(texto: str):
    palabras = texto.split()
    if len(palabras) <= MAX_PALABRAS:
        yield texto
        return
    paso = MAX_PALABRAS - SOLAPE
    for i in range(0, len(palabras), paso):
        yield " ".join(palabras[i : i + MAX_PALABRAS])
        if i + MAX_PALABRAS >= len(palabras):
            break

def id_estable(doc_id: str, ruta: list[str], n: int) -> str:
    clave = f"{doc_id}|{' > '.join(ruta)}|{n}"
    return hashlib.sha1(clave.encode()).hexdigest()[:16]

def trocear(doc_id: str, md: str, meta_doc: dict) -> list[Fragmento]:
    fragmentos = []
    for ruta, texto in secciones(limpiar(md)):
        if not texto:
            continue
        for n, trozo in enumerate(partir(texto)):
            cabecera = " > ".join(ruta)
            fragmentos.append(Fragmento(
                id=id_estable(doc_id, ruta, n),
                texto=f"{cabecera}\n\n{trozo}" if cabecera else trozo,
                metadatos={**meta_doc, "doc_id": doc_id, "seccion": cabecera,
                           "hash": hashlib.sha1(trozo.encode()).hexdigest()},
            ))
    return fragmentos

Dos decisiones del código merecen explicación. La ruta de encabezados se antepone al texto que se indexa: así la búsqueda léxica y la vectorial la ven. Y el id se calcula a partir del documento, la sección y la posición, no del contenido: si corriges una errata, el fragmento conserva su id y el hash te dice que hay que reindexarlo.

Metadatos que pagan su coste

Cada campo de metadatos debe servir para algo concreto. Estos son los que suelen merecer la pena:

CampoPara qué sirve
doc_id, seccion, urlcitar con precisión y enlazar al original
actualizadofiltrar lo obsoleto y desempatar entre versiones
version / productofiltrar por lo que el usuario tiene contratado
idiomano mezclar resultados en dos idiomas
tipo (manual, FAQ, ticket)ponderar fuentes con distinta fiabilidad
acl (grupos con acceso)no devolver a un usuario lo que no puede ver
hashsaber si un fragmento cambió y hay que recalcular su embedding

Reindexar de forma incremental

Recalcular todos los embeddings en cada cambio es lento y caro. Con IDs estables y hashes, la actualización es un diff:

def sincronizar(indice, doc_id: str, md: str, meta_doc: dict):
    nuevos = {f.id: f for f in trocear(doc_id, md, meta_doc)}
    actuales = indice.hashes_de(doc_id)            # {id: hash} ya guardados
    a_escribir = [f for i, f in nuevos.items()
                  if actuales.get(i) != f.metadatos["hash"]]
    a_borrar = [i for i in actuales if i not in nuevos]
    if a_escribir:
        indice.upsert(a_escribir)                  # aquí se calculan los embeddings
    if a_borrar:
        indice.borrar(a_borrar)
    return len(a_escribir), len(a_borrar)

indice representa tu vector store; casi todos ofrecen upsert por id y filtros por metadatos. Si cambias de modelo de embeddings o de estrategia de troceado, en cambio, reindexa todo y guarda el nombre de la configuración como metadato del índice: mezclar vectores de dos modelos distintos rompe la similitud.

Errores frecuentes

  • Trocear el texto plano del PDF sin estructura. La ruta de encabezados es el metadato más barato y útil que existe.
  • Fragmentos diminutos. Una frase suelta se recupera bien y responde mal: le falta contexto.
  • IDs basados en el orden de lectura. Insertar un párrafo al principio renumera todo y rompe citas y evaluaciones.
  • Metadatos que nadie usa. Si un campo no filtra, no ordena ni se cita, sobra.

Ejercicio

EjercicioPistas

Si muchos fragmentos fallan por «idea incompleta», suele ser una tabla o una lista partida: trátalas como bloques indivisibles en partir. Si fallan por «no se entiende solo», revisa que la ruta de encabezados se esté anteponiendo.

Resumen

  • La indexación decide lo que el RAG puede encontrar; revísala con tus ojos antes de medir.
  • Extrae con estructura, limpia lo repetido y deduplica antes de trocear.
  • Trocea por secciones y antepón la ruta de encabezados al texto.
  • Usa IDs estables y hashes para reindexar solo lo que cambia.
  • Filtra permisos en la consulta al índice, nunca en el prompt.

Para seguir