Robust Sentence Splitting & Snippet Generation con spaCy

Immagine di copertina

Quando si costruisce un motore di ricerca, un sistema di question answering o un pipeline di indicizzazione semantica, due operazioni tornano sempre: suddividere il testo in frasi (sentence splitting) e generare uno snippet rappresentativo attorno a una parola chiave o a un segmento rilevante. Con spaCy entrambe le operazioni sono robuste, veloci e facilmente personalizzabili.

In questo articolo esploreremo come sfruttare la pipeline di spaCy per implementare uno splitter di frasi affidabile e come costruire, a partire da esso, un generatore di snippet contestuali di qualità.

Cosa vedrai

Sentence splitting con spaCy

La segmentazione delle frasi (sentence boundary detection, SBD) è uno dei passaggi più delicati delle attività di preprocessing in ambito NLP. Un semplice split(".") fallisce su abbreviazioni, numeri decimali e titoli: ad esempio, la frase Dr. Rossi ha ottenuto un punteggio di 9.5. verrebbe spezzata in tre frammenti che perdono di significato.

spaCy risolve il problema grazie al componente senter (o sentencizer nella versione più leggera), addestrato su corpus reali per gestire correttamente questi casi.

Per prima cosa installiamo spaCy con il modello della lingua desiderata:

pip install spacy
# Modello italiano (large, più accurato)
python -m spacy download it_core_news_lg
# Modello inglese
python -m spacy download en_core_web_lg

Segmentare un testo è immediato:

import spacy

nlp = spacy.load("it_core_news_lg")

testo = """
Dr. Rossi ha ricevuto un punteggio di 9.5 su 10. È un ottimo risultato!
L'azienda Acme S.p.A. ha annunciato ricavi per 3.2 mld €. I dettagli seguiranno.
La conferenza si terrà il 15 dic. 2026 a Milano.
"""

doc = nlp(testo.strip())

for i, sent in enumerate(doc.sents, 1):
    print(f"[{i}] {sent.text.strip()}")

Output:

[1] Dr. Rossi ha ricevuto un punteggio di 9.5 su 10.
[2] È un ottimo risultato!
[3] L'azienda Acme S.p.A. ha annunciato ricavi per 3.2 mld €.
[4] I dettagli seguiranno.
[5] La conferenza si terrà il 15 dic. 2026 a Milano.

Le abbreviazioni (Dr., S.p.A., dic.) e i numeri decimali (9.5, 3.2) vengono gestiti correttamente senza alcuna configurazione aggiuntiva.

Usare solo il sentencizer (senza NER e POS)

Se hai testi molto lunghi o risorse limitate e non ti servono le annotazioni linguistiche complete, puoi usare la pipeline minimale con solo il componente sentencizer:

import spacy
from spacy.lang.it import Italian

nlp_fast = Italian()
nlp_fast.add_pipe("sentencizer")

doc = nlp_fast(testo.strip())
frasi = [s.text.strip() for s in doc.sents]
print(frasi)

Il sentencizer si basa su regole euristiche (punteggiatura + maiuscola successiva) ed è molto più veloce, ma meno preciso del modello statistico su testi con molte abbreviazioni.

Personalizzare le regole di segmentazione

A volte le frasi nel tuo corpus hanno caratteristiche particolari: ad esempio testi tecnici con elenchi puntati, pseudocodice o intestazioni Markdown, per cui il modello standard può sbagliare. In questi casi puoi iniettare regole personalizzate tramite un componente dedicato.

Nel seguente esempio impediamo che i trattini di apertura elenco (-, •, –) vengano trattati come inizio di nuova frase se seguono immediatamente un token di fine frase:

import spacy
from spacy.language import Language

nlp = spacy.load("it_core_news_lg")


@Language.component("custom_sentence_rules")
def custom_sentence_rules(doc):
    """
    Impedisce che i trattini di apertura elenco (- o •)
    vengano trattati come inizio di nuova frase se seguono
    immediatamente un token di fine frase.
    """
    for token in doc[1:]:
        if token.text in ("-", "•", "–") and token.i > 0:
            # Non iniziare una nuova frase su questi token
            token.is_sent_start = False
    return doc


# Inserisco il componente prima del senter per avere precedenza
nlp.add_pipe("custom_sentence_rules", before="senter")

testo_lista = """
Requisiti del progetto:
- Python 3.11 o superiore
- spaCy 3.x
- Un modello linguistico installato
"""

doc = nlp(testo_lista.strip())
for sent in doc.sents:
    print(repr(sent.text.strip()))

Generare snippet contestuali

Uno snippet contestuale è un breve estratto di testo (tipicamente 1-3 frasi) che circonda un termine di interesse. Per capirci, è lo stesso testo che vedi sotto ogni risultato in Google o in qualsiasi motore di ricerca.

L’approccio con spaCy per la sua generazione è estremamente elegante: siccome il documento è già segmentato in frasi, basta trovare la frase che contiene il termine e restituire quella frase, opzionalmente con le frasi adiacenti per aumentare il contesto.

import spacy
from dataclasses import dataclass

nlp = spacy.load("it_core_news_lg")


@dataclass
class Snippet:
    text: str
    sentence_index: int
    start_char: int
    end_char: int


def generate_snippet(
    text: str,
    query: str,
    context_sentences: int = 1,
    max_length: int = 300,
) -> Snippet | None:
    """
    Restituisce lo snippet più rilevante attorno alla prima
    occorrenza di `query` nel testo.

    Args:
        text:               Il testo sorgente.
        query:              Il termine (o frase) da cercare.
        context_sentences:  Numero di frasi adiacenti da includere.
        max_length:         Lunghezza massima dello snippet in caratteri.

    Returns:
        Un oggetto Snippet, oppure None se il termine non è trovato.
    """
    doc = nlp(text)
    sentences = list(doc.sents)
    query_lower = query.lower()

    # Trova la prima frase contenente il termine
    match_idx = next(
        (i for i, s in enumerate(sentences) if query_lower in s.text.lower()),
        None,
    )
    if match_idx is None:
        return None

    # Calcola il range di frasi da includere
    start_idx = max(0, match_idx - context_sentences)
    end_idx = min(len(sentences), match_idx + context_sentences + 1)

    snippet_parts = [s.text.strip() for s in sentences[start_idx:end_idx]]
    snippet_text = " ".join(snippet_parts)

    # Tronca se supera max_length, cercando di non spezzare parole
    if len(snippet_text) > max_length:
        snippet_text = snippet_text[:max_length].rsplit(" ", 1)[0] + "…"

    return Snippet(
        text=snippet_text,
        sentence_index=match_idx,
        start_char=sentences[match_idx].start_char,
        end_char=sentences[match_idx].end_char,
    )


# ── Demo ──────────────────────────────────────────────────────────────────────
testo = """
spaCy è una libreria open-source per il Natural Language Processing in Python.
Supporta decine di lingue ed è progettata per l'uso in produzione.
La funzione di sentence splitting divide automaticamente un testo in frasi.
Questa funzionalità è fondamentale per costruire pipeline NLP robuste.
Il componente senter, addestrato su corpus reali, gestisce correttamente
abbreviazioni e casi particolari.
"""

snippet = generate_snippet(testo.strip(), query="sentence splitting", context_sentences=1)
if snippet:
    print(f"Snippet trovato (frase {snippet.sentence_index}):")
    print(snippet.text)

Output:

Snippet trovato (frase 2):
Supporta decine di lingue ed è progettata per l'uso in produzione. La funzione di sentence splitting divide automaticamente un testo in frasi. Questa funzionalità è fondamentale per costruire pipeline NLP robuste.

Snippet per motori di ricerca interni

Nei motori di ricerca interni (intranet, knowledge base, e-commerce) è spesso utile evidenziare il termine di query all’interno dello snippet con un tag HTML. Ecco un’estensione della funzione precedente che produce output HTML:

import html
import re

def highlight_query(snippet_text: str, query: str) -> str:
    """
    Avvolge le occorrenze di `query` in un tag <mark> per l'evidenziazione.
    Effettua un'escaping dell'HTML per sicurezza.
    """
    safe_text = html.escape(snippet_text)
    safe_query = re.escape(html.escape(query))
    highlighted = re.sub(
        f"({safe_query})",
        r"<mark>\1</mark>",
        safe_text,
        flags=re.IGNORECASE,
    )
    return highlighted


snippet = generate_snippet(testo.strip(), query="sentence splitting")
if snippet:
    html_snippet = highlight_query(snippet.text, "sentence splitting")
    print(html_snippet)

Output (semplificato):

Supporta decine di lingue ... La funzione di <mark>sentence splitting</mark> divide ...

Pipeline completa: da testo grezzo a snippet

Mettiamo tutto insieme in una classe SnippetEngine riutilizzabile, che accetta più documenti e restituisce gli snippet per qualsiasi query:

import spacy
from dataclasses import dataclass, field

nlp = spacy.load("it_core_news_lg")


@dataclass
class Document:
    id: str
    title: str
    text: str
    sentences: list = field(default_factory=list)

    def __post_init__(self):
        doc = nlp(self.text)
        self.sentences = [s.text.strip() for s in doc.sents if s.text.strip()]


class SnippetEngine:
    """
    Motore di snippet su una collezione di documenti.
    Pre-processa i documenti una sola volta al momento dell'indicizzazione.
    """

    def __init__(self):
        self._docs: list[Document] = []

    def index(self, document: Document) -> None:
        """Indicizza un documento."""
        self._docs.append(document)

    def search(
        self,
        query: str,
        context_sentences: int = 1,
        max_length: int = 300,
    ) -> list[dict]:
        """
        Cerca la query nei documenti indicizzati e restituisce
        una lista di risultati con titolo e snippet.
        """
        results = []
        query_lower = query.lower()

        for doc in self._docs:
            match_idx = next(
                (i for i, s in enumerate(doc.sentences) if query_lower in s.lower()),
                None,
            )
            if match_idx is None:
                continue

            start = max(0, match_idx - context_sentences)
            end = min(len(doc.sentences), match_idx + context_sentences + 1)
            snippet = " ".join(doc.sentences[start:end])

            if len(snippet) > max_length:
                snippet = snippet[:max_length].rsplit(" ", 1)[0] + "…"

            results.append(
                {
                    "id": doc.id,
                    "title": doc.title,
                    "snippet": snippet,
                    "sentence_index": match_idx,
                }
            )

        return results


# ── Demo ──────────────────────────────────────────────────────────────────────
engine = SnippetEngine()

engine.index(Document(
    id="doc-1",
    title="Introduzione a spaCy",
    text=(
        "spaCy è una libreria NLP open-source scritta in Python e Cython. "
        "È progettata per essere veloce e scalabile in ambienti di produzione. "
        "Supporta il sentence splitting, la tokenizzazione e il riconoscimento delle entità."
    ),
))

engine.index(Document(
    id="doc-2",
    title="Generazione di snippet per la ricerca",
    text=(
        "Uno snippet è un breve estratto di testo che mostra il contesto attorno a un termine cercato. "
        "I motori di ricerca moderni usano tecniche NLP per generare snippet rilevanti. "
        "La qualità degli snippet influenza direttamente il CTR dei risultati di ricerca."
    ),
))

for result in engine.search("snippet", context_sentences=1):
    print(f"[{result['id']}] {result['title']}")
    print(f"  → {result['snippet']}")
    print()

Output:

[doc-1] Introduzione a spaCy
  → È progettata per essere veloce e scalabile in ambienti di produzione. Supporta il sentence splitting, la tokenizzazione e il riconoscimento delle entità.

[doc-2] Generazione di snippet per la ricerca
  → Uno snippet è un breve estratto di testo che mostra il contesto attorno a un termine cercato. I motori di ricerca moderni usano tecniche NLP per generare snippet rilevanti. La qualità degli snippet influenza direttamente il CTR dei risultati di ricerca.

Consiglio pratico: se la tua applicazione deve processare migliaia di documenti, usa nlp.pipe() al posto di chiamate singole a nlp(). Questo consente a spaCy di fare batch processing in modo molto più efficiente, riducendo significativamente i tempi di elaborazione.

# Processamento in batch — molto più efficiente su grandi volumi
texts = [doc.text for doc in raw_documents]
for spacy_doc in nlp.pipe(texts, batch_size=64):
    sentences = [s.text.strip() for s in spacy_doc.sents]
    # ... indicizza le frasi

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.

✉️ Scrivici

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