Aquellos que hayáis trabajado con Elasticsearch conoceréis la frase “You know, for search”, una especie de “Hello world!”, la primera noticia que tenemos de que Elasticsearch está funcionando y una declaración de intenciones.

Elasticsearch sirve “ya sabes, para buscar”, pero aunque esa frase sea lo primero que veamos al instalarlo y hacer una primera consulta vacía, Elasticsearch no se detiene únicamente en la funcionalidad de búsqueda, sino que proporciona muchas utilidades adicionales, algunas para mejorar la experiencia de búsqueda y otras que van más allá.

En este post, veremos cómo podemos implementar en Elasticsearch la funcionalidad de autocompletado, o mejor dicho, las funcionalidades de autocompletado, porque aunque parece que este funcionamiento no admite mucha variación, cada buscador interpreta de manera diferente en qué consiste esta funcionalidad, dando lugar a diferentes implementaciones. ¡Empezamos!

Por lo general, se conoce como autocompletado a la capacidad de sugerirle al usuario palabras de búsqueda que empiezan con los caracteres que va escribiendo, adaptándose con cada nuevo carácter introducido.

De esta manera se ayuda al usuario ofreciéndole diversas posibilidades según va escribiendo, intentando averiguar qué quiere buscar para que no tenga que teclear todas las letras de una palabra.

Pero, aunque este sea el funcionamiento habitual, también se utiliza este término para referirse a la funcionalidad que realiza directamente una búsqueda por fragmentos de palabra devolviendo resultados según el usuario va tecleando (o “search-as-you-type”).

En esta guía trataremos dos tipos de autocompletado dependiendo del tipo de resultado que se le ofrece al usuario:

Ejemplo de autocompletado de documento (Fuente: IMDB) Ejemplo de autocompletado de documento (Fuente: IMDB)
Ejemplo de autocompletado de búsqueda (Fuente: Amazon) Ejemplo de autocompletado de búsqueda (Fuente: Amazon)

Implementación de un autocompletado de documento

Este tipo de autocompletado se puede lograr de manera directa mediante la funcionalidad “completion suggester” de Elasticsearch.

Para ello, simplemente se tendrá que definir un campo de tipo “completion”, que será el campo por el que se realizará el autocompletado, y realizar una query de tipo “suggest”.

Con el fin de centrarnos en la funcionalidad de autocompletado simplificaremos cada uno de los documentos a un único campo, de tal forma que uno de nuestros productos podría ser el siguiente:

{
  "name": "Camiseta negra de algodón"
}

Y usaremos para nuestros ejemplos cinco productos diferentes:

"Camisa negra"
"Camisa negra de algodón"
"Camiseta negra deportiva"
"Camiseta negra de deporte"
"Camiseta roja de deporte"

Que podríamos cargar en Elasticsearch con la siguiente llamada a la API de indexación en bulk:

POST autocomplete/autocomplete/_bulk
{ "index" : { "_id" : "1" } }
{ "name": "Camisa negra" }
{ "index" : { "_id" : "2" } }
{ "name": "Camisa negra de algodón" }
{ "index" : { "_id" : "3" } }
{ "name": "Camiseta negra deportiva" }
{ "index" : { "_id" : "4" } }
{ "name": "Camiseta negra de deporte" }
{ "index" : { "_id" : "5" } }
{ "name": "Camiseta roja de deporte" }

Creamos el índice de prueba con la siguiente llamada:

PUT autocomplete
{
  "settings": {
    "index": {
      "number_of_shards": 1,
      "number_of_replicas": 0
    }
  },
  "mappings": {
    "autocomplete": {
      "properties": {
        "name": { "type": "completion" }
      }
    }
  }
}

Y al cargar los documentos y realizar esta petición:

GET autocomplete/_search
{
  "suggest": {
    "name" : {
      "prefix" : "camis", 
      "completion" : { 
        "field" : "name",
        "size": 5
      }
    }
  }
}

Obtendremos el equivalente a realizar un autocompletado de documento cuando el usuario ha escrito los caracteres “camis” y los documentos recuperados son:

"Camisa negra"
"Camisa negra de algodón"
"Camiseta negra deportiva"
"Camiseta negra de deporte"
"Camiseta roja de deporte"

De manera equivalente al sustituir “camis” por “camise” obtendremos:

"Camiseta negra deportiva"
"Camiseta negra de deporte"
"Camiseta roja de deporte"

Y al sustituir “camis” por “camisa” obtendremos:

"Camisa negra"
"Camisa negra de algodón"

Según vamos añadiendo más caracteres a nuestra búsqueda los resultados se van adaptando. Así al escribir “camiseta neg” obtendremos los siguientes documentos:

"Camiseta negra deportiva"
"Camiseta negra de deporte"

Gracias a esta funcionalidad tenemos, por lo tanto, un servicio de autocompletado de documento funcional con una configuración mínima, pero ¿cómo se comporta en casos en los que necesitemos algo más específico?

¿Podemos hacer autocompletado por palabras que no sean la inicial? ¿Podemos no tener en cuenta ciertas palabras como “de” u otras “stopwords”? ¿Y autocompletar por palabras no consecutivas como “camiseta deportiva”?

Si buscamos por ejemplo “dep” o “camiseta dep” con la query anterior no obtendremos resultados y si buscamos “camiseta negra dep” recuperaremos “Camiseta negra deportiva” pero no “Camiseta negra de deporte”.

La funcionalidad de autocompletado nos permite para estos casos definir no solo una cadena de texto de autocompletado, sino varias, de esta forma podemos adaptar los resultados de autocompletado a nuestras necesidades. Por ejemplo si en vez de guardar:

{
  "name": "Camiseta negra de algodón"
}

Guardamos:

{
  "name": [
    "Camiseta negra de deporte", 
    "camiseta negra deporte", 
    "camiseta negra", 
    "camiseta de deporte", 
    "camiseta deporte", 
    "camiseta", 
    "negra", 
    "deporte"
  ]
}

Conseguiremos un autocompletado mucho más flexible, eso sí, a costa de tener que procesar los documentos de entrada para añadir las diferentes opciones.

En este caso, si en vez la llamada al API bulk de carga de documentos utilizamos esta otra:

POST autocomplete/autocomplete/_bulk
{ "index" : { "_id" : "1" } }
{ "name": ["Camisa negra", "Camisa", "negra"] }
{ "index" : { "_id" : "2" } }
{ "name": ["Camisa negra de algodón", "camisa negra algodón", "camisa negra", "camisa de algodón", "camisa algodón", "camisa", "negra", "algodón"] }
{ "index" : { "_id" : "3" } }
{ "name": ["Camiseta negra deportiva", "camiseta negra", "camiseta deportiva", "camiseta", "negra", "deportiva"] }
{ "index" : { "_id" : "4" } }
{ "name": ["Camiseta negra de deporte", "Camiseta negra deporte", "camiseta negra", "camiseta de deporte", "camiseta deporte", "camiseta", "negra", "deporte"] }
{ "index" : { "_id" : "5" } }
{ "name": ["Camiseta roja de deporte", "Camiseta roja deporte", "camiseta roja", "camiseta de deporte", "camiseta deporte", "camiseta", "roja", "deporte"] }

Conseguiremos que búsquedas como “deport”, “camiseta depor” o “camiseta negra dep” obtengan los resultados esperados.

En el caso de que no queramos procesar los documentos aún así tenemos alguna opción para al menos ignorar las “stopwords”, ya que podremos definir un analizador que las elimine de manera automática.

Si creamos el índice con la siguiente configuración:

PUT autocomplete
{
  "settings": {
   "index": {
      "number_of_shards": 1,
     "number_of_replicas": 0
   },
    "analysis": {
      "filter": {
        "stopwords": { "type": "stop", "stopwords": "_spanish_" }
      },
      "analyzer": {
        "stopwords": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "stopwords"]
        }
      }
    }
  },
  "mappings": {
    "autocomplete": {
      "properties": {
        "name": { 
          "type": "completion", 
          "analyzer": "stopwords", 
          "preserve_position_increments": false 
        }
      }
    }
  }
}

Podremos ignorar las “stopwords” en nuestras sugerencias de autocompletado sin necesidad de preprocesar los documentos, así que la búsqueda “camiseta negra depo” recuperaría:

"Camiseta negra deportiva"
"Camiseta negra de deporte"

Implementación de un autocompletado de búsqueda

El caso del autocompletado de búsqueda es algo más complicado, en primer lugar porque Elasticsearch no incluye ninguna herramienta que nos proporcione la funcionalidad de manera directa y en segundo lugar porque, por lo general, el funcionamiento esperado depende del tipo de dato sobre el que queramos autocompletar.

Para ver las posibles implementaciones de este tipo de autocompletado nos centraremos en dos casos dependiendo del tipo de campo:

También puede complicar la implementación el hecho de que un mismo documento pueda tener múltiples valores para un mismo campo, pero eso es algo que veremos más adelante.

Para ilustrar ambos tipos de campos en los ejemplos de autocompletado de búsqueda añadiremos dos campos más a los documentos del apartado anterior.

Así tendremos un campo con mucha variabilidad (el nombre) y dos más con un conjunto de valores limitado, uno con un solo valor por documento (la marca) y otro que puede tener múltiples valores (la categoría), por lo tanto ahora uno de nuestros productos podría ser:

{
  "name": "Camiseta negra deportiva",
  "brand": "Marca Alfa",
  "category": ["Camisetas", "Camisetas de Deporte", "Deporte"]
}

Y para nuestros ejemplos cargaremos los documentos con esta llamada a la API de indexación en bulk:

POST autocomplete/autocomplete/_bulk
{ "index" : { "_id" : "1" } }
{ "name": "Camisa negra", "brand": "Marca Alpha", "category": "Camisas" }
{ "index" : { "_id" : "2" } }
{ "name": "Camisa negra de algodón", "brand": "Marca Beta", "category": "Camisas" }
{ "index" : { "_id" : "3" } }
{ "name": "Camiseta negra deportiva", "brand": "Marca Alpha", "category": ["Camisetas", "Camisetas de Deporte", "Deporte"] }
{ "index" : { "_id" : "4" } }
{ "name": "Camiseta negra de deporte", "brand": "Marca Alpha", "category": ["Camisetas", "Camisetas de Deporte", "Deporte"] }
{ "index" : { "_id" : "5" } }
{ "name": "Camiseta roja de deporte", "brand": "Marca Gamma", "category": ["Camisetas", "Camisetas de Deporte", "Deporte"] }

Cuando se lleva a cabo un autocompletado de búsqueda no se recuperan documentos concretos, sino valores contenidos en múltiples documentos. De hecho es recomendable devolver primero los resultados que tengan más documentos asociados.

Es por ello que, para llevar a cabo este tipo de autocompletado, se hará uso de la funcionalidad de agregación por término que proporciona Elasticsearch.

Una vez definidos los tipos de autocompletados de búsqueda y algunas de las herramientas y datos que vamos a utilizar en los diferentes ejemplos, pasaremos a las distintas implementaciones dependiendo del tipo de campo.

Campo con poca variabilidad y un único valor por documento

En este caso necesitaremos un campo por el que limitar el número de documentos mediante una query y otro sobre el que realizar una agregación, así pues usaremos la siguiente configuración de índice para poder tener el campo “brand” con un campo auxiliar “brand.raw”:

PUT autocomplete
{
  "settings": {
   "index": {
     "number_of_shards": 1,
     "number_of_replicas": 0
   }
  },
  "mappings": {
    "autocomplete": {
      "properties": {
        "brand": { 
          "type": "text",
          "fields": {
            "raw": { "type":  "keyword" }
          } 
        }
      }
    }
  }
}

Tras cargar los datos, si el usuario escribe los caracteres “Marc”, lo primero que tendríamos que hacer es buscar todos los documentos cuyo campo “brand” empiece por dichos caracteres.

Para ello, podemos hacer uso de la funcionalidad “match phrase prefix query” de Elasticsearch:

GET autocomplete/_search
{
  "query": {
    "match_phrase_prefix" : { "brand" : "Marc" }
  }
}

Al ejecutar esta petición, podemos ver cómo conseguimos un funcionamiento equivalente al caso del autocompletado de documento.

Ahora, para obtener el autocompletado de búsqueda añadiremos la agregación por marca y pediremos que no nos devuelva documentos:

GET autocomplete/_search
{
  "size": 0,
  "query": {
    "match_phrase_prefix": { "brand": "Marc" }
  }, 
  "aggs": {
    "Brand": {
      "terms": { "field": "brand.raw" }
    }
  }
}

Como resultado de la petición obtendremos:

{
  "key" : "Marca Alpha",
  "doc_count" : 3
},
{
  "key" : "Marca Beta",
  "doc_count" : 1
},
{
  "key" : "Marca Gamma",
  "doc_count" : 1
}

Si en vez de buscar “Marc” buscamos “Marca Al” obtenemos en su lugar:

{
  "key" : "Marca Alpha",
  "doc_count" : 3
}

Con lo cual no solo conseguimos el funcionamiento de autocompletado deseado, sino que además podemos ordenar los diferentes valores de autocompletado por el número de documentos que contienen dichos valores.

Campo con poca variabilidad y múltiples valores por documento

Si intentamos llevar a cabo el autocompletado por el campo de categoría con la misma funcionalidad del punto anterior encontraremos un efecto no deseado.

Si configuramos el índice de manera equivalente por el campo “category” y realizamos la búsqueda con los caracteres “Cam”, esperaríamos que nos devolviese los siguientes resultados:

{
  "key" : "Camisetas",
  "doc_count" : 3
},
{
  "key" : "Camisetas de Deporte",
  "doc_count" : 3
},
{
  "key" : "Camisas",
  "doc_count" : 2
}

Sin embargo lo que obtenemos es:

{
  "key" : "Camisetas",
  "doc_count" : 3
},
{
  "key" : "Camisetas de Deporte",
  "doc_count" : 3
},
{
  "key" : "Deporte",
  "doc_count" : 3
},
{
  "key" : "Camisas",
  "doc_count" : 2
}

El problema que nos encontramos es que, con la query filtramos los documentos que contienen una categoría que empieza por “Cam”.

Sin embargo, pueden tener categorías adicionales que no empiecen por esos caracteres, así que al agregar esos valores aparecerán también en los resultados.

Para solucionar ese problema podemos, no solo filtrar los documentos, sino además filtrar los diferentes valores de la agregación haciendo uso de la funcionalidad de filtrado de valores de agregación.

Por lo tanto, al igual que en el caso anterior creamos el índice:

PUT autocomplete
{
  "settings": {
   "index": {
     "number_of_shards": 1,
     "number_of_replicas": 0
   }
  },
  "mappings": {
    "autocomplete": {
      "properties": {
        "category": { 
          "type": "text",
          "fields": {
            "raw": { "type":  "keyword" }
          } 
        }
      }
    }
  }
}

Y tras cargar los datos podemos ejecutar la siguiente query:

GET autocomplete/_search
{
  "size": 0,
  "query": {
    "match_phrase_prefix": { "category": "Cam" }
  }, 
  "aggs": {
    "Brand": {
      "terms": { 
        "field": "category.raw",
        "include" : "Cam.*"
      }
    }
  }
}

Obteniendo, ahora sí, los resultados esperados, ya que los valores de la agregación sólo incluirán aquellos valores que empiecen por los caracteres que ha solicitado el usuario.

En este caso hay que tener en cuenta dos consideraciones:

Sin embargo, a nivel de rendimiento es mucho más adecuado limitar el número de resultados y posteriormente agregar y filtrar los valores que agregar todos los documentos del índice y filtrar posteriormente.

Para evitar este comportamiento se puede usar un campo normalizado en vez del campo original, pero en ese caso las sugerencias de autocompletado también se recuperarían normalizadas.

Campo con mucha variabilidad

En este caso podríamos utilizar una aproximación equivalente a los casos con poca variabilidad de valores, pero por una parte los resultados serían mucho más parecidos a un autocompletado de documento y no a uno de búsqueda.

Por otro lado el rendimiento casi nunca sería adecuado, ya que posiblemente habría tantos valores de agregación como documentos cumpliesen la query de filtrado.

Una posible opción en este caso es conseguir pasar de un escenario en el que tenemos valores muy específicos a otro en el que tenemos subconjuntos de esos valores que son más comunes dentro del índice.

Poniendo un ejemplo, si tenemos el producto "Camiseta negra de deporte", podríamos separar en diferentes términos y combinaciones de términos que es más probable que ocurran en otros productos, como “camiseta”, “negra”, “deporte” o incluso “camiseta negra” o “camiseta de deporte”.

Además estos términos serían más útiles como sugerencia de autocompletado de búsqueda que una sugerencia que va a recuperar un único documento (para ese tipo de sugerencias ya existe el autocompletado por documento).

El enfoque de esta solución es, por lo tanto, pasar de una situación en la que tenemos un campo muy específico con mucha variación a una serie de valores más comunes sobre los que podamos aplicar una solución de agregaciones y para ello se hará uso del filtro de shingles.

Creamos el índice con la siguiente petición:

PUT autocomplete
{
  "settings": {
   "index": {
      "number_of_shards": 1,
     "number_of_replicas": 0
   },
    "analysis": {
      "filter": {
        "shingles": { 
          "type": "shingle", 
          "max_shingle_size": 4 
        }
      },
      "analyzer": {
        "shingles": { 
          "type": "custom", 
          "tokenizer": "standard", 
          "filter": ["shingles"]
        }
      }
    }
  },
  "mappings": {
    "autocomplete": {
      "properties": {
        "name": { 
          "type": "text",
          "fields": {
            "shingle": { 
              "type": "text", 
              "analyzer": "shingles", 
              "fielddata": true 
            }
          }
        }
      }
    }
  }
}

Es importante usar “fielddata” a true, ya que vamos a tener que agregar por un campo analizado para poder recuperar las sugerencias de autocompletado.

Al indexar los datos, en el campo “name.shingle” se almacenarán diferentes combinaciones de palabras del texto original, de tal forma que al ejecutar la siguiente query:

GET autocomplete/_search
{
  "size": 0,
  "query": {
    "match_phrase_prefix": { "name": "Cam" }
  }, 
  "aggs": {
    "Brand": {
      "terms": { 
        "field": "name.shingle",
        "include" : "Cam.*",
        "size" : 5
      }
    }
  }
}

Obtendríamos los siguientes resultados:

{
  "key" : "Camiseta",
  "doc_count" : 3
},
{
  "key" : "Camisa",
  "doc_count" : 2
},
{
  "key" : "Camisa negra",
  "doc_count" : 2
},
{
  "key" : "Camiseta negra",
  "doc_count" : 2
},
{
  "key" : "Camisa negra de",
  "doc_count" : 1
}

Estas sugerencias se pueden mejorar si eliminamos las “stopwords” del campo en el que se generan los shingles, ya que dichas palabras no son útiles para el usuario.

Esto puede dar lugar a ciertos resultados no deseados, ya que al eliminar las “stopwords” se pueden generar múltiples espacios en blanco, pero podemos solucionarlo con un filtro “pattern replace” y otro de tipo “trim”.

Por último, para evitar conflictos con la capitalización, se aplicará también un filtro “lowercase”.

Por lo tanto, con todos estos cambios tenemos:

PUT autocomplete
{
  "settings": {
   "index": {
      "number_of_shards": 1,
     "number_of_replicas": 0
   },
    "analysis": {
      "filter": {
        "shingles": { 
          "type": "shingle", 
          "max_shingle_size": 4, 
          "filler_token": "" 
        },
        "stopwords": { 
          "type": "stop", 
          "stopwords": "_spanish_" 
        },
        "whitespace_remover": { 
          "type": "pattern_replace", 
          "pattern": " +", 
          "replacement": " " 
        }
      },
      "analyzer": {
        "shingles": { 
          "type": "custom", 
          "tokenizer": "standard", 
          "filter": [
            "lowercase", 
            "stopwords", 
            "shingles", 
            "whitespace_remover", 
            "trim"
          ]
        }
      }
    }
  },
  "mappings": {
    "autocomplete": {
      "properties": {
        "name": { 
          "type": "text",
          "fields": {
            "shingle": { 
              "type": "text", 
              "analyzer": "shingles", 
              "fielddata": true
            }
          }
        }
      }
    }
  }
}

Y al ejecutar la query de búsqueda (esta vez con los caracteres de búsqueda en minúscula):

GET autocomplete/_search
{
  "size": 0,
  "query": {
    "match_phrase_prefix": { "name": "cam" }
  }, 
  "aggs": {
    "Brand": {
      "terms": { 
        "field": "name.shingle",
        "include" : "cam.*",
        "size" : 5
      }
    }
  }
}

Obtendremos unos resultados más adecuados para nuestro servicio de autocompletado y ordenados por importancia de la sugerencia en base a cuántos resultados se recuperarán con cada una de las búsquedas:

{
  "key" : "camiseta",
  "doc_count" : 3
},
{
  "key" : "camisa",
  "doc_count" : 2
},
{
  "key" : "camisa negra",
  "doc_count" : 2
},
{
  "key" : "camiseta negra",
  "doc_count" : 2
},
{
  "key" : "camisa negra algodón",
  "doc_count" : 1
}

Consideraciones adicionales

Uso de Match Phrase Prefix Query

Como primera aproximación el uso de este tipo de query parece razonable, ya que es algo que cumple con la funcionalidad requerida y que nos proporciona Elasticsearch de manera directa.

Sin embargo, en la propia documentación se nos informa de un posible problema que puede hacer que ciertos resultados no se muestren debido al uso que hace de la expansión de términos.

Existe otra forma, no tan directa, de realizar este filtrado de documentos previo a la agregación. Para ello se puede hacer uso del tokenizador de N-gramas (más concretamente en este caso de Edge NGram).

Gracias a este procesado previo conseguiremos guardar en el índice fragmentos del contenido, de tal forma que podremos realizar búsquedas parciales sin necesidad de recurrir a la query de tipo “Match Phrase Prefix”.

En este caso necesitaremos, por lo tanto, un campo adicional que será sobre el que se realizará el filtrado previo de documentos:

PUT autocomplete
{
  "settings": {
   "index": {
      "number_of_shards": 1,
     "number_of_replicas": 0
   },
    "analysis": {
      "filter": {
        "shingles": { 
          "type": "shingle", 
          "max_shingle_size": 4, 
          "filler_token": "" 
        },
        "stopwords": { 
          "type": "stop", 
          "stopwords": "_spanish_" 
        },
        "whitespace_remover": { 
          "type": "pattern_replace", 
          "pattern": " +", 
          "replacement": " " 
        }
      },
      "tokenizer": {
        "edge_ngram": { "type": "edge_ngram", "min_gram": 3, "max_gram": 15 }
      },
      "analyzer": {
        "shingles": { 
          "type": "custom", 
          "tokenizer": "standard", 
          "filter": [
            "lowercase", 
            "stopwords", 
            "shingles", 
            "whitespace_remover", 
            "trim"
          ]
        },
        "ngram": {
          "type": "custom", 
          "tokenizer": "edge_ngram", 
          "filter": ["lowercase"] 
        },
        "lowercase": {
          "type": "custom", 
          "tokenizer": "keyword", 
          "filter": ["lowercase"] 
        }
      }
    }
  },
  "mappings": {
    "autocomplete": {
      "properties": {
        "name": { 
          "type": "text",
          "fields": {
            "autocomplete_filter": {
              "type": "text", 
              "analyzer": "ngram",
              "search_analyzer": "lowercase"
            },
            "autocomplete_agg": { 
              "type": "text", 
              "analyzer": "shingles", 
              "fielddata": true
            }
          }
        }
      }
    }
  }
}

Es importante también crear un analizador que no tokenize n-gramas para poder indexar las diferentes variaciones, pero únicamente buscar en ellas con el texto original del usuario.

Una vez indexados los documentos podríamos llevar a cabo la siguiente búsqueda:

GET autocomplete/_search
{
  "size": 0,
  "query": {
    "bool": {
      "filter": {
        "match": { "name.autocomplete_filter": "cam" }
      }
    }
  },
  "aggs": {
    "Brand": {
      "terms": {
        "field": "name.autocomplete_agg",
        "include": "cam.*",
        "size": 5
      }
    }
  }
}

Y obtendríamos los mismos resultados que con la query equivalente realizada con “Match Phrase Prefix”, pero sin los posibles problemas que conlleva la expansión de términos en ese tipo de búsqueda.

Cabe destacar también la utilización de un filtro en la query, de tal forma que podemos ganar algo de rendimiento ya que Elasticsearch no tiene que calcular un “score” que no va a influir en el resultado que se le devuelve al usuario.

Autocompletado por inicio de cualquier palabra

Puede que nos interese no solo autocompletar por el principio de una frase, sino por el inicio de cualquiera de las palabras de la frase. En este caso simplemente habría que hacer un par de cambios sobre el caso anterior.

Cuando se define la configuración del tokenizador “Edge Ngram” podemos indicar que solo tengan en cuenta letras a la hora de generar los tokens de tal forma que generará los n-gramas para cada una de las palabras del texto.

De esta forma el filtrado permitiría recuperar no solo documentos que empiecen por los caracteres que ha escrito el usuario, sino que contengan palabras que empiecen por dichos caracteres.

Adicionalmente debemos cambiar el analizador de búsqueda para que tenga en cuenta los espacios (pero siga sin aplicar n-gramas).

Así que la instrucción de creación del índice sería:

PUT autocomplete
{
  "settings": {
   "index": {
      "number_of_shards": 1,
     "number_of_replicas": 0
   },
    "analysis": {
      "filter": {
        "shingles": { 
          "type": "shingle", 
          "max_shingle_size": 4, 
          "filler_token": "" 
        },
        "stopwords": { 
          "type": "stop", 
          "stopwords": "_spanish_" 
        },
        "whitespace_remover": { 
          "type": "pattern_replace", 
          "pattern": " +", 
          "replacement": " " 
        }
      },
      "tokenizer": {
        "edge_ngram": { 
          "type": "edge_ngram", 
          "min_gram": 3, 
          "max_gram": 15, 
          "token_chars": ["letter"]
        }
      },
      "analyzer": {
        "shingles": { 
          "type": "custom", 
          "tokenizer": "standard", 
          "filter": [
            "lowercase", 
            "stopwords", 
            "shingles", 
            "whitespace_remover", 
            "trim"
          ]
        },
        "ngram": {
          "type": "custom", 
          "tokenizer": "edge_ngram", 
          "filter": ["lowercase"] 
        },
        "lowercase": {
          "type": "custom", 
          "tokenizer": "whitespace", 
          "filter": ["lowercase"] 
        }
      }
    }
  },
  "mappings": {
    "autocomplete": {
      "properties": {
        "name": { 
          "type": "text",
          "fields": {
            "autocomplete_filter": {
              "type": "text", 
              "analyzer": "ngram",
              "search_analyzer": "lowercase"
            },
            "autocomplete_agg": { 
              "type": "text", 
              "analyzer": "shingles", 
              "fielddata": true
            }
          }
        }
      }
    }
  }
}

Por otra parte, tenemos que adaptar el patrón de filtrado de agregaciones para que contemple esa posibilidad, por lo tanto usaremos “(.* +)*dep.*” en vez de “dep.*”.

Así que al ejecutar la siguiente query:

GET autocomplete/_search
{
  "size": 0,
  "query": {
    "bool": {
      "filter": {
        "match": { "name.autocomplete_filter": "dep" }
      }
    }
  },
  "aggs": {
    "Brand": {
      "terms": {
        "field": "name.autocomplete_agg",
        "include": "(.* +)*dep.*",
        "size": 5
      }
    }
  }
}

Obtendremos sugerencias que contengan palabras que empiezan por “dep”:

{
  "key" : "deporte",
  "doc_count" : 2
},
{
  "key" : "camiseta negra deporte",
  "doc_count" : 1
},
{
  "key" : "camiseta negra deportiva",
  "doc_count" : 1
},
{
  "key" : "camiseta roja deporte",
  "doc_count" : 1
},
{
  "key" : "deportiva",
  "doc_count" : 1
}

Conclusiones

Elasticsearch no solo nos permite hacer búsquedas sobre documentos, sino que nos da la posibilidad llevar a cabo funcionalidades adicionales para ayudarnos en dichas búsquedas.

Una de las funcionalidades más comunes es el autocompletado y Elasticsearch nos proporciona la flexibilidad para conseguir diferentes funcionamientos y con diferente grado de complejidad.

En esta guía se han intentado ver algunas de las posibilidades que tenemos a la hora de construir un servicio de autocompletado, pero con Elasticsearch no suele haber una única manera de implementar una funcionalidad, aunque cada una tiene diferentes matices, ventajas e inconvenientes.

Y no solo eso, sino que una misma funcionalidad puede requerir pequeñas variaciones. ¿Es necesario ignorar las tildes en las sugerencias? ¿Cómo hacer un autocompletado sobre varios campos? ¿Se puede mantener la capitalización al recuperar los resultados pero ignorarla en la búsqueda?

Por supuesto no es posible detallar todas estas opciones y variaciones en un artículo, pero espero que al menos pueda servir como referencia y permita elegir la opción de autocompletado que mejor se adapte a cada caso.

Cuéntanos qué te parece.

Enviar.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.