Skip to main content

Lições aprendidas ao aprimorar a busca de texto completo na Snyk com Elasticsearch

Escrito por
Headshot of Sergey Vasilkov

Sergey Vasilkov

blog banner snyk preview early access

4 de novembro de 2021

0 minutos de leitura

Elasticsearch é um mecanismo de busca de código aberto bastante popular. Por oferecer velocidade em tempo real e uma API robusta, é uma escolha frequente entre desenvolvedores que precisam adicionar recursos de busca de texto completo aos projetos. Além de ser popular em geral, é também o mecanismo para o qual estamos migrando a funcionalidade de relatórios da Snyk para issues! Quando tudo estiver ajustado em issues, começaremos a usar o Elasticsearch em outras áreas de relatórios.

Painel de problemas da Snyk com vulnerabilidades de segurança, licenças, pontuações, identificadores CVE, projetos afetados e maturidade da exploração

Embora o Elasticsearch seja poderoso, ele também pode parecer complicado no início (a menos que você já tenha experiência com mecanismos de busca). Como aprendi bastante sobre essa ferramenta recentemente, enquanto a implementava na Snyk, achei que valia a pena compartilhar um pouco desse conhecimento com você — de desenvolvedor para desenvolvedor!

Às vezes, um analisador padrão não é suficiente

O Elasticsearch mantém a velocidade tratando dados diferentes de formas diferentes. Entre os vários tipos de campos, há os campos de texto — campos comuns para conteúdo textual (ou seja, strings). Para tornar pesquisáveis as informações armazenadas nesse campo, o Elasticsearch faz a análise do texto durante a ingestão, convertendo os dados em tokens (termos) e armazenando esses tokens e outras informações relevantes, como comprimento e posição, no índice. Por padrão, ele usa um analisador padrão para a análise de texto.

Da documentação oficial: “O analisador padrão oferece suporte imediato à maioria dos idiomas naturais e casos de uso. Se você optar por usar o analisador padrão sem alterações, não será necessária nenhuma configuração adicional.”

Em casos simples, a análise padrão pode ser suficiente. Vamos ver como ela funciona: com o comando a seguir, podemos conferir a análise gerada para qualquer texto que fornecermos:

POST _analyze
{
  "analyzer": "standard",
  "text": "Regular Expression Denial of Service (ReDoS)"
}

And the result will look like:

{
  "tokens" : [
    {
      "token" : "regular",
      "start_offset" : 0,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "expression",
      "start_offset" : 8,
      "end_offset" : 18,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "denial",
      "start_offset" : 19,
      "end_offset" : 25,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "of",
      "start_offset" : 26,
      "end_offset" : 28,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "service",
      "start_offset" : 29,
      "end_offset" : 36,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "redos",
      "start_offset" : 38,
      "end_offset" : 43,
      "type" : "<ALPHANUM>",
      "position" : 5
    }
  ]
}

Aqui, podemos ver exatamente o que o analisador padrão faz: ele divide o texto em tokens e gera informações de serviço para cada trecho, que são armazenadas no índice.

Pesquisando o texto indexado

Depois de indexado o conteúdo, podemos testar a busca de texto completo com um analisador padrão. Vamos criar um documento de exemplo com dois campos: title e description. O Elasticsearch mapeará o documento automaticamente e identificará os campos de texto.

PUT text-search-index/_doc/1
{
  "title": "Regular Expression Denial of Service (ReDoS)",
  "description": "The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren'\''t very intuitive and can ultimately end up making it easy for attackers to take your site down."
}

Agora, vamos pesquisar por “express” com a seguinte consulta:

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "express"
      }
    }
  }
}

Nenhum documento será retornado, embora “expression” esteja no título. Esse resultado é esperado (pelo menos para mim, já que já passei por isso!), pois o analisador não criou nenhum token “express” (como podemos ver nos tokens gerados que consultamos acima).

Se alterarmos a consulta de busca para “expression”, o documento será retornado:

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "expression"
      }
    }
  }
}

Há consultas mais custosas que podem contornar esse problema de correspondência de tokens, como as consultas fuzzy e wildcard, mas elas exigem muito poder computacional. Então, em vez de gastar ciclos de CPU com o problema, precisamos reavaliar nosso analisador.

Fica claro que um analisador padrão pode não ser suficiente se quisermos resultados de busca melhores e mais flexíveis. Para alcançar esse objetivo, vamos configurar um analisador personalizado. Estes serão nossos requisitos:

  • Usar consultas menores

  • Busca sem distinção entre maiúsculas e minúsculas

Para isso, os próximos passos serão:

  1. Criar um analisador personalizado

  2. Configurar um tokenizador mais adequado ao caso de uso

  3. Ativar o novo analisador personalizado para os campos escolhidos no índice do Elasticsearch

Analisadores em mais detalhes

Com o analisador padrão, facilitamos as coisas e não precisamos entender exatamente o que ele fazia. Agora que vamos criar um analisador personalizado, precisamos entender o que acontece durante a análise de texto:

  1. Os filtros de caracteres (opcionais) são aplicados ao texto analisado para remover caracteres.

  2. Um tokenizador divide o texto em tokens ou termos. Isso pode ser feito de diferentes formas, gerando tokens por espaços em branco, letras etc.

  3. Os filtros de tokens (opcionais) fazem alterações adicionais nos tokens, como convertê-los para minúsculas, remover tokens específicos e muito mais.

Um analisador é uma combinação de tokenizadores e filtros. Ele analisa o texto e o prepara para a busca. Todo analisador precisa ter um único tokenizador, mas pode ter poucos ou muitos filtros dos dois tipos. A ordem das operações é sempre a mesma: filtro de caracteres > tokenizador > filtro de tokens.

Criando nosso primeiro analisador personalizado

Como vimos antes, por padrão, todos os campos de texto usam o analisador padrão. O Elasticsearch oferece uma ampla variedade de analisadores integrados, que podem ser usados em qualquer índice sem configuração adicional, e também permite criar os seus próprios. Para este caso de uso, vamos precisar criar um.

Os analisadores podem ser configurados em diferentes níveis: índice, campo ou consulta. Vamos criar um analisador personalizado e defini-lo para um campo. Veja como configurar um analisador personalizado. Observação: as configurações são armazenadas nos ajustes do índice:

PUT text-search-index
{
  "settings":{
     "analysis":{
        "analyzer":{
           "my_analyzer":{
              "type":"custom",
              "tokenizer":"standard"
           }
        }
     }
  },
  "mappings":{
      "properties":{
         "title": {
            "type":"text",
            "analyzer":"my_analyzer"
         },
         "text": {
           "type": "text"
         }
      }
   }
}

Veja o que fizemos:

  1. Um novo analisador personalizado chamado my_analyzer é definido para o índice

  2. my_analyzer usa o tokenizador standard e o filtro lowercase

  3. my_analyzer foi ativado como analisador da propriedade title no mapeamento do índice

Este exemplo é bem simples e não altera o comportamento do analisador padrão. Agora podemos ajustá-lo para permitir buscas sem distinção entre maiúsculas e minúsculas usando palavras incompletas.

Escolha e configure um tokenizador melhor

No exemplo anterior, usamos o tokenizador padrão. Agora é hora de substituí-lo pelo edge_ngram, que divide os tokens de acordo com o comprimento (e não pelos espaços em branco). Se você está se perguntando por que estou usando edge_ngram em vez de stemmer, continue lendo!

...
"analysis":{
  "analyzer":{
    "my_analyzer":{
      "type":"custom",
      "tokenizer":"my_tokenizer"
    }
  },
  "tokenizer": {
    "my_tokenizer": {
      "type": "edge_ngram",
      "min": 3,
      "max": 8,
      "token_chars": ["letter", "digit"]
    }
  }
}
...

Veja o que fizemos:

  1. my_tokenizer usa o tokenizador edge_ngram

  2. Ele criará tokens com comprimento entre 3 e 8 caracteres

  3. Os tokens incluirão apenas letras e dígitos

Escolha e configure um filtro de tokens melhor

O analisador que criamos acima agora permite buscar palavras incompletas com 3 a 8 caracteres. Falta apenas permitir buscas sem distinção entre maiúsculas e minúsculas. Para isso, basta adicionar o filtro de tokens lowercase adequado a my_analyzer:

...
"analysis":{
  "analyzer":{
    "my_analyzer":{
      "type":"custom",
      "tokenizer":"my_tokenizer",
      "filter":[
        "lowercase"
      ]
    }
  },
  "tokenizer": {
    "my_tokenizer": {
      "type": "edge_ngram",
      "min": 3,
      "max": 8,
      "token_chars": ["letter", "digit"]
    }
  }
}
...

Resultado final

Depois de todas as alterações, já podemos criar um novo índice com um analisador personalizado. Vale lembrar que, antes de alterar o analisador, não é possível atualizar essa configuração em campos existentes usando a API de atualização de mapeamento.

Veja como ficarão as configurações e os mapeamentos do nosso índice de teste básico:

PUT text-search-index
{
  "settings":{
    "analysis":{
      "analyzer":{
        "my_analyzer":{
          "type":"custom",
          "tokenizer":"my_tokenizer",
          "filter":[
            "lowercase"
          ]
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "edge_ngram",
          "min": 3,
          "max": 8,
          "token_chars": ["letter", "digit"]
        }
      }
    }
  },
  "mappings":{
    "properties":{
      "title": {
        "type":"text",
        "analyzer":"my_analyzer"
      },
      "text": {
        "type": "text"
      }
    }
  }
}

Agora podemos adicionar um documento e executar diferentes consultas para garantir que nossa busca funcione como esperado:

PUT text-search-index/_doc/1
{
  "title": "Regular Expression Denial of Service (ReDoS)",
  "description": "The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren'\''t very intuitive and can ultimately end up making it easy for attackers to take your site down."
}

Pesquise títulos que contenham “express” ou outros tokens incompletos, sem distinção entre maiúsculas e minúsculas:

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "express" // try: EXPRESS, exp, expression
      }
    }
  }
}

Observe que não configuramos um analisador personalizado para a propriedade de texto do nosso índice. Portanto, nesse campo, ainda temos a boa e velha busca com o analisador padrão.

E agora?

Neste artigo simples, configuramos um analisador personalizado para ter uma busca de texto mais flexível no Elasticsearch e abordamos algumas configurações básicas e específicas. O Elasticsearch oferece uma ampla variedade de analisadores, tokenizadores, filtros, normalizadores e configurações de stop words integrados, entre muitos outros recursos.

Combinando esses recursos, podemos obter ótimos resultados de busca personalizados. Se você está começando a usar o Elasticsearch, espero que este artigo tenha sido útil. Fique de olho, pois planejamos compartilhar mais sobre nossa jornada com buscas. O próximo assunto serão as agregações! (Pode contar com isso...)

Se você gostaria de fazer parte da Snyk e criar ótimas ferramentas de segurança para desenvolvedores, confira nossas vagas abertas em engenharia.

Leia mais

blog feature ai lilac
Blog

Transforme seu backlog de vulnerabilidades em pull requests com remediação agentiva

O Evo Remediation Agent está em Open Preview e traz a resolução automatizada de dependências para o Evo Agentic AppSec. Agora, as equipes de AppSec podem executar campanhas contínuas de remediação

illustration hero ai
Blog

PHP corrige duas vulnerabilidades críticas de RCE sem autenticação (CVE-2026-109618, CVE-2026-109619): atualize agora

Duas falhas críticas no ciclo de requisições do PHP podem permitir execução remota de código sem autenticação. Atualize para uma versão corrigida ou instale um backport do fornecedor para versões mais antigas.

blog feature ai pink
Blog

O risco de um modelo de IA depende do que ele pode acessar

A pontuação de risco de um modelo é apenas parte da história: seu impacto depende dos dados, das ferramentas e das ações que a aplicação pode acessar. Saiba como as equipes de segurança podem verificar esse alcance e escolher controles direcionados.