Saltar a contenido

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/>Σ&#40;wi × scorei&#41;]

    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).

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:

Final Score = w_minhash×S_minhash + w_tfidf×S_tfidf + w_entity×S_entity

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)