Um tutorial do Elasticsearch para desenvolvedores .NET

Publicados: 2022-03-11

Um desenvolvedor .NET deve usar o Elasticsearch em seus projetos? Embora o Elasticsearch seja construído em Java, acredito que ele oferece muitas razões pelas quais o Elasticsearch vale a pena tentar pesquisar texto completo para qualquer projeto.

O Elasticsearch, como tecnologia, percorreu um longo caminho nos últimos anos. Ele não apenas faz a pesquisa de texto completo parecer mágica, mas também oferece outros recursos sofisticados, como preenchimento automático de texto, pipelines de agregação e muito mais.

Se a ideia de introduzir um serviço baseado em Java em seu ecossistema .NET o deixa desconfortável, não se preocupe, pois depois de instalar e configurar o Elasticsearch, você passará a maior parte do tempo com um dos pacotes .NET mais legais do mercado. lá: NEST.

Neste artigo, você aprenderá como usar a incrível solução de mecanismo de pesquisa Elasticsearch em seus projetos .NET.

Instalando e Configurando

A instalação do Elasticsearch em seu ambiente de desenvolvimento se resume ao download do Elasticsearch e, opcionalmente, do Kibana.

Quando descompactado, um arquivo bat como este é útil:

 cd "D:\elastic\elasticsearch-5.2.2\bin" start elasticsearch.bat cd "D:\elastic\kibana-5.0.0-windows-x86\bin" start kibana.bat exit

Depois de iniciar os dois serviços, você sempre pode verificar o servidor Kibana local (geralmente disponível em http://localhost:5601), brincar com índices e tipos e pesquisar usando JSON puro, conforme amplamente descrito aqui.

O primeiro passo

Sendo um desenvolvedor completo e bom, com total suporte e compreensão da gerência, você começa adicionando um projeto de teste de unidade e escrevendo um SearchService com pelo menos 90% de cobertura de código.

A primeira etapa é configurar claramente o arquivo app.config para fornecer uma string de tipo de conexão para o servidor Elasticsearch.

O legal do Elasticsearch é que ele é totalmente gratuito. Mas, eu ainda aconselharia usar o serviço Elastic Cloud fornecido pela Elastic.co. O serviço hospedado facilita bastante toda a manutenção e configuração. Ainda mais, você tem duas semanas de teste gratuito, o que deve ser mais do que suficiente para experimentar todos os exemplos aqui!

Como aqui estamos executando localmente, uma chave de configuração como esta deve fazer:

 <add key="Search-Uri" value="http://localhost:9200" />

A instalação do Elasticsearch é executada na porta 9200 por padrão, mas você pode alterá-la se desejar.

ElasticClient e o pacote NEST

O ElasticClient é um simpático companheiro que fará a maior parte do trabalho para nós, e vem com o pacote NEST.

Vamos primeiro instalar o pacote.

Para configurar o cliente, algo assim pode ser usado:

 var node = new Uri(ConfigurationManager.AppSettings["Search-Uri"]); var settings = new ConnectionSettings(node); settings.ThrowExceptions(alwaysThrow: true); // I like exceptions settings.PrettyJson(); // Good for DEBUG var client = new ElasticClient(settings);

Indexação e mapeamento

Para poder pesquisar algo, devemos armazenar alguns dados no ES. O termo usado é “indexação”.

O termo “mapeamento” é usado para mapear nossos dados no banco de dados para objetos que serão serializados e armazenados no Elasticsearch. Usaremos o Entity Framework (EF) neste tutorial.

Geralmente, ao usar o Elasticsearch, você provavelmente está procurando uma solução de mecanismo de pesquisa em todo o site. Você usará algum tipo de feed ou resumo ou pesquisa semelhante ao Google que retorna todos os resultados de várias entidades, como usuários, entradas de blog, produtos, categorias, eventos etc.

Provavelmente não será apenas uma tabela ou entidade em seu banco de dados, mas sim, você desejará agregar diversos dados e talvez extrair ou derivar algumas propriedades comuns como título, descrição, data, autor/proprietário, foto e assim por diante. Outra coisa é que você provavelmente não fará isso em uma consulta, mas se estiver usando um ORM, terá que escrever uma consulta separada para cada uma dessas entradas de blog, usuários, produtos, categorias, eventos ou qualquer outra coisa.

Estruturei meus projetos criando um índice para cada tipo “grande”, por exemplo, postagem de blog ou produto. Alguns tipos do Elasticsearch podem ser adicionados para tipos mais específicos que se enquadram no mesmo índice. Por exemplo, se um artigo puder ser uma história, um artigo em vídeo ou um podcast, ele ainda estará no índice “artigo”, mas teríamos esses quatro tipos nesse índice. No entanto, ainda é provável que seja a mesma consulta no banco de dados.

Tenha em mente que você precisa de pelo menos um tipo para cada índice—provavelmente um tipo que tenha o mesmo nome do índice.

Para mapear suas entidades, você desejará criar algumas classes adicionais. Eu costumo usar a classe DocumentSearchItemBase , da qual cada uma das classes especializadas herdará BlogPostSearchItem , ProductSearchItem e assim por diante.

Eu gosto de ter expressões mapeadoras dentro dessas classes. Eu sempre posso modificar as expressões, se necessário, no futuro.

Em um dos meus primeiros projetos com o Elasticsearch, escrevi uma classe SearchService bastante grande com mapeamentos e indexação feitos com instruções switch-case agradáveis ​​e longas: Para cada tipo de entidade que quero lançar no Elasticsearch, havia um switch e uma consulta com mapeamento que fez isso.

No entanto, ao longo do processo, aprendi que não é o melhor caminho, pelo menos não para mim.

Uma solução mais elegante é ter algum tipo de classe inteligente IndexDefinition e uma classe de definição de índice específica para cada índice. Dessa forma, minha classe IndexDefinition base pode armazenar uma lista de todos os índices disponíveis e alguns métodos auxiliares, como analisadores e relatórios de status necessários, enquanto as classes específicas de índice derivadas lidam com a consulta do banco de dados e o mapeamento dos dados para cada índice especificamente. Isso é útil especialmente quando você precisa adicionar uma entidade adicional ao ES algum tempo depois. Tudo se resume a adicionar outra classe SomeIndexDefinition que herda de IndexDefinition e exige que você implemente apenas alguns métodos que consultam os dados que você deseja em seu índice.

O Elasticsearch Fala

No centro de tudo o que você pode fazer com o Elasticsearch está sua linguagem de consulta. Idealmente, tudo o que você precisa para poder se comunicar com o Elasticsearch é saber como construir um objeto de consulta.

Nos bastidores, o Elasticsearch expõe suas funcionalidades como uma API baseada em JSON sobre HTTP.

Embora a própria API e a estrutura do objeto de consulta sejam bastante intuitivas, lidar com muitos cenários da vida real ainda pode ser um incômodo.

Geralmente, uma solicitação de pesquisa ao Elasticsearch requer as seguintes informações:

  • Qual índice e quais tipos são pesquisados

  • Informações de paginação (quantos itens pular e quantos itens retornar)

  • Uma seleção de tipo concreto (ao fazer uma agregação, como estamos prestes a fazer aqui)

  • A própria consulta

  • Definição de destaque (o Elasticsearch pode destacar automaticamente os hits, se quisermos)

Por exemplo, você pode querer implementar um recurso de pesquisa onde apenas alguns dos usuários possam ver o conteúdo premium em seu site, ou você pode querer que algum conteúdo seja visível apenas para os “amigos” de seus autores e assim por diante.

Ser capaz de construir o objeto de consulta está no centro das soluções para esses problemas e pode realmente ser um problema ao tentar cobrir muitos cenários.

De todos os itens acima, o mais importante e mais difícil de configurar é, naturalmente, o segmento de consulta - e aqui, nos concentraremos principalmente nele.

As consultas são construções recursivas combinadas de BoolQuery e outras consultas, como MatchPhraseQuery , TermsQuery , DateRangeQuery e ExistsQuery . Esses foram suficientes para atender a quaisquer requisitos básicos e devem ser bons para começar.

Uma consulta MultiMatch é muito importante, pois nos permite especificar campos nos quais queremos fazer a pesquisa e ajustar um pouco mais os resultados - aos quais retornaremos mais tarde.

Um MatchPhraseQuery pode filtrar resultados pelo que seria uma chave estrangeira em bancos de dados SQL convencionais ou valores estáticos como enums — por exemplo, ao corresponder resultados por autor específico ( AuthorId ) ou corresponder a todos os artigos públicos ( ContentPrivacy=Public ).

TermsQuery seria traduzido como “in” na linguagem SQL convencional. Por exemplo, ele pode devolver todos os artigos escritos por um dos amigos do usuário ou obter produtos exclusivamente de um conjunto fixo de comerciantes. Assim como no SQL, não se deve abusar disso e colocar 10.000 membros nessa matriz, pois isso terá impacto no desempenho, mas geralmente lida com quantidades razoáveis ​​bastante bem.

DateRangeQuery é auto-documentado.

ExistsQuery é interessante: permite ignorar ou retornar documentos que não possuem um campo específico.

Estes, quando combinados com BoolQuery , permitem definir uma lógica de filtragem complexa.

Pense em um site de blog, por exemplo, onde as postagens do blog podem ter um campo AvailableFrom que indica quando elas devem se tornar visíveis.

Se aplicarmos um filtro como AvailableFrom <= Now , não obteremos documentos que não tenham esse campo específico (agregamos dados e alguns documentos podem não ter esse campo definido). Para resolver o problema, você combinaria ExistsQuery com DateRangeQuery e o envolveria em BoolQuery com a condição de que pelo menos um elemento em BoolQuery seja atendido. Algo assim:

 BoolQuery Should (at least one of the following conditions should be fulfilled) DateRangeQuery with AvailableFrom condition Negated ExistsQuery for field AvailableFrom

Negar consultas não é um trabalho tão simples e pronto para uso. Mas com a ajuda de BoolQuery , é possível, no entanto:

 BoolQuery MustNot ExistsQuery

Automação e testes

Para facilitar as coisas, o método recomendado é definitivamente escrever testes à medida que avança.

Dessa forma, você poderá experimentar com mais eficiência e, ainda mais importante, garantirá que quaisquer novas alterações introduzidas (como filtros mais complexos) não quebrem a funcionalidade existente. Eu explicitamente não queria dizer "testes de unidade", já que não sou fã de zombar de algo como o mecanismo do Elasticsearch - o simulado quase nunca será uma aproximação realista de como o ES realmente se comporta - portanto, isso poderia ser testes de integração, se você é um fã de terminologia.

Exemplos do mundo real

Depois que todo o trabalho de base é feito com indexação, mapeamento e filtragem, agora estamos prontos para a parte mais interessante: ajustar os parâmetros de pesquisa para obter melhores resultados.

No meu último projeto, usei o Elasticsearch para fornecer um feed de usuário: todo o conteúdo agregado a um local ordenado por data de criação e pesquisa de texto completo com algumas das opções. O feed em si é bastante direto; apenas certifique-se de que haja um campo de data em algum lugar em seus dados e ordene por esse campo.

A pesquisa, por outro lado, não funcionará surpreendentemente bem fora da caixa. Isso porque, naturalmente, o Elasticsearch não pode saber quais são as coisas importantes em seus dados. Digamos que temos alguns dados que (entre outros campos) possuem os campos Title , Tags (array) e Body . O campo do corpo pode ser conteúdo HTML (para tornar as coisas um pouco mais realistas).

Erros de ortografia

O requisito: Nossa pesquisa deve retornar resultados mesmo se ocorrerem erros de ortografia ou se a terminação da palavra for diferente. Por exemplo, se houver um artigo com o título “Coisas magníficas que você pode fazer com uma colher de pau”, quando eu procurar por “coisa” ou “madeira”, eu ainda gostaria de obter uma correspondência.

Para lidar com isso, teremos que nos familiarizar com analisadores, tokenizers, filtros de caracteres e filtros de token. Essas são as transformações que são aplicadas no momento da indexação.

  • Os analisadores precisam ser definidos. Isso pode ser definido por índice.

  • Os analisadores podem ser aplicados a alguns campos em nossos documentos. Isso pode ser feito usando atributos ou API fluente. Em nosso exemplo, estamos usando atributos.

  • Os analisadores são uma combinação de filtros, filtros de caracteres e tokenizers.

Para cumprir o requisito (correspondência parcial de palavras), criaremos o analisador “autocomplete”, que consiste em:

  • Um filtro de palavras irrelevantes em inglês: o filtro que remove todas as palavras comuns em inglês, como “and” ou “the”.

  • Filtro de corte: remove o espaço em branco ao redor de cada token

  • Filtro de minúsculas: converte todos os caracteres em minúsculas. Isso não significa que, quando buscarmos nossos dados, eles serão convertidos em minúsculas, mas, em vez disso, habilitará a pesquisa invariável entre maiúsculas e minúsculas.

  • Tokenizer Edge-n-gram: este tokenizer nos permite ter correspondências parciais. Por exemplo, se tivermos uma frase “Minha avó tem uma cadeira de madeira”, ao procurar o termo “madeira”, ainda gostaríamos de obter uma resposta nessa frase. O que o edge-n-gram faz é armazenar “woo”, “wood”, “woode” e “wooden” para que qualquer palavra parcial com pelo menos três letras seja encontrada. Os parâmetros MinGram e MaxGram definem o número mínimo e máximo de caracteres a serem armazenados. No nosso caso, teremos no mínimo três e no máximo 15 letras.

Na seção a seguir, todos eles estão vinculados:

 analysis.Analyzers(a => a .Custom("autocomplete", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .Tokenizer("autocomplete") ) .Tokenizers(tdesc => tdesc .EdgeNGram("autocomplete", e => e .MinGram(3) .MaxGram(15) .TokenChars(TokenChar.Letter, TokenChar.Digit) ) ) .TokenFilters(f => f .Stop("eng_stopwords", lang => lang .StopWords("_english_") ) );

E, quando quisermos usar este analisador, devemos apenas anotar os campos que queremos assim:

 public class SearchItemDocumentBase { ... [Text(Analyzer = "autocomplete", Name = nameof(Title))] public string Title { get; set; } ... }

Agora, vamos dar uma olhada em alguns exemplos que demonstram requisitos bastante comuns em quase todos os aplicativos com muito conteúdo.

Limpeza de HTML

O requisito: Alguns de nossos campos podem conter texto HTML.

Naturalmente, você não gostaria de pesquisar por “section” para retornar algo como “<section>…</section>” ou “body” retornando o elemento HTML “<body>.” Para evitar isso, durante a indexação, retiraremos o HTML e deixaremos apenas o conteúdo dentro.

Felizmente, você não é o primeiro com esse problema. O Elasticsearch vem com um filtro de caracteres útil para isso:

 analysis.Analyzers(a => a .Custom("html_stripper", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .CharFilters("html_strip") .Tokenizer("autocomplete") )

E para aplicar:

 [Text(Analyzer = "html_stripper", Name = nameof(HtmlText))] public string HtmlText { get; set; }

Campos importantes

O requisito: as correspondências em um título devem ser mais importantes do que as correspondências no conteúdo.

Felizmente, o Elasticsearch oferece estratégias para aumentar os resultados se a correspondência ocorrer em um campo ou outro. Isso é feito na construção da consulta de pesquisa usando a opção de boost :

 const int titleBoost = 15; .Query(qx => qx.MultiMatch(m => m .Query(searchRequest.Query.ToLower()) .Fields(ff => ff .Field(f => f.Title, boost: titleBoost) .Field(f => f.Summary) ... ) .Type(TextQueryType.BestFields) ) && filteringQuery)

Como você pode ver, a consulta MultiMatch é muito útil em situações como essa, e situações como essa não são tão raras assim! Muitas vezes, alguns campos são mais importantes e outros não – esse mecanismo nos permite levar isso em consideração.

Nem sempre é fácil definir valores de aumento imediatamente. Você precisará brincar um pouco com isso para obter os resultados desejados.

Priorizando artigos

O requisito: Alguns artigos são mais importantes que outros. Ou o autor é mais importante, ou o próprio artigo tem mais curtidas/compartilhamentos/upvotes/etc. Artigos mais importantes devem ter uma classificação mais alta.

O Elasticsearch nos permite implementar nossa função de pontuação e a simplificamos de forma que definimos um campo "Importância", que é o valor duplo - no nosso caso, maior que 1. Você pode definir sua própria função/fator de importância e aplicá-la similarmente. Você pode definir vários modos de aumento e pontuação - o que melhor lhe convier. Este funcionou bem para nós:

 .Query(q => q .FunctionScore(fsc => fsc .BoostMode(FunctionBoostMode.Multiply) .ScoreMode(FunctionScoreMode.Sum) .Functions(f => f .FieldValueFactor(b => b .Field(nameof(SearchItemDocumentBase.Rating)) .Missing(0.7) .Modifier(FieldValueFactorModifier.None) ) ) .Query(qx => qx.MultiMatch(m => m .Query(searchRequest.Query.ToLower()) .Fields(ff => ff ... ) .Type(TextQueryType.BestFields) ) && filteringQuery) ) )

Cada filme tem uma classificação, e deduzimos a classificação do ator pela média das classificações dos filmes em que foram escalados (um método não muito científico). Escalamos essa classificação para um valor duplo no intervalo [0,1].

Correspondências de palavras completas

O requisito: correspondências de palavras completas devem ter uma classificação mais alta.

Até agora, estamos obtendo resultados bastante bons para nossas pesquisas, mas você pode notar que alguns resultados que contêm correspondências parciais podem ter uma classificação mais alta do que as correspondências exatas. Para lidar com isso, adicionamos um campo adicional em nosso documento chamado “Palavras-chave”, que não usa um analisador de preenchimento automático, mas usa um tokenizador de palavra-chave e fornece um fator de aumento para aumentar os resultados de correspondência exata.

Este campo corresponderá apenas se a palavra exata for correspondida. Não corresponderá “madeira” a “madeira” como o analisador de preenchimento automático faz.

Embrulhar

Este artigo deve fornecer uma visão geral de como configurar o Elasticsearch em seu projeto .NET e, com um pouco de esforço, fornecer uma boa funcionalidade de pesquisa em todos os lugares.

A curva de aprendizado pode ser um pouco íngreme, mas vale a pena, especialmente quando você a ajusta da maneira certa e começa a obter ótimos resultados de pesquisa.

Lembre-se sempre de adicionar casos de teste completos com os resultados esperados para garantir que você não estrague muito os parâmetros ao introduzir alterações e brincar.

O código completo deste artigo está disponível no GitHub e usa dados extraídos do banco de dados TMDB para mostrar como os resultados da pesquisa estão melhorando a cada etapa.