Búsqueda de texto completo de diálogos con Apache Lucene: un tutorial
Publicado: 2022-03-11Apache 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.
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:
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úferchar[]
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 enCharTermAttribute
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:
Si hemos llegado al final del flujo de tokens (es decir
hasNext
es falso), hemos terminado y simplemente regresamos.Si tenemos un token de más de un carácter y uno de esos caracteres es una comilla, dividimos el token.
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):
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:
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!