segunda-feira, maio 23, 2005

Suggest com o Lucene

Um recurso bastante interessante nos mecanismos de busca mais sofisticados é o de sugestões. Funciona assim: você, usuário, digita algo errado e o mecanismo lhe sugere a palavra correta. Ou, em alguns outros casos, sugere outra palavra parecida com a que digitada mas com um numero maior de resultados. Algo como sugerir "lucene" se você digitar "luceni". Criar um mecanismo de sugestão com o Lucene é algo bastante simples. A maior parte da complicação está em definir se a estratégia trará as palavras mais parecidas ou as palavras parecidas com maior numero de resultados. Outro aspecto considerado é o quanto as palavras devem ser parecidas, ou seja, o grau de similaridade entre elas. No Lucene esse grau varia entre no intervalo [0,1] e o valor default é 0.5. Geralmente ambas as decisões são tomadas depois de alguns testes para verificar qual abordagem melhor se adequa ao seu caso, mas, para o grau de similaridade, quanto menor a base de índices, menor deve ser o grau mínimo requerido, já que não há muitas palavras a disposição. Feitas as escolhas, vamos a um exemplo simples. Criamos uma classe Suggest que encapsulará os dados, palavra e quantidade de resultados, de uma sugestão:


public class Suggest implements Comparable<Suggest> {

private String word;
private int occurrences;

/**
* @param word
* @param occurrences
*/
public Suggest ( String word, int occurrences ) {

this.word = word;
this.occurrences = occurrences;
}

/**
* @see java.lang.Object#equals(java.lang.Object)
*/
public boolean equals( Object obj ) {

boolean result = false;

if (obj instanceof Suggest) {

Suggest suggest = (Suggest) obj;

String sWord = suggest.word.toLowerCase();
String thisWord = word.toLowerCase();

result = sWord.equals(thisWord)
∧∧ occurrences == suggest.occurrences;

}

return result;
}

/**
* @see java.lang.Object#hashCode()
*/
public int hashCode() {

return word.toLowerCase().hashCode() * occurrences;

}

/**
* @see java.lang.Object#toString()
*/
public String toString() {

return word + " - " + occurrences;

}

/**
* @param other
* @return
* @see java.lang.Comparable#compareTo(T)
*/
public int compareTo( Suggest other ) {

return other.occurrences - occurrences;

}

/**
* @return Returns the occurrences.
*/
public int getOccurrences() {

return occurrences;
}

/**
* @return Returns the word.
*/
public String getWord() {

return word;
}
}

A classe é Comparable para permitir a ordenação de acordo com o numero de documentos nos quais o termo procurado aparece. E agora a classe que cria as sugestões a partir do seu índice:


import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.Token;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.Term;
import org.apache.lucene.index.TermEnum;
import org.apache.lucene.search.FuzzyQuery;
import org.apache.lucene.search.FuzzyTermEnum;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;


/**
* @author Marcos Silva Pereira - marcos.pereira@vicinity.com.br
*
*
* @since 21/05/2005
* @version $Id$
*/
public class Suggestor {

private Directory lucenePath;
private Analyzer analyzer;
private float minSimilarity = FuzzyQuery.defaultMinSimilarity;

/**
* @param path
* @param analyzer
* @param similarity
*/
public Suggestor ( Directory path, Analyzer analyzer, float similarity ) {

lucenePath = path;
minSimilarity = similarity;
this.analyzer = analyzer;
}

/**
* @param word
* @param field
* @return
*/
public List<Suggest> suggestsByFrequency( String word, String field ) {

List<Suggest> suggests = suggestsBySimilarity(word, field);

Collections.sort(suggests);

return suggests;

}

/**
* @param word
* @param field
* @param maxSuggestions
* @return
*/
public List<Suggest> suggestsByFrequency( String word, String field,
int maxSuggestions ) {

List<Suggest> suggests = suggestsByFrequency(word, field);

suggests = chopList(maxSuggestions, suggests);

return suggests;

}

/**
* @param word
* @param field
* @param maxSuggestions
* @return
*/
public List<Suggest> suggestsBySimilarity( String word, String field,
int maxSuggestions ) {

List<Suggest> suggests = suggestsBySimilarity(word, field);

suggests = chopList(maxSuggestions, suggests);

return suggests;

}

/**
* @param word
* @param field
* @return
* @throws IOException
*/
public List<Suggest> suggestsBySimilarity( String word, String field ) {

IndexReader reader = null;

try {

List<Suggest> suggests;

reader = IndexReader.open(lucenePath);
Term term = new Term(field, word);

FuzzyTermEnum termEnum;
termEnum = new FuzzyTermEnum(reader, term, minSimilarity);

suggests = termEnumToList(termEnum);

return suggests;

} catch (IOException ex) {

throw new SuggestException(ex);

} finally {

try {

if (reader != null)
reader.close();

} catch (Exception ex) {

// TODO log this exception...
}

}

}

/**
* @param phrase
* @param field
* @return
*/
public List<Suggest> phrasalSuggest( String phrase, String field ) {

List<Suggest> suggests = new ArrayList<Suggest>();

TokenStream tokenStream = null;

try {

Reader reader = new StringReader(phrase);
tokenStream = analyzer.tokenStream(field, reader);

Token token;
while((token = tokenStream.next()) != null) {

String tokenText = token.termText();
List<Suggest> temp = suggestsByFrequency(tokenText, field, 1);

if(!temp.isEmpty()) {

suggests.addAll(temp);

}

}

} catch (IOException ex) {

throw new SuggestException(ex);

} finally {

try {

if(tokenStream != null) tokenStream.close();

} catch (IOException ex) {

// TODO Logger this exception

}

}

return suggests;

}

private List<Suggest> termEnumToList( TermEnum termEnum )
throws IOException {

List<Suggest> suggests = new ArrayList<Suggest>();

while (termEnum.next()) {

Term term = termEnum.term();

String termValue = term.text();
int frequency = termEnum.docFreq();

suggests.add(new Suggest(termValue, frequency));

}

return suggests;
}

private List<Suggest> chopList( int maxSuggestions, List<Suggest> suggests ) {

if (suggests.size() > maxSuggestions) {

suggests = suggests.subList(0, maxSuggestions);

}

return suggests;
}
}

Os métodos suggestBySimilarity trazem as palavras mais parecidas com a buscada e não se preocupam com o numero de documentos com os quais o termo buscado está relacionado. Já os métodos suggestsByFrequency trazem primeiro as palavras parecidas e com maior numero de documentos. Nesse caso, o numero de documentos é determinante e a similaridade é usada apenas para retornar o conjunto de palavras. Infelizmente o Lucene não provê o grau de similaridade para que se pudesse criar uma media entre esse grau e o numero de documentos. O método phrasalSuggest gera um conjunto de sugestões baseado na frase indicada. Um analyzer é usado para que as palavras da consulta sejam tratadas do mesmo modo que são para a busca. Assim, stop words, palavras descartadas por serem muito comuns, não receberão uma sugestão e daí por diante. Usar a classes Suggestor nas classes clientes é bastante simples. Em uma action do WebWork, por exemplo, bastaria fazer algo como a seguir:


public class ActionSearch extends ActionSupport {

private Suggestor suggestor;
private List<Suggest> suggests;

private List searchResult;
private String query;

/**
* @return Action execute status
*/
public String findByText() {

String result = Action.ERROR;

try {

// abstraia a busca, afinal o foco está em como fazer as sugestões
this.searchResult = Mystical.doSearch(query);

// recuperando conjunto de sugestões.
this.suggests = suggests();

result = Action.SUCCESS;

} catch (Exception e) {

addActionError("Erro ao realizar a busca: " + e.getMessage());

}

return result;

}

private List<Suggest> suggests() {

List<Suggest> suggestions = new ArrayList<Suggest>();

try {

// pode acreditar, a classe Mystical faz um café de primeira
suggestions = suggestor.suggestsByFrequency(query, Mystical.FIELD_TO_SEARCH, 2);

} catch (Exception e) {

// TODO logger this exception

}

return suggestions;

}

// gets and sets

}

O método suggests foi criado para realizar o tratamento de exceções, ou seja, se a recuperação das sugestões lançar alguma exception, a busca é feita, como é de se esperar. As sugestões ficam então disponíveis para serem usadas na pagina que mostra o resultado da busca. Por fim, os códigos acima estão sendo desenvolvidos para o projeto JavaBB e não demora a estarem disponíveis (sem mágicas, ou quase) no CVS do projeto. Até lá!

valeuz...

terça-feira, maio 10, 2005

Lucene e Compass

O meu esquema de criar anotações para o Lucene ficou quase sem sentido quando descobri o Compass, uma biblioteca que usa uma especie de mapeamento de campos de um objeto para campos do Lucene. Quase porque ele usa XML, num esquema parecido com o Hibernate, para realizar o mapeamento e isso é algo que eu gostaria muito de evitar. Fora isso, o Compass possui um esquema de Template e Callback bastante familiar para quem já tentou usar o Spring. Penso seriamente em ajudar no desenvolvimento do projeto, em especial, criar interceptors para tornar o compass menos intrusivo quando usado com o Spring, anotações baseadas na DTD deles e quem sabe um modulo XDoclet, só para manter o projeto compativel com versões antigas do Java.

O dificil até agora tem sido entrar em contato com os desenvolvedores. Simplesmente não consegui assinar a lista de desenvolvedores. Vou tentar contato direto com o lider do projeto e ver como posso ajudar.

valeuz...