Skip to main content

Lecciones aprendidas al mejorar la búsqueda de texto completo en Snyk con Elasticsearch

Escrito por
Headshot of Sergey Vasilkov

Sergey Vasilkov

blog banner snyk preview early access

4 de noviembre de 2021

0 minutos de lectura

Elasticsearch es un motor de búsqueda de código abierto muy popular. Gracias a su velocidad en tiempo real y a su API robusta, es una opción frecuente entre los desarrolladores que necesitan agregar capacidades de búsqueda de texto completo a sus proyectos. Además de su popularidad general, también es el motor al que estamos migrando actualmente la funcionalidad de informes de Snyk para issues. Cuando terminemos de ajustar todo en issues, empezaremos a usar Elasticsearch en otras áreas de informes.

Panel de Snyk Issues que muestra vulnerabilidades de seguridad, licencias, puntajes, identificadores CVE, proyectos afectados y nivel de madurez de las explotaciones

Aunque Elasticsearch es potente, al principio también puede parecer complicado (a menos que tengas experiencia con motores de búsqueda). Pero como hace poco aprendí mucho sobre esta herramienta al implementarla en Snyk, pensé en compartir algunos conocimientos contigo, ¡de desarrollador a desarrollador!

A veces, un analizador estándar no es suficiente

Elasticsearch es rápido porque trata los distintos datos de manera distinta. Entre la gran variedad de tipos de campos, Elasticsearch cuenta con campos de texto, es decir, campos habituales para contenido textual (como cadenas de caracteres). Para que la información almacenada en esos campos se pueda buscar, Elasticsearch analiza el texto al ingerirlo y convierte los datos en tokens (términos). Luego almacena esos tokens y otra información relevante, como la longitud y la posición, en el índice. De forma predeterminada, usa un analizador estándar para analizar el texto.

Según la documentación oficial: «El analizador estándar ofrece compatibilidad inmediata con la mayoría de los idiomas naturales y casos de uso. Si eliges usar el analizador estándar tal como está, no necesitas ninguna configuración adicional».

En casos básicos, el análisis estándar puede ser suficiente. Veamos cómo funciona. Al ejecutar el siguiente comando, podremos ver el análisis generado para cualquier texto que proporcionemos:

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

Aquí podemos ver exactamente qué hace el analizador estándar: divide el texto en tokens y genera información de servicio para cada segmento, que luego almacena en el índice.

Buscar en el texto indexado

Una vez indexado el contenido, podemos probar la búsqueda de texto completo con un analizador estándar. Crearemos un documento de ejemplo con dos campos: title y description. Elasticsearch asignará automáticamente el mapeo del documento y detectará los 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."
}

Ahora busquemos «express» con la siguiente consulta:

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

No se devolverá ningún documento, aunque tengamos «expression» en el título. Este resultado es el esperado (bueno, era de esperarse porque ya me había topado con esto), ya que el analizador no creó ningún token «express» (como podemos ver en los tokens generados que obtuvimos antes).

Si cambiamos la consulta de búsqueda a «expression», se devolverá el documento:

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

Hay consultas más costosas que permiten resolver este problema de coincidencia de tokens, como las consultas difusas y de comodín, pero consumen muchos recursos de procesamiento. Así que, en lugar de gastar ciclos de CPU en el problema, debemos reevaluar nuestro analizador.

Está claro que un analizador estándar puede no ser suficiente si queremos resultados de búsqueda mejores y más flexibles. Para lograrlo, configuraremos un analizador personalizado. Estos serán nuestros requisitos:

  • Usar consultas más cortas

  • Búsqueda que no distinga entre mayúsculas y minúsculas

Para lograrlo, seguiremos estos pasos:

  1. Crear un analizador personalizado

  2. Configurar un tokenizador más adecuado para este caso de uso

  3. Habilitar el nuevo analizador personalizado para los campos elegidos en el índice de Elasticsearch

Profundizar en los analizadores

Con el analizador estándar, nos facilitamos el trabajo al evitar tener que entender realmente qué hacía el analizador. Ahora que vamos a crear uno personalizado, necesitamos entender qué ocurre durante el análisis de texto:

  1. Los filtros de caracteres (opcionales) se aplican al texto que se analiza para eliminar caracteres.

  2. Un tokenizador divide el texto en tokens o términos. Esto puede hacerse de distintas maneras: generar tokens por espacios en blanco, por letras, etc.

  3. Los filtros de tokens (opcionales) realizan cambios adicionales en los tokens, como convertirlos a minúsculas, eliminar ciertos tokens y más.

Un analizador es una combinación de tokenizadores y filtros. Analiza el texto y lo prepara para la búsqueda. Todo analizador debe tener un solo tokenizador, pero puede tener pocos o muchos filtros de ambos tipos. El orden de las operaciones nunca cambia: filtro de caracteres > tokenizador > filtro de tokens.

Crear nuestro primer analizador personalizado

Como vimos antes, de forma predeterminada todos los campos de texto usan el analizador estándar. Elasticsearch ofrece una gran variedad de analizadores integrados que se pueden usar en cualquier índice sin necesidad de configurarlos, y también puedes crear los tuyos. Para este caso de uso, tendremos que crear uno propio.

Los analizadores se pueden configurar en distintos niveles: índice, campo o consulta. Crearemos un analizador personalizado y lo especificaremos para un campo. Así se configura un analizador personalizado. Nota:Las configuraciones se almacenan en los ajustes del í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"
         }
      }
   }
}

Esto es lo que hicimos:

  1. Se especifica un nuevo analizador personalizado llamado my_analyzer para el índice

  2. my_analyzer usa el tokenizador standard y el filtro lowercase

  3. my_analyzer se habilita como analizador de la propiedad title en el mapeo del índice

Este ejemplo es bastante sencillo y no cambia el comportamiento del analizador estándar. Ahora podemos probar algunos ajustes para que sea posible buscar palabras incompletas sin distinguir entre mayúsculas y minúsculas.

Elegir y configurar un tokenizador mejor

En el ejemplo anterior usamos el tokenizador estándar; ahora es momento de reemplazarlo por edge_ngram, que divide los tokens según su longitud (no por los espacios en blanco). Y si te preguntas por qué uso edge_ngram en lugar de stemmer, ¡sigue leyendo!

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

Esto es lo que hicimos:

  1. my_tokenizer usa el tokenizador edge_ngram

  2. Creará tokens de entre 3 y 8 caracteres

  3. Los tokens incluirán solo letras y dígitos

Elegir y configurar un filtro de tokens mejor

El analizador que creamos antes ahora permite buscar con palabras incompletas de entre 3 y 8 caracteres. Lo último que falta es hacer que la búsqueda no distinga entre mayúsculas y minúsculas. Para lograrlo, hay que agregar el filtro de tokens lowercase adecuado 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

Después de todos los cambios, ya podemos crear un índice nuevo con un analizador personalizado. Antes de cambiar el analizador, vale la pena tener en cuenta que no se puede actualizar su configuración en campos existentes mediante la API de actualización de mapeos.

Así se verán los ajustes y los mapeos de nuestro índice de prueba 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"
      }
    }
  }
}

Ahora podemos agregar un documento y ejecutar distintas consultas para verificar que la búsqueda funcione como esperamos:

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."
}

Buscar títulos que contengan «express» u otros tokens incompletos, sin distinguir entre mayúsculas y minúsculas:

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

Ten en cuenta que no configuramos un analizador personalizado para la propiedad text de nuestro índice, así que ahí seguimos usando la búsqueda de siempre con un analizador estándar.

¿Qué sigue?

En este artículo sencillo, configuramos un analizador personalizado para lograr una búsqueda de texto más flexible en Elasticsearch y vimos algunos ajustes y configuraciones básicos y específicos. Elasticsearch ofrece una gran variedad de analizadores, tokenizadores, filtros y normalizadores integrados, opciones de búsqueda para excluir términos y mucho más.

Al combinarlos, podemos lograr excelentes resultados de búsqueda personalizados. Si estás empezando con Elasticsearch, espero que esto te haya resultado útil. No te pierdas las próximas novedades: tenemos pensado compartir más sobre nuestro recorrido con la búsqueda. ¡Lo próximo serán las agregaciones! (Puedes contar con eso...)

Si te gustaría formar parte de Snyk y crear excelentes herramientas de seguridad para desarrolladores, consulta nuestras vacantes abiertas en ingeniería.