Elasticsearch 实现博客全文检索与搜索词高亮

  • 原创
  • Madman
  • /
  • 2018-10-23 15:02
  • /
  • 0
  • 179 次阅读

elk-min.jpg

Synopsis: 博客系统使用 Flask 开发,ORM使用 MongoEngine,文章正文支持Markdown。使用上一篇文章中搭建的 Elasticsearch 来实现博文全文检查,需要使用 Python Elasticsearch Client 来操作 Elasticsearch。为各个数据模型创建Index,添加对象时自动往对应的Index中添加Document,删除对象时自动删除Document,这就要用到 mongoengine.signals 所提供的 "信号" 机制。高亮搜索词使用 re 正则表达式,难点在于文章正文是 Markdown 类型的,所以可能会出现搜索词刚好在 图片链接、URL链接、TOC目录的锚中,需要额外处理

先上两张效果图:

1. 安装中文分词 ik

由于博文基本上是中文字符,使用Elasticsearch默认的词法分析器效果不好,所以需要安装elasticsearch-analysis-ik

首先确认你的 Elasticsearch 版本号是多少,安装对应的ik版本哦: https://github.com/medcl/elasticsearch-analysis-ik/releases

[root@CentOS ~]# /usr/share/elasticsearch/bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v6.4.2/elasticsearch-analysis-ik-6.4.2.zip

[root@CentOS ~]# ls /usr/share/elasticsearch/plugins

需要重启Elasticsearch

2. Python Elasticsearch Client

参考: https://elasticsearch-py.readthedocs.io/en/master/

通过pip安装elasticsearch包:

[root@CentOS ~]# pip install elasticsearch

2.1 在Flask中配置Elasticsearch

首先在Flask配置类config.py中添加:

# 通用环境配置基类
class Config(object):
    ...
    ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL') or '192.168.40.128:9200'

然后在应用程序工厂函数app/__ini__.py中添加一个elasticsearch属性:

from elasticsearch import Elasticsearch

def create_app(config_name=None):
    """Factory Pattern: Create Flask app."""
    app = Flask(__name__)
    configure_app(app, config_name)
    ...

def configure_app(app, config_name):
    """Configures App."""
    app.config.from_object(config[config_name])
    # 全文搜索
    app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \
        if app.config['ELASTICSEARCH_URL'] else None
    config[config_name].init_app(app)

2.2 全文检索抽象化

创建app/utils/elasticsearch.py

from flask import current_app
from elasticsearch.exceptions import NotFoundError


def add_to_index(index, model):
    if not current_app.elasticsearch:
        return

    # 由于博客主要是中文的,所以使用 ik 中文分词插件。先要配置 Index 的 mapping
    if not current_app.elasticsearch.indices.exists(index=index):  # 如果是第一次插入,Index 还没创建
        # 创建 Index
        current_app.elasticsearch.indices.create(index=index, ignore=400)
        # IK 模板,这里假设每个字段都用 text 类型,如果你要修改,也可以通过 __searchable__ 传递过来
        chinese_field_config = {
            "type": "text",
            "analyzer": "ik_max_word",
            "search_analyzer": "ik_max_word"
        }

        properties = {}
        for field in model.__searchable__:
            properties[field] = chinese_field_config

        mapping = {
            index: {
                "properties": properties
            }
        }
        current_app.elasticsearch.indices.put_mapping(index=index, doc_type=index, body=mapping)

    # 插入新文档
    payload = {}
    for field in model.__searchable__:
        payload[field] = getattr(model, field)
    current_app.elasticsearch.index(index=index, doc_type=index, id=model.id,
                                    body=payload)


def remove_from_index(index, model):
    if not current_app.elasticsearch:
        return
    try:
        current_app.elasticsearch.delete(index=index, doc_type=index, id=model.id)
    except NotFoundError as e:
        pass


def query_index(index, query):
    if not current_app.elasticsearch:
        return [], 0

    # 中文分词器 ik 会将 query 拆分成哪些查找关键字,前端将通过正则表达式来高亮这些词
    analyze_body = {
        "analyzer": "ik_max_word",
        "text": query
    }
    tokens = current_app.elasticsearch.indices.analyze(index=index, body=analyze_body)
    highlights = '+'.join([item['token'] for item in tokens['tokens']])

    # 匹配的记录, ES默认只返回查询结果的10条,指定 size 可以多条
    search = current_app.elasticsearch.search(
        index=index, doc_type=index,
        body={
            'query': {
                'multi_match': {
                    'query': query,
                    'fields': ['*']
                }
            },
            "size": 1000
        })
    ids = [str(hit['_id']) for hit in search['hits']['hits']]
    scores = [hit['_score'] for hit in search['hits']['hits']]

    return ids, scores, highlights

然后,再创建app/models/search.py,里面添加一个SearchableMixin类,以后哪个数据模型要实现全文检索功能的话,就继承这个类即可

from app.utils.elasticsearch import add_to_index, remove_from_index, query_index


class SearchableMixin(object):
    @classmethod
    def search(cls, expression, queryset_manager='objects'):
        ids, scores, highlights = query_index(cls._meta['collection'], expression)
        queryset = getattr(cls, queryset_manager)  # 比如博客类,可以只返回 已发布 的博客

        # 更新数据库,因为后续 queryset(id__in=ids) 会按定义的字段进行排序,不会按 ids或scores 的顺序
        for id, score in zip(ids, scores):
            queryset(id=id).update(es_score=score)  # Atomic updates, 不要用 save() 会很慢,因为定义了 post_save() 信号
            queryset(id=id).update(es_highlights=highlights)

        return queryset(id__in=ids).order_by('-es_score')

    @classmethod
    def post_save(cls, sender, document, **kwargs):
        '''注意: 不能写在 clean() 方法里,因为创建博客对象时,还没有分配 id
        要使用 MongoEngine 提供的信号机制: 需要安装blinker库
        '''
        add_to_index(cls._meta['collection'], document)

    @classmethod
    def post_delete(cls, sender, document, **kwargs):
        '''注意: 不能写在 clean() 方法里'''
        remove_from_index(cls._meta['collection'], document)

    @classmethod
    def reindex(cls):
        for document in cls.objects:
            add_to_index(cls._meta['collection'], document)

2.3 修改数据模型

假设我要为博客Post数据模型提供Elasticsearch全文检索功能的话,首先需要继承SearchableMixin类,然后在该数据模型中增加__searchable__属性

另外,Elasticsearch搜索到的记录是按得分_score来排序的,但我们用查询到记录的ids去MongoEngine返回查询集(BaseQuerySet)Post.objects(id__in=ids),博客对象的顺序跟得分_score并不匹配,所以我们还需要新增两个属性es_scorees_highlights

from app.models.search import SearchableMixin


# 数据模型: 博客文章
class Post(SearchableMixin, db.DynamicDocument):
    # Elasticsearch 全文搜索的字段
    __searchable__ = ['title', 'summary', 'content_raw', 'content_ckeditor']
    ...
    # Elasticsearch 搜索时,如果找到了此对象,则把此次搜索的分数保存下来,用于按分数降序展示搜索结果
    es_score = db.FloatField(max_length=20, required=False, default=None)
    # Elasticsearch 搜索时,通过分词器将搜索词拆分后的tokens
    es_highlights = db.StringField(required=False, default=None)

另外,要实现新增博客时,自动创建Index下的Document;删除博客时,自动删除Index下的Document,需要 mongoengine.signals 所提供的 "信号" 机制

MongoEngine提供的信号包括:

  • pre_init: 在Document或者EmbeddedDocument实例对象初始化完成之前调用
  • post_init: 在Document或者EmbeddedDocument实例对象初始化完成之后调用
  • pre_save: 在调用save()方法新增/修改实例对象之前调用
  • pre_save_post_validation: 在调用save()方法新增/修改实例对象,并在数据检验完成之后、数据保存之前调用
  • post_save: 在调用save()方法新增/修改实例对象之后调用
  • pre_delete: 在调用delete()方法删除实例对象之前调用
  • post_delete: 在调用delete()方法删除实例对象之后调用
  • pre_bulk_insert: 在数据检验完成之后、数据保存之前调用
  • post_bulk_insert: 在数据保存之后调用

我们需要用到post_savepost_delete两个信号,注意下面代码中,使用 signals 将信号与回调函数进行连接是在数据模型的外面哦:

# 数据模型: 博客文章
class Post(SearchableMixin, db.DynamicDocument):
    ...

signals.post_save.connect(Post.post_save, sender=Post)  # 在数据保存完成之后调用
signals.post_delete.connect(Post.post_delete, sender=Post)  # 在数据成功删除之后调用

如果要为Category、Tag、Media、Comment等数据模型也实现全文检索功能的话,操作步骤一样

2.4 初始化索引

python manage.py shell会话中:

>>> Post.reindex()

2.5 搜索表单

<!-- Search Form -->
<div class="col-6 col-md-5">
  <form id="search-form" class="input-group rounded" action="{{ url_for('blog.search') }}" method="GET">
    <input id="q" name="q" class="form-control w-100 g-brd-secondary-light-v2 g-brd-primary--focus g-color-secondary-dark-v1 g-placeholder-secondary-dark-v1 g-bg-white g-font-weight-400 g-font-size-13 rounded g-px-20 g-py-12" type="text" placeholder="请输入搜索关键字...">
    <span class="input-group-addon g-brd-none g-py-0 g-pr-0">
      <button class="btn u-btn-white g-color-primary--hover g-bg-secondary g-font-weight-600 g-font-size-13 text-uppercase rounded g-py-12 g-px-20" type="submit">
        <span class="g-hidden-md-down">Search</span>
        <i class="g-hidden-lg-up fa fa-search"></i>
      </button>
    </span>
  </form>
</div>
<!-- End Search Form -->

2.6 搜索视图函数

class Search(MethodView):
    def get(self):
        q = request.args.get('q')

        # 分页的博客列表(且发布状态为True)
        page = request.args.get('page', 1, type=int)

        if current_app.elasticsearch:  # 如果启用了 Elasticsearch
            elasticsearch = True
            posts = Post.search(q, queryset_manager='live_posts')
        else:
            elasticsearch = False
            posts = Post.live_posts.filter(
                Q(title__icontains=q) | Q(summary__icontains=q)
                | Q(content_html__icontains=q) | Q(content_raw__icontains=q))
        ...

3. 搜索词高亮

博客的摘要信息是去除了HTML标记的普通文本,所以搜索词高亮比较简单,定义一个Jinja2模版过滤器即可:

@app.template_filter()
    def es_highlight(source, keyword):
        """搜索结果中高亮关键词"""
        for key in keyword.split('+'):
            source = re.sub(
                r'(%s)' % re.escape(key),
                "<span style='color: red; background: yellow;'>\g<1></span>",
                source,
                flags=re.IGNORECASE)
        return source

使用时:

{# 摘要 #}
{% if q %}
    {% if elasticsearch %}
        {{ post.summary|striptags|es_highlight(post.es_highlights)|safe }}
    {% else %}
        {{ post.summary|striptags|highlight(q)|safe }}
    {% endif %}
{% else %}
    {{ post.summary|striptags }}
{% endif %}

而博客标题需要额外处理,先定义一个Jinja2模版过滤器:

@app.template_filter()
def es_highlight_title(source, keyword):
    """搜索结果中高亮关键词。 bug: 不能搜索html标记中包含的字母或字母组合,如搜索a,会把html标签也替换,整个页面就乱了"""
    bleach_html_cleans = ['a', 'abbr', 'acronym', 'b', 'br', 'blockquote', 'code', 'caption', 'del', 'dl', 'dd', 'div', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'strong', 'span', 'sub', 'sup', 'section', 'small', 'table', 'thead', 'tr', 'th', 'td', 'tbody', 'u', 'ul', 'class', 'id', 'href', 'title', 'rel', 'target', 'title', 'src', 'title', 'alt', 'style', 'width', 'height', 'style', 'color', 'background-color', 'red', 'yellow', 'font-weight', 'http', 'https', 'mailto', 'smb']
    bleach_flag = False
    for key in keyword.split('+'):
        for clean in bleach_html_cleans:
            if key in clean:
                bleach_flag = True
                break
        if not bleach_flag:  # 不在违法的HTML标记中时,才替换
            source = re.sub(
                r'(%s)' % re.escape(key),
                "<span style='color: red; background: yellow;'>\g<1></span>",
                source,
                flags=re.IGNORECASE)
    return source

使用时:

{# 展示全文搜索的结果的标题 #}
{% if q %}
    {# 说明使用 Elasticsearch, 要高亮数据库中的 es_highlights 字段值 #}
    {% if elasticsearch %}
        <a class="u-link-v5 g-color-black g-color-primary--hover" href="{{ url_for('blog.post_detail', slug=post.slug) }}?q={{ q }}&highlights={{ post.es_highlights }}">
            {{ post.title|es_highlight_title(post.es_highlights)|safe }}
        </a>
    {% else %}
        <a class="u-link-v5 g-color-black g-color-primary--hover" href="{{ url_for('blog.post_detail', slug=post.slug) }}?q={{ q }}">
            {{ post.title|highlight(q)|safe }}
        </a>
    {% endif %}
{# 展示普通的博客列表的标题(非搜索) #}
{% else %}
    <a class="u-link-v5 g-color-black g-color-primary--hover" href="{{ url_for('blog.post_detail', slug=post.slug) }}">
        {{ post.title }}
    </a>
{% endif %}

最麻烦的是博客正文,由于是Markdown转换后的HTML格式内容,所以可能会出现搜索词刚好在 图片链接、URL链接、TOC目录的锚中,需要额外处理:

@app.template_filter()
def es_highlight_body(source, keyword):
    """搜索结果中高亮关键词。 bug: 不能搜索html标记中包含的字母或字母组合,如搜索a,会把html标签也替换,整个页面就乱了"""
    # 当搜索关键字在 Markdown 转换后的HTML的标签中时,
    # 不能设置为 '<span style="color: red; background: yellow;">\g<1></span>',因为HTML标签的属性默认是双引号,会乱
    # 而要设置为 "<span style='color: red; background: yellow;'>\g<1></span>"
    bleach_html_cleans = ['a', 'abbr', 'acronym', 'b', 'br', 'blockquote', 'code', 'caption', 'del', 'dl', 'dd', 'div', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'strong', 'span', 'sub', 'sup', 'section', 'small', 'table', 'thead', 'tr', 'th', 'td', 'tbody', 'u', 'ul', 'class', 'id', 'href', 'title', 'rel', 'target', 'title', 'src', 'title', 'alt', 'style', 'width', 'height', 'style', 'color', 'background-color', 'font-weight', 'http', 'https', 'mailto', 'smb']
    bleach_flag = False
    for key in keyword.split('+'):
        for clean in bleach_html_cleans:
            if key in clean:
                bleach_flag = True
                break
        if not bleach_flag:  # 不在违法的HTML标记中时,才替换
            source = re.sub(
                r'(%s)' % re.escape(key),
                "<span style='color: red; background: yellow;'>\g<1></span>",
                source,
                flags=re.IGNORECASE)

    # 现在HTML标签属性里面的值是不对的,主要包括,id="...", alt="...", href="...",比如:搜索关键字是 mongodb
    # <h1 id="3-<span style='color: red;'>mongodb</span>windows-service">3. 配置<span style='color: red;'>MongoDB</span>为Windows Service</h1>
    # <img alt="<span style='color: red;'>mongodb</span> pic" src="/admin/medias/uploaded/win10an-zhuang-<span style='color: red;'>mongodb</span>-min-5f19fad3.png">
    # <a href="https://docs.<span style='color: red;'>mongodb</span>.com/manual/tutorial/install-<span style='color: red;'>mongodb</span>-on-windows/" rel="nofollow"><strong>参考<span style='color: red;'>MongoDB</span>官方文档</strong></a>
    pattern = re.compile(r'(<.*?[id|alt|href]=[^>]*?)<span style=\'color: red; background: yellow;\'>(.*?)</span>(.*?>)', re.IGNORECASE)
    while re.search(pattern, source) is not None:  # 可能在HTML标签内有多个匹配,所以要重复多次
        source = re.sub(pattern, "\g<1>\g<2>\g<3>", source)

    return source

使用时:

{% if request.args.get('q') %}
    {# 说明使用 Elasticsearch, 要高亮数据库中的 es_highlights 字段值 #}
    {% if request.args.get('highlights') %}

        {% if post.content_html %}
            {{ post.content_html|es_highlight_body(post.es_highlights)|safe }}
        {% elif post.content_ckeditor %}
            {{ post.content_ckeditor|es_highlight_body(post.es_highlights)|safe }}
        {% endif %}

    {% else %}

        {% if post.content_html %}
            {{ post.content_html|highlight(request.args.get('q'))|safe }}
        {% elif post.content_ckeditor %}
            {{ post.content_ckeditor|highlight(request.args.get('q'))|safe }}
        {% endif %}

    {% endif %}

{# 没有查询关键字时,不需要高亮 #}
{% else %}
    {% if post.content_html %}
        {{ post.content_html|safe }}
    {% elif post.content_ckeditor %}
        {{ post.content_ckeditor|safe }}
    {% endif %}
{% endif %}

4. 调整 Elasticsearch JVM参数

由于我的云主机只有2G内存,而Elasticsearch默认会使用1G,导致服务器卡死。目前索引数据量非常小,可惜限制Elasticsearch使用的内存量:

[root@CentOS ~]# vim /etc/elasticsearch/jvm.options
修改:
# Xms represents the initial size of total heap space
# Xmx represents the maximum size of total heap space

-Xms256m
-Xmx256m

重启Elasticsearch即可,可以看到状态:

[root@CentOS ~]# systemctl status elasticsearch
● elasticsearch.service - Elasticsearch
   Loaded: loaded (/usr/lib/systemd/system/elasticsearch.service; enabled; vendor preset: disabled)
   Active: active (running) since Fri 2018-10-19 11:22:19 CST; 4min 50s ago
     Docs: http://www.elastic.co
 Main PID: 27280 (java)
   CGroup: /system.slice/elasticsearch.service
           ├─27280 /bin/java -Xms256m -Xmx256m -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+AlwaysPreTouch ...
           └─27342 /usr/share/elasticsearch/modules/x-pack-ml/platform/linux-x86_64/bin/controller

Oct 19 11:22:19 madmalls.com systemd[1]: Started Elasticsearch.
Oct 19 11:22:19 madmalls.com systemd[1]: Starting Elasticsearch...
Oct 19 11:22:19 madmalls.com elasticsearch[27280]: OpenJDK 64-Bit Server VM warning: If the number of processors is expected to increase from one, then you...Threads=N
Hint: Some lines were ellipsized, use -l to show in full.
未经允许不得转载: LIFE & SHARE - 王颜公子 » Elasticsearch 实现博客全文检索与搜索词高亮

分享

作者

作者头像

Madman

如果博文内容有误或其它任何问题,欢迎留言评论,我会尽快回复; 或者通过QQ、微信等联系我

0 条评论

暂时还没有评论.

发表评论前请先登录