Pesquisa de texto completo de diálogos com o Apache Lucene: um tutorial

Publicados: 2022-03-11

Apache Lucene é uma biblioteca Java usada para pesquisa de texto completo de documentos e está no núcleo de servidores de pesquisa como Solr e Elasticsearch. Ele também pode ser incorporado em aplicativos Java, como aplicativos Android ou back-ends da Web.

Embora as opções de configuração do Lucene sejam extensas, elas são destinadas ao uso por desenvolvedores de banco de dados em um corpus genérico de texto. Se os seus documentos tiverem uma estrutura ou tipo de conteúdo específico, você pode tirar proveito de ambos para melhorar a qualidade da pesquisa e a capacidade de consulta.

pesquisa de texto completo com apache lucene

Como exemplo desse tipo de customização, neste tutorial do Lucene vamos indexar o corpus do Projeto Gutenberg, que oferece milhares de e-books gratuitos. Sabemos que muitos desses livros são romances. Suponha que estejamos especialmente interessados ​​no diálogo dentro desses romances. Nem o Lucene, o Elasticsearch nem o Solr fornecem ferramentas prontas para uso para identificar o conteúdo como diálogo. Na verdade, eles vão jogar fora a pontuação nos estágios iniciais da análise do texto, o que vai contra a capacidade de identificar partes do texto que são diálogo. Portanto, é nesses estágios iniciais que nossa personalização deve começar.

Partes do pipeline de análise do Apache Lucene

O JavaDoc de análise Lucene fornece uma boa visão geral de todas as partes móveis no pipeline de análise de texto.

Em um nível alto, você pode pensar no pipeline de análise como consumindo um fluxo bruto de caracteres no início e produzindo “termos”, correspondendo aproximadamente a palavras, no final.

O pipeline de análise padrão pode ser visualizado como:

Pipeline de análise do Lucene

Veremos como personalizar esse pipeline para reconhecer regiões de texto marcadas por aspas duplas, que chamarei de diálogo, e depois aumentar as correspondências que ocorrem ao pesquisar nessas regiões.

Lendo caracteres

Quando os documentos são adicionados inicialmente ao índice, os caracteres são lidos a partir de um Java InputStream e, portanto, podem vir de arquivos, bancos de dados, chamadas de serviços da Web, etc. Para criar um índice para o Projeto Gutenberg, baixamos os e-books e crie um pequeno aplicativo para ler esses arquivos e gravá-los no índice. Criar um índice Lucene e ler arquivos são caminhos bem percorridos, então não vamos explorá-los muito. O código essencial para produzir um índice é:

 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 e-book corresponderá a um único Document Lucene, então, mais tarde, nossos resultados de pesquisa serão uma lista de livros correspondentes. Store.YES indica que armazenamos o campo de título , que é apenas o nome do arquivo. No entanto, não queremos armazenar o corpo do ebook, pois isso não é necessário ao pesquisar e apenas desperdiçaria espaço em disco.

A leitura real do fluxo começa com addDocument . O IndexWriter extrai tokens do final do pipeline. Esse pull continua pelo pipe até que o primeiro estágio, o Tokenizer , leia o InputStream .

Observe também que não fechamos o fluxo, pois Lucene cuida disso para nós.

Personagens de tokenização

O Lucene StandardTokenizer joga fora a pontuação, então nossa customização começará aqui, pois precisamos preservar as aspas.

A documentação do StandardTokenizer convida você a copiar o código-fonte e adaptá-lo às suas necessidades, mas essa solução seria desnecessariamente complexa. Em vez disso, estenderemos o CharTokenizer , que permite especificar caracteres para “aceitar”, onde aqueles que não forem “aceitos” serão tratados como delimitadores entre tokens e descartados. Como estamos interessados ​​em palavras e citações em torno delas, nosso Tokenizer personalizado é simplesmente:

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

Dado um fluxo de entrada de [He said, "Good day".] , os tokens produzidos seriam [He] , [said] , ["Good] , [day"]

Observe como as aspas são intercaladas nos tokens. É possível escrever um Tokenizer que produz tokens separados para cada cotação, mas o Tokenizer também se preocupa com detalhes complicados e fáceis de estragar, como armazenamento em buffer e varredura, portanto, é melhor manter seu Tokenizer simples e limpar o fluxo de token mais adiante no pipeline.

Dividindo tokens usando filtros

Após o tokenizer vem uma série de objetos TokenFilter . Observe, aliás, que o filtro é um pouco impróprio, pois um TokenFilter pode adicionar, remover ou modificar tokens.

Muitas das classes de filtro fornecidas pelo Lucene esperam palavras únicas, portanto, não será necessário que nossos tokens mistos de palavras e aspas fluam para elas. Assim, a próxima personalização do nosso tutorial do Lucene deve ser a introdução de um filtro que limpará a saída de QuotationTokenizer .

Essa limpeza envolverá a produção de um token de cotação inicial extra se a cotação aparecer no início de uma palavra ou um token de cotação final se a cotação aparecer no final. Deixaremos de lado o manuseio de palavras entre aspas simples para simplificar.

A criação de uma subclasse TokenFilter envolve a implementação de um método: incrementToken . Esse método deve chamar incrementToken no filtro anterior no pipe e, em seguida, manipular os resultados dessa chamada para executar qualquer trabalho pelo qual o filtro seja responsável. Os resultados de incrementToken estão disponíveis por meio de objetos Attribute , que descrevem o estado atual do processamento do token. Após nossa implementação de incrementToken retornar, espera-se que os atributos tenham sido manipulados para configurar o token para o próximo filtro (ou o índice se estivermos no final do pipe).

Os atributos nos quais estamos interessados ​​neste ponto do pipeline são:

  • CharTermAttribute : Contém um buffer char[] contendo os caracteres do token atual. Precisaremos manipulá-lo para remover a cotação ou para produzir um token de cotação.

  • TypeAttribute : Contém o “tipo” do token atual. Como estamos adicionando aspas iniciais e finais ao fluxo de token, apresentaremos dois novos tipos usando nosso filtro.

  • OffsetAttribute : Lucene pode opcionalmente armazenar referências à localização dos termos no documento original. Essas referências são chamadas de “deslocamentos”, que são apenas índices iniciais e finais no fluxo de caracteres original. Se alterarmos o buffer em CharTermAttribute para apontar apenas para uma substring do token, devemos ajustar esses deslocamentos de acordo.

Você pode estar se perguntando por que a API para manipular fluxos de token é tão complicada e, em particular, por que não podemos simplesmente fazer algo como String#split nos tokens de entrada. Isso ocorre porque o Lucene foi projetado para indexação de alta velocidade e baixa sobrecarga, por meio da qual os tokenizers e filtros integrados podem mastigar rapidamente gigabytes de texto enquanto usam apenas megabytes de memória. Para conseguir isso, poucas ou nenhuma alocação é feita durante a tokenização e filtragem e, portanto, as instâncias de Attribute mencionadas acima devem ser alocadas uma vez e reutilizadas. Se seus tokenizers e filtros forem escritos dessa maneira e minimizarem suas próprias alocações, você poderá personalizar o Lucene sem comprometer o desempenho.

Com tudo isso em mente, vamos ver como implementar um filtro que recebe um token como ["Hello] , e produz os dois tokens, ["] e [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);

Começamos obtendo referências a alguns dos atributos que vimos anteriormente. Sufixamos os nomes dos campos com “Attr” para que fique claro mais tarde quando nos referirmos a eles. É possível que algumas implementações de Tokenizer não forneçam esses atributos, então usamos addAttribute para obter nossas referências. addAttribute criará uma instância de atributo se estiver ausente, caso contrário, obterá uma referência compartilhada para o atributo desse tipo. Observe que o Lucene não permite várias instâncias do mesmo tipo de atributo de uma só vez.

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

Como nosso filtro apresentará um novo token que não estava presente no fluxo original, precisamos de um local para salvar o estado desse token entre as chamadas para incrementToken . Como estamos dividindo um token existente em dois, basta saber apenas os deslocamentos e o tipo do novo token. Também temos um sinalizador que nos informa se a próxima chamada para incrementToken emitirá esse token extra. Na verdade, o Lucene fornece um par de métodos, captureState e restoreState , que farão isso para você. Mas esses métodos envolvem a alocação de um objeto State e podem ser mais complicados do que simplesmente gerenciar esse estado, portanto, evitaremos usá-los.

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

Como parte de sua prevenção agressiva de alocação, o Lucene pode reutilizar instâncias de filtro. Nessa situação, espera-se que uma chamada para reset coloque o filtro de volta em seu estado inicial. Então aqui, nós simplesmente redefinimos nossos campos de token extras.

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

Agora estamos chegando às partes interessantes. Quando nossa implementação de incrementToken é chamada, temos a oportunidade de não chamar incrementToken no estágio anterior do pipeline. Ao fazer isso, introduzimos efetivamente um novo token, porque não estamos extraindo um token do Tokenizer .

Em vez disso, chamamos advanceToExtraToken para configurar os atributos para nosso token extra, configuramos emitExtraToken como false para evitar essa ramificação na próxima chamada e, em seguida, retornamos true , que indica que outro token está disponível.

 @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; }

O restante do incrementToken fará uma das três coisas diferentes. Lembre-se de que termBufferAttr é usado para inspecionar o conteúdo do token que passa pelo pipe:

  1. Se chegamos ao final do fluxo de token (ou seja, hasNext é false), terminamos e simplesmente retornamos.

  2. Se tivermos um token com mais de um caractere e um desses caracteres for uma citação, dividiremos o token.

  3. Se o token for uma cotação solitária, assumimos que é uma cotação final. Para entender o porquê, observe que as aspas iniciais sempre aparecem à esquerda de uma palavra (ou seja, sem pontuação intermediária), enquanto as aspas finais podem seguir a pontuação (como na frase, [He told us to "go back the way we came."] ). Nesses casos, a cotação final já será um token separado e, portanto, precisamos apenas definir seu tipo.

splitTermQuoteFirst e splitTermWordFirst atributos para tornar o token atual uma palavra ou uma citação e configurarão os campos “extras” para permitir que a outra metade seja consumida posteriormente. Os dois métodos são semelhantes, então veremos apenas 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); }

Como queremos dividir esse token com a cotação que aparece primeiro no fluxo, truncamos o buffer definindo o comprimento como um (ou seja, um caractere; ou seja, a cotação). Ajustamos os deslocamentos de acordo (ou seja, apontando para a cotação no documento original) e também definimos o tipo como uma cotação inicial.

prepareExtraTerm definirá os campos extra* e definirá emitExtraToken como true. Ele é chamado com deslocamentos apontando para o token “extra” (ou seja, a palavra após a aspa).

A totalidade do QuotationTokenFilter está disponível no GitHub.

Como um aparte, enquanto esse filtro produz apenas um token extra, essa abordagem pode ser estendida para introduzir um número arbitrário de tokens extras. Apenas substitua os campos extra* por uma coleção ou, melhor ainda, um array de tamanho fixo se houver um limite no número de tokens extras que podem ser produzidos. Consulte SynonymFilter e sua classe interna PendingInput para obter um exemplo disso.

Consumindo tokens de cotação e diálogo de marcação

Agora que fizemos todo esse esforço para adicionar essas citações ao fluxo de tokens, podemos usá-las para delimitar seções de diálogo no texto.

Como nosso objetivo final é ajustar os resultados da pesquisa com base se os termos fazem parte do diálogo ou não, precisamos anexar metadados a esses termos. O Lucene fornece PayloadAttribute para essa finalidade. As cargas úteis são matrizes de bytes que são armazenadas ao lado dos termos no índice e podem ser lidas posteriormente durante uma pesquisa. Isso significa que nosso sinalizador ocupará um byte inteiro, portanto, cargas adicionais podem ser implementadas como sinalizadores de bits para economizar espaço.

Abaixo está um novo filtro, DialoguePayloadTokenFilter , que é adicionado ao final do pipeline de análise. Ele anexa a carga útil indicando se o token faz ou não parte do 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; } }

Como esse filtro precisa manter apenas um único pedaço de estado, dentro de withinDialogue , é muito mais simples. Uma citação inicial indica que estamos agora dentro de uma seção de diálogo, enquanto uma citação final indica que a seção de diálogo terminou. Em ambos os casos, o token de cotação é descartado fazendo uma segunda chamada para incrementToken , portanto, os tokens de cotação inicial ou final nunca passam desse estágio no pipeline.

Por exemplo, DialoguePayloadTokenFilter transformará o fluxo de token:

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

neste novo fluxo:

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

Vinculando Tokenizers e Filtros Juntos

Um Analyzer é responsável por montar o pipeline de análise, normalmente combinando um Tokenizer com uma série de TokenFilter s. Analyzer também podem definir como esse pipeline é reutilizado entre as análises. Não precisamos nos preocupar com isso, pois nossos componentes não exigem nada, exceto uma chamada para reset() entre os usos, o que o Lucene sempre fará. Só precisamos fazer a montagem 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, os filtros contêm uma referência de volta ao estágio anterior no pipeline, então é assim que os instanciamos. Também incluímos alguns filtros do StandardAnalyzer : LowerCaseFilter e StopFilter . Esses dois devem vir após QuotationTokenFilter para garantir que todas as aspas foram separadas. Podemos ser mais flexíveis em nossa colocação de DialoguePayloadTokenFilter , já que qualquer lugar depois de QuotationTokenFilter servirá. Colocamos depois de StopFilter para evitar perder tempo injetando a carga de diálogo em palavras de parada que serão removidas.

Aqui está uma visualização do nosso novo pipeline em ação (menos as partes do pipeline padrão que removemos ou já vimos):

Nova visualização de pipeline no apache lucene

DialogueAnalyzer agora pode ser usado como qualquer outro Analyzer de ações, e agora podemos construir o índice e prosseguir para a pesquisa.

Pesquisa de texto completo do diálogo

Se quiséssemos apenas pesquisar o diálogo, poderíamos simplesmente descartar todos os tokens fora de uma citação e teríamos terminado. Em vez disso, ao deixar todos os tokens originais intactos, demos a nós mesmos a flexibilidade de realizar consultas que levam em consideração o diálogo ou tratá-lo como qualquer outra parte do texto.

Os fundamentos da consulta de um índice Lucene estão bem documentados. Para nossos propósitos, basta saber que as consultas são compostas de objetos Term unidos a operadores como MUST ou SHOULD , juntamente com documentos de correspondência baseados nesses termos. Os documentos correspondentes são então pontuados com base em um objeto de Similarity configurável e esses resultados podem ser ordenados por pontuação, filtrados ou limitados. Por exemplo, Lucene nos permite fazer uma consulta para os dez principais documentos que devem conter os termos [hello] e [world] .

A personalização dos resultados da pesquisa com base no diálogo pode ser feita ajustando a pontuação de um documento com base na carga útil. O primeiro ponto de extensão para isso será em Similarity , que é responsável por pesar e pontuar os termos correspondentes.

Semelhança e pontuação

As consultas, por padrão, usarão DefaultSimilarity , que pondera os termos com base na frequência com que ocorrem em um documento. É um bom ponto de extensão para ajustar pesos, por isso o estendemos para também pontuar documentos com base na carga útil. O método DefaultSimilarity#scorePayload é fornecido para esta finalidade:

 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; } }

O DialogueAwareSimilarity simplesmente pontua as cargas úteis que não são de diálogo como zero. Como cada Term pode ser correspondido várias vezes, ele potencialmente terá várias pontuações de carga útil. A interpretação dessas pontuações até a implementação da Query .

Preste muita atenção ao BytesRef que contém o payload: devemos verificar o byte em offset , pois não podemos assumir que o array de bytes é o mesmo payload que armazenamos anteriormente. Ao ler o índice, o Lucene não desperdiçará memória alocando uma matriz de bytes separada apenas para a chamada de scorePayload , então obtemos uma referência em uma matriz de bytes existente. Ao codificar com a API Lucene, vale a pena ter em mente que o desempenho é a prioridade, bem à frente da conveniência do desenvolvedor.

Agora que temos nossa nova implementação de Similarity , ela deve ser definida no IndexSearcher usado para executar consultas:

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

Consultas e Termos

Agora que nosso IndexSearcher pode pontuar cargas úteis, também precisamos construir uma consulta que reconheça a carga útil. PayloadTermQuery pode ser usado para corresponder a um único Term enquanto também verifica as cargas dessas correspondências:

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

Essa consulta corresponde ao termo [hello] dentro do campo body (lembre-se que é aqui que colocamos o conteúdo do documento). Também devemos fornecer uma função para calcular a pontuação final da carga útil de todas as correspondências de termos, portanto, AveragePayloadFunction , que calcula a média de todas as pontuações da carga útil. Por exemplo, se o termo [hello] ocorrer dentro do diálogo duas vezes e fora do diálogo uma vez, a pontuação final da carga útil será ²⁄₃. Essa pontuação de carga útil final é multiplicada pela fornecida por DefaultSimilarity para todo o documento.

Usamos uma média porque gostaríamos de enfatizar os resultados da pesquisa onde muitos termos aparecem fora do diálogo e produzir uma pontuação zero para documentos sem nenhum termo em diálogo.

Também podemos compor vários objetos PayloadTermQuery usando um BooleanQuery se quisermos pesquisar vários termos contidos no diálogo (observe que a ordem dos termos é irrelevante nesta consulta, embora outros tipos de consulta sejam sensíveis à posição):

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

Quando esta consulta é executada, podemos ver como a estrutura da consulta e a implementação de similaridade funcionam juntas:

pipeline de análise de diálogo lucene

Execução e explicação da consulta

Para executar a consulta, nós a entregamos ao IndexSearcher :

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

Os objetos Collector são usados ​​para preparar a coleção de documentos correspondentes.

os coletores podem ser compostos para obter uma combinação de classificação, limitação e filtragem. Para obter, por exemplo, os dez principais documentos de pontuação que contêm pelo menos um termo em diálogo, combinamos TopScoreDocCollector e PositiveScoresOnlyCollector . Tomar apenas pontuações positivas garante que as correspondências de pontuação zero (ou seja, aquelas sem termos em diálogo) sejam filtradas.

Para ver essa consulta em ação, podemos executá-la e usar IndexSearcher#explain para ver como os documentos individuais foram pontuados:

 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)); }

Aqui, iteramos sobre os IDs de documentos nos TopDocs obtidos pela pesquisa. Também usamos IndexSearcher#doc para recuperar o campo de título para exibição. Para nossa consulta de "hello" , isso resulta em:

 --- 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() ...

Embora a saída esteja carregada de jargões, podemos ver como nossa implementação de Similarity personalizada foi usada na pontuação e como a MaxPayloadFunction produziu um multiplicador de 1.0 para essas correspondências. Isso implica que a carga útil foi carregada e pontuada, e todas as correspondências de "Hello" ocorreram no diálogo e, portanto, esses resultados estão bem no topo, onde os esperamos.

Também vale a pena ressaltar que o índice do Projeto Gutenberg, com cargas úteis, chega a quase quatro gigabytes de tamanho e, ainda assim, na minha modesta máquina de desenvolvimento, as consultas ocorrem instantaneamente. Não sacrificamos nenhuma velocidade para atingir nossos objetivos de busca.

Empacotando

O Lucene é uma biblioteca de pesquisa de texto completo poderosa e desenvolvida para fins específicos que pega um fluxo bruto de caracteres, agrupa-os em tokens e os mantém como termos em um índice. Ele pode consultar rapidamente esse índice e fornecer resultados classificados, além de oferecer ampla oportunidade de extensão, mantendo a eficiência.

Ao usar o Lucene diretamente em nossos aplicativos ou como parte de um servidor, podemos realizar pesquisas de texto completo em tempo real em gigabytes de conteúdo. Além disso, por meio de análise e pontuação personalizadas, podemos tirar proveito de recursos específicos de domínio em nossos documentos para melhorar a relevância dos resultados ou consultas personalizadas.

As listagens completas de código para este tutorial do Lucene estão disponíveis no GitHub. O repositório contém dois aplicativos: LuceneIndexerApp para criar o índice e LuceneQueryApp para realizar consultas.

O corpus do Projeto Gutenberg, que pode ser obtido como uma imagem de disco via BitTorrent, contém muitos livros que valem a pena ser lidos (com Lucene, ou apenas à moda antiga).

Boa indexação!