MokaByte 100 - 8bre 2005
 
MokaByte 100 - 8bre 2005 Prima pagina Cerca Home Page

 

 

 

Applicazioni Full-Text con Apache Lucene

Dopo aver studiato due forme di comunicazione basati sulla connessione, attraverso le classi URL, URLConnection e le socket TCP, è giunto il momento di illustrare l'UDP, un protocollo inaffidabile, privo di connessione che non garantisce né l'arrivo né l'ordine dei pacchetti, ma che d'altra parte fornisce una prestazione di gran lunga superiore a TCP. Come di consueto verranno illustrate le classi che implementano questo protocollo e alcuni esempi utili a chiarire il funzionamento del protocollo stesso

Le funzioni di trattamento di dati testuali sono una parte importante in un numero di applicazioni sempre più alto; dal CMS al più semplice software gestionale è infatti necessario permettere all'utente di interagire con delle risorse testuali. Da alcuni anni esiste un componente open-source che permette di realizzare soluzioni estremamente avanzate, Apache Lucene.

 

DBMS vs Full-Text
Possiamo evidenziare con grande efficacia le possibilità offerte da una soluzione full-text partendo da un confronto con le soluzioni presenti nei sistemi maggiormente utilizzati dagli sviluppatori, le basi di dati relazionali.
Di solito siamo abituati a lavorare con i DBMS basati sul modello relazionale che, come è ben noto, prevede una rappresentazione dei dati in tabelle, le cui righe rappresentano gli elementi inseriti e le cui colonne sono dei campi cui viene assegnato un valore; all'interno di queste tabelle possiamo effettuare operazioni di selezione ed ordinamento, basandoci sui valori di alcune colonne, che contengono valori numerici (o pseudonumerici).
Pensiamo, ad esempio, ad un sito Web di carattere giornalistico: il sistema di presentazione molto spesso si avvarrà di una base di dati, partendo dalla quale selezionerà gli articoli da presentare ai visitatori sulla base dell'identificativo con cui sono salvati nel database, per selezionare gli ultimi elementi pubblicati, oppure sulla base di colonne che specificano la categoria della notizia (per fornire più pagine, ad esempio una dedicata all'economia ed una allo sport).
Come è possibile notare dalle considerazioni appena fatte il database relazionale serve perlopiù a garantire la persistenza dei dati testuali, mentre poco può fare per quanto riguarda la loro elaborazione.
Volendo eseguire invece una ricerca all'interno dei testi, per trovare quelli che contengono un certo termini, possiamo utilizzare l'operatore SQL 'LIKE'; questa soluzione presenta però notevoli limiti, in termini di efficienza e di prestazioni.
Per fornire all'utente un sistema più avanzato di ricerca quasi tutti i DBMS forniscono dei moduli o delle estensioni full-text: attivando queste funzionalità è possibile accedere a servizi avanzati di ricerca testuale. Ad esempio in MySql, una volta definita l'indicizzazione full-text è possibile eseguire interrogazioni mediante le parole chiave MATCH() ed AGAINST().
Esistono però altre soluzioni che consentono di estendere le proprie applicazioni per fornire servizi avanzati di analisi testuale: tra questi prodotti software Apache Lucene è uno dei più apprezzati e potenti.
Ma quali sono i motivi concreti per cui può essere opportuno appoggiarsi a prodotti specifici per il trattamento di testi?
Le ragioni sono molteplici, ma preferisco soffermarmi sulle due che ritengo più importanti. In primo luogo molto spesso ci si può trovare a confrontarsi con banche dati testuali che contengono fino a diverse decine di migliaia di documenti: in questa situazione le prestazioni ottenute tramite un motore ad hoc sono nettamente superiori rispetto a quelle raggiungibili con le espansioni full-text dei DBMS commerciali. Come esperienza personale posso dire di aver visto situazioni in cui uno dei più diffusi DBMS enterprise ha mostrato evidenti limiti a livello di prestazioni, soprattutto se la base documentale si caratterizza per frequenti aggiunte e cancellazioni di documenti (comportando problemi di organizzazione ed ottimizzazione alla base dati).
L'altro motivo, come è prevedibile, risiede nelle possibilità offerte da una soluzione full-text pura come Lucene: ricerche complesse con operatori booleani, ricerche all'interno di una specifica frase (i termini della query devono apparire vicini o ad una distanza massima tra loro), ricerche con wildcard e così via.
Da aggiungere a questi vantaggi è sicuramente anche la licenza open source: non solo un risparmio economico, ma soprattutto la possibilità di accedere ad una comunità di utenti cui appartengono anche gli stessi sviluppatori del progetto. In questa maniera è possibile ottenere, semplicemente e senza dispendio, un aiuto (quasi una consulenza) sulle problematiche che si manifestano durante lo sviluppo di applicazione.

 

Una panoramica su Lucene
Una base di dati testuale Lucene viene chiamata, con un termine che può essere fuorviante, indice; riprendendo il confronto con i database relazionali possiamo dire che ogni indice contiene una sola tabella le cui righe altro non sono che i documenti da noi inseriti.
L'indice è normalmente contenuto in una cartella del filesystem, ma esistono altre possibilità, quali la creazione di un indice in RAM, utilizzato soprattutto per aumentare le prestazioni (attenzione però al limite imposto dalle dimensioni della memoria!).
All'interno dell'indice vengono inseriti documenti (istanze di org.apache.lucene.document.Document), a loro volta divisi in campi o colonne (Fields); come nei database esistono vari tipi di campi, anche se in questo caso il tipo di dati è lo stesso per ogni colonna (o quasi, in realtà esistono alcune piccole differenze).
I campi testuali veri e propri (come il titolo ed il corpo di un documento) verranno indicizzati , mentre altri campi, ad esempio l'identificatore del documento all'interno di un database, saranno solo salvati, per essere utilizzati come riferimento.
In pratica non sarà possibile eseguire ricerche in questi campi, ma solo accedere al loro valore, nei documenti ritornati da una query eseguita su un altro campo. Lo scenario di utilizzo prevede infatti nella quasi totalità dei casi l'affiancamento della base di dati full-text ad una relazionale.
Così facendo utilizzeremo Lucene per fornire un sistema di ricerca (e per presentarne i risultati), mentre quando l'utente desiderà visualizzare un documento (ad esempio cliccando sul titolo) accederemo ad una pagina popolata dal database relazionale; sfrutteremo Lucene per l'efficienza delle ricerche ed il DBMS per la maggiore velocità nella selezione di un singolo elemento (aiutato in questo magari da un opportuno meccanismo di caching, attivo e passivo).

 

Inserire un documento nell'indice
L'inserimento di un documento all'interno dell'indice comporta una scansione del testo, per individuare le parole presenti; tale procedimento prende il nome di analisi ed ovviamente è dipendente dalla lingua del testo. Lucene fornisce un analizzatore standard, mirato sulle necessità delle lingue occidentali più diffuse; in questo articolo verrà utilizzato, ma per noi italiani è necessaria una piccola precisazione: in questa maniera le parole apostrofate non verranno riconosciute correttamente (ad esempio un'ancora non verrà diviso in un ed ancora, ma resterà un unico termine).
Una volta che il documento (o meglio, i suoi campi) sono stati indicizzati questi sono disponibili per l'esecuzione di ricerche.
Il Listato 1 mostra come sia possibile aggiungere un documento all'indice, prendendo come scenario un ipotetico sistema CMS Web.

/* Listato1 */
import org.apache.lucene.index;

protected void addDocument(String aPath, String theSubject, String theBody, int anId){
//Apro l'oggetto attraverso il quale accedo all'indice
IndexWriter writer = new IndexWriter(aPath, new StandardAnalyzer(), true);
//Creo un nuovo documento
org.apache.lucene.document.Document aDoc=new org.apache.lucene.document.Document ();
//Aggiungo i campi opportuni
aDoc.add(Field.Text("subject", theSubject));
aDoc.add(Field.Text("body", theBody));
aDoc.add(Field.UnIndexed("id", anId));
//Salvo il documento nell'indice
writer.addDocument(aDoc);
//eseguo un'ottimizzazione dell'indice
writer.optimize();
//Infine chiudo l'accesso in scrittura all'indice
writer.close();
}

Le operazioni avvengono attraverso un'istanza di org.apache.lucene.index.IndexWriter. Il documento contiene tre campi, il titolo, il corpo del testo e l'identificativo numerico del documento all'interno della base di dati relazionale.
Come abbiamo già detto, i campi possono essere trattati in maniera diversa, a seconda della funzione che rivestono. I campi testuali sono normalmente analizzati (scomposti in una lista di termini) e quindi indicizzati, mentre il campo identificatore (che contiene solamente un riferimento al corrispondente elemento della base di dati relazionale) deve essere solamente salvato all'interno dell'indice, per essere usato come riferimento tra l'indice full-text e una base di dati relazionale.
Nel listato1, come possiamo vedere utilizziamo i metodi statici della classe Field per creare i campi adatti alle nostre esigenze (Field.Text(), Field.UnIndexed()).
Il listato 2, mostra uno scenario differente, in cui l'indicizzazione avviene in una directory del filesystem.

/** Listato 2 **/
import java.io;
import org.apache.lucene.index;

public static void index(String aPath, String indexPath) throws IOException {
File file=new File(aPath);
if (!file.exists() || !file.isDirectory()) {
throw new IOException("Verificare l'esistenza della directory " + file + "!");
}

IndexWriter writer = new IndexWriter(indexPath, new StandardAnalyzer(), true);
indexDirectory(writer, file);
writer.close();
}

private static void indexDirectory(IndexWriter writer, File dir) throws IOException {
File[] files = dir.listFiles();

for (int i=0; i < files.length; i++) {
File f = files[i];
if (f.isDirectory()) {
//Esplora la sottodirectory
indexDirectory(writer, f);
}
else if (f.getName().endsWith(".txt")) {
indexFile(writer, f);
}
}
}

Un'interessante osservazione, relativa sia al primo che al secondo listato, ci consente di notare che in entrambi è presente la seguente istruzione:

writer.optimize();

L'indicizzazione di documenti è ottimizzata da Lucene, in maniera tale da aumentare le prestazioni senza però interferire con eventuali operazioni contemporanee di ricerca. Il metodo IndexWriter.optimize() serve appunto a riorganizzare l'indice, per aumentare le prestazioni delle future ricerche (e scritture). Nel primo caso, avendo ipotizzato di trovarci in un CMS, possiamo supporre che gli inserimenti di nuovi documenti siano relativamente sporadici, mentre nel secondo ci troviamo di fronte ad una sorta di operazione batch, pertanto effettuiamo l'ottimizzazione solamente al termine di tutti gli inserimenti (o di una tranche di questi).
La gestione dell'accesso concorrente (in scrittura e lettura, quindi indicizzazione e ricerca) ad un indice Lucene è piuttosto articolata, anche se di facile comprensione: è possibile un solo accesso in scrittura, ma che può essere condiviso da vari processi logici (o thread) di indicizzazione, mentre non sono poste limitazioni alle ricerche contemporanee.

 

La ricerca
E' possibile eseguire ricerche sull'indice in due modalità distinte: utilizzando direttamente le API di ricerca oppure utilizzando una sorta di linguaggio di interrogazione. Il primo metodo prevede l'utilizzo delle classi che derivano da Query e che si trovano nel package org.apache.lucene.search, per brevità aggiungerò solo alcune note su queste classi più avanti, per informazioni più approfondite si può leggere la documentazione.
Nel listato 3 possiamo trovare un esempio di come sia possibile utilizzare la classe QueryParser per eseguire una semplice ricerca. La sintassi della stringa di ricerca da passare al metodo QueryParser.parse() è disponibile in questa pagina.

/** Listato 3 **/
public void search(String aDirectory, String q) throws Exception{
IndexSearcher searcher = new IndexSearcher(aDirectory);

Query query = QueryParser.parse(q, "body", new StandardAnalyzer());
//questo metodo esegue la ricerca nell'indice
Hits hits = searcher.search(query);
System.out.println("La ricerca ha restituito "+ hits.length() +" elementi.");
int i, c;
c= hits.length();
//ciclo sui risultati della ricerca
for (i = 0; i < c;i++) {
Document doc = hits.doc(i);
System.out.println(doc.get("subject") + " - Score: " + hits.score(i));
}
//chiudo l'accesso in lettura all'indice
searcher.close();
}

L'accesso in lettura all'indice avviene attraverso la classe IndexSearcher. Il metodo QueryParser.parse() ci permette di ottenere direttamente un oggetto Query; è necessario passare a questo metodo la stringa di ricerca (che naturalmente può essere composta da più parole), un campo in cui eseguire la ricerca ed un analizzatore (usato per trattare la query string, che sostanzialmente è elaborata come se fosse un documento da inserire nell'indice).
Il metodo IndexSearcher.search() restituisce un oggetto di tipo Hits, che contiene i risultati. Il codice mostra come accedere al titolo dei documenti restituiti ed anche come stampare un punteggio che rappresenta in forma numerica l'attinenza del documento alla query. Questo punteggio è calcolato sulla base di un'analisi semantica della query, cercando cioè di interpretare l'importanza che hanno i termini nel fornire il significato del testo.
Per evitare di incappare in errori abbastanza fastidiosi e che potrebbero rivelarsi di difficile interpretazione è bene avere presente che i documenti all'interno della collezione Hits non sono più accessibili quando si è chiamato il metodo IndexSearcher.close(), pertanto è necessario estrarre i dati necessari alla presentazione dei risultati prima di procedere a questa chiamata.
Naturalmente utilizzando le API è possibile eseguire ricerche più complesse, ad esempio per termini vicini tra loro(PhraseQuery), oppure utilizzando wildcards. Per un'introduzione a queste funzionalità rimando ai documenti presenti nella bibliografia in fondo all'articolo.

 

Conclusioni
La necessità di trattare in maniera opportuna dati testuali appartiene ormai alla maggior parte dei progetti software, soprattutto grazie all'enorme sviluppo di Internet. In questo articolo è stato presentato un componente estremamente avanzato, che permette di realizzare con facilità soluzioni anche complesse. Per testimoniare la potenza di Lucene posso aggiungere che su questa base è stato costruito un progetto, Nutch, che ha creato un'infrastruttura tecnologica assolutamente in grado di competere con quelle dei più diffusi motori di ricerca commerciali.
Per rimanere nel campo delle soluzioni che lo sviluppatore si trova a dover realizzare più frequentemente segnalo, ad esempio, la possibilità di estrarre da un indice documenti in base alla similitudine: in questa maniera un sistema di commercio elettronico potrà mostrare prodotti simili a quello correntemente visualizzato dall'utente (sulla base delle descrizione), oppure consultando una banca dati sarà possibile estrarre articoli simili per significato ad un testo scelto dall'utente.
Inoltre è possibile realizzare sistemi di correzione ortografica, simili a quelli presenti negli elaboratori di testi.
Naturalmente lo scopo di questo articolo non è quello di mostrare le soluzioni più avanzate, ma avvicinare all'utilizzo di tale tecnologia. Credo che a questo punto per il lettore non sarà difficile realizzare le prime applicazioni basate su Lucene, magari avvalendosi dei documenti riportati nella bibliografia. Tra questi mi sento di raccomandare la lettura e la frequentazione della mailing list general@lucene.apache.org, attraverso la quale è possibile accedere ad esempi o best practices.

 

Bibliografia
Lucene, http://lucene.apache.org
Lucene Wiki, http://wiki.apache.org/jakarta-lucene
E. Hatcher, O. Gospodnetic, Lucene in Action, Manning, 2004
Mailing List general@lucene.apache.org (archivi su http://mail-archives.apache.org/mod_mbox/lucene-general/)

Lorenzo Viscanti è un ingegnere informatico e si occupa soprattutto di problematiche relative al trattamento di testi, quali realizzazioni di motori di ricerca, text clustering e text mining.