2019 年 4 月
第 34 卷,第 4 期
[.NET]
实现你自己的企业搜索
你可能会认为搜索是理所当然的。你每天都会用它来执行各种任务,从为下次旅行找个房间到查找工作所需的信息。正确实现的搜索可以帮你省钱,也可以帮你赚钱。即使应用程序很棒,糟糕的搜索也仍会带来不令人满意的用户体验。
问题在于,尽管搜索很重要,但搜索是 IT 领域中最被误解的功能之一,只有当它缺失或损坏时,人们才会注意到它。不过,搜索不一定就是难以实现的复杂难懂功能。在本文中,我将介绍如何开发 C# 企业搜索 API。
若要了解搜索,必须有搜索引擎。搜索引擎有许多,从开放源代码到商用中的每一种搜索引擎,其中许多引擎在内部利用首选的信息检索库 Lucene。这包括 Azure 搜索、ElasticSearch 和 Solr。在本文中,我将使用 Solr。为什么会这样呢?它已推出很长时间,不仅有大量文档、充满活力的社区,还有许多知名用户。我个人用它在许多应用程序中实现搜索,从小型网站到大型企业应用程序。
获取 Solr
安装搜索引擎听起来可能像是非常复杂的任务。实际上,若要创建需要支持很高的每秒查询数 (QPS) 的生产 Solr 实例,那么的确如此,这可能会很复杂。(QPS 是用于表示搜索工作负荷的常见指标。)
Solr 文档 (lucene.apache.org/solr) 中的“安装 Solr”部分,以及“Apache Solr 参考指南”(bit.ly/2IK7mqY) 中的“良好配置的 Solr 实例”部分详细介绍了这些步骤。但我只需要创建开发 Solr 实例,所以我直接转到 bit.ly/2tEXqoo,并获取二进制版本;solr-7.7.0.zip 就可以了。
我下载并解压缩这个 .zip 文件,打开命令行,然后将目录更改为解压缩文件所在文件夹的根目录。然后,我发出以下命令:
> bin\solr.cmd start
就是这样。我只需转到 http://localhost:8983,便能看到 Solr 管理 UI,如图 1 所示。
图 1:创建并开始使用搜索引擎
获取数据
接下来,我需要获取数据。有许多数据集包含有意思的数据,但每天有数以千计的开发人员最终都会在 StackOverflow 上寻求帮助,因为他们不太记得如何将内容写入文件;他们无法退出 VIM;或者他们需要能解决特定问题的 C# 代码片段。好消息是,StackOverflow 数据以 XML 转储形式提供,其中包含大约 1000 万个问题和答案、标记、徽章、匿名用户信息等 (bit.ly/1GsHll6)。
更棒的是,我可以选择较小 StackExchange 网站中的格式相同、但仅包含几千个问题的数据集。我可以先使用较小的数据集进行测试,稍后再加强基础结构来处理更多数据。
我将先从 datascience.stackexchange.com 中的数据入手,其中包含大约 25,000 个帖子。文件名为 datascience.stackexchange.com.7z。我下载它并提取 Posts.xml。
几个必须了解的概念
搜索引擎和数据已就位,所以现在是时候回顾一些重要概念了。如果你过去常常使用关系数据库,可能会非常熟悉这些概念。
索引是搜索引擎用来存储收集到的所有可供搜索数据的位置。概括性地讲,Solr 将数据存储在所谓的倒排索引中。在倒排索引中,字词(或标记)指向特定文档。当你搜索特定字词时,这就是查询。如果找到(命中或匹配)字词,索引会指示哪些文档包含此字词以及具体位置。
架构是你指定的数据结构。每条记录称为“文档”,就像定义数据库的列一样,你在搜索引擎中指定文档字段。
若要构造架构,可以编辑 XML 文件 schema.xml,同时定义字段及其类型。不过,也可以使用动态字段,这样就会在添加未知字段时自动新建字段。如果你不完全确定数据中的所有字段,可能会发现这样做很有用。
你可能已经想到,Solr 与 NoSQL 文档存储相当类似,因为其中文档内的字段是非规范化字段,并且不一定跨文档集合保持一致。
无架构模式是另一种对索引中的数据进行建模的方法。在这种情况下,你不显式指示 Solr 要为哪些字段编制索引,而是直接添加数据,Solr 会根据添加到索引的数据类型构造架构。若要使用无架构模式,必须有托管架构;也就是说,无法手动编辑字段。这对数据探索阶段或概念验证尤为有用。
我将使用手动编辑的架构,这是推荐用于生产的方法,因为它可以带来更好的控制。
编制索引是向索引添加数据的过程。在编制索引期间,应用程序读取不同的数据源,并准备数据以供引入。这是用来添加文档的字词;你也可能听过“馈送”一词。
为文档编制索引后,就可以进行搜索,但愿能在最前面几个位置返回与特定搜索最相关的文档。
相关性排名是指如何在最前面几个位置返回最适合的结果。这是一个很容易解释的概念。在最理想情况下,当你运行查询后,搜索引擎需要“读懂”你的想法,确切返回你要查找的文档。
精准率与召回率:精准率是指有多少个结果是相关的(结果质量),而召回率则是指返回的所有结果都是相关的有几个(结果数量或完整性)。
分析器、tokenizer 和筛选器:当你为数据编制索引或搜索数据时,分析器会检查文本,并生成标记流。然后,通过筛选器应用转换,以尝试将查询与已编制索引的数据进行匹配。若要更深入地掌握搜索引擎技术,就需要了解这些概念。幸运的是,仅通过简要概述,就能开始创建应用程序了。
了解数据
我需要了解数据,才能对索引建模。为此,我将打开并分析 Posts.xml。
数据如下所示。posts 节点包含许多行子节点。每个子节点对应一条记录,每个字段作为每个子节点中的属性导出。数据非常干净,易于引入搜索引擎,这一点很好:
<posts>
<row Id=”5” PostTypeId=”1” CreationDate=”2014-05-13T23:58:30.457”
Score=”9” ViewCount=”448” Body=”<Contains the body of the question or answer>”
OwnerUserId=”5” LastActivityDate=”2014-05-14T00:36:31.077”
Title=”How can I do simple machine learning without hard-coding behavior?”
Tags=”<machine-learning,artificial-intelligence>;” AnswerCount=”4”
CommentCount=”5” FavoriteCount=”1”
ClosedDate=”2014-05-14T14:40:25.950” />
...
</posts>
我可以一目了然地快速观察到,有不同类型的字段。有一个唯一标识符、一些日期、几个数字字段、一些长文本字段和短文本字段,以及一些元数据字段。为简洁起见,我编辑了大文本字段 Body,而其他所有字段都处于原始状态。
将 Solr 配置为使用经典架构
指定索引的数据结构的方法有很多种。默认情况下,Solr 使用托管架构,即使用无架构模式。但我要手动构造架构,即所谓的经典架构,所以我需要进行一些配置更改。首先,我将创建用于保留索引配置的文件夹,并将它命名为“msdnarticledemo”。此文件夹位于 <solr>\server\solr\,其中 <solr> 是 Solr 解压缩文件所在的文件夹。
接下来,我在此文件夹的根目录下创建文本文件 core.properties,只需要添加以下代码行:name=msdnarticledemo。此文件用于创建 Solr 核心,它仅运行 Lucene 索引实例。你可能也听过“集合”一词,它的具体含义视上下文而定。就我目前的意图和目的而言,核心相当于索引。
我现在需要复制示例干净索引的内容,以用作基础。Solr 在 <solr>\server\solr\configsets\_default 中就包含一个。我将 conf 文件夹复制到 msdnarticledemo 中。
下一步非常重要,我指示 Solr 要使用经典架构;也就是说,我将手动编辑架构。为此,我打开 solrconfig.xml,并添加以下代码行:
<schemaFactory class=”ClassicIndexSchemaFactory”/>
另外,仍在这个文件中,我注释掉两个节点,即 updateRequestProcessorChain with:
name=”add-unknown-fields-to-the-schema”
and updateProcessor with:
name=”add-schema-fields”
借助这两个功能,Solr 可以添加新字段,同时在无架构模式下为数据编制索引。考虑到 xml 注释中不允许使用“--”,我还删除这个 xml 节点内的注释。
最后,我将托管架构重命名为“schema.xml”。就这样,Solr 可以使用手动创建的架构了。
创建架构
下一步是定义架构中的字段,因此我打开 schema.xml,并向下滚动,直到找到 id、_text_ 和 _root_ 的定义。
下面展示了每个字段的定义方式,即作为包含以下内容的 <field> xml 节点:
- 名称:每个字段的名称。
- type:字段类型;可以在 schema.xml 中修改每种类型的处理方式。
- indexed:True 表示此字段可用于搜索。
- stored:True 表示可以返回此字段以供显示。
- required:True 表示必须为此字段编制索引,否则会抛出错误。
- multiValued:True 表示此字段可能包含多个值。
这一开始可能会令人困惑,但一些字段可以显示但无法搜索,一些字段可以搜索,但可能无法在编制索引后进行检索。还有其他高级属性,但此时我不会详细介绍它们。
图 2 展示了我如何在架构中定义 Posts 的字段。对于文本类型,我有很多种,包括 string、text_get_sort 和 text_general。文本搜索是搜索的主要目标,因此有不同的文本类型。我还有 date、integer、float 和一个包含多个值的字段 Tags。
图 2:Schema.xml 中的字段
<field name=”id” type=”string” indexed=”true” stored=”true”
required=”true” multiValued=”false” />
<field name=”postTypeId” type=”pint” indexed=”true” stored=”true” />
<field name=”title” type=”text_gen_sort” indexed=”true”
stored=”true” multiValued=”false”/
<field name=”body” type=”text_general” indexed=”false”
stored=”true” multiValued=”false”/>
<field name=”tags” type=”string” indexed=”true” stored=”true”
multiValued=”true”/>
<field name=”postScore” type=”pfloat” indexed=”true” stored=”true”/>
<field name=”ownerUserId” type=”pint” indexed=”true” stored=”true” />
<field name=”answerCount” type=”pint” indexed=”true” stored=”true” />
<field name=”commentCount” type=”pint” indexed=”true” stored=”true” />
<field name=”favoriteCount” type=”pint” indexed=”true” stored=”true” />
<field name=”viewCount” type=”pint” indexed=”true” stored=”true” />
<field name=”creationDate” type=”pdate” indexed=”true” stored=”true” />
<field name=”lastActivityDate” type=”pdate” indexed=”true” stored=”true” />
<field name=”closedDate” type=”pdate” indexed=”true” stored=”true” />
接下来的内容可能因实现而异。目前,我有很多字段,我可以指定要搜索的哪个字段,以及相应字段相对于其他字段的重要程度。
但首先,我可以使用全方位字段 _text_ 对我的所有字段执行搜索。我仅创建 copyField,并指示 Solr 所有字段中的数据都应该复制到我的默认字段中:
<copyField source=”*” dest=”_text_”/>
现在,当我运行搜索时,Solr 会在此字段中查找,并返回所有与我的查询匹配的文档。
接下来,通过运行以下命令,我重新启动 Solr,以加载核心并应用更改:
> bin\solr.cmd restart
我现在可以开始创建 C# 应用程序了。
获取 SolrNet 并对数据建模
Solr 提供类似 REST 的 API,可以在任何应用程序中轻松使用它。更棒的是,有 SolrNet 库 (bit.ly/2XwkROA),它在 Solr 之上提供抽象层,可便于轻松使用强类型对象,它还有许多功能,可加快搜索应用程序开发。
获取 SolrNet 的最简单方法是,从 NuGet 安装 SolrNet 包。我将在我使用 Visual Studio 2017 新建的控制台应用程序中添加此库。还建议下载其他包(如 SolrCloud),这些是使用其他反转控制机制和附加功能所必需的包。
在我的控制台应用程序中,我需要对索引中的数据进行建模。这很简单:我只需新建类文件 Post.cs 即可,如图 3**** 所示。
图 3:Post 文档模型
class Post
{
[SolrUniqueKey(“id”)]
public string Id { get; set; }
[SolrField(“postTypeId”)]
public int PostTypeId { get; set; }
[SolrField(“title”)]
public string Title { get; set; }
[SolrField(“body”)]
public string Body { get; set; }
[SolrField(“tags”)]
public ICollection<string> Tags { get; set; } = new List<string>();
[SolrField(“postScore”)]
public float PostScore { get; set; }
[SolrField(“ownerUserId”)]
public int? OwnerUserId { get; set; }
[SolrField(“answerCount”)]
public int? AnswerCount { get; set; }
[SolrField(“commentCount”)]
public int CommentCount { get; set; }
[SolrField(“favoriteCount”)]
public int? FavoriteCount { get; set; }
[SolrField(“viewCount”)]
public int? ViewCount { get; set; }
[SolrField(“creationDate”)]
public DateTime CreationDate { get; set; }
[SolrField(“lastActivityDate”)]
public DateTime LastActivityDate { get; set; }
[SolrField(“closedDate”)]
public DateTime? ClosedDate { get; set; }
}
这不过是普通旧 CLR 对象 (POCO),在索引中表示各个文档,但有指示 SolrNet 每个属性映射到哪个字段的属性。
创建搜索应用程序
创建搜索应用程序时,通常需要创建两个独立功能:
索引器:这是我需要先创建的应用程序。很简单,为了有数据可供搜索,我需要将相应数据馈送给 Solr。这可能涉及读取多个数据源中的数据、转换各种格式的数据等,直到数据最终可供搜索。
搜索应用程序:当我的索引中有数据后,我就可以开始创建搜索应用程序了。
有了这两个功能,第一步需要初始化 SolrNet,可以在控制台应用程序中运行下面的代码行(确保 Solr 正在运行!):
Startup.Init<Post>(“http://localhost:8983/solr/msdnarticledemo”);
我将为应用程序中的每个功能都创建一个类。
生成索引器
若要为文档编制索引,我先获取 SolrNet 服务实例,这样就能启动任何受支持的操作:
var solr = ServiceLocator.Current.GetInstance<ISolrOperations<Post>>();
接下来,我需要将 Posts.xml 的内容读取到 XMLDocument 中,这涉及循环访问每个节点、新建 Post 对象、从 XMLNode 中提取每个属性,以及将它分配到相应属性。
请注意,在信息检索或搜索字段中,存储的数据是非规范化的。相比之下,使用数据库时,通常会规范化数据,以免发生重复。不是将所有者名称添加到帖子中,而是添加整数 Id,并创建独立表来匹配 Id 与 Name。不过,在搜索中,你将名称添加为帖子的一部分,导致数据重复。为什么会这样呢?因为规范化数据时,需要执行联接才能进行检索,而联接非常昂贵。但搜索引擎的主要目标之一是速度。用户希望按下按钮就立即获得所需的结果。
现在回到创建 post 对象。在图 4 中,我将仅展示三个字段,因为添加其他字段非常简单。请注意,Tags 是多值字段,我要检查它是否为空值,以免异常抛出。
图 4:填充字段
Post post = new Post();
post.Id = node.Attributes[“Id”].Value;
if (node.Attributes[“Title”] != null)
{
post.Title = node.Attributes[“Title”].Value;
}
if (node.Attributes[“Tags”] != null){
post.Tags = node.Attributes[“Tags”].Value.Split(new char[] { ‘<’, ‘>’ })
.Where(t => !string.IsNullOrEmpty(t)).ToList();}
// Add all other fields
填充对象后,我就可以使用 Add 方法添加每个实例了:
solr.Add(post);
我也可以创建 posts 集合,并使用 AddRange 批量添加 posts:
solr.AddRange(post_list);
这两种方法都很好,但在许多生产部署中已经观察到,每次批量添加 100 个文档往往有助于提升性能。请注意,添加文档并不会让它可供搜索。我需要提交:
solr.Commit();
现在我将执行操作,根据编入索引的数据量和运行数据的计算机,耗时可能介于几秒钟和几分钟之间。
在此过程完成后,我可以转到 Solr 管理 UI,在左中查找“核心选择器”下拉列表,并选择我的核心 (msdnarticledemo)。在“概览”选项卡中,我可以看到“统计信息”,其中显示我刚刚编入索引的文档数。
在我的数据转储中,我有 25,488 个帖子,与我看到的数量相符:
Statistics
Last Modified: less than a minute ago
Num Docs:25488
Max Doc:25688
至此,我的索引中已有数据,可以开始处理搜索了。
在 Solr 中搜索
在返回 Visual Studio 之前,我想展示一下如何在 Solr 管理 UI 中快速搜索,并解释一些可用参数。
在 msdnarticledemo 核心中,我将单击“查询”,并按下底部的蓝色按钮“执行查询”。我获得以 JSON 格式返回的所有文档,如图 5**** 所示。
图 5:通过管理 UI 在 Solr 中查询
那么,我刚刚到底做了什么?为什么系统会返回索引中的所有文档?答案很简单。看看参数,即最上面显示“请求处理器(qt)”的列。可以看到,一个参数的标签为 q,且值为 *:*。 正因为此,系统会返回所有文档。实际上,我运行的查询是使用键值对在所有字段中搜索所有值。如果我只想在“标题”中搜索 Solr,那么 q 值应为 Title:Solr。
这对于生成更复杂的查询非常有用,这些查询为每个字段提供不同的权重,这样做很有意义。在“标题”中找到的字词或短语比在内容中找到的更重要。例如,如果文档的“标题”中有“企业搜索”短语,整个文档很可能与企业搜索有关。不过,如果我在文档的任何“正文”部分中找到这个短语,它可能只是引用几乎不相关的内容。
q 参数可能是最重要的参数,因为它通过计算分数按相关性顺序检索文档。不过,可以通过请求处理器使用其他许多参数来配置 Solr 如何处理请求,包括筛选器查询 (fq)、排序、字段列表 (fl),以及 Solr 文档 (bit.ly/2GVmYGl) 中介绍的其他许多参数。使用这些参数,可以开始生成更复杂的查询。需要一段时间才能掌握,但学习的越多,获得的相关性排名就越好。
请注意,应用程序不是通过管理 UI 使用 Solr。为此,使用的是类似 REST 的接口。如果查看结果正上方,就会看到灰色框中有一个链接,其中包含这个特定查询的调用。单击它会打开一个新窗口,其中包含响应。
下面就是我的查询:
http://localhost:8983/solr/msdnarticledemo/select?q=*%3A*&wt=json
虽然 SolrNet 在后台进行这样的调用,但它呈现我可以在 .NET 应用程序中使用的对象。现在,我将生成基本搜索应用程序。
生成搜索应用程序
在此示例中,我将在数据集的所有字段中搜索问题。如果数据包含问题和答案,我将按 PostTypeId 等于“1”进行筛选,这意味着筛选出问题。 为此,我使用筛选器查询,即 fq 参数。
另外,我将设置一些查询选项来一次返回一页结果,即指示有多少个结果的 Rows,以及指定偏移 (start) 的 StartOrCursor。当然,我也将设置查询。
图 6 展示了运行基本搜索所需的代码,其中查询是我要搜索的文本。
图 6:运行基本搜索
QueryOptions query_options = new QueryOptions
{
Rows = 10,
StartOrCursor = new StartOrCursor.Start(0),
FilterQueries = new ISolrQuery[] {
new SolrQueryByField(“postTypeId”, “1”),
}
};
// Construct the query
SolrQuery query = new SolrQuery(keywords);
// Run a basic keyword search, filtering for questions only
var posts = solr.Query(query, query_options);
运行查询后,我获得 posts,它是 SolrQueryResults 对象。它包含一组结果以及许多属性(其中有多个提供附加功能的对象)。至此,我已有这些结果,可以将它们显示给用户了。
缩小结果范围
在许多情况下,原始结果可能就很好,但用户可能希望通过特定元数据字段来缩小结果范围。可以使用 Facet 按字段向下钻取。在 Facet 中,我获得键值对列表。例如,使用 Tags,我获得每个标记以及每个标记出现的次数。Facet 通常与数值、日期或字符串字段一起使用。文本字段比较难办。
若要启用 Facet,我需要添加新 QueryOption,即 Facet:
Facet = new FacetParameters
{
Queries = new[] {
new SolrFacetFieldQuery(“tags”)
}
}
现在我可以从 posts.FacetFields[“tags”] 中检索 Facet,它是包含每个特定标记以及每个标记在结果集中出现次数的键值对集合。
然后,我可以让用户选择要钻取的标记,以减少筛选器查询返回的结果数,理想情况下会返回相关文档。
改进搜索 - 后续步骤
到目前为止,我已经介绍了使用 Solr 和 SolrNet 实现 C# 基本搜索的要点,数据来自 StackExchange 网站之一中的问题。然而,这只是深入研究使用 Solr 返回相关结果的技巧这一新旅程的开始。
一些后续步骤包括,按权重不同的各个字段进行搜索;在结果中突出显示内容中的匹配项;应用同义词,以返回相关但可能不包含确切搜索字词的结果;词干分解,这是通过将字词缩减至基础形式来提高召回率;为国际用户提供帮助的拼音搜索等。
总而言之,学习如何实现搜索是一项宝贵技能,可能会在你未来的开发人员工作中带来有价值的回报。
我创建了基本搜索项目作为本文的随附内容,如图 7**** 所示。你可以下载它来详细了解企业搜索。搜索愉快!
图 7:示例搜索项目
Xavier Morera 帮助开发人员了解企业搜索和大数据。他在 Pluralsight 开设有课程,有时也会在 Cloudera 开设课程。他在 Search Technologies(现属于 Accenture)工作了很多年,负责搜索实现。他住在哥斯达黎加,可以通过 xaviermorera.com 联系他。**
衷心感谢以下技术专家对本文的审阅:Jose Arias (Accenture)、Jonathan Gonzalez (Accenture)
Jose Arias 热衷于与搜索和大数据相关的技术,特别是用于数据分析的技术。 他是 Accenture 搜索和内容分析部门的高级开发人员。https://www.linkedin.com/in/joseariasq/
Jonathan Gonzalez <(j.gonzalez.vindas@accenture.com)>
Jonathan Gonzalez 是 Accenture 的高级管理架构师,拥有超过 18 年的软件开发和设计经验,最近 13 年专注于信息检索、企业搜索、数据处理和内容分析。https://www.linkedin.com/in/jonathan-gonzalez-vindas-ba3b1543/