Nessun risultato. Prova con un altro termine.
Guide
Notizie
Software
Tutorial

Creare un motore di ricerca full-text con Lucene

L'architettura di un motore di ricerca e l'implementazione pratica
L'architettura di un motore di ricerca e l'implementazione pratica
Link copiato negli appunti

Il reperimento veloce ed efficace delle informazioni è molto importante, sia sul Web, sia nei tanti ambiti in cui la mole di dati digitali è molto grande. Per questo occorre introdurre sistemi che indicizzino i dati, semplificando e ottimizzando le ricerche. In un articolo precedente abbiamo introdotto Lucene, la libreria di Apache che fornisce degli strumenti preconfezionati per implementare proprio l'indicizzazione e la ricerca.

In questo articolo esamineremo prima un'architettura generica per motori di ricerca full-text, poi un'implementazione di questa architettura che sfrutta la Lucene.

Architettura di un generico motore di ricerca full-text

Un motore di ricerca full-text è composto, in genere, di due parti principali: un front-end, che serve da interfaccia utente, e un back-end che, invece, implementa le funzioni avanzate di ricerca delle informazioni, sia dai documenti (file di testo o documenti web), sia dai database. Il back-end si occupa anche di realizzare degli indici ottimizzati, che sono fondamentali in fase di ricerca.

Figura 1. Architettura di un generico motore di ricerca full-text
Architettura di un generico motore di ricerca full-text

Le parti di front-end e di back-end del sistema si compongono di diverse entità, ognuna delle quali ricopre un ruolo specifico e compie una parte di elaborazione specifica del processo di ricerca.

La parte di front-end contiene:

  • un'interfaccia utente, alla quale l'utente richiederà il servizio di ricerca e dalla quale riceverà i risultati
  • un query parser, al quale è fornita la keyword di ricerca introdotta dall'utente (può essere una stringa di testo molto generica) che deve essere trasformata in un'opportuna query di ricerca, ovvero in una query adatta al formato degli indici a disposizione ed alla tecnologia/tecnica di interrogazione di questi ultimi
  • un searcher, che è un'entità particolarmente importante, dal momento che cura la parte vera e propria di ricerca sugli indici a partire dalla query (opportunamente resa) in ingresso.

La parte di back-end, invece, è composta da:

  • uno spider, detto anche crawler, il quale ha il compito di aprire in maniera metodica i documenti che incontra durante l'avanscoperta (su di un file-system, piuttosto che su di una rete o su di un database) e di analizzarne il contenuto al fine di trovare e riportare i token ritenuti più importanti/discriminanti rispetto al contesto
  • un indicizzatore che ha, invece, il compito di acquisire i token fornitigli ed inserirli in un indice ottimizzato di ricerca insieme a delle altre particolari informazioni discriminanti rispetto al documento che contiene il token in questione.

Appartengono alla parte di back-end del sistema, ovviamente, anche gli indici generati dall'indicizzatore, i database ed infine i file che contengono generici documenti di testo oppure pagine Web salvate da Internet (una delle modalità operative degli spider è proprio quella di esplorare la rete globale, salvare i documenti ipertestuali ed analizzarli).

Una volta esaminata l'architettura generale di un motore di ricerca full-text, sarà più semplice comprendere i passaggi necessari all'implemtrattazione del progetto.

L'architettura implementata con Java ed Apache Lucene

Riprendendo l'architettura proposta in Fig. 1, è interessante comprendere come questa possa essere implementata nel contesto di un'applicazione Web, utilizzando una libreria open-source e free di API Java per l'indicizzazione e la ricerca full-text come Lucene di Apache Jakarta.

Le entità chiave dell'architettura sono indubbiamente indicizzatore e searcher, rispettivamente collocati nel back-end e nel front-end.

È in funzione di queste due entità che le altre lavorano: il crawler lavora prettamente per l'indicizzatore, in particolare per fornirgli token e metainformazioni (ad esempio, nome del documento contenitore del token, relativa URI, etc.), mentre il query parser lavora prettamente per il searcher, dal momento che a quest'ultimo deve necessariamente fornire delle stringhe di ricerca che rispettino un formato predeterminato.

Figura 2. Architettura del motore di ricerca implementato con Lucene
Architettura del motore di ricerca implementato con Lucene

Lucene possiede quattro classi particolarmente importanti. IndexWriter ed IndexSearcher, due delle quattro, sono classi che incapsulano la logica applicativa delle componenti d'indicizzazione e ricerca dell'architettura in questione (Fig. 1).

QueryParser ed Analyzer incapsulano, invece, la logica applicativa delle componenti per l'interpretazione delle keyword fornite dall'utente e per l'analisi dei documenti, rispettivamente query parser e spider della nostra architettura.

Lucene, però, non offre un'implementazione completa ed intesa nel "senso classico" di spider: Analyzer è una classe che fornisce differenti modalità di analisi e resa dei documenti, ma manca di un'implementazione basilare per la ricerca dei documenti. Stando a ciò, è necessario implementare tale logica in maniera autonoma e personalizzata: per un'analisi di documenti Web la logica di ricerca e sottomissione all'analizzatore dei documenti sarà differente rispetto al caso di ricerca e sottomissione dei documenti presenti, invece, sul file system locale.

L'implementazione con Lucene dell'architettura del motore di ricerca full-text può essere decomposta in due macro-componenti:

  1. macro-componente di front-end, con il compito di curare la logica per la resa delle keyword fornite dall'utente e la ricerca diretta sugli indici a disposizione
  2. macro-componente di back-end, con il compito di incapsulare la logica di ricerca e sottomissione personalizzata (rispetto ad uno o più contesti specifici) dei documenti, insieme alle funzionalità di analisi e di indicizzazione di questi ultimi. Una tale implementazione, rispetto all'architettura di riferimento, è frutto dell'uso semplice e diretto delle API messe a disposizione dalla libreria.

La Fig. 2 rappresenta l'architettura che implementeremo sfruttando le API di Lucene. Nella seconda parte dell'articolo ci dedicheremo alla stesura del codice: faremo riferimento ad un caso di studio, insieme alle specifiche implementazioni delle macro-componenti.

Il "caso di studio"

Prima di passare alla stesura del codice facciamo qualche ipotesi sul caso pratico che stiamo per affrontare.

Supponiamo di avere a disposizione una directory del file system che contiene un vasto insieme di sottodirectory e file, e che sia necessario indicizzarla per cercare, in un secondo momento, uno o più documenti utilizzando una keyword.

Supponiamo ancora che i documenti di interesse ai fini della ricerca, e quindi dell'indicizzazione, siano solo quelli di testo e testo semplice (file di immagini di vario genere, file PDF e altri non sono di interesse).

Infine decidiamo di voler trovare tutti i documenti che contengano una o più sottostringhe presenti nella keyword.

Per risolvere questo problema, anzitutto è necessario implementare un meccanismo che consenta al macro-componente di back-end di indicizzare il contenuto della nostra directory, in maniera sistematica e puntuale: filtrando tutti i file che non vogliamo coinvolgere nella ricerca.

Il macro-componente di front-end, invece, può essere abbastanza standard, dal momento che in ogni caso l'unica risorsa di interesse per tale componente risulta essere l'indice realizzato in fase di indicizzazione (come si vede nella Fig. 1).

Questo caso di studio può raggruppare in sé anche casi più generali per i quali, magari, è necessario filtrare soltanto un numero più limitato di documenti/file; altro caso più generale potrebbe essere quello che vede una apposita logica ulteriore che esplora il Web e salva i file di interesse nella directory che si andrà poi ad indicizzare in maniera graduale.

Questo stesso discorso di generalizzazione del caso di studio non può essere adottato al caso di ricerca di informazioni da uno o più database. In questo caso è necessario adottare una logica di ricerca delle informazioni, nella fase di realizzazione dell'indice, che è del tutto diversa da quella che utilizzeremo a breve.

Il back-end

Il macro-componente di back-end è stato implementato in un'apposita classe Java denominata sinteticamente Indexer. Questa classe possiede metodi per:

  1. l'esplorazione di una directory generica che può essere composta da un numero non definito di subdirectory e file (Listato 1)
  2. individuare un file in una directory ed indicizzarne il contenuto con annesse informazioni descrittive (Listato 2)
  3. avviare la ricerca ed indicizzazione su di un'istanza della classe suddetta (Listato. 3)

Questi tre metodi della classe Indexer costituiscono l'insieme basilare di funzionalità necessarie per poter effettuare un'indicizzazione sfruttando le API di Lucene. Per memorizzare le directory con i dati e gli indici, definiamo due attributi di tipo File:

private File dir_for_index;
private File dir_for_data;

che valorizziamo da subito nel costruttore:

public Indexer(File dir_for_index, File dir_for_data)
{
  super();
  this.dir_for_index = dir_for_index;
  this.dir_for_data = dir_for_data;
}

Iniziamo implementando la logica di esplorazione di un semplice spider. Scriviamo il metodo indexDirectory, il quale, a partire dalla cartella segnalata dal parametro dir, effettua la visita di tutti i file e le sottocartelle, grazie a semplici chiamate ricorsive.

Listato 1. Logica di esplorazione di una generica directory

private void indexDirectory(IndexWriter writer, File dir) throws IOException 
{
  File[] files = dir.listFiles();
  
  File tmp_f = null;
  
  for (int i=0; i < files.length; i++)
  {
    tmp_f = files[i];
    if (tmp_f.isDirectory())
    {
      // continua a cercare
      indexDirectory(writer, tmp_f); 
    }
    else 
    {
      // filtra a indicizza
      indexFile(writer, tmp_f);
    }
  }
}

Usiamo la classe Java File per capire se il file in esame risulta non essere una directory, in questo caso invochiamo il metodo che si occupa del filtraggio dei file e dell'indicizzazione vera e propria.

Si tratta del metodo indexFile, che prende in input un IndexWriter di Lucene istanziato nel metodo chiamante e un Java File che è proprio il file che si andrà ad indicizzare mediante le API di Lucene.

Il metodo indexFile crea un Document di Lucene al quale aggiunge due Field con rispettive etichette:

  • il Field con etichetta "content" contiene il documento prelevato dal file system che poi sarà passato dall'IndexWriter, in fase di scrittura dell'indice, all'Analyzer per le elaborazioni di rito (realizzazione di un insieme di token significativi e resa in minuscolo di questi ultimi)
  • il Field con etichetta "filename" contiene delle metainformazioni che, nella fattispecie, sono rappresentate semplicemente dal path completo su file system della risorsa in analisi.

La fase di indicizzazione viene poi effettuata con l'aggiunta del documento nell'indice.

Listato 2. Logica di indicizzazione di un file

private void indexFile(IndexWriter writer, File f) throws IOException 
{
  String filename = f.getName();
  
  if(filename.endsWith(".txt") || filename.endsWith(".doc") || filename.endsWith(".rtf"))
  {
    System.out.println("(@Indexer) I'm Indexing : [" + f.getName() + "] ");
    
    Document doc = new Document();
    doc.add(new Field("content",  new FileReader(f), Field.TermVector.YES));
    doc.add(new Field("filename", f.getCanonicalPath(), Field.Store.YES,
                                  Field.Index.NOT_ANALYZED));
    writer.addDocument(doc);
  }
}

Il prossimo metodo che scriviamo è doTheIndexing, che, richiamato su una generica istanza della classe Indexer, consente di dare il via al processo di ricerca, filtro e indicizzazione dei file.

È qui che avviene la creazione dell'IndexWriter che passa come parametro il desiderato Analyzer. L'Analyzer è di fondamentale importanza per il processo di indicizzazione, dal momento che è l'entità che prende in carico l'elaborazione del documento in termini di:

  1. suddivisione in token
  2. individuazione di token significativi/discriminanti
  3. resa in minuscolo dei token per evitare che si verifichi, nel processo di ricerca e presentazione dei risultati, la dipendenza dalla digitazione in minuscolo e/o maiuscolo (case sensitivity)

Per completezza, sottolineiamo che Lucene mette a disposizione dello sviluppatore diversi tipi di Analyzer, ognuno dei quali presenta caratteristiche peculiari e quindi diverse dagli altri; per semplicità e per effettive esigenze, abbiamo deciso di utilizzare un Analyzer standard che, in ogni caso, adempie in pieno ed in maniera efficiente alla nostre necessità.

Listato 3. Metodo che avvia il processo di indicizzazione

public void doTheIndexing() throws IOException 
{
  if (!this.dir_for_data.exists() || !this.dir_for_data.isDirectory())
  {
    throw new IOException(" [" + this.dir_for_data.toString() + "] : does not exist or is not a directory!");
  }
  
  IndexWriter writer = new IndexWriter(this.dir_for_index, new StandardAnalyzer(), true);
  indexDirectory(writer, this.dir_for_data);
  writer.close();
}

Si può consultare il codice completo della classe Indexer, che implementa il macro-componente di back-end grazie alle API di Lucene.

Il front-end

Anche il macro-componente di front-end è stato implementato con un'apposita classe Java denominata Searcher. Questa classe possiede uno metodo specifico per implementare la gestione del query parser di Lucene, oltre ad un metodo che consente di avviare la ricerca su di un indice di Lucene precedentemente costituito.

Il primo metodo che esaminiamo è parseKeyword che, data una keyword di ricerca fornita dall'utente, restituisce la Query di Lucene da utilizzare in fase di ricerca.

Il passo fondamentale consiste nell'inizializzazione della classe QueryParser con l'etichetta del Field che si prende in considerazione in fase di ricerca e dell'Analyzer che utilizzeremo per elaborare la keyword. L'Analyzer deve essere deve essere lo stesso usato in fase di indicizzazione.

Una volta inizializzato il QueryParser di Lucene, effettuiamo parsing della keyword e inseriamo il risultato di questa operazione in un oggetto Query di Lucene, che è poi restituito al chiamante. I passi necessari per utilizzare il QueryParser di Lucene non sono molti ma risultano molto efficaci.

Listato 5. Metodo che implementa la logica per effettuare il parsing della keyword

private Query parseKeyword(String keyword) 
{
  QueryParser parser = new QueryParser("content", new StandardAnalyzer());
  
  Query parsedString = null;
  
  try
  {
    parsedString = parser.parse(keyword);
  }
  catch (ParseException e)
  {
    e.printStackTrace();
  }
  
  return parsedString;
}

L'ultimo metodo che esaminiamo è quello necessario all'inizializzazione e l'esecuzione della ricerca: doTheSearch.

Il passo fondamentale è l'inizializzazione di una Directory di Lucene con la directory reale del file system che contiene gli indici. Effettuata questa inizializzazione, la risorsa può essere poi passata ad un opportuno IndexSearcher che, acquisiti gli indici, consentirà di effettuare la ricerca mediante il passaggio della Query ottenuta dal parsing della keyword fornita dall'utente.

I risultati della ricerca sono inseriti in un oggetto Hits che contiene tanti Document di Lucene quanti sono i documenti indicizzati restituiti dalla ricerca.

Dall'oggetto della classe Hits che contiene i risultati della ricerca è possibile ottenere, appunto, il Document di Lucene e le relative metainformazioni inserite in fase di ricerca. Bella fattispecie si recupera il contenuto del Field "filename", con il path completo della risorsa indicizzata.

Listato 6. La logica necessaria ad effettuare una ricerca sugli indici di Lucene

public void doTheSearch()  throws Exception
{
  Directory fsDir = FSDirectory.getDirectory(this.dir_for_index, false);
  IndexSearcher is = new IndexSearcher(fsDir);
  
  Hits hits = is.search(parseKeyword(query));
  
  System.out.println("(@Searcher) Found : " + hits.length() + " document(s) that matched query '" + this.query + "'");
  
  for (int i = 0; i < hits.length(); i++)
  {
    Document doc = hits.doc(i);
    System.out.println(" "+ (i + 1) +".Hit in : [" +doc.get("filename") + "]");
  }
}

Si può consultare il codice completo della classe Searcher, che implementa il macro-componente di front-end grazie alle API di Lucene.

Conclusioni

A partire da una generica architettura di motore di ricerca full-text, sono state approfondite le principali entità che entrano in gioco nelle fasi di elaborazioni per i processi di front-end e di back-end. Ogni entità è stata poi ricollocata nel contesto dell'implementazione proposta ed è stata associata ad una specifica classe di Lucene (secondo i compiti specifici e le necessità architetturali); nel caso dell'entità spider, assimilabile in parte con un Analyzer di Lucene, inoltre, è stata proposta un'implementazione ricorsiva di un generico esploratore di directory per un file system locale.

La lettura di quest'articolo consente di comprendere i concetti basilari di una tecnica di ricerca full-text, inoltre consente di conoscere le entità principali di una generica architettura per motori di ricerca full-text. Il valore aggiunto rispetto ai concetti teorici consta del passaggio all'implementazione mediante una libreria di API Java open-source e free quale è Lucene di Apache Jakarta.

Ti consigliamo anche