.NET 開發人員的 Elasticsearch 教程
已發表: 2022-03-11.NET 開發人員應該在他們的項目中使用 Elasticsearch 嗎? 儘管 Elasticsearch 是基於 Java 構建的,但我相信它提供了許多理由說明 Elasticsearch 值得為任何項目進行全文搜索。
Elasticsearch 作為一項技術,在過去幾年中取得了長足的進步。 它不僅讓全文搜索感覺像魔術,還提供了其他復雜的功能,例如文本自動完成、聚合管道等。
如果將基於 Java 的服務引入整潔的 .NET 生態系統的想法讓您感到不舒服,請不要擔心,因為一旦您安裝和配置了 Elasticsearch,您將大部分時間都花在最酷的 .NET 軟件包之一上那裡:巢。
在本文中,您將了解如何在您的 .NET 項目中使用令人驚嘆的搜索引擎解決方案 Elasticsearch。
安裝和配置
將 Elasticsearch 本身安裝到您的開發環境歸結為下載 Elasticsearch 和可選的 Kibana。
解壓後,這樣的 bat 文件就派上用場了:
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
啟動這兩個服務後,您始終可以檢查本地 Kibana 服務器(通常在 http://localhost:5601 上可用),使用索引和類型,並使用純 JSON 進行搜索,如此處廣泛描述的。
第一步
作為一個全面而優秀的開發人員,在管理層的全面支持和理解下,您首先要添加一個單元測試項目並編寫一個代碼覆蓋率至少為 90% 的 SearchService。
第一步是明確配置app.config
文件,為 Elasticsearch 服務器提供一個連接類型的字符串。
Elasticsearch 最酷的地方在於它是完全免費的。 但是,我仍然建議使用 Elastic.co 提供的 Elastic Cloud 服務。 託管服務使所有維護和配置變得相當容易。 更重要的是,您有兩週的免費試用期,這應該足以試用這裡的所有示例!
由於這裡我們在本地運行,因此應該使用這樣的配置鍵:
<add key="Search-Uri" value="http://localhost:9200" />
Elasticsearch 安裝默認在端口 9200 上運行,但您可以根據需要更改它。
ElasticClient 和 NEST 包
ElasticClient 是一個很好的小伙伴,它將為我們完成大部分工作,它帶有 NEST 包。
讓我們首先安裝軟件包。
要配置客戶端,可以使用以下內容:
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);
索引和映射
為了能夠搜索某些東西,我們必須將一些數據存儲到 ES 中。 使用的術語是“索引”。
術語“映射”用於將我們在數據庫中的數據映射到將被序列化並存儲在 Elasticsearch 中的對象。 我們將在本教程中使用實體框架 (EF)。
通常,在使用 Elasticsearch 時,您可能正在尋找一個站點範圍的搜索引擎解決方案。 您將使用某種提要或摘要,或類似 Google 的搜索,它返回來自各種實體的所有結果,例如用戶、博客條目、產品、類別、事件等。
這些可能不僅僅是數據庫中的一個表或實體,而是您希望聚合不同的數據,並可能提取或導出一些常見屬性,如標題、描述、日期、作者/所有者、照片等。 另一件事是,您可能不會在一個查詢中執行此操作,但如果您使用的是 ORM,則必須為每個博客條目、用戶、產品、類別、事件或其他內容編寫單獨的查詢。
我通過為每個“大”類型(例如博客文章或產品)創建索引來構建我的項目。 然後可以為更多特定類型添加一些 Elasticsearch 類型,這些類型將屬於同一索引。 例如,如果一篇文章可以是故事、視頻文章或播客,它仍然會在“文章”索引中,但我們會在該索引中包含這四種類型。 但是,它仍然很可能是數據庫中的相同查詢。
請記住,每個索引至少需要一種類型——可能是與索引同名的類型。
要映射您的實體,您需要創建一些額外的類。 我通常使用DocumentSearchItemBase
類,每個專門的類都將從該類繼承BlogPostSearchItem
、 ProductSearchItem
等。
我喜歡在這些類中有映射器表達式。 如果需要,我總是可以修改表達式。
在我最早使用 Elasticsearch 的項目之一中,我編寫了一個相當大的 SearchService 類,它使用漂亮而冗長的 switch-case 語句完成了映射和索引:對於我想要放入 Elasticsearch 的每種實體類型,都有一個帶有映射的 switch 和查詢做過某事。
然而,在整個過程中,我了解到這不是最好的方法,至少對我來說不是。
一個更優雅的解決方案是為每個索引設置某種智能IndexDefinition
類和一個特定的索引定義類。 這樣,我的基礎IndexDefinition
類可以存儲所有可用索引的列表和一些輔助方法,例如所需的分析器和狀態報告,而派生的特定於索引的類處理查詢數據庫並專門為每個索引映射數據。 這很有用,尤其是當您稍後必須向 ES 添加其他實體時。 它歸結為添加另一個SomeIndexDefinition
類,該類繼承自IndexDefinition
並要求您只實現一些方法來查詢您在索引中需要的數據。
Elasticsearch 演講
您可以使用 Elasticsearch 做的所有事情的核心是它的查詢語言。 理想情況下,與 Elasticsearch 進行通信所需的只是知道如何構造查詢對象。
在幕後,Elasticsearch 將其功能公開為基於 JSON 的基於 HTTP 的 API。
儘管 API 本身和查詢對象的結構相當直觀,但處理許多現實生活場景仍然很麻煩。
通常,對 Elasticsearch 的搜索請求需要以下信息:
搜索哪些索引和哪些類型
分頁信息(跳過多少項,返回多少項)
一個具體的類型選擇(在進行聚合時,就像我們在這裡要做的那樣)
查詢本身
高亮定義(如果我們願意,Elasticsearch 可以自動高亮命中)
例如,您可能希望實現一個搜索功能,其中只有部分用戶可以看到您網站上的優質內容,或者您可能希望某些內容僅對其作者的“朋友”可見,等等。
能夠構造查詢對像是解決這些問題的核心,當試圖覆蓋很多場景時,這確實是一個問題。
綜上所述,最重要和最難設置的自然是查詢段——在這裡,我們將主要關注這一點。
查詢是BoolQuery
和其他查詢(例如MatchPhraseQuery
、 TermsQuery
、 DateRangeQuery
和ExistsQuery
)組合的遞歸構造。 這些足以滿足任何基本要求,並且應該是一個好的開始。
MultiMatch
查詢非常重要,因為它使我們能夠指定要在其上進行搜索並稍微調整結果的字段——我們稍後將返回。
MatchPhraseQuery
可以通過常規 SQL 數據庫中的外鍵或枚舉等靜態值來過濾結果——例如,當匹配特定作者的結果 ( AuthorId
) 或匹配所有公共文章 ( ContentPrivacy=Public
) 時。
TermsQuery
將被翻譯為“in”成傳統的 SQL 語言。 例如,它可以返回用戶的一個朋友寫的所有文章,或者從一組固定的商家那裡獨家獲取產品。 與 SQL 一樣,不應過度使用它並將 10,000 個成員放入此數組,因為它會影響性能,但它通常可以很好地處理合理的數量。
DateRangeQuery
是自記錄的。
ExistsQuery
是一個有趣的查詢:它使您能夠忽略或返回沒有特定字段的文檔。
這些與BoolQuery
結合使用時,允許您定義復雜的過濾邏輯。
例如,考慮一個博客站點,其中博客文章可以有一個AvailableFrom
字段,該字段表示它們應該何時可見。
如果我們應用像AvailableFrom <= Now
這樣的過濾器,那麼我們將不會獲得根本沒有該特定字段的文檔(我們聚合數據,並且某些文檔可能沒有定義該字段)。 為了解決這個問題,您可以將ExistsQuery
與DateRangeQuery
結合起來,並將其包裝在BoolQuery
中,條件是至少滿足BoolQuery
中的一個元素。 像這樣的東西:
BoolQuery Should (at least one of the following conditions should be fulfilled) DateRangeQuery with AvailableFrom condition Negated ExistsQuery for field AvailableFrom
否定查詢並不是一個簡單的開箱即用的工作。 但是在BoolQuery
的幫助下,仍然有可能:

BoolQuery MustNot ExistsQuery
自動化和測試
為了讓事情變得更容易,推薦的方法絕對是隨手編寫測試。
這樣,您將能夠更有效地進行實驗,而且更重要的是,您將確保您引入的任何新更改(如更複雜的過濾器)不會破壞現有功能。 我明確不想說“單元測試”,因為我不喜歡模擬 Elasticsearch 引擎之類的東西——模擬幾乎永遠不會是 ES 實際行為方式的現實近似——因此,這可能是集成測試,如果你是一個術語迷。
現實世界的例子
在索引、映射和過濾完成所有基礎工作之後,我們現在準備好進行最有趣的部分:調整搜索參數以產生更好的結果。
在我的上一個項目中,我使用 Elasticsearch 來提供用戶提要:所有內容聚合到一個按創建日期排序的位置,並帶有一些選項的全文搜索。 提要本身非常簡單。 只需確保您的數據中某處有一個日期字段並按該字段排序。
另一方面,搜索在開箱即用時效果不佳。 那是因為,自然地,Elasticsearch 無法知道數據中的重要內容。 假設我們有一些數據(在其他字段中)有Title
、 Tags
(數組)和Body
字段。 body 字段可以是 HTML 內容(讓事情更真實一點)。
拼寫錯誤
要求:即使出現拼寫錯誤或詞尾不同,我們的搜索也應該返回結果。 例如,如果有一篇標題為“你可以用木勺做的偉大事情”的文章,當我搜索“東西”或“木頭”時,我仍然想找到匹配項。
為了解決這個問題,我們必須熟悉分析器、標記器、字符過濾器和標記過濾器。 這些是在索引時應用的轉換。
需要定義分析器。 這可以按索引定義。
分析器可以應用於我們文檔中的某些字段。 這可以使用屬性或流暢的 API 來完成。 在我們的示例中,我們使用了屬性。
分析器是過濾器、字符過濾器和標記器的組合。
為了滿足要求(部分單詞匹配),我們將創建“自動完成”分析器,它包括:
英語停用詞過濾器:刪除所有英語常用詞的過濾器,例如“and”或“the”。
修剪過濾器:刪除每個標記周圍的空白
小寫過濾器:將所有字符轉換為小寫。 這並不意味著當我們獲取數據時,它會被轉換為小寫,而是啟用大小寫不變的搜索。
Edge-n-gram 分詞器:這個分詞器使我們能夠進行部分匹配。 例如,如果我們有一個句子“我奶奶有一把木椅”,當我們尋找“木頭”這個詞時,我們仍然希望找到那個句子。 edge-n-gram 所做的是存儲“woo”、“wood”、“woode”和“wooden”,以便找到與至少三個字母匹配的任何部分單詞。 參數 MinGram 和 MaxGram 定義要存儲的最小和最大字符數。 在我們的例子中,我們至少有 3 個字母,最多 15 個字母。
在下一節中,所有這些都綁定在一起:
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_") ) );
而且,當我們想使用這個分析器時,我們應該像這樣註釋我們想要的字段:
public class SearchItemDocumentBase { ... [Text(Analyzer = "autocomplete", Name = nameof(Title))] public string Title { get; set; } ... }
現在,讓我們看幾個示例,這些示例演示了幾乎所有包含大量內容的應用程序中的常見需求。
清理 HTML
要求:我們的某些字段可能包含 HTML 文本。
自然,您不希望搜索“section”返回類似“<section>...</section>”或返回 HTML 元素“<body>”的“body”。 為避免這種情況,在索引期間,我們將刪除 HTML 並只保留其中的內容。
幸運的是,你不是第一個遇到這個問題的人。 Elasticsearch 附帶了一個有用的字符過濾器:
analysis.Analyzers(a => a .Custom("html_stripper", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .CharFilters("html_strip") .Tokenizer("autocomplete") )
並應用它:
[Text(Analyzer = "html_stripper", Name = nameof(HtmlText))] public string HtmlText { get; set; }
重要領域
要求:標題中的匹配應該比內容中的匹配更重要。
幸運的是,如果匹配發生在一個字段或另一個字段中,Elasticsearch 會提供提高結果的策略。 這是通過使用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)
如您所見, MultiMatch
查詢在這種情況下非常有用,而且這種情況並不少見! 通常,有些字段更重要,有些則不重要——這種機制使我們能夠考慮到這一點。
立即設置提升值並不總是那麼容易。 您需要稍微嘗試一下才能獲得所需的結果。
優先文章
要求:有些文章比其他文章更重要。 要么作者更重要,要么文章本身有更多的likes/shares/upvotes/etc。 更重要的文章應該排名更高。
Elasticsearch 允許我們實現我們的評分功能,我們通過定義一個字段“Importance”的方式對其進行簡化,該字段是雙倍值——在我們的例子中,大於 1。您可以定義自己的重要性函數/因子並應用它相似地。 您可以定義多種提升和評分模式——以最適合您的方式為準。 這對我們很有效:
.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) ) )
每部電影都有一個評分,我們通過他們所出演的電影的平均評分來推斷演員評分(這不是一種非常科學的方法)。 我們將該評級縮放到區間 [0,1] 中的雙倍值。
全字匹配
要求:全詞匹配應該排名更高。
到目前為止,我們的搜索結果相當不錯,但您可能會注意到某些包含部分匹配的結果的排名可能高於完全匹配。 為了解決這個問題,我們在文檔中添加了一個名為“Keywords”的附加字段,該字段不使用自動完成分析器,而是使用關鍵字標記器並提供提升因子以提高精確匹配結果。
僅當確切的單詞匹配時,此字段才會匹配。 它不會像自動完成分析器那樣為“wooden”匹配“wood”。
包起來
本文應該已經為您提供瞭如何在您的 .NET 項目中設置 Elasticsearch 的概述,並通過一些努力提供了一個很好的搜索無處不在的功能。
學習曲線可能有點陡峭,但這是值得的,尤其是當您調整得恰到好處並開始獲得出色的搜索結果時。
永遠記得添加帶有預期結果的完整測試用例,以確保在引入更改和玩耍時不會過多地弄亂參數。
本文的完整代碼可在 GitHub 上獲得,並使用從 TMDB 數據庫中提取的數據來展示搜索結果如何隨著每一步而改進。