十九種Elasticsearch字符串搜索方式終極介紹

前言

剛開始接觸Elasticsearch的時候被Elasticsearch的搜索功能搞得暈頭轉向,每次想在Kibana裏面查詢某個字段的時候,查出來的結果經常不是自己想要的,然而又不知道問題出在了哪裡。出現這個問題歸根結底是因為對於Elasticsearch的底層索引原理以及各個查詢搜索方式的不了解,在Elasticsearch中僅僅字符串相關的查詢就有19個之多,如果不弄清楚查詢語句的工作方式,應用可能就不會按照我們預想的方式運作。這篇文章就詳細介紹了Elasticsearch的19種搜索方式及其原理,老闆再也不用擔心我用錯搜索語句啦!

簡介

Elasticsearch為所有類型的數據提供實時搜索和分析,不管數據是結構化文本還是非結構化文本、數字數據或地理空間數據,都能保證在支持快速搜索的前提下對數據進行高效的存儲和索引。用戶不僅可以進行簡單的數據檢索,還可以聚合信息來發現數據中的趨勢和模式。

搜索是Elasticsearch系統中最重要的一個功能,它支持結構化查詢、全文查詢以及結合二者的複雜查詢。結構化查詢有點像SQL查詢,可以對特定的字段進行篩選,然後按照特定的字段進行排序得到結果。全文查詢會根據查詢字符串尋找相關的文檔,並且按照相關性排序。

Elasticsearch內包含很多種查詢類型,下面介紹是其中最重要的19種。如果你的app想要添加一個搜索框,為用戶提供搜索操作,並且數據量很大用MySQL會造成慢查詢想改用Elasticsearch,那麼我相信這篇文章會給你帶來很大的幫助。

query和filter區別

在正式進入到搜索部分之前,我們需要區分query(查詢)和filter(過濾)的區別。

在進行query的時候,除了完成匹配的過程,我們實際上在問「這個結果到底有多匹配我們的搜索關鍵詞」。在所有的返回結果的後面都會有一個_score字段表示這個結果的匹配程度,也就是相關性。相關性越高的結果就越排在前面,相關性越低就越靠後。當兩個文檔的相關性相同的時候,會根據lucene內部的doc_id字段來排序,這個字段對於用戶是不可見的也不能控制。

而在進行filter的時候,僅僅是在問「這個文檔符不符合要求」,這僅僅是一個過濾的操作判斷文檔是否滿足我們的篩選要求,不會計算任何的相關性。比如timestamp的範圍是否在2019和2020之間,status狀態是否是1等等。

在一個查詢語句裏面可以同時存在queryfilter,只不過只有query的查詢字段會進行相關性_score的計算,而filter僅僅用來篩選。比如在下面的查詢語句裏面,只有title字段會進行相關性的計算,而下面的status只是為了篩選並不會計算相關性。

GET /_search
{
  "query": {
    "bool": {
      "must": [
        {"match": {"title": "Search"}}
      ],
      "filter": [
        {"term": {"state": 1}}
      ]
    }
  }
}

對於在實際應用中應該用query還是用filter需要根據實際的業務場景來看。如果你的產品的搜索只是需要篩選得到最後的搜索結果並不需要Elasticsearch的相關性排序(你可能自定義了其他的排序規則),那麼使用filter就完全能夠滿足要求並且能夠有更好的性能(filter不需要計算相關性而且會緩存結果);如果需要考慮文檔和搜索詞的相關性,那麼使用query就是最好的選擇。

相關性

上面講到了在使用query查詢的時候會計算相關性並且進行排序,很多人都會好奇相關性是怎麼計算的?

相關性的計算是比較複雜的,詳細的文檔可以看這兩篇博客——什麼是相關性ElasticSearch 使用教程之_score(評分)介紹,我這裡只是做一個簡單的介紹。

Elasticsearch的相似度計算主要是利用了全文檢索領域的計算標準——TF/IDF(Term Frequency/Inverted Document Frequency)也就是檢索詞頻率反向文檔頻率

  1. TF(檢索詞頻率):檢索詞在這個字段裏面出現的頻率越高,相關性越高。比如搜索詞出現5次肯定比出現1次的文檔相關性更高。
  2. IDF(反向文檔頻率):包含檢索詞的文檔的頻率越高,這個檢索詞的相關性比重越低。如果一個檢索詞在所有的文檔裏面都出現了,比如中文的,那麼這個檢索詞肯定就不重要,相對應的根據這個檢索詞匹配的文檔的相關性權重應該下降。
  3. 字段長度:注意這個字段是文檔的裏面被搜索的字段,不是檢索詞。如果這個字段的長度越長,相關性就越低。這個主要是因為這個檢索詞在字段內的重要性降低了,文檔就相對來說不那麼匹配了。

在複合查詢裏面,比如bool查詢,每個子查詢計算出來的評分會根據特定的公式合併到綜合評分裏面,最後根據這個綜合評分來排序。當我們想要修改不同的查詢語句的在綜合評分裏面的比重的時候,可以在查詢字段裏面添加boost參數,這個值是相對於1來說的。如果大於1則這個查詢參數的權重會提高;如果小於1,權重就下降。

這個評分系統一般是系統默認的,我們可以根據需要定製化我們自己的相關性計算方法,比如通過腳本自定義評分。

分析器

分析器是針對text字段進行文本分析的工具。文本分析是把非結構化的數據(比如產品描述或者郵件內容)轉化成結構化的格式從而提高搜索效率的過程,通常在搜索引擎裏面應用的比較多。

text格式的數據和keyword格式的數據在存儲和索引的時候差別比較大。keyword會直接被當成整個字符串保存在文檔裏面,而text格式數據,需要經過分析器解析之後,轉化成結構化的文檔再保存起來。比如對於the quick fox字符串,如果使用keyword類型,保存直接就是the quick fox,使用the quick fox作為關鍵詞可以直接匹配,但是使用the或者quick就不能匹配;但是如果使用text保存,那麼分析器會把這句話解析成thequickfox三個token進行保存,使用the quick fox就無法匹配,但是單獨用thequickfox三個字符串就可以匹配。所以對於text類型的數據的搜索需要格外注意,如果你的搜索詞得不到想要的結果,很有可能是你的搜索語句有問題。

分析器的工作過程大概分成兩步:

  1. 分詞(Tokenization):根據停止詞把文本分割成很多的小的token,比如the quick fox會被分成thequickfox,其中的停止詞就是空格,還有很多其他的停止詞比如&或者#,大多數的標點符號都是停止詞
  2. 歸一化(Normalization):把分隔的token變成統一的形式方便匹配,比如下面幾種
    • 把單詞變成小寫,Quick會變成quick
    • 提取詞幹,foxes變成fox
    • 合併同義詞,jumpleap是同義詞,會被統一索引成jump

Elasticsearch自帶了一個分析器,是系統默認的標準分析器,使用標準分詞器,大多數情況下都能夠有不錯的分析效果。用戶也可以定義自己的分析器,用於滿足不同的業務需求。

想要知道某個解析器的分析結果,可以直接在ES裏面進行分析,執行下面的語句就行了:

POST /_analyze
{
  "analyzer": "standard",
  "text": "1 Fire's foxes"
}

返回的結果是:

{
  "tokens" : [
    {
      "token" : "1",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<NUM>",
      "position" : 0
    },
    {
      "token" : "fire's",
      "start_offset" : 2,
      "end_offset" : 8,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "fox",
      "start_offset" : 9,
      "end_offset" : 12,
      "type" : "<ALPHANUM>",
      "position" : 2
    }
  ]
}

返回的tokens內部就是所有的解析結果,token表示解析的詞語部分,start_offsetend_offset分別表示token在原text內的起始和終止位置,type表示類型,position表示這個token在整個tokens列表裏面的位置。

OK!有了上面的基礎知識,就可以進行下面的搜索的介紹了。

term搜索

term搜索不僅僅可以對keyword類型的字段使用,也可以對text類型的數據使用,前提是使用的搜索詞必須要預先處理一下——不包含停止詞並且都是小寫(標準解析器),因為文檔裏面保存的text字段分詞後的結果,用term是可以匹配的。

exists

返回所有指定字段不為空的文檔,比如這個字段對應的值是null或者[]或者沒有為這個字段建立索引。

GET /_search
{
  "query": {
    "exists": {
      "field": "user"
    }
  }
}

如果字段是空字符串""或者包含null的數組[null,"foo"],都會被當作字段存在。

這個方法可以用來搜索沒有被索引的值或者不存在的值。

fuzzy

fuzzy查詢是一種模糊查詢,會根據檢索詞和檢索字段的編輯距離(Levenshtein Distance)來判斷是否匹配。一個編輯距離就是對單詞進行一個字符的修改,這種修改可能是

  • 修改一個字符,比如boxfox
  • 刪除一個字符,比如blacklack
  • 插入一個字符,比如sicsick
  • 交換兩個相鄰的字符的位置,比如actcat

在進行fuzzy搜索的時候,ES會生成一系列的在特定編輯距離內的變形,然後返回這些變形的準確匹配。默認情況下,當檢索詞的長度在0..2中間時,必須準確匹配;長度在3..5之間的時候,編輯距離最大為1;長度大於5的時候,最多允許編輯距離為2

可以通過配置fuzziness修改最大編輯距離,max_expansions修改最多的變形的token的數量

比如搜索是以下條件的時候:

GET /_search
{
  "query": {
    "fuzzy": {
      "name": "Accha"
    }
  }
}

返回結果有IcchaAccHaaccha還有ccha

ids

根據文檔的_id數組返回對應的文檔信息

GET /_search
{
  "query": {
    "ids": {
      "values": ["1","4","100"]
    }
  }
}

prefix

返回所有包含以檢索詞為前綴的字段的文檔。

GET /_search
{
  "query": {
    "prefix": {
      "name": "ac"
    }
  }
}

返回所有以ac開頭的字段,比如acchuachuachar等等

在某些場景下面比如搜索框裏面,需要用戶在輸入內容的同時也要實時展示與輸入內容前綴匹配的搜索結果,就可以使用prefix查詢。為了加速prefix查詢,還可以在設置字段映射的時候,使用index_prefixes映射。ES會額外建立一個長度在2和5之間索引,在進行前綴匹配的時候效率會有很大的提高。

range

對字段進行範圍的匹配。

GET /_search
{
  "query": {
    "range": {
      "age": {
        "gte": 10,
        "lte": 20
      }
    }
  }
}

搜索年齡在10(包含)和20(包含)之間的結果

regexp

正則表達式匹配。通過正則表達式來尋找匹配的字段,lucene會在搜索的時候生成有限狀態機,其中包含很多的狀態,默認的最多狀態數量是10000

GET /_search
{
  "query": {
    "regexp": {
      "name": "ac.*ha"
    }
  }
}

這個搜索會匹配achhaachintha還有achutha

term

根據檢索詞來準確匹配字段。官方文檔建議不要用term去搜索text類型的字段,因為分析器的原因很有可能不會出現你想要的結果。但是直接使用term去搜索text字段還是可以工作的,前提是明白為什麼會返回這些數據。比如通過下面的搜索:

GET /_search
{
  "query": {
    "term": {
      "name": {
        "value": "accha"
      }
    }
  }
}

如果name字段是keyword類型的,沒有進行解析,那麼只會匹配所有nameaccha的文檔。

如果name字段是text類型的,原字段經過分詞、小寫化處理之後,只能匹配到解析之後的單獨token,比如使用標準解析器,這個搜索會匹配Accha Bacchaso cute accha baccha或者Accha Baccha Shivam等字段。

terms

根據檢索詞列表來批量搜索文檔,每個檢索詞在搜索的時候相當於or的關係,只要一個匹配就行了。Elasticsearch最多允許65,536個term同時查詢。

GET /_search
{
  "query": {
    "terms": {
      "name": [
        "accha",
        "ghazali"
      ]
    }
  }
}

上面的查詢會匹配name字段為acchaghazali的文檔。

除了直接指定查詢的term列表,還可以使用Terms lookUp功能,也就是指定某一個存在的文檔的某一個字段(可能是數字、字符串或者列表)來作為搜索條件,進行terms搜索。

比如有一個文件indexmy_docid10name字段是term並且值為accha,搜索可以這樣寫:

{
  "query": {
    "terms": {
      "name": {
        "index": "my_doc",
        "id": "10",
        "path": "name"
      }
    }
  }
}

這樣就可以返回所有name字段值是accha的文檔里,這個通常可以用來查詢所有和某個文檔某個字段重複的文檔並且不需要提前知道這個字段的值是什麼。

terms_set

terms_set和terms十分類似,只不過是多了一個最少需要匹配數量minimum_should_match_field參數。當進行匹配的時候,只有至少包含了這麼多的terms中的term的時候,才會返回對應的結果。

GET /_search
{
  "query": {
    "terms_set": {
      "programming_languages": {
        "terms": ["c++","java","php"],
        "minimum_should_match_field": "required_match"
      }
    }
  }
}
{
    "name":"Jane Smith",
    "programming_languages":[
        "c++",
        "java"
    ],
    "required_matches":2
}

那麼只有programming_languages列表裏面至少包含["c++", "java", "php"]其中的2項才能滿足條件

還可以使用minimum_should_match_script腳本來配置動態查詢

{
  "query": {
    "terms_set": {
      "programming_languages": {
        "terms": ["c++","java","php"],
        "minimum_should_match_script": {
          "source": "Math.min(params.num_terms, doc['required_matches'].value)"
        }
      }
    }
  }
}

其中params.num_terms是在terms字段中的元素的個數

wildcard

通配符匹配,返回匹配包含通配符的檢索詞的結果。

目前只支持兩種通配符:

  • ?:匹配任何單一的字符
  • *:匹配0個或者多個字符

在進行wildcard搜索的時候最好避免在檢索詞的開頭使用*或者?,這會降低搜索性能。

GET /_search
{
  "query": {
    "wildcard": {
      "name": {
        "value": "acc*"
      }
    }
  }
}

這個搜索會匹配acchuacche或者accio父

text搜索

text搜索實際上是針對被定義為text類型的字段的搜索,通常搜索的時候不能根據輸入的字符串的整體來理解,而是要預先處理一下,把搜索詞變成小的token,再來查看每個token的匹配。

interval

返回按照檢索詞的特定排列順序排列的文檔。這個查詢比較複雜,這裡只是簡單的介紹,詳細的介紹可以看官方文檔

比如我們想查詢同時包含rajnayaka的字段並且ray正好在nayaka前面,查詢語句如下:

POST /_search
{
  "query": {
    "intervals": {
      "name": {
        "match": {
          "query": "raj nayaka",
          "max_gaps": 0,
          "ordered": true
        }
      }
    }
  }
}

上面的查詢會匹配Raj Nayaka Acchu ValmikiYateesh Raj Nayaka

如果把ordered:true去掉,就會匹配nayaka raj

如果把max_gaps:0去掉,系統會用默認值-1也就是沒有距離要求,就會匹配Raj Raja nayaka或者Raj Kumar Nayaka

其中有兩個關鍵詞orderedmax_gaps分別用來控制這個篩選條件是否需要排序以及兩個token之間的最大間隔

match

查找和檢索詞短語匹配的文檔,這些檢索詞在進行搜索之前會先被分析器解析,檢索詞可以是文本、數字、日期或者布爾值。match檢索也可以進行模糊匹配。

GET /_search
{
  "query": {
    "match": {
      "name": "nagesh acchu"
    }
  }
}

以上的查詢會匹配NaGesh AcchuAcchu Acchuacchu。系統默認是在分詞後匹配任何一個token都可以完成匹配,如果修改operatorAND,則會匹配同時包含nageshacchu的字段。

GET /_search
{
  "query": {
    "match": {
      "name": {
        "query": "nagesh acchu",
        "operator": "and"
      }
    }
  }
}

上面這個查詢就只會返回NaGesh Acchu

查詢的時候也可以使用模糊查詢,修改fuzziness參數

GET /_search
{
  "query": {
    "match": {
      "name": {
        "query": "nagesh acchu",
        "operator": "and",
        "fuzziness": 1
      }
    }
  }
}

上面的語句會匹配NaGesh Acchu還有Nagesh Bacchu

match_bool_prefix

match_bool_prefix會解析檢索詞,然後生成一個bool複合檢索語句。如果檢索詞由很多個token構成,除了最後一個會進行prefix匹配,其他的會進行term匹配。

比如使用nagesh ac進行match_bool_prefix搜索

GET /_search
{
  "query": {
    "match_bool_prefix": {
      "name": "nagesh ac"
    }
  }
}

上面的查詢會匹配Nagesh NageshRakshith Achar或者ACoco

實際查詢等價於

GET /_search
{
  "query": {
    "bool": {
      "should": [
        {
          "term": {
            "name": {
              "value": "nagesh"
            }
          }
        },
        {
          "prefix": {
            "name": {
              "value": "ac"
            }
          }
        }
      ]
    }
  }
}

match_phrase

詞組匹配會先解析檢索詞,並且標註出每個的token相對位置,搜索匹配的字段的必須包含所有的檢索詞的token,並且他們的相對位置也要和檢索詞裏面相同。

GET /_search
{
  "query": {
    "match_phrase": {
      "name": "Bade Acche"
    }
  }
}

這個搜索會匹配Bade Acche Lagte,但是不會匹配Acche Bade Lagte或者Bade Lagte Acche

如果我們不要求這兩個單詞相鄰,希望放鬆一點條件,可以添加slop參數,比如設置成1,代表兩個token之間相隔的最多的距離(最多需要移動多少次才能相鄰)。下面的查詢語句會匹配Bade Lagte Acche

GET /_search
{
  "query": {
    "match_phrase": {
      "name": {
        "query": "Bade Acche",
        "slop": 1
      }
    }
  }
}

match_phrase_prefix

match_phrase_prefix相當於是結合了match_bool_prefix和match_phrase。ES會先解析檢索詞,分成很多個token,然後除去最後一個token,對其他的token進行match_phrase的匹配,即全部都要匹配並且相對位置相同;對於最後一個token,需要進行前綴匹配並且匹配的這個單詞在前面的match_phrase匹配的結果的後面。

GET /_search
{
  "query": {
    "match_phrase_prefix": {
      "name": "acchu ac"
    }
  }
}

上面的查詢能夠匹配Acchu Acchu1Acchu Acchu Papu,但是不能匹配acc acchu或者acchu pa

multi_match

multi_match可以同時對多個字段進行查詢匹配,ES支持很多種不同的查詢類型比如best_fields(任何字段match檢索詞都表示匹配成功)、phrase(用match_phrase代替match)還有cross_field(交叉匹配,通常用在所有的token必須在至少一個字段中出現)等等

下面是普通的best_fields的匹配

GET /_search
{
  "query": {
    "multi_match": {
      "query": "acchu",
      "fields": [
        "name",
        "intro"
      ]
    }
  }
}

只要name或者intro字段任何一個包含acchu都會完成匹配。

如果使用cross_fields匹配如下

GET /_search
{
  "query": {
    "multi_match": {
      "query": "call acchu",
      "type": "cross_fields",
      "fields": [
        "name",
        "intro"
      ],
      "operator": "and"
    }
  }
}

上面的匹配需要同時滿足下面兩個條件:

  • name中出現callintro中出現call
  • name中出現acchuintro中出現acchu

所以這個查詢能夠匹配name包含acchuintro包含call的文檔,或者匹配name同時包含callacchu的文檔。

common

common查詢會把查詢語句分成兩個部分,較為重要的分為一個部分(這個部分的token通常在文章中出現頻率比較低),不那麼重要的為一個部分(出現頻率比較高,以前可能被當作停止詞),然後分別用low_freq_operatorhigh_freq_operator以及minimum_should_match來控制這些語句的表現。

在進行查詢之前需要指定一個區分高頻和低頻詞的分界點,也就是cutoff_frequency,它既可以是小數比如0.001代表該字段所有的token的集合裏面出現的頻率也可以是大於1的整數代表這個詞出現的次數。當token的頻率高於這一個閾值的時候,他就會被當作高頻詞。

GET /_search
{
  "query": {
    "common": {
      "body": {
        "query": "nelly the elephant as a cartoon",
        "cutoff_frequency": 0.001,
        "low_freq_operator": "and"
      }
    }
  }
}

其中高頻詞是theaas,低頻詞是nellyelephantcartoon,上面的搜索大致等價於下面的查詢

GET /_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {"body": "nelly"}},
        {"term": {"body": "elephant"}},
        {"term": {"body": "cartoon"}}
      ],
      "should": [
        {"term": {"body": "the"}},
        {"term": {"body": "as"}},
        {"term": {"body": "a"}}
      ]
    }
  }
}

但是第一個查詢的效率要優於第二個,因為common語句有性能上的優化,只有重要的token匹配之後的文檔,才會在不重要的文檔的查詢時候計算_score;不重要的token在查詢的時候不會計算_score

query_string

輸入一個查詢語句,返回和這個查詢語句匹配的所有的文檔。

這個查詢語句不是簡單的檢索詞,而是包含特定語法的的搜索語句,裏面包含操作符比如ANDOR,在進行查詢之前會被一個語法解析器解析,轉化成可以執行的搜索語句進行搜索。用戶可以生成一個特別複雜的查詢語句,裏面可能包含通配符、多字段匹配等等。在搜索之前ES會檢查查詢語句的語法,如果有語法錯誤會直接報錯。

GET /_search
{
  "query": {
    "query_string": {
      "default_field": "name",
      "query": "acchu AND nagesh"
    }
  }
}

上面的查詢會匹配所有的同時包含acchunagesh的結果。簡化一下可以這樣寫:

GET /_search
{
  "query": {
    "query_string": {
      "query": "name: acchu AND nagesh"
    }
  }
}

query_string裏面還支持更加複雜的寫法:

  • name: acchu nagesh:查詢name包含acchunagesh其中的任意一個
  • book.\*:(quick OR brown)book的任何子字段比如book.titlebook.content,包含quick或者brown
  • _exists_: titletitle字段包含非null
  • name: acch*:通配符,匹配任何acch開頭的字段
  • name:/joh?n(ath[oa]n)/:正則表達式,需要把內容放到兩個斜杠/中間
  • name: acch~:模糊匹配,默認編輯距離為2,不過80%的情況編輯距離為1就能解決問題name: acch~1
  • count:[1 TO 5]:範圍查詢,或者count: >10

下面的查詢允許匹配多個字段,字段之間時OR的關係

GET /_search
{
  "query": {
    "query_string": {
      "fields": [
        "name",
        "intro"
      ],
      "query": "nagesh"
    }
  }
}

simple_query_string

和上面的query_string類似,但是使用了更加簡單的語法。使用了下面的操作符:

  • +表示AND操作
  • |表示OR操作
  • -表示否定
  • "用於圈定一個短語
  • *放在token的後面表示前綴匹配
  • ()表示優先級
  • ~N放在token後面表示模糊查詢的最大編輯距離fuzziness
  • ~N放在phrase後面表示模糊匹配短語的slop
GET /_search
{
  "query": {
    "simple_query_string": {
      "query": "acch* + foll~2 + -Karen",
      "fields": [
        "intro"
      ]
    }
  }
}

上面的搜索相當於搜索包含前綴為acch的、和foll編輯距離最大是2的並且不包含Karen的字段,這樣的語句會匹配call me acchu或者acchu follow me

總結

Elasticsearch提供了強大的搜索功能,使用query匹配可以進行相關性的計算排序但是filter可能更加適用於大多數的過濾查詢的情況,如果用戶對於標準解析器不太滿意可以自定義解析器或者第三方解析器比如支持中文的IK解析器

在進行搜索的時候一定要注意搜索keywordtext字段時候的區別,使用term相關的查詢只能匹配單個的token但是使用text相關的搜索可以利用前面的term搜索進行組合查詢,text搜索更加靈活強大,但是性能相對差一點。

參考

什麼是相關性?
ElasticSearch 使用教程之_score(評分)介紹
Full text queries
Term-level queries
Elasticsearch query performance using filter query
Unicode Text Segmentation
短詞匹配
Top hits query with same score?

更多精彩內容請看我的個人博客