Búsqueda de texto completo de diálogos con Apache Lucene: un tutorial

Publicado: 2022-03-11

Apache Lucene es una biblioteca de Java utilizada para la búsqueda de documentos de texto completo y es el núcleo de los servidores de búsqueda como Solr y Elasticsearch. También se puede incrustar en aplicaciones Java, como aplicaciones de Android o backends web.

Si bien las opciones de configuración de Lucene son amplias, están diseñadas para que las utilicen los desarrolladores de bases de datos en un corpus de texto genérico. Si sus documentos tienen una estructura específica o un tipo de contenido, puede aprovechar cualquiera de ellos para mejorar la calidad de búsqueda y la capacidad de consulta.

búsqueda de texto completo con apache lucene

Como ejemplo de este tipo de personalización, en este tutorial de Lucene indexaremos el corpus del Proyecto Gutenberg, que ofrece miles de libros electrónicos gratuitos. Sabemos que muchos de estos libros son novelas. Supongamos que estamos especialmente interesados ​​en el diálogo dentro de estas novelas. Ni Lucene, Elasticsearch ni Solr proporcionan herramientas listas para usar para identificar contenido como diálogo. De hecho, descartarán la puntuación en las primeras etapas del análisis del texto, lo que va en contra de poder identificar partes del texto que son diálogo. Por lo tanto, es en estas primeras etapas donde debe comenzar nuestra personalización.

Piezas de la canalización de análisis de Apache Lucene

El JavaDoc de análisis de Lucene proporciona una buena visión general de todas las partes móviles en la tubería de análisis de texto.

En un nivel alto, puede pensar que la canalización de análisis consume un flujo de caracteres sin procesar al principio y produce "términos", que corresponden aproximadamente a palabras, al final.

La tubería de análisis estándar se puede visualizar como tal:

Tubería de análisis de Lucene

Veremos cómo personalizar esta canalización para reconocer regiones de texto marcadas con comillas dobles, a las que llamaré diálogo, y luego aumentar las coincidencias que se producen al buscar en esas regiones.

Caracteres de lectura

Cuando los documentos se agregan inicialmente al índice, los caracteres se leen desde Java InputStream, por lo que pueden provenir de archivos, bases de datos, llamadas a servicios web, etc. Para crear un índice para Project Gutenberg, descargamos los libros electrónicos y cree una pequeña aplicación para leer estos archivos y escribirlos en el índice. La creación de un índice de Lucene y la lectura de archivos son caminos bien transitados, por lo que no los exploraremos mucho. El código esencial para producir un índice es:

 IndexWriter writer = ...; BufferedReader reader = new BufferedReader(new InputStreamReader(... fileInputStream ...)); Document document = new Document(); document.add(new StringField("title", fileName, Store.YES)); document.add(new TextField("body", reader)); writer.addDocument(document);

Podemos ver que cada libro electrónico corresponderá a un solo Document de Lucene, por lo que, más adelante, nuestros resultados de búsqueda serán una lista de libros coincidentes. Store.YES indica que almacenamos el campo de título , que es solo el nombre del archivo. Sin embargo, no queremos almacenar el cuerpo del libro electrónico, ya que no es necesario al buscar y solo desperdiciaría espacio en disco.

La lectura real de la secuencia comienza con addDocument . IndexWriter extrae tokens del final de la canalización. Esta extracción continúa a través de la tubería hasta que la primera etapa, el Tokenizer , lee del InputStream .

También tenga en cuenta que no cerramos la transmisión, ya que Lucene se encarga de esto por nosotros.

Personajes tokenizados

El StandardTokenizer de Lucene elimina la puntuación, por lo que nuestra personalización comenzará aquí, ya que debemos conservar las comillas.

La documentación de StandardTokenizer lo invita a copiar el código fuente y adaptarlo a sus necesidades, pero esta solución sería innecesariamente compleja. En su lugar, extenderemos CharTokenizer , que le permite especificar caracteres para "aceptar", donde aquellos que no son "aceptados" serán tratados como delimitadores entre tokens y descartados. Como nos interesan las palabras y las citas que las rodean, nuestro Tokenizer personalizado es simplemente:

 public class QuotationTokenizer extends CharTokenizer { @Override protected boolean isTokenChar(int c) { return Character.isLetter(c) || c == '"'; } }

Dado un flujo de entrada de [He said, "Good day".] , los tokens producidos serían [He] , [said] , ["Good] , [day"]

Observe cómo las comillas se intercalan dentro de los tokens. Es posible escribir un Tokenizer que produzca tokens separados para cada cotización, pero Tokenizer también se ocupa de los detalles complicados y fáciles de estropear, como el almacenamiento en búfer y el escaneo, por lo que es mejor mantener su Tokenizer simple y limpiar el flujo de token más adelante en la canalización.

Dividir tokens usando filtros

Después del tokenizador viene una serie de objetos TokenFilter . Tenga en cuenta, por cierto, que el nombre de filtro es un poco inapropiado, ya que un TokenFilter puede agregar, eliminar o modificar tokens.

Muchas de las clases de filtro proporcionadas por Lucene esperan palabras sueltas, por lo que no será bueno que nuestros tokens de palabras y comillas combinadas fluyan hacia ellas. Por lo tanto, la siguiente personalización de nuestro tutorial de Lucene debe ser la introducción de un filtro que limpie la salida de QuotationTokenizer .

Esta limpieza implicará la producción de un token de comillas de inicio adicional si la comilla aparece al principio de una palabra, o un token de comillas de finalización si la comilla aparece al final. Dejaremos de lado el manejo de palabras entre comillas simples por simplicidad.

Crear una subclase TokenFilter implica implementar un método: incrementToken . Este método debe llamar a incrementToken en el filtro anterior en la canalización y luego manipular los resultados de esa llamada para realizar cualquier trabajo del que sea responsable el filtro. Los resultados de incrementToken están disponibles a través de objetos Attribute , que describen el estado actual del procesamiento de tokens. Después de que nuestra implementación de incrementToken regrese, se espera que los atributos hayan sido manipulados para configurar el token para el siguiente filtro (o el índice si estamos al final de la canalización).

Los atributos que nos interesan en este punto del pipeline son:

  • CharTermAttribute : contiene un búfer char[] que contiene los caracteres del token actual. Tendremos que manipular esto para eliminar la cotización o para producir un token de cotización.

  • TypeAttribute : contiene el "tipo" del token actual. Debido a que estamos agregando comillas de inicio y finalización al flujo de tokens, presentaremos dos nuevos tipos usando nuestro filtro.

  • OffsetAttribute : Lucene puede almacenar opcionalmente referencias a la ubicación de los términos en el documento original. Estas referencias se denominan "compensaciones", que son solo índices de inicio y finalización en el flujo de caracteres original. Si cambiamos el búfer en CharTermAttribute para que apunte solo a una subcadena del token, debemos ajustar estas compensaciones en consecuencia.

Quizás se pregunte por qué la API para manipular flujos de tokens es tan complicada y, en particular, por qué no podemos simplemente hacer algo como String#split en los tokens entrantes. Esto se debe a que Lucene está diseñado para la indexación de alta velocidad y baja sobrecarga, por lo que los tokenizadores y filtros incorporados pueden analizar rápidamente gigabytes de texto mientras usan solo megabytes de memoria. Para lograr esto, se realizan pocas o ninguna asignación durante la tokenización y el filtrado, por lo que las instancias de Attribute mencionadas anteriormente están destinadas a asignarse una vez y reutilizarse. Si sus tokenizadores y filtros están escritos de esta manera y minimizan sus propias asignaciones, puede personalizar Lucene sin comprometer el rendimiento.

Con todo eso en mente, veamos cómo implementar un filtro que toma un token como ["Hello] y produce los dos tokens, ["] y [Hello] :

 public class QuotationTokenFilter extends TokenFilter { private static final char QUOTE = '"'; public static final String QUOTE_START_TYPE = "start_quote"; public static final String QUOTE_END_TYPE = "end_quote"; private final OffsetAttribute offsetAttr = addAttribute(OffsetAttribute.class); private final TypeAttribute typeAttr = addAttribute(TypeAttribute.class); private final CharTermAttribute termBufferAttr = addAttribute(CharTermAttribute.class);

Empezamos por obtener referencias a algunos de los atributos que vimos anteriormente. Agregamos el sufijo "Attr" a los nombres de los campos para que quede claro más adelante cuando nos referimos a ellos. Es posible que algunas implementaciones Tokenizer no proporcionen estos atributos, por lo que usamos addAttribute para obtener nuestras referencias. addAttribute creará una instancia de atributo si falta; de lo contrario, tomará una referencia compartida al atributo de ese tipo. Tenga en cuenta que Lucene no permite varias instancias del mismo tipo de atributo a la vez.

 private boolean emitExtraToken; private int extraTokenStartOffset, extraTokenEndOffset; private String extraTokenType;

Debido a que nuestro filtro introducirá un nuevo token que no estaba presente en la secuencia original, necesitamos un lugar para guardar el estado de ese token entre las llamadas a incrementToken . Debido a que estamos dividiendo un token existente en dos, es suficiente saber solo las compensaciones y el tipo del nuevo token. También tenemos una bandera que nos dice si la siguiente llamada a incrementToken emitirá este token extra. Lucene en realidad proporciona un par de métodos, captureState y restoreState , que harán esto por usted. Pero estos métodos implican la asignación de un objeto de State y, de hecho, pueden ser más complicados que simplemente administrar ese estado usted mismo, por lo que evitaremos usarlos.

 @Override public void reset() throws IOException { emitExtraToken = false; extraTokenStartOffset = -1; extraTokenEndOffset = -1; extraTokenType = null; super.reset(); }

Como parte de su agresiva evasión de asignación, Lucene puede reutilizar instancias de filtro. En esta situación, se espera que una llamada para reset a colocar el filtro en su estado inicial. Así que aquí, simplemente reiniciamos nuestros campos de token adicionales.

 @Override public boolean incrementToken() throws IOException { if (emitExtraToken) { advanceToExtraToken(); emitExtraToken = false; return true; } ...

Ahora estamos llegando a las partes interesantes. Cuando se llama a nuestra implementación de incrementToken , tenemos la oportunidad de no llamar a incrementToken en la etapa anterior de la canalización. Al hacerlo, presentamos efectivamente un nuevo token, porque no estamos extrayendo un token del Tokenizer .

En su lugar, llamamos a advanceToExtraToken para configurar los atributos de nuestro token adicional, establecemos emitExtraToken en false para evitar esta rama en la siguiente llamada y luego devolvemos true , lo que indica que hay otro token disponible.

 @Override public boolean incrementToken() throws IOException { ... (emit extra token) ... boolean hasNext = input.incrementToken(); if (hasNext) { char[] buffer = termBufferAttr.buffer(); if (termBuffer.length() > 1) { if (buffer[0] == QUOTE) { splitTermQuoteFirst(); } else if (buffer[termBuffer.length() - 1] == QUOTE) { splitTermWordFirst(); } } else if (termBuffer.length() == 1) { if (buffer[0] == QUOTE) { typeAttr.setType(QUOTE_END_TYPE); } } } return hasNext; }

El resto de incrementToken hará una de tres cosas diferentes. Recuerde que termBufferAttr se usa para inspeccionar el contenido del token que pasa por la tubería:

  1. Si hemos llegado al final del flujo de tokens (es decir hasNext es falso), hemos terminado y simplemente regresamos.

  2. Si tenemos un token de más de un carácter y uno de esos caracteres es una comilla, dividimos el token.

  3. Si el token es una comilla solitaria, asumimos que es una comilla final. Para entender por qué, tenga en cuenta que las comillas iniciales siempre aparecen a la izquierda de una palabra (es decir, sin puntuación intermedia), mientras que las comillas finales pueden seguir a la puntuación (como en la oración, [He told us to "go back the way we came."] ). En estos casos, la comilla final ya será un token separado, por lo que solo necesitamos establecer su tipo.

splitTermQuoteFirst y splitTermWordFirst establecerán atributos para hacer que el token actual sea una palabra o una comilla, y configurarán los campos "adicionales" para permitir que la otra mitad se consuma más tarde. Los dos métodos son similares, por lo que solo splitTermQuoteFirst :

 private void splitTermQuoteFirst() { int origStart = offsetAttr.startOffset(); int origEnd = offsetAttr.endOffset(); offsetAttr.setOffset(origStart, origStart + 1); typeAttr.setType(QUOTE_START_TYPE); termBufferAttr.setLength(1); prepareExtraTerm(origStart + 1, origEnd, TypeAttribute.DEFAULT_TYPE); }

Debido a que queremos dividir este token con la cita que aparece primero en la secuencia, truncamos el búfer estableciendo la longitud en uno (es decir, un carácter, es decir, la cita). Ajustamos las compensaciones en consecuencia (es decir, apuntando a la cotización en el documento original) y también configuramos el tipo para que sea una cotización inicial.

prepareExtraTerm establecerá los campos extra* y establecerá emitExtraToken en verdadero. Se llama con compensaciones que apuntan al token "extra" (es decir, la palabra que sigue a la comilla).

La totalidad de QuotationTokenFilter está disponible en GitHub.

Aparte, si bien este filtro solo produce un token adicional, este enfoque se puede ampliar para introducir una cantidad arbitraria de tokens adicionales. Simplemente reemplace los campos extra* con una colección o, mejor aún, una matriz de longitud fija si hay un límite en la cantidad de tokens adicionales que se pueden producir. Consulte SynonymFilter y su clase interna PendingInput para ver un ejemplo de esto.

Consumir tokens de cotización y marcar el diálogo

Ahora que hemos hecho todo ese esfuerzo para agregar esas comillas al flujo de tokens, podemos usarlas para delimitar secciones de diálogo en el texto.

Dado que nuestro objetivo final es ajustar los resultados de la búsqueda en función de si los términos forman parte del diálogo o no, debemos adjuntar metadatos a esos términos. Lucene proporciona PayloadAttribute para este propósito. Las cargas útiles son matrices de bytes que se almacenan junto con los términos en el índice y se pueden leer más tarde durante una búsqueda. Esto significa que nuestra bandera ocupará un byte completo, por lo que se podrían implementar cargas útiles adicionales como banderas de bits para ahorrar espacio.

A continuación se muestra un nuevo filtro, DialoguePayloadTokenFilter , que se agrega al final de la canalización de análisis. Adjunta la carga útil que indica si el token es o no parte del diálogo.

 public class DialoguePayloadTokenFilter extends TokenFilter { private final TypeAttribute typeAttr = getAttribute(TypeAttribute.class); private final PayloadAttribute payloadAttr = addAttribute(PayloadAttribute.class); private static final BytesRef PAYLOAD_DIALOGUE = new BytesRef(new byte[] { 1 }); private static final BytesRef PAYLOAD_NOT_DIALOGUE = new BytesRef(new byte[] { 0 }); private boolean withinDialogue; protected DialoguePayloadTokenFilter(TokenStream input) { super(input); } @Override public void reset() throws IOException { this.withinDialogue = false; super.reset(); } @Override public boolean incrementToken() throws IOException { boolean hasNext = input.incrementToken(); while(hasNext) { boolean isStartQuote = QuotationTokenFilter .QUOTE_START_TYPE.equals(typeAttr.type()); boolean isEndQuote = QuotationTokenFilter .QUOTE_END_TYPE.equals(typeAttr.type()); if (isStartQuote) { withinDialogue = true; hasNext = input.incrementToken(); } else if (isEndQuote) { withinDialogue = false; hasNext = input.incrementToken(); } else { break; } } if (hasNext) { payloadAttr.setPayload(withinDialogue ? PAYLOAD_DIALOGUE : PAYLOAD_NOT_DIALOGUE); } return hasNext; } }

Dado que este filtro solo necesita mantener una sola pieza de estado, dentro de withinDialogue , es mucho más simple. Una comilla de inicio indica que ahora estamos dentro de una sección de diálogo, mientras que una comilla de fin indica que la sección de diálogo ha terminado. En cualquier caso, el token de cotización se descarta al hacer una segunda llamada a incrementToken , por lo que, en efecto, los tokens de cotización inicial o final nunca pasan de esta etapa en la canalización.

Por ejemplo, DialoguePayloadTokenFilter transformará el flujo de tokens:

 [the], [program], [printed], ["], [hello], [world], ["]`

en esta nueva corriente:

 [the][0], [program][0], [printed][0], [hello][1], [world][1]

Vinculación de tokenizadores y filtros

Un Analyzer es responsable de ensamblar la tubería de análisis, generalmente combinando un Tokenizer con una serie de TokenFilter . Analyzer también pueden definir cómo se reutiliza esa canalización entre análisis. No necesitamos preocuparnos por eso ya que nuestros componentes no requieren nada excepto una llamada a reset() entre usos, lo que Lucene siempre hará. Solo necesitamos hacer el ensamblaje implementando Analyzer#createComponents(String) :

 public class DialogueAnalyzer extends Analyzer { @Override protected TokenStreamComponents createComponents(String fieldName) { QuotationTokenizer tokenizer = new QuotationTokenizer(); TokenFilter filter = new QuotationTokenFilter(tokenizer); filter = new LowerCaseFilter(filter); filter = new StopFilter(filter, StopAnalyzer.ENGLISH_STOP_WORDS_SET); filter = new DialoguePayloadTokenFilter(filter); return new TokenStreamComponents(tokenizer, filter); } }

Como vimos anteriormente, los filtros contienen una referencia a la etapa anterior en la canalización, así es como los instanciamos. También deslizamos algunos filtros de StandardAnalyzer : LowerCaseFilter y StopFilter . Estos dos deben ir después de QuotationTokenFilter para garantizar que se hayan separado las comillas. Podemos ser más flexibles en la ubicación de DialoguePayloadTokenFilter , ya que cualquier lugar después de QuotationTokenFilter servirá. Lo colocamos después de StopFilter para evitar perder el tiempo inyectando la carga útil del diálogo en palabras vacías que finalmente se eliminarán.

Aquí hay una visualización de nuestra nueva canalización en acción (menos las partes de la canalización estándar que hemos eliminado o que ya hemos visto):

Nueva visualización de tubería en apache lucene.

DialogueAnalyzer ahora se puede usar como cualquier otro Analyzer de acciones, y ahora podemos construir el índice y pasar a la búsqueda.

Búsqueda de texto completo del diálogo

Si solo quisiéramos buscar diálogo, podríamos simplemente haber descartado todos los tokens fuera de una cita y habríamos terminado. En cambio, al dejar intactos todos los tokens originales, nos hemos dado la flexibilidad de realizar consultas que tengan en cuenta el diálogo o tratar el diálogo como cualquier otra parte del texto.

Los conceptos básicos para consultar un índice de Lucene están bien documentados. Para nuestros propósitos, es suficiente saber que las consultas se componen de objetos Term unidos con operadores como MUST o SHOULD , junto con documentos de coincidencia basados ​​en esos términos. Luego, los documentos coincidentes se califican en función de un objeto de Similarity configurable, y esos resultados se pueden ordenar por puntaje, filtrar o limitar. Por ejemplo, Lucene nos permite realizar una consulta de los diez documentos principales que deben contener los términos [hello] y [world] .

La personalización de los resultados de la búsqueda en función del diálogo se puede realizar ajustando la puntuación de un documento en función de la carga útil. El primer punto de extensión para esto estará en Similarity , que es responsable de sopesar y puntuar los términos coincidentes.

Similitud y puntuación

Las consultas, de forma predeterminada, utilizarán DefaultSimilarity , que pondera los términos en función de la frecuencia con la que aparecen en un documento. Es un buen punto de extensión para ajustar los pesos, por lo que lo ampliamos para calificar también los documentos en función de la carga útil. El método DefaultSimilarity#scorePayload se proporciona para este propósito:

 public final class DialogueAwareSimilarity extends DefaultSimilarity { @Override public float scorePayload(int doc, int start, int end, BytesRef payload) { if (payload.bytes[payload.offset] == 0) { return 0.0f; } return 1.0f; } }

DialogueAwareSimilarity simplemente califica las cargas útiles que no son de diálogo como cero. Como cada Term puede coincidir varias veces, potencialmente tendrá múltiples puntajes de carga útil. La interpretación de estos puntajes hasta la implementación de Query .

Preste mucha atención a BytesRef que contiene la carga útil: debemos verificar el byte en el offset , ya que no podemos asumir que la matriz de bytes es la misma carga útil que almacenamos anteriormente. Al leer el índice, Lucene no desperdiciará memoria asignando una matriz de bytes separada solo para la llamada a scorePayload , por lo que obtenemos una referencia a una matriz de bytes existente. Al codificar contra la API de Lucene, vale la pena tener en cuenta que el rendimiento es la prioridad, muy por delante de la conveniencia del desarrollador.

Ahora que tenemos nuestra nueva implementación de Similarity , debe configurarse en el IndexSearcher utilizado para ejecutar consultas:

 IndexSearcher searcher = new IndexSearcher(... reader for index ...); searcher.setSimilarity(new DialogueAwareSimilarity());

Consultas y Términos

Ahora que nuestro IndexSearcher puede puntuar cargas útiles, también tenemos que construir una consulta que reconozca la carga útil. PayloadTermQuery se puede usar para hacer coincidir un solo Term y al mismo tiempo verificar las cargas útiles de esas coincidencias:

 PayloadTermQuery helloQuery = new PayloadTermQuery(new Term("body", "hello"), new AveragePayloadFunction());

Esta consulta coincide con el término [hello] dentro del campo del cuerpo (recuerde que aquí es donde ponemos el contenido del documento). También debemos proporcionar una función para calcular la puntuación de carga útil final de todas las coincidencias de términos, por lo que conectamos AveragePayloadFunction , que promedia todas las puntuaciones de carga útil. Por ejemplo, si el término [hello] aparece dentro del diálogo dos veces y fuera del diálogo una vez, la puntuación final de la carga útil será ²⁄₃. Esta puntuación de carga útil final se multiplica por la proporcionada por DefaultSimilarity para todo el documento.

Usamos un promedio porque nos gustaría restar importancia a los resultados de búsqueda en los que aparecen muchos términos fuera del diálogo y producir una puntuación de cero para los documentos sin ningún término en el diálogo.

También podemos componer varios objetos PayloadTermQuery utilizando BooleanQuery si queremos buscar varios términos contenidos en el diálogo (tenga en cuenta que el orden de los términos es irrelevante en esta consulta, aunque otros tipos de consulta son conscientes de la posición):

 PayloadTermQuery worldQuery = new PayloadTermQuery(new Term("body", "world"), new AveragePayloadFunction()); BooleanQuery query = new BooleanQuery(); query.add(helloQuery, Occur.MUST); query.add(worldQuery, Occur.MUST);

Cuando se ejecuta esta consulta, podemos ver cómo la estructura de consulta y la implementación de similitud funcionan juntas:

tubería de análisis de diálogo de lucene

Consulta de ejecución y explicación

Para ejecutar la consulta, la entregamos a IndexSearcher :

 TopScoreDocCollector collector = TopScoreDocCollector.create(10); searcher.search(query, new PositiveScoresOnlyCollector(collector)); TopDocs topDocs = collector.topDocs();

Los objetos Collector se utilizan para preparar la recopilación de documentos coincidentes.

los colectores se pueden componer para lograr una combinación de clasificación, limitación y filtrado. Para obtener, por ejemplo, los diez documentos de mayor puntuación que contienen al menos un término en el diálogo, combinamos TopScoreDocCollector y PositiveScoresOnlyCollector . Tomar solo puntuaciones positivas asegura que las coincidencias de puntuación cero (es decir, aquellas sin términos en el diálogo) se filtren.

Para ver esta consulta en acción, podemos ejecutarla y luego usar IndexSearcher#explain para ver cómo se puntuaron los documentos individuales:

 for (ScoreDoc result : topDocs.scoreDocs) { Document doc = searcher.doc(result.doc, Collections.singleton("title")); System.out.println("--- document " + doc.getField("title").stringValue() + " ---"); System.out.println(this.searcher.explain(query, result.doc)); }

Aquí, iteramos sobre los ID de documentos en los TopDocs obtenidos por la búsqueda. También usamos IndexSearcher#doc para recuperar el campo de título para mostrarlo. Para nuestra consulta de "hello" , esto da como resultado:

 --- Document whelv10.txt --- 0.072256625 = (MATCH) btq, product of: 0.072256625 = weight(body:hello in 7336) [DialogueAwareSimilarity], result of: 0.072256625 = fieldWeight in 7336, product of: 2.345208 = tf(freq=5.5), with freq of: 5.5 = phraseFreq=5.5 3.1549776 = idf(docFreq=2873, maxDocs=24796) 0.009765625 = fieldNorm(doc=7336) 1.0 = AveragePayloadFunction.docScore() --- Document daved10.txt --- 0.061311778 = (MATCH) btq, product of: 0.061311778 = weight(body:hello in 6873) [DialogueAwareSimilarity], result of: 0.061311778 = fieldWeight in 6873, product of: 3.3166249 = tf(freq=11.0), with freq of: 11.0 = phraseFreq=11.0 3.1549776 = idf(docFreq=2873, maxDocs=24796) 0.005859375 = fieldNorm(doc=6873) 1.0 = AveragePayloadFunction.docScore() ...

Aunque el resultado está lleno de jerga, podemos ver cómo se usó nuestra implementación de Similarity personalizada en la puntuación y cómo MaxPayloadFunction produjo un multiplicador de 1.0 para estas coincidencias. Esto implica que la carga útil se cargó y puntuó, y todas las coincidencias de "Hello" ocurrieron en el diálogo, por lo que estos resultados están justo en la parte superior donde los esperamos.

También vale la pena señalar que el índice del Proyecto Gutenberg, con cargas útiles, tiene un tamaño de casi cuatro gigabytes y, sin embargo, en mi modesta máquina de desarrollo, las consultas ocurren instantáneamente. No hemos sacrificado ninguna velocidad para lograr nuestros objetivos de búsqueda.

Terminando

Lucene es una potente biblioteca de búsqueda de texto completo diseñada específicamente para este propósito que toma un flujo de caracteres sin procesar, los agrupa en tokens y los conserva como términos en un índice. Puede consultar rápidamente ese índice y proporcionar resultados clasificados, y brinda una amplia oportunidad para la extensión mientras mantiene la eficiencia.

Al usar Lucene directamente en nuestras aplicaciones, o como parte de un servidor, podemos realizar búsquedas de texto completo en tiempo real en gigabytes de contenido. Además, mediante el análisis y la puntuación personalizados, podemos aprovechar las funciones específicas de dominio en nuestros documentos para mejorar la relevancia de los resultados o las consultas personalizadas.

Las listas completas de códigos para este tutorial de Lucene están disponibles en GitHub. El repositorio contiene dos aplicaciones: LuceneIndexerApp para crear el índice y LuceneQueryApp para realizar consultas.

El corpus del Proyecto Gutenberg, que se puede obtener como una imagen de disco a través de BitTorrent, contiene muchos libros que vale la pena leer (ya sea con Lucene o simplemente a la antigua).

¡Feliz indexación!