.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类,每个专门的类都将从该类继承BlogPostSearchItemProductSearchItem等。

我喜欢在这些类中有映射器表达式。 如果需要,我总是可以修改表达式。

在我最早使用 Elasticsearch 的项目之一中,我编写了一个相当大的 SearchService 类,它使用漂亮而冗长的 switch-case 语句完成了映射和索引:对于我想要放入 Elasticsearch 的每个实体类型,都有一个带有映射的 switch 和查询做过某事。

然而,在整个过程中,我了解到这不是最好的方法,至少对我来说不是。

一个更优雅的解决方案是为每个索引设置某种智能IndexDefinition类和一个特定的索引定义类。 这样,我的基础IndexDefinition类可以存储所有可用索引的列表和一些辅助方法,例如所需的分析器和状态报告,而派生的特定于索引的类处理查询数据库并专门为每个索引映射数据。 这很有用,尤其是当您稍后必须向 ES 添加其他实体时。 它归结为添加另一个SomeIndexDefinition类,该类继承自IndexDefinition并要求您只实现一些方法来查询您在索引中需要的数据。

Elasticsearch 演讲

您可以使用 Elasticsearch 做的所有事情的核心是它的查询语言。 理想情况下,与 Elasticsearch 进行通信所需的只是知道如何构造查询对象。

在幕后,Elasticsearch 将其功能公开为基于 JSON 的基于 HTTP 的 API。

尽管 API 本身和查询对象的结构相当直观,但处理许多现实生活场景仍然很麻烦。

通常,对 Elasticsearch 的搜索请求需要以下信息:

  • 搜索哪些索引和哪些类型

  • 分页信息(跳过多少项,返回多少项)

  • 一个具体的类型选择(在进行聚合时,就像我们在这里要做的那样)

  • 查询本身

  • 高亮定义(如果我们愿意,Elasticsearch 可以自动高亮命中)

例如,您可能希望实现一个搜索功能,其中只有部分用户可以看到您网站上的优质内容,或者您​​可能希望某些内容仅对其作者的“朋友”可见,等等。

能够构造查询对象是解决这些问题的核心,当试图覆盖很多场景时,这确实是一个问题。

综上所述,最重要和最难设置的自然是查询段——在这里,我们将主要关注这一点。

查询是BoolQuery和其他查询(例如MatchPhraseQueryTermsQueryDateRangeQueryExistsQuery )组合的递归构造。 这些足以满足任何基本要求,并且应该是一个好的开始。

MultiMatch查询非常重要,因为它使我们能够指定要在其上进行搜索并稍微调整结果的字段——我们稍后将返回。

MatchPhraseQuery可以通过常规 SQL 数据库中的外键或枚举等静态值来过滤结果——例如,当匹配特定作者的结果 ( AuthorId ) 或匹配所有公共文章 ( ContentPrivacy=Public ) 时。

TermsQuery将被翻译为“in”成传统的 SQL 语言。 例如,它可以返回用户的一个朋友写的所有文章,或者从一组固定的商家那里独家获取产品。 与 SQL 一样,不应过度使用它并将 10,000 个成员放入此数组,因为它会影响性能,但它通常可以很好地处理合理的数量。

DateRangeQuery是自记录的。

ExistsQuery是一个有趣的查询:它使您能够忽略或返回没有特定字段的文档。

这些与BoolQuery结合使用时,允许您定义复杂的过滤逻辑。

例如,考虑一个博客站点,其中博客文章可以有一个AvailableFrom字段,该字段表示它们应该何时可见。

如果我们应用像AvailableFrom <= Now这样的过滤器,那么我们将不会获得根本没有该特定字段的文档(我们聚合数据,并且某些文档可能没有定义该字段)。 为了解决这个问题,您可以将ExistsQueryDateRangeQuery结合起来,并将其包装在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 无法知道数据中的重要内容。 假设我们有一些数据(在其他字段中)有TitleTags (数组)和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 数据库中提取的数据来展示搜索结果如何随着每一步而改进。