Limpieza de datos para RAG de producción: codificación, OCR, eliminación de duplicados y PII
Cómo convertir documentos empresariales analizados en texto listo para su recuperación: reparación de codificación, eliminación de plantillas y duplicados, marcadores de OCR de riesgo, manejo de idiomas y enmascaramiento de PII.
Fuentes: Data Cleaning and Preprocessing for Production RAG: Building Retrieval-Ready Documents
Imagínese un banco que ha implementado un asistente de conocimiento sobre 200,000 documentos internos y regulatorios. La ingesta funciona sin problemas: los PDF se procesan, las circulares de AML escaneadas pasan por OCR, los archivos de SharePoint llegan a tiempo y las tablas se extraen por separado. Parece que no falta nada en el corpus.
No obstante, las respuestas son inconsistentes. Un oficial de cumplimiento pregunta qué documentos se requieren para una debida diligencia reforzada con un cliente corporativo de alto riesgo. El sistema devuelve cinco fragmentos: tres contienen la política KYC relevante y dos presentan texto casi idéntico de una versión anterior del documento. Cada fragmento comienza con el mismo encabezado:
CONFIDENTIAL - INTERNAL USE ONLY
Group Financial Crime Compliance
Page 47 of 132
Otro fragmento también contiene la frase “AII high-risk corporate customers must undergo enhanced due diIigence.” Fíjese bien: All se convirtió en AII y diligence en diIigence, porque el OCR confundió una l minúscula con una I mayúscula.
En otras partes del mismo archivo, un espacio sin romper sobrevivió a la extracción, una apóstrofe de Windows-1252 se decodificó incorrectamente, y ese pie de página aparece 132 veces porque estaba en cada página. La copia archivada de la política se encuentra en el índice junto a la versión aprobada, ya que SharePoint conservó ambas.
Técnicamente, el proceso de incorporación funcionó. En términos operativos, el corpus está contaminado, y los ingenieros que desarrollan su primer sistema RAG a menudo subestiman cuán importante es esa diferencia.
Por qué los datos contaminados se ocultan
El daño suele ser lo suficientemente sutil como para que nada se caiga. Una secuencia de bytes mal formada no hará que la base de datos vectorial arroje un error. Un párrafo duplicado no hará que la API de embeddings falle. Un pie de página repetido no activará ninguna alerta, y un error de OCR de 1 a l no detendrá el proceso de indexación. El pipeline incorpora todo sin problemas, y la factura llega más tarde en forma de fallos al recuperar datos.
Los documentos duplicados ocupan varios espacios en los resultados top-k. El texto genérico repetido crea una similitud artificial entre fragmentos no relacionados. Los errores de OCR modifican entidades cruciales, porcentajes, números de cuenta y límites regulatorios. Los artefactos de codificación añaden tokens sin significado comercial. Para cuando aparece una respuesta incorrecta, cada componente posterior podría estar cumpliendo con su función: el recuperador devolvió lo que estaba almacenado, el reordenador clasificó según lo recibido, y el LLM trabajó con el contexto que tuvo a su disposición. El problema surgió antes que cualquiera de los tres.
Se debe adoptar una política de AML que establezca que las transacciones superiores a 10.000 euros requieren verificaciones adicionales sobre el origen de los fondos. Cuando ocurren errores en el OCR y una conversión de codificación defectuosa, lo que se indexa es “EUR l0,000”. Una persona detecta el error al instante. Sin embargo, un modelo de embedding sigue tratando la frase como algo relacionado con conceptos como umbrales de transacción y verificaciones del origen de los fondos, por lo que la recuperación de información funciona perfectamente, y eso es lo preocupante. La declaración corrupta llega al LLM porque la recuperación semántica perdona las imperfecciones textuales. El sistema encuentra la sección correcta de la política, pero con un valor regulatorio incorrecto dentro.
Por eso, la limpieza del texto no puede reducirse a text.strip().lower().
Dónde encaja la limpieza en un pipeline RAG
La limpieza se realiza entre la ingesta de documentos y su división en fragmentos, y esta secuencia es intencionada. No se debe medir la similitud entre documentos antes de corregir las corrupturas evidentes en el codificado. Tampoco se debe normalizar el texto de forma excesiva antes de detectar su idioma. No hay que eliminar cláusulas legales repetidas solo porque se asemejan a textos genéricos. Además, las correcciones del OCR de baja confiabilidad no deben modificar silenciosamente las obligaciones legales.
Una vez que el texto con ruido ha sido dividido en fragmentos e incrustado, la limpieza se vuelve mucho más difícil. Los párrafos duplicados ya han generado vectores duplicados, las palabras rotas han definido los límites de los fragmentos, los encabezados repetidos han ocupado espacio en los tokens, y los errores del OCR están integrados en la representación semántica. La limpieza debe realizarse antes de dividir los documentos en fragmentos, y no como una corrección aplicada cuando la calidad comienza a deteriorarse.
Normalización del codificado: reparar el texto primero
Las colecciones empresariales rara vez comparten el mismo historial de codificación. Una política moderna podría ser UTF-8, un archivo de correspondencia con clientes podría estar en Windows-1252 y las exportaciones antiguas en Latin-1. El texto copiado entre SharePoint, correo electrónico, Word y sistemas de documentos heredados puede haber sido codificado y decodificado varias veces antes de llegar al sistema. El resultado es el siguiente:
Customer’s identity must be verified before account opening.
The customer must provide proof of address.
Enhanced Due Diligence â€" High Risk Customers
Esta corrupción se denomina mojibake y ocurre cuando los bytes escritos con un código de codificación se leen como si estuvieran en otro. De inmediato se puede observar que customer’s está dañado. En un proceso de recuperación, el token corrupto llega al tokenizador de manera diferente a customer's, y a gran escala tales incoherencias fragmentan el vocabulario y añaden variaciones innecesarias al espacio de embeddings.
Tenga cuidado con el alcance de las reparaciones. Una función imprudente considera que cualquier carácter fuera del ASCII es basura, sin embargo, un corpus de pagos europeo contiene legítimamente términos como Société Générale, Deutsche Bundesbank, €10,000 y Überweisung. Si se eliminan los acentos y símbolos, la información real desaparece. El objetivo no es un texto exclusivamente en ASCII, sino un Unicode coherente que mantenga su significado.
Reparación de Unicode con ftfy
No es necesario que usted mismo catalogue cada patrón de mojibake, ya que ftfy ya corrige los errores habituales de Unicode y codificación. Allí puede comenzar la fase de limpieza en producción:
import re
import unicodedata
from dataclasses import dataclass, field
import ftfy
@dataclass
class TextCleaningResult:
original_text: str
cleaned_text: str
transformations: list[str] = field(default_factory=list)
quality_flags: list[str] = field(default_factory=list)
def normalize_encoding(text: str) -> TextCleaningResult:
"""
Repair common encoding problems while preserving meaningful Unicode.
Suitable for policy, AML/KYC, payment, and regulatory documents where
accented names, currency symbols, and multilingual text must survive.
"""
transformations: list[str] = []
quality_flags: list[str] = []
if not text:
return TextCleaningResult(original_text=text, cleaned_text=text)
cleaned = text
# Repair mojibake and common Unicode encoding mistakes.
repaired = ftfy.fix_text(cleaned)
if repaired != cleaned:
transformations.append("ftfy_encoding_repair")
cleaned = repaired
# NFC preserves characters while producing a consistent Unicode
# representation. Safer than aggressively stripping accents.
normalized = unicodedata.normalize("NFC", cleaned)
if normalized != cleaned:
transformations.append("unicode_nfc_normalization")
cleaned = normalized
# Replace non-breaking spaces with normal spaces.
if "\u00a0" in cleaned:
cleaned = cleaned.replace("\u00a0", " ")
transformations.append("non_breaking_space_normalization")
# Remove zero-width characters that frequently leak from PDFs,
# web pages, and copied Office content.
zero_width_chars = {"\u200b", "\u200c", "\u200d", "\ufeff"}
if any(char in cleaned for char in zero_width_chars):
cleaned = "".join(char for char in cleaned if char not in zero_width_chars)
transformations.append("zero_width_character_removal")
# Remove control characters; preserve newline and tab because
# they may still carry document structure needed downstream.
cleaned_without_controls = "".join(
char for char in cleaned
if char in "\n\t" or unicodedata.category(char) != "Cc"
)
if cleaned_without_controls != cleaned:
transformations.append("control_character_removal")
cleaned = cleaned_without_controls
# Normalize horizontal whitespace without flattening paragraphs.
whitespace_normalized = re.sub(r"[ \t]+", " ", cleaned)
whitespace_normalized = re.sub(r"\n{3,}", "\n\n", whitespace_normalized)
if whitespace_normalized != cleaned:
transformations.append("whitespace_normalization")
cleaned = whitespace_normalized
cleaned = cleaned.strip()
# Keep suspicious replacement characters observable rather than silently deleting them.
if "\ufffd" in cleaned:
quality_flags.append("unicode_replacement_character_detected")
return TextCleaningResult(
original_text=text,
cleaned_text=cleaned,
transformations=transformations,
quality_flags=quality_flags,
)
Tenga en cuenta qué deja intacto la función. No convierte todo a minúsculas, no elimina los acentos de Société Générale, no quita el símbolo del euro ni reduce el documento a una sola línea. Esas omisiones son intencionales.
Por qué una limpieza excesiva es perjudicial
Muchos pipelines de NLP antiguos estaban diseñados para modelos tipo “bolsa de palabras” o TF-IDF, y convertían todo a minúsculas, eliminaban la puntuación, quitaban las palabras de parada y realizaban procesos de stemming. Aplicar ciegamente esos métodos antes de utilizar embeddings modernos hace que se pierda información útil. Considere qué efecto tendría en esta oración:
Customer must NOT be classified as low risk.
El texto financiero ofrece ejemplos aún más delicados. EUR 10,000 y EUR 100,000 se parecen en su escritura pero difieren enormemente en su significado práctico, y Article 6(1)(a) y Article 6(1)(b) tienen una estructura idéntica pero un valor legal distinto. La regla de trabajo es normalizar la representación de forma enérgica y conservar el significado de manera cautelosa. Convertir customer’s en customer's es un cambio de forma que restablece lo que quería expresar el autor. Eliminar el símbolo €, quitar los acentos del nombre de un cliente o convertir automáticamente l0% en 10% modifica datos en los que la empresa podría basarse.
Eliminación de texto genérico: estructura, no palabras clave
Los encabezados y pies de página no son el mismo tipo de problema que el ruido de codificación. La repetición de Página 47 de 132 es claramente texto genérico, pero un requisito de cumplimiento que aparece en varias secciones de las políticas puede repetirse intencionadamente y tener peso legal. La frecuencia con la que aparece una línea no permite determinar cuál de los dos casos se está analizando.
Una señal más útil combina la repetición con la posición. Los encabezados se agrupan en la parte superior de las páginas y los pies de página en la inferior, mientras que los números de página cambian ligeramente manteniendo el mismo patrón estructural:
KYC Policy | Version 7.2 | Page 41 of 132
KYC Policy | Version 7.2 | Page 42 of 132
La lógica de coincidencia exacta considera dos cadenas diferentes, mientras que la lógica consciente de la estructura ve dos instancias de un mismo plantilla. Esa es una de las razones por las cuales la detección de texto genérico funciona mejor mientras aún existan los límites de las páginas; por eso hay que evitar convertir un documento analizado en una cadena gigantesca inmediatamente después de su procesamiento.
import re
from collections import Counter
from dataclasses import dataclass
@dataclass
class BoilerplatePattern:
normalized_text: str
occurrences: int
page_ratio: float
position: str
def normalize_boilerplate_candidate(line: str) -> str:
"""
Normalize variable fields so structurally identical headers and
footers can be compared across pages.
"""
normalized = line.strip()
normalized = re.sub(
r"\bpage\s+\d+\s+of\s+\d+\b", "page <n> of <n>",
normalized, flags=re.IGNORECASE,
)
normalized = re.sub(r"\bpage\s+\d+\b", "page <n>", normalized, flags=re.IGNORECASE)
normalized = re.sub(r"\s+", " ", normalized)
return normalized.casefold()
def detect_repeated_marginal_text(
pages: list[dict],
margin_lines: int = 3,
min_page_ratio: float = 0.6,
) -> list[BoilerplatePattern]:
"""
Detect repeated text in page-header or page-footer regions.
A candidate is considered boilerplate only when it appears in the same
marginal position across a substantial fraction of the document.
"""
if not pages:
return []
header_counts: Counter = Counter()
footer_counts: Counter = Counter()
for page in pages:
lines = [line.strip() for line in page["text"].splitlines() if line.strip()]
if not lines:
continue
header_counts.update(normalize_boilerplate_candidate(l) for l in lines[:margin_lines])
footer_counts.update(normalize_boilerplate_candidate(l) for l in lines[-margin_lines:])
total_pages = len(pages)
patterns: list[BoilerplatePattern] = []
for position, counts in (("header", header_counts), ("footer", footer_counts)):
for normalized_text, occurrences in counts.items():
page_ratio = occurrences / total_pages
if page_ratio >= min_page_ratio:
patterns.append(BoilerplatePattern(
normalized_text=normalized_text,
occurrences=occurrences,
page_ratio=page_ratio,
position=position,
))
return patterns
El umbral min_page_ratio es la decisión más importante. La regla no es “eliminar cualquier línea que aparezca dos veces”. Es “tratar como texto genérico a una línea estructuralmente similar si sigue apareciendo en la parte inferior de la página en casi todo el documento”. En un documento de 100 páginas donde CONFIDENTIAL - INTERNAL USE ONLY aparece en 98 páginas, la evidencia es contundente.
Otro punto relacionado: eliminar algo del texto utilizado para incrustar no implica borrarlo del sistema. A veces la información debe pasar de la capa de contenido semántico a la capa de metadatos. Campos como versión, propietario, clasificación y fecha de vigencia son mejores conservados como metadatos que copiados en cada fragmento.
Deduplicación más allá de las copias exactas
Un documento puede estar perfectamente codificado, sin daños causados por OCR y libre de texto genérico, pero aún así resultar difícil de recuperar simplemente porque el corpus lo contiene varias veces. Los repositorios empresariales están repletos de esto:
AML_Policy_v8_Final.docx
AML_Policy_v8_Final_Approved.docx
AML_Policy_v8_Final_Copy.docx
AML_Policy_v8_Approved_2026.docx
Indexe los cuatro archivos como documentos separados y sus fragmentos terminan compitiendo entre sí en la recuperación de información. Si solicita una supervisión mejorada de los clientes de alto riesgo, es posible que tres espacios queden ocupados por una sola sección del poliza, dejando solo dos para pruebas verdaderamente diferentes. Los duplicados cuestan más que espacio en disco: distorsionan el significado real de “top-k”.
Duplicados exactos con hashing de contenido
import hashlib
from dataclasses import dataclass
@dataclass
class DeduplicationRecord:
document_id: str
source: str
content_hash: str
duplicate_of: str | None = None
def canonicalize_for_hashing(text: str) -> str:
"""
Deterministic normalization before hashing.
Does not lowercase or remove punctuation because those transformations
could collapse documents that are not actually identical.
"""
lines = [" ".join(line.split()) for line in text.splitlines()]
return "\n".join(lines).strip()
def calculate_content_hash(text: str) -> str:
canonical_text = canonicalize_for_hashing(text)
return hashlib.sha256(canonical_text.encode("utf-8")).hexdigest()
def find_exact_duplicates(
documents: list[dict],
) -> tuple[list[dict], list[DeduplicationRecord]]:
"""
Keep one canonical copy of each identical document while preserving
duplicate lineage for auditability.
"""
seen_hashes: dict[str, str] = {}
unique_documents: list[dict] = []
records: list[DeduplicationRecord] = []
for document in documents:
content_hash = calculate_content_hash(document["clean_text"])
if content_hash in seen_hashes:
records.append(DeduplicationRecord(
document_id=document["document_id"],
source=document["source"],
content_hash=content_hash,
duplicate_of=seen_hashes[content_hash],
))
continue
seen_hashes[content_hash] = document["document_id"]
unique_documents.append(document)
records.append(DeduplicationRecord(
document_id=document["document_id"],
source=document["source"],
content_hash=content_hash,
))
return unique_documents, records
El hash también es útil para el indexado incremental. Si el mismo documento normalizado aparece en la ejecución de ingestión de mañana por la noche, su hash ya se conoce, por lo que el proceso puede omitir la limpieza, el particionamiento y su incrustación nuevamente.
Duplicados casi idénticos con MinHash LSH
El hashing exacto abarca los casos sencillos. El más complicado es el de dos versiones de una política KYC, digamos la versión 7 y la versión 8, que son un 98% idénticas en texto pero difieren en una cláusula regulatoria. Desde el punto de vista del cumplimiento, definitivamente no son duplicados, ya que esa última cláusula modifica el requisito de control.
En un sistema RAG regulado, la detección de documentos casi idénticos debería responder primero a una pregunta diferente: cuáles documentos son lo suficientemente similares como para merecer ser investigados, dejando la eliminación como una decisión aparte. Para 200,000 documentos, la comparación entre pares requiere aproximadamente 20 mil millones de comparaciones. MinHash con hashing sensible a la localidad evita esto al crear firmas compactas y agrupar las similares en contenedores de candidatos.
import re
from datasketch import MinHash, MinHashLSH
def create_word_shingles(text: str, shingle_size: int = 5) -> set[str]:
"""
Five-word shingles work well for long policy and regulatory documents
because they capture local textual structure without being overly
sensitive to isolated formatting changes.
"""
tokens = re.findall(r"\b\w+\b", text.casefold())
if len(tokens) < shingle_size:
return {" ".join(tokens)} if tokens else set()
return {
" ".join(tokens[i:i + shingle_size])
for i in range(len(tokens) - shingle_size + 1)
}
def create_minhash(text: str, num_perm: int = 128) -> MinHash:
shingles = create_word_shingles(text)
minhash = MinHash(num_perm=num_perm)
for shingle in shingles:
minhash.update(shingle.encode("utf-8"))
return minhash
def build_duplicate_index(
documents: list[dict],
threshold: float = 0.85,
num_perm: int = 128,
) -> tuple[MinHashLSH, dict[str, MinHash]]:
"""
Build an LSH index for candidate near-duplicate discovery.
The threshold identifies candidates. It does not automatically
determine whether a document is deleted.
"""
lsh = MinHashLSH(threshold=threshold, num_perm=num_perm)
signatures: dict[str, MinHash] = {}
for document in documents:
document_id = document["document_id"]
signature = create_minhash(document["clean_text"], num_perm=num_perm)
signatures[document_id] = signature
lsh.insert(document_id, signature)
return lsh, signatures
Cuando las políticas KYC v7 y v8 terminan en la misma categoría, el sistema ha aprendido que merecen un análisis más detallado, y nada más. No ha aprendido que una de ellas deba eliminarse. Una decisión en entorno de producción debe combinar la similitud textual con metadatos como el ID de la política, versión, fecha de entrada en vigor, estado de aprobación y jurisdicción, ya que la similitud por sí sola no permite establecer esa distinción.
Errores de OCR: texto que parece correcto pero está equivocado
La reparación de codificación corrige caracteres que se almacenaron de forma incorrecta. Los fallos de OCR constituyen una categoría aparte. En este caso, el motor puede haber leído un carácter Unicode perfectamente válido con total confianza, pero simplemente haber elegido el incorrecto. Las confusiones típicas entre caracteres similares son 0 y O, 1, l y I, 5 y S, y rn y m.
Los índices de error generales pueden ser bajos, pero la precisión acumulada no es la métrica importante en los sistemas RAG regulados. Una circular de 40 páginas que esté al 99,8% correcta aún puede tener sus valores más críticos dañados. El punto arquitectónico clave es que los errores de OCR difieren en cuanto a su riesgo. Corregir custorner por customer es de bajo riesgo, ya que el contexto y un diccionario indican claramente la palabra deseada. Por otro lado, cambiar EUR l0,000 por EUR 10,000 pertenece a una categoría completamente distinta, porque un error en la interpretación puede alterar una obligación de cumplimiento.
import re
from dataclasses import dataclass
@dataclass
class OCRIssue:
token: str
issue_type: str
risk_level: str
suggested_value: str | None
requires_review: bool
CURRENCY_PATTERN = re.compile(r"\b(EUR|USD|GBP|INR)\s+([A-Za-z0-9.,]+)\b")
PERCENTAGE_PATTERN = re.compile(r"\b([A-Za-z0-9.,]+)\s*%")
def detect_numeric_ocr_issues(text: str) -> list[OCRIssue]:
"""
Detect suspicious OCR substitutions inside financial values.
This function identifies candidates. It does not silently rewrite
high-risk values in the source text.
"""
issues: list[OCRIssue] = []
substitutions = {"O": "0", "o": "0", "I": "1", "l": "1"}
def inspect_numeric_token(token: str, issue_type: str) -> None:
if token.replace(",", "").replace(".", "").isdigit():
return
corrected = "".join(substitutions.get(char, char) for char in token)
numeric_candidate = corrected.replace(",", "").replace(".", "")
if numeric_candidate.isdigit() and corrected != token:
issues.append(OCRIssue(
token=token,
issue_type=issue_type,
risk_level="high",
suggested_value=corrected,
requires_review=True,
))
for match in CURRENCY_PATTERN.finditer(text):
inspect_numeric_token(match.group(2), issue_type="currency_value")
for match in PERCENTAGE_PATTERN.finditer(text):
inspect_numeric_token(match.group(1), issue_type="percentage")
return issues
La función no modifica el documento de forma intencionada. Una expresión regular puede indicar que l0,000 parece sospechoso en un contexto monetario, pero no puede demostrar que la fuente dijera realmente 10,000.
El vocabulario común se maneja mejor con un diccionario que conozca el dominio específico. El texto bancario requiere una lista general de palabras además de términos como beneficial ownership, counterparty, SWIFT, IBAN, SEPA y sanctioned. De lo contrario, un corrector ortográfico habitual corregirá “felizmente” términos financieros legítimos por otros peores.
En la producción, la evaluación del OCR sopesa el nivel de confianza del motor frente a la importancia que tendría un error. Las lecturas fiables de texto de bajo riesgo suelen poder corregirse sin intervención humana. El contenido de alto riesgo, como valores monetarios, porcentajes, números de artículos regulatorios y fechas, debe ser revisado independientemente del nivel de confianza del OCR. Un sistema financiero debe buscar una corrección controlada y no la automatización máxima.
Preprocesamiento multilingüe
La detección de idioma parece algo menor hasta que un corpus trasciende las fronteras. Un repositorio de operaciones de pago puede contener documentos en inglés, cartas de clientes en alemán, notificaciones regulatorias en francés y contratos en español, y un archivo de investigación AML puede incluir varios idiomas en un único registro de caso.
El idioma afecta todo lo que viene después. La corrección ortográfica depende de él, los patrones de errores del OCR varían según el sistema de escritura, las entidades nombradas llevan acentos que deben mantenerse tras la normalización, y el propio modelo de embedding puede comportarse de manera diferente según el idioma indexado.
from dataclasses import dataclass
from langdetect import DetectorFactory, LangDetectException, detect_langs
DetectorFactory.seed = 0 # Makes pipeline output reproducible.
@dataclass
class LanguagePrediction:
language: str
confidence: float
alternatives: list[tuple[str, float]]
needs_review: bool
def detect_document_language(
text: str,
confidence_threshold: float = 0.85,
) -> LanguagePrediction:
sample = text.strip()
if not sample:
return LanguagePrediction(language="unknown", confidence=0.0, alternatives=[], needs_review=True)
try:
predictions = detect_langs(sample)
except LangDetectException:
return LanguagePrediction(language="unknown", confidence=0.0, alternatives=[], needs_review=True)
best = predictions[0]
alternatives = [(p.lang, round(p.prob, 4)) for p in predictions]
return LanguagePrediction(
language=best.lang,
confidence=best.prob,
alternatives=alternatives,
needs_review=best.prob < confidence_threshold,
)
Cuando un documento mezcla idiomas, detectar a nivel de párrafo es mejor que emitir un único veredicto para todo el archivo. También proporciona al divisor una pista sobre la estructura: si la división ocurre a través de un límite lingüístico que el divisor no había detectado, un único bloque puede terminar con dos contextos lingüísticos no relacionados.
Algunos equipos traducen todo primero al inglés. Eso simplifica el índice, pero introduce una transformación adicional entre el texto oficial y lo que se recupera; además, una cláusula de cumplimiento en alemán puede utilizar expresiones con un sentido legal preciso que una traducción modifica sutilmente. Un diseño más sólido trata el texto en su idioma original como la fuente de verdad y almacena cualquier traducción como una representación adicional, con la procedencia registrada en los metadatos. Los metadatos lingüísticos también deben ser precisos:
metadata = {
"primary_language": "en",
"languages": ["en", "de", "fr"],
"language_distribution": {"en": 0.72, "de": 0.21, "fr": 0.07},
"is_multilingual": True,
}
La detección de idioma también puede funcionar como una verificación de estado. Supongamos que una política AML del Reino Unido, esperada en inglés, informa de repente primary_language = “unknown” con un confidence = 0.31. Es posible que el archivo no sea multilingüe; el OCR podría haber dañado tanto el texto que el detector ya no lo reconoce.
PII y datos sensibles: detectar antes de incrustar
El preprocesamiento es una de las últimas oportunidades para controlar qué entra en la infraestructura de recuperación. Un archivo KYC puede contener nombres de clientes, números de pasaporte, IBAN, números de teléfono y direcciones de correo electrónico. Una vez que estos valores pasan por un servicio de incrustación e ingresan a una base de datos vectorial, el problema se vuelve mucho más difícil de resolver que proteger el documento original, ya que ahora existen copias en las solicitudes de incrustación, vectores, metadatos, cachés, índices, registros y copias de seguridad.
La cuestión no es si el modelo de incrustación puede comprender la PII, ya que sí puede hacerlo. Se trata de si realmente es necesario incrustar esa información.
Borrar todas las entidades que encuentra el detector sí hace que el corpus sea más seguro, pero arruina la recuperación por entidades. Si cada nombre de empresa se reemplaza por <ORGANIZATION>, un investigador de AML que busque Northbridge Trading GmbH no encontrará nada. Por lo tanto, el enfoque práctico consiste en establecer una política de manejo para cada tipo de entidad detectada antes de tomar acción. Los nombres públicos y operativos generalmente se conservan, la PII de los clientes se edita, se tokeniza o se restringe, las referencias internas a la organización siguen las reglas de acceso, y las credenciales nunca llegan al índice.
from dataclasses import dataclass, field
from presidio_analyzer import AnalyzerEngine
@dataclass
class SensitiveEntity:
entity_type: str
start: int
end: int
score: float
original_value: str
@dataclass
class PIIScanResult:
entities: list[SensitiveEntity] = field(default_factory=list)
needs_review: bool = False
class BankingPIIDetector:
def __init__(self) -> None:
self.analyzer = AnalyzerEngine()
def detect(
self,
text: str,
language: str = "en",
score_threshold: float = 0.70,
) -> PIIScanResult:
"""
Detect sensitive entities without modifying the source text.
Detection is deliberately separated from anonymization because
different entity types require different handling policies.
"""
results = self.analyzer.analyze(
text=text, language=language, score_threshold=score_threshold
)
entities = [
SensitiveEntity(
entity_type=r.entity_type,
start=r.start,
end=r.end,
score=r.score,
original_value=text[r.start:r.end],
)
for r in results
]
high_risk_types = {"CREDIT_CARD", "IBAN_CODE", "US_SSN", "IP_ADDRESS"}
return PIIScanResult(
entities=entities,
needs_review=any(e.entity_type in high_risk_types for e in entities),
)
Sustituir valores por marcadores de posición tipados suele funcionar mejor en RAG que borrarlos:
Customer IBAN: <IBAN>
Customer Email: <EMAIL_ADDRESS>
La oración mantiene su estructura que transmite el significado, por lo que una pregunta sobre dónde se verifica el IBAN de un cliente puede seguir coincidiendo con texto que contiene <IBAN>. Cuando es necesario preservar las relaciones entre las menciones, los tokens deterministas son superiores a los marcadores genéricos. CUSTOMER_7F29A1, que aparece en 40 registros, mantiene intacto el rastro de investigación, mientras que reemplazar cada nombre por <PERSON> lo interrumpiría.
Los metadatos son una fuga fácil de olvidar. El texto principal puede ser editado con cuidado y aún así incluir en sus campos customer_name: "Lena Hoffmann", o una ruta de origen como /customers/Lena_Hoffmann/KYC/passport_review.pdf. Por lo tanto, los controles de datos sensibles deben abarcar todo lo que se indexa, no solo el texto.
Un pipeline de nivel profesional
Individual cleaning functions are enough while you're experimenting. In production a stricter standard applies: a document must not move on to chunking just because a chain of steps ran without an exception. The pipeline has to record what it altered, what still looks suspicious, whether sensitive data turned up, whether the file duplicates another, and whether the end result deserves to be indexed.
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class QualityStatus(str, Enum):
PASS = "pass"
REVIEW = "review"
REJECT = "reject"
@dataclass
class Transformation:
stage: str
operation: str
details: dict[str, Any] = field(default_factory=dict)
@dataclass
class QualityFlag:
code: str
severity: str
details: dict[str, Any] = field(default_factory=dict)
@dataclass
class RetrievalReadyDocument:
document_id: str
source: str
raw_text: str
clean_text: str
language: str | None = None
language_confidence: float | None = None
content_hash: str | None = None
duplicate_of: str | None = None
transformations: list[Transformation] = field(default_factory=list)
quality_flags: list[QualityFlag] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
status: QualityStatus = QualityStatus.PASS
La diferencia entre raw_text y clean_text es fundamental. El primero indica qué fue lo que realmente se extrajo durante la ingesta, mientras que el segundo muestra qué representación estamos dispuestos a dividir en partes e integrar. Un registro de transformaciones explica cómo uno se convirtió en el otro, y esa trayectoria es de gran valor cuando se reporta un fallo de recuperación seis meses después.
import logging
logger = logging.getLogger(__name__)
class ProductionDocumentCleaner:
def __init__(
self,
pii_detector: BankingPIIDetector,
pii_anonymizer,
redact_pii_for_embeddings: bool = True,
) -> None:
self.pii_detector = pii_detector
self.pii_anonymizer = pii_anonymizer
self.redact_pii_for_embeddings = redact_pii_for_embeddings
def clean(self, document: dict) -> RetrievalReadyDocument:
result = RetrievalReadyDocument(
document_id=document["document_id"],
source=document["source"],
raw_text=document["text"],
clean_text=document["text"],
metadata=document.get("metadata", {}).copy(),
)
try:
self._normalize_text(result)
self._detect_language(result)
self._validate_ocr(result)
self._handle_sensitive_data(result)
self._calculate_hash(result)
self._apply_quality_gate(result)
except Exception as exc:
logger.exception("Preprocessing failed for document %s", result.document_id)
result.quality_flags.append(QualityFlag(
code="PREPROCESSING_EXCEPTION",
severity="critical",
details={"error": str(exc)},
))
result.status = QualityStatus.REJECT
return result
def _apply_quality_gate(self, document: RetrievalReadyDocument) -> None:
severities = {flag.severity for flag in document.quality_flags}
if "critical" in severities:
document.status = QualityStatus.REJECT
elif "high" in severities:
document.status = QualityStatus.REVIEW
else:
document.status = QualityStatus.PASS
La salida de la compuerta indica uno de tres estados explícitos. PASS permite que el documento continúe con el proceso de particionamiento sin hallazgos de alto riesgo sin resolver. REVIEW significa que se obtuvo contenido utilizable, pero ciertas transformaciones requieren la validación humana antes de poder indexarse. REJECT indica que el resultado no es lo suficientemente fiable como para indexarse. Un documento escaneado con un umbral dudoso de l0% pasa a REVIEW; un PDF que contiene solo 17 caracteres corruptos en 80 páginas probablemente termina en REJECT; y una política cuyas únicas correcciones fueron la adición de apóstrofos y espacios en blanco recibe el estado PASS.
También vale la pena conservar las métricas de la etapa de limpieza. Si text_reduction_ratio normalmente elimina del 4 al 8% de un documento y sube al 63% después de un lanzamiento, algo ha salido mal. Si unknown_language_rate aumenta de 0,3% a 18% tras una actualización de la biblioteca OCR, el detector está indicando una regresión en etapas anteriores del proceso. Números como estos hacen que la limpieza sea visible y manejable, en lugar de ser un proceso invisible basado en scripts.
Compromisos en producción
Ninguna configuración de limpieza es adecuada para todos, y las decisiones tienen consecuencias reales.
- Reparación de codificación.
ftfyofrece una solución económica y eficaz para los problemas típicos de mojibake y daños en Unicode. Considérelo como un complemento a la normalización determinista de Unicode, no como una licencia para reescribir el texto a voluntad. - Deduplicación. El hash SHA-256 es prácticamente gratuito y debe manejar los duplicados exactos antes que las técnicas de similitud más costosas. MinHash LSH es útil para encontrar duplicados cercanos a gran escala, pero su umbral debe generar candidatos y no decisiones de eliminación.
- Corrección OCR. La búsqueda en un diccionario cuesta poco y funciona de manera predecible con las palabras comunes. Un LLM que analiza el contexto puede resolver casos más difíciles, pero un modelo generativo en la etapa de preprocesamiento implica un costo adicional además de un riesgo nuevo: reescrituras que suenan plausibles. En el caso de material regulado, permita que proponga correcciones en un formato estructurado para su aprobación por parte de un revisor, en lugar de permitirle editar documentos sin verlos.
- Gestión multilingüe. La detección de documentos completos es económica y adecuada para la enrutación. La detección a nivel de párrafo funciona mejor con archivos mixtos de idiomas, pero a un costo mayor; lo que resulte más eficaz depende de la distribución real de los idiomas y de las pruebas de rendimiento de recuperación. La palabra “multilingüe” en la ficha del modelo dice poco sobre la capacidad de recuperar términos relacionados con AML en alemán a partir de una pregunta de cumplimiento en inglés.
- PII. La eliminación rigurosa de datos personales reduce la exposición, pero impide las búsquedas por entidad. A menudo, un diseño más adecuado mantiene la búsqueda vectorial semántica separada de una búsqueda precisa y segura para identificadores, de modo que un único índice no tenga que resolver ambos problemas.
Lo que conecta todo esto es: deja que los requisitos de recuperación y el riesgo de distorsionar el significado guíen las decisiones de preprocesamiento, e ignora cuán ordenado se vea el texto una vez limpio.
Volver a la política inicial
Vuelva a esa política KYC original. El proceso de extracción la obtuvo con éxito, y ahora el pipeline de limpieza realiza muchas más tareas antes de permitir que ingrese al índice. Repara la codificación dañada sin eliminar los caracteres multilingües legítimos, elimina los encabezados de página repetidos manteniendo la metadatos del documento, detecta copias exactas almacenadas en diferentes repositorios, y trata las versiones antiguas de la política como relacionadas y no como elementos que deben eliminarse ciegamente. Marca EUR l0,000 como una anomalía grave de OCR y se niega a hacer conjeturas en silencio, reconoce las secciones multilingües, detecta identificadores sensibles de los clientes antes de que lleguen a la capa de incrustación, registra cada transformación y decide si el resultado debe aprobarse, someterse a revisión o rechazarse.
Solo entonces el documento pasa a la etapa de fragmentación. El orden es importante porque la fragmentación no puede recuperar información que el preprocesamiento ya destruyó, y los embeddings no pueden distinguir entre conocimientos fiables y corrupciones que llegaron al corpus sin ser verificadas.
Lo que sigue a la limpieza es otro desafío: ¿dónde se debe dividir una política AML limpia de 80 páginas? Es sencillo crear un marco de 500 tokens, pero este puede separar una regla de su excepción, quitar el encabezado a una tabla o unir cláusulas no relacionadas simplemente porque comparten el mismo presupuesto de tokens. Los métodos de división van desde los de tamaño fijo y recursivos hasta los semánticos, basados en la estructura, en relaciones padre-hijo y a nivel de proposiciones, y cada uno merece un tratamiento aparte.
Una lista de verificación antes de la fragmentación
Antes de que un archivo pase a la etapa de fragmentación e incrustación, la fase de preprocesamiento debe poder dar una respuesta clara a cada uno de los siguientes puntos:
- ¿Se realizó la codificación y la normalización Unicode sin perder caracteres que tengan significado?
- ¿Se gestionaron los caracteres de control, los caracteres de ancho cero, los encabezados y pies de página repetidos, y otros elementos que generen ruido en la recuperación de datos?
- ¿Se detectaron duplicados exactos y casi idénticos, manteniendo intactas las versiones reales del documento?
- ¿Se informó sobre las lecturas dudosas del OCR de cantidades, porcentajes, fechas, identificadores y referencias legales a alguien, en lugar de corregirlas en silencio?
- ¿Se detectó el idioma con la precisión adecuada, incluyendo contenido multilingüe?
- ¿Se clasificó la información sensible según la política de seguridad de la aplicación antes de incrustarla?
- ¿Es posible rastrear cada cambio significativo hasta el texto analizado originalmente?
- ¿Existe un veredicto explícito de APROBAR, REVISAR o RECHAZAR antes de que comience el procesamiento en fragmentos?
Si alguna respuesta no está clara, el documento no está listo para su recuperación.
Si una póliza dañada ya ha sido dividida en 80 fragmentos, incrustada, copiada a un índice vectorial, almacenada en caché y servida por una aplicación en tiempo real, reparar el archivo de origen ya no es suficiente. Es necesario localizar cada fragmento derivado, eliminar sus vectores, invalidar las cachés, reprocesar el documento y verificar la eliminación de las representaciones obsoletas. Rechazar el conocimiento defectuoso es la opción más económica antes de que se convierta en una incrustación.
El código mostrado busca la claridad y la corrección. Para entornos de producción, se debe añadir registro estructurado, gestión de configuraciones y secretos, registros de auditoría persistentes para las transformaciones, métricas de preprocesamiento, recuperación ante fallos y revisión humana para las correcciones de alto riesgo. En entornos regulados, toda transformación destructiva debe ser rastreable hasta su fuente original.
Lecturas relacionadas
- Estrategias de Particionamiento para RAG en Producción: de Tamaño Fijo, Semántico y Padre-Hijo — Un recorrido práctico por el particionamiento de RAG, desde la división por tamaño fijo y recursiva hasta los enfoques padre-hijo, proposiciones, particionamiento tardío, RAPTOR, tablas, código y pruebas de rendimiento.
- Búsqueda de video y audio por significado: Una implementación multimodal de RAG con Gemini Embedding 2 — Cómo un único espacio de embedding para texto, audio, video y PDFs reemplazó a los pipelines de transcripción, con fragmentación, ChromaDB, expansión de consultas, reclasificación y umbrales.
- Ingestión de datos para RAG en producción en los bancos: PDFs, OCR, tablas y conectores — Cómo ingresar documentos bancarios para RAG: clasificar PDFs, ejecutar OCR con indicadores de revisión, mantener la estructura de Word, manejar tablas e imágenes, y crear conectores resistentes.
- RAG híbrido para textos jurídicos: fragmentación, BM25, BGE y reclasificación — Un pipeline RAG jurídico basado en la Constitución de la India: fragmentación padre-hijo, recuperación con BM25 y BGE, fusión de rangos, reclasificación con codificador cruzado y expansión de consultas, probado en pruebas de referencia.
- Diseñando RAG para 100 millones de documentos: particionamiento, búsqueda híbrida y costos — Qué cambia cuando un sistema RAG crece hasta alcanzar miles de millones de fragmentos: ingestión asíncrona, enrutamiento, particionamiento, recuperación híbrida, reclasificación, autorización, caché y control de costos.
- Tres formas en que falla RAG en entornos de producción: contexto fragmentado, desviación y uso incorrecto — Por qué los sistemas RAG pierden las matices, degradan a medida que crecen las bases de conocimiento o dan respuestas erróneas a pesar de una buena recuperación de información, con soluciones prácticas en la capa de recuperación.