1. 程式人生 > >Django Haystack 全文檢索與關鍵詞高亮

Django Haystack 全文檢索與關鍵詞高亮

作者:HelloGitHub-追夢人物

文中所涉及的示例程式碼,已同步更新到 HelloGitHub-Team 倉庫

部落格提供 RSS 訂閱應該是標配,這樣讀者就可以通過一些聚合閱讀工具訂閱你的部落格,時時檢視是否有文章更新,而不必每次都跳轉到部落格上來檢視。現在我們就來為部落格新增 RSS 訂閱功能。

在此之前我們使用了 Django 內建的一些方法實現了一個簡單的搜尋功能。但這個搜尋功能實在過於簡單,沒有多大的實用性。對於一個搜尋引擎來說,至少應該能夠根據使用者的搜尋關鍵詞對搜尋結果進行排序以及高亮關鍵字。現在我們就來使用 django-haystack 實現這些特性。

Django Haystack 簡介

django-haystack 是一個專門提供搜尋功能的 django 第三方應用,它支援 Solr、Elasticsearch、Whoosh、Xapian 等多種搜尋引擎,上一版本的教程中我們使用 Whoosh 加 jieba 中文分詞的方案,原因是為了簡單,無需安裝外部服務。但現在有了 docker,安裝一個外部服務就是輕而易舉的事情,所以這次我們採用更為強大的 elasticsearch 作為我們部落格的搜尋引擎,同時使用 elasticsearch 的中文分詞外掛 ik,來提升中文搜尋的效果。

安裝必要依賴

安裝 django-haystack

django-haystack 安裝非常簡單,只需要執行 pipenv install django-haystack

即可。需要注意的是,目前 elasticsearch 有 2 系列和 5 系列兩大版本,本來新專案的原則是儘可能採用新版本,但目前 django-haystack 在 pypi 上釋出的穩定版只支援 elasticsearch2,master 分支下支援 elasticsearch5,因此處於穩定性考慮,我們暫時使用 elasticsearch2,後續如果 django-haystack 釋出了支援 elasticsearch5 的pypi版本,我們會升級到 elasticsearch5,有了 docker,升級就是輕而易舉的事情。

由於使用 elasticsearch 服務,haystack 連線 elasticsearch 需要 python 版本的 SDK 支援,因此還需要安裝 elasticsearch python SDK,這裡我們不要直接使用 pipenv 安裝,而是手動編輯 Pipfile 檔案,指定 SDK 的版本,否則 pipenv 預設會安裝最新版。開啟 Pipfile 檔案,將依賴手動新增到 packages 板塊下:

[packages]
django = "~=2.2"
elasticsearch = ">=2,<3"

安裝 elasticsearch 2

接下來就是構建一個新的容器來執行 elasticsearch 服務,因此首先需要來編排容器映象,回顧一下容器映象的目錄結構:

compose\
    local\
    production\
        django\
        nginx\

由於 elasticsearch 在線上環境和本地測試都要使用,我們把映象編排在 production 目錄下,新建一個 elasticsearch 目錄,用來存放和 elasticsearch 相關的內容。Dockfile 內容如下:

FROM elasticsearch:2.4.6-alpine

COPY ./compose/production/elasticsearch/elasticsearch-analysis-ik-1.10.6.zip /usr/share/elasticsearch/plugins/
RUN cd /usr/share/elasticsearch/plugins/ && mkdir ik && unzip elasticsearch-analysis-ik-1.10.6.zip -d ik/
RUN rm /usr/share/elasticsearch/plugins/elasticsearch-analysis-ik-1.10.6.zip

USER root
COPY ./compose/production/elasticsearch/elasticsearch.yml /usr/share/elasticsearch/config/
RUN chown elasticsearch:elasticsearch /usr/share/elasticsearch/config/elasticsearch.yml

USER elasticsearch

這個映象從 elasticsearch 的官方基礎映象 2.4.6 版本進行構建,接著我們把 ik 分詞外掛複製到 elasticsearch 安裝外掛的目錄下,然後解壓啟用。

接著我們又把 elasticsearch.yml 配置檔案複製到容器內,然後切換使用者為 elasticsearch,因為我們將以 elasticsearch 使用者和組執行 elasticsearch 服務。

elasticsearch.yml 配置檔案內容很簡單:

bootstrap.memory_lock: true
network.host: 0.0.0.0

其中 bootstrap.memory_lock 這個引數是為了提高 elasticsearch 的效率(涉及到 JVM 相關的優化,不做過多介紹)。network.host 指定服務啟動的地址。

接著修改 docker compose 檔案,我們先在本地啟動,因此修改 local.yml 檔案,加入 elasticsearch 服務:

version: '3'

volumes:
  database_local:
  esdata_local:

services:
  hellodjango_blog_tutorial_local:
    # 其它配置不變...
    depends_on:
      - elasticsearch_local

  elasticsearch_local:
    build:
      context: .
      dockerfile: ./compose/production/elasticsearch/Dockerfile
    image: elasticsearch_local
    container_name: elasticsearch_local
    volumes:
      - esdata_local:/usr/share/elasticsearch/data
    ports:
      - "9200:9200"
    environment:
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
      nproc: 65536
      nofile:
        soft: 65536
        hard: 65536

主要是加入了 elasticsearch 服務,其中 environmentulimits 的引數與 elasticksearch 服務調優有關,對於簡單的部落格搜尋來說,調優的意義不是很大,因此這裡不做過多介紹,感興趣的可以參考 elasticksearch 的官方文件。

配置 Haystack

安裝好 django haystack 後需要在專案的 settings.py 做一些簡單的配置。

首先是把 django haystack 加入到 INSTALLED_APPS 設定裡:

blogproject/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    # 其它 app...
    'haystack',
    'blog',
    'comments',
]

然後加入如下配置項:

blogproject/common.py

# 搜尋設定
HAYSTACK_CONNECTIONS = {
    'default': {
        'ENGINE': 'haystack.elasticsearch2_backend.Elasticsearch2SearchEngine',
        'URL': '',
        'INDEX_NAME': 'hellodjango_blog_tutorial',
    },
}
HAYSTACK_SEARCH_RESULTS_PER_PAGE = 10
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'

HAYSTACK_CONNECTIONSENGINE 指定了 django haystack 使用的搜尋引擎,這裡我們使用了 haystack 預設的 Elasticsearch2 搜尋引擎。PATH 指定了索引檔案需要存放的位置,我們設定為專案根目錄 BASE_DIR 下的 whoosh_index 資料夾(在建立索引是會自動建立)。

HAYSTACK_SEARCH_RESULTS_PER_PAGE 指定如何對搜尋結果分頁,這裡設定為每 10 項結果為一頁。

HAYSTACK_SIGNAL_PROCESSOR 指定什麼時候更新索引,這裡我們使用 haystack.signals.RealtimeSignalProcessor,作用是每當有文章更新時就更新索引。由於部落格文章更新不會太頻繁,因此實時更新沒有問題。

由於開發環境和線上環境,elasticsearch 服務的 url 地址是不同的,所以我們在 common 的配置中沒有指定 url,在 local.py 設定檔案指定之:

HAYSTACK_CONNECTIONS['default']['URL'] = 'http://elasticsearch_local:9200/'

處理資料

接下來就要告訴 django haystack 使用哪些資料建立索引以及如何存放索引。如果要對 blog 應用下的資料進行全文檢索,做法是在 blog 應用下建立一個 search_indexes.py 檔案,寫上如下程式碼:

blog/search_indexes.py

from haystack import indexes
from .models import Post


class PostIndex(indexes.SearchIndex, indexes.Indexable):
    text = indexes.CharField(document=True, use_template=True)

    def get_model(self):
        return Post

    def index_queryset(self, using=None):
        return self.get_model().objects.all()

這是 django haystack 的規定。要相對某個 app 下的資料進行全文檢索,就要在該 app 下建立一個 search_indexes.py 檔案,然後建立一個 XXIndex 類(XX 為含有被檢索資料的模型,如這裡的 Post),並且繼承 SearchIndexIndexable

為什麼要建立索引?索引就像是一本書的目錄,可以為讀者提供更快速的導航與查詢。在這裡也是同樣的道理,當資料量非常大的時候,若要從這些資料裡找出所有的滿足搜尋條件的幾乎是不太可能的,將會給伺服器帶來極大的負擔。所以我們需要為指定的資料新增一個索引(目錄),在這裡是為 Post 建立一個索引,索引的實現細節是我們不需要關心的,我們只關心為哪些欄位建立索引,如何指定。

每個索引裡面必須有且只能有一個欄位為 document=True,這代表 django haystack 和搜尋引擎將使用此欄位的內容作為索引進行檢索(primary field)。注意,如果使用一個欄位設定了document=True,則一般約定此欄位名為text,這是在 SearchIndex 類裡面一貫的命名,以防止後臺混亂,當然名字你也可以隨便改,不過不建議改。

並且,haystack 提供了 use_template=True 在 text 欄位中,這樣就允許我們使用資料模板去建立搜尋引擎索引的檔案,說得通俗點就是索引裡面需要存放一些什麼東西,例如 Post 的 title 欄位,這樣我們可以通過 title 內容來檢索 Post 資料了。舉個例子,假如你搜索 Python ,那麼就可以檢索出 title 中含有 Python 的Post了,怎麼樣是不是很簡單?資料模板的路徑為 templates/search/indexes/youapp/<model_name>_text.txt(例如 templates/search/indexes/blog/post_text.txt),其內容為:

templates/search/indexes/blog/post_text.txt

{{ object.title }}
{{ object.body }}

這個資料模板的作用是對 Post.title、Post.body 這兩個欄位建立索引,當檢索的時候會對這兩個欄位做全文檢索匹配,然後將匹配的結果排序後作為搜尋結果返回。

配置 URL

接下來就是配置 URL,搜尋的檢視函式和 URL 模式 django haystack 都已經幫我們寫好了,只需要專案的 urls.py 中包含它:

blogproject/urls.py

urlpatterns = [
    # 其它...
    path('search/', include('haystack.urls')),
]

另外在此之前我們也為自己寫的搜尋檢視配置了 URL,把那個 URL 刪掉,以免衝突:

blog/urls.py

# path('search/', views.search, name='search'),

修改搜尋表單

修改一下搜尋表單,讓它提交資料到 django haystack 搜尋檢視對應的 URL:

<form role="search" method="get" id="searchform" action="{% url 'haystack_search' %}">
  <input type="search" name="q" placeholder="搜尋" required>
  <button type="submit"><span class="ion-ios-search-strong"></span></button>
</form>

主要是把表單的 action 屬性改為 {% url 'haystack_search' %}

建立搜尋結果頁面

haystack_search 檢視函式會將搜尋結果傳遞給模板 search/search.html,因此建立這個模板檔案,對搜尋結果進行渲染:

templates/search/search.html

{% extends 'base.html' %}
{% load highlight %}

{% block main %}
  {% if query %}
    {% for result in page.object_list %}
      <article class="post post-{{ result.object.pk }}">
        <header class="entry-header">
          <h1 class="entry-title">
            <a href="{{ result.object.get_absolute_url }}">{% highlight result.object.title with query %}</a>
          </h1>
          <div class="entry-meta">
                    <span class="post-category">
                        <a href="{% url 'blog:category' result.object.category.pk %}">
                            {{ result.object.category.name }}</a></span>
            <span class="post-date"><a href="#">
                            <time class="entry-date" datetime="{{ result.object.created_time }}">
                                {{ result.object.created_time }}</time></a></span>
            <span class="post-author"><a href="#">{{ result.object.author }}</a></span>
            <span class="comments-link">
                        <a href="{{ result.object.get_absolute_url }}#comment-area">
                            {{ result.object.comment_set.count }} 評論</a></span>
            <span class="views-count"><a
                    href="{{ result.object.get_absolute_url }}">{{ result.object.views }} 閱讀</a></span>
          </div>
        </header>
        <div class="entry-content clearfix">
          <p>{% highlight result.object.body with query %}</p>
          <div class="read-more cl-effect-14">
            <a href="{{ result.object.get_absolute_url }}" class="more-link">繼續閱讀 <span
                    class="meta-nav">→</span></a>
          </div>
        </div>
      </article>
    {% empty %}
      <div class="no-post">沒有搜尋到你想要的結果!</div>
    {% endfor %}

    {% if page.has_previous or page.has_next %}
      <div class="text-center" style="margin-top: 30px">
        {% if page.has_previous %}
          <a href="?q={{ query }}&amp;page={{ page.previous_page_number }}">{% endif %}&laquo; Previous
        {% if page.has_previous %}</a>{% endif %}
        <span style="margin: 0 10px">|</span>
        {% if page.has_next %}<a href="?q={{ query }}&amp;page={{ page.next_page_number }}">{% endif %}Next
        &raquo;{% if page.has_next %}</a>{% endif %}
      </div>
    {% endif %}
  {% else %}
    請輸入搜尋關鍵詞,例如 django
  {% endif %}
{% endblock main %}

這個模板基本和 blog/index.html 一樣,只是由於 haystack 對搜尋結果做了分頁,傳給模板的變數是一個 page 物件,所以我們從 page 中取出這一頁對應的搜尋結果,然後對其迴圈顯示,即 {% for result in page.object_list %}。另外要取得 Post(文章)以顯示文章的資料如標題、正文,需要從 result 的 object 屬性中獲取。query 變數的值即為使用者搜尋的關鍵詞。

高亮關鍵詞

注意到百度的搜尋結果頁面,含有使用者搜尋的關鍵詞的地方都是被標紅的,在 django haystack 中實現這個效果也非常簡單,只需要使用 {% highlight %} 模板標籤即可,其用法如下:

# 使用預設值  
{% highlight result.summary with query %}  
  
# 這裡我們為 {{ result.summary }} 裡所有的 {{ query }} 指定了一個<div></div>標籤,並且將class設定為highlight_me_please,這樣就可以自己通過CSS為{{ query }}新增高亮效果了,怎麼樣,是不是很科學呢  
{% highlight result.summary with query html_tag "div" css_class "highlight_me_please" %}  
  
# 可以 max_length 限制最終{{ result.summary }} 被高亮處理後的長度
{% highlight result.summary with query max_length 40 %}  

在部落格文章搜尋頁中我們對 title 和 body 做了高亮處理:{% highlight result.object.title with query %},{% highlight result.object.body with query %}。高亮處理的原理其實就是給文字中的關鍵字包上一個 span 標籤並且為其新增 highlighted 樣式(當然你也可以修改這個預設行為,具體參見上邊給出的用法)。因此我們還要給 highlighted 類指定樣式,在 base.html 中新增即可:

base.html

<head>
    <title>Black &amp; White</title>
    ...
    <style>
        /* 搜尋關鍵詞高亮 */
        span.highlighted {
          color: red;
        }
    </style>
    ...
</head>

建立索引檔案

最後一步就是建立索引檔案了,執行命令 :

$ docker exec -it hellodjango_blog_tutorial_local python manage.py rebuild_index

就可以建立索引檔案了。一切就緒後,就可以嘗試搜尋了。但是體驗下來會發現搜尋的結果並不是很友好,很多關鍵詞文章中命名存在但搜尋結果中卻沒有顯示,原因是 haystack 專門為英文搜尋設計,如果使用其預設的搜尋引擎分詞器,中文搜尋的結果就不是很理想,接下來我們來將它預設的分詞器設定為中文分詞器。

修改搜尋引擎為中文分詞

還記得文章開頭編排 elasticsearch 的 Docker 映象時,我們將一個 elasticsearch 的中文分詞外掛複製到了 elasticsearch 的外掛目錄,接下來要做的,就是讓 haystack 在建立索引時,使用指定的外掛來對進行分詞並建立索引,具體做法是,首先在 blog 應用下建立一個 elasticsearch2_ik_backend.py,程式碼如下:

from haystack.backends.elasticsearch2_backend import Elasticsearch2SearchBackend, Elasticsearch2SearchEngine

DEFAULT_FIELD_MAPPING = {'type': 'string', "analyzer": "ik_max_word", "search_analyzer": "ik_smart"}


class Elasticsearch2IkSearchBackend(Elasticsearch2SearchBackend):

    def __init__(self, *args, **kwargs):
        self.DEFAULT_SETTINGS['settings']['analysis']['analyzer']['ik_analyzer'] = {
            "type": "custom",
            "tokenizer": "ik_max_word",
        }
        super(Elasticsearch2IkSearchBackend, self).__init__(*args, **kwargs)


class Elasticsearch2IkSearchEngine(Elasticsearch2SearchEngine):
    backend = Elasticsearch2IkSearchBackend

這些程式碼的作用是,繼承 haystack 預設的 Elasticsearch2SearchBackend 和 Elasticsearch2SearchEngine,覆蓋掉它的一些預設行為,這裡主要就是讓 haystack 在建立索引時,使用指定的 ik 分詞器。

由於自定義了搜尋引擎,因此在配置檔案中將原來指定的 Elasticsearch2SearchEngine 替換為自定義的 Engine:

# 搜尋設定
HAYSTACK_CONNECTIONS = {
    'default': {
        'ENGINE': 'blog.elasticsearch2_ik_backend.Elasticsearch2IkSearchEngine',
        'URL': '',
        'INDEX_NAME': 'hellodjango_blog_tutorial',
    },
}
HAYSTACK_SEARCH_RESULTS_PER_PAGE = 10
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'

由於修改了索引建立方式,因此需要重建一下索引:python manage.py rebuild_index。然後就可以檢視搜尋結果了,中文搜尋體驗是不是好了很多?

防止標題被截斷

haystack 在展示搜尋結果時,預設行為是將第一個出現的關鍵詞前的內容截斷,被截掉的部分用省略號代替。對於正文來說,因為內容較多,截斷是合理的,但是對於標題這種較短的內容來說,截斷就沒有必要了。同樣的,我們通過繼承的方式,替換掉 haystack 的預設行為。我們在 blog/utils.py 中繼承 HaystackHighlighter 這個用於高亮搜尋關鍵詞的輔助類。

from django.utils.html import strip_tags
from haystack.utils import Highlighter as HaystackHighlighter


class Highlighter(HaystackHighlighter):
    """
    自定義關鍵詞高亮器,不截斷過短的文字(例如文章標題)
    """

    def highlight(self, text_block):
        self.text_block = strip_tags(text_block)
        highlight_locations = self.find_highlightable_words()
        start_offset, end_offset = self.find_window(highlight_locations)
        if len(text_block) < self.max_length:
            start_offset = 0
        return self.render_html(highlight_locations, start_offset, end_offset)

關鍵程式碼是:if len(text_block) < self.max_length:start_offset 是 haystack 根據關鍵詞算出來第一個關鍵詞在文字中出現的位置。max_length 指定了展示結果的最大長度。我們在程式碼中做一個判斷,如果文字內容 text_block 沒有超過允許的最大長度,就將 start_offset 設為 0,這樣就從文字的第一個字元開始展示,標題這種短文字就不會被截斷了。

然後設定,讓 haystack 在高亮文字時,使用我們自定義的輔助類:

HAYSTACK_CUSTOM_HIGHLIGHTER = 'blog.utils.Highlighter'

在來看一下搜尋效果吧!

線上釋出

以上步驟都是在本地執行除錯的,elasticsearch 服務也是在本地的 Docker 容器中執行,接下來在 production.yml 中加入 elasticsearch 服務,就可以釋出線上了,配置內容和 local.yml 是一樣的,只是簡單修改一下服務名和容器名等命名:

  elasticsearch:
    build:
      context: .
      dockerfile: ./compose/production/elasticsearch/Dockerfile
    image: hellodjango_blog_tutorial_elasticsearch
    container_name: hellodjango_blog_tutorial_elasticsearch
    volumes:
      - esdata:/usr/share/elasticsearch/data
    ports:
      - "9200:9200"
    environment:
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
      nproc: 65536
      nofile:
        soft: 65536
        hard: 65536

別忘了修改 settings/production.py,修改線上環境 elasticsearch 服務的連線地址:

HAYSTACK_CONNECTIONS['default']['URL'] = 'http://hellodjango_blog_tutorial_elasticsearch:9200/'

這樣就可以直接釋出線上了!

HelloDjango 往期回顧:

第 27 篇:開啟 Django 部落格實現簡單的全文搜尋

第 26 篇:開啟 Django 部落格的 RSS 功能

第 25 篇:統計各個分類和標籤下的文章數


關注公眾號加入交流