Un tutorial Elasticsearch pentru dezvoltatorii .NET

Publicat: 2022-03-11

Ar trebui un dezvoltator .NET să folosească Elasticsearch în proiectele lor? Deși Elasticsearch este construit pe Java, cred că oferă multe motive pentru care Elasticsearch merită încercat să caute text integral pentru orice proiect.

Elasticsearch, ca tehnologie, a parcurs un drum lung în ultimii ani. Nu numai că face căutarea în text complet să pară magică, dar oferă și alte funcții sofisticate, cum ar fi completarea automată a textului, conductele de agregare și multe altele.

Dacă gândul de a introduce un serviciu bazat pe Java în ecosistemul dvs. .NET îngrijit vă face să vă simțiți inconfortabil, atunci nu vă faceți griji, deoarece odată ce ați instalat și configurat Elasticsearch, vă veți petrece cea mai mare parte a timpului cu unul dintre cele mai tari pachete .NET disponibile. acolo: NEST.

În acest articol, veți afla cum puteți utiliza uimitoarea soluție de motor de căutare, Elasticsearch, în proiectele dvs. .NET.

Instalare și configurare

Instalarea Elasticsearch în sine în mediul dvs. de dezvoltare se reduce la descărcarea Elasticsearch și, opțional, Kibana.

Când este dezarhivat, un fișier bat ca acesta este util:

 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

După pornirea ambelor servicii, puteți oricând să verificați serverul Kibana local (disponibil de obicei la http://localhost:5601), să vă jucați cu indici și tipuri și să căutați folosind JSON pur, așa cum este descris pe larg aici.

Primul pas

Fiind un dezvoltator amănunțit și bun, cu sprijin și înțelegere completă din partea conducerii, începeți prin adăugarea unui proiect de testare unitară și scrierea unui serviciu de căutare cu acoperire de cod de cel puțin 90%.

Primul pas este configurarea în mod clar a fișierului app.config pentru a oferi un șir de tip conexiune pentru serverul Elasticsearch.

Lucrul tare despre Elasticsearch este că este complet gratuit. Dar, tot sfătuiesc să utilizați serviciul Elastic Cloud oferit de Elastic.co. Serviciul găzduit face ca întreținerea și configurarea să fie destul de ușoară. Mai mult, aveți două săptămâni de încercare gratuită, care ar trebui să fie mai mult decât suficientă pentru a încerca toate exemplele de aici!

Deoarece aici rulăm local, o cheie de configurare ca aceasta ar trebui să facă:

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

Instalarea Elasticsearch rulează implicit pe portul 9200, dar o puteți schimba dacă doriți.

ElasticClient și pachetul NEST

ElasticClient este un om drăguț care va face cea mai mare parte a muncii pentru noi și vine cu pachetul NEST.

Mai întâi să instalăm pachetul.

Pentru a configura clientul, se poate folosi ceva de genul acesta:

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

Indexare și cartografiere

Pentru a putea căuta ceva, trebuie să stocăm unele date în ES. Termenul folosit este „indexare”.

Termenul „mapping” este folosit pentru maparea datelor noastre din baza de date la obiecte care vor fi serializate și stocate în Elasticsearch. Vom folosi Entity Framework (EF) în acest tutorial.

În general, atunci când utilizați Elasticsearch, probabil că sunteți în căutarea unei soluții de motor de căutare la nivelul întregului site. Veți folosi fie un fel de feed sau rezumat, fie căutare similară cu Google, care returnează toate rezultatele de la diferite entități, cum ar fi utilizatori, intrări de blog, produse, categorii, evenimente etc.

Probabil că acestea nu vor fi doar un tabel sau o entitate din baza de date, ci mai degrabă, veți dori să agregați diverse date și poate să extrageți sau să obțineți unele proprietăți comune, cum ar fi titlul, descrierea, data, autorul/proprietarul, fotografia și așa mai departe. Un alt lucru este că probabil nu o veți face într-o singură interogare, dar dacă utilizați un ORM, va trebui să scrieți o interogare separată pentru fiecare dintre acele intrări de blog, utilizatori, produse, categorii, evenimente sau altceva.

Mi-am structurat proiectele creând un index pentru fiecare tip „mare”, de exemplu, postare de blog sau produs. Unele tipuri Elasticsearch pot fi apoi adăugate pentru mai multe tipuri specifice care ar intra sub același index. De exemplu, dacă un articol poate fi o poveste, un articol video sau un podcast, ar fi în continuare în indexul „articol”, dar am avea acele patru tipuri în acel index. Cu toate acestea, este încă probabil să fie aceeași interogare în baza de date.

Rețineți că aveți nevoie de cel puțin un tip pentru fiecare index - probabil un tip care are același nume cu indexul.

Pentru a vă mapa entitățile, veți dori să creați câteva clase suplimentare. De obicei folosesc clasa DocumentSearchItemBase , de la care fiecare dintre clasele specializate va moșteni BlogPostSearchItem , ProductSearchItem și așa mai departe.

Îmi place să am expresii mapper în cadrul acestor clase. Pot oricând să modific expresiile dacă este necesar.

Într-unul dintre primele mele proiecte cu Elasticsearch, am scris o clasă SearchService destul de mare, cu mapări și indexări făcute cu declarații switch-case frumoase și lungi: pentru fiecare tip de entitate pe care vreau să-l introduc în Elasticsearch, a existat o comutare și o interogare cu mapare care A facut asta.

Totuși, pe tot parcursul procesului, am învățat că nu este cea mai bună cale, cel puțin nu pentru mine.

O soluție mai elegantă este de a avea un fel de clasă inteligentă IndexDefinition și o clasă specifică de definire a indexului pentru fiecare index. În acest fel, clasa mea de bază IndexDefinition poate stoca o listă a tuturor indecșilor disponibili și a unor metode de ajutor, cum ar fi analizoarele necesare și rapoartele de stare, în timp ce clasele derivate specifice indexului se ocupă de interogarea bazei de date și de maparea datelor pentru fiecare index în mod specific. Acest lucru este util mai ales când trebuie să adăugați o entitate suplimentară la ES cândva mai târziu. Se rezumă la adăugarea unei alte clase SomeIndexDefinition care moștenește din IndexDefinition și necesită doar să implementați câteva metode care interogează datele pe care le veți dori în index.

Elasticsearch Speak

La baza a tot ceea ce puteți face cu Elasticsearch este limbajul său de interogare. În mod ideal, tot ceea ce aveți nevoie pentru a putea comunica cu Elasticsearch este să știți cum să construiți un obiect de interogare.

În culise, Elasticsearch își expune funcționalitățile ca un API bazat pe JSON prin HTTP.

Deși API-ul în sine și structura obiectului de interogare sunt destul de intuitive, gestionarea multor scenarii din viața reală poate fi totuși o problemă.

În general, o solicitare de căutare către Elasticsearch necesită următoarele informații:

  • Ce index și ce tipuri sunt căutate

  • Informații de paginare (câte articole să omiteți și câte articole trebuie returnate)

  • O selecție de tip concret (când facem o agregare, așa cum suntem pe cale să facem aici)

  • Interogarea în sine

  • Definiție de evidențiere (Elasticsearch poate evidenția automat rezultate dacă dorim)

De exemplu, este posibil să doriți să implementați o funcție de căutare în care doar unii dintre utilizatori pot vedea conținutul premium de pe site-ul dvs. sau poate doriți ca un anumit conținut să fie vizibil doar pentru „prietenii” autorilor săi și așa mai departe.

Capacitatea de a construi obiectul de interogare este nucleul soluțiilor la aceste probleme și poate fi într-adevăr o problemă atunci când încercați să acoperiți o mulțime de scenarii.

Dintre toate cele de mai sus, cel mai important și mai dificil de configurat este, desigur, segmentul de interogări - și aici, ne vom concentra în principal pe asta.

Interogările sunt constructe recursive combinate cu BoolQuery și alte interogări, cum ar fi MatchPhraseQuery , TermsQuery , DateRangeQuery și ExistsQuery . Acestea au fost suficiente pentru a îndeplini orice cerințe de bază și ar trebui să fie bune pentru început.

O interogare MultiMatch este destul de importantă, deoarece ne permite să specificăm câmpurile în care dorim să facem căutarea și să modificăm puțin rezultatele - la care vom reveni mai târziu.

Un MatchPhraseQuery poate filtra rezultatele după ceea ce ar fi o cheie străină în bazele de date SQL convenționale sau valori statice, cum ar fi enumerarea — de exemplu, atunci când se potrivesc rezultatele după un anumit autor ( AuthorId ) sau se potrivesc toate articolele publice ( ContentPrivacy=Public ).

TermsQuery ar fi tradus ca „în” în limbajul SQL convențional. De exemplu, poate returna toate articolele scrise de unul dintre prietenii utilizatorului sau poate obține produse exclusiv de la un set fix de comercianți. Ca și în cazul SQL, nu ar trebui să folosiți în exces acest lucru și să puneți 10.000 de membri în această matrice, deoarece va avea un impact asupra performanței, dar, în general, gestionează cantități rezonabile destul de bine.

DateRangeQuery se auto-documentează.

ExistsQuery este unul interesant: vă permite să ignorați sau să returnați documente care nu au un anumit câmp.

Acestea, atunci când sunt combinate cu BoolQuery , vă permit să definiți o logică complexă de filtrare.

Gândiți-vă la un site de blog, de exemplu, unde postările de blog pot avea un câmp AvailableFrom care indică momentul în care ar trebui să devină vizibile.

Dacă aplicăm un filtru precum AvailableFrom <= Now , atunci nu vom obține documente care nu au deloc acel câmp anume (agregam date, iar unele documente ar putea să nu aibă acel câmp definit). Pentru a rezolva problema, veți combina ExistsQuery cu DateRangeQuery și veți include BoolQuery cu condiția ca cel puțin un element din BoolQuery fie îndeplinit. Ceva de genul:

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

Negarea interogărilor nu este o sarcină atât de simplă. Dar cu ajutorul lui BoolQuery , este posibil totuși:

 BoolQuery MustNot ExistsQuery

Automatizare și testare

Pentru a ușura lucrurile, metoda recomandată este cu siguranță să scrieți teste pe măsură ce mergeți.

În acest fel, veți putea experimenta mai eficient și – și mai important – vă veți asigura că orice modificări noi pe care le introduceți (cum ar fi filtrele mai complexe) nu vor rupe funcționalitatea existentă. Nu am vrut să spun în mod explicit „teste unitare”, deoarece nu sunt un fan să batjocoresc ceva ca motorul Elasticsearch — simularea nu va fi aproape niciodată o aproximare realistă a modului în care se comportă ES cu adevărat — prin urmare, acestea ar putea fi teste de integrare, dacă esti un fan de terminologie.

Exemple din lumea reală

După ce s-au făcut toate lucrările de bază cu indexare, mapare și filtrare, acum suntem pregătiți pentru cea mai interesantă parte: ajustarea parametrilor de căutare pentru a obține rezultate mai bune.

În ultimul meu proiect, am folosit Elasticsearch pentru a oferi un feed pentru utilizatori: tot conținutul a fost agregat într-un singur loc, ordonat după data creării și căutarea textului integral cu unele dintre opțiuni. Furajul în sine este destul de simplu; asigurați-vă doar că există un câmp de dată undeva în datele dvs. și ordonați după acel câmp.

Căutarea, pe de altă parte, nu va funcționa uimitor de bine din cutie. Asta pentru că, firește, Elasticsearch nu poate ști care sunt lucrurile importante în datele dvs. Să presupunem că avem câteva date care (printre alte câmpuri) au câmpuri Title , Tags (matrice) și Body Câmpul body poate fi conținut HTML (pentru a face lucrurile puțin mai realiste).

Greșeli de ortografie

Cerință: căutarea noastră ar trebui să returneze rezultate chiar dacă apar greșeli de ortografie sau dacă sfârșitul cuvântului este diferit. De exemplu, dacă există un articol cu ​​titlul „Lucruri magnifice pe care le poți face cu o lingură de lemn”, atunci când caut „lucru” sau „lemn”, tot aș dori să obțin o potrivire.

Pentru a face față acestui lucru, va trebui să fim familiarizați cu analizoare, tokenizer, filtre char și filtre token. Acestea sunt transformările care sunt aplicate în momentul indexării.

  • Analizatorii trebuie definiți. Acesta poate fi definit pe index.

  • Analizatoarele pot fi aplicate în unele domenii din documentele noastre. Acest lucru se poate face folosind atribute sau API fluent. În exemplul nostru, folosim atribute.

  • Analizoarele sunt o combinație de filtre, filtre de caractere și tokenizatoare.

Pentru a îndeplini cerința (potrivire parțială a cuvintelor), vom crea analizatorul „completare automată”, care constă din:

  • Un filtru de cuvinte oprite în engleză: filtrul care elimină toate cuvintele obișnuite în limba engleză, cum ar fi „și” sau „the”.

  • Filtru de tăiere: elimină spațiul alb din jurul fiecărui jeton

  • Filtru minuscule: convertește toate caracterele în minuscule. Acest lucru nu înseamnă că atunci când ne preluăm datele, acestea vor fi convertite în litere mici, ci activează căutarea fără diferențe între majuscule și minuscule.

  • Tokenizer Edge-n-gram: acest tokenizer ne permite să avem potriviri parțiale. De exemplu, dacă avem o propoziție „Bunica mea are un scaun de lemn”, atunci când căutăm termenul „lemn”, am dori totuși să obținem o propoziție pozitivă. Ceea ce face edge-n-gram este să stocheze „woo”, „wood”, „woode” și „wooden”, astfel încât să fie găsită orice potrivire parțială a cuvântului cu cel puțin trei litere. Parametrii MinGram și MaxGram definesc numărul minim și maxim de caractere care trebuie stocate. În cazul nostru, vom avea minim trei și maxim 15 litere.

În secțiunea următoare, toate acestea sunt legate între ele:

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

Și, când vrem să folosim acest analizor, ar trebui doar să adnotăm câmpurile pe care le dorim astfel:

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

Acum, să aruncăm o privire la câteva exemple care demonstrează cerințe destul de comune în aproape orice aplicație cu mult conținut.

Curățarea HTML

Cerință: unele dintre câmpurile noastre pot avea text HTML în interior.

Desigur, nu ați dori că căutarea „secțiune” să returneze ceva de genul „<section>…</section>” sau „body” returnând elementul HTML „<body>”. Pentru a evita acest lucru, în timpul indexării, vom elimina HTML-ul și vom lăsa doar conținutul în interior.

Din fericire, nu ești primul cu această problemă. Elasticsearch vine cu un filtru de caractere util pentru asta:

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

Și pentru a o aplica:

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

Câmpuri importante

Cerință: potrivirile dintr-un titlu ar trebui să fie mai importante decât potrivirile din conținut.

Din fericire, Elasticsearch oferă strategii pentru a spori rezultatele dacă potrivirea are loc într-un domeniu sau altul. Acest lucru se face în construcția interogării de căutare folosind opțiunea 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)

După cum puteți vedea, interogarea MultiMatch este foarte utilă în astfel de situații, iar situațiile ca aceasta nu sunt deloc atât de rare! Adesea, unele domenii sunt mai importante, iar altele nu - acest mecanism ne permite să luăm în considerare acest lucru.

Nu este întotdeauna ușor să setați imediat valorile de creștere. Va trebui să te joci puțin cu asta pentru a obține rezultatele dorite.

Prioritizarea articolelor

Cerință: Unele articole sunt mai importante decât altele. Fie autorul este mai important, fie articolul în sine are mai multe like-uri/share/upvotes/etc. Articolele mai importante ar trebui să se claseze mai sus.

Elasticsearch ne permite să implementăm funcția noastră de scor și o simplificăm într-un mod în care definim un câmp „Importanță”, care este o valoare dublă – în cazul nostru, mai mare decât 1. Puteți defini propria funcție/factor de importanță și îl puteți aplica în mod similar. Puteți defini mai multe moduri de amplificare și de notare, care vi se potrivește cel mai bine. Acesta a funcționat frumos pentru 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) ) )

Fiecare film are o evaluare și am dedus ratingul actorilor din media evaluărilor filmelor în care au fost distribuite (nu este o metodă foarte științifică). Am scalat această evaluare la o valoare dublă în intervalul [0,1].

Potriviri cu cuvinte întregi

Cerință: potrivirile cu cuvinte întregi ar trebui să fie clasate mai sus.

Până acum, obținem rezultate destul de bune pentru căutările noastre, dar este posibil să observați că unele rezultate care conțin potriviri parțiale s-ar putea clasa mai sus decât potrivirile exacte. Pentru a face față acestui lucru, am adăugat un câmp suplimentar în documentul nostru, numit „Cuvinte cheie”, care nu utilizează un analizor de completare automată, ci folosește un tokenizer de cuvinte cheie și oferă un factor de creștere pentru a împinge rezultatele potrivirii exacte mai sus.

Acest câmp se va potrivi numai dacă se potrivește cuvântul exact. Nu se va potrivi „lemn” cu „lemn”, așa cum o face analizorul de completare automată.

Învelire

Acest articol ar fi trebuit să vă ofere o privire de ansamblu asupra modului de a configura Elasticsearch în proiectul dvs. .NET și, cu puțin efort, să vă ofere o funcționalitate plăcută de căutare peste tot.

Curba de învățare poate fi puțin abruptă, dar merită, mai ales când o modifici corect și începi să obții rezultate excelente de căutare.

Nu uitați întotdeauna să adăugați cazuri de testare amănunțite cu rezultatele așteptate, pentru a vă asigura că nu încurcați prea mult parametrii atunci când introduceți modificări și vă jucați.

Codul complet pentru acest articol este disponibil pe GitHub și utilizează date extrase din baza de date TMDB pentru a arăta cum se îmbunătățesc rezultatele căutării cu fiecare pas.