Un tutorial su Elasticsearch per sviluppatori .NET

Pubblicato: 2022-03-11

Uno sviluppatore .NET dovrebbe utilizzare Elasticsearch nei propri progetti? Sebbene Elasticsearch sia basato su Java, credo che offra molte ragioni per cui vale la pena provare Elasticsearch per la ricerca full-text di qualsiasi progetto.

Elasticsearch, come tecnologia, ha fatto molta strada negli ultimi anni. Non solo fa sembrare magica la ricerca full-text, ma offre anche altre funzionalità sofisticate, come il completamento automatico del testo, le pipeline di aggregazione e altro ancora.

Se il pensiero di introdurre un servizio basato su Java nel tuo ecosistema .NET ordinato ti mette a disagio, non preoccuparti, poiché una volta installato e configurato Elasticsearch, trascorrerai la maggior parte del tuo tempo con uno dei pacchetti .NET più interessanti in circolazione lì: NIDO.

In questo articolo imparerai come utilizzare la straordinaria soluzione del motore di ricerca, Elasticsearch, nei tuoi progetti .NET.

Installazione e configurazione

L'installazione di Elasticsearch nel tuo ambiente di sviluppo si riduce al download di Elasticsearch e, facoltativamente, di Kibana.

Una volta decompresso, un file bat come questo torna utile:

 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

Dopo aver avviato entrambi i servizi, puoi sempre controllare il server Kibana locale (di solito disponibile all'indirizzo http://localhost:5601), giocare con indici e tipi e cercare utilizzando JSON puro, come ampiamente descritto qui.

Il primo passo

Essendo uno sviluppatore completo e bravo, con il supporto completo e la comprensione da parte del management, inizi aggiungendo un progetto di unit test e scrivendo un SearchService con almeno il 90% di copertura del codice.

Il primo passo è configurare chiaramente il file app.config per fornire una sorta di stringa di connessione per il server Elasticsearch.

La cosa interessante di Elasticsearch è che è completamente gratuito. Tuttavia, consiglierei comunque di utilizzare il servizio Elastic Cloud fornito da Elastic.co. Il servizio ospitato rende tutta la manutenzione e la configurazione abbastanza semplici. Inoltre, hai due settimane di prova gratuita, che dovrebbero essere più che sufficienti per provare tutti gli esempi qui!

Poiché qui stiamo eseguendo localmente, una chiave di configurazione come questa dovrebbe fare:

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

L'installazione di Elasticsearch viene eseguita sulla porta 9200 per impostazione predefinita, ma puoi modificarla se lo desideri.

ElasticClient e il pacchetto NEST

ElasticClient è un simpatico ometto che farà la maggior parte del lavoro per noi e viene fornito con il pacchetto NEST.

Installiamo prima il pacchetto.

Per configurare il client, è possibile utilizzare qualcosa del genere:

 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);

Indicizzazione e mappatura

Per poter cercare qualcosa, dobbiamo memorizzare alcuni dati in ES. Il termine utilizzato è "indicizzazione".

Il termine "mappatura" viene utilizzato per mappare i nostri dati nel database su oggetti che verranno serializzati e archiviati in Elasticsearch. Useremo Entity Framework (EF) in questo tutorial.

In genere, quando utilizzi Elasticsearch, stai probabilmente cercando una soluzione per il motore di ricerca a livello di sito. Utilizzerai una sorta di feed o digest o una ricerca simile a Google che restituisce tutti i risultati di varie entità, come utenti, post di blog, prodotti, categorie, eventi, ecc.

Questi probabilmente non saranno solo una tabella o un'entità nel tuo database, ma piuttosto, vorrai aggregare dati diversi e forse estrarre o derivare alcune proprietà comuni come titolo, descrizione, data, autore/proprietario, foto e così via. Un'altra cosa è che probabilmente non lo farai in una query, ma se stai utilizzando un ORM, dovrai scrivere una query separata per ciascuna di queste voci di blog, utenti, prodotti, categorie, eventi o qualcos'altro.

Ho strutturato i miei progetti creando un indice per ogni tipo “grande”, ad esempio post di blog o prodotto. Alcuni tipi di Elasticsearch possono quindi essere aggiunti per tipi più specifici che rientrerebbero nello stesso indice. Ad esempio, se un articolo può essere una storia, un articolo video o un podcast, sarebbe ancora nell'indice "articolo", ma avremmo quei quattro tipi all'interno di quell'indice. Tuttavia, è ancora probabile che sia la stessa query nel database.

Tieni presente che hai bisogno di almeno un tipo per ogni indice, probabilmente un tipo con lo stesso nome dell'indice.

Per mappare le tue entità, ti consigliamo di creare alcune classi aggiuntive. Di solito utilizzo la classe DocumentSearchItemBase , da cui ciascuna delle classi specializzate erediterà BlogPostSearchItem , ProductSearchItem e così via.

Mi piace avere espressioni mapper all'interno di quelle classi. Posso sempre modificare le espressioni se necessario lungo la strada.

In uno dei miei primi progetti con Elasticsearch, ho scritto una classe SearchService abbastanza grande con mappature e indicizzazione eseguite con istruzioni switch-case belle e lunghe: per ogni tipo di entità che voglio inserire in Elasticsearch, c'era un interruttore e una query con mappatura che l'ha fatto.

Tuttavia, durante tutto il processo, ho imparato che non è il modo migliore, almeno non per me.

Una soluzione più elegante consiste nell'avere una sorta di classe IndexDefinition intelligente e una classe di definizione dell'indice specifica per ogni indice. In questo modo, la mia classe IndexDefinition di base può archiviare un elenco di tutti gli indici disponibili e alcuni metodi di supporto come gli analizzatori richiesti e i report di stato, mentre le classi derivate specifiche dell'indice gestiscono le query sul database e mappano i dati per ciascun indice in modo specifico. Ciò è utile soprattutto quando devi aggiungere un'entità aggiuntiva a ES in un secondo momento. Si tratta di aggiungere un'altra classe SomeIndexDefinition che eredita da IndexDefinition e richiede di implementare solo alcuni metodi che interrogano i dati che vorrai nel tuo indice.

L'Elasticsearch parla

Al centro di tutto ciò che puoi fare con Elasticsearch c'è il suo linguaggio di query. Idealmente, tutto ciò di cui hai bisogno per comunicare con Elasticsearch è sapere come costruire un oggetto query.

Dietro le quinte, Elasticsearch espone le sue funzionalità come API basata su JSON su HTTP.

Sebbene l'API stessa e la struttura dell'oggetto query siano abbastanza intuitive, gestire molti scenari di vita reale può comunque essere una seccatura.

In genere, una richiesta di ricerca a Elasticsearch richiede le seguenti informazioni:

  • Quale indice e quali tipi vengono cercati

  • Informazioni sull'impaginazione (quanti articoli saltare e quanti articoli restituire)

  • Una selezione del tipo concreta (quando si fa un'aggregazione, come stiamo per fare qui)

  • La domanda stessa

  • Evidenzia definizione (Elasticsearch può evidenziare automaticamente i risultati se lo desideriamo)

Ad esempio, potresti voler implementare una funzione di ricerca in cui solo alcuni utenti possono vedere i contenuti premium sul tuo sito, oppure potresti volere che alcuni contenuti siano visibili solo agli "amici" dei suoi autori e così via.

Essere in grado di costruire l'oggetto query è al centro delle soluzioni a questi problemi e può essere davvero un problema quando si tenta di coprire molti scenari.

Da tutto quanto sopra, il più importante e più difficile da configurare è, naturalmente, il segmento delle query e qui ci concentreremo principalmente su questo.

Le query sono costrutti ricorsivi combinati di BoolQuery e altre query, come MatchPhraseQuery , TermsQuery , DateRangeQuery ed ExistsQuery . Quelli erano sufficienti per soddisfare qualsiasi requisito di base e dovrebbero essere buoni per cominciare.

Una query MultiMatch è piuttosto importante poiché ci consente di specificare i campi su cui vogliamo eseguire la ricerca e di modificare un po' di più i risultati, su cui torneremo in seguito.

Un MatchPhraseQuery può filtrare i risultati in base a quella che sarebbe una chiave esterna nei database SQL convenzionali o valori statici come enums, ad esempio quando si confrontano i risultati per autore specifico ( AuthorId ) o si abbinano tutti gli articoli pubblici ( ContentPrivacy=Public ).

TermsQuery verrebbe tradotto come "in" nel linguaggio SQL convenzionale. Ad esempio, può restituire tutti gli articoli scritti da uno degli amici dell'utente o ricevere prodotti esclusivamente da un gruppo fisso di commercianti. Come con SQL, non si dovrebbe abusare di questo e inserire 10.000 membri in questo array poiché avrà un impatto sulle prestazioni, ma generalmente gestisce abbastanza bene quantità ragionevoli.

DateRangeQuery è auto-documentante.

ExistsQuery è interessante: ti consente di ignorare o restituire documenti che non hanno un campo specifico.

Questi, se combinati con BoolQuery , consentono di definire logiche di filtraggio complesse.

Pensa a un sito di blog, ad esempio, in cui i post di blog possono avere un campo AvailableFrom che indica quando dovrebbero diventare visibili.

Se applichiamo un filtro come AvailableFrom <= Now , non otterremo documenti che non hanno affatto quel campo particolare (aggreghiamo i dati e alcuni documenti potrebbero non avere quel campo definito). Per risolvere il problema, devi combinare ExistsQuery con DateRangeQuery e racchiuderlo in BoolQuery con la condizione che almeno un elemento in BoolQuery sia soddisfatto. Qualcosa come questo:

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

Negare le query non è un lavoro così semplice e immediato. Ma con l'aiuto di BoolQuery , è comunque possibile:

 BoolQuery MustNot ExistsQuery

Automazione e test

Per semplificare le cose, il metodo consigliato è sicuramente scrivere i test mentre procedi.

In questo modo, sarai in grado di sperimentare in modo più efficiente e, cosa ancora più importante, ti assicurerai che eventuali nuove modifiche introdotte (come filtri più complessi) non interrompano la funzionalità esistente. Non volevo esplicitamente dire "test unitari", dal momento che non sono un fan di prendere in giro qualcosa come il motore di Elasticsearch - il mock non sarà quasi mai un'approssimazione realistica di come si comporta davvero ES - quindi, questo potrebbe essere test di integrazione, se sei un fan della terminologia.

Esempi del mondo reale

Dopo che tutto il lavoro di base è stato fatto con l'indicizzazione, la mappatura e il filtraggio, ora siamo pronti per la parte più interessante: modificare i parametri di ricerca per ottenere risultati migliori.

Nel mio ultimo progetto, ho utilizzato Elasticsearch per fornire un feed utente: tutto il contenuto aggregato in un unico luogo ordinato per data di creazione e ricerca full-text con alcune delle opzioni. Il feed stesso è abbastanza semplice; assicurati solo che ci sia un campo data da qualche parte nei tuoi dati e ordina in base a quel campo.

La ricerca, d'altra parte, non funzionerà sorprendentemente bene. Questo perché, naturalmente, Elasticsearch non può sapere quali sono le cose importanti nei tuoi dati. Diciamo che abbiamo alcuni dati che (tra gli altri campi) hanno campi Title , Tags (array) e Body . Il campo del corpo può essere contenuto HTML (per rendere le cose un po' più realistiche).

Errori di spelling

Il requisito: la nostra ricerca dovrebbe restituire risultati anche se si verificano errori di ortografia o se la fine della parola è diversa. Ad esempio, se c'è un articolo dal titolo "Cose magnifiche che puoi fare con un cucchiaio di legno", quando cerco "cosa" o "legno", vorrei comunque ottenere una corrispondenza.

Per far fronte a questo, dovremo conoscere analizzatori, tokenizzatori, filtri di caratteri e filtri di token. Queste sono le trasformazioni che vengono applicate al momento dell'indicizzazione.

  • Gli analizzatori devono essere definiti. Questo può essere definito per indice.

  • Gli analizzatori possono essere applicati ad alcuni campi dei nostri documenti. Questo può essere fatto usando gli attributi o l'API fluente. Nel nostro esempio, stiamo usando gli attributi.

  • Gli analizzatori sono una combinazione di filtri, filtri per caratteri e tokenizzatori.

Per soddisfare il requisito (corrispondenza parziale delle parole), creeremo l'analizzatore di "completamento automatico", che consiste in:

  • Un filtro per le parole non significative in inglese: il filtro che rimuove tutte le parole comuni in inglese, come "and" o "the".

  • Filtro di ritaglio: rimuove lo spazio bianco attorno a ciascun token

  • Filtro minuscolo: converte tutti i caratteri in minuscolo. Ciò non significa che quando recuperiamo i nostri dati, verranno convertiti in minuscolo, ma abilita invece la ricerca invariante tra maiuscole e minuscole.

  • Tokenizzatore Edge-n-gram: questo tokenizer ci consente di avere corrispondenze parziali. Ad esempio, se abbiamo una frase "Mia nonna ha una sedia di legno", quando cerchiamo il termine "legno", vorremmo comunque ottenere un successo su quella frase. Ciò che fa edge-n-gram è memorizzare "woo", "wood", "woode" e "wooden" in modo che venga trovata qualsiasi corrispondenza parziale di parole con almeno tre lettere. I parametri MinGram e MaxGram definiscono il numero minimo e massimo di caratteri da memorizzare. Nel nostro caso avremo un minimo di tre e un massimo di 15 lettere.

Nella sezione seguente, tutti quelli sono legati insieme:

 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_") ) );

E, quando vogliamo usare questo analizzatore, dovremmo semplicemente annotare i campi che vogliamo in questo modo:

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

Ora, diamo un'occhiata ad alcuni esempi che dimostrano requisiti abbastanza comuni in quasi tutte le applicazioni con molti contenuti.

Pulizia HTML

Il requisito: alcuni dei nostri campi potrebbero contenere del testo HTML.

Naturalmente, non vorrai che la ricerca di "sezione" restituisca qualcosa come "<section>...</section>" o "body" restituendo l'elemento HTML "<body>". Per evitare ciò, durante l'indicizzazione, elimineremo l'HTML e lasceremo solo il contenuto all'interno.

Fortunatamente, non sei il primo con questo problema. Elasticsearch viene fornito con un utile filtro char per questo:

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

E per applicarlo:

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

Campi importanti

Il requisito: le corrispondenze in un titolo dovrebbero essere più importanti delle corrispondenze all'interno del contenuto.

Fortunatamente, Elasticsearch offre strategie per aumentare i risultati se la corrispondenza si verifica in un campo o nell'altro. Questo viene fatto all'interno della costruzione della query di ricerca utilizzando l'opzione 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)

Come puoi vedere, la query MultiMatch è molto utile in situazioni come questa e situazioni come questa non sono affatto rare! Spesso, alcuni campi sono più importanti e altri no: questo meccanismo ci consente di tenerne conto.

Non è sempre facile impostare subito i valori di boost. Dovrai giocarci un po' per ottenere i risultati desiderati.

Priorità agli articoli

Il requisito: alcuni articoli sono più importanti di altri. O l'autore è più importante, o l'articolo stesso ha più Mi piace/condivisioni/upvotes/ecc. Gli articoli più importanti dovrebbero essere classificati più in alto.

Elasticsearch ci consente di implementare la nostra funzione di punteggio e la semplifichiamo in modo da definire un campo "Importanza", che è un valore doppio, nel nostro caso maggiore di 1. Puoi definire la tua funzione/fattore di importanza e applicarla allo stesso modo. Puoi definire più modalità di potenziamento e punteggio, a seconda di quella che preferisci. Questo ha funzionato bene per noi:

 .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) ) )

Ogni film ha una valutazione e abbiamo dedotto la valutazione dell'attore dalla media delle valutazioni per i film in cui sono stati scelti (un metodo non molto scientifico). Abbiamo ridimensionato tale valutazione a un valore doppio nell'intervallo [0,1].

Corrispondenze a parola intera

Il requisito: le corrispondenze di parole intere dovrebbero essere classificate più in alto.

Al momento, stiamo ottenendo risultati abbastanza buoni per le nostre ricerche, ma potresti notare che alcuni risultati che contengono corrispondenze parziali potrebbero essere classificati più in alto rispetto alle corrispondenze esatte. Per far fronte a ciò, abbiamo aggiunto un campo aggiuntivo nel nostro documento chiamato "Parole chiave" che non utilizza un analizzatore di completamento automatico, ma utilizza invece un tokenizzatore di parole chiave e fornisce un fattore di aumento per aumentare i risultati della corrispondenza esatta.

Questo campo corrisponderà solo se la parola esatta è abbinata. Non corrisponderà a "legno" per "legno" come fa l'analizzatore di completamento automatico.

Incartare

Questo articolo dovrebbe averti fornito una panoramica su come configurare Elasticsearch nel tuo progetto .NET e, con un piccolo sforzo, fornire una bella funzionalità di ricerca ovunque.

La curva di apprendimento può essere un po' ripida, ma ne vale la pena, soprattutto quando la modifichi nel modo giusto e inizi a ottenere ottimi risultati di ricerca.

Ricorda sempre di aggiungere casi di test approfonditi con i risultati previsti per assicurarti di non incasinare troppo i parametri quando introduci modifiche e giochi.

Il codice completo per questo articolo è disponibile su GitHub e usa i dati estratti dal database TMDB per mostrare come i risultati della ricerca stanno migliorando ad ogni passaggio.