结构化搜索与全文搜索
ES在搜索时有两种类型,即全文搜索与结构化搜索。其相对应于"term"系列的查询 和 "match"系列的查询。
这两种类型的查询的区别在于,使用match查询时,ES会对输入的字符串先进行分词,然后进行查询,而term查询不会进行分词。
在ES中,分词对应于Analyzer这个功能,有很多内置的分词器,同时用户也可以自定义分词器。一个完整的分词器会包含3个部分:
charactor filter
: 对文本进行预处理,比如去除html标签之类的工作,会影响Tokenizer的pos和offset信息。
tokenizer
:对文本进行切分,划分成一个一个词。
token filter
:对切分出来的结果进行过滤,过滤掉停用词等。
用户可以在设置索引配置时,为索引设置自定义analyzer,然后为索引的字段设置上这个自定义的analyzer。
PUT products
{
"settings": {
"number_of_shards": 1,
"analysis": {
"analyzer": {
"my_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase"
]
}
}
}
},
"mappings": {
"properties": {
"my_text": {
"type": "text",
"analyzer": "my_analyzer"
}
}
}
}
不仅仅analyzer可以自定义,analyzer的3个组成部分char_filter
,tokenizer
, filter
,用户都可以自定义。
由于文档在写入时,构造倒排索引会对原文进行转小写,去除停用词,加入同义词,变换时态等操作,因此如果在查询时对text类型的字段使用term搜索,可能得不到想要的结果。
PUT /my_book/_doc/1
{
"title": "Hello, World"
}
GET /my_book/_search
{
"query": {
"term": {
"title": {
"value": "Hello, World"
}
}
}
}
{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 0,
"relation" : "eq"
},
"max_score" : null,
"hits" : [ ]
}
}
上面这个查询,由于使用standard分词器,对文本进行了分词,对文本进行了小写处理。在搜索时,搜索大写的”Hello,World!“是搜索不到结果的。如果想搜到结果由两种方法。
在动态mapping中,ES会为text类型的字段添加一个keyword类型的子字段:
GET /my_book/_mapping
{
"my_book" : {
"mappings" : {
"properties" : {
"title" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
}
}
}
}
}
我看可以针对这个keyword类型的子字段进行'term'查询,就可以获得我们想要的结果。
另外一种方法,我们可以使用全文搜索的方式,使用这种方式,在search发生时,ES对输入text类型同样会做分词转换,这样我们就可以搜索到相关的结果了。
GET /my_book/_search
{
"query": {
"term": {
"title.keyword": {
"value": "Hello, World"
}
}
}
}
GET /my_book/_search
{
"query": {
"match": {
"title": "Hello, World"
}
}
}
如上两种搜索方式,都可以获得结果。
TF-IDF 算法与相关性算分
ES在5.x版本之前使用的是相关性算分算法是TF-IDF。
TF,是Term Frequency的缩写,代指词频。算法是用该词在一篇文档中出现的次数除以该文档的总词数。
tf = num_of_searched_terms/num_of_terms_in_doc
根据这个公式来看,这个词在一篇文档中出现频率越高,其词频即TF就越高
IDF,是Inverse Document Frequency,代表的是该词在所有文档中的频率。算法是
idf = log(总文档数/包含该词的文档数)
可以看到,包含改词的文档数越大,总文档数/包含该词的文档数的取值约接近1,idf取值约接近于0。
TF-IDF 就是将TF和IDF进行了加权和。
比如说我们进行如下搜索:
GET article/_search
{
"query": {
"match": {
"title": "beautiful world"
}
}
}
搜索时会将搜索项,"beautiful world"拆分成beautiful和world两个词,对搜索的每篇文档都会对这两个词进行相关性算分,然后将其相加,得到对应文档的相关性算分(tf(beautiful)*idf(beautiful) + tf(world)*idf(world)
)。
Lucene中的tf-idf简化版
score(q,d) = coord(q,d) * queryNorm(q) * cumulate(tf(t in d) * idf(t)^2 * boost(t) * norm(t,d))
其中boost指的是,在搜索时我们可以为某个term提高其算分。norm(t,d)则是表示某个文档长度越短,其贡献的算分越高。
这个相关性算分会存储在返回结果的_score
这个字段里面,并以此进行结果排序。需要注意的是,如果在搜索过程中,指定了排序,那么返回结果不会包含算分。即"_score" : null
。
在现在的版本中(5.x之后),默认的相关性算法改成了BM25,与TF-IDF相比,该算法会在一个最高值处收敛,而不是TD-IDF算法的发散式结果。
匹配一个,匹配两个,顺序匹配
上文我们已经讨论过,在进行全文搜索时,ES会对搜索字段进行分词,针对分词分别在每个文档计算tf和idf,然后进行累加获得一个算分。然后根据算分对结果进行排序。这样的分析逻辑就会带来一个结果。比如,我搜索”Hello World",ES会分别正对“Hello”和“World”进行算分。如果文档中只含有“hello”或者“world”,也会进入到搜索的结果中,只是算分要低一些。
如果想获得匹配两个词项的结果,我们可以通过为match设置参数来达成。
GET /my_book/_search
{
"query": {
"match": {
"title": {
"query": "Hello, World",
"operator": "and"
}
}
}
}
比如说,match的默认operator其实是“or”,只要命中词项中的任意一个就算命中。我们可以在搜索时显式地把参数“operator”设为“and”,这样只有搜索结果中包含所有词项的情况下,结果才算命中。
另外一个方法是设置最小命中词项数。
GET /my_book/_search
{
"query": {
"match": {
"title": {
"query": "Hello, World",
"minimum_should_match": 2
}
}
}
}
但是这样还是无法保证返回结果的顺序,”hello world“和”world hello“会拥有相同的算分。如果要保证匹配的顺序,需要使用match_phrase搜索
GET /my_book/_search
{
"query": {
"match_phrase": {
"title": {
"query": "Hello World"
}
}
}
}
不过由于进行了分词,”Hello, World“与”Hello World“以及”hello world“都会有相同的算分。这个时候可以自定义分词器,或者使用term search来实现。
PUT my_book/_doc/1
{
"title": "Hello, My Girl",
"body": "This World is Beautiful"
}
PUT my_book/_doc/2
{
"title": "hahaha, It is very delicious",
"body": "Hello, World!"
}
GET my_book/_search
{
"query": {
"bool": {
"should": [
{
"match": {
"title": "hello, World"
}
},
{
"match": {
"body": "hello, World"
}
}
]
}
}
}
以上面为例,由于should查询的算分是每个field分别算分再相加。因此虽然2号文档有更符合的组合。但是返回结果算分最高的是第一个结果。
这种情况,可以使用"dis_max"搜索来解决。
GET my_book/_search
{
"query": {
"dis_max": {
"tie_breaker": 0,
"queries": [
{
"match": {
"title": "hello, World"
}
},
{
"match": {
"body": "hello, World"
}
}
]
}
}
}
dismax搜索主要会依靠最佳匹配的结果,对其他结果会使用一个明明tie_breaker的参数,调整期取值,该参数默认为0。
最佳字段,多数字段和混合字段
上一节的情况同样可以使用multi-match的方法来实现,下面这段和上文dis_max的搜索方式等价:
GET my_book/_search
{
"query": {
"multi_match": {
"type": "best_fields",
"query": "hello, World",
"tie_breaker": 0,
"fields": ["title", "body"]
}
}
}
其中需要注意的是type字段,该字段默认值为"best_fields",即搜索结果以最佳匹配的field结果为主,其他fields上的算分通过tie_breaker进行控制。
这个字段另外还可以取值”most_fields“和”cross_fields“,
"most_fields"的效果和上面的bool 查询的效果相似,会对所有fields上面的算分进行累加得到一个结果作为算分。
而"cross_fields"则可以设置一个"operator: "and"",这样只有所有词项都出现的结果才会返回。与上面match搜索中设置”operator“的效果是一致的。
GET my_book/_search
{
"query": {
"multi_match": {
"type": "cross_fields",
"query": "hahaha world",
"operator": "and",
"fields": ["title", "body"]
}
}
}
比如这个搜索,最终只会返回一个结果:
"hits" : [
{
"_index" : "my_book",
"_type" : "_doc",
"_id" : "2",
"_score" : 0.83994377,
"_source" : {
"title" : "hahaha, It is very delicious",
"body" : "Hello, World!"
}
}
]
query_string与simple_query_string
string_query实际上和URI query的功能是类似的,比如上文的搜索就可以写成:
GET my_book/_search
{
"query": {
"query_string": {
"fields": ["title", "body"],
"query": "hahaha AND world"
}
}
}
可以指定要搜索的字段,和内容的组合。
simple_query_string功能类似,但是不支持在query中使用"AND OR NOT",但是可以使用:+代表AND,|代表OR,-代表NOT,这三个符合在query_string中同样可用。同时会忽略错误的语法。
GET my_book/_search
{
"query": {
"simple_query_string": {
"fields": ["title", "body"],
"query": "-hahaha +world",
"default_operator": "AND"
}
}
}