Elasticsearch 使用示例及 Hexo 集成

2016.08.14发布于研究暂无评论/目录

这是 Elasticsearch 总结系列的最后一篇,以本博客为例介绍一个 Elasticsearch 的使用实例,系列文章见:

  1. Elasticsearch 简介
  2. Elasticsearch API 介绍
  3. Elasticsearch 使用示例及 Hexo 集成

以下内容均基于 Debian jessie 上安装的 Elasticsearch 2.3。

使用示例

以本博客为例,介绍下索引的定义和查询语句,效果参见搜索页

索引定义

主要考虑了如下几个点:

  • 总共就几百篇文章,所以只用了一个 shard 来存储,没有配置 replica shard,因为重建索引的代价很低。
  • 使用了 STConvert Analysis for Elasticsearch 提供的 char_filter,在分词前把繁体转换成简体。
  • 使用了 IK Analysis for Elasticsearch 插件提供的两个 tokenizer 来分词,为实现同义词匹配,要自定义 analyzer。
  • 分词后使用了一系列的 filter,注意 filter 的顺序。其中 word_delimiter 的配置文件每行一个词。同义词文件格式兼容 solr 和 wordnet 的同义词格式。word 分词里有现成的同义词库,可供参考,因为 filter 在 tonkenizer 之后执行,所以同义词必须要先在词库里。需要注意的是,这里给 title 和 conent 专门保留了一个没有使用同义词 filter 的子 Field,用于提高原始词的评分。
  • 保存了许多字段,但目前查询的时候只用到了 title 和 content,都是进行全文检索,建索引的时候使用 my_ik_max_word 对 Field 做最细粒度的分词,检索的时候使用 my_ik_smart 对关键词做最粗粒度的分词。
  • 配置了 title 和 content 的 term_vector,以使用 fast vector highlighter。
  • 禁用了 content 的 norms,与 title 不同,短的 content 权重应该更低。
{
    "settings": {
        "number_of_shards" :   1,
        "number_of_replicas" : 0,
        "analysis": {
            "filter": {
                "my_synonym_filter": {
                    "type": "synonym",
                    "synonyms_path": "/etc/elasticsearch/synonyms.txt"
                },
                "my_delimiter_filter": {
                    "type": "word_delimiter",
                    "generate_word_parts": false,
                    "split_on_case_change": false,
                    "catenate_words": true,
                    "protected_words_path": "/etc/elasticsearch/protected_delimiter_words.txt"
                },
                "my_verbose_delimiter_filter": {
                    "type": "word_delimiter",
                    "catenate_words": true,
                    "protected_words_path": "/etc/elasticsearch/protected_delimiter_words.txt"
                }
            },
            "analyzer": {
                "my_ik_smart": {
                    "type": "custom",
                    "char_filter": [
                        "tsconvert"
                    ],
                    "tokenizer": "ik_smart",
                    "filter": [
                        "decimal_digit",
                        "cjk_width",
                        "my_delimiter_filter",
                        "lowercase"
                    ]
                },
                "my_ik_max_word": {
                    "type": "custom",
                    "char_filter": [
                        "tsconvert"
                    ],
                    "tokenizer": "ik_max_word",
                    "filter": [
                        "decimal_digit",
                        "cjk_width",
                        "my_verbose_delimiter_filter",
                        "lowercase"
                    ]
                },
                "my_ik_max_word_synonym": {
                    "type": "custom",
                    "char_filter": [
                        "tsconvert"
                    ],
                    "tokenizer": "ik_max_word",
                    "filter": [
                        "decimal_digit",
                        "cjk_width",
                        "my_verbose_delimiter_filter",
                        "lowercase",
                        "my_synonym_filter"
                    ]
                }
            }
        }
    },
    "_default_": {},
    "mappings": {
       "article": {
            "dynamic": false,
            "date_detection": false,
            "properties": {
                "title": {
                    "type": "string" ,
                    "term_vector": "with_positions_offsets",
                    "analyzer": "my_ik_max_word_synonym",
                    "search_analyzer": "my_ik_smart",
                    "fields": {
                        "non_synonym": {
                            "type":  "string",
                            "term_vector": "with_positions_offsets",
                            "analyzer": "my_ik_max_word",
                            "search_analyzer": "my_ik_smart"
                        }
                    }
                },
                "path": {
                    "type": "string",
                    "index": "no"
                },
                "date": {
                    "type": "date",
                    "format": "epoch_second"
                },
                "updated": {
                    "type": "date",
                    "format": "epoch_second"
                },
                "categories": {
                    "type": "string",
                    "index": "not_analyzed"
                },
                "tags": {
                    "type": "string",
                    "index": "not_analyzed"
                },
                "excerpt": {
                    "type": "string",
                    "index": "no"
                },
                "content": {
                    "type": "string",
                    "term_vector": "with_positions_offsets",
                    "analyzer": "my_ik_max_word_synonym",
                    "search_analyzer": "my_ik_smart",
                    "norms": { "enabled": false },
                    "fields": {
                        "non_synonym": {
                            "type":  "string",
                            "term_vector": "with_positions_offsets",
                            "norms": { "enabled": false },
                            "analyzer": "my_ik_max_word",
                            "search_analyzer": "my_ik_smart"
                        }
                    }
                }
           }
       }
    }
}

查询语句

针对 title 和 content 两个 Field 进行全文检索,主要考虑了以下几点:

  • 先使用一个 cross_fields 类型的 multi_match 筛选出包含所有分词的 Document。
  • 再使用两个 phrase 类型的 multi_match 提高分词距离较近的 Document 的评分。
  • 因为建索引的时候禁用了 content 的 norms,这里需要提高下 title 的权重。
  • 为了后面的验证功能,这里使用的是 URI Search 语句,DSL Query 写在 source 参数里。需要注意的是,如果配置 minimum_should_match 的时候需要用到 % 号,必须用 % 进行转义,例如:minimum_should_match: 90%%

假设搜索关键词为 elasticsearch 使用

GET /blogs/article/_search
    ?_source_include=title,excerpt,date,updated,path
    &from=0&size=7
    &source={
        "query": {
            "bool": {
                "must": {
                    "multi_match": {
                        "operator": "and",
                        "type": "cross_fields",
                        "fields": ["title^3", "content"],
                        "query": "elasticsearch 使用"
                    }
                },
                "should": {
                    "multi_match": {
                        "boost": 5,
                        "slop": 100,
                        "type": "phrase",
                        "fields": ["title^6", "content"],
                        "query": "elasticsearch 使用"
                    }
                },
                "should": {
                    "multi_match": {
                        "boost": 10,
                        "slop": 100,
                        "type": "phrase",
                        "fields": ["title.non_synonym^6", "content.non_synonym"],
                        "query": "elasticsearch 使用"
                    }
                }
            }
        },
        "highlight": {
            "fields": {
                "title": {},
                "content": {}
            }
        },
        "sort": [{
            "_score": "desc"
        }, {
            "updated": "desc"
        }, {
            "date": "desc"
        }]
    }

安全配置

Elasticsearch 默认没有任何的登陆验证功能,不过可以购买他们家的 Shield 插件来提供基于角色的认证功能,免费试用了下,挺不错的,不过买不起呢。

这里采用的安全策略是将 Elasticsearch 服务监听本地端口,然后使用 nginx 做反向代理,但只开放 OPIONS (方便跨域 Ajax 请求) 和 GET 方法,至于建索引和其他 Elasticsearch 管理接口,都在服务器本地执行。

这里参考了一篇很给力的文章,使用 nginx 自带的 HTTP Basic Auth 来验证搜索请求。

nginx 具体配置如下:

upstream elasticsearch_server {
    server localhost:9200;
    keepalive 15;
}

server {

    # 省略其他配置

    location /es {
        return 302 /es/;
    }

    location /es/ {
        limit_except GET OPTIONS {
            deny all;
        }

        if ($request_method = OPTIONS) {
            add_header "Access-Control-Allow-Origin" "*";
            add_header "Access-Control-Allow-Methods" "GET, OPTIONS";
            add_header "Access-Control-Allow-Headers" "Cache-Control, Authorization";
            return 204;
        }

        auth_basic "Protected Elasticsearch";
        auth_basic_user_file "elasticsearch-passwd";

        proxy_pass http://elasticsearch_server/;
        proxy_http_version 1.1;
        proxy_redirect off;
        proxy_set_header Connection "Keep-Alive";
        proxy_set_header Proxy-Connection "Keep-Alive";
    }
}

然后创建 elasticsearch-passwd 文件,替换 username 和 password。

echo -n "username:$(openssl passwd -crypt password)" >> /etc/nginx/elasticsearch-passwd

然后用 base64 编码你的用户名和密码:

echo -n "username:password" | base64

发送 ajax 请求的时候,设置一个请求头:Authorization: BASIC base64-str,用上面输出的结果替换掉 base64-str 即可。

集成到 Hexo

为了方便建立索引,编写了几个辅助脚本,代码在 Github 上。

安装依赖

sudo pip install elasticsearch PyYaml

文件说明

  • blogs-index.txt: 定义 blogs 索引。
  • elasticsearch-index.py: 解析 hexo 的 db.json 缓存文件,使用 elasticsearch 的 python api 更新索引。
  • create-blogs-index.sh: 读取 blogs-index.txt 文件并创建 blogs 索引。
  • delete-blogs-index.sh: 删除 blogs 索引。
  • update-blogs-index.sh: 使用 elasticsearch-index.py 脚本读取 hexo 的缓存文件 db.json,并重新索引更新过的文章。

需要注意的地方

  • 这里默认 elasticsearch 服务运行在 localhost:9200。
  • 建立索引的时候,使用了处理过的 page.path 作为 Document ID,因为 hexo 每次清缓存后 page.id 会改变。
  • elasticsearch-index.py 脚本每次更新索引之后,都会把更新时间(东八区)记录下来(默认是.es-last-index-time),下一次更新的时候,只会更新 page.updated 大于上次索引更新时间的文章。如果要重建索引,直接删除这个文件即可。
  • 如果有不想建索引的文章,可以在 exclude 文件(默认是 .es-exclude-articles) 里列出文章的路径 (page.path),例如:
    /search.html
    /404.html
    
#elasticsearch#全文检索#总结

评论