Elasticsearch实战(第2版)
上QQ阅读APP看书,第一时间看更新

2.3 全文搜索

一旦索引了大量文档,能够找到符合特定条件的文档就显得尤为重要。Elasticsearch提供了搜索功能,以全文查询的名义来搜索非结构化文本。

注意 非结构化文本(也称为全文),就像自然语言一样,不像结构化文本那样遵循特定的模式或模型。现代搜索引擎在全文上做了大量的工作以获取相关的结果。Elasticsearch提供了一套丰富的匹配和查询功能,用于处理全文数据。

2.3.1 匹配查询:按作者找书

例如,一位访问我们在线书店的读者想要找到Joshua Bloch撰写的所有书。我们可以使用_search API搭配match查询来构造查询:match查询有助于在非结构化文本或全文中搜索单词。搜索Joshua撰写的书的查询如代码清单2-7所示。

代码清单2-7 查询特定作者撰写的书

GET books/_search
{ 
  "query": {
    "match": {
      "author": "Joshua"
    }
  }
}

在请求体中,我们创建了一个query对象,其中定义了一个match查询。在这个query子句中,我们要求Elasticsearch在索引的所有文档中匹配Joshua撰写的书。

查询发出后,服务器就会分析查询,将查询与其内部的数据结构(倒排索引)进行匹配,从文件存储中获取相关文档,并将它们返回给客户端。在这个例子中,服务器找到了Joshua Bloch撰写的一本书,并返回了它,如图2-13所示。

prefix(前缀)查询

我们可以用各种组合搜索(如全小写、大小写混合等)来重写代码清单2-7中的查询:

"author":"JoShUa" 
"author":"joshua" (or "JOSHUA") 
"author":"Bloch"

所有这些查询都会成功,但搜索缩写人名会失败: 

"author":"josh"

要返回这种正则表达式类型的查询,我们可以使用prefix查询。按如下方式将match查询改为prefix查询,可以获取搜索缩写人名的响应:

GET books/_search
{
  "query": {
    "prefix": {
      "author": "josh"
    }
  }
}

查询值必须是小写,因为prefix查询是一个词项级查询。

图2-13 获取Joshua撰写的书

2.3.2 带有AND运算符的匹配查询

如果将查询中的人名改为“Joshua Doe”,你期望得到什么结果?我们的索引中确实没有Joshua Doe撰写的任何书,因此查询理应不返回任何结果,对吧?但情况并非如此:Joshua Bloch写的那本书仍会被返回。原因是引擎会搜索所有Joshua或Doe撰写的书。在这种情况下,OR运算符被隐式地使用。

让我们看看如何使用运算符根据作者的全名来搜索书。这里以一个查询为例,它搜索作者Joshua Schildt(将Joshua的名和Herbert的姓混合起来)。显然,我们知道索引中并不包含由这个虚构作者撰写的书。如果使用“Joshua Schildt”执行代码清单2-7中的查询,就会如代码清单2-8所示,得到的结果中包含两本书,一本是Joshua Bloch写的,另一本是Herbert Schildt写的,这是因为Elasticsearch默认搜索Joshua或(OR)Schildt撰写的书。

代码清单2-8 搜索虚构作者撰写的书

GET books/_search
{
  "query": {
   "match": {
     "author": "Joshua Schildt"  ←---  搜索这个虚构作者可以得到两本书
   }
  }
}

我们可以调整这个查询,定义一个名为operator的参数,并将其显式地设置为AND,如代码清单2-9所示。这个查询有一个小变化,即在代码清单2-7的基础上添加一个由query和operator组成的author对象(与代码清单2-7中只是简单地为字段提供了query值不同)。

代码清单2-9 在查询中使用AND运算符获取精确匹配的结果

GET books/_search
{
  "query": {
    "match": {
      "author": {  ←---  author字段现在定义了内部属性
        "query": "Joshua Schildt",   ←---  我们的查询
        "operator": "AND"  ←---  AND运算符(默认的是OR)
      }
    }
  }
}

执行查询不会返回任何结果(因为没有Joshua Schildt撰写的书)。

按照同样的逻辑,如果我们想要获取书名精确匹配Effective Java的书,相应的代码如图2-14所示。如果不更改运算符,我们将获得title字段中包含“Effective”或“Java”任一单词的所有书;一旦通过AND运算符来连接这两个单词,查询就会在title字段中寻找同时包含这两个搜索词的书。

图2-14 使用AND运算符精确匹配标题

在执行更加复杂的搜索查询之前,我们需要做一件小事——索引更多文档。到目前为止,我们只有3个文档,对于任何有意义的查询,拥有较多文档将很有帮助。Elasticsearch提供了一个方便的_bulk API来批量索引文档。

2.3.3 使用_bulk API索引文档

在开始准备尝试各种搜索查询之前,我们需要向存储中添加更多文档。我们可以像在2.1.2节中那样,重复使用文档API来多次索引单条文档。然而,可以想象,逐个加载大量文档是一个烦琐的过程。

幸好有一个方便的_bulk API,让我们可以同时索引多个文档。在索引多个文档时,我们可以使用Kibana或cURL来执行_bulk API,但这两种方式在数据格式上有所不同。在第5章中我们详细讨论_bulk API,这里我先简要地介绍一些要点。

批量操作会覆盖现有数据

如果你对图书文档执行_bulk操作,它会将这些图书文档索引到本章开始时创建的现有索引(books)中。新的图书文档中有诸如price、rating等字段,对象结构因为这些额外的属性而变得更加丰富,但与之前的结构有所不同。

如果你不希望现有的books索引受到影响,可以创建一个新的索引(也许是books_new),以避免覆盖现有的索引。为此,你可以通过下面这行代码来修改批量数据文件中的索引名称:

{"index":{"_index":"books_new","_id":"1"}}

确保更新所有的索引行,而不只是更新顶部的那一行。或者,你也可以完全删除_index字段,并将"index"添加到URL中:

POST books_new/_bulk
{"index":{"_id":"1"}}
...

图2-15所示的文本中的示例更新了现有的books索引,而不是创建一个新的索引,因此本章中的所有查询都是在更新后的books索引上完成的。

这个示例使用了本书提供的数据集,这些数据集可以从本书的配套资源中找到。复制books- kibana-dataset.txt文件的内容,并将其粘贴到Kibana的Dev Tools中。图2-15展示了这个文件的部分内容。

图2-15 使用_bulk端点批量索引文档

执行这个查询我们会收到一个确认消息,表明所有文档都已成功索引。

看起来有些奇怪的批量索引文档格式

如果仔细观察使用_bulk加载的文档,你会注意到一些奇怪的语法。每两行对应一个文档,如下所示:

{"index":{"_id":"1"}} 
{"brand": "Samsung","name":"UHD","size_inches":65,"price":1400}

第一行是关于记录的元数据,包括即将执行的操作(如index、delete、update,在本例中是index)、文档ID和记录所属的索引;第二行是实际的文档。在第5章中讨论文档操作时,我们会重新探讨这种格式。

现在我们已经索引了更多文档,可以重新回到正题,继续实验一些其他的搜索功能。让我们从同时在多个字段中搜索一个单词开始。

2.3.4 多字段搜索

当客户在搜索栏中搜索某些内容时,搜索并不一定局限于单个字段。例如,假设我们想要搜索所有出现“Java”一词的文档,不仅在title字段中搜索,还包括在synopsis、tags等其他字段中搜索。为此,我们可以使用多字段(multi-field)搜索。

下面来看一个在title和synopsis两个字段中搜索“Java”的查询示例。与之前的match查询类似,Elasticsearch提供了multi_match查询来满足我们的需求。

我们需要在内部query对象中提供搜索词和我们感兴趣的字段,如代码清单2-10所示。

代码清单2-10 多字段搜索

GET books/_search
{
  "query": {
    "multi_match": {  ←---  multi_match查询可以同时搜索多个字段
      "query": "Java",  ←---  搜索词
      "fields": ["title","synopsis"]  ←---  同时搜索两个字段
    }
  }
}

当执行这个多字段查询时,包含“Java”搜索词的结果会同时出现在title和synopsis字段中。但是,假设我们想要根据特定字段提高结果的优先级,例如,如果在title字段中找到“Java”,我们想要提升这个搜索结果,使其重要性和相关性提升 3 倍,而其他文档保持正常的优先级,就可以使用(内置的)提升功能(用户可能希望看到书名中包含搜索词的文档排在列表的顶部)。

相关性分数

全文查询结果中每个文档都有一个分数,以_score属性的形式附加在每个结果中。_score是一个正浮点数,表示结果文档与查询的相关程度。返回的第一个文档分数最高,最后一个文档分数最低。这就是相关性分数,它表示文档与查询的匹配程度。分数越高,匹配度越高。

Elasticsearch使用了一种叫作Okapi最佳匹配25(Best Match 25,BM25)的算法,这是一种增强的词频-逆文档频率(term frequency-inverse document frequency,TF-IDF)相似性算法,用于计算结果的相关性分数,并按照分数对结果进行排序,最后呈现给客户。

下面我们就来看一下如何提升结果。

2.3.5 提升结果

当针对多个字段发起查询时,我们希望赋予某些字段更高的优先级(相关性)。这样,即使用户没有明确指定哪些字段应该被提升,也可以得到最佳结果。Elasticsearch允许在查询中通过为字段设置提升因子来提升其优先级。例如,要将title字段提升3倍,可以将其设置为title^3(字段名后面跟上插入符^和提升因子),如代码清单2-11所示。

代码清单2-11 在multi_match查询中提升字段的重要性

GET books/_search
{
  "query": {
    "multi_match": {  ←---  在多个字段中搜索
      "query": "Java",
      "fields": ["title^3","synopsis"]  ←---  插入符后面跟上提升因子,表示此字段要提升
    }
  }
}

结果显示,title字段的分数权重已经提高。这意味着我们通过提升此文档的分数,将其排名提高了。

我们可能想要搜索一个短语,例如“how is the weather in London this morning”或者“recipe for potato mash”。为此,我们需要使用另一种类型的查询——match_phrase查询,我们接下来就来讨论它。

2.3.6 搜索短语

有时,我们希望按照给定的顺序精确地搜索一组单词,例如找到synopsis字段中包含短语“must-have book for every Java programmer”的所有书。我们可以为此编写一个match_phrase查询,如代码清单2-12所示。

代码清单2-12 搜索包含精确短语的书

GET books/_search
{
  "query": {
    "match_phrase": {  ←---  match_phrase查询获取精确匹配的短语(一组有序的单词)
      "synopsis": "must-have book for every Java programmer"  ←---  在每本书的synopsis字段中搜索的短语
    }
  }
}

这个查询会在所有书的synopsis字段中搜索这组有序的单词,并返回Effective Java这本书:

"hits" : [{
  "_score" : 7.300332,
  "_source" : {
  "title" : "Effective Java",
  "synopsis" : "A must-have book for every Java programmer and Java ...",
}]}

高亮显示的部分证明查询成功地找到了我们想要的书。

高亮显示结果

让我们看看如何在返回的文档中高亮显示与原始查询相匹配的部分文本。例如,当我们在博客网站上搜索一个单词或短语时,网站通常会使用颜色或阴影来高亮显示匹配的文本。下图展示了我们在Elasticsearch的官方文档网站中搜索“match phrase”时的高亮显示的实际效果。

匹配的内容通过颜色和阴影高亮显示

我们可以使用称为高亮显示的便捷功能在结果中实现与上面相同的效果。为此,我们需要修改搜索查询,在请求体中与query对象同级的位置加入一个highlight对象:

GET books/_search
{
  "query": {
    "match_phrase": {
      "synopsis": "must-have book for every Java programmer"
    }
  },
  "highlight": {  ←---  highlight对象与query对象位于同一层级
    "fields": {  ←---  希望高亮显示的字段
      "synopsis": {}
    }
  }
}

在highlight对象中,我们可以设置想要高亮显示的字段。例如,这里我们告诉引擎在synopsis对象上设置高亮显示。最终的结果类似下面这样,其中使用HTML标记标签(em)高亮显示的匹配项表示要强调的单词:

"hits" : [
  "_source" : {
    ...
    "title" : "Effective Java",
    "synopsis" : "A must-have book for every Java programmer"
  },
  "highlight" : {
    "synopsis" : [
    "A <em>must</em>-<em>have</em> <em>book</em> <em>for</em>
<em>every</em> <em>Java</em> <em>programmer</em> and Java aspirant.."]}}
]

我们可以依赖match_phrase进行精确的短语搜索。但是,如果我们遗漏了短语中的一个或两个单词怎么办?例如,如果我们请求搜索“must-have book Java programmer”(删除了“for”和“every”这两个词),查询是否还能工作?如果我们在删除这两个单词后重新执行这个查询,将不会得到结果。幸运的是,我们可以通过在match_phrase查询上设置一个slop参数来请求Elasticsearch。下面我们就来讨论这一点。

2.3.7 处理缺失单词的短语

match_phrase查询期望一个完整的短语:一个没有缺失任何单词的短语。然而,用户可能不会总是输入精确的短语。为了处理这种情况,Elasticsearch的解决方案是在match_phrase查询上设置一个slop参数——表示在搜索时短语可以缺失的单词数量的正整数。代码清单2-13所示的查询中slop为2,表示这个搜索查询在执行的时候可以接受最多有两个单词缺失或者顺序不正确。

代码清单2-13 匹配缺失单词的短语(使用slop)

GET books/_search
{
  "query": {
    "match_phrase": {
      "synopsis": {
      "query": "must-have book every Java programmer",
      "slop": 2
      }
    }
  }
}

2.3.8 处理拼写错误

有时用户在搜索条件中会输入错误的拼写。搜索引擎可以容忍,尽管存在拼写错误,它们仍然会返回结果。现代搜索引擎接纳拼写问题,并提供功能来优雅地处理这些问题。Elasticsearch使用莱文斯坦距离算法(Levenshtein distance algorithm,也称edit distance algorithm,即编辑距离算法),非常努力地查找单词之间的相似性。

Elasticsearch通过使用带有fuzziness设置的match查询来处理拼写错误。如果fuzziness设置为1,则可以容忍一处拼写错误(一个字母位置错误、遗漏或多余)。例如,如果一个用户搜索“Komputer”,默认情况下查询不应该返回任何结果,因为“Komputer”拼写错误。这可以通过编写代码清单2-14所示的match查询来纠正。

代码清单2-14 匹配包含拼写错误的短语(使用fuzziness)

GET books/_search
{
  "query": {
    "match": {
      "tags": {
        "query": "Komputer",  ←---  拼写错误的查询
        "fuzziness": 1  ←---  将fuzziness设为1来处理一处拼写错误
      }
    }
  }
}

模糊匹配和编辑距离

模糊查询通过使用编辑距离来搜索与查询相似的词项。在前面的例子中,需要将单个字母K替换为C来找到与查询匹配的文档。编辑距离是一种通过更改单个字符来将一个单词转换为另一个单词的方法。模糊匹配不仅可以更改字符,还可以插入和删除字符以匹配单词。例如,“Komputer”可以通过替换单个字母变成“Computer”。如果你想了解更多关于编辑距离的信息,可查阅CodeProject上的博客文章“Fast, memory efficient Levenshtein algorithm”。

到目前为止,我们一直在通过使用全文查询来搜索非结构化(全文)数据。除了全文查询,Elasticsearch还支持用于搜索结构化数据的查询:词项级查询(term-level query)。词项级查询可以用于搜索结构化数据,如数值、日期、IP地址等。下面我们就看一下这些查询。