Ricerca lessicale e ranking con BM25

Immagine di copertina

Prima ancora che gli embedding e la ricerca semantica diventassero popolari, i motori di ricerca hanno risolto per decenni il problema del ranking dei documenti con un algoritmo elegante, veloce e sorprendentemente efficace: BM25 (Best Matching 25). È l’algoritmo alla base di Elasticsearch, Lucene e Solr, ed è tuttora lo standard de facto per la ricerca lessicale.

In questo articolo vediamo come implementare un piccolo motore di ricerca BM25 in Python, capirne il funzionamento e usarlo per costruire un sistema di ricerca interno su una knowledge base o un archivio di articoli, senza alcun bisogno di un LLM o di modelli di embedding.

Cosa vedrai

Cos’è BM25 e perché funziona ancora bene

BM25 è un algoritmo di ranking probabilistico: dato un insieme di documenti e una query, assegna a ciascun documento un punteggio di rilevanza basato su:

  • term frequency (TF): quante volte i termini della query compaiono nel documento (con un effetto di saturazione, per evitare che la ripetizione ossessiva di una parola “vinca” sempre)
  • inverse document frequency (IDF): quanto è raro un termine nell’intera collezione (le parole comuni pesano meno, quelle rare pesano di più)
  • lunghezza del documento: normalizza il punteggio rispetto alla lunghezza media dei documenti nella collezione, per non favorire ingiustamente i testi più lunghi

A differenza della ricerca semantica basata su embedding, BM25:

  • non richiede alcun modello di machine learning né GPU
  • è completamente interpretabile: puoi sempre spiegare perché un documento ha ottenuto un certo punteggio
  • è velocissimo anche su milioni di documenti
  • funziona molto bene quando la query condivide termini lessicali con i documenti rilevanti (FAQ, documentazione tecnica, ricerca per codice prodotto, ecc.)

Un motore di ricerca BM25 in poche righe

Usiamo la libreria rank-bm25, leggera e senza dipendenze pesanti:

pip install rank-bm25
from rank_bm25 import BM25Okapi

documents = [
    "spaCy è una libreria NLP open-source scritta in Python e Cython, veloce e scalabile.",
    "NLTK è una delle librerie storiche per il Natural Language Processing in Python.",
    "BM25 è un algoritmo di ranking usato dai motori di ricerca per il retrieval lessicale.",
    "La pizza napoletana è un piatto tipico della cucina italiana con pomodoro e mozzarella.",
    "Il machine learning e il deep learning sono tecniche di intelligenza artificiale.",
]

# BM25 lavora su documenti già tokenizzati
tokenized_corpus = [doc.lower().split() for doc in documents]
bm25 = BM25Okapi(tokenized_corpus)

query = "libreria python per elaborazione del linguaggio"
tokenized_query = query.lower().split()

scores = bm25.get_scores(tokenized_query)
ranked = sorted(zip(scores, documents), key=lambda x: x[0], reverse=True)

print(f"Query: {query!r}\n")
for score, doc in ranked:
    print(f"{score:.4f}  {doc}")

Output:

Query: 'libreria python per elaborazione del linguaggio'

2.1685  spaCy è una libreria NLP open-source scritta in Python e Cython, veloce e scalabile.
0.3433  NLTK è una delle librerie storiche per il Natural Language Processing in Python.
0.3216  BM25 è un algoritmo di ranking usato dai motori di ricerca per il retrieval lessicale.
0.0000  La pizza napoletana è un piatto tipico della cucina italiana con pomodoro e mozzarella.
0.0000  Il machine learning e il deep learning sono tecniche di intelligenza artificiale.

Anche con un tokenizer estremamente semplice (str.split()), BM25 riesce a distinguere correttamente i documenti pertinenti da quelli irrilevanti, assegnando punteggio zero ai documenti che non condividono alcun termine con la query.

Per ottenere direttamente i documenti più rilevanti:

top_docs = bm25.get_top_n(tokenized_query, documents, n=2)
for d in top_docs:
    print(" -", d)

Preprocessing del testo per migliorare il ranking

Il tokenizer “ingenuo” (str.split()) funziona, ma un preprocessing più curato migliora sensibilmente la qualità del ranking, soprattutto su corpus reali. Un preprocessing tipico prevede:

  • rimozione della punteggiatura
  • normalizzazione a minuscolo
  • rimozione delle stopword
  • opzionalmente, lemmatizzazione o stemming
import re
from nltk.corpus import stopwords
from nltk.stem.snowball import SnowballStemmer

it_stopwords = set(stopwords.words("italian"))
stemmer = SnowballStemmer("italian")


def tokenize(text: str) -> list[str]:
    """Tokenizzazione semplice con rimozione stopword e stemming."""
    text = re.sub(r"[^\w\s]", " ", text.lower())
    tokens = text.split()
    return [stemmer.stem(t) for t in tokens if t not in it_stopwords and len(t) > 2]


print(tokenize("Le librerie per il Natural Language Processing in Python"))

Output:

['liber', 'natural', 'language', 'processing', 'python']

Lo stemming riduce le parole alla loro radice (es. “librerie” → “liber”), aumentando le possibilità di match tra query e documenti anche quando le forme grammaticali non coincidono esattamente.

Costruire un motore di ricerca riutilizzabile

Mettiamo insieme tutto in una classe BM25SearchEngine, pensata per indicizzare una collezione di documenti (articoli di blog, pagine di documentazione, ticket chiusi) e rispondere a query multiple:

import re
from dataclasses import dataclass, field
from rank_bm25 import BM25Okapi
from nltk.corpus import stopwords

IT_STOPWORDS = set(stopwords.words("italian"))


def simple_tokenize(text: str) -> list[str]:
    text = re.sub(r"[^\w\s]", " ", text.lower())
    return [t for t in text.split() if t not in IT_STOPWORDS and len(t) > 1]


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

    def __post_init__(self):
        self.tokens = simple_tokenize(self.text)


class BM25SearchEngine:
    """Motore di ricerca lessicale basato su BM25."""

    def __init__(self):
        self._docs: list[Document] = []
        self._bm25: BM25Okapi | None = None

    def index(self, documents: list[Document]) -> None:
        """Indicizza (o re-indicizza) la collezione di documenti."""
        self._docs = documents
        corpus = [doc.tokens for doc in documents]
        self._bm25 = BM25Okapi(corpus)

    def search(self, query: str, top_k: int = 3) -> list[dict]:
        """Restituisce i top_k documenti più rilevanti per la query."""
        if self._bm25 is None:
            raise RuntimeError("Devi prima chiamare index() con almeno un documento.")

        query_tokens = simple_tokenize(query)
        scores = self._bm25.get_scores(query_tokens)

        ranked = sorted(
            zip(scores, self._docs), key=lambda x: x[0], reverse=True
        )

        return [
            {"id": doc.id, "title": doc.title, "score": round(float(score), 4)}
            for score, doc in ranked[:top_k]
            if score > 0
        ]


# ── Demo ──────────────────────────────────────────────────────────────────────
engine = BM25SearchEngine()

engine.index([
    Document(
        id="doc-1",
        title="Introduzione a spaCy",
        text="spaCy è una libreria NLP open-source per il Natural Language Processing in Python, veloce e scalabile.",
    ),
    Document(
        id="doc-2",
        title="Sentence splitting e snippet generation",
        text="La segmentazione delle frasi e la generazione di snippet sono attività fondamentali per un motore di ricerca.",
    ),
    Document(
        id="doc-3",
        title="Anonimizzazione di dati PII",
        text="Anonimizzare email, telefoni e codici fiscali in un testo richiede regole e riconoscimento delle entità.",
    ),
])

for risultato in engine.search("motore di ricerca per il linguaggio naturale"):
    print(risultato)

Output:

{'id': 'doc-2', 'title': 'Sentence splitting e snippet generation', 'score': 1.1004}
{'id': 'doc-1', 'title': 'Introduzione a spaCy', 'score': 0.7328}

Nota che doc-3 non compare tra i risultati perché non condivide alcun termine rilevante con la query: BM25, a differenza della ricerca semantica, non inventa relazioni di significato che non esistono lessicalmente. È una caratteristica, non un limite: il comportamento è sempre prevedibile e verificabile.

BM25 vs ricerca semantica: quando basta il lessico

ScenarioBM25Ricerca semantica (embedding/LLM)
Query con termini presenti nei documentiEccellenteBuono ma più costoso
Query con sinonimi o parafrasiLimitatoMigliore
Documentazione tecnica, codici prodotto, FAQOttimoSpesso superfluo
Costo computazionaleBassissimoPiù alto (embedding + storage vettoriale)
Necessità di GPU/infrastruttura MLNoSpesso sì
Spiegabilità del risultatoAltaBassa
Milioni di documenti, bassa latenzaSì, gestibile facilmenteRichiede infrastruttura dedicata (vector DB)

In molti contesti reali (documentazione interna, help center, ricerca su un blog tecnico) la query dell’utente condivide gran parte del vocabolario con i documenti rilevanti: in questi casi BM25 offre un rapporto costo/beneficio imbattibile, ed è spesso la prima cosa da provare prima di introdurre pipeline più complesse basate su embedding o LLM.


Consiglio pratico: puoi combinare BM25 con la generazione di snippet vista nell’articolo su sentence splitting e snippet generation per costruire un motore di ricerca completo — ranking lessicale + estratto testuale contestuale — senza mai coinvolgere un LLM.

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