使用 Elasticsearch 實現博客站內搜索

Reference:  http://www.open-open.com/lib/view/open1452046497511.html 

一直以來,爲了優化本博客站內搜索效果和速度,我使用 bing 的 site: 站內搜索做爲數據源,在服務端獲取、解析、處理並緩存搜索結果,直接輸出 HTML。這個方案唯一的問題是時效性難以保證,儘管我可以在發佈和修改文章時主動告訴 bing,但它什麼時候更新索引則完全不受我控制。

本着不折騰就渾身不自在的原則,我最終還是使用 Elasticsearch 搭建了自己的搜索服務。Elasticsearch 是一個基於 Lucene 構建的開源、分佈式、RESTful 搜索引擎,很多大公司都在用,程序員的好夥伴 Github 的搜索也用的是它。本文記錄我使用 Elasticsearch 搭建站內搜索的過程,目前支持中文分詞、同義詞、標題匹配優先等常見策略,可以點擊這裏體驗。

安裝 Elasticsearch

部署 Elasticsearch 最簡單的方法是使用 Elasticsearch Dockerfile 。爲了更徹底地折騰,我沒有使用 Docker,好在手動安裝過程也不復雜。

我的虛擬機和線上環境都是 Ubuntu 14.04.3 LTS,Elasticsearch 用的是目前最新的 2.1.1。一切開始之前,先要檢查機器上是否裝有 java 環境,如果沒有可以通過以下命令安裝:

sudo apt-get install openjdk-7-jre-headless

下載 Elasticsearch 2.1.1 壓縮包並解壓:

1
2
wget -c https://download.elasticsearch.org/elasticsearch/release/org/elasticsearch/distribution/zip/elasticsearch/2.1.1/elasticsearch-2.1.1.zip
unzip elasticsearch-2.1.1.zip

我將解壓得到的 elasticsearch-2.1.1 目錄重命名爲 ~/es_root (名稱及位置沒有限制,可以將它挪到你認爲合適的任何位置)。Elasticsearch 無需安裝,直接可以運行:

1
2
3
cd ~/es_root/bin/
chmod a+x elasticsearch
./elasticsearch

如果屏幕上沒有打印錯誤信息,說明 Elasticsearch 服務已經成功啓動。新建一個終端,用 curl 驗證下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
curl -XGET http://127.0.0.1:9200/?pretty
 
{
  "name" : "Goblyn",
  "cluster_name" : "elasticsearch",
  "version" : {
    "number" : "2.1.1",
    "build_hash" : "40e2c53a6b6c2972b3d13846e450e66f4375bd71",
    "build_timestamp" : "2015-12-15T13:05:55Z",
    "build_snapshot" : false,
    "lucene_version" : "5.3.1"
  },
  "tagline" : "You Know, for Search"
}

如果看到以上信息,說明一切正常,否則請根據屏幕上的錯誤信息查找原因。儘管 Elasticsearch 本身是用 java 寫的,但它對外可以通過 RESTful 接口交互,十分方便。

默認情況下 Elasticsearch 的 RESTful 服務只有本機才能訪問,也就是說無法從主機訪問虛擬機中的服務。爲了方便調試,可以修改 ~/es_root/config/elasticsearch.yml 文件,加入以下兩行:

1
2
network.bind_host: "0.0.0.0"
network.publish_host: _non_loopback:ipv4_

但線上環境切忌不要這樣配置,否則任何人都可以通過這個接口修改你的數據。

安裝 IK Analysis

Elasticsearch 自帶的分詞器會粗暴地把每個漢字直接分開,沒有根據詞庫來分詞。爲了處理中文搜索,還需要安裝中文分詞插件。我使用的是 elasticsearch-analysis-ik ,支持自定義詞庫。

首先,下載與 Elasticsearch 2.1.1 匹配的 elasticsearch-analysis-ik 插件。根據文檔,當前需要使用 master 版:

1
2
wget -c https://github.com/medcl/elasticsearch-analysis-ik/archive/master.zip
unzip master.zip

解壓後,進入插件源碼目錄編譯:

1
2
3
sudo apt-get install maven
cd elasticsearch-analysis-ik-master/
mvn package

如果一切順利,在 target/releases/ 目錄下可以找到編好的文件。將其解壓並拷到 ~/es_root 對應目錄:

1
2
mkdir -p ~/es_root/plugins/ik/
unzip target/releases/elasticsearch-analysis-ik-1.6.2.zip -d ~/es_root/plugins/ik/

再將 elasticsearch-analysis-ik 的配置也拷貝到 ~/es_root 對應目錄:

1
2
mkdir -p ~/es_root/config/ik
cp -r config/ik/* ~/es_root/config/ik/

elasticsearch-analysis-ik 的配置文件中很多都是詞表,直接用文本編輯器打開就可以修改,改完記得保存爲 utf-8 格式。

現在再啓動 Elasticsearch 服務,如果看到類似下面這樣的信息,說明 IK Analysis 插件已經裝好了:

[plugins] [Libra] loaded [elasticsearch-analysis-ik]

配置同義詞

Elasticsearch 自帶一個名爲 synonym 的同義詞 filter。爲了能讓 IK 和 synonym 同時工作,我們需要定義新的 analyzer,用 IK 做 tokenizer,synonym 做 filter。聽上去很複雜,實際上要做的只是加一段配置。

打開 ~/es_root/config/elasticsearch.yml 文件,加入以下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
index:
  analysis:
    analyzer:
      ik_syno:
          type: custom
          tokenizer: ik_max_word
          filter: [my_synonym_filter]
      ik_syno_smart:
          type: custom
          tokenizer: ik_smart
          filter: [my_synonym_filter]
    filter:
      my_synonym_filter:
          type: synonym
          synonyms_path: analysis/synonym.txt

以上配置定義了 ik_syno 和 ik_syno_smart 這兩個新的 analyzer,分別對應 IK 的 ik_max_word 和 ik_smart 兩種分詞策略。根據 IK 的文檔,二者區別如下:

  • ik_max_word:會將文本做最細粒度的拆分,例如「中華人民共和國國歌」會被拆分爲「中華人民共和國、中華人民、中華、華人、人民共和國、人民、人、民、共和國、共和、和、國國、國歌」,會窮盡各種可能的組合;
  • ik_smart:會將文本做最粗粒度的拆分,例如「中華人民共和國國歌」會被拆分爲「中華人民共和國、國歌」;

ik_syno 和 ik_syno_smart 都會使用 synonym filter 實現同義詞轉換。爲了方便後續測試,建議創建 ~/es_root/config/analysis/synonym.txt 文件,輸入一些同義詞並存爲 utf-8 格式。例如:

ua,user-agent,userAgent
js,javascript

使用 JavaScript API

通過前面的示例,我們知道通過 curl 或者 Chrome 的 Postman 擴展能輕鬆地與 Elasticsearch 服務交互。爲了更好與已有系統集成,我們還可以使用 Elasticsearch Client。Elasticsearch Client 只是將 RESTful 接口包裝了一層,常見語言都有對應的實現( 查看官方 Client ),自己寫一套也不難。

我的博客系統是 Node.js 寫的,在項目裏直接 npm install elasticsearch --save 就可以安裝 Elasticsearch 的 Node.js 包。

無論進行什麼操作,首先都需要實例化 Elasticsearch Client 對象:

1
2
3
4
5
6
var elasticsearch = require('elasticsearch');
 
var client = new elasticsearch.Client({
    host: '10.211.55.23:9200', //服務 IP 和端口
    log: 'trace' //輸出詳細的調試信息
});

然後就可以調用 client 對象提供的各種方法了,client 對象擁有大量方法,請查看 官方文檔 。這個庫支持兩種調用方式:callback 和 promise:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//callback
client.info({}, function(err, data) {
    if(!err) {
        console.log('result:', data);
    } else {
        console.log('error:', err);
    }
});
 
//promise
client.info({}).then(function(data) {
    console.log('result:', data);
}, function(err) {
    console.log('error:', err);
});

爲了節約篇幅,本文後續貼出的代碼都採用 promise 寫法,並且省略 then 函數。

全文搜索

到現在爲止,所有準備工作都已經完成,馬上就要大功告成了。在進行下一步之前,先簡單介紹一下 Elasticsearch 幾個名詞:

Elasticsearch 集羣可以包含多個索引(Index),每個索引可以包含多個類型(Type),每個類型可以包含多個文檔(Document),每個文檔可以包含多個字段(Field)。以下是 MySQL 和 Elasticsearch 的術語類比圖,幫助理解:

MySQL Elasticsearch
Database Index
Table Type
Row Document
Column Field
Schema Mappping
Index Everything Indexed by default
SQL Query DSL

就像使用 MySQL 必須指定 Database 一樣,要使用 Elasticsearch 首先需要創建 Index:

client.indices.create({index : 'test'});

這樣就創建了一個名爲 test 的 Index。Type 不用單獨創建,在創建 Mapping 時指定就可以。Mapping 用來定義 Document 中每個字段的類型、所使用的 analyzer、是否索引等屬性,非常關鍵。創建 Mapping 的代碼示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
client.indices.putMapping({
    index : 'test',
    type : 'article',
    body : {
        article: {
            properties: {
                title: {
                    type: 'string',
                    term_vector: 'with_positions_offsets',
                    analyzer: 'ik_syno',
                    search_analyzer: 'ik_syno',
                },
                content: {
                    type: 'string',
                    term_vector: 'with_positions_offsets',
                    analyzer: 'ik_syno',
                    search_analyzer: 'ik_syno',
                },
                tags: {
                    type: 'string',
                    term_vector: 'no',
                    analyzer: 'ik_syno',
                    search_analyzer: 'ik_syno',
                },
                slug: {
                    type: 'string',
                    term_vector: 'no',
                },
                update_date: {
                    type : 'date',
                    term_vector: 'no',
                    index : 'no',
                }
            }
        }
    }
});

以上代碼爲 test 索引下的 article 類型指定了字段特徵: title 、 content 和 tags 字段使用 ik_syno 做爲 analyzer,說明它使用 ik_max_word 做爲分詞,並且應用 synonym 同義詞策略; slug 字段沒有指定 analyzer,說明它使用默認分詞;而 update_date 字段則不會被索引。

接着,寫入測試數據並索引:

1
2
3
4
5
6
7
8
9
10
11
12
client.index({
    index : 'test',
    type : 'article',
    id : '100',
    body : {
        title : '什麼是 JS?',
        slug :'what-is-js',
        tags : ['JS', 'JavaScript', 'TEST'],
        content : 'JS 是 JavaScript 的縮寫!',
        update_date : '2015-12-15T13:05:55Z',
    }
})

id 參數如果不指定,系統會自動生成一個並返回,後續在更新、刪除時都要用到它。至於如何更新、刪除,這裏就不寫了,請自行 查看文檔 。

搜一下試試:

1
2
3
4
5
6
7
8
9
10
11
client.search({
    index : 'test',
    type : 'article',
    q : 'JS',
}).then(function(data) {
    console.log('result:');
    console.log(JSON.stringify(data));
}, function(err) {
    console.log('error:');
    console.log(err);
});

沒有問題,可以搜出來!查詢結果數量和具體內容都在 hits 字段中:

1
2
result:
{"took":50,"timed_out":false,"_shards":{"total":5,"successful":5,"failed":0},"hits":{"total":1,"max_score":0.076713204,"hits":[{"_index":"test","_type":"article","_id":"100","_score":0.076713204,"_source":{"title":"什麼是 JS?","slug":"what-is-js","tags":["JS","JavaScript","TEST"],"content":"JS 是 JavaScript 的縮寫!","update_date":"2015-12-15T13:05:55Z"}}]}}

如果要實現更復雜的查詢策略該怎麼辦?那就要請出前面表格中與 SQL 對應的 Query DSL 了。例如以下是本博客站內搜索所使用的 Query DSL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
{
    index : 'test',
    type : 'article',
    from : start,
    body : {
        query : {
            dis_max : {
                queries : [
                    {
                        match : {
                            title : {
                                query : keyword,
                                minimum_should_match : '50%',
                                boost : 6,
                            }
                        }
                    }, {
                        match : {
                            content : {
                                query : keyword,
                                minimum_should_match : '75%',
                                boost : 4,
                            }
                        }
                    }, {
                        match : {
                            tags : {
                                query : keyword,
                                minimum_should_match : '100%',
                                boost : 2,
                            }
                        }
                    }, {
                        match : {
                            slug : {
                                query : keyword,
                                minimum_should_match : '100%',
                                boost : 1,
                            }
                        }
                    }
                ],
                tie_breaker : 0.3
            }
        },
        highlight : {
            pre_tags : ['<b>'],
            post_tags : ['</b>'],
            fields : {
                title : {},
                content : {},
            }
        }
    }
}

from 參數指定從開始跳過多少條結果,用來實現分頁。這份複雜的 Query DSL 搜出來的結果如下:

1
2
result:
{"took":108,"timed_out":false,"_shards":{"total":5,"successful":5,"failed":0},"hits":{"total":1,"max_score":0.29921508,"hits":[{"_index":"test","_type":"article","_id":"100","_score":0.29921508,"_source":{"title":"什麼是 JS?","slug":"what-is-js","tags":["JS","JavaScript","TEST"],"content":"JS 是 JavaScript 的縮寫!","update_date":"2015-12-15T13:05:55Z"},"highlight":{"content":["<b>JS</b> 是 <b>JavaScript</b> 的縮寫!"],"title":["什麼是 <b>JS</b>?"]}}]}}

可以看到,同義詞策略和關鍵詞高亮功能都正常。跑通 Elasticsearch 基本流程,剩餘工作就是導入更多數據、配置更多詞表和嘗試不同策略了,略過不寫。

我接觸 Elasticsearch 一共才幾小時,我的出發點也很簡單,只是爲了給博客加上站內搜索,故本文既不全面也不深入,甚至還包含各種錯誤,僅供參考。Elasticsearch 功能十分強大和複雜,遠遠不是花幾個小時就能玩明白的。最後推薦「 Elasticsearch 權威指南(中文版) 」這本書,非常細緻和全面,我對 Elasticsearch 僅有的一點了解都來自於這本書和官方文檔。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章