Elasticsearch: un vistazo bajo el capó

Elasticsearch es una herramienta muy interesante. No sólo nos permite almacenar y consultar grandes cantidades de documentos ofreciendo unas capacidades de búsqueda excelentes, sino que también nos permite extraer todo tipo de información de ellos. Capacidades como contar el número de veces que aparece un término en una selección de documentos, crear completas estadísticas por fecha, categoría o ubicación…

Pero, como muchos sabréis, un SSD convencional es capaz de leer datos a una velocidad de 500MB/s, siendo generosos. Entonces, ¿Cómo es posible encontrar y clasificar información en conjuntos de datos de cientos de gigabytes en cuestión de unos pocos milisegundos? La respuesta está en los índices y en una serie de cuidadosas estructuras de datos. Estas estructuras son inmutables y se almacenan de un modo fácil de cachear por el sistema operativo.

Para hacernos a la idea, basta con pensar en un libro de cientos de páginas: ¿Cómo encontramos la sección que buscamos? La mayoría de los libros tienen un índice de unas pocas páginas en el que se nos indica el número de página al que pertenece cada sección. Esto está muy bien, y es algo de lo que disponen la mayoría de los sistemas de bases de datos. Pero tiene muchas limitaciones: si queremos una sección concreta en un determinado libro, podemos consultar su índice. Pero, ¿qué sucede si queremos buscar todas las secciones que contengan, por ejemplo, la palabra «barco»?

La respuesta a esto está en los llamados índices invertidos. Algunos libros disponen de ellos y suelen consistir en una lista de conceptos relevantes acompañados de las páginas en las que aparecen. Es en este tipo de índices en los que se basa Elasticsearch para obrar su magia. En esencia, Elasticsearch es una herramienta para construir y manejar grandes índices, proporcionándonos una potente infraestructura capaz de escalar de forma horizontal. Pero Elasticsearch no se encarga del proceso de indexado y búsqueda en sí mismo. El responsable de esto es Lucene.

Funcionamiento básico de Lucene

Lucene consiste en dos piezas:

  • Un IndexWriter que indexa documentos.
  • Un IndexReader que nos permite consultarlos.

Supongamos que tenemos los siguientes documentos:

{
  "id": 0,
  "texto": "Elasticsearch usa Lucene"
}
{
  "id": 1,
  "texto": "Lucene crea índices para Elasticsearch"
}

Lucene nos permite construir un índice sobre estos documentos especificando cómo queremos hacerlo (esto, en el caso de Elasticsearch, lo hacemos a través del mapping). Vamos a suponer que queremos indexar sólo el texto, y que queremos buscar por palabras sueltas; nuestro índice tendrá una pinta similar a la siguiente:

{
  "texto": {
    "Elasticsearch": [0, 1],
    "usa": [0],
    "Lucene": [0, 1],
    "crea": [1],
    "índices": [1],
    "para": [1]
  }
}

Ahora buscar todos los documentos que contienen la palabra «barco» en el campo texto es trivial: accediendo a texto['barco'] encontraremos una lista con los id de todos los resultados (en este caso ninguno), y a través de dicha lista es sencillo acceder a los documentos.

Estas estructuras se almacenan en unos ficheros llamados segmentos y pueden ser comprimidas mediante distintas técnicas.

Se puede observar que esta estructura en particular no permite recuperar los documentos originales. Eso se debe a que el objetivo de Lucene no es crear bases de datos, sino índices. Sin embargo, es posible especificar a Lucene qué hacer con cada campo: ¿debe indexarlo? ¿Cómo? ¿Lo debe almacenar? Elasticsearch, para poder comportarse como una base de datos, opta por almacenar el documento completo en un campo especial llamado _source, para hacer esto, se podría especificar a Lucene lo siguiente: «no almacenes ni indexes id, no almacenes texto pero indéxalo, y almacena _source pero no lo indexes», el resultado sería algo así:

{
  "_source": {
    "_fields": {
      "0": "{\"id\":0,\"texto\":\"Elasticsearch usa Lucene\"}",
      "1": "{\"id\":1,\"texto\":\"Lucene crea índices para Elasticsearch\"}"
    }
  },
  "texto": {
    "Elasticsearch": [0, 1],
    "usa": [0],
    "Lucene": [0, 1],
    "crea": [1],
    "índices": [1],
    "para": [1]
  }
}

De este modo se puede recuperar el documento original a partir del id desde el propio índice de Lucene, haciendo que Elasticsearch pueda funcionar como una base de datos. Esto permite cosas que de otro modo no serían posibles, como reindexar, o actualizar mediante scripts. El coste es un mayor uso de espacio en disco.

La posibilidad de especificar qué se indexa y qué se almacena ofrecen una gran flexibilidad. Nos permiten desde crear un base de datos muy potente capaz de manejar todo tipo de búsquedas -a costa de un gran uso de recursos- hasta construir un sencillo índice sobre ciertas palabras clave para obtener los id almacenados en, por ejemplo, una base de datos relacional.

IndexWriter, IndexReader y segmentos

Como ya hemos visto, el índice se almacena en una serie de archivos en el disco duro. Para lograr que el sistema operativo se encargue de mantener estos archivos en caché, nunca son modificados.

Conforme el IndexWriter indexa nuevos documentos, va creando nuevos segmentos, y, cuando se borra un documento, lo que se hace es añadirlo a un segmento que identifica los documentos borrados.

Es el IndexReader el que se encarga de buscar en todos los segmentos simultáneamente, descartando de los resultados los documentos eliminados.

Para evitar que la cantidad de segmentos se vuelva muy grande, reduciendo el rendimiento de búsqueda, cada cierto tiempo se crean nuevos segmentos más grandes a partir de los más pequeños, aprovechando para eliminar definitivamente los documentos borrados.

Relación entre Elasticsearch y Lucene

Elasticsearch nos permite crear índices distribuidos en shards, que son porciones del índice independientes que pueden estar en diferentes máquinas. Cada shard es un índice de Lucene y mediante el llamado mapping se define qué se indexa y cómo. Cuando realizamos una búsqueda, Elasticsearch se encarga de buscar en todos los shards, agregando posterioremente los resultados. Elastic también se encarga de distribuir los shards entre los distintos nodos, de crear réplicas, etc, así como de ofrecer vista única de los índices a través de una serie de API.

Debido a la arquitectura de Lucene, Elasticsearch no funciona realmente en tiempo real: para evitar la creación de una enorme cantidad de segmentos, estos son escritos en disco cada cierto intervalo de tiempo, por defecto es un segundo, pero puede ser configurado en función de si se desea potenciar capacidad de indexado o la fiabilidad de las búsquedas.

Búsqueda avanzada

Hemos visto cómo Elasticsearch almacena los datos en una serie de índices invertidos. Pero todavía no hemos visto cómo se realizan búsquedas más complejas; ni cómo se calcula la puntuación para ordenar los resultados por relevancia.

Y es que, aunque crear un índice invertido por palabras es bastante útil, sólo es el comienzo de las capacidades de búsqueda que tenemos a nuestra disposición. Elasticsearch hace muchas más cosas y para ello también almacena lo siguiente:

  • El número de veces que aparece un término en el documento.
  • El número de veces que aparece un término en el conjunto de documentos.
  • Las posiciones de cada término dentro del documento, lo que permite buscar por frases.
  • Las normas, que son utilizadas para ajustar la relevancia de las búsquedas en documentos de distinto tamaño.

Tanto las frecuencias de términos, como las normas y los vectores de términos se pueden eliminar. Así podemos mejorar el rendimiento y reducir las necesidades de espacio, a costa de perder funcionalidad.

Proceso de análisis

Probando el endpoint _analyze de elasticsearch
Elemplo de uso de _analyze

El proceso de análisis de los textos es más complejo de lo que hemos visto al comienzo. Hay que dividir el texto en tókens, y luego aplicar una serie de filtros a dichos tókens.

Mediante el endpoint _analyze, podemos ver el resultado de aplicar este proceso afecta a los tókens generados.

Por ejemplo podemos ver los efectos de los distintos analyzers en un texto. En la imagen se muestra el resultado de utilizar el analyzer predeterminado «spanish» sobre uno de los ejemplos anteriores; pero también podemos especificar nuestro propio analyzer.

Podemos observar en la imagen lo siguiente:

  • Ya no hay mayúsculas.
  • Sólo han quedado las raíces de las palabras.
  • Ha desaparecido la palabra «para» por ser una «stop word», o una palabra que no tiene relevancia en las búsquedas.
  • Pese a que la palabra «para» ha desaparecido, la posición de la palabra «elasticsearch» sigue siendo la 4, no la 3.

Cálculo de relevancia

La puntuación de relevancia de un documento ante una búsqueda se calcula mediante la fórmula conocida como TF-IDF, que está basada en la frecuencia de un término (Term Frecuency) y la frecuencia inversa del documento (Inverse Document Frecuency). A grandes rasgos, la puntuación aumenta cuando el documento contiene más veces la palabra que se está buscando, y baja cuantas más veces aparezca dicha palabra en el conjunto de documentos. Esta fórmula, al igual que casi todo en Elasticsearch, se puede cambiar.

Agregaciones, scripts y ordenación

Ya hemos visto cómo se almacenan los datos para realizar búsquedas de forma eficiente; pero una vez recolectados los resultados, Elasticsearch nos permite ordenarlos, ejecutar scripts sobre ellos y realizar agregaciones. Sin embargo, si sólo tenemos una lista de los identificadores de los documentos, ¿cómo se hace esto? Si tenemos que acceder a los documentos completos el rendimiento puede ser muy malo. Debido a esto necesitamos un modo de acceder a los datos de un campo a partir de su id. Para ello existen dos opciones: fielddata y doc_values.

La primera, fielddata, consiste en dejar que Elasticsearch almacene en memoria los valores de los campos, éste era el comportamiento por defecto en las primeras versiones de Elasticsearch, pero tenía serias limitaciones debido al manejo de la memoria de Java y a que era necesario «desinvertir» el índice para obtener los valores por primera vez.

La segunda, doc_values, es el comportamiento por defecto para todos los campos no analizados, y consiste en almacenar los valores directamente en el índice, como siempre, de una forma optimizada para consultarlos de forma rápida y mantenerlos en la caché del sistema operativo.

De nuevo, estos valores se pueden deshabilitar por completo en uno o varios campos, mejorando el rendimiento y ahorrando espacio.

Conclusión

Elasticsearch es una herramienta muy potente y sobre todo, muy versátil. Conociendo cómo se comporta y cómo optimizar el mapping y otras opciones de configuración, se puede conseguir un índice adaptado distintas necesidades.

Comparte esto en...

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *