Intent Detection senza LLM: approccio NLP veloce e controllabile

Immagine di copertina

Chatbot, form di contatto, caselle email condivise, ticket di supporto: in tutti questi contesti, prima ancora di rispondere, serve capire cosa vuole davvero l’utente. È un preventivo? Un reclamo? Assistenza tecnica? Una candidatura spontanea? Questo problema si chiama intent detection (letteralmente “riconoscimento dell’intento”) ed è uno dei task di NLP più classici: quando il numero di intent è chiuso e conosciuto, un classificatore statistico classico è spesso più veloce, più economico e più affidabile di un LLM.

In questo articolo costruiamo un classificatore di intent che usa TF-IDF con Logistic Regression, lo valutiamo, e lo trasformiamo in un componente riutilizzabile per instradare automaticamente richieste in ingresso.

Cosa vedrai

Cos’è l’intent detection e quando serve

L’intent detection è un problema di classificazione testuale supervisionata: dato un testo in ingresso, il modello deve assegnarlo a una categoria (ossia l’intento) tra un insieme predefinito e chiuso. Alcuni esempi tipici:

  • customer care: richiesta di supporto o di assistenza tecnica, richiesta di preventivo, invio di un reclamo, disdetta di un servizio;
  • HR: invio candidatura, richiesta informazioni su una posizione, domanda su selezione in corso;
  • e-commerce: stato ordine, reso, richiesta fattura, domanda su un prodotto;
  • chatbot FAQ: saluto, domanda su prezzi, domanda su orari, richiesta contatto umano.

La caratteristica comune è che gli intent sono noti in anticipo e tendenzialmente stabili nel tempo: è esattamente lo scenario in cui i modelli di machine learning classico brillano.

Perché un classificatore classico, e non un LLM

Con un dominio chiuso e un set di intent stabile, un classificatore addestrato su TF-IDF offre vantaggi concreti rispetto a un LLM:

  • latenza di pochi millisecondi, anche su hardware modesto, senza bisogno di GPU
  • costo per predizione praticamente nullo, importante quando i volumi sono alti (migliaia di richieste al giorno)
  • comportamento deterministico: lo stesso testo produce sempre la stessa classificazione
  • facile da validare e certificare: puoi calcolare precision/recall per ogni intent e sapere esattamente dove il modello sbaglia
  • si addestra in pochi secondi su poche decine di esempi per categoria, e si ri-addestra facilmente quando cambia il dominio senza bisogno di GPU

Un LLM diventa utile quando gli intent non sono noti a priori, cambiano di continuo, o richiedono comprensione di sfumature complesse: per la maggior parte degli use case, però, non è necessario.

Costruire il classificatore

Usiamo una pipeline con scikit-learn utilizzando il TF-IDF per la vettorizzazione e Logistic Regression come classificatore: una combinazione semplice, veloce da addestrare e molto solida su dataset piccoli.

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline

TRAIN_DATA = [
    ("vorrei un preventivo per il vostro servizio", "richiesta_preventivo"),
    ("quanto costa il piano professionale", "richiesta_preventivo"),
    ("potete mandarmi un'offerta commerciale", "richiesta_preventivo"),
    ("mi serve un preventivo per 10 licenze", "richiesta_preventivo"),
    ("il mio account non funziona, aiuto", "assistenza_tecnica"),
    ("ho un errore quando provo ad accedere", "assistenza_tecnica"),
    ("l'applicazione si blocca continuamente", "assistenza_tecnica"),
    ("non riesco a resettare la password", "assistenza_tecnica"),
    ("voglio disdire il mio abbonamento", "reclamo"),
    ("sono insoddisfatto del servizio ricevuto", "reclamo"),
    ("chiedo il rimborso per il mese scorso", "reclamo"),
    ("il prodotto non funziona come promesso", "reclamo"),
    ("vorrei candidarmi per la posizione aperta", "candidatura"),
    ("allego il mio curriculum per la posizione", "candidatura"),
    ("siete alla ricerca di dev python", "candidatura"),
    ("vorrei sapere se cercate stagisti", "candidatura"),
]

texts = [t for t, _ in TRAIN_DATA]
labels = [l for _, l in TRAIN_DATA]

pipeline = Pipeline([
    ("tfidf", TfidfVectorizer()),
    ("clf", LogisticRegression(max_iter=1000)),
])
pipeline.fit(texts, labels)

Nota: in un caso reale, per ogni intent servono almeno 20-30 esempi rappresentativi (idealmente qualche centinaio), raccolti da conversazioni o ticket storici già categorizzati manualmente.

Testiamo il modello su frasi mai viste durante l’addestramento:

test_sentences = [
    "buongiorno, potreste inviarmi un preventivo dettagliato?",
    "l'app continua a darmi errore 500",
    "voglio il rimborso, il servizio è pessimo",
    "invio la mia candidatura per il ruolo di data scientist",
]

for s in test_sentences:
    pred = pipeline.predict([s])[0]
    proba = pipeline.predict_proba([s])[0]
    classes = pipeline.classes_
    top = sorted(zip(classes, proba), key=lambda x: x[1], reverse=True)[0]
    print(f"{s!r:65s} -> {pred}  (confidenza {top[1]:.2f})")

Output:

'buongiorno, potreste inviarmi un preventivo dettagliato?'        -> richiesta_preventivo  (confidenza 0.43)
"l'app continua a darmi errore 500"                               -> assistenza_tecnica  (confidenza 0.32)
'voglio il rimborso, il servizio è pessimo'                       -> reclamo  (confidenza 0.40)
'invio la mia candidatura per il ruolo di data scientist'         -> candidatura  (confidenza 0.34)

Il modello classifica correttamente tutte e quattro le frasi mai viste prima, pur avendo un dataset di addestramento minuscolo (16 esempi in totale), la cui confidence è infatti relativamente bassa. Con un dataset reale di qualche centinaio di esempi per intent, questa cresce sensibilmente, e di conseguenza anche la robustezza del modello.

Valutare il modello

Prima di mettere in produzione un classificatore, è fondamentale misurarne le prestazioni con una suddivisione train/test e le metriche standard di classificazione:

from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report

X_train, X_test, y_train, y_test = train_test_split(
    texts, labels, test_size=0.25, random_state=42, stratify=labels
)

pipeline.fit(X_train, y_train)
y_pred = pipeline.predict(X_test)

print(classification_report(y_test, y_pred, zero_division=0))

Su un dataset così piccolo il test set è minimo e va interpretato con cautela, ma su un dataset reale (centinaia di esempi per classe) questo report ti permette di individuare rapidamente:

  • intent con recall basso: il modello li riconosce raramente (servono più esempi di training)
  • intent con precision bassa: il modello li confonde spesso con altre categorie (servono esempi più distintivi o una revisione delle etichette)

Nota: ricordiamoci che la recall è la percentuale di esempi di una classe che il modello riesce a identificare correttamente, mentre la precision è la percentuale di predizioni corrette per quella classe. In produzione, spesso si preferisce massimizzare la precision a scapito della recall, per evitare di inviare richieste al flusso sbagliato.

Un router di intent riutilizzabile

Incapsuliamo tutto in una classe pensata per l’uso in produzione, con soglia di confidenza configurabile. Nel nostro caso, impostiamo una soglia di confidenza del 30%: se il modello non è sufficientemente sicuro, restituisce sconosciuto invece di forzare una classificazione probabilmente sbagliata; in un sistema di produzione, questa soglia può essere regolata in base al rischio di errore accettabile.

from dataclasses import dataclass
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline


@dataclass
class IntentResult:
    intent: str
    confidence: float
    is_confident: bool


class IntentRouter:
    """
    Router di intent basato su TF-IDF + Logistic Regression.
    Pensato per instradare richieste (ticket, email, messaggi chatbot)
    verso il team o il flusso corretto, senza LLM.
    """

    def __init__(self, confidence_threshold: float = 0.35):
        self.confidence_threshold = confidence_threshold
        self._pipeline = Pipeline([
            ("tfidf", TfidfVectorizer()),
            ("clf", LogisticRegression(max_iter=1000)),
        ])
        self._fitted = False

    def fit(self, texts: list[str], labels: list[str]) -> "IntentRouter":
        self._pipeline.fit(texts, labels)
        self._fitted = True
        return self

    def predict(self, text: str) -> IntentResult:
        if not self._fitted:
            raise RuntimeError("Il modello non è stato ancora addestrato: chiama fit() prima.")

        proba = self._pipeline.predict_proba([text])[0]
        classes = self._pipeline.classes_
        best_intent, best_conf = max(zip(classes, proba), key=lambda x: x[1])

        return IntentResult(
            intent=best_intent if best_conf >= self.confidence_threshold else "sconosciuto",
            confidence=round(float(best_conf), 4),
            is_confident=best_conf >= self.confidence_threshold,
        )


# ── Demo ──────────────────────────────────────────────────────────────────────
router = IntentRouter(confidence_threshold=0.30)
router.fit(texts, labels)

richieste = [
    "quanto costa passare al piano premium?",
    "non riesco più a fare login da ieri",
    "che tempo fa oggi a Milano?",  # fuori dominio
]

for richiesta in richieste:
    risultato = router.predict(richiesta)
    print(f"{richiesta!r:45s} -> {risultato.intent:22s} (conf. {risultato.confidence})")

Output:

'quanto costa passare al piano premium?'     -> richiesta_preventivo   (conf. 0.3833)
'non riesco più a fare login da ieri'        -> assistenza_tecnica     (conf. 0.3857)
'che tempo fa oggi a Milano?'                -> sconosciuto            (conf. 0.2593)

Gestire l’incertezza e gli intent sconosciuti

Un dettaglio spesso trascurato ma cruciale in produzione: cosa succede quando la richiesta non appartiene a nessun intent conosciuto? (“che tempo fa oggi?”, scritto per errore nella casella sbagliata, o un caso limite mai visto in training).

La soglia di confidenza (confidence_threshold) risolve proprio questo problema: se la probabilità massima assegnata dal modello è troppo bassa, il router restituisce sconosciuto invece di forzare una classificazione probabilmente sbagliata. In produzione, questo si traduce tipicamente in:

  • instradamento a un operatore umano invece che a un flusso automatico
  • richiesta di chiarimento all’utente
  • fallback su una categoria generica “altro” per revisione manuale periodica

Questo pattern (classificazione + soglia di confidenza + fallback) è uno dei modi più semplici ed efficaci per rendere un classificatore classico affidabile in produzione, senza il non-determinismo e i costi di un LLM.

Conclusioni

In questo articolo abbiamo visto come costruire un classificatore di intent con TF-IDF e Logistic Regression, senza usare LLM, per instradare automaticamente richieste in ingresso. Abbiamo discusso i vantaggi di un approccio classico rispetto a un LLM in scenari con intent chiusi e stabili, e abbiamo implementato un router di intent riutilizzabile con soglia di confidenza per gestire anche richieste sconosciute.

Il codice completo per riprodurre l’esperimento è il seguente:

"""
Esempio completo: Intent Detection senza LLM
TF-IDF + Logistic Regression con scikit-learn.

Dipendenza:
    pip install scikit-learn
"""

from dataclasses import dataclass

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline


# ── Dataset di esempio ────────────────────────────────────────────────────────

TRAIN_DATA = [
    ("vorrei un preventivo per il vostro servizio", "richiesta_preventivo"),
    ("quanto costa il piano professionale", "richiesta_preventivo"),
    ("potete mandarmi un'offerta commerciale", "richiesta_preventivo"),
    ("mi serve un preventivo per 10 licenze", "richiesta_preventivo"),
    ("il mio account non funziona, aiuto", "assistenza_tecnica"),
    ("ho un errore quando provo ad accedere", "assistenza_tecnica"),
    ("l'applicazione si blocca continuamente", "assistenza_tecnica"),
    ("non riesco a resettare la password", "assistenza_tecnica"),
    ("voglio disdire il mio abbonamento", "reclamo"),
    ("sono insoddisfatto del servizio ricevuto", "reclamo"),
    ("chiedo il rimborso per il mese scorso", "reclamo"),
    ("il prodotto non funziona come promesso", "reclamo"),
    ("vorrei candidarmi per la posizione aperta", "candidatura"),
    ("allego il mio curriculum per la posizione", "candidatura"),
    ("siete alla ricerca di dev python", "candidatura"),
    ("vorrei sapere se cercate stagisti", "candidatura"),
]

texts = [text for text, _ in TRAIN_DATA]
labels = [label for _, label in TRAIN_DATA]


def build_pipeline() -> Pipeline:
    """Crea una pipeline TF-IDF + Logistic Regression."""
    return Pipeline([
        ("tfidf", TfidfVectorizer()),
        ("clf", LogisticRegression(max_iter=1000)),
    ])


# ── Addestramento e predizioni di esempio ─────────────────────────────────────

def demo_predictions() -> None:
    pipeline = build_pipeline()
    pipeline.fit(texts, labels)

    test_sentences = [
        "buongiorno, potreste inviarmi un preventivo dettagliato?",
        "l'app continua a darmi errore 500",
        "voglio il rimborso, il servizio è pessimo",
        "invio la mia candidatura per il ruolo di data scientist",
    ]

    print("=== Predizioni su frasi mai viste ===")
    for sentence in test_sentences:
        prediction = pipeline.predict([sentence])[0]
        probabilities = pipeline.predict_proba([sentence])[0]
        best_confidence = max(probabilities)

        print(
            f"{sentence!r:65s} -> {prediction:22s} "
            f"(confidenza {best_confidence:.2f})"
        )


# ── Valutazione train/test ────────────────────────────────────────────────────

def evaluate_model() -> None:
    X_train, X_test, y_train, y_test = train_test_split(
        texts,
        labels,
        test_size=0.25,
        random_state=42,
        stratify=labels,
    )

    pipeline = build_pipeline()
    pipeline.fit(X_train, y_train)
    predictions = pipeline.predict(X_test)

    print("\n=== Report di valutazione ===")
    print(classification_report(y_test, predictions, zero_division=0))


# ── Router riutilizzabile ─────────────────────────────────────────────────────

@dataclass
class IntentResult:
    """Risultato della classificazione di un testo."""

    intent: str
    confidence: float
    is_confident: bool


class IntentRouter:
    """
    Router di intent basato su TF-IDF + Logistic Regression.

    Pensato per instradare ticket, email o messaggi chatbot verso
    il team o il flusso corretto, senza usare un LLM.
    """

    def __init__(self, confidence_threshold: float = 0.35):
        self.confidence_threshold = confidence_threshold
        self._pipeline = build_pipeline()
        self._fitted = False

    def fit(self, training_texts: list[str], training_labels: list[str]) -> "IntentRouter":
        """Addestra il classificatore e restituisce il router stesso."""
        if len(training_texts) != len(training_labels):
            raise ValueError("texts e labels devono avere la stessa lunghezza.")

        if len(set(training_labels)) < 2:
            raise ValueError("Sono necessari almeno due intent distinti.")

        self._pipeline.fit(training_texts, training_labels)
        self._fitted = True
        return self

    def predict(self, text: str) -> IntentResult:
        """
        Classifica il testo.

        Restituisce l'intent 'sconosciuto' se la confidenza della
        migliore classe è inferiore alla soglia configurata.
        """
        if not self._fitted:
            raise RuntimeError(
                "Il modello non è stato ancora addestrato: chiama fit() prima."
            )

        probabilities = self._pipeline.predict_proba([text])[0]
        classes = self._pipeline.classes_
        best_intent, best_confidence = max(
            zip(classes, probabilities),
            key=lambda item: item[1],
        )

        is_confident = best_confidence >= self.confidence_threshold

        return IntentResult(
            intent=best_intent if is_confident else "sconosciuto",
            confidence=round(float(best_confidence), 4),
            is_confident=is_confident,
        )


def demo_router() -> None:
    router = IntentRouter(confidence_threshold=0.30)
    router.fit(texts, labels)

    requests = [
        "quanto costa passare al piano premium?",
        "non riesco più a fare login da ieri",
        "che tempo fa oggi a Milano?",  # fuori dominio
    ]

    print("\n=== Router di intent ===")
    for request in requests:
        result = router.predict(request)
        print(
            f"{request!r:45s} -> {result.intent:22s} "
            f"(conf. {result.confidence})"
        )


# ── Smoke test ────────────────────────────────────────────────────────────────

def run_smoke_tests() -> None:
    """
    Verifica che le classificazioni principali e il fallback funzionino.

    Non verifica valori numerici esatti di confidence perché possono cambiare
    leggermente tra versioni diverse di scikit-learn.
    """
    router = IntentRouter(confidence_threshold=0.30).fit(texts, labels)

    cases = {
        "mi mandate un'offerta per cinque utenti?": "richiesta_preventivo",
        "non riesco ad accedere al mio profilo": "assistenza_tecnica",
        "vorrei ottenere il rimborso del servizio": "reclamo",
        "mi candido per la posizione di sviluppatore": "candidatura",
        "che tempo farà domani a Roma?": "sconosciuto",
    }

    for text, expected_intent in cases.items():
        result = router.predict(text)
        assert result.intent == expected_intent, (
            f"Per {text!r}: atteso {expected_intent!r}, "
            f"ottenuto {result.intent!r} "
            f"(confidence: {result.confidence})"
        )

    print("\nSmoke test superati.")


if __name__ == "__main__":
    demo_predictions()
    evaluate_model()
    demo_router()
    run_smoke_tests()

Consiglio pratico: puoi combinare l’intent detection con la keyword extraction per arricchire ogni ticket con intento e temi principali, oppure con BM25 per suggerire automaticamente le risposte standard più pertinenti una volta identificato l’intento.

Risorse utili

Conosci meglio chi ha scritto questo articolo

Serena Sensini

Ciao! Mi chiamo Serena Sensini e sono la creatrice di @ TheRedCode.it. Ho aperto questo blog nel 2021 per raccontare il mio lavoro e il mondo dell’informatica a parole semplici, in piccole pillole e alla portata di tutte le persone.

Sono un’ingegnera informatica specializzata in ambito AI & NLP. Di giorno lavoro come CTO @ Welyk e come Innovation & Emerging Technologies Leader @ Dedalus, mentre di notte scrivo e sono autrice di 5 libri -per ora-. 🖊️

Foto di Serena Sensini

Partners

Community, aziende e persone che supportano attivamente il blog

Vuoi diventare partner?

Collaboriamo con community, aziende e strumenti del mondo tech che vogliono raggiungere la nostra community di dev ed engineer italiani. Scrivici per proporre una collaborazione.

Vuoi diventare tech content creator? 🖊️

Se ti va di raccontare la tua esperienza nel mondo tech, questo è il posto giusto.

Cerchiamo voci autentiche, esempi pratici e punti di vista utili per chi legge.

Scrivici a collaborazioni[at]theredcode.it con una proposta: idea, taglio del contenuto e una breve presentazione. Non vediamo l'ora di leggere la tua esperienza!

Invia la tua idea