Un tutorial de Elasticsearch para desarrolladores de .NET

Publicado: 2022-03-11

¿Debe un desarrollador de .NET usar Elasticsearch en sus proyectos? Aunque Elasticsearch se basa en Java, creo que ofrece muchas razones por las que vale la pena probar Elasticsearch para la búsqueda de texto completo para cualquier proyecto.

Elasticsearch, como tecnología, ha recorrido un largo camino en los últimos años. No solo hace que la búsqueda de texto completo se sienta mágica, sino que ofrece otras características sofisticadas, como autocompletado de texto, canalizaciones de agregación y más.

Si la idea de introducir un servicio basado en Java en su ecosistema .NET ordenado lo hace sentir incómodo, entonces no se preocupe, ya que una vez que haya instalado y configurado Elasticsearch, pasará la mayor parte de su tiempo con uno de los mejores paquetes .NET. allí: NIDO.

En este artículo, aprenderá cómo puede utilizar la increíble solución de motor de búsqueda, Elasticsearch, en sus proyectos .NET.

Instalación y configuración

Instalar Elasticsearch en su entorno de desarrollo se reduce a descargar Elasticsearch y, opcionalmente, Kibana.

Cuando se descomprime, un archivo bat como este es útil:

 cd "D:\elastic\elasticsearch-5.2.2\bin" start elasticsearch.bat cd "D:\elastic\kibana-5.0.0-windows-x86\bin" start kibana.bat exit

Después de iniciar ambos servicios, siempre puede consultar el servidor Kibana local (generalmente disponible en http://localhost:5601), jugar con índices y tipos y buscar usando JSON puro, como se describe detalladamente aquí.

El primer paso

Al ser un desarrollador minucioso y bueno, con soporte y comprensión completos por parte de la gerencia, comienza agregando un proyecto de prueba de unidad y escribiendo un SearchService con al menos un 90 % de cobertura de código.

El primer paso es claramente configurar el archivo app.config para proporcionar una especie de cadena de conexión para el servidor de Elasticsearch.

Lo bueno de Elasticsearch es que es completamente gratis. Sin embargo, aún recomendaría usar el servicio Elastic Cloud proporcionado por Elastic.co. El servicio alojado hace que todo el mantenimiento y la configuración sean bastante fáciles. ¡Aún más, tienes dos semanas de prueba gratis, lo que debería ser más que suficiente para probar todos los ejemplos aquí!

Dado que aquí estamos ejecutando localmente, una clave de configuración como esta debería funcionar:

 <add key="Search-Uri" value="http://localhost:9200" />

La instalación de Elasticsearch se ejecuta en el puerto 9200 de forma predeterminada, pero puede cambiarlo si lo desea.

ElasticClient y el paquete NEST

ElasticClient es un tipo agradable que hará la mayor parte del trabajo por nosotros y viene con el paquete NEST.

Primero instalemos el paquete.

Para configurar el cliente, se puede usar algo como esto:

 var node = new Uri(ConfigurationManager.AppSettings["Search-Uri"]); var settings = new ConnectionSettings(node); settings.ThrowExceptions(alwaysThrow: true); // I like exceptions settings.PrettyJson(); // Good for DEBUG var client = new ElasticClient(settings);

Indexación y mapeo

Para poder buscar algo, debemos almacenar algunos datos en ES. El término utilizado es "indexación".

El término "mapeo" se usa para mapear nuestros datos en la base de datos a objetos que serán serializados y almacenados en Elasticsearch. Usaremos Entity Framework (EF) en este tutorial.

En general, cuando usa Elasticsearch, probablemente esté buscando una solución de motor de búsqueda para todo el sitio. Utilizará algún tipo de feed o resumen, o una búsqueda similar a Google que devuelve todos los resultados de varias entidades, como usuarios, entradas de blog, productos, categorías, eventos, etc.

Probablemente no serán solo una tabla o entidad en su base de datos, sino que querrá agregar diversos datos y tal vez extraer o derivar algunas propiedades comunes como título, descripción, fecha, autor/propietario, foto, etc. Otra cosa es que probablemente no lo haga en una consulta, pero si está utilizando un ORM, tendrá que escribir una consulta separada para cada una de esas entradas de blog, usuarios, productos, categorías, eventos o algo más.

Estructuré mis proyectos creando un índice para cada tipo "grande", por ejemplo, publicación de blog o producto. Luego, se pueden agregar algunos tipos de Elasticsearch para tipos más específicos que se incluirían en el mismo índice. Por ejemplo, si un artículo puede ser una historia, un artículo de video o un podcast, aún estaría en el índice de "artículos", pero tendríamos esos cuatro tipos dentro de ese índice. Sin embargo, todavía es probable que sea la misma consulta en la base de datos.

Tenga en cuenta que necesita al menos un tipo para cada índice, probablemente un tipo que tenga el mismo nombre que el índice.

Para mapear sus entidades, querrá crear algunas clases adicionales. Usualmente uso la clase DocumentSearchItemBase , de la cual cada una de las clases especializadas heredará BlogPostSearchItem , ProductSearchItem , etc.

Me gusta tener expresiones mapeadoras dentro de esas clases. Siempre puedo modificar las expresiones si es necesario en el futuro.

En uno de mis primeros proyectos con Elasticsearch, escribí una clase de servicio de búsqueda bastante grande con mapeos e indexación realizados con declaraciones de cambio de caso agradables y largas: para cada tipo de entidad que quiero incluir en Elasticsearch, había un cambio y una consulta con mapeo que hizo que.

Sin embargo, a lo largo del proceso, aprendí que no es la mejor manera, al menos no para mí.

Una solución más elegante es tener algún tipo de clase IndexDefinition inteligente y una clase de definición de índice específica para cada índice. De esta manera, mi clase base IndexDefinition puede almacenar una lista de todos los índices disponibles y algunos métodos auxiliares, como los analizadores necesarios y los informes de estado, mientras que las clases derivadas específicas de índice manejan la consulta de la base de datos y el mapeo de los datos para cada índice específicamente. Esto es especialmente útil cuando tiene que agregar una entidad adicional a ES en algún momento posterior. Todo se reduce a agregar otra clase SomeIndexDefinition que hereda de IndexDefinition y requiere que solo implemente algunos métodos que consultan los datos que desea en su índice.

El habla de Elasticsearch

El núcleo de todo lo que puede hacer con Elasticsearch es su lenguaje de consulta. Idealmente, todo lo que necesita para poder comunicarse con Elasticsearch es saber cómo construir un objeto de consulta.

Detrás de escena, Elasticsearch expone sus funcionalidades como una API basada en JSON sobre HTTP.

Aunque la API en sí misma y la estructura del objeto de consulta son bastante intuitivas, lidiar con muchos escenarios de la vida real aún puede ser una molestia.

Generalmente, una solicitud de búsqueda a Elasticsearch requiere la siguiente información:

  • Qué índice y qué tipos se buscan

  • Información de paginación (cuántos elementos omitir y cuántos elementos devolver)

  • Una selección de tipo concreto (al hacer una agregación, como estamos a punto de hacer aquí)

  • La consulta en sí

  • Resaltar definición (Elasticsearch puede resaltar automáticamente los resultados si así lo deseamos)

Por ejemplo, es posible que desee implementar una función de búsqueda en la que solo algunos de los usuarios puedan ver el contenido premium en su sitio, o tal vez desee que parte del contenido sea visible solo para los "amigos" de sus autores, y así sucesivamente.

Ser capaz de construir el objeto de consulta es el núcleo de las soluciones a estos problemas, y realmente puede ser un problema cuando se trata de cubrir muchos escenarios.

De todo lo anterior, el más importante y más difícil de configurar es, naturalmente, el segmento de consulta, y aquí nos centraremos principalmente en eso.

Las consultas son construcciones recursivas combinadas de BoolQuery y otras consultas, como MatchPhraseQuery , TermsQuery , DateRangeQuery y ExistsQuery . Esos fueron suficientes para cumplir con los requisitos básicos, y deberían ser buenos para empezar.

Una consulta MultiMatch es bastante importante ya que nos permite especificar campos en los que queremos realizar la búsqueda y ajustar un poco más los resultados, a lo que volveremos más adelante.

MatchPhraseQuery puede filtrar resultados por lo que sería una clave externa en bases de datos SQL convencionales o valores estáticos como enumeraciones, por ejemplo, cuando se comparan resultados por autor específico ( AuthorId ) o se comparan todos los artículos públicos ( ContentPrivacy=Public ).

TermsQuery se traduciría como "en" al lenguaje SQL convencional. Por ejemplo, puede devolver todos los artículos escritos por uno de los amigos del usuario u obtener productos exclusivamente de un conjunto fijo de comerciantes. Al igual que con SQL, no se debe abusar de esto y colocar 10 000 miembros en esta matriz, ya que tendrá un impacto en el rendimiento, pero generalmente maneja cantidades razonables bastante bien.

DateRangeQuery se autodocumenta.

ExistsQuery es interesante: le permite ignorar o devolver documentos que no tienen un campo específico.

Estos, cuando se combinan con BoolQuery , le permiten definir una lógica de filtrado compleja.

Piense en un sitio de blog, por ejemplo, donde las publicaciones de blog pueden tener un campo AvailableFrom que indica cuándo deberían volverse visibles.

Si aplicamos un filtro como AvailableFrom <= Now , no obtendremos documentos que no tengan ese campo en particular (agregamos datos y es posible que algunos documentos no tengan ese campo definido). Para resolver el problema, combinaría ExistsQuery con DateRangeQuery y lo envolvería dentro de BoolQuery con la condición de que se cumpla al menos un elemento en BoolQuery . Algo como esto:

 BoolQuery Should (at least one of the following conditions should be fulfilled) DateRangeQuery with AvailableFrom condition Negated ExistsQuery for field AvailableFrom

Negar consultas no es un trabajo tan sencillo y listo para usar. Pero con la ayuda de BoolQuery , es posible, no obstante:

 BoolQuery MustNot ExistsQuery

Automatización y Pruebas

Para facilitar las cosas, el método recomendado definitivamente es escribir pruebas sobre la marcha.

De esta manera, podrá experimentar de manera más eficiente y, lo que es más importante, se asegurará de que cualquier cambio nuevo que introduzca (como filtros más complejos) no rompa la funcionalidad existente. Explícitamente no quería decir "pruebas unitarias", ya que no soy partidario de burlarme de algo como el motor de Elasticsearch; la simulación casi nunca será una aproximación realista de cómo se comporta realmente ES; por lo tanto, esto podría ser una prueba de integración, si eres un fan de la terminología.

Ejemplos del mundo real

Después de realizar todo el trabajo preliminar con la indexación, el mapeo y el filtrado, ahora estamos listos para la parte más interesante: ajustar los parámetros de búsqueda para obtener mejores resultados.

En mi último proyecto, utilicé Elasticsearch para proporcionar un feed de usuario: todo el contenido agregado a un lugar ordenado por fecha de creación y búsqueda de texto completo con algunas de las opciones. El feed en sí es bastante sencillo; solo asegúrese de que haya un campo de fecha en algún lugar de sus datos y ordene por ese campo.

La búsqueda, por otro lado, no funcionará sorprendentemente bien desde el primer momento. Eso se debe a que, naturalmente, Elasticsearch no puede saber cuáles son las cosas importantes en sus datos. Digamos que tenemos algunos datos que (entre otros campos) tienen campos Title , Tags (matriz) y Body . El campo del cuerpo puede ser contenido HTML (para que las cosas sean un poco más realistas).

Errores de ortografía

El requisito: nuestra búsqueda debe arrojar resultados incluso si se producen errores ortográficos o si la terminación de la palabra es diferente. Por ejemplo, si hay un artículo con el título "Cosas magníficas que puedes hacer con una cuchara de madera", cuando busco "cosa" o "madera", todavía querría encontrar una coincidencia.

Para lidiar con esto, tendremos que estar familiarizados con analizadores, tokenizadores, filtros de caracteres y filtros de token. Esas son las transformaciones que se aplican al momento de indexar.

  • Es necesario definir los analizadores. Esto se puede definir por índice.

  • Los analizadores se pueden aplicar a algunos campos de nuestros documentos. Esto se puede hacer usando atributos o API fluida. En nuestro ejemplo, estamos usando atributos.

  • Los analizadores son una combinación de filtros, filtros de caracteres y tokenizadores.

Para cumplir con el requisito (coincidencia de palabra parcial), crearemos el analizador "autocompletar", que consta de:

  • Un filtro de palabras vacías en inglés: el filtro que elimina todas las palabras comunes en inglés, como "y" o "el".

  • Filtro de recorte: elimina el espacio en blanco alrededor de cada token

  • Filtro de minúsculas: convierte todos los caracteres a minúsculas. Esto no significa que cuando obtengamos nuestros datos, se convertirán a minúsculas, sino que habilita la búsqueda invariable entre mayúsculas y minúsculas.

  • Tokenizador Edge-n-gram: este tokenizador nos permite tener coincidencias parciales. Por ejemplo, si tenemos una oración "Mi abuela tiene una silla de madera", al buscar el término "madera", aún nos gustaría obtener una respuesta en esa oración. Lo que hace edge-n-gram es almacenar "woo", "wood", "woode" y "wooden" para que se encuentre cualquier palabra que coincida parcialmente con al menos tres letras. Los parámetros MinGram y MaxGram definen el número mínimo y máximo de caracteres que se almacenarán. En nuestro caso, tendremos un mínimo de tres y un máximo de 15 letras.

En la siguiente sección, todos ellos están unidos:

 analysis.Analyzers(a => a .Custom("autocomplete", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .Tokenizer("autocomplete") ) .Tokenizers(tdesc => tdesc .EdgeNGram("autocomplete", e => e .MinGram(3) .MaxGram(15) .TokenChars(TokenChar.Letter, TokenChar.Digit) ) ) .TokenFilters(f => f .Stop("eng_stopwords", lang => lang .StopWords("_english_") ) );

Y, cuando queramos usar este analizador, solo debemos anotar los campos que queremos así:

 public class SearchItemDocumentBase { ... [Text(Analyzer = "autocomplete", Name = nameof(Title))] public string Title { get; set; } ... }

Ahora, echemos un vistazo a algunos ejemplos que demuestran requisitos bastante comunes en casi cualquier aplicación con mucho contenido.

Limpieza de HTML

El requisito: Algunos de nuestros campos pueden tener texto HTML dentro.

Naturalmente, no querrá buscar "sección" para devolver algo como "<sección>...</sección>" o "cuerpo" que devuelve el elemento HTML "<cuerpo>". Para evitar eso, durante la indexación, eliminaremos el HTML y dejaremos solo el contenido dentro.

Por suerte, no eres el primero con ese problema. Elasticsearch viene con un filtro de caracteres útil para eso:

 analysis.Analyzers(a => a .Custom("html_stripper", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .CharFilters("html_strip") .Tokenizer("autocomplete") )

Y para aplicarlo:

 [Text(Analyzer = "html_stripper", Name = nameof(HtmlText))] public string HtmlText { get; set; }

Campos importantes

El requisito: las coincidencias en un título deben ser más importantes que las coincidencias dentro del contenido.

Afortunadamente, Elasticsearch ofrece estrategias para impulsar los resultados si la coincidencia se da en un campo o en el otro. Esto se hace dentro de la construcción de la consulta de búsqueda usando la opción de boost :

 const int titleBoost = 15; .Query(qx => qx.MultiMatch(m => m .Query(searchRequest.Query.ToLower()) .Fields(ff => ff .Field(f => f.Title, boost: titleBoost) .Field(f => f.Summary) ... ) .Type(TextQueryType.BestFields) ) && filteringQuery)

Como puede ver, la consulta MultiMatch es muy útil en situaciones como esta, ¡y situaciones como esta no son tan raras en absoluto! A menudo, algunos campos son más importantes y otros no; este mecanismo nos permite tener eso en cuenta.

No siempre es fácil establecer valores de refuerzo de inmediato. Tendrás que jugar un poco con esto para obtener los resultados deseados.

Priorizar artículos

El requisito: algunos artículos son más importantes que otros. O el autor es más importante, o el artículo en sí tiene más Me gusta/compartir/votos a favor/etc. Los artículos más importantes deberían clasificarse más alto.

Elasticsearch nos permite implementar nuestra función de puntuación y la simplificamos de manera que definimos un campo "Importancia", que tiene un valor doble, en nuestro caso, mayor que 1. Puede definir su propia función/factor de importancia y aplicarlo. similar. Puede definir múltiples modos de impulso y puntuación, lo que más le convenga. Este funcionó muy bien para nosotros:

 .Query(q => q .FunctionScore(fsc => fsc .BoostMode(FunctionBoostMode.Multiply) .ScoreMode(FunctionScoreMode.Sum) .Functions(f => f .FieldValueFactor(b => b .Field(nameof(SearchItemDocumentBase.Rating)) .Missing(0.7) .Modifier(FieldValueFactorModifier.None) ) ) .Query(qx => qx.MultiMatch(m => m .Query(searchRequest.Query.ToLower()) .Fields(ff => ff ... ) .Type(TextQueryType.BestFields) ) && filteringQuery) ) )

Cada película tiene una calificación, y deducimos la calificación del actor por el promedio de calificaciones de las películas en las que fueron elegidos (un método no muy científico). Escalamos esa calificación a un valor doble en el intervalo [0,1].

Coincidencias de palabra completa

El requisito: las coincidencias de palabras completas deben tener una clasificación más alta.

Por ahora, estamos obteniendo resultados bastante buenos para nuestras búsquedas, pero es posible que observe que algunos resultados que contienen coincidencias parciales pueden tener una clasificación más alta que las coincidencias exactas. Para lidiar con eso, agregamos un campo adicional en nuestro documento llamado "Palabras clave" que no usa un analizador de autocompletar, sino que usa un tokenizador de palabras clave y proporciona un factor de impulso para aumentar los resultados de coincidencia exacta.

Este campo coincidirá solo si coincide la palabra exacta. No coincidirá con "madera" por "madera" como lo hace el analizador de autocompletar.

Envolver

Este artículo debería haberle brindado una descripción general de cómo configurar Elasticsearch en su proyecto .NET y, con un poco de esfuerzo, proporcionar una buena funcionalidad de búsqueda en todas partes.

La curva de aprendizaje puede ser un poco empinada, pero vale la pena, especialmente cuando la ajustas a la perfección y comienzas a obtener excelentes resultados de búsqueda.

Recuerde siempre agregar casos de prueba completos con los resultados esperados para asegurarse de no estropear demasiado los parámetros al introducir cambios y jugar.

El código completo de este artículo está disponible en GitHub y utiliza datos extraídos de la base de datos TMDB para mostrar cómo los resultados de búsqueda mejoran con cada paso.