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

佛西先森發表於2020-04-16

前言

剛開始接觸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?

更多精彩內容請看我的個人部落格

相關文章