Ingeniería

Una introducción práctica a Elasticsearch

Nota del editor (3 de agosto de 2021): En este blog se usan características obsoletas. Consulta la documentación Map custom regions with reverse geocoding (Mapeo de regiones personalizadas con geocodificación inversa) para conocer las instrucciones actuales.

¿Por qué este blog?

Recientemente tuve el placer de dictar una clase del máster en la Universidad de La Coruña, en el curso Recuperación de información y web semántica. El foco de esta lección era brindar una visión general de Elasticsearch a los estudiantes para que pudieran comenzar a usar Elasticsearch en las asignaciones del curso; los asistentes iban desde personas ya familiarizadas con Lucene hasta individuos que se enfrentaban por primera vez a los conceptos de recuperación de la información. Al ser una clase a última hora (comenzaba a las 07:30 p. m.), uno de los desafíos era mantener la atención de los estudiantes (o, mejor dicho, evitar que se duerman). Hay dos enfoques principales para mantener la atención en la enseñanza: traer chocolate (de lo cual me olvidé) y hacer que la clase sea lo más práctica posible.

Y esto es lo que brinda el blog de hoy: haremos la parte práctica de esa misma lección. El objetivo no es aprender cada uno de los comandos o solicitudes de Elasticsearch (para eso está la documentación), sino experimentar el placer de usar Elasticsearch sin conocimiento previo en un tutorial guiado de 30-60 minutos. Simplemente copia y pega cada solicitud para ver los resultados, e intenta descubrir la solución a las preguntas planteadas.

¿Quién se beneficiará de este blog?

Mostraré características básicas en Elastic para presentar algunos de los conceptos principales (en ocasiones, conceptos más técnicos o complejos) e incluiré los enlaces a la documentación para referencia adicional. No lo olvides: para referencia adicional. Es decir, puedes continuar con los ejemplos y dejar la documentación para más adelante. Si nunca antes usaste Elasticsearch y deseas verlo en acción, y ser quien esté a cargo de la acción, estás en el lugar indicado. Si ya tienes experiencia con Elasticsearch, echa un vistazo al set de datos que usaremos: cuando un amigo te pregunta qué puedes hacer con Elasticsearch, es más fácil explicarlo con búsquedas en las obras de Shakespeare.

¿Qué abarcaremos y qué no?

Comenzaremos por agregar algunos documentos, buscar y eliminarlos. Luego, usaremos un set de datos de Shakespeare para brindar más información sobre las búsquedas y agregaciones. Este es un blog práctico del tipo "quiero comenzar a verlo funcionar ahora".

Ten en cuenta que no abarcaremos nada relacionado con la configuración o las mejores prácticas en despliegues de producción: usa esta información para probar lo que ofrece Elasticsearch, un punto de partida para imaginar cómo puede adaptarse a tus necesidades.

Preparación

Primero, necesitas Elasticsearch. Sigue las instrucciones de la documentación para descargar la versión más reciente, instálala e iníciala. Básicamente, necesitas una versión reciente de Java, descargar e instalar Elasticsearch para tu sistema operativo y, por último, iniciarlo con los valores predeterminados - bin/elasticsearch. En esta lección usaremos la versión más reciente disponible al momento: 5.5.0.

A continuación, debes comunicarte con Elasticsearch: puedes hacerlo emitiendo solicitudes HTTP a la API REST. De forma predeterminada, Elastic se inicia en el puerto 9200. Para acceder, puedes usar la herramienta que mejor se adapte a tu experiencia: hay herramientas de líneas de comando (como curl para Linux), plugins de REST para navegador web para Chrome o Firefox, o puedes instalar Kibana y usar el plugin de la consola. Cada solicitud consiste en un verbo HTTP (GET, POST, PUT…), un endpoint y un cuerpo opcional; en la mayoría de los casos, el cuerpo es un objeto JSON.

A modo de ejemplo, y para confirmar que Elasticsearch se haya iniciado, realicemos una solicitud GET en la URL base para acceder al endpoint básico (no se requiere cuerpo):

GET localhost:9200

La respuesta debe ser similar a lo siguiente. Dado que no configuramos nada, el nombre de nuestra instancia será una cadena aleatoria de 7 letras:

{
    "name": "t9mGYu5",
    "cluster_name": "elasticsearch",
    "cluster_uuid": "xq-6d4QpSDa-kiNE4Ph-Cg",
    "version": {
        "number": "5.5.0",
        "build_hash": "260387d",
        "build_date": "2017-06-30T23:16:05.735Z",
        "build_snapshot": false,
        "lucene_version": "6.6.0"
    },
    "tagline": "You Know, for Search"
}

Algunos ejemplos básicos

Ya tenemos una instancia de Elasticsearch limpia inicializada y en funcionamiento. Lo primero que haremos será agregar documentos para poder recuperarlos. Los documentos en Elasticsearch se representan en formato JSON. Además, los documentos se agregan a índices y tienen un tipo. Ahora estamos agregando al índice accounts un documento de tipo person con la ID 1; como el índice aún no existe, Elasticsearch lo creará automáticamente.

POST localhost:9200/accounts/person/1 
{
    "name" : "John",
    "lastname" : "Doe",
    "job_description" : "Systems administrator and Linux specialit"
}

La respuesta devolverá información sobre la creación del documento:

{
    "_index": "accounts",
    "_type": "person",
    "_id": "1",
    "_version": 1,
    "result": "created",
    "_shards": {
        "total": 2,
        "successful": 1,
        "failed": 0
    },
    "created": true
}

Ahora que el documento existe, podemos recuperarlo:

GET localhost:9200/accounts/person/1 

El resultado contendrá metadatos y el documento completo (que se muestra en el campo "_source"):

{
    "_index": "accounts",
    "_type": "person",
    "_id": "1",
    "_version": 1,
    "found": true,
    "_source": {
        "name": "John",
        "lastname": "Doe",
        "job_description": "Systems administrator and Linux specialit"
    }
}

Un lector con el ojo entrenado ya notó que hay un error de escritura en la descripción del puesto (specialit); corrijámoslo actualizando el documento ("_update"):

POST localhost:9200/accounts/person/1/_update
{
      "doc":{
          "job_description" : "Systems administrator and Linux specialist"
       }
}

Una vez que la operación se completa correctamente, el documento se modificará. Recuperémoslo de nuevo y revisemos la respuesta:

{
    "_index": "accounts",
    "_type": "person",
    "_id": "1",
    "_version": 2,
    "found": true,
    "_source": {
        "name": "John",
        "lastname": "Doe",
        "job_description": "Systems administrator and Linux specialist"
    }
}

Como preparación para las operaciones siguientes, agreguemos un documento adicional con la ID 2:

POST localhost:9200/accounts/person/2
{
    "name" : "John",
    "lastname" : "Smith",
    "job_description" : "Systems administrator"
}

Hasta ahora, recuperamos documentos por su ID, pero no hicimos búsquedas. Al buscar con la API REST, podemos pasar la búsqueda en el cuerpo de la solicitud o directamente en la URL con una sintaxis específica. En esta sección, haremos las búsquedas directamente en la URL con el formato "/es/_search?q=something":

GET localhost:9200/_search?q=john

Esto devolverá ambos documentos, dado que los dos incluyen john:

{
    "took": 58,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "failed": 0
    },
    "hits": {
        "total": 2,
        "max_score": 0.2876821,
        "hits": [
            {
                "_index": "accounts",
                "_type": "person",
                "_id": "2",
                "_score": 0.2876821,
                "_source": {
                    "name": "John",
                    "lastname": "Smith",
                    "job_description": "Systems administrator"
                }
            },
            {
                "_index": "accounts",
                "_type": "person",
                "_id": "1",
                "_score": 0.28582606,
                "_source": {
                    "name": "John",
                    "lastname": "Doe",
                    "job_description": "Systems administrator and Linux specialist"
                }
            }
        ]
    }
}

En este resultado podemos ver los documentos con coincidencias y algunos metadatos como la cantidad total de resultados para la búsqueda. Sigamos haciendo más búsquedas. Antes de ejecutar las búsquedas, intenta descubrir por tu cuenta qué documentos se recuperarán (la respuesta aparece después del comando):

GET localhost:9200/_search?q=smith

Esta búsqueda devolverá solo el último documento que agregamos, el único que contiene smith.

GET localhost:9200/_search?q=job_description:john

Esta búsqueda no devolverá ningún documento. En este caso, restringimos la búsqueda solo al campo "job_description", que no contiene el término. A modo de ejercicio para el lector, intenta hacer lo siguiente:

  • Una búsqueda en ese campo que solo devuelva el documento con la ID 1
  • Una búsqueda en ese campo que devuelva ambos documentos
  • Una búsqueda en ese campo que solo devuelva el documento con la ID 2; necesitarás una pista: el parámetro "q" usa la misma sintaxis que la cadena de búsqueda.

Este último ejemplo trae aparejada una pregunta relacionada: podemos hacer búsquedas en campos específicos; ¿es posible buscar solo en un índice específico? La respuesta es sí, podemos especificar el índice y el tipo en la URL. Prueba esto:

GET localhost:9200/accounts/person/_search?q=job_description:linux

Además de buscar en un índice, podemos buscar en varios índices al mismo tiempo proporcionando una lista separada por comas de los nombres de los índices, lo mismo puede hacerse con los tipos. Hay más opciones: puedes encontrar información al respecto en Multi-Index, Multi-type (Multiíndice, multitipo). A modo de ejercicio para el lector, agrega documentos a un segundo índice (diferente) y realiza las búsquedas en ambos índices al mismo tiempo.

Para cerrar esta sección, eliminaremos un documento y luego el índice completo. Tras eliminar el documento, intenta recuperarlo o encontrarlo en las búsquedas.

DELETE localhost:9200/accounts/person/1

La respuesta será la confirmación:

{
    "found": true,
    "_index": "accounts",
    "_type": "person",
    "_id": "1",
    "_version": 3,
    "result": "deleted",
    "_shards": {
        "total": 2,
        "successful": 1,
        "failed": 0
    }
}

Por último, podemos eliminar el índice completo.

DELETE localhost:9200/accounts

Este es el final de la primera sección. Resumamos lo que hicimos:

  1. Agregamos un documento. De forma implícita, se creó un índice (el índice no existía previamente).
  2. Recuperamos el documento.
  3. Actualizamos el documento, para corregir un error de escritura, y comprobamos que se corrigió.
  4. Agregamos un segundo documento.
  5. Buscamos, incluidas búsquedas que usaban de forma implícita todos los campos y una búsqueda enfocada solo en un campo.
  6. Propusimos varios ejercicios de búsqueda.
  7. Explicamos los conceptos básicos de búsqueda en varios índices y tipos de forma simultánea.
  8. Propusimos buscar en varios índices de forma simultánea.
  9. Eliminamos un documento.
  10. Eliminamos un índice completo.

Para obtener más información sobre los temas en esta sección, consulta los enlaces siguientes:

Practicar con datos más interesantes.

Hasta ahora, practicamos con algunos datos ficticios. En esta sección exploraremos las obras de Shakespeare. El primer paso es descargar el archivo shakespeare.json; disponible en Kibana: loading sample data (Carga de datos de muestra). Elasticsearch ofrece una API de bulk que te permite realizar operaciones de agregar, eliminar, actualizar y crear en maso, es decir, muchas a la vez. Este archivo contiene datos listos para ingestar con esta API, preparados para indexar en un índice denominado Shakespeare que contiene documentos de tipo act, scene y line. El cuerpo de las solicitudes a la API de bulk consiste en 1 objeto JSON por línea; en las operaciones de adición, como las del archivo, hay 1 objeto JSON que indica metadatos sobre la operación de agregado y un segundo objeto JSON en la línea siguiente que contiene el documento que se agregará:

{"index":{"_index":"shakespeare","_type":"act","_id":0}}
{"line_id":1,"play_name":"Henry IV","speech_number":"","line_number":"","speaker":"","text_entry":"ACT I"}

No profundizaremos en la API de bulk: si al lector le interesa, puede consultar la documentación de bulk.

Introduzcamos todos estos datos en Elasticsearch. Como el cuerpo de esta solicitud es bastante grande (más de 200 000 líneas), se recomienda hacerlo a través de una herramienta que permita cargar el cuerpo de una solicitud desde un archivo, por ejemplo con curl:

curl -XPOST "localhost:9200/shakespeare/_bulk?pretty" --data-binary @shakespeare.json

Una vez cargados los datos, podemos comenzar a hacer búsquedas. En la sección anterior realizamos las búsquedas pasando la búsqueda en la URL. En esta sección, presentaremos el DSL de búsqueda que especifica un formato JSON que se usará en el cuerpo de las solicitudes de búsqueda para definir las búsquedas. Según el tipo de operación, se pueden emitir búsquedas tanto con el verbo GET como POST. Comencemos por lo más simple: obtener todos los documentos. Para hacerlo, especificamos en el cuerpo una clave "query" y, en el valor, la búsqueda "match_all".

GET localhost:9200/shakespeare/_search
{
    "query": {
            "match_all": {}
    }
}

El resultado mostrará 10 documentos; esta es una salida parcial:

{
    "took": 7,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "failed": 0
    },
    "hits": {
        "total": 111393,
        "max_score": 1,
        "hits": [
              ...          
            {
                "_index": "shakespeare",
                "_type": "line",
                "_id": "19",
                "_score": 1,
                "_source": {
                    "line_id": 20,
                    "play_name": "Henry IV",
                    "speech_number": 1,
                    "line_number": "1.1.17",
                    "speaker": "KING HENRY IV",
                    "text_entry": "The edge of war, like an ill-sheathed knife,"
                }
            },
            ...          

El formato de las búsquedas es bastante directo. Hay disponibles muchos tipos diferentes de búsqueda: Elastic ofrece búsquedas directas ("Buscar este término", "Buscar elementos en este rango", etc.) y búsquedas compuestas ("a AND b", "a OR b", etc.). La referencia completa se encuentra en la documentación del DSL de búsqueda; aquí veremos solo algunos ejemplos para familiarizarnos con cómo podemos usarlas.

POST localhost:9200/shakespeare/scene/_search/
{
    "query":{
        "match" : {
            "play_name" : "Antony"
        }
    }
}

En la búsqueda anterior buscamos todas las escenas (ve la URL) en las que el nombre de la obra contenga Antony. Podemos refinar esta búsqueda y seleccionar también las escenas en las que Demetrius sea quien habla:

POST localhost:9200/shakespeare/scene/_search/
{
    "query":{
        "bool": {
            "must" : [
                {
                    "match" : {
                        "play_name" : "Antony"
                    }
                },
                {
                    "match" : {
                        "speaker" : "Demetrius"
                    }
                }
            ]
        }
    }
}

Como primer ejercicio para el lector, modifica la búsqueda anterior de modo que devuelva no solo escenas en las que Demetrius sea quien habla, sino también escenas en las que hable Antony; una pista, comprueba la cláusula "should" booleana. Como segundo ejercicio para el lector, puedes explorar las distintas opciones que pueden usarse en el cuerpo de la solicitud al buscar; por ejemplo, seleccionar desde qué posición de los resultados queremos comenzar y cuántos resultados queremos recuperar para hacer la paginación.

Hasta ahora hicimos algunas búsquedas con el DSL de búsqueda. ¿Y si, además de recuperar los contenidos que buscamos, también podemos hacer algunas analíticas? Aquí es donde entran en juego las agregaciones. Las agregaciones nos permiten obtener información más profunda de los datos: por ejemplo, ¿cuántos tipos diferentes de obras existen en nuestro set de datos actual? ¿Cuántas escenas hay en promedio por obra? ¿Cuáles son las obras con más escenas?

Antes de pasar a ejemplos prácticos, regresemos a cuando creamos el índice Shakespeare, porque continuar sin un poco de teoría sería un desperdicio. En Elastic, podemos crear índices que definan cuáles son los tipos de datos para los distintos campos que pueden tener: campos numéricos, campos de palabras clave, campos de texto… hay muchos tipos de datos. Los tipos de datos que puede tener un índice se definen a través de los mapeos. En este caso, no creamos ningún índice antes de indexar los documentos, por lo que Elastic decidió cuál era el tipo de cada campo (creó el mapeo del índice). El tipo "text" se seleccionó para los campos de texto: este tipo se analiza, eso es lo que nos permitió encontrar el play_name Antony and Cleopatra con solo buscar Antony. De forma predeterminada, no podemos hacer agregaciones en campos analizados. ¿Cómo mostraremos agregaciones si los campos no son válidos para hacerlas? Elastic, cuando decidió el tipo de cada campo, también agregó una versión no analizada de los campos de texto (denominada "keyword") en caso de que quisiéramos hacer agregaciones/clasificaciones/scripts: podemos simplemente usar "play_name.keyword" en las agregaciones. A modo de ejercicio para el lector, debes ver cómo inspeccionar los mapeos actuales.

Luego de esta lección relativamente breve y teórica, volvamos al teclado y las agregaciones. Podemos comenzar a inspeccionar nuestros datos comprobando cuántas obras diferentes tenemos:

GET localhost:9200/shakespeare/_search
{
    "size":0,
    "aggs" : {
        "Total plays" : {
            "cardinality" : {
                "field" : "play_name.keyword"
            }
        }
    }
}

Ten en cuenta que como no nos interesan los documentos, decidimos mostrar 0 resultados. Además, como queremos explorar el índice completo, no tenemos una sección de búsqueda: las agregaciones se computarán usando todos los documentos que cumplen con la búsqueda, lo que de forma predeterminada es "match_all" en este caso. Por último, decidimos usar una agregación "cardinality" que nos permitirá saber cuántos valores únicos tenemos para el campo "play_name".

{
    "took": 67,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "failed": 0
    },
    "hits": {
        "total": 111393,
        "max_score": 0,
        "hits": []
    },
    "aggregations": {
        "Total plays": {
            "value": 36
        }
    }
}

Ahora, hagamos una lista de las obras que aparecen con más frecuencia en nuestro set de datos:

GET localhost:9200/shakespeare/_search
{
    "size":0,
    "aggs" : {
        "Popular plays" : {
            "terms" : {
                "field" : "play_name.keyword"
            }
        }
    }
}

El resultado sería:

{
    "took": 35,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "failed": 0
    },
    "hits": {
        "total": 111393,
        "max_score": 0,
        "hits": []
    },
    "aggregations": {
        "Popular plays": {
            "doc_count_error_upper_bound": 2763,
            "sum_other_doc_count": 73249,
            "buckets": [
                {
                    "key": "Hamlet",
                    "doc_count": 4244
                },
                {
                    "key": "Coriolanus",
                    "doc_count": 3992
                },
                {
                    "key": "Cymbeline",
                    "doc_count": 3958
                },
                {
                    "key": "Richard III",
                    "doc_count": 3941
                },
                {
                    "key": "Antony and Cleopatra",
                    "doc_count": 3862
                },
                {
                    "key": "King Lear",
                    "doc_count": 3766
                },
                {
                    "key": "Othello",
                    "doc_count": 3762
                },
                {
                    "key": "Troilus and Cressida",
                    "doc_count": 3711
                },
                {
                    "key": "A Winters Tale",
                    "doc_count": 3489
                },
                {
                    "key": "Henry VIII",
                    "doc_count": 3419
                }
            ]
        }
    }
}

Podemos ver los 10 valores más populares de "play_name". Depende del lector indagar en la documentación para descubrir cómo mostrar más o menos valores en la agregación.

Si llegaste hasta aquí, definitivamente sabrás cuál es el paso siguiente: combinar agregaciones. Quizá nos interesa saber cuántas escenas, actos y líneas tenemos en el índice; pero también podrían interesarnos estos mismos valores de cada obra. Podemos hacerlo anidando agregaciones dentro de agregaciones:

GET localhost:9200/shakespeare/_search
{
    "size":0,
    "aggs" : {
        "Total plays" : {
            "terms" : {
                "field" : "play_name.keyword"
            },
            "aggs" : {
                "Per type" : {
                    "terms" : {
                        "field" : "_type"
                     }
                }
            }
        }
    }
}

Y una parte de la respuesta:

    "aggregations": {
        "Total plays": {
            "doc_count_error_upper_bound": 2763,
            "sum_other_doc_count": 73249,
            "buckets": [
                {
                    "key": "Hamlet",
                    "doc_count": 4244,
                    "Per type": {
                        "doc_count_error_upper_bound": 0,
                        "sum_other_doc_count": 0,
                        "buckets": [
                            {
                                "key": "line",
                                "doc_count": 4219
                            },
                            {
                                "key": "scene",
                                "doc_count": 20
                            },
                            {
                                "key": "act",
                                "doc_count": 5
                            }
                        ]
                    }
                },
                ...

Existen muchas agregaciones diferentes en Elasticsearch: agregaciones que usan el resultado de agregaciones, agregaciones de métricas (como "cardinality"), agregaciones de cubetas (como "terms")… Depende del lector echar un vistazo a la lista y decidir qué agregación se adaptará a un caso de uso específico que quizá ya tengas en mente. ¿Tal vez una agregación "Significant terms" para encontrar lo inusualmente común?

Este es el final de la segunda sección. Resumamos lo que hicimos:

  1. Usamos la API de bulk para agregar obras de Shakespeare.
  2. Hicimos búsquedas simples, a través de la inspección del formato genérico para hacer búsqueda mediante el DSL de búsqueda.
  3. Realizamos una búsqueda de hoja, mediante la búsqueda de texto en un campo.
  4. Llevamos a cabo una búsqueda compuesta, en la que combinamos 2 búsquedas de texto.
  5. Propusimos agregar una segunda búsqueda compuesta.
  6. Propusimos probar las distintas opciones en el cuerpo de la solicitud.
  7. Presentamos el concepto de agregaciones, junto con un breve resumen de los tipos de campos y mapeos.
  8. Calculamos cuántas obras hay en nuestro set de datos.
  9. Recuperamos cuáles eran las obras que aparecían con más frecuencia en el set de datos.
  10. Combinamos varias agregaciones para ver cuántos actos, escenas y líneas tenían cada una de las 10 obras más frecuentes.
  11. Propusimos explorar algunas agregaciones más en Elastic.

Algunas sugerencias adicionales

Durante estos ejercicios jugamos un poco con el concepto de los tipos en Elasticsearch. A fin de cuentas, un tipo es solo un campo adicional interno: cabe destacar que a partir de la versión 6 solo permitiremos crear índices con un solo tipo y a partir de la versión 7 se espera eliminar los tipos. Puedes encontrar más información en este blog.

Conclusión

En este blog, usamos algunos ejemplos para que pruebes un poco Elasticsearch.

Hay mucho (¡mucho!) más que explorar en Elasticsearch y el Elastic Stack que lo que se mostró en este breve artículo. Algo que vale la pena mencionar antes de terminar es la relevancia. Elasticsearch no se trata solo de ¿Satisface este documento mi requisito de búsqueda?, sino que también abarca ¿Qué tan bien satisface este documento mi requisito de búsqueda?, gracias a que ofrece primero los resultados de búsqueda más relevantes. Hay una gran cantidad de documentación, y está repleta de ejemplos.

Antes de implementar cualquier característica personalizada nueva, recomendamos revisar la documentación para comprobar si ya la hemos implementado, así resultará más fácil aprovecharla en tu proyecto. Es muy probable que una característica o idea que crees que sería útil ya esté disponible, dado que nuestro roadmap de desarrollo tiene una gran influencia de todo lo que nuestros usuarios y desarrolladores nos indican que desean.

Si necesitas autenticación/control de acceso/encriptación/auditoría, estas características ya están disponibles en Security. Si necesitas monitorear el cluster, esto está disponible en Monitoring. Si necesitas crear campos a demanda en tus resultados, ya lo permitimos a través de campos de script. Si necesitas crear alertas por correo electrónico/Slack/Hipchat/etc., esto está disponible a través de Alerting. Si necesitas visualizar los datos y las agregaciones en gráficos, ya ofrecemos un entorno enriquecido para hacerlo: Kibana. Si necesitas indexar datos desde las bases de datos, archivos de log, colas de gestión o cualquier fuente imaginable, esto se ofrece a través de Logstash y Beats. Si necesitas, si necesitas, si necesitas… Comprueba si ya puedes hacerlo.