volver al blog

Deduplicar el corpus antes de indexar: el paso que casi nadie hace

ragembeddingsdatos

El síntoma: top-5 con una sola idea

Montas el RAG, haces las primeras consultas y el retrieval parece impecable. Los cinco fragmentos que vuelven hablan exactamente de lo que has preguntado. Miras un poco más de cerca y descubres que son el mismo párrafo cinco veces: la versión del manual de 2023, la del PDF exportado, la de la página de ayuda, la del correo donde alguien lo copió y la del FAQ que lo reutilizó entero.

Tu k=5 acaba de convertirse en k=1. El contexto que le pasas al modelo tiene la quinta parte de la información que creías, con el coste de tokens completo. Y si esas cinco copias no dicen exactamente lo mismo —porque una está desactualizada— el modelo tiene que decidir a cuál hace caso. Normalmente no avisa de que había conflicto.

La deduplicación no es higiene de datos. Es una decisión de arquitectura del retrieval que se toma antes de generar un solo embedding.

Tres tipos de duplicado, tres respuestas distintas

Meter todo en el mismo saco es el error habitual. Los duplicados no son una categoría, son tres, y cada una se ataca con una herramienta diferente.

TipoEjemplo típicoDetecciónCoste
ExactoEl mismo fichero indexado dos veces por dos conectoresHash del texto normalizadoDespreciable
Casi duplicadoMisma página con cabecera, fecha o número de versión distintosMinHash + LSH o SimHashBajo, lineal
SemánticoDos redacciones distintas de la misma políticaSimilitud de embeddings + clusteringAlto, cuadrático si no lo acotas

El reparto real en los corpus que he visto suele ser muy desigual: la mayor parte del volumen se va en los dos primeros tipos, que son baratos de resolver. El tercero es el que da titulares y el que menos ahorro aporta por euro invertido. Ese es el orden en el que conviene atacarlos.

Hash exacto: la línea base que no tiene excusa

La normalización importa más que el hash. Si comparas bytes crudos, dos copias del mismo párrafo con un salto de línea distinto te salen como documentos diferentes.

import hashlib
import re
import unicodedata

def clave_exacta(texto: str) -> str:
    # NFKC unifica variantes tipográficas: comillas, ligaduras, espacios duros.
    t = unicodedata.normalize("NFKC", texto)
    t = t.casefold()
    # Colapsa cualquier bloque de espacios, tabuladores y saltos en uno solo.
    t = re.sub(r"\s+", " ", t).strip()
    # Fuera la puntuación de adorno que cambia entre exportadores de PDF.
    t = re.sub(r"[·•●▪–—]", "-", t)
    return hashlib.sha256(t.encode("utf-8")).hexdigest()

vistos: dict[str, str] = {}
unicos = []
for doc in documentos:
    k = clave_exacta(doc.texto)
    if k in vistos:
        # No lo tires: registra el alias para poder citar todas las fuentes.
        doc.duplicado_de = vistos[k]
        continue
    vistos[k] = doc.id
    unicos.append(doc)

Dos detalles que marcan la diferencia. El primero: guarda el alias en lugar de descartar el documento en silencio. Cuando el usuario pregunte de dónde sale una respuesta, querrás poder decir que ese contenido está en tres sitios. El segundo: haz el hash después del chunking, no antes. Dos documentos distintos pueden compartir un apartado entero de aviso legal que no aporta nada al índice.

Casi duplicados: MinHash con LSH

Comparar todos los fragmentos con todos es O(n²) y a partir de unos cientos de miles de chunks deja de ser viable. MinHash con locality-sensitive hashing resuelve el problema en tiempo prácticamente lineal: agrupa candidatos en cubos y solo compara dentro de cada cubo.

from datasketch import MinHash, MinHashLSH

def firma(texto: str, n: int = 5) -> MinHash:
    m = MinHash(num_perm=128)
    palabras = texto.lower().split()
    # Shingles de n palabras: capturan orden local, no solo bolsa de palabras.
    for i in range(max(1, len(palabras) - n + 1)):
        m.update(" ".join(palabras[i:i + n]).encode("utf-8"))
    return m

# threshold 0.8 ≈ 80 % de solapamiento Jaccard estimado.
lsh = MinHashLSH(threshold=0.8, num_perm=128)
firmas = {}

for chunk in chunks:
    m = firma(chunk.texto)
    candidatos = lsh.query(m)          # vecinos probables, no exactos
    if candidatos:
        chunk.grupo = candidatos[0]    # se une al grupo ya existente
    else:
        lsh.insert(chunk.id, m)        # cabeza de grupo nueva
        firmas[chunk.id] = m

El umbral es el mando que de verdad vas a tocar. Con 0,9 solo detectas copias con cambios cosméticos. Con 0,7 empiezas a fusionar documentos que comparten plantilla pero dicen cosas distintas. Ese es el fallo caro: eliminas información válida y no te enteras. En corpus de documentación técnica, 0,8 suele ser un punto de partida razonable. Verifícalo con una muestra de 50 grupos revisados a mano antes de dejarlo correr sobre todo el corpus.

Con cuál de las copias te quedas

Detectar el grupo es la mitad fácil. Elegir el representante es donde se decide si el sistema mejora o empeora, y no hay una regla universal. La que uso por defecto, en este orden:

  1. La más reciente, si tienes fecha fiable. Un manual desactualizado que gana el retrieval es peor que no tener el documento.
  2. La de la fuente más autorizada. El wiki oficial por delante del hilo de chat donde alguien lo pegó.
  3. La más completa, medida en tokens del chunk. Suele ser la que conserva el contexto que los otros recortaron.

Y una regla que no es negociable: guarda siempre los identificadores de todas las copias del grupo. La deduplicación es reversible mientras no pierdas el mapa; en cuanto lo pierdes, has borrado datos del cliente sin poder justificarlo.

Dónde encaja en el pipeline

El sitio correcto es entre el chunking y el embedding, por una razón económica directa: cada duplicado que eliminas antes de esa frontera es una llamada al modelo de embeddings que no pagas y un vector que no almacenas. En un corpus empresarial con un 20 % o 30 % de redundancia —una cifra nada rara cuando hay varios conectores apuntando a los mismos contenidos— el ahorro de indexación se nota en la primera factura.

Hazlo incremental desde el principio. Persiste el índice LSH y las claves de hash junto al resto de metadatos, y pasa cada documento nuevo por el mismo filtro en el momento de la ingesta. Reprocesar el corpus entero cada semana funciona hasta que el corpus crece, y entonces deja de funcionar de golpe.

Qué hacer el lunes

Antes de tocar el reranker, mide. Coge tus 100 consultas de evaluación, lanza el retrieval y cuenta cuántos de los k resultados de cada una son variantes del mismo contenido. Si la media supera 1,5, tienes un problema de duplicados disfrazado de problema de relevancia. Ninguna mejora del reranker lo va a arreglar: estás reordenando copias.

El hash exacto lo montas en una tarde y suele llevarse la mitad del volumen redundante. MinHash con LSH es otra tarde más y se lleva casi todo lo que queda. La deduplicación semántica déjala para cuando hayas agotado las dos anteriores y sigas viendo el problema. En la mayoría de los casos, no llegarás a necesitarla.