Lucene: Analyzers, stemming y búsqueda de documentos similares.

0
22628

Lucene: Analyzers, stemming y búsqueda de
documentos similares.

0. Índice de contenidos.

1. Introducción

Lucene
es un api para la recuperación de
información, Information Retrieval (IR), distribuido bajo
la Apache Software License.

Encaja perfectamente en el concepto de gestión
documental (DMS) e incluso en la gestión de
contenidos (CMS), puesto que un
sistema de gestión documental requiere
de la extracción del contenido de los documentos, la
indexación de los mismos en un repositorio y la posibilidad
de recuperarlos realizando búsquedas por su contenido textual.

No penséis en un sistema de
gestión documental como en la mega-aplicación
construida única y exclusivamente como «contenedor de documentos»
para vuestra organización, cualquier aplicación tiene algo
de gestión de documentos. Y, si tenemos en cuenta que buena parte del existo de esa
gestión radicará en la capacidad de recuperar la
información que se cataloga, después de leer este
tutorial pensarás que Lucene debe formar parte de tu vida…
😀

Ya dimos, de la mano de Roberto Canales,
los
primeros pasos con Lucene en java,

instalándolo, creando un
índice, extrayendo el contenido de un pdf,
indexándolo y recuperando
la información del mismo.

En este tutorial vamos a ver cómo implementar un
analizador semántico en nuestro idioma, potenciando
la indexación y búsqueda, para terminar
analizando la viabilidad de realizar búsquedas de documentos
similares.

2. Entorno.

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil Asus G1 (Core 2 Duo a 2.1
    GHz, 2048 MB RAM, 120 GB HD).
  • Sistema Operativo: GNU / Linux, Debian (unstable), Kernel
    2.6.23, KDE 3.5
  • JDK 1.5.0_14
  • Eclipse Europa 3.3
  • Lucene 2.2.0

3. Consideraciones previas.

Para la indexación y recuperación del contenido
textual de los documentos que gestionamos nos bastaría con
utilizar alguno de los analizadores que proporciona por defecto Lucene,
pero si queremos potenciar las búsquedas de modo que no se
produzca demasiado ruido en el resultado y para cumplir el
objetivo de buscar documentos similares, tenemos que conseguir que los
documentos pasen por un filtro lo más exhaustivo posible.

Podemos conseguirlo implementando los siguientes conceptos:

  • stopwords:
    son una lista de palabras de uso frecuente que, tanto en la
    indexación como
    en la búsqueda, no se tienen en consideración, se
    omiten. 
  • stemming:
    es un método para obtener la raíz
    semántica
    de una palabra. Las palabras se reducen a su raíz o stem
    (tema), de modo
    que, si buscamos por “abandonados”
    encontrará
    “abandonados” pero también
    “abandonadas”,
    “abandonamos”, … porque, en realidad, estamos
    buscando por
    “abandon”.
  • modelo de
    espacio vectorial
    : es un modelo algebraico utilizado para
    filtrar, indexar, recuperar
    y calcular la relevancia de la información. Representa los
    documentos con un lenguaje natural mediante
    el uso
    de vectores en un espacio lineal multidimensional. La relevancia de un
    documento frente a una búsqueda puede calcularse
    usando la diferencia de ángulos de cada uno de los
    documentos respecto del vector de busca, utilizando
    el producto escalar entre el vector de búsqueda.

A priori parece complejo, pero lo que cuesta es comprenderlo, vamos a
ver cómo Lucene nos va a facilitar mucho la vida. Lo costoso
que resulte implementarlo en tus desarrollos, eso solo lo sabes
tú, aunque sino te haces una idea de ello… siempre nos
puedes llamar para analizarlo y, en su caso, desarrollarlo.

A continuación, las dependencias que tendrá
el proyecto en nuestro pom.xml, si no usáis maven,
serán las librerías a importar manualmente:

<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>2.3.1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-snowball</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queries</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.14</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>3.0-FINAL</version>
</dependency>

4. SpanishAnalyzer.

Lucene provee varios analizadores por defecto, el StandardAnalyzer con
un listado reducido de
stopwords en inglés y podemos obtener una
librería (lucene-analyzers) con analizadores en bastantes
idiomas… casi todos menos en Castellano… 🙁

Aún teniendo un analizador en el idioma requerido, la
recomendación es que construyáis el vuestro
propio para aumentar el listado de stopwords, si fuese necesario, y
comprobar el algoritmo con el que se está realizando, si es
que se realiza, el stemming.

Para la elaboración del listado de stopwords podemos acudir
a páginas
especializadas en IR o Text Mining (http://snowball.tartarus.org/
, http://www.unine.ch/info/clef/).

Nuestra clase SpanishAnalyzer hederará de org.apache.lucene.analysis.Analyzer y tendría el siguiente
código fuente:

package com.autentia.lucene.es; 
 
import java.io.File; 
import java.io.IOException; 
import java.io.Reader; 
import java.util.HashSet; 
import java.util.Set; 
 
import org.apache.lucene.analysis.Analyzer; 
import org.apache.lucene.analysis.LowerCaseFilter; 
import org.apache.lucene.analysis.StopFilter; 
import org.apache.lucene.analysis.TokenStream; 
import org.apache.lucene.analysis.WordlistLoader; 
import org.apache.lucene.analysis.standard.StandardFilter; 
import org.apache.lucene.analysis.standard.StandardTokenizer; 
 
 
/** Filters {@link StandardTokenizer} with {@link StandardFilter}, {@link 
 * LowerCaseFilter}, {@link StopFilter} and {@link SpanishStemFilter}. */ 
 
/** 
 * Analyzer for Spanish using the SNOWBALL stemmer. Supports an external list of stopwords 
 * (words that will not be indexed at all). 
 * A default set of stopwords is used unless an alternative list is specified, the 
 * exclusion list is empty by default. 
 * 
 * @author jose 
 */ 
 
public class SpanishAnalyzer extends Analyzer { 
     
    /** An array containing some common Spanish words that are usually not 
     * useful for searching. Imported from http://www.unine.ch/info/clef/. 
     */ 
    // TODO: no pego en el tutorial el listado de stopWords utilizado para
    // no sobredimensionarlo, son 351 términos.
    public static final String[] SPANISH_STOP_WORDS = { "" };
    
    /**
     * Contains the stopwords used with the StopFilter.
     */
    private Set stopTable = new HashSet();
    
    /**
     * Contains words that should be indexed but not stemmed.
     */
    private Set exclTable = new HashSet();
    
    /**
     * Builds an analyzer with the default stop words.
     */
    public SpanishAnalyzer() {
        stopTable = StopFilter.makeStopSet(SPANISH_STOP_WORDS);
    }

    /** Builds an analyzer with the given stop words. */
    public SpanishAnalyzer(String[] stopWords) {
        stopTable = StopFilter.makeStopSet(stopWords);
    }
    
    /**
     * Builds an analyzer with the given stop words from file.
     * @throws IOException 
     */
    public SpanishAnalyzer(File stopWords) throws IOException {
        stopTable = new HashSet(WordlistLoader.getWordSet(stopWords));
    }
    
    /** Constructs a {@link StandardTokenizer} filtered by a {@link
     * StandardFilter}, a {@link LowerCaseFilter}, a {@link StopFilter}
     * and a {@link SpanishStemFilter}. */
    public final TokenStream tokenStream(String fieldName, Reader reader) {
        TokenStream result = new StandardTokenizer(reader);
        result = new StandardFilter(result);
        result = new LowerCaseFilter(result);
        result = new StopFilter(result, stopTable);
        result = new SpanishStemFilter(result);
        return result;
    }
}

En la constante SPANISH_STOP_WORDS incluiremos el listado por defecto de
stopWords en castellano que tendrá el analizador. Lo ideal sería que el listado final de stopWords
se obtuviese de un recurso externo parametrizable (un fichero de propiedades, base de datos…).

En el método tokenStream es donde se filtra el
contenido, para ello hemos incluido varios filters:

  • StandardFilter: básicamente, elimina los signos
    de puntuación, 
  • LowerCaseFilter: convierte el contenido a
    minúsculas,
  • StopFilter: filtrará el contenido con el listado
    de stopWords,
  • SpanishStemFilter: objeto del siguiente punto del tutorial.

5. SpanishTemFilter.

Nuestro filtro de stemming heredará de org.apache.lucene.analysis.TokenFilter y tendrá el siguiente código fuente:

package com.autentia.lucene.es; 
 
import java.io.IOException; 
 
import net.sf.snowball.ext.SpanishStemmer; 
 
import org.apache.lucene.analysis.Token; 
import org.apache.lucene.analysis.TokenFilter; 
import org.apache.lucene.analysis.TokenStream; 
 
/**  
 * Spanish stemming algorithm. 
 */ 
public final class SpanishStemFilter extends TokenFilter { 
     
    private SpanishStemmer stemmer; 
    private Token token = null; 
     
    public SpanishStemFilter(TokenStream in) { 
    	super(in); 
        stemmer = new SpanishStemmer(); 
    } 
     
    /** Returns the next input Token, after being stemmed */ 
    public final Token next() throws IOException { 
        if ((token = input.next()) == null) { 
            return null; 
        } 
        else { 
            stemmer.setCurrent(token.termText()); 
            stemmer.stem(); 
            String s = stemmer.getCurrent(); 
            if ( !s.equals( token.termText() ) ) { 
                return new Token( s, token.startOffset(), 
                token.endOffset(), token.type() ); 
            } 
            return token; 
        } 
    } 
     
    /** 
     * Set a alternative/custom Stemmer for this filter. 
     */ 
    public void setStemmer(SpanishStemmer stemmer) { 
        if ( stemmer != null ) { 
            this.stemmer = stemmer; 
        } 
    } 
}

Utiliza la clase SpanishStemmer, de la librería lucene-snowball.
Snowball es un lenguaje de programación para el manejo de strings que
permite implementar fácilmente algoritmos de stemming.

6. Test unitario SpanishAnalyzerTest.

Desde Autentia insistimos
mucho en la realización de test (unitarios y de integración) de nuestras aplicaciones.

Una vez implementado el analizador en castellano deberíamos probarlo, y qué mejor
que un test de Junit que forme parte de nuestro código fuente?, con ello testeamos
el funcionamiento actual y futuro (si alguien toca nuestro código, los tests deben
seguir funcionando).

package com.autentia.lucene;

import junit.framework.TestCase;

import java.io.StringReader;

import org.apache.log4j.Logger;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.Token;
import org.apache.lucene.analysis.TokenStream;

import com.autentia.lucene.en.EnglishAnalyzer;
import com.autentia.lucene.es.SpanishAnalyzer;


public class SnowballAnalyzerTest extends TestCase {
    
	private static Logger logger = Logger.getRootLogger();
	
    public SnowballAnalyzerTest(String name) {
        super(name);
    }

    public void assertAnalyzesTo(Analyzer a, String input, String[] output) throws Exception {
        TokenStream ts = a.tokenStream("content", new StringReader(input));
        for (int i=0; i BAR",
            new String[] { "foo", "bar", "foo", "bar" });
        assertAnalyzesTo(a, "C.A.M.",
            new String[] { "cam" });
        assertAnalyzesTo(a, "C++",
            new String[] { "c" });
        assertAnalyzesTo(a, "\"QUOTED\" word",
            new String[] { "quot", "word" });
        assertAnalyzesTo(a, "El camino del hombre recto",
            new String[] { "camin", "hombr", "rect"});
        assertAnalyzesTo(a, "está por todos lados rodeado de la injusticia de los egoístas y la tiranía de los hombres malos.",
                new String[] { "lad", "rod", "injustici", "egoist", "tiran", "hombr", "mal"});
    }
}

7. Búsqueda de documentos por similitud.

Tomando como base el modelo de espacio vectorial que Lucene implementa, podemos realizar una búsqueda por similitud teniendo en cuenta los siguientes parámetros:

  • el número mínimo de ocurrencias de una palabra en un documento,
  • el número mínimo de ocurrencias de un término, esto es, de la raíz semántica de una palabra en un documento,
  • la longitud mínima de una palabra, el número mínimo de caracteres de una palabra para que sea tenida en cuenta. Fortaleciendo, de este modo, el listado de stop words.
  • un número máximo de términos para componer la consulta.

En función a los mismos se generará una consulta que estará formada por los términos del vector más relevantes del documento, hasta llegar al número máximo parametrizado.

Lucene distribuye una librería (lucene-similarity) que contiene una clase que implementa ésta funcionalidad: MoreLikeThis. Vamos a ver su funcionamiento en el siguiente test:

package com.autentia.lucene;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

import junit.framework.TestCase;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.Field.Index;
import org.apache.lucene.document.Field.Store;
import org.apache.lucene.document.Field.TermVector;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.search.Hits;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.similar.MoreLikeThis;
import org.apache.poi.hwpf.extractor.WordExtractor;

import com.autentia.lucene.es.SpanishAnalyzer;

/**
 * Test that verifies the search by similarity of documents.
 */
public class SimilarityDocumentSearchTest extends TestCase {
    
	private static Log log = LogFactory.getLog(SimilarityDocumentSearchTest.class);

	/** repository path */
	private static final String PATH = "localhost_index";

	/** Name of the field in Lucene that contains the content of the document */
	private static final String FIELD_CONTENT_NAME = "content";
	
	/** Name of the search fields in Lucene */
	private static final String[] DEFAULT_FIELD_NAMES = new String[] { FIELD_CONTENT_NAME  };

	/** Ignore words that are less than it often in the document code */
	private static final int DEFALT_MIN_DOC_FREQ = 1;

	/** Ignore terms that are less than it often in the document code */
	private static final int DEFAULT_MIN_TERM_FREQ = 1;

	/** Maximum number of terms that will be included in the query */
	private static final int MAX_QUERY_TERMS = 1000;

	/** Minimum length of a word to be taken into consideration */
	private static final int DEFAULT_MIN_WORD_LENGTH = 2;
	
	/** Our SpanishAnalyzer */
	private static Analyzer spanishAnalyzer = new SpanishAnalyzer();
	
	/** Number total of documents indexed */
	private int totalDocs = 0;
	
    public SimilarityDocumentSearchTest(String name) {
        super(name);
    }

	@Override
	protected void setUp() throws Exception {
		log.trace("Entering " + getName());
		createIndex(spanishAnalyzer);
		indexFiles("files");
		log.debug("'"+totalDocs+"' documents indexed.");
	}

	@Override
	protected void tearDown() throws Exception {
		log.trace("Exiting " + getName());
		destroyIndex();
	}
	
    /** run test */
    public void test_DocumentAnalyzer() throws Throwable {
    	moreLikeThisAnalyzer(spanishAnalyzer,"files/original.doc");
    }

    /** Search documents by similarity using the class {@link MoreLikeThis} */
    private void moreLikeThisAnalyzer(Analyzer analyzer, String original) throws Throwable {
    	log.trace("Entering");
    	
    	IndexReader indexReader = IndexReader.open(PATH);
    	IndexSearcher indexSearcher = new IndexSearcher(indexReader);
    	
		MoreLikeThis mlt = new MoreLikeThis(indexReader);
		mlt.setFieldNames(DEFAULT_FIELD_NAMES);
		mlt.setMinDocFreq(DEFALT_MIN_DOC_FREQ);
		mlt.setMinTermFreq(DEFAULT_MIN_TERM_FREQ);
		mlt.setMaxQueryTerms(MAX_QUERY_TERMS);
		mlt.setMinWordLen(DEFAULT_MIN_WORD_LENGTH);
		mlt.setAnalyzer(analyzer);
		
        Query query= mlt.like( new FileInputStream(getClass().getClassLoader().getResource(original).getPath()) );

    	Hits hits = indexSearcher.search(query);

		int len = hits.length();
		
        log.debug("-------------------------------------------");
        log.debug("original :" + original);
        log.debug("query: " + query);
        log.debug("found: " + len + " documents");
        for (int i = 0; i < Math.min(25, len); i++) {
			Document d = hits.doc(i);
			log.debug("score   : " + hits.score(i));
			log.debug("name    : " + d.get("name"));
		}
		log.debug("-------------------------------------------");
    	log.trace("Exiting");
    }
	
	/** Created the index with the analyzer given as parameter*/
	private void createIndex(Analyzer analyzer) throws Exception {
		IndexWriter writer = new IndexWriter(PATH, analyzer, true);
		writer.setUseCompoundFile(false);
		writer.close();
		log.debug("Index created.");
	}

	/** Remove index */
	private void destroyIndex() throws Exception {
		File indexDir = new File(PATH);
		if (indexDir.isDirectory()) {
			for (File file : indexDir.listFiles()) {
				file.delete();
			}
		}
		log.trace("Index destroyed.");
	}

	/** Indexes a document filtering with the analyzer given as parameter */
	private void indexDocument(Document document, Analyzer analyzer) {
		try {
			log.trace("Indexing the document='" + document
					+ "' with the analyzer='" + analyzer + "'.");
			IndexWriter writer = new IndexWriter(PATH, analyzer, false);
			writer.addDocument(document);
			writer.optimize();
			writer.close();
		} catch (Throwable e) {
			System.out.println("Exception indexing:" + e);
		}
	}

	/** creates a Lucene document */
	private Document createLuceneDocument(String name, File file) throws IOException {
		
		Document document = new Document();
		document.add(new Field("name", name, Store.YES, Index.TOKENIZED));
		document.add(new Field(FIELD_CONTENT_NAME, new WordExtractor(new FileInputStream(file)).getText(), Store.YES, Index.TOKENIZED, TermVector.WITH_POSITIONS_OFFSETS));
		return document;
	}
	
	/** Indexes all the documents of a path */
	private void indexFiles(String path) throws Exception {

		File[] files = null;
		try {
			files = new File(getClass().getClassLoader().getResource(path).toURI()).listFiles();
		} catch (Throwable t1) {
			log.trace("The path='" + path + "' is out of the test environment.",t1);
			try {	
				files = new File(path).listFiles();
			} catch (Throwable t2) {}
		}

		if (files == null) {
			log.trace("Path='" + path + "' not found.");
			return;
		}
		for (int i = 0; i < files.length; i++) {
			if (files[i].isDirectory()) {
				indexFiles(path + "/" + files[i].getName());
				continue;
			}
			totalDocs++;
			log.debug("["+totalDocs+"] doc :" + files[i].getName());
			indexDocument(createLuceneDocument(files[i].getName(), files[i]), spanishAnalyzer);
		}
	}
}

El test se ejecuta en un entorno en el que el directorio src/test/resources/files contiene una serie de documentos, en formato word, que serán indexados.
Y se busca los documentos similares al documento Original.doc [línea 60].

A destacar:

  • línea 70 y siguientes: en la que se crea una instancia de la clase MoreLikeThis y se setean los parámetros que hemos comentado para la generación de la consulta.
  • línea 135: en la que se crea el campo del documento de Lucene que almacenará el contenido de nuestro documento, indicando para ello el tipo de almacenamiento que tendrá nuestro vector de términos (TermVector.WITH_POSITIONS_OFFSETS) y extrayendo el contenido de nuestro documento de Microsoft Word utilizando la última versión de la librería poi

Si tienes la oportunidad pruébalo con alguno de los documentos que manejas habitualmente, te sorprenderán los resultados.

Recomendamos utilizar una capa de indexación que encapsule la lógica de acceso a Lucene como lius, y si tu problemática es solo la extracción del contenido de los
documentos (en diferentes formatos) puedes echar un ojo al proyecto tika de apache, basado en lius.

8. Conclusiones.

Hemos visto cómo realizar un analizador semántico en castellano para Lucene que implemente
un listado elaborado de stopwords, y filtre el contenido de los términos, a indexar y buscar, en
función de su raíz semántica. Con ello podemos comprobar cómo realizar búsquedas
de documentos por simlitud, con un alto grado de acierto.

Y hemos potenciado la funcionalidad de Lucene, se te ocurre alguna aplicación práctica?, a nosotros sí.

Si necesitas un empujón a tus desarrollos, ampliar la funcionalidad de tus aplicaciones, un estudio de la viabilidad de implementar ésta tecnología u otras... llámanos!!!

Somos Autentia.

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad