第八章-複雜搜尋
黑夜給了我黑色的眼睛,我卻用它尋找光明。
經過了解簡單的API和簡單搜尋,已經基本上能應付大部分的使用場景。可是非關係型資料庫資料的文件資料往往又多又雜,各種各樣冗餘的欄位,組成了一條"記錄"。複雜的資料結構,帶來的就是複雜的搜尋。所以在進入本章節前,我們要構建一個儘可能"複雜"的資料結構。
下面分為兩個場景,場景1偏向資料結構上的複雜並且介紹聚合查詢、指定欄位返回、深分頁,場景2偏向搜尋精度上的複雜。
場景1
儲存一個公司的員工,員工資訊包含姓名、工號、性別、出生年月日、崗位、上級、下級、所在部門、進入公司時間、修改時間、建立時間。其中員工工號作為主鍵ID全域性唯一,員工只有一個直屬上級,但有多個下級,可以通過父子文件實現。員工有可能屬於多個部門(特別是領導可能兼任多個部門的負責人)。
資料結構
建立索引並定義對映結構:
PUT http://localhost:9200/company
{
"mappings":{
"employee":{
"properties":{
"id":{
"type":"keyword"
},
"name":{
"type":"text",
"analyzer":"ik_smart",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
}
},
"sex":{
"type":"keyword"
},
"age":{
"type":"integer"
},
"birthday":{
"type":"date"
},
"position":{
"type":"text",
"analyzer":"ik_smart",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
}
},
"level":{
"type":"join",
"relations":{
"superior":"staff",
"staff":"junior"
}
},
"departments":{
"type":"text",
"analyzer":"ik_smart",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
}
},
"joinTime":{
"type":"date"
},
"modified":{
"type":"date"
},
"created":{
"type":"date"
}
}
}
}
}
資料
接下來是構造資料,我們構造幾條關鍵資料。
- 張三是公司的董事長,他是最大的領導,不屬於任何部門。
- 李四的上級是張三,他的下級是王五、趙六、孫七、周八,他同時是市場部和研發部的負責人,也就是隸屬於市場部和研發部。
- 王五、趙六的上級是張三,他沒有下級,他隸屬於市場部。
- 孫七、周八的上級是李四,他沒有下級,他隸屬於研發部。
更為全面直觀的資料如下表所示:
姓名 | 工號 | 性別 | 年齡 | 出生年月日 | 崗位 | 上級 | 下級 | 部門 | 進入公司時間 | 修改時間 | 建立時間 |
---|---|---|---|---|---|---|---|---|---|---|---|
張三 | 1 | 男 | 49 | 1970-01-01 | 董事長 | / | 李四 | / | 1990-01-01 | 1562167817000 | 1562167817000 |
李四 | 2 | 男 | 39 | 1980-04-03 | 總經理 | 張三 | 王五、趙六、孫七、周八 | 市場部、研發部 | 2001-02-02 | 1562167817000 | 1562167817000 |
王五 | 3 | 女 | 27 | 1992-09-01 | 銷售 | 李四 | / | 市場部 | 2010-07-01 | 1562167817000 | 1562167817000 |
趙六 | 4 | 男 | 29 | 1990-10-10 | 銷售 | 李四 | / | 市場部 | 2010-08-08 | 1562167817000 | 1562167817000 |
孫七 | 5 | 男 | 26 | 1993-12-10 | 前端工程師 | 李四 | / | 研發部 | 2016-07-01 | 1562167817000 | 1562167817000 |
周八 | 6 | 男 | 25 | 1994-05-11 | Java工程師 | 李四 | / | 研發部 | 2018-03-10 | 1562167817000 | 1562167817000 |
插入6條資料:
POST http://localhost:9200/company/employee/1?routing=1
{
"id":"1",
"name":"張三",
"sex":"男",
"age":49,
"birthday":"1970-01-01",
"position":"董事長",
"level":{
"name":"superior"
},
"joinTime":"1990-01-01",
"modified":"1562167817000",
"created":"1562167817000"
}
POST http://localhost:9200/company/employee/2?routing=1
{
"id":"2",
"name":"李四",
"sex":"男",
"age":39,
"birthday":"1980-04-03",
"position":"總經理",
"level":{
"name":"staff",
"parent":"1"
},
"departments":["市場部","研發部"],
"joinTime":"2001-02-02",
"modified":"1562167817000",
"created":"1562167817000"
}
POST http://localhost:9200/company/employee/3?routing=1
{
"id":"3",
"name":"王五",
"sex":"女",
"age":27,
"birthday":"1992-09-01",
"position":"銷售",
"level":{
"name":"junior",
"parent":"2"
},
"departments":["市場部"],
"joinTime":"2010-07-01",
"modified":"1562167817000",
"created":"1562167817000"
}
POST http://localhost:9200/company/employee/4?routing=1
{
"id":"4",
"name":"趙六",
"sex":"男",
"age":29,
"birthday":"1990-10-10",
"position":"銷售",
"level":{
"name":"junior",
"parent":"2"
},
"departments":["市場部"],
"joinTime":"2010-08-08",
"modified":"1562167817000",
"created":"1562167817000"
}
POST http://localhost:9200/company/employee/5?routing=1
{
"id":"5",
"name":"孫七",
"sex":"男",
"age":26,
"birthday":"1993-12-10",
"position":"前端工程師",
"level":{
"name":"junior",
"parent":"2"
},
"departments":["研發部"],
"joinTime":"2016-07-01",
"modified":"1562167817000",
"created":"1562167817000"
}
POST http://localhost:9200/company/employee/6?routing=1
{
"id":"6",
"name":"周八",
"sex":"男",
"age":28,
"birthday":"1994-05-11",
"position":"Java工程師",
"level":{
"name":"junior",
"parent":"2"
},
"departments":["研發部"],
"joinTime":"2018-03-10",
"modified":"1562167817000",
"created":"1562167817000"
}
搜尋
- 查詢研發部的員工
GET http://localhost:9200/company/employee/_search
{
"query":{
"match":{
"departments":"研發部"
}
}
}
- 查詢在研發部且在市場部的員工
GET http://localhost:9200/company/employee/_search
{
"query": {
"bool":{
"must":[{
"match":{
"departments":"市場部"
}
},{
"match":{
"departments":"研發部"
}
}]
}
}
}
*被搜尋的欄位是一個陣列型別,但對查詢語句並沒有特殊的要求。
- 查詢name="張三"的直接下屬。
GET http://localhost:9200/company/employee/_search
{
"query": {
"has_parent":{
"parent_type":"superior",
"query":{
"match":{
"name":"張三"
}
}
}
}
}
- 查詢name="李四"的直接下屬。
GET http://localhost:9200/company/employee/_search
{
"query": {
"has_parent":{
"parent_type":"staff",
"query":{
"match":{
"name":"李四"
}
}
}
}
}
- 查詢name="王五"的直接上級。
GET http://localhost:9200/company/employee/_search
{
"query": {
"has_child":{
"type":"junior",
"query":{
"match":{
"name":"王五"
}
}
}
}
}
聚合查詢
ES中的聚合查詢類似MySQL中的聚合函式(avg、max等),例如計算員工的平均年齡。
GET http://localhost:9200/company/employee/_search?pretty
{
"size": 0,
"aggs": {
"avg_age": {
"avg": {
"field": "age"
}
}
}
}
指定欄位查詢
指定欄位返回值在查詢結果中指定需要返回的欄位。例如只查詢張三的生日。
GET http://localhost:9200/company/employee/_search?pretty
{
"_source":["name","birthday"],
"query":{
"match":{
"name":"張三"
}
}
}
深分頁
ES的深分頁是一個老生常談的問題。用過ES的都知道,ES預設查詢深度不能超過10000條,也就是page * pageSize < 10000。如果需要查詢超過1萬條的資料,要麼通過設定最大深度,要麼通過scroll
滾動查詢。如果調整配置,即使能查出來,效能也會很差。但通過scroll
滾動查詢的方式帶來的問題就是隻能進行"上一頁"、"下一頁"的操作,而不能進行頁碼跳轉。
scroll
原理簡單來講,就是一批一批的查,上一批的最後一個資料,作為下一批的第一個資料,直到查完所有的資料。
首先需要初始化查詢
GET http://localhost:9200/company/employee/_search?scroll=1m
{
"query":{
"match_all":{}
},
"size":1,
"_source": ["id"]
}
像普通查詢結果一樣進行查詢,url中的scroll=1m指的是遊標查詢的過期時間為1分鐘,每次查詢就會更新,設定過長佔會用過多的時間。
接下來就可以通過上述API返回的_scroll_id
進行滾動查詢,假設上面的結果返回"_scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAAFBFk1pNzdFUVhDU3hxX3VtSVFUdDJBWlEAAAAAAAABQhZNaTc3RVFYQ1N4cV91bUlRVHQyQVpRAAAAAAAAAUMWTWk3N0VRWENTeHFfdW1JUVR0MkFaUQAAAAAAAAFEFk1pNzdFUVhDU3hxX3VtSVFUdDJBWlEAAAAAAAABRRZNaTc3RVFYQ1N4cV91bUlRVHQyQVpR"
。
GET http://localhost:9200/_search/scroll
{
"scroll":"1m",
"scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAAFBFk1pNzdFUVhDU3hxX3VtSVFUdDJBWlEAAAAAAAABQhZNaTc3RVFYQ1N4cV91bUlRVHQyQVpRAAAAAAAAAUMWTWk3N0VRWENTeHFfdW1JUVR0MkFaUQAAAAAAAAFEFk1pNzdFUVhDU3hxX3VtSVFUdDJBWlEAAAAAAAABRRZNaTc3RVFYQ1N4cV91bUlRVHQyQVpR"
}
這種方式有一個小小的弊端,如果超過過期時間就不能繼續往下查詢,這種查詢適合一次全量查詢所有資料。但現實情況有可能是使用者在一個頁面停留很長時間,再點選上一頁或者下一頁,此時超過過期時間頁面不能再進行查詢。所以還有另外一種方式,範圍查詢。
另一種深分頁
假設員工資料中的工號ID是按遞增且唯一的順序,那麼我們可以通過範圍查詢進行分頁。
例如,按ID遞增排序,第一查詢ID>0的資料,資料量為1。
GET http://localhost:9200/company/employee/_search
{
"query":{
"range":{
"id":{
"gt":0
}
}
},
"size":1,
"sort":{
"id":{
"order":"asc"
}
}
}
此時返回ID=1的1條資料,我們再繼續查詢ID>1的資料,資料量仍然是1。
GET http://localhost:9200/company/employee/_search
{
"query":{
"range":{
"id":{
"gt":1
}
}
},
"size":1,
"sort":{
"id":{
"order":"asc"
}
}
}
這樣我們同樣做到了深分頁的查詢,並且沒有過期時間的限制。
場景2
儲存商品資料,根據商品名稱搜尋商品,要求準確度高,不能搜尋洗面奶結果出現麵粉。
由於這個場景主要涉及的是搜尋的精度問題,所以並不會有複雜的資料結構,只有一個title欄位。
定義一個只包含title欄位且分詞器預設為standard
的索引:
PUT http://localhost:9200/ware_index
{
"mappings": {
"ware": {
"properties": {
"title":{
"type":"text"
}
}
}
}
}
插入兩條資料:
POST http://localhost:9200/ware_index/ware
{
"title":"洗面奶"
}
POST http://localhost:9200/ware_index/ware
{
"title":"麵粉"
}
搜尋關鍵字"洗面奶":
POST http://localhost:9200/ware_index/ware/_search
{
"query":{
"match":{
"title":"洗面奶"
}
}
}
搜尋結果出現了"洗面奶"和"麵粉"兩個風馬牛不相及的結果,這顯然不符合我們的預期。
原因在分詞一章中已經說明,text
型別預設分詞器為standard
,它會將中文字串一個字一個字拆分,也就是將"洗面奶"拆分成了"洗"、"面"、"奶",將"麵粉"拆分成了"面"、"粉"。而match
會將搜尋的關鍵詞拆分,也就拆分成了"洗"、"面"、"奶",最後兩個"面"都能匹配上,也就出現了上述結果。所以對於中文的字串搜尋我們需要指定分詞器,而常用的分詞器是ik_smart
,它會按照最大粒度拆分,如果採用ik_max_word
它會將詞按照最小粒度拆分,也有可能造成上述結果。
DELETE http://localhost:9200/ware_index
刪除索引,重新建立並指定title欄位的分詞器為ik_smart
。
PUT http://localhost:9200/ware_index
{
"mappings":{
"ware":{
"properties":{
"id":{
"type":"keyword"
},
"title":{
"type":"text",
"analyzer":"ik_smart"
}
}
}
}
}
這時如果插入“洗面奶”和“麵粉”,搜尋“洗面奶”是結果就只有一條。但此時我們插入以下兩條資料:
POST http://localhost:9200/ware_index/ware
{
"id":"1",
"title":"新希望牛奶"
}
POST http://localhost:9200/ware_index/ware
{
"id":"2",
"title":"春秋上新短袖"
}
搜尋關鍵字”新希望牛奶“:
POST http://localhost:9200/ware_index/ware/_search
{
"query":{
"match":{
"title":"新希望牛奶"
}
}
}
搜尋結果出現了剛插入的2條,顯然第二條”春秋上新短袖“並不是我們想要的結果。出現這種問題的原因同樣是因為分詞的問題,在ik
外掛的詞庫中並沒有"新希望"一詞,所以它會把搜尋的關鍵詞"新希望"拆分為"新"和"希望",同樣在"春秋上新短袖"中"新"也並沒有組合成其它詞語,它也被單獨拆成了"新",這就造成了上述結果。解決這個問題的辦法當然可以在ik
外掛中新增"新希望"詞語,如果我們在分詞中所做的那樣,但也有其它的辦法。
短語查詢
match_phrase
,短語查詢,它會將搜尋關鍵字"新希望牛奶"拆分成一個詞項列表"新 希望 牛奶",對於搜尋的結果需要完全匹配這些詞項,且位置對應,本例中的"新希望牛奶"文件資料從詞項和位置上完全對應,故通過match_phrase
短語查詢可搜尋出結果,且只有一條資料。
POST http://localhost:9200/ware_index/ware/_search
{
"query":{
"match_phrase":{
"title":"新希望牛奶"
}
}
}
儘管這能滿足我們的搜尋結果,但是使用者實際在搜尋中常常可能是"牛奶 新希望"這樣的順序,但遺憾的是根據match_phrase
短語匹配的要求是需要被搜尋的文件需要完全匹配詞項且位置對應,關鍵字"牛奶 新希望"被解析成了"牛奶 新 希望",儘管它與"新希望牛奶"詞項匹配但位置沒有對應,所以並不能搜尋出任何結果。同理,此時如果我們插入"新希望的牛奶"資料時,無論是搜尋"新希望牛奶"還是"牛奶新希望"均不能搜尋出"新希望的牛奶"結果,前者的關鍵字是因為詞項沒有完全匹配,後者的關鍵字是因為詞項和位置沒有完全匹配。
所以match_phrase
也沒有達到完美的效果。
短語字首查詢
match_phrase_prefix
,短語字首查詢,類似MySQL中的like "新希望%"
,它大體上和match_phrase_prefix
一致,也是需要滿足文件資料和搜尋關鍵字在詞項和位置上保持一致,同樣如果搜尋"牛奶新希望"也不會出現任何結果。它也並沒有達到我們想要的結果。
最低匹配度
前面兩種查詢中雖然能通過"新希望牛奶"搜尋到我們想要的結果,但是對於"牛奶 新希望"卻無能為力。接下來的這種查詢方式能"完美"的達到我們想要的效果。
先來看最低匹配度的查詢示例:
POST http://localhost:9200/ware_index/ware/_search
{
"query": {
"match": {
"title": {
"query": "新希望牛奶",
"minimum_should_match": "80%"
}
}
}
}
minimum_should_match
即最低匹配度。"80%"代表什麼意思呢?還是要從關鍵字"新希望牛奶"被解析成哪幾個詞項說起,前面說到"新希望牛奶"被解析成"新 希望 牛奶"三個詞項,如果通過match
搜尋,則含有"新"的資料同樣出現在搜尋結果中。"80%"的含義則是3個詞項必須至少匹配80% * 3 = 2.4個詞項才會出現在搜尋結果中,向下取整為2,即搜尋的資料中需要至少包含2個詞項。顯然,"春秋上新短袖"只有1個詞項,不滿足最低匹配度2個詞項的要求,故不會出現在搜尋結果中。
同樣,如果搜尋"牛奶 新希望"也是上述的結果,它並不是短語匹配,所以並不會要求詞項所匹配的位置相同。
可以推出,如果"minimum_should_match":"100%"
也就是要求完全匹配,此時要求資料中包含所有的詞項,這樣會出現較少的搜尋結果;如果"minimun_should_match:0"
此時並不代表一個詞項都可以不包含,而是隻需要有一個詞項就能出現在搜尋結果,實際上就是預設的match
搜尋,這樣會出現較多的搜尋結果。
找到一個合適的值,就能有一個較好的體驗,根據二八原則,以及實踐表明,設定為"80%"能滿足大部分場景,既不會多出無用的搜尋結果,也不會少。
第九章-Java客戶端(下)
基於Java客戶端(上),本文不再贅述如何建立一個Spring Data ElasticSearch工程,也不再做過多文字敘述。更多的請一定配合原始碼使用,原始碼地址https://github.com/yu-linfeng/elasticsearch6.x_tutorial/tree/master/code/spring-data-elasticsearch,具體程式碼目錄在complex
包。
本章請一定結合程式碼重點關注如何如何通過Java API進行父子文件的資料插入,以及查詢。
父子文件的資料插入
父子文件在ES中儲存的格式實際上是以鍵值對方式存在,例如在定義對映Mapping時,我們將子文件定義為:
{
......
"level":{
"type":"join",
"relations":{
"superior":"staff",
"staff":"junior"
}
}
......
}
在寫入一條資料時:
{
......
"level":{
"name":"staff",
"parent":"1"
}
......
}
對於於Java實體,我們可以把level
欄位設定為Map<String, Object>
型別。關鍵注意的是,在使用Spring Data ElasticSearch時,我們不能直接呼叫sava
或者saveAll
方法。ES規定父子文件必須屬於同一分片,也就是說在寫入子文件時,需要定義routing
引數。下面是程式碼節選:
BulkRequestBuilder bulkRequestBuilder = client.prepareBulk();
bulkRequestBuilder.add(client.prepareIndex("company", "employee", employeePO.getId()).setRouting(routing).setSource(mapper.writeValueAsString(employeePO), XContentType.JSON)).execute().actionGet();
一定參考原始碼一起使用。
ES實在是一個非常強大的搜尋引擎。能力有限,實在不能將所有的Java API一一舉例講解,如果你在編寫程式碼時,遇到困難也請聯絡作者郵箱hellobug at outlook.com,或者通過公眾號coderbuff,解答得了的一定解答,解答不了的一起解答。
關注公眾號:CoderBuff,回覆“es”獲取《ElasticSearch6.x實戰教程》完整版PDF。