Come è possibile realizzare una funzionalità di ricerca “full text” in una applicazione? Gli autori lo spiegano utilizzando Hibernate Search: vengono affrontati certi problemi insiti nella configurazione di default e vengono proposte utili soluzioni.
Introduzione
In quest’articolo presentiamo alcune soluzioni di problemi che sorgono frequentemente al momento in cui si decida di introdurre funzionalità di ricerca “full-text” nella propria applicazione. La tecnologia utilizzata è Hibernate, espanso con Hibernate Search (che a sua volta è basato su Apache Lucene), un nuovo modulo che è rilasciato come sempre in LGPL dall’Hibernate team.
In particolare, vedremo come certe problematiche non siano risolvibili in modo ovvio utilizzando Hibernate Search come proposto di default e forniremo espansioni e soluzioni alternative. Tutti i sorgenti forniti sono “free as in free beer” (LGPL).
Per la comprensione approfondita dei concetti, si presuppone una certa familiarità con Hibernate, con le annotazioni e una conoscenza superficiale di Lucene.
La differenza tra ricerche su database e ricerche full-text
Vi sono circostanze in cui le ricerche di tipo “like” su database non sono sufficienti. Ci siamo ormai familiarizzati a circostanze smart di ricerca, grazie ai noti motori di ricerca, in primis Google.
Per esempio, si può ricercare “circostanze familiari”, e si troverà questo articolo: nel caso in cui questo articolo sia su database (anche se la soluzione qui proposta vale anche per dati su file) e si utilizzi query su DB per la ricerca, non sarà immediato e generalizzabile trovare questo risultato; ancora peggio se ricerco “generalizzabili trovo” e voglio che si trovi ciò che contiene “generalizzabile trovare”. Il secondo caso introduce le problematiche di tokenising e stemming legate a una lingua che sono affrontate da motori di ricerca come Apache Lucene, ma non in genere dai database. Per tokenising si intende l’estrazione di parti di testo (token) da uno stream, per esempio eliminando segni di interpunzione; per stemming si intende il processo (non banale) di riduzione delle parole alla radice grammaticale (= “stem”); per una introduzione si veda “Lucene in Action” [8] nei Riferimenti.
E allora… come si ottengono questi fantastici risultati?
Hibernate Search: un esempio semplice di utilizzo
Hibernate Search è una estensione del framework di persistenza Hibernate che integra le funzionalità di ricerca full-text fornite da Lucene.
La caratteristica più interessante è la semplicità e uniformità con cui si integra il full-text con la gestione della persistenza dei dati; Hibernate Search si occupa di (quasi) tutta la gestione, una volta che sia stato correttamente configurato. Tale configurazione avviene per via dichiarativa utilizzando le annotazioni, in modo simile a quanto si fa con la persistenza. Si noti che è possibile utilizzare Hibernate Search anche se non si usano le annotazioni per la persistenza delle classi, cioè se si utilizzano i classici .hbm.xml files.
Vi rimandiamo al manuale di Hibernate Search per i dettagli (si veda [2] nei Riferimenti).
Nel nostro codice di esempio, abbiamo una classe persistente, “Issue”, sulla quale vogliamo fare ricerche full-text in certi campi, per esempio sulla proprietà “description”, che in questo caso supporta contenuti di lunghezza arbitraria.
... @Lob @Column(name = "descriptionx") @Field(name = "content", index = org.hibernate.search.annotations.Index.TOKENIZED, store = Store.NO) @Boost(3) public String getDescription() { return description; } ...
I tag di Hibernate Search da rilevare nel nostro esempio (ve ne sono molti altri) sono:
- @Field: si dichiara che Hibernate Search deve indicizzare questo campo, tokenizzandolo (Index.TOKENIZED), e di non scrivere nell’indice il contenuto non tokenizzato della proprietà (Store.NO), il che permette di limitare la dimensione dell’indice.
- @Boost: si dichiara che questa proprietà ha un “peso” triplo rispetto alle altre proprietà indicizzate.
Come si attiva l’indicizzazione? Come si configura lo stemming? In fase di configurazione di Hibernate, va configurato anche Hibernate Search: ecco un esempio da cui trarre ispirazione:
AnnotationConfiguration hibConfiguration = ...; hibConfiguration.getProperties().put("org.hibernate.worker.execution","async"); hibConfiguration.getProperties().put("org.hibernate.worker.thread_pool.size","5"); hibConfiguration.setListener("post-update", new FullTextIndexEventListener()); hibConfiguration.setListener("post-insert", new FullTextIndexEventListener()); hibConfiguration.setListener("post-delete", new FullTextIndexEventListener()); hibConfiguration.getProperties().put( "hibernate.search.default.optimizer.operation_limit.max", "1000"); hibConfiguration.getProperties().put( "hibernate.search.default.optimizer.transaction_limit.max", "100"); hibConfiguration.getProperties().put( "hibernate.search.default.indexBase", indexPath); hibConfiguration.getProperties().put(org.hibernate.search.Environment.ANALYZER_CLASS, MyAnalyzer.class.getName());
Qui MyAnalyzer è un Lucene analyzer, che conterrà un tokenizer ed uno stemmer specifici per una lingua. In caso di applicazioni internazionalizzate, dovranno essere adottate soluzioni più raffinate.
Come si vede dalla configurazione del lifecycle (post-update etc.) Hibernate Search funziona grazie a dei listener configurati per aggiornare l’indice di Lucene nei momenti opportuni.
Vale la pena far presente che alcune caratteristiche tipiche dell’indicizzazione full-text differiscono dal comportamento a cui ci hanno abituati i dbms. Ad esempio, in caso di cancellazione di una Issue, Hibernate Search interviene (avrete certamente notato il “post-delete”) e marca il “record” sull’indice di Lucene come eliminato, ma l’eliminazione reale avverrà solo dopo l’ottimizzazione dell’indice.
Vediamo un esempio molto semplice su come ricercare in tale indice:
FullTextSession fullTextSession = Search.createFullTextSession(myHibernateSession); Transaction tx = fullTextSession.beginTransaction(); QueryParser parser = new QueryParser("description", new MyAnalyzer()); Query query = parser.parse( "Java rocks!" ); org.hibernate.Query hibQuery = fullTextSession.createFullTextQuery( query, Issue.class ); List result = hibQuery.list(); tx.commit(); myHibernateSession.close();
Si noti l’uso dello stesso analyzer anche in fase di ricerca: dato che utilizzando uno stemmer specifico per una lingua, sull’indice vi sono solo le radici secondo tale lingua; nel ricercare, occorre che dai termini scritti nella ricerca siano estratte e quindi ricercate sull’indice le stesse radici.
Per esempio, se si è indicizzato un documento in inglese, e quindi si è riconosciuta la lingua del documento (he he he – si veda dopo per come fare), e si è utilizzato un analyzer per l’inglese, il termine “compiling” è stato indicizzato come “compil_”, per cui se si ricerca “compiled”, l’analyzer inglese lo riporterà a “compil_” e troverà e peserà un matching. Se si ricercasse in italiano, “compiled” rimarrebbe tale, e quindi non sarebbe trovato.
Un uso più evoluto di Hibernate Search: indicizzazione di file
Supponiamo di voler indicizzare non solo il contenuto standard persistente dei vostri oggetti, come nell’esempio precedente “description” di Issue, ma anche riferimenti esterni a file, come documenti PDF, contenuti HTML etc. . Un primo approccio è di indicizzare tali proprietà esattamente come le altre: in questo caso l’estrazione del testo avverrà contestualmente al salvataggio degli oggetti: vediamo un esempio:
... public String getAttachmentFileName() { ... } @Field(name = "content", index = org.hibernate.search.annotations.Index.TOKENIZED, store = Store.NO) public String getAttachmentContents() { File file = new File(getAttachmentFileName()); return TextExtractor.getContents(file); } ...
In questo caso, la proprietà persistente è il nome del file, e il metodo non persistente ma usato solo per l’indicizzazione è getAttachmentContents().
Al momento in cui si salva una issue, verrà indicizzato in modo sincrono il contenuto dell’attachment, per cui l’operazione rimarrà sospesa fino al completamento della lettura del file. L’indicizzazione sarà comunque asincrona grazie all’impostazione
hibConfiguration.getProperties().put("org.hibernate.worker.execution","async");
ma questo non è sufficiente, perch� la lettura del file rimane sincrona. La classe TextExtractor, che vedremo in seguito, provvede a estrarre plain text da diversi formati (.pdf, .doc, .html etc.)
Si può migliorare questo approccio con un “Hibernate Search custom field bridge” e un’implementazione “lazy” di campo indicizzabile di Lucene.
Riprendiamo la nostra classe esempio, aggiungendo una proprietà da indicizzare di tipo “PersistentFile”, che è un wrapper di una nozione astratta di file che può essere implementata su file system, database blob, SVN, FTP etc., e che è resa persistente mediante un Hibernate custom type, come usuale per chi usa Hibernate per la persistenza.
... @Lob @Column(name = "descriptionx") @Field(name = "content", index = org.hibernate.search.annotations.Index.TOKENIZED, store = Store.NO) @Boost(3) public String getDescription() { return description; } ... @Type(type = "org.jblooming.ontology.PersistentFileType") @Column(name = "attachment") @Field(name = "content", index = org.hibernate.search.annotations.Index.TOKENIZED, store = Store.NO) @FieldBridge(impl = PersistentFileBridge.class) public PersistentFile getAttachment() { return attachment; } ...
Si noti qui la parte cruciale che è @FieldBridge(impl = PersistentFileBridge.class)
Utilizziamo qui come estrattore un comodo tool per PDF, PDFBox, scaricabile da http://www.pdfbox.org. Si possono aggiungere altri estrattori, per esempio per HTML, .doc etc. come vedremo in seguito.
Questa è la nostra classe “Hibernate Search custom field bridge”:
public class PersistentFileBridge implements FieldBridge { public void set(String name, Object value, Document document, Field.Store store, Field.Index index, Float boost) { if (value != null) { PersistentFile pf = (PersistentFile) value; LazyField field = new LazyField(name, pf, store, index, boost); document.add(field); } } }
Come si può intuire dal nome stesso dell’interfaccia, questa classe serve per collegare un oggetto del “nostro mondo” con un “Field” che Lucene utilizza per tutto il processo di indicizzazione (parsing, tokenizing, stemming, indexing). Essendo la nostra classe indicizzata annotata con @FieldBridge(impl = PersistentFileBridge.class), Hibernate Search sa che deve utilizzare PersistentFileBridge per indicizzare la nostra proprietà, e quindi quando la proprietà verrà salvata, verrà anche chiamata PersistentFileBridge.
Tornando al codice sopra, il metodo “set” riceve il valore del campo come generico object, si provvede quindi all’opportuno cast a “PersistentFile” ed a costruire con questo un LazyField che abbiamo scritto appositamente, di cui si riporta il costruttore:
public LazyField(String name, PersistentFile persistentFile, Field.Store store, Field.Index index, Float boost) { super(name, store, index, Field.TermVector.NO); //set fondamentale!!!: istruisce Lucene a chiamare "stringValue()" //solo al momento dell'indicizzazione lazy = true; if ( boost != null ) setBoost( boost ); this.persistentFile = persistentFile; }
e qui si può vedere il metodo “critico”, quello “lento” che recupera il file (o meglio uno stream) e lo passa all’estrattore di testo, ma che grazie all’impostazione “lazy=true” viene chiamato solo al momento dell’effettiva indicizzazione:
public String stringValue() { if (content==null) try { content = TextExtractor.getContent(persistentFile); } catch (IOException e) { // you may implement something smarter throw new RuntimeException(e); } return content; }
Il nostro TextExtractor avrà una serie di switch del tipo:
... if (pf.getOriginalFileName().toLowerCase().endsWith(".pdf")) { PDFTextStripper stripper = new PDFTextStripper(); PDDocument document = PDDocument.load(inputStream); stripper.writeText(document, sw); content = content + sw.getBuffer().toString(); document.close(); } ...
Riassumendo: con questo approccio si riesce ad indicizzare allegati anche molto grandi senza dover attendere al momento del salvataggio l’estrazione del testo dal file.
Se avete tutto chiaro fino a questo punto potete andare oltre …
Hibernate Search: l’uso più complesso
Esaminiamo adesso un caso più complesso, ma forse più frequente in una applicazione reale.
Vogliamo ottenere una soluzione che soddisfi questi requisiti:
- Semplicità. Vogliamo che il codice sia mantenibile: quindi configurazione della persistenza e dell’indicizzazione “nello stesso posto”.
- Dati Lob. Poter gestire oggetti con proprietà lob, link a file esterni, file remoti.
- Multi documenti. Poter gestire degli oggetti “document”, che abbiano a loro volta allegati, ma che condivdano il life-cycle dell’oggetto principale. Non solo, cercare negli allegati e poter risalire all’oggetto principale. Per esempio una Issue e un allegato che contiene lo stacktrace che documenta il bug.
- Multi Lingua. L’indicizzazione di documenti di lingue differenti. È comune avere in uno stesso corpus documenti di lingue diverse (italiano, inglese francese etc.) e potersi avvalere dello stemming.
- Document formats. Avere documenti in formati diversi.
- Paging. Mostrare I risultati paginati
- Sicurezza. Poter utilizzare l’indicizzazione full-text in un ambiente con restrizioni di sicurezza.
Soluzione
Vediamo punto per punto quali soluzioni adottare
1. Semplicità
Abbiamo scelto di utilizzare le annotazioni, sia per la parte di persistenza che per la parte di indicizzazione. Hibernate Search ci permette di mantenere localizzate queste informazioni. Inoltre utilizzando questa unica soluzione (invece che utilizzare Lucene separatamente) ottengo di avere lo stesso lifecycle per gli oggetti e gli indici (pur con le limitazioni viste in precedenza).
Pensate a quanto lavoro ci verrà risparmiato in caso di una cancellazione in cascata Issue -> n allegati!
2. Dati Lob
Come abbiamo visto sopra la gestione di campi lob è già integrata in Hibernate Search.
3. Multi documenti
Questa caratteristica necessita di un raffinamento rispetto alla soluzione prospettata precedentemente: la soluzione prevede la realizzazione di un semplice job schedulato che gestisce una coda di documenti (modellati dalla classe DataForLucene) che saranno indicizzati in modo asincrono.
Questo approccio ci permette di gestire con più flessibilità sia il numero di eventuali allegati ad un documento, sia come comporre dati provenienti da più proprietà dell’oggetto. In pratica è chiaro che l’indicizzazione di un campo contenente molto testo (un campo note ad es.) si presta bene alla ricerca full-text, ma se vogliamo nella stessa ricerca includere anche i dati “classici” di un oggetto (come nome, cognome, tipo etc. ) è molto comodo implementare un metodo che compone le proprietà in un’unica stringa.
Quindi nel nostro esempio la classe Issue sarà arricchita e annotata come segue (per semplicità diciamo che la ns. Issue preveda due soli allegati, ma il metodo è valido in generale):
... @Transient @Fields({ @Field(name = "fullcontent", index = org.hibernate.search.annotations.Index.TOKENIZED, store = Store.NO, analyzer = @Analyzer(impl = StopAnalyzer.class)), @Field(name = "content", index = org.hibernate.search.annotations.Index.TOKENIZED, store = Store.NO) }) private String getContentForIndexing() { if (getAllegato1() != null) { IndexingMachine.addToBeIndexed(this, getArea().getId(),getAllegato1 ()); } if (getAllegato2() != null) { IndexingMachine.addToBeIndexed(this, getArea().getId(),getAllegato2 ()); } return getAbstractForIndexing(); } @Transient public String getAbstractForIndexing() { return getDescription() + (getNotes() != null ? " " + getNotes().getText() : "") + (getTask() != null ? " " + getTask().getDisplayName() : ""); } ...
Come si può notare, abbiamo aggiunto un metodo getContentForIndexing() che NON è persistente per Hibernate (@Transient), ma è indicizzato con Search.
L’annotazione di questo metodo impone al search di creare due campi nell’indice “fullcontent” e “content”. Il primo è indicizzato usando lo StopAnalyzer (che non tiene conto della lingua), il secondo utilizza quello specificato in fase di configurazione. Entrambi i campi non mantengono i dati originali (Store.NO), per non appesantire l’indice.
In questo modo, ho un indice con due “colonne” una contenente i dati grezzi (“compiling” per l’esempio di prima), l’altra con i dati manipolati dallo stemmer (“compil_” tanto per intenderci).
Con questa configurazione, al salvataggio di una Issue, Hibernate Search chiamerà (N.B.: in maniera sincrona al momento del salvataggio) il metodo getContentForIndexing(). Qusto metodo ha una duplice funzione: accodare gli allegati per l’indicizzazione asincrona e restituire un abstract per i dati di base della Issue; a questo scopo è stato aggiunto in metodo getAbstractForIndexing(), una specie di toString() evoluto, che può servire per esempio quando ottenuto il documento ricercato, si vuole mostrare il testo trovato in un contesto più ampio.
La IndexingMachine sopra citata opera su thread separato e provvede all’aggiornamento dell’indice operando sulla coda dei documenti da indicizzare, indipendentemente da Hibernate Search, di cui emula il funzionamento. IndexingMachine ha una implementazione molto semplice:
... /** * Written by * Roberto Bicchierai rbicchierai@open-lab.com * Pietro Polsinelli ppolsinelli@open-lab.com * for the Teamwork Project Management application - http://www.twproject.com */ public class IndexingMachine extends TimerTask { public static IndexingMachine machine = new IndexingMachine(); public long tick = 10000; private boolean stopped = true; private boolean indexing = false; private static List toBeExecuteds = new ArrayList(); private IndexingMachine() { } public static void start() { machine.stopped = false; if (!machine.indexing) { machine.run(); } } public static void stop() { machine.stopped = true; } public void run() { if (toBeExecuteds.size() > 0) { DataForLucene ij = toBeExecuteds.get(0); synchronized (toBeExecuteds) { toBeExecuteds.remove(0); } indexing = true; ij.indexMe(); indexing = false; } if (toBeExecuteds.size() > 0) tick = 20; else tick = 10000; if (!machine.stopped && !machine.indexing) { Timer t = new Timer(false); machine = new IndexingMachine(); machine.stopped = false; t.schedule(machine, tick); } } public static void addToBeIndexed(Identifiable i, Serializable areaId, PersistentFile pf) { DataForLucene dfl = new DataForLucene(); dfl.clazz = i.getClass(); dfl.id = i.getId(); dfl.areaid = areaId; dfl.pf = pf; if (!toBeExecuteds.contains(dfl)) synchronized (toBeExecuteds) { toBeExecuteds.add(dfl); } } public static int getQueueSize() { return toBeExecuteds.size(); } public static boolean isRunning() { return !machine.stopped; } public static boolean isIndexing() { return machine.indexing; } }
Da notare che il metodo statico addToBeIndexed(…) costruisce un oggetto DataForLucene che, come vedremo di seguito, costruisce un record nell’indice compatibilmente a quanto fa Hibernate Search.
Ecco la classe DataForLucene:
/** * Written by * Roberto Bicchierai rbicchierai@open-lab.com * Pietro Polsinelli ppolsinelli@open-lab.com * for the Teamwork Project Management application - http://www.twproject.com */ ... import org.apache.lucene.analysis.snowball.SnowballAnalyzer; import org.apache.lucene.document.Document; import org.apache.lucene.document.Field; import org.apache.lucene.index.IndexWriter; import org.hibernate.search.FullTextSession; import org.hibernate.search.Search; import org.hibernate.search.SearchFactory; import org.hibernate.search.engine.DocumentBuilder; import org.hibernate.search.store.DirectoryProvider; import org.jblooming.ontology.Identifiable; ... public class DataForLucene { public Serializable id; public Class<? extends Identifiable> clazz; public Serializable areaid; public PersistentFile pf; public long expiry; // Questo è l'entry point per l'indicizzazione public void indexMe() { PersistenceContext pc = null; IndexWriter w = null; try { // si recupera la sessione di Hibernate pc = HibernateFactory.newFreeSession(); // si recupera la sessione del fulltext engine per ottenere la configurazione FullTextSession fullTextSession = Search.createFullTextSession(pc.session); SearchFactory searchFactory = fullTextSession.getSearchFactory(); // si recupera la classe reale dell'oggetto (non il proxy) clazz = (Class<? extends Identifiable>) Class.forName(PersistenceHome.deProxy(clazz.getName())); // si recupera l'indice relativo alla classe DirectoryProvider[] provider = searchFactory.getDirectoryProviders(clazz); Directory directory = provider[0].getDirectory(); // si estrae il testo String content = TextExtractor.getContent(pf, pc); // si cerca di indovinare la lingua con cui è scritto String guessedLanguage = IndexingBricks.guess(content); // si istanzia un index writer con lo stemmer per la lingua w = new IndexWriter(directory, true, new SnowballAnalyzer(IndexingBricks.stemmerFromLanguage(guessedLanguage))); String abstractOfContent = JSP.limWr(content, 5000); // si crea un'entry nell'indice di Lucene Document doc = new Document(); // si creano I campi per I dati che vogliamo indicizzare Field classField = new Field(DocumentBuilder.CLASS_FIELDNAME, clazz.getName(), Field.Store.YES, Field.Index.UN_TOKENIZED); doc.add(classField); // il fondamentale campo id Field docidField = new Field("id", id.toString(), Field.Store.YES, Field.Index.UN_TOKENIZED); doc.add(docidField); Field contentField = new Field("content", content, Field.Store.NO, Field.Index.TOKENIZED); doc.add(contentField); Field absField = new Field("abstract", abstractOfContent, Field.Store.COMPRESS, Field.Index.UN_TOKENIZED); doc.add(absField); Field fullcontentField = new Field("fullcontent", content, Field.Store.NO, Field.Index.TOKENIZED); doc.add(fullcontentField); Field pfField = new Field("persistentFile", pf.serialize(), Field.Store.YES, Field.Index.UN_TOKENIZED); doc.add(pfField); // per dettagli vedi Sicurezza: Field areaId = new Field("area.id", "" + areaid, Field.Store.YES, Field.Index.UN_TOKENIZED); doc.add(areaId); Field language = new Field("language", guessedLanguage, Field.Store.YES, Field.Index.UN_TOKENIZED); doc.add(language); w.addDocument(doc); } catch (Throwable throwable) { Tracer.platformLogger.error(throwable); } finally { if (w != null) try { w.close(); } catch (IOException e) { Tracer.platformLogger.error(e); } if (pc != null) try { pc.commitAndClose(); } catch (PersistenceException e) { Tracer.platformLogger.error(e); } } } ... }
Il record (o meglio i record) di indice creato è compatibile con Hibernate Search, quindi la ricerca agirà sui dati della Issue e sui suoi allegati, ma in ogni caso dal risultato potrò risalire alla Issue.
Inoltre, in caso di cancellazione della Issue saranno eliminati dall’indice i tre record: uno per i dati di base dell’Issue (generati in modo standard da Search), ed i due relativi agli allegati (generati dalla nostra IndexingMachine).
4. Multi Lingua
Come abbiamo visto, è necessario conoscere la lingua in cui un documento è scritto per indicizzarlo correttamente. Una volta trovata la lingua si può istanziare l’analyzer corretto.
Ci possono essere molti sistemi per recuperare la lingua di un documento: usualmente si ricorre ai metadati (.pdf, .doc etc.) oppure alla configurazione del sistema. Purtroppo, è comune utilizzare browser/editor/sistema operativo in una lingua e generare documenti in un’altra. Inoltre, nel caso in cui i dati vengano da campi testo del DB, queste informazioni sono ancor più incerte.
Un sistema pratico prevede di “indovinare” la lingua analizzando il testo. Abbiamo trovato una soluzione molto semplice ed efficace basata su TCatNG [3] (rilasciata da pt.tumba.ngram in licenza BSD), dal quale abbiamo estratto un set molto ridotto, 10 classi invece di 117).
Per rendere “trovabili” le informazioni anche quando ricerco da una lingua (p.e. Tedesco) un documento scritto in un’altra lingua (p.e. Inglese), si indicizza due volte il testo, come permesso da Hibernate Search: una volta usando un analyzer completo per la lingua sul campo “fullcontent” dell’indice, e un’altra senza usare lo stemming, con per esempio il semplice StopAnalyzer sul campo “content” dell’indice che appunto non effettua stemming.
Esercizio per il lettore: così facendo cosa accade quando ricerco dal tedesco la parola “Telefunken” (che sarà stemmata in “Telefunk_”) in un documento in inglese che contiene “I have an old Telefunken stereo”?
5. Document formats
Se si dà la possibilità di allegare documenti, si dovrà essere pronti ad estrarre testo da moooolti formati diversi. Come al solito vi proponiamo una soluzione che punta ad essere semplice ed efficace ispirata da Luis [6].
Per chi desidera fare di più, il progetto Nutch [7] costituisce un’ottima base di partenza.
Ecco una implementazione di un estrattore multiformato basato su varie librerie LGPL.
import com.opnlb.fulltext.extractors.ExcelIndexer; import com.opnlb.fulltext.extractors.PPTIndexer; import org.jblooming.ontology.PersistentFile; import org.jblooming.persistence.hibernate.PersistenceContext; import org.jblooming.tracer.Tracer; import org.jblooming.utilities.Zipping; import org.pdfbox.pdmodel.PDDocument; import org.pdfbox.util.PDFTextStripper; import org.textmining.text.extraction.WordExtractor; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.w3c.dom.Text; import org.w3c.tidy.Tidy; import javax.swing.text.DefaultStyledDocument; import javax.swing.text.rtf.RTFEditorKit; import java.io.*; import java.util.Set; /** * (c) Open Lab - www.open-lab.com * Date: Oct 23, 2007 * Time: 5:48:16 PM */ public class TextExtractor { public static String getContent(PersistentFile pf) { return getContent(pf, null); } public static String getContent(PersistentFile pf, PersistenceContext pc) { String content = pf.getOriginalFileName(); InputStream inputStream = null; try { inputStream = pf.getInputStream(pc); String fileName = pf.getOriginalFileName().toLowerCase(); content = extractFromStream(fileName, inputStream); } catch (Throwable e) { Tracer.platformLogger.error(e); } finally { if (inputStream != null) try { inputStream.close(); } catch (IOException e) { } } return content; } private static String extractFromStream(String fileName, InputStream inputStream) throws Exception { StringWriter sw = new StringWriter(); String content =""; if (fileName.endsWith(".pdf")) { PDFTextStripper stripper = new PDFTextStripper(); PDDocument document = PDDocument.load(inputStream); stripper.writeText(document, sw); content = content + sw.getBuffer().toString(); document.close(); } else if (fileName.endsWith(".doc")) { WordExtractor we = new WordExtractor(); content = we.extractText(inputStream); } else if (fileName.endsWith(".htm") || fileName.endsWith(".html")) { Node root = getDOMRoot(inputStream); content = getTextContentOfDOM(root); } else if (fileName.endsWith(".ppt")) { PPTIndexer reader = new PPTIndexer(); content = reader.getContent(inputStream); } else if (fileName.endsWith(".xls")) { ExcelIndexer reader = new ExcelIndexer(); content = reader.getContent(inputStream); } else if (fileName.endsWith(".rtf")) { DefaultStyledDocument sd = new DefaultStyledDocument(); RTFEditorKit kit = new RTFEditorKit(); kit.read(inputStream, sd, 0); content = sd.getText(0, sd.getLength()); } else if (fileName.endsWith(".zip") || fileName.endsWith(".war") || fileName.endsWith(".jar")) { Set files = Zipping.getZipContents(inputStream); for (File file : files) { FileInputStream fis = new FileInputStream(file); content = content + extractFromStream(file.getName(),fis); fis.close(); } } else if (fileName.endsWith(".txt") || fileName.endsWith(".log")) { StringBuffer sb = new StringBuffer(); BufferedReader br = new BufferedReader(new InputStreamReader(inputStream)); String line; while ((line = br.readLine()) != null) { sb.append(line); sb.append(" "); } content = sb.toString(); } return content; } private static Node getDOMRoot(InputStream is) { Tidy tidy = new Tidy(); tidy.setQuiet(true); tidy.setShowWarnings(false); org.w3c.dom.Document doc = tidy.parseDOM(is, null); return doc.getDocumentElement(); } private static String getTextContentOfDOM(Node node) { NodeList children = node.getChildNodes(); StringBuffer sb = new StringBuffer(); for (int i = 0; i < children.getLength(); i++) { Node child = children.item(i); switch (child.getNodeType()) { case Node.ELEMENT_NODE: sb.append(getTextContentOfDOM(child)); sb.append(" "); break; case Node.TEXT_NODE: sb.append(((Text) child).getData()); break; } } return sb.toString(); } }
6. Paging
Dato che Search è compatibile con la paginazione standard di Hibernate potete utilizzare ad esempio quella da noi proposta qua:
http://www.hibernate.org/314.html
7. Sicurezza
Questo è un aspetto piuttosto complicato da integrare con le ricerche fulltext. In effetti siamo abituati ad aggiungere le clausole di sicurezza nei metodi degli oggetti e nelle query hql, ma quando si interrogano gli indici di Lucene non è detto che si riesca a trasportare la stessa logica.
Può essere di aiuto la capacità di Hibernate Search di includere in un indice dati provenienti da un altro oggetto persistente. Nell’esempio, le nosgtre Issue sono divise per Area che rappresentano delle sandbox. È possibile includere i dati dell’Area utilizzando l’annotazione @IndexEmbedded. Nel caso specifico la issue eredita l’area dal progetto da cui è stata generata, per cui nell’esempio riportato sotto l’area è marcata come @Transient, in quanto la proprietà non è persistita direttamente.
I filtri di ricerca ci permetterano di “sgrossare” il set dei risultati selezionando solo dati di aree specifiche, ma per una sicurezza più fine-grained si dovrà comunque interrogare gli oggetti estratti.
@Transient @IndexedEmbedded public Area getArea() { Area result = null; if (task != null) { result = task.getArea(); } else if (getOwner() != null) { result = ((TeamworkOperator) getOwner()).getMyPerson().getArea(); } return result; }
Conclusioni
Hibernate Search è una soluzione flessibile e potente, per aggiungere la possibilità di ricerche Google-like alle nostre applicazioni Java. Speriamo di avervi mostrato come poter estendere l’uso di base a situazioni più complesse rimanendo conformi alla sua filosofia di base.
Riferimenti
[1] Lucene
http://lucene.apache.org
[2] Hibernate Search
http://www.hibernate.org/410.html
[3] Language guessing API, TCatNG
http://tcatng.sourceforge.net
[4] Offline text extraction with Hibernate Search: our contribution on Hibernate’s Wiki
http://www.hibernate.org/432.html
[5] Paging with Hibernate: our contribution on Hibernate’s Wiki
http://www.hibernate.org/314.html
[6] Luis search engine
http://sourceforge.net/projects/lius
[7] The Nutch project
http://lucene.apache.org/nutch
[8] Otis Gospodnetic – Erik Hatcher, “Lucene in Action”,Manning, 2004
http://www.manning.com/hatcher2
[9] Le classi di esempio sono tratte da Teamwork
http://www.twproject.com
[10] I sorgenti dell’esempio di base
http://sourceforge.net/project/showfiles.php?group_id=128221&package_id=251438
[11] PDFBox
http://www.pdfbox.org
Roberto Bicchierai è Lead Architect di Open Lab, una azienda fiorentina di sviluppo software e soluzioni per la comunicazione, caratterizzata dall‘elevato standard tecnologico e dall‘originalità delle soluzioni proposte.
Tra i prodotti realizzati dall‘azienda, c‘è Teamwork, un software di project management e gestione collaborativa che ha ricevuto notevoli riconoscimenti a livello internazionale.
www.open-lab.com
Pietro Polsinelli è Lead Architect di Open Lab, una azienda fiorentina di sviluppo software e soluzioni per la comunicazione, caratterizzata dall‘elevato standard tecnologico e dall‘originalità delle soluzioni proposte. Tra i prodotti realizzati dall‘azienda, c‘è Teamwork, un software di project management e gestione collaborativa che ha ricevuto notevoli riconoscimenti a livello internazionale. www.open-lab.com