regex - ¿Cómo combinar sabiamente el herpes zóster y edgeNgram para proporcionar una búsqueda flexible de texto completo?
elasticsearch lucene (1)
Este es un caso de uso interesante. Aquí está mi opinión:
{
"settings": {
"analysis": {
"analyzer": {
"my_ngram_analyzer": {
"tokenizer": "my_ngram_tokenizer",
"filter": ["lowercase"]
},
"my_edge_ngram_analyzer": {
"tokenizer": "my_edge_ngram_tokenizer",
"filter": ["lowercase"]
},
"my_reverse_edge_ngram_analyzer": {
"tokenizer": "keyword",
"filter" : ["lowercase","reverse","substring","reverse"]
},
"lowercase_keyword": {
"type": "custom",
"filter": ["lowercase"],
"tokenizer": "keyword"
}
},
"tokenizer": {
"my_ngram_tokenizer": {
"type": "nGram",
"min_gram": "2",
"max_gram": "25"
},
"my_edge_ngram_tokenizer": {
"type": "edgeNGram",
"min_gram": "2",
"max_gram": "25"
}
},
"filter": {
"substring": {
"type": "edgeNGram",
"min_gram": 2,
"max_gram": 25
}
}
}
},
"mappings": {
"test_type": {
"properties": {
"text": {
"type": "string",
"analyzer": "my_ngram_analyzer",
"fields": {
"starts_with": {
"type": "string",
"analyzer": "my_edge_ngram_analyzer"
},
"ends_with": {
"type": "string",
"analyzer": "my_reverse_edge_ngram_analyzer"
},
"exact_case_insensitive_match": {
"type": "string",
"analyzer": "lowercase_keyword"
}
}
}
}
}
}
}
-
my_ngram_analyzer
se usa para dividir cada texto en partes pequeñas, el tamaño de las partes depende de su caso de uso. Elegí, para fines de prueba, 25 caracteres. se usalowercase
ya que dijiste que no distingue entre mayúsculas y minúsculas. Básicamente, este es el tokenizer utilizado parasubstringof(''table 1'',name)
. La consulta es simple:
{
"query": {
"term": {
"text": {
"value": "table 1"
}
}
}
}
-
my_edge_ngram_analyzer
se usa para dividir el texto comenzando desde el principio y esto se usa específicamente para elstartswith(name,''table 1'')
uso delstartswith(name,''table 1'')
. De nuevo, la consulta es simple:
{
"query": {
"term": {
"text.starts_with": {
"value": "table 1"
}
}
}
}
-
Encontré esta es la parte más complicada: la que
endswith(name,''table 1'')
. Para estomy_reverse_edge_ngram_analyzer
que usa un tokenizador dekeyword
junto conlowercase
y un filtroedgeNGram
precedido y seguido por un filtroreverse
. Lo que básicamente hace este tokenizer es dividir el texto en edgeNGrams, pero el borde es el final del texto, no el inicio (como con eledgeNGram
normal). La consulta:
{
"query": {
"term": {
"text.ends_with": {
"value": "table 1"
}
}
}
}
-
para el caso del
name eq ''table 1''
, un simple tokenizador dekeyword
junto con un filtro enlowercase
debería hacerlo La consulta:
{
"query": {
"term": {
"text.exact_case_insensitive_match": {
"value": "table 1"
}
}
}
}
Con respecto a
query_string
, esto cambia un poco la solución, porque contaba con el
term
para no analizar el texto de entrada y para que coincida exactamente con uno de los términos en el índice.
Pero esto se puede "simular" con
query_string
si se especifica el
analyzer
apropiado para él
.
La solución sería un conjunto de consultas como las siguientes (siempre use ese analizador, cambiando solo el nombre del campo):
{
"query": {
"query_string": {
"query": "text.starts_with:(/"table 1/")",
"analyzer": "lowercase_keyword"
}
}
}
Tenemos una API compatible con OData que delega algunas de sus necesidades de búsqueda de texto completo en un clúster Elasticsearch.
Dado que las expresiones OData pueden ser bastante complejas, decidimos simplemente traducirlas a su sintaxis de consulta Lucene equivalente y alimentarla en una consulta
query_string
.
Admitimos algunas expresiones de filtro OData relacionadas con el texto, como:
-
startswith(field,''bla'')
-
endswith(field,''bla'')
-
substringof(''bla'',field)
-
name eq ''bla''
Los campos con los que hacemos coincidir pueden
analyzed
, no
not_analyzed
o ambos (es decir, mediante un campo múltiple).
El texto buscado puede ser un solo token (por ejemplo,
table
), solo una parte del mismo (por ejemplo,
tab
) o varios tokens (por ejemplo,
table 1.
,
table 10
, etc.).
La búsqueda debe ser entre mayúsculas y minúsculas.
Aquí hay algunos ejemplos del comportamiento que debemos apoyar:
-
startswith(name,''table 1'')
debe coincidir con " Tabla 1 ", " tabla 1 00", " Tabla 1 .5", " tabla 1 12 nivel superior" -
endswith(name,''table 1'')
debe coincidir con "Sala 1, Tabla 1 ", " Tabla secundaria 1 ", " tabla 1 ", " Tabla Jeff 1 " -
substringof(''table 1'',name)
debe coincidir con "Big Table 1 back", " table 1 ", " Table 1 ", "Small Table1 2" -
name eq ''table 1''
debe coincidir con " Tabla 1 ", " TABLA 1 ", " tabla 1 "
Básicamente, tomamos la entrada del usuario (es decir, lo que se pasa al segundo parámetro de
startswith
/
endswith
, resp. El primer parámetro de
substringof
, resp. El valor del lado derecho de la
eq
) y tratamos de hacer coincidir exactamente si las fichas coinciden completamente o solo parcialmente.
En este momento, nos estamos librando de una solución torpe resaltada a continuación que funciona bastante bien, pero está lejos de ser ideal.
En nuestra
query_string
, hacemos coincidir un campo no
not_analyzed
utilizando la
sintaxis de Expresión regular
.
Dado que el campo no se
not_analyzed
y la búsqueda no distingue entre mayúsculas y minúsculas, hacemos nuestra propia tokenización mientras preparamos la expresión regular para alimentar la consulta con el fin de llegar a algo como esto, es decir, esto es equivalente al filtro OData
endswith(name,''table 8'')
(=> coincide con todos los documentos cuyo
name
termina con "tabla 8")
"query": {
"query_string": {
"query": "name.raw:/.*(T|t)(A|a)(B|b)(L|l)(E|e) 8/",
"lowercase_expanded_terms": false,
"analyze_wildcard": true
}
}
Entonces, a pesar de que esta solución funciona bastante bien y el rendimiento no es tan malo (lo cual fue una sorpresa), nos gustaría hacerlo de manera diferente y aprovechar todo el poder de los analizadores para cambiar toda esta carga en la indexación tiempo en lugar de buscar tiempo. Sin embargo, dado que reindexar todos nuestros datos llevará semanas, nos gustaría investigar primero si hay una buena combinación de filtros de token y analizadores que nos ayudarían a lograr los mismos requisitos de búsqueda enumerados anteriormente.
Mi opinión es que la solución ideal contendría una combinación sabia de herpes zóster (es decir, varias fichas juntas) y edge-nGram (es decir, que coincidan al principio o al final de una ficha).
Sin embargo, de lo que no estoy seguro es de si es posible hacer que funcionen juntos para que coincidan con varios tokens, donde el usuario podría no ingresar completamente uno de los tokens).
Por ejemplo, si el campo de nombre indexado es "Big Table 123", necesito la
substringof(''table 1'',name)
para que coincida, por lo que "table" es un token totalmente coincidente, mientras que "1" es solo un prefijo del siguiente ficha
Gracias de antemano por compartir sus células cerebrales en este caso.
ACTUALIZACIÓN 1: después de probar la solución de Andrei
=>
startswith
exacta (
eq
) y
startswith
funcionar perfectamente.
A.
endswith
fallas
La búsqueda de
substringof(''table 112'', name)
produce 107 documentos.
La búsqueda de un caso más específico, como
endswith(name, ''table 112'')
produce 1525 documentos, mientras que debería producir menos documentos (las coincidencias de sufijos deberían ser un subconjunto de coincidencias de subcadenas).
Comprobando con mayor profundidad he encontrado algunos desajustes, como "Social Club, Table 12" (no contiene "112") u "Order 312" (no contiene "table" ni "112").
Supongo que es porque terminan con "12" y ese es un gramo válido para el token "112", de ahí la coincidencia.
B.
substringof
falla
La búsqueda de
substringof(''table'',name)
coincide con "Party table", "Alex on big table" pero no coincide con "Table 1", "table 112", etc. La búsqueda de
substringof(''tabl'',name)
no no coincide con nada
ACTUALIZACIÓN 2
Estaba implícito, pero olvidé mencionar explícitamente que la solución tendrá que funcionar con la consulta
query_string
, principalmente debido al hecho de que las expresiones OData (por complejas que sean) seguirán siendo traducidas a su equivalente Lucene.
Soy consciente de que estamos intercambiando el poder de Elasticsearch Query DSL con la sintaxis de consulta de Lucene, que es un poco menos potente y menos expresiva, pero eso es algo que realmente no podemos cambiar.
Sin embargo, estamos bastante cerca.
ACTUALIZACIÓN 3 (25 de junio de 2019):
ES 7.2 introdujo un nuevo tipo de datos llamado
search_as_you_type
que permite este tipo de comportamiento de forma nativa.
Lea más en:
https://www.elastic.co/guide/en/elasticsearch/reference/7.2/search-as-you-type.html