Whoosh come motore di ricerca per un blog

Da un bel po’ di tempo il mio sito sta aspettando un bell’aggiornamento, ma ogni volta mi blocco per un motivo o per l’altro. Questa volta è stato il turno della ricerca.

Questa funzionalità è una delle più complicate da implementare, per una moltitudine di fattori, che vanno dalla necessaria attenzione alle prestazioni al fatto che la ricerca non è un problema con una soluzione precisa. Non a caso i motori di ricerca di oggi si basano completamente su algoritmi di machine learning, che però per un piccolo sito come il mio, che vuole comunque offire questa possibilità, sarebbe un’esagerazione, peraltro impossibile, in quanto non avrei nemmeno i dati per il training.

In ogni caso, non voglio rimuovere questa funzionalità dal sito, né implementarla in maniera troppo inefficace, né rivolgermi a servizi di terzi, quindi mi sono guardato un po’ attorno per decidere sul da farsi e ho provato diverse soluzioni.

Come già annunciato, sto lavorando in Python, con Django, quindi il mio primo passo è stato provare Haystack.

Questo è un framework che fa da ponte tra i modelli dell’ORM di Django e diversi backend per la ricerca, tra cui i celebri Elasticsearch e Solr, entrambi basati su Apache Lucene, e i meno famosi Xapian e Whoosh. Precisamente Haystack mette a disposizione delle API agnostiche dal backend per indicizzare, eseguire ricerche ed altre operazioni sui modelli di Django. Quindi, una volta creato il codice basato su Haystack, si possono facilmente provare i vari backend.

Una delle prima prove è stata con Elasticsearch. Avevo ottenuto dei risultati abbastanza soddisfacenti, c’era poco ancora da cambiare, però c’era un grande problema: in una macchina virtuale di prova, da solo, Elasticsearch occupava più di 1800MB di RAM, ovvero occuperebbe quasi tutta la RAM del mio VPS e non ne varrebbe la pena per indicizzare poco più di 600 post. Ho deciso quindi di valutare anche le altre opzioni.

Elasticsearch in una macchina virtuale di test occupa quasi tutta la sua RAM

Con Solr non c’è stato molto da fare: ho avuto immediatamente problemi con lo schema e ho deciso di lasciare stare e casomai usarlo come ultima possibilità. Non escludo che comunque avrebbe occupato molta RAM, come Elasticsearch.

Xapian non è stato semplicissimo da installare, ce l’avevo anche fatta, ma mi sembra non fossi riuscito ad ottenere i risultati che speravo di ottenere e nel frattempo ho fatto anche delle prove con il rimanente sistema: Whoosh.

Whoosh è stato creato come motore di ricerca per la documentazione del software Houdini 3D, ma poi è stato rilasciato come progetto Open Source ed esteso. È scritto in Python, è ricco di funzionalità che possono essere abilitate, configurate e modificate a piacere, senza bisogno di server o infrastrutture aggiuntive. Purtroppo la documentazione a volte è un po’ scarsa e la risoluzione di alcuni problemi richiede la lettura del codice sorgente, che però, essendo Python è semplice in partenza e in più è ben scritto, ben commentato e facilmente comprensibile.

Inizialmente ho usato Whoosh tramite Haystack, ma non ero pienamente soddisfatto del risultato, in particolare della cattiva implementazione delle ricerche fuzzy e dell’evidenziazione. Provando a usare direttamente le API di Whoosh, mi sono accorto che la parte di creazione dello schema e del popolamento dell’indice non è così difficile, che potevo ottenere risultati più soddisfacenti, e che le aggiunte di Haystack non mi erano veramente utili. Quindi ho deciso di procedere per questa strada e di scrivere questo post come riassunto di cosa ho ottenuto e come.

Alcune avvertenze: innanzitutto queste sono le mie prove, non sono ancora in produzione e potrebbero cambiare col tempo. Il mio obiettivo in realtà è abbastanza semplice, perché si tratta del mio sito, ovvero un blog con qualche centinaio di post che necessita di un unico modello e non sono interessato a fornire una ricerca avanzata (che tenga conto, per esempio, di categorie e di intervalli di tempo). Infine non sono per niente un esperto di questo campo, quindi potrei aver fatto delle cose che non vanno per niente bene, alla fine questo è il risultato di pochi giorni di sperimentazione.

Installazione, schema e popolamento

L’installazione è davvero semplice e può essere fatta tramite pip:

pip install whoosh

Poi dobbiamo aggiungere al nostro script degli import simili a questi:

from whoosh.fields import Schema, TEXT, NGRAM, ID
from whoosh.index import create_in

A differenza dei servizi come Google e simili, un motore di ricerca autonomo può avere in partenza più informazioni sul contenuto indicizzato, anziché doverle estrarre dall’HTML, però abbiamo bisogno di quello che viene chiamato uno schema:

schema = Schema(title=TEXT(stored=True), content=TEXT(stored=True),
    titleAuto=NGRAM(3, 4), id=ID(stored=True))

In questo modo creiamo uno schema per salvare il titolo del post, il suo contenuto, il suo ID e infine un campo particolare per fornire la funzionalità di autocompletamento delle ricercha in base al titolo: un campo che viene indicizzato tramite n-grammi. In pratica il titolo (in titleAuto) viene scomposto e indicizzato in pezzi da 3 e da 4 caratteri (spazi e punteggiatura inclusi), così mentre un utente digita, gli possono essere forniti dei suggerimenti andando a guardare questi pezzi, anziché le parole complete. Tuttavia questa funzionalità non va usata per le ricerche normali, perché in caso di query contenenti parole corte (ad esempio 3 lettere), verrebbero presi tutti i titoli contenenti parole che le contengono.

title, content e id hanno il parametro stored=True in modo che vengano interamente copiati dentro il database di Whoosh e non solamente indicizzati. Nel caso dei primi due campi serve per poter fare l’evidenziazione delle keyword nei risultati e per avere i titoli senza dover fare query al database di Django nel caso dei suggerimenti. Invece nel caso dell’id, serve per avere un modo più veloce (e a prova di cambiamenti di titolo/contenuto) per riottenere il post originale a cui si riferisce un risultato di ricerca. Sempre a proposito di questo campo, Whoosh per il tipo ID si aspetta una stringa, però non è obbligatorio che ci sia né un campo chiamato id, né un campo con tipo ID. Quindi, personalmente, sto valutando di usare un campo NUMERIC per l’id. Un’alternativa possibile è anche quella di salvare l’URL del post, se l’unica cosa che vi interessa è mettere il titolo e il link del post nella pagina ricerca.

In modo simile al campo id, anche i nomi degli altri campi non hanno alcun significato per Whoosh, ma sono completamente arbitrari. In altre parole:

  • qualsiasi funzionalità e/o collegamento tra i dati dovranno essere decisi e implementati da voi nel codice della ricerca e, al momento della creazione dello schema;
  • l’unica cosa importante per Whoosh sono i tipi di dato e le loro opzioni.

In ogni caso con questo schema, che mi sembra sufficientemente semplice, si può fare un motore di ricerca per un blog che abbia anche funzionalità comode quali i suggerimenti per le query.

Adesso è però arrivato il momento di creare un indice per questo schema e di popolarlo:

ix = create_in('index', schema) # index deve essere una cartella esistente
writer = ix.writer()

# Per semplicità, posts = [{'id': n, 'title': 'Titolo', 'content': 'Contenuto del post'}, {...}]
# In un sito vero, qui ci sarà l'iterazione su un database, probabilmente
for p in posts:
    p['id'] = str(p['id']) # Il tipo ID deve essere una stringa, altrimenti si può usare NUMERIC, vedi sopra
    p['titleAuto'] = p['title'] # Semplicemente copiamo. Queste operazioni sono da intendersi sulla copia locale dei valori, non sui dati del database
    p['content'] = htmlToPlaintext(p['content']) # Dovrete metterci una vostra funzione! Qui è simbolica per far capire lo scopo
    writer.add_document(**p)

writer.commit()

Anche la creazione dell’indice è semplice, ma bisogna avere alcune accortezze. La prima riguarda i dati che vengono inseriti: dovrebbero essere in testo semplice, senza HTML o altri linguaggi di markup/formatazioni. Nel mio caso ho usato prima una funzione di Django che rimuove i tag HTML, dopodiché ho usato una funzione dalla libreria standard di Python per trasformare le entities nei corrispondenti caratteri. Un possibile miglioramento potrebbe essere quello di sostituire le immagini con il loro attributo obbligatorio alt, usando, per esempio BeautifulSoup.

from django.template.defaultfilters import striptags
from html import unescape

def htmlToPlaintext(text):
    unescape(striptags(text))

La seconda cosa a cui fare attenzione è che la cartella dell’indice deve essere già esistente e scrivibile dall’utente che eseguirà questo script.

Ricerca

La ricerca in Whoosh è altamente configurabile; d’altra parte, però, richiede che si segua un certo procedimento anche per le ricerche più semplici: bisogna creare un searcher, un analizzatore di query con cui fare il parsing di quanto desiderato dall’utente e mettendo insieme le due cose si ottengono i risultati della ricerca.

Gran parte delle opzioni vanno specificate nell’analizzatore. Già il fatto di cercare gli stessi termini su più campi contemporaneamente richiede l’utilizzo di un analizzatore non standard, ma di un MultifieldParser. Tra le altre cose, questo permette di dare diverse priorità ai diversi campi: io ho deciso di dare un’importanza 5 volte maggiore al titolo rispetto al contenuto del post.

Un’altra cosa non banale è l’operatore logico che verrà usato per legare i diversi termini di ricerca: di default è un and. In alternativa si può usare un or o, meglio ancora, una specie di ibrido: un or che assegni punteggi maggiori quando figurano insieme più termini della query, che è ciò che fa OrGroup.factory(0.9), dove 0.9 è un parametro suggerito dalla documentazione ufficiale.

Al parser si possono aggiungere dei plugin; in realtà lo stesso MultifieldParser è una scorciatoia per un parser di default a cui è stato aggiungo il plugin MultifieldPlugin.

Io ho aggiungo i plugin PlusMinusPlugin (per specificare con + e - termini che ci devono essere o no nei risultati) e FuzzyTermPlugin, per consentire di cercare parole non esatte, specificandoli con una tilde finale ed eventualmente il numero di modifiche consentite (1 di default, più di 2 modifiche possono essere lente, e quindi potreste voler limitare la cosa per evitare DoS tramite la ricerca).

Infine, l’ultima personalizzazione che ho fatto riguarda il formatter, che viene usato per fare l’evidenziazione delle parole nei risultati. Quello predefinito, whoosh.highlight.HtmlFormatter aveva un unico “problema” (in realtà dipende dai gusti): univa i vari risultati con ... senza alcuno spazio. Personalmente preferisco usare l’entity … che sono i tre puntini in un singolo carattere e di mettere uno spazio sia prima che dopo di loro.

from whoosh.index import open_dir
from whoosh.highlight import HtmlFormatter
from whoosh.qparser import MultifieldParser, OrGroup
from whoosh.qparser.plugins import FuzzyTermPlugin, PlusMinusPlugin

ix = open_dir('index') # La stessa cartella di prima

q = ... # Dalla vostra form

with ix.searcher() as searcher: # Un searcher apre diversi file, quindi va preferibilmente usato con il with in modo da farli anche chiudere
    og = OrGroup.factory(0.9) # Dalla documentazione ufficiale, maggiore priorità se ci sono più termini
    parser = MultifieldParser(['title', 'content'], ix.schema,
        {'title': 1.0, 'content': 0.2}, group=og) # Dai priorità ai titoli
    parser.add_plugin(PlusMinusPlugin()) # Consente di specificare con + e - termini che ci devono essere o no
    parser.add_plugin(FuzzyTermPlugin()) # Consente di cercare termini non precisi con la tilde (~)
    # parser.add_plugin(ForceFuzzyPlugin()) # Un mio hack, vedi dopo

    query = parser.parse(q)
    #print(query) # Per fare debug

    results = searcher.search(query)
    results.formatter = HtmlFormatter(between=' … ') # Questioni di gusto

    # Simulazione della stampa dei risultati in un terminale, anziché usare HTML e un template engine
    for r in results:
        titleHigh = r.highlights("title")
        title = titleHigh if titleHigh else r['title'] # Se non c'è il termine nel titolo, senza questa linea non verrebbe stampato nulla
        print(title, 'n', r.highlights("content"), 'n')

Ricerche fuzzy, sempre attive?

A tutti noi capita di commettere errori quando digitiamo. I motori di ricerca sono molto evoluti e sanno capire quando sbagliamo e addirittura conoscono diversi sinonimi per le parole che scriviamo. Whoosh ha delle API per i sinonimi e per dare dei suggerimenti sulla correzione delle parole cercate, però a me sembra più semplice, ed abbastanza efficace, ammettere sempre un errore sulle parole scritte. In particolare, questo errore può essere una lettera di troppo o una mancante (cfr. distanza di edit).

Nel listato di codice precedente, la possibilità di fare una ricerca con parole non precise, chiamata fuzzy search, è già abilitata, ma richiede l’aggiunto di una tilde alla fine della parola. Questa possibilità deve essere però adeguatamente documentata, e comunque è scomoda per gli utenti con tastiera italiana su Windows (su Linux, basta fare AltGr + ì, su Mac, da quanto ho letto, Alt + 5).

La mia idea è quindi quella di fare un plugin che trasformi i termini di ricerca normale in termini di ricerca fuzzy:

from whoosh.qparser.plugins import Plugin, FuzzyTermPlugin
from whoosh.qparser.syntax import GroupNode, WordNode

class ForceFuzzyPlugin(Plugin):

    """Run after the multifield"""
    priority = 120

    def __init__(self, maxdist=None, prefixlength=0, minlength=4):
        super().__init__()
        self.maxdist = maxdist or {}
        self.prefixlength = prefixlength
        self.minlength = minlength

    def filters(self, parser):
        return [(self.do_fuzzyhack, self.priority)]

    def do_fuzzyhack(self, parser, group):
        for i, node in enumerate(group):
            if isinstance(node, GroupNode):
                # Recurse inside groups
                group[i] = self.do_fuzzyhack(parser, node)
            elif type(node) == WordNode and len(node.text) >= self.minlength:
                # Use type because we want to catch the basic words only
                group[i] = FuzzyTermPlugin.FuzzyTermNode(node,
                    self.maxdist.get(node.fieldname, 1), self.prefixlength)
        return group

Come ho scritto anche nel commento dell’elif, questo plugin lascia inalterate le parole che hanno già un modificatore, tra cui termini fuzzy e termini con le wildcard.

Anche se non è proprio attinente con la descrizione del plugin, la funzione do_fuzzyhack potrebbe essere un buon posto per fare un controllo sull massima lunghezza dei termini fuzzy specificati dagli utenti ed evitare che un utente malizioso possa degradare le prestazioni del vostro servizio.

Tra le varie cose che questa classe consente di fare, c’è l’assegnazione di una distanza di edit predefinita diversa per ogni campo, esempio 2 per il titolo e 1 per il contenuto1, e la specifica di un minimo di lunghezza per trasformare il termine normale in un termine fuzzy. Per impostazione predefinita ho messo 4 per non trasformare monosillabi e molte preposizioni/congiunzioni in termini fuzzy.

La soluzione potrebbe essere migliorata veramente molto, ma, almeno per quanto concerne le mie esigenze, può andare bene.

Comunque questa rimane una delle parti su cui sono più indeciso e che potrebbe essere più soggetta a variazioni.

Suggerimenti/autocompletamento

Come scritto sin dall’inizio del post, voglio poter fornire l’autocompletamento delle query, sia per le form, che per OpenSearch.

In realtà questa funzionalità può essere implementata con una ricerca base senza plugin aggiuntivi:

from whoosh.index import open_dir
from whoosh.qparser import QueryParser

q = ... # Probabilmente da un parametro GET

ix = open_dir('index')
parser = QueryParser('titleAuto', ix.schema) # Cerchiamo solo in titleAuto
query = parser.parse(q)

with ix.searcher() as searcher:
    results = searcher.search(query)

    for r in results[0:10]: # Per fornire 10 suggerimenti
        print(r['title']) # Possiamo stampare direttamente il titolo grazie a stored=True
    # Più probabilmente json.dumps([r['title'] for r in results[0:10]])

Tutte le considerazioni fatte nella sezione precedente possono essere applicate anche qui, quindi tutte le parole della ricerca devono comparire (ovvero è un and, ma si può usare anche un or), si possono aggiungere plugin per il parsing etc.

Conclusioni

Con qualche giorno di esperimenti ho ottenuto dei risultati che mi soddisfano e che mi consentono di andare avanti con il resto del sito.

Quello che ho ottenuto è una ricerca ricca di funzionalità, performante ma senza la necessità di troppe risorse a lei dedicate. Certe cose potrebbero essere implementate in modo un po’ migliore (per esempio la flessibilità tramite fuzzy terms potrebbe non essere il massimo), però, pur non avendo dati che mi indichino quanto la ricerca sia effettivamente usata nel mio sito, non penso lo sia molto, quindi non varrebbe la pena dedicarci più tempo.

Il codice sorgente qui disponibile è basato sui vari esempi della documentazione di Whoosh e per la classe ForceFuzzyPlugin mi sono ispirato alla classe FuzzyTermPlugin. Detto questo, se possibile rilascio i miei listati nel pubblico dominio, altrimenti potrebbe essere necessario citare Whoosh e la licenza MIT.

Footnotes

  1. Tuttavia non viene gestito il caso dello 0, che con una piccola modifica al codice potrebbe disabilitare la trasformazione in fuzzy search per quel campo
    ^top