Pipeline de Detección de Duplicados Híbrido¶
Descripción¶
Diagrama detallado del sistema de detección de duplicados con gate pre-OCR (SHA-256) y 3 niveles post-OCR (MinHash LSH, TF-IDF, Entity Matching) con ensemble ponderado.
Incluye short-circuit en gate pre-OCR y auto-exclusión de documento consultado.
Diagrama¶
flowchart TD
Start([Nuevo PDF]) --> Gate{Gate Pre-OCR:<br/>SHA-256 binario<br/>+ file_size}
Gate -->|Hash+Size Match<br/>en BD| GateHit[✅ Duplicado Exacto<br/>Retornar doc existente]
GateHit --> GateResult[🔥 Short-Circuit:<br/>Sin OCR/NER/Dedup<br/>Ahorro: 60-170s]
Gate -->|No Match| OCR[Extracción Híbrida:<br/>Nativo + OCR]
OCR --> NER[NER Ensemble]
NER --> Input[/Texto: content<br/>ID: doc_id<br/>Cédula + Correo/]
Input --> AutoExcl[Filtrar auto-match:<br/>cand_id ≠ doc_id]
AutoExcl --> Level1{Nivel 1:<br/>MinHash LSH<br/>Jaccard Similarity}
Level1 --> MinHashCalc[Calcular MinHash<br/>n_perm=128<br/>threshold=0.50]
MinHashCalc --> LSHQuery[Consultar LSH Index<br/>Recuperar candidatos]
LSHQuery --> MinHashScore[Score: 0.70-0.95<br/>Latency: ~30ms]
MinHashScore --> Level2{Nivel 2:<br/>TF-IDF Cosine<br/>Similarity}
Level2 --> TFIDFCalc[Vectorizar texto<br/>TF-IDF scikit-learn]
TFIDFCalc --> CosineCalc[Calcular similitud<br/>coseno vs corpus]
CosineCalc --> TFIDFScore[Score: 0.60-0.80<br/>Latency: ~50ms]
TFIDFScore --> Level3{Nivel 3:<br/>Entity Matching<br/>Legal Parties}
Level3 --> EntityExtract[Extraer entidades:<br/>• Demandante<br/>• Demandado<br/>• Cédula<br/>• Correo]
EntityExtract --> EntityCompare[Comparar vs<br/>candidatos]
EntityCompare --> EntityBoost[Boosts:<br/>+0.20 ambas partes<br/>+0.10 cédula<br/>+0.05 correo]
EntityBoost --> Ensemble[📊 Ensemble Ponderado:<br/>minhash=0.25, tfidf=0.40<br/>entity=0.35<br/>Configurable via JSON]
Ensemble --> FinalScore[Final Score =<br/>Σ(wi × scorei)]
FinalScore --> Classify{Clasificar<br/>Confidence}
Classify -->|Score ≥ threshold_high| ConfHigh[HIGH<br/>Action: REVIEW]
Classify -->|Score ≥ threshold_medium| ConfMedium[MEDIUM<br/>Action: REVIEW]
Classify -->|Score < threshold_medium| ConfLow[LOW<br/>Action: ACCEPT]
ConfHigh --> BuildResult[Construir<br/>DuplicateDetectionResult]
ConfMedium --> BuildResult
ConfLow --> BuildResult
BuildResult --> SortCandidates[Ordenar candidatos<br/>por score descendente]
SortCandidates --> TopN[Retornar Top N<br/>candidatos max=10]
TopN --> End([Retornar Resultado])
style Gate fill:#ffe6e6
style Level1 fill:#fff4e6
style Level2 fill:#e6f3ff
style Level3 fill:#e6ffe6
style Ensemble fill:#f3e6ff
style GateResult fill:#ff9999
style ConfHigh fill:#ffcc66
style ConfMedium fill:#ffff66
style ConfLow fill:#66ff66
Gate Pre-OCR: SHA-256 Binario (ProcessDocumentUseCase)¶
Objetivo: Detectar re-uploads del mismo PDF sin ejecutar OCR/NER.
- Método: SHA-256 del archivo binario (no del texto) + verificación de
file_size_bytes - Ubicación:
ProcessDocumentUseCase.execute()— antes de cualquier OCR - Short-circuit: Si hash+size coinciden en BD → retornar documento existente
- Ahorro: 60-170 segundos de OCR/NER evitados
- Latencia: <50ms (hash + query BD)
content_hash = hashlib.sha256(file_bytes).hexdigest()
existing = repository.find_by_hash(content_hash)
if existing and existing.file_size_bytes == file_size:
return Success(existing_response) # Sin OCR/NER
Niveles Post-OCR (HybridEnsembleDetector)¶
Nivel 1: MinHash LSH (Jaccard Similarity)¶
Objetivo: Detectar duplicados con modificaciones menores (70-95% similitud).
- Método: MinHash con LSH (Locality-Sensitive Hashing)
- Parámetros:
n_perm=128(número de permutaciones)threshold=0.50(umbral Jaccard)- Score: 0.70-0.95 (similitud aproximada)
- Latency: ~30ms (query LSH index)
Caso de uso: Documentos con cambios menores (fechas, nombres), reordenamiento de párrafos.
Ventaja: Escalable a millones de documentos (sub-linear query time).
Nivel 2: TF-IDF Cosine Similarity¶
Objetivo: Detectar similitud semántica y lexical (60-80% similitud).
- Método: TF-IDF vectorization + cosine similarity
- Parámetros:
max_features=5000(vocabulario)ngram_range=(1,2)(unigrams + bigrams)- Score: 0.60-0.80 (similitud coseno)
- Latency: ~50ms con caché, ~5min sin caché (corpus 10,000 docs)
Caso de uso: Documentos con parafraseo, sinónimos, diferentes estructuras.
Optimización crítica: Caché incremental de matriz TF-IDF (ver CLAUDE.md sección 6.4).
Nivel 3: Entity Matching (Legal Parties + Contact)¶
Nota: EntityMatcher prioriza datos NER del pipeline cuando están disponibles; regex es fallback.
Objetivo: Boost de score si entidades legales clave coinciden.
- Método: Comparación exacta + fuzzy (SequenceMatcher ≥ 0.85) de demandante, demandado, cédula y correo
- Boosts:
- +0.20 si ambas partes (demandante + demandado) coinciden
- +0.10 si cédula coincide
- +0.05 si correo coincide
- Latency: <5ms (comparación de strings)
Caso de uso: Documentos del mismo caso legal, diferentes versiones/anexos.
Lógica:
if both_parties_match:
final_score += 0.20
if cedula_match:
final_score += 0.10
if correo_match:
final_score += 0.05
Auto-exclusión: El documento consultado nunca aparece como su propio candidato (cand_id == document_id → skip).
Ensemble Ponderado¶
SHA-256 exact match actúa como gate pre-OCR (short-circuit). Los 3 niveles restantes se combinan con pesos configurables:
Pesos por defecto (persistidos en data/config/dedup_config.json):
- minhash = 0.25: Similitud estructural fuzzy
- tfidf = 0.40: Similitud léxica (mayor peso)
- entity = 0.35: Matching de entidades legales
Normalización: Σwi = 1.0
Persistencia: Pesos y umbrales se guardan/cargan via save_config()/load_config() en JSON.
La GUI de Duplicados permite editar y guardar la configuración.
Clasificación de Confianza¶
Los umbrales son configurables por tipo de documento en EnsembleConfig.THRESHOLDS:
| Confidence Level | Default | Tutela | Habeas Corpus | Acción | Color GUI |
|---|---|---|---|---|---|
| EXACT | = 1.00 | = 1.00 | = 1.00 | REJECT (automático) | 🔴 Rojo |
| HIGH | ≥ 0.80 | ≥ 0.85 | ≥ 0.90 | REVIEW (manual) | 🟠 Naranja |
| MEDIUM | ≥ 0.65 | ≥ 0.70 | ≥ 0.75 | REVIEW (manual) | 🟡 Amarillo |
| LOW | ≥ 0.50 | ≥ 0.50 | ≥ 0.60 | ACCEPT (único) | 🟢 Verde |
Estructura de Resultado¶
@dataclass(frozen=True)
class DuplicateDetectionResult:
candidates: List[DuplicateCandidate] # Top N duplicados
total_checked: int # Documentos comparados
high_confidence_count: int # EXACT + HIGH
medium_confidence_count: int # MEDIUM
low_confidence_count: int # LOW
recommended_action: str # REJECT/REVIEW/ACCEPT
total_latency_ms: float # Latencia total
short_circuited: bool # True si Nivel 1 match
level_scores: Dict[DetectionLevel, float] # Scores individuales
Performance Benchmarks¶
| Corpus Size | Latency (con caché) | Latency (sin caché) |
|---|---|---|
| 100 docs | 50-80ms | 200ms |
| 1,000 docs | 80-120ms | 800ms |
| 10,000 docs | 120-200ms | 2-5min ⚠️ |
| 100,000 docs | 200-300ms | N/A (requiere rebuild) |
Bottleneck: TF-IDF sin caché. Solución: Caché incremental + rebuild threshold (10%).
Casos de Uso Típicos¶
1. Duplicado Exacto (Score: 1.00)¶
- Escenario: Documento escaneado 2 veces
- Detección: Nivel 1 (Short-circuit)
- Acción: Rechazar automáticamente
2. Duplicado con Cambios Menores (Score: 0.88)¶
- Escenario: Documento con fecha modificada
- Detección: Nivel 2 (MinHash 0.92) + Nivel 4 (Entity boost)
- Acción: Revisar manualmente
3. Parafraseo Significativo (Score: 0.72)¶
- Escenario: Mismo caso, redacción diferente
- Detección: Nivel 3 (TF-IDF 0.68) + Nivel 4 (Entity boost)
- Acción: Revisar manualmente
4. Documento Único (Score: 0.45)¶
- Escenario: Nuevo caso legal
- Detección: Todos los niveles retornan scores bajos
- Acción: Aceptar
Configuración¶
Ver src/sherlock_docs/infrastructure/dedup/config.py:
@dataclass(frozen=True)
class EnsembleConfig:
# Pesos ensemble (deben sumar 1.0) — SHA-256 es gate pre-OCR
LEVEL_WEIGHTS: dict = {
"minhash": 0.25, # Fuzzy hash rápido
"tfidf": 0.40, # Similitud léxica (mayor peso)
"entity": 0.35, # Matching legal
}
# Umbrales por tipo de documento
THRESHOLDS: dict = {
"default": {"exact": 1.0, "high": 0.80, "medium": 0.65, "low": 0.50},
"tutela": {"exact": 1.0, "high": 0.85, "medium": 0.70, "low": 0.50},
"habeas_corpus": {"exact": 1.0, "high": 0.90, "medium": 0.75, "low": 0.60},
}
MINHASH_NUM_PERM: int = 128
MINHASH_THRESHOLD: float = 0.50
TFIDF_MAX_FEATURES: int = 5000
TFIDF_NGRAM_RANGE: tuple = (1, 2)
ENTITY_BOTH_MATCH_BOOST: float = 0.20
USE_SHORT_CIRCUIT: bool = True
TOP_K_CANDIDATES: int = 10
# Persistencia JSON (Sprint 17)
def save_config(config: EnsembleConfig, path: Path) -> bool: ...
def load_config(path: Path) -> EnsembleConfig: ...
Última actualización: 2026-03-05 (Fix títulos L2/L3 invertidos, EntityMatcher prioriza NER sobre regex)