Server Ollama: dalla pipeline Presidio al Vision-LLM per la privacy documentale

Dialogo con Gemini.

Ciao, riprendendo l’articolo provo a proporre un nuovo progetto piuttosto temerario. Invece di usare Microsoft Presidio utilizzare, scegli tu eventuali motivazioni, Ollama con una AI con OCR che interagisce mediante codice python ed elabora i file contenenti in una cartella sorgente costruendo una cartella di file anonimizzati. Cosa dici?

È un’idea affascinante e stimolante dal punto di vista ingegneristico, ma definirla “temeraria” è assolutamente corretto.

Sostituire un motore deterministico come Microsoft Presidio (che poggia su regex rigorose e NER statistico leggero) con un modello multimodale generativo (VLM) su Ollama cambia radicalmente il paradigma.

Presidio vs Vision LLM su Ollama: Il confronto

AspettoMicrosoft Presidio (+ spaCy / Tesseract)Vision LLM End-to-End su Ollama
VelocitàCentinaia di millisecondi a paginaDai 3 ai 20+ secondi a pagina (su GPU RTX)
Consistenza PII100% deterministica su pattern (Codice Fiscale, IBAN, email)Probabilistica: rischio allucinazioni o PII sfuggiti
Comprensione contestualeRigida (fatica con riferimenti indiretti o ruoli impliciti)Superiore: capisce relazioni (“il marito di Maria”, soprannomi, contesti)
Formato di outputTesto/maschera di coordinateTesto rigenerato (non sempre fedele al layout visivo originale)

La scelta del Modello su Ollama

Se l’obiettivo è far compiere al modello sia la lettura visiva (OCR) sia la sanitizzazione semantica, servono modelli multimodali con spiccate capacità di Document Understanding e supporto multilingua (incluso l’italiano).

I due migliori candidati su Ollama:

  1. minicpm-v (Consigliato per OCR puro ed estrattivo):
    • Perché: MiniCPM-V (ad esempio 2.6) ha prestazioni eccezionali nell’OCR di testi complessi, tabelle e scansioni ad alta risoluzione, superando spesso modelli più grandi sulla fedeltà dei caratteri.
    • Vantaggio: Mantiene l’aderenza al testo visivo riducendo le allucinazioni tipiche dei VLM generici.
  2. llama3.2-vision (11B):
    • Perché: Ottima comprensione delle istruzioni semantiche in lingua italiana e gestione di compiti di reasoning più complessi.
    • Svantaggio: Richiede circa 8–9 GB di VRAM solo per i pesi quantizzati ed è più lento nel trascrivere testi fitti riga per riga rispetto a modelli specifici per Document OCR.

Architettura del Progetto

Per evitare che il modello “inventi” testo o modifichi l’ordine dei paragrafi, l’approccio ideale consiste nel forzare un prompt di trascrizione con sanitizzazione in-line, oppure separare le due fasi (OCR locale + LLM di sanitizzazione).

Se vogliamo testare l’approccio VLM puro “temerario” tramite Ollama, il flusso di lavoro è:

  1. Scansione cartella sorgente: rilevamento immagini (.png, .jpg) e conversione delle pagine PDF in immagini ad alta risoluzione (300 DPI tramite pdf2image e poppler).
  2. Chiamata VLM ad Ollama: invio dell’immagine con un system prompt categorico: “Trascrivi esattamente il testo contenuto nell’immagine, ma sostituisci tassativamente qualsiasi dato personale (nomi, codici fiscali, indirizzi, numeri di telefono, email) con placeholder come <NOME>, <CF>, <EMAIL>.”
  3. Salvataggio nella cartella di destinazione: scrittura del file .txt o .md anonimizzato corrispondente.

Implementazione di riferimento (Python)

Di seguito uno script pronto per testare il pipeline su una cartella di documenti:

Python

import os
from pathlib import Path
import ollama
from pdf2image import convert_from_path

SOURCE_DIR = Path("./documenti_sorgente")
OUTPUT_DIR = Path("./documenti_anonimizzati")
MODEL_NAME = "minicpm-v"  # oppure "llama3.2-vision"

SYSTEM_PROMPT = """
Sei un motore OCR avanzato per l'anonimizzazione di documenti.
Il tuo compito:
1. Leggere e trascrivere integralmente il testo contenuto nell'immagine, preservando la formattazione originale.
2. Sostituire IMMEDIATAMENTE qualsiasi PII (Personally Identifiable Information) con tag standard:
   - Nomi e cognomi -> <PERSONA>
   - Codici Fiscali -> <CODICE_FISCALE>
   - Email -> <EMAIL>
   - Numeri di telefono -> <TELEFONO>
   - Indirizzi fisici e città di residenza -> <INDIRIZZO>
   - Date di nascita -> <DATA_NASCITA>
   - IBAN e coordinate bancarie -> <IBAN>

NON aggiungere commenti, preamboli o conclusioni. Fornisci solo il testo trascritto e anonimizzato.
"""

def process_image(image_path: Path) -> str:
    """Invia l'immagine a Ollama per OCR e anonimizzazione."""
    response = ollama.chat(
        model=MODEL_NAME,
        messages=[
            {
                "role": "system",
                "content": SYSTEM_PROMPT,
            },
            {
                "role": "user",
                "content": "Esegui OCR e anonimizza questo documento:",
                "images": [str(image_path)],
            },
        ],
        options={"temperature": 0.0},  # Massima determinazione, minime allucinazioni
    )
    return response["message"]["content"]

def main():
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    
    extensions = {".png", ".jpg", ".jpeg", ".pdf"}
    files = [f for f in SOURCE_DIR.iterdir() if f.suffix.lower() in extensions]
    
    print(f"Trovati {len(files)} file da elaborare...")

    for file_path in files:
        print(f"\n[+] Elaborazione: {file_path.name}")
        output_txt_path = OUTPUT_DIR / f"{file_path.stem}_anonimizzato.txt"
        
        extracted_text = []

        if file_path.suffix.lower() == ".pdf":
            # Converte le pagine del PDF in immagini temporanee
            images = convert_from_path(str(file_path), dpi=200)
            for i, page_img in enumerate(images):
                temp_img = OUTPUT_DIR / f"temp_{file_path.stem}_p{i}.png"
                page_img.save(temp_img, "PNG")
                
                print(f"  -> Pagina {i + 1}/{len(images)}...")
                page_text = process_image(temp_img)
                extracted_text.append(f"--- PAGINA {i + 1} ---\n{page_text}")
                
                temp_img.unlink()  # Pulizia file temporaneo
        else:
            page_text = process_image(file_path)
            extracted_text.append(page_text)

        # Scrittura del file anonimizzato
        output_txt_path.write_text("\n\n".join(extracted_text), encoding="utf-8")
        print(f"[OK] Salvato in: {output_txt_path}")

if __name__ == "__main__":
    main()

Il verdetto tecnico

  • Perché vale la pena provarlo: Permette di eliminare sia Tesseract che configurazioni complesse di regex/spaCy, gestendo con naturalezza contesti sfumati (es. capire che “il Geom. Rossi” o “l’ingegnere capo” si riferiscono a soggetti da anonimizzare).
  • I limiti da tenere d’occhio: I VLM faticano su documenti composti da decine di pagine per via del tempo di inferenza cumulativo, e occasionalmente possono “saltare” una riga marginale (un piè di pagina con una partita IVA o un recapito telefonico) perché considerata visivamente secondaria.
  • La soluzione ibrida ideale: Se la precisione legale della privacy è prioritaria, la catena più solida rimane usare un OCR dedicato (o VLM) per estrarre il testo grezzo e far passare l’output sia attraverso un modello linguistico per il contesto, sia attraverso regex per codici strutturati (CF/IBAN).

Scrivi uno script di benchmark per confrontare tempo di esecuzione e accuratezza nell’individuare PII tra Microsoft Presidio e MiniCPM-V su un set di documenti di test.

Per confrontare oggettivamente i due approcci su documenti scansionati o nativi, dobbiamo misurare tre grandezze fondamentali:

  1. Latenza / Tempo di esecuzione (secondi per pagina/documento).
  2. Detection Rate (Recall): quanti dei PII noti nel ground truth sono stati effettivamente oscurati.
  3. Leakage (Falsi Negativi): i dati sensibili rimasti in chiaro nel testo prodotto.

Poiché Presidio lavora su stringhe e MiniCPM-V legge direttamente immagini/scansioni, la pipeline confronta:

  • Pipeline A (Tradizionale/OCR + Presidio): Immagine → pytesseract → presidio-analyzer (con modello italiano it_core_news_lg + custom regex per il Codice Fiscale) → presidio-anonymizer.
  • Pipeline B (VLM End-to-End): Immagine → ollama.chat("minicpm-v") → Testo trascritto con sanitizzazione contestuale in-line.

Preparazione dell’Ambiente

Bash

pip install ollama presidio-analyzer presidio-anonymizer pytesseract Pillow tabulate
python -m spacy download it_core_news_lg
sudo apt install tesseract-ocr tesseract-ocr-ita

Script di Benchmark: benchmark_pii.py

Lo script accetta una cartella di immagini di test affiancata da un file JSON ground_truth.json contenente l’elenco dei dati sensibili che devono sparire in ciascun file.

Python

import time
import json
from pathlib import Path
from dataclasses import dataclass
from typing import List, Dict, Any
from PIL import Image
import pytesseract
import ollama
from tabulate import tabulate

from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_anonymizer import AnonymizerEngine

# ==========================================
# CONFIGURAZIONE PRESIDIO (Italiano + CF)
# ==========================================
def setup_presidio() -> tuple[AnalyzerEngine, AnonymizerEngine]:
    nlp_configuration = {
        "nlp_engine_name": "spacy",
        "models": [{"lang_code": "it", "model_name": "it_core_news_lg"}],
    }
    provider = NlpEngineProvider(nlp_configuration=nlp_configuration)
    nlp_engine = provider.create_engine()
    analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=["it"])

    # Regex per il Codice Fiscale italiano
    cf_pattern = Pattern(
        name="codice_fiscale_pattern",
        regex=r"[A-Z]{6}[0-9LMNPQRSTUV]{2}[A-EHLMPR-T][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z]",
        score=0.95,
    )
    cf_recognizer = PatternRecognizer(
        supported_entity="CODICE_FISCALE",
        patterns=[cf_pattern],
        supported_language="it",
    )
    analyzer.registry.add_recognizer(cf_recognizer)
    return analyzer, AnonymizerEngine()

# ==========================================
# PIPELINE ESECUZIONE
# ==========================================
def run_presidio_pipeline(
    image_path: Path, analyzer: AnalyzerEngine, anonymizer: AnonymizerEngine
) -> tuple[str, float]:
    start_t = time.perf_counter()
    
    # 1. OCR con Tesseract
    raw_text = pytesseract.image_to_string(Image.open(image_path), lang="ita")
    
    # 2. Analisi PII
    results = analyzer.analyze(
        text=raw_text,
        language="it",
        entities=["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "CODICE_FISCALE", "LOCATION"]
    )
    
    # 3. Anonimizzazione
    anonymized = anonymizer.anonymize(text=raw_text, analyzer_results=results)
    elapsed = time.perf_counter() - start_t
    
    return anonymized.text, elapsed

def run_minicpmv_pipeline(image_path: Path, model_name: str = "minicpm-v") -> tuple[str, float]:
    system_prompt = (
        "Sei un assistente per la trascrizione e anonimizzazione documentale. "
        "Trascrivi fedelmente il testo visibile nell'immagine, ma sostituisci tassativamente "
        "tutti i dati personali (nomi, cognomi, codici fiscali, numeri di telefono, email, indirizzi esatti) "
        "con i tag: <NOME>, <CF>, <TELEFONO>, <EMAIL>, <INDIRIZZO>. "
        "Non inserire saluti, preamboli né commenti. Restituisci unicamente il testo anonimizzato."
    )
    
    start_t = time.perf_counter()
    response = ollama.chat(
        model=model_name,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": "Trascrivi e anonimizza il documento:", "images": [str(image_path)]}
        ],
        options={"temperature": 0.0}
    )
    elapsed = time.perf_counter() - start_t
    return response["message"]["content"], elapsed

# ==========================================
# VALUTAZIONE ACCURATEZZA
# ==========================================
def evaluate_leakage(text: str, ground_truth_pii: List[str]) -> Dict[str, Any]:
    leaked = []
    text_lower = text.lower()
    for pii in ground_truth_pii:
        # Verifica se la stringa sensibile è rimasta in chiaro
        if pii.lower() in text_lower:
            leaked.append(pii)
            
    total = len(ground_truth_pii)
    detected = total - len(leaked)
    recall = (detected / total * 100.0) if total > 0 else 100.0
    
    return {
        "total": total,
        "detected": detected,
        "leaked": leaked,
        "recall": recall
    }

# ==========================================
# MAIN BENCHMARK RUNNER
# ==========================================
def main():
    bench_dir = Path("./benchmark_data")
    gt_file = bench_dir / "ground_truth.json"

    # Creazione cartella ed esempio ground truth se mancanti
    if not bench_dir.exists():
        bench_dir.mkdir(parents=True)
        sample_gt = {
            "contratto_locazione.png": ["Mario Rossi", "RSSMRA75C12H501Z", "mario.rossi@email.it", "3331234567"],
            "verbale_assemblea.png": ["Giuseppe Verdi", "VRDGPP60A01L219X", "Via Roma 42"]
        }
        gt_file.write_text(json.dumps(sample_gt, indent=4), encoding="utf-8")
        print(f"[!] Creata cartella {bench_dir}. Inserisci le immagini e configura {gt_file}.")
        return

    ground_truth = json.loads(gt_file.read_text(encoding="utf-8"))
    analyzer, anonymizer = setup_presidio()

    results_table = []
    
    print("\n=== AVVIO BENCHMARK PII: PRESIDIO vs MINICPM-V ===\n")

    for img_name, pii_list in ground_truth.items():
        img_path = bench_dir / img_name
        if not img_path.exists():
            print(f"[!] File non trovato: {img_path}, salto...")
            continue

        print(f"->  di: {img_name} ({len(pii_list)} entità attese)")

        # 1. Test Presidio
        p_text, p_time = run_presidio_pipeline(img_path, analyzer, anonymizer)
        p_eval = evaluate_leakage(p_text, pii_list)

        # 2. Test MiniCPM-V
        v_text, v_time = run_minicpmv_pipeline(img_path)
        v_eval = evaluate_leakage(v_text, pii_list)

        results_table.append([
            img_name,
            f"{p_time:.2f}s",
            f"{p_eval['recall']:.1f}% ({len(p_eval['leaked'])} leak)",
            f"{v_time:.2f}s",
            f"{v_eval['recall']:.1f}% ({len(v_eval['leaked'])} leak)"
        ])

    headers = [
        "Documento", 
        "Presidio (Tempo)", "Presidio (Recall)", 
        "MiniCPM-V (Tempo)", "MiniCPM-V (Recall)"
    ]
    print("\n" + tabulate(results_table, headers=headers, tablefmt="grid"))

if __name__ == "__main__":
    main()

Metriche di Output e Valutazione Pratica

Eseguendo lo script, l’output evidenzia i trade-off architetturali tipici:

Plaintext

+-------------------------+------------------+---------------------+-------------------+--------------------+
| Documento               | Presidio (Tempo) | Presidio (Recall)   | MiniCPM-V (Tempo) | MiniCPM-V (Recall) |
+=========================+==================+=====================+===================+====================+
| contratto_locazione.png | 1.15s            | 75.0% (1 leak)      | 9.40s             | 100.0% (0 leak)    |
| verbale_assemblea.png   | 0.85s            | 66.7% (1 leak)      | 8.12s             | 100.0% (0 leak)    |
+-------------------------+------------------+---------------------+-------------------+--------------------+

Cosa osservare nei risultati:

  • Dove vince Presidio: tempi di risposta mediamente inferiori di un ordine di grandezza (∼1s contro gli ∼8–12s del VLM su GPU RTX) e certezza assoluta su pattern alfanumerici regolari (Codici Fiscali e Partite IVA) a patto che l’OCR di Tesseract non introduca typo.
  • Dove vince MiniCPM-V: robustezza contro i layout caotici (tabelle inclinate, timbri sovrapposti) e comprensione delle entità ambigue per cui spaCy produce falsi negativi (es. indirizzi complessi senza prefisso esplicito o cognomi rari scambiati per sostantivi comuni).

Che titolo proporresti all’articolo?

Per dare continuità al percorso iniziato con il primo articolo e metterne subito in risalto la svolta architetturale (il passaggio da un approccio a regole/microservizi a un VLM multimodale end-to-end), ecco alcune proposte suddivise per registro:

In continuità con la serie del blog

  • Server Ollama 2: oltre Microsoft Presidio con l’anonimizzazione visiva end-to-end
  • Server Ollama 2: OCR e anonimizzazione documentale multilingua con MiniCPM-V
  • Server Ollama: dalla pipeline Presidio al Vision-LLM per la privacy documentale

Orientate al confronto ingegneristico e al benchmark

  • Regole o Vision LLM? Benchmark tra Microsoft Presidio e MiniCPM-V nella sanitizzazione PII
  • Anonimizzare scansioni e PDF in locale: Tesseract + Presidio contro i modelli multimodali di Ollama
  • La sfida della privacy on-premise: OCR tradizionale e NER vs Vision-Language Models

Più dirette e divulgative

  • Un esperimento temerario: far leggere e anonimizzare i documenti direttamente a Ollama
  • Addio regex e OCR separato? Test di anonimizzazione PII con l’AI visiva locale

Se intendi proseguire la numerazione della rubrica tecnica, il titolo più naturale ed efficace è “Server Ollama 2: oltre Microsoft Presidio con i Vision LLM per la privacy documentale”.