Fundamentos do Elasticsearch

Elasticsearch — um mecanismo de busca com API REST JSON, que utiliza Lucene e é escrito em Java. A descrição de todas as vantagens deste mecanismo está disponível em site oficial. Daqui em diante, chamaremos Elasticsearch de ES.

Mecanismos como esse são usados em buscas complexas em bancos de documentos. Por exemplo, busca levando em conta a morfologia da língua ou busca por coordenadas geográficas.

Neste artigo, falarei sobre os fundamentos do ES usando o exemplo da indexação de posts de blog. Vou mostrar como filtrar, classificar e buscar documentos.

Para não depender do sistema operacional, farei todas as requisições ao ES usando CURL. Também há um plugin para o Google Chrome chamado sense.

O texto contém links para a documentação e outras fontes. No final, estão links para acesso rápido à documentação. As definições de termos desconhecidos podem ser lidas no glossário.

Instalação do ES

Para isso, primeiro precisaremos do Java. Os desenvolvedores recomendam devem instalar versões do Java superiores a Java 8 update 20 ou Java 7 update 55.

O pacote do ES está disponível em site do desenvolvedor. Após descompactar o arquivo, é necessário iniciar bin/elasticsearch. Também estão disponíveis pacotes para apt e yum. Há imagem oficial para docker. Saiba mais sobre a instalação.

Após a instalação e execução, vamos verificar se está funcionando:

# для удобства запомним адрес в переменную
#export ES_URL=$(docker-machine ip dev):9200
export ES_URL=localhost:9200

curl -X GET $ES_URL

Receberemos uma resposta semelhante a esta:

{
  "name" : "Heimdall",
  "cluster_name" : "elasticsearch",
  "version" : {
    "number" : "2.2.1",
    "build_hash" : "d045fc29d1932bce18b2e65ab8b297fbf6cd41a1",
    "build_timestamp" : "2016-03-09T09:38:54Z",
    "build_snapshot" : false,
    "lucene_version" : "5.4.1"
  },
  "tagline" : "You Know, for Search"
}

Indexação

Vamos adicionar um post ao ES:

# Добавим документ c id 1 типа post в индекс blog.
# ?pretty указывает, что вывод должен быть человеко-читаемым.

curl -XPUT "$ES_URL/blog/post/1?pretty" -d'
{
  "title": "Веселые котята",
  "content": "<p>Uma história engraçada sobre gatinhos<p>",
  "tags": [
    "gatinhos",
    "história engraçada"
  ],
  "published_at": "2014-09-12T20:44:42+00:00"
}'

resposta do servidor:

{
  "_index" : "blog",
  "_type" : "post",
  "_id" : "1",
  "_version" : 1,
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "created" : false
}

O ES criou automaticamente índice blog e o tipo post. Pode-se fazer uma analogia: o índice é como um banco de dados, e o tipo é como uma tabela nesse banco. Cada tipo tem seu próprio esquema — mapeamento, assim como uma tabela relacional. O mapeamento é gerado automaticamente ao indexar um documento:

# Получим mapping всех типов индекса blog
curl -XGET "$ES_URL/blog/_mapping?pretty"

Na resposta do servidor, adicionei em comentários os valores dos campos do documento indexado:

{
  "blog" : {
    "mappings" : {
      "post" : {
        "properties" : {
          /* "content": "<p>Uma história engraçada sobre gatinhos<p>", /*
          "content" : {
            "type" : "string"
          },
          /* "published_at": "2014-09-12T20:44:42+00:00" */
          "published_at" : {
            "type" : "date",
            "format" : "strict_date_optional_time||epoch_millis"
          },
          /* "tags": ["котята", "смешная история"] */
          "tags" : {
            "type" : "string"
          },
          /*  "title": "Веселые котята" */
          "title" : {
            "type" : "string"
          }
        }
      }
    }
  }
}

Vale destacar que o ES não faz distinção entre um único valor e um array de valores. Por exemplo, o campo title contém apenas um título, enquanto o campo tags é um array de strings, embora sejam apresentados no mapeamento de forma idêntica.
Mais tarde, falaremos sobre o mapeamento de forma mais detalhada.

Consultas

Extraindo um documento pelo seu ID:

# извлечем документ с id 1 типа post из индекса blog
curl -XGET "$ES_URL/blog/post/1?pretty"
{
    "_index": "blog",
    "_type": "post",
    "_id": "1",
    "_version": 1,
    "found": true,
    "_source": {
        "title": "\u0412\u0435\u0441\u0435\u043b\u044b\u0435 \u043a\u043e\u0442\u044f\u0442\u0430",
        "content": "<p>Uma hist\u00f3ria engra\u00e7ada sobre gatinhos<p>",
        "tags": [
            "\u043a\u043e\u0442\u044f\u0442\u0430",
            "\u0441\u043c\u0435\u0448\u043d\u0430\u044f \u0438\u0441\u0442\u043e\u0440\u0438\u044f"
        ],
        "published_at": "2014-09-12T20:44:42+00:00"
    }
}

Na resposta, surgiram novas chaves: _version e _source. De fato, todas as chaves que começam com _ são relacionadas a serviços.

Chave _version mostra a versão do documento. Isso é necessário para o funcionamento do mecanismo de bloqueio otimista. Por exemplo, queremos alterar um documento que possui a versão 1. Enviamos o documento modificado e indicamos que se trata de uma edição do documento com a versão 1. Se outra pessoa também editou o documento com a versão 1 e enviou as alterações antes de nós, então o ES não aceitará nossas mudanças, pois ele armazena o documento com a versão 2.

Chave _source contém o documento que indexamos. O ES não usa esse valor para operações de busca, pois os índices são utilizados para busca. Para economizar espaço, o ES armazena o documento original de forma compactada. Se precisarmos apenas do id, e não do documento original completo, podemos desativar o armazenamento do original.

Se não precisarmos de informações adicionais, podemos obter apenas o conteúdo de _source:

curl -XGET "$ES_URL/blog/post/1/_source?pretty"
{
    "title": "\u0412\u0435\u0441\u0435\u043b\u044b\u0435 \u043a\u043e\u0442\u044f\u0442\u0430",
    "content": "<p>Uma hist\u00f3ria engra\u00e7ada sobre gatinhos<p>",
    "tags": [
        "\u043a\u043e\u0442\u044f\u0442\u0430",
        "\u0441\u043c\u0435\u0448\u043d\u0430\u044f \u0438\u0441\u0442\u043e\u0440\u0438\u044f"
    ],
    "published_at": "2014-09-12T20:44:42+00:00"
}

Também é possível selecionar apenas determinados campos:

# извлечем только поле title
curl -XGET "$ES_URL/blog/post/1?_source=title&pretty"
{
  "_index" : "blog",
  "_type" : "post",
  "_id" : "1",
  "_version" : 1,
  "found" : true,
  "_source" : {
    "title" : "Gatinhos divertidos"
  }
}

Vamos indexar mais algumas postagens e realizar consultas mais complexas.

curl -XPUT "$ES_URL/blog/post/2" -d'
{
  "title": "Cachorrinhos Divertidos",
  "content": "<p>Uma história engraçada sobre cachorros<p>"
  "tags": [
    "cachorros",
    "história engraçada"
  ],
  "published_at": "2014-08-12T20:44:42+00:00"
}'
curl -XPUT "$ES_URL/blog/post/3" -d'
{
  "title": "Como eu ganhei um filhote de gato",
  "content": "<p>Uma história comovente sobre um pobre filhote de gato da rua<p>",
  "tags": [
    "filhotes"
  ],
  "published_at": "2014-07-21T20:44:42+00:00"
}'

Classificação

# найдем последний пост по дате публикации и извлечем поля title и published_at
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "size": 1,
  "_source": ["title", "published_at"],
  "sort": [{"published_at": "desc"}]
}'
{
  "took" : 8,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 3,
    "max_score" : null,
    "hits" : [ {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "1",
      "_score" : null,
      "_source" : {
        "title" : "Gatinhos divertidos",
        "published_at" : "2014-09-12T20:44:42+00:00"
      },
      "sort" : [ 1410554682000 ]
    } ]
  }
}

Selecionamos a última postagem. tamanho limita o número de documentos nos resultados. total mostra o número total de documentos que correspondem à solicitação. sort nos resultados contém um array de números inteiros, que são usados para a ordenação. Ou seja, a data foi convertida em um número inteiro. Mais informações sobre ordenação podem ser encontradas em documentação.

Filtros e solicitações

ES a partir da versão 2 não distingue entre filtros e solicitações, em vez disso é introduzido o conceito de contextos.
O contexto de uma solicitação difere do contexto de um filtro, pois a solicitação gera _score e não é armazenada em cache. O que é _score eu mostrarei mais tarde.

Filtragem por data

Usamos a solicitação range no contexto do filtro:

# получим посты, опубликованные 1ого сентября или позже
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "filter": {
    "range": {
      "published_at": { "gte": "2014-09-01" }
    }
  }
}'

Filtragem por tags

Usaremos consulta de termo para buscar ids de documentos que contenham a palavra especificada:

# найдем все документы, в поле tags которых есть элемент 'котята'
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "_source": [
    "title",
    "tags"
  ],
  "filter": {
    "term": {
      "tags": "котята"
    }
  }
}'
{
  "took" : 9,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 2,
    "max_score" : 1.0,
    "hits" : [ {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "1",
      "_score" : 1.0,
      "_source" : {
        "title" : "Gatinhos Divertidos",
        "tags" : [ "gatinhos", "história engraçada" ]
      }
    }, {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "3",
      "_score" : 1.0,
      "_source" : {
        "title" : "Como eu adotei um gatinho",
        "tags" : [ "gatinhos" ]
      }
    } ]
  }
}

Busca de texto completo

Nossos três documentos contêm o seguinte no campo content:

  • <p>Uma história engraçada sobre gatinhos<p>
  • <p>Uma história engraçada sobre cachorros<p>
  • <p>Uma história comovente sobre um pobre filhote de gato da rua<p>

Usaremos consulta de correspondência para buscar ids de documentos que contenham a palavra especificada:

# source: false означает, что не нужно извлекать _source найденных документов
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "_source": false,
  "query": {
    "match": {
      "content": "история"
    }
  }
}'
{
  "took" : 13,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 3,
    "max_score" : 0.11506981,
    "hits" : [ {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "2",
      "_score" : 0.11506981
    }, {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "1",
      "_score" : 0.11506981
    }, {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "3",
      "_score" : 0.095891505
    } ]
  }
}

No entanto, se buscarmos "histórias" no campo de conteúdo, não encontraremos nada, pois o índice contém apenas as palavras originais e não suas raízes. Para realizar uma busca de qualidade, é necessário configurar o analisador.

Campo _score mostra relevância. Se a consulta for executada no contexto de filtro, o valor de _score sempre será 1, o que significa total conformidade com o filtro.

Analisadores

Analisadores são necessários para transformar o texto original em um conjunto de tokens.
Analisadores consistem em um Tokenizer e vários opcionais TokenFilters. O Tokenizer pode preceder vários CharFilters. O Tokenizer divide a string original em tokens, por exemplo, por espaços e símbolos de pontuação. O TokenFilter pode modificar os tokens, removendo ou adicionando novos, por exemplo, mantendo apenas a raiz da palavra, removendo preposições, adicionando sinônimos. O CharFilter altera a string original completamente, por exemplo, cortando tags HTML.

No ES, existem vários analisadores padrão. Por exemplo, o analisador russian.

Vamos utilizar api e vamos ver como os analisadores standard e russian transformam a string "Histórias Divertidas sobre Gatinhos":

# используем анализатор standard       
# обязательно нужно перекодировать не ASCII символы
curl -XGET "$ES_URL/_analyze?pretty&analyzer=standard&text=%D0%92%D0%B5%D1%81%D0%B5%D0%BB%D1%8B%D0%B5%20%D0%B8%D1%81%D1%82%D0%BE%D1%80%D0%B8%D0%B8%20%D0%BF%D1%80%D0%BE%20%D0%BA%D0%BE%D1%82%D1%8F%D1%82"
{
  "tokens" : [ {
    "token" : "divertidas",
    "start_offset" : 0,
    "end_offset" : 9,
    "type" : "",
    "position" : 0
  }, {
    "token" : "histórias",
    "start_offset" : 10,
    "end_offset" : 18,
    "type" : "",
    "position" : 1
  }, {
    "token" : "sobre",
    "start_offset" : 19,
    "end_offset" : 24,
    "type" : "",
    "position" : 2
  }, {
    "token" : "gatinhos",
    "start_offset" : 25,
    "end_offset" : 33,
    "type" : "",
    "position" : 3
  } ]
}
# используем анализатор russian
curl -XGET "$ES_URL/_analyze?pretty&analyzer=russian&text=%D0%92%D0%B5%D1%81%D0%B5%D0%BB%D1%8B%D0%B5%20%D0%B8%D1%81%D1%82%D0%BE%D1%80%D0%B8%D0%B8%20%D0%BF%D1%80%D0%BE%20%D0%BA%D0%BE%D1%82%D1%8F%D1%82"
{
  "tokens" : [ {
    "token" : "divertido",
    "start_offset" : 0,
    "end_offset" : 7,
    "type" : "",
    "position" : 0
  }, {
    "token" : "história",
    "start_offset" : 8,
    "end_offset" : 15,
    "type" : "",
    "position" : 1
  }, {
    "token" : "gato",
    "start_offset" : 20,
    "end_offset" : 25,
    "type" : "",
    "position" : 3
  } ]
}

O analisador padrão dividiu a string por espaços e converteu tudo para minúsculas, o analisador russo removeu palavras irrelevantes, converteu para minúsculas e manteve a raiz das palavras.

Vamos ver quais Tokenizers, TokenFilters, CharFilters são utilizados pelo analisador russo:

{
  "filter": {
    "russian_stop": {
      "type":       "stop",
      "stopwords":  "_russian_"
    },
    "russian_keywords": {
      "type":       "keyword_marker",
      "keywords":   []
    },
    "russian_stemmer": {
      "type":       "stemmer",
      "language":   "russian"
    }
  },
  "analyzer": {
    "russian": {
      "tokenizer":  "standard",
      /* TokenFilters */
      "filter": [
        "lowercase",
        "russian_stop",
        "russian_keywords",
        "russian_stemmer"
      ]
      /* CharFilters ausentes */
    }
  }
}

Descreveremos nosso analisador baseado no russo, que irá remover as tags HTML. Vamos chamá-lo de default, pois um analisador com esse nome será usado por padrão.

{
  "filter": {
    "ru_stop": {
      "type":       "stop",
      "stopwords":  "_russian_"
    },
    "ru_stemmer": {
      "type":       "stemmer",
      "language":   "russian"
    }
  },
  "analyzer": {
    "default": {
      /* adicionando remoção de tags HTML */
      "char_filter": ["html_strip"],
      "tokenizer":  "standard",
      "filter": [
        "lowercase",
        "ru_stop",
        "ru_stemmer"
      ]
    }
  }
}

Primeiro, todas as tags HTML serão removidas da string original, depois ela será dividida em tokens pelo tokenizer padrão, os tokens obtidos serão convertidos para minúsculas, palavras irrelevantes serão removidas e da lista restante de tokens, a raiz da palavra será mantida.

Criação do índice

Acima, descrevemos o analisador default. Ele será aplicado a todos os campos de string. Nossa postagem contém um array de tags, portanto, as tags também serão processadas pelo analisador. Como estamos buscando postagens por correspondência exata da tag, é necessário desativar a análise para o campo tags.

Vamos criar o índice blog2 com o analisador e mapeamento, onde a análise do campo tags está desativada:

curl -XPOST "$ES_URL/blog2" -d'
{
  "settings": {
    "analysis": {
      "filter": {
        "ru_stop": {
          "type": "stop",
          "stopwords": "_russian_"
        },
        "ru_stemmer": {
          "type": "stemmer",
          "language": "russian"
        }
      },
      "analyzer": {
        "default": {
          "char_filter": [
            "html_strip"
          ],
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "ru_stop",
            "ru_stemmer"
          ]
        }
      }
    }
  },
  "mappings": {
    "post": {
      "properties": {
        "content": {
          "type": "string"
        },
        "published_at": {
          "type": "date"
        },
        "tags": {
          "type": "string",
          "index": "not_analyzed"
        },
        "title": {
          "type": "string"
        }
      }
    }
  }
}'

Vamos adicionar os mesmos 3 posts a este índice (blog2). Vou omitir este processo, pois é análogo à adição de documentos no índice blog.

Busca de texto completo com suporte a expressões

Vamos conhecer outro tipo de consultas:

# найдем документы, в которых встречается слово 'истории'
# query -> simple_query_string -> query содержит поисковый запрос
# поле title имеет приоритет 3
# поле tags имеет приоритет 2
# поле content имеет приоритет 1
# приоритет используется при ранжировании результатов
curl -XPOST "$ES_URL/blog2/post/_search?pretty" -d'
{
  "query": {
    "simple_query_string": {
      "query": "истории",
      "fields": [
        "title^3",
        "tags^2",
        "content"
      ]
    }
  }
}'

Como estamos usando um analisador com stemming em russo, esta consulta retornará todos os documentos, mesmo que contenham apenas a palavra 'história'.

A consulta pode conter caracteres especiais, por exemplo:

""fried eggs" +(eggplant | potato) -frittata"

Sintaxe da consulta:

+ significa operação AND
| significa operação OR
- nega um único token
" envolve um número de tokens para significar uma frase de busca
* no final de um termo significa uma consulta de prefixo
( e ) significam precedência
~N após uma palavra significa distância de edição (fuzziness)
~N após uma frase significa quantidade de slop
# найдем документы без слова 'щенки'
curl -XPOST "$ES_URL/blog2/post/_search?pretty" -d'
{
  "query": {
    "simple_query_string": {
      "query": "-щенки",
      "fields": [
        "title^3",
        "tags^2",
        "content"
      ]
    }
  }
}'

# получим 2 поста про котиков

Links

PS

Se você está interessado em artigos-aulas semelhantes, tem ideias para novos artigos ou sugestões de colaboração, ficarei feliz em receber uma mensagem no privado ou por e-mail m.kuzmin+habr@darkleaf.ru.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster