13

文章搜索

Elasticsearch简介

Elasticsearch 的底层是开源库 Apache Lucene

Lucene可以被认为是迄今为止最先进、性能最好的、功能最全的搜索引擎库。但是Lucene非常复杂,要使用Lucene则必须了解检索相关知识和Lucene的工作原理才可以。

Elasticsearch 是 Lucene 的封装,提供了开箱即用,丰富并简单连贯的REST API 的操作接口,让全文搜索变得简单并隐藏Lucene的复杂性。所以,开源的 Elasticsearch 是目前业内实现全文搜索引擎的首选。它可以快速地储存、搜索和分析海量数据。维基百科、Stack Overflow[爆栈]、Github 都采用它。

官网:https://www.elastic.co/cn/elasticsearch/

搜索引擎在对数据构建索引时,需要进行分词处理。分词是指将一句话拆解成多个单字或词,这些字或词便是这句话的关键词。如

我是中国人。

'我'、'是'、'中'、'国'、'人'、'中国'等都可以是这句话的关键词。

Elasticsearch 不支持对中文进行分词建立索引,需要配合扩展ik分词器[elasticsearch-ik]来实现中文分词处理。

扩展:https://www.cnblogs.com/leeSmall/p/9189078.html

docker安装Elasticsearch和ik分词器

1.拉取镜像

Elasticsearch 是用Java实现的,所以需要Java虚拟机的支持,在运行之前保证机器上安装了JDK,并且JDK版本不能低于1.7_55。


sudo docker pull bachue/elasticsearch-ik:2.2-1.8

注意: 容器较大,所以可以选择配置国内加速器

国内的镜像加速器选项较多,如:阿里云,DaoCloud 等。这里我们使用阿里云的docker加速器

# 配置国内镜像
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
  "registry-mirrors": ["https://2xdmrl8d.mirror.aliyuncs.com"]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker 

# 再重新拉取镜像
sudo docker pull bachue/elasticsearch-ik:2.2-1.8

也可以使用笔记里面的素材镜像文件加载到docker中

sudo docker load -i elasticsearch-ik.tar.gz
sudo docker image ls

2.创建容器

拉取了镜像以后,直接创建容器

vm.max_map_count参数,是允许一个进程在内容中拥有的最大数量(VMA:虚拟内存地址, 一个连续的虚拟地址空间),当进程占用内存超过max_map_count时, 直接GG。所以错误提示:elasticsearch用户拥有的内存权限太小,至少需要262144。

max_map_count配置文件写在系统中的/proc/sys/vm文件中,但是我们不需要进入docker容器中配置,因为docker使用宿主机的/proc/sys作为只读路径之一。因此我们在Ubuntu系统下设置一下命令即可:

sudo sysctl -w vm.max_map_count=262144 # 本次服务器,的mvm = 262144,如果服务器关闭了,需要重新设置
sudo docker run -itd --restart=always --network=host -e ES_JAVA_OPTS="-Xms256m -Xmx256m" --name=esik bachue/elasticsearch-ik:2.2-1.8

3.测试

完成上面操作以后,我们接下来,直接访问浏览器,输入IP:http://127.0.0.1:9200/,出现以下内容则表示elasticsearch安装成功:

{
  "name" : "Metalhead",
  "cluster_name" : "elasticsearch",
  "version" : {
    "number" : "2.2.0",
    "build_hash" : "8ff36d139e16f8720f2947ef62c8167a888992fe",
    "build_timestamp" : "2016-01-27T13:32:39Z",
    "build_snapshot" : false,
    "lucene_version" : "5.4.1"
  },
  "tagline" : "You Know, for Search"
}

接下来,我们快速的学习下使用分词器。

ik分词器的基本使用

上面的分词器测试中,我们使用了postman发起了如下请求:

GET请求    http://127.0.0.1:9200/_analyze?pretty

{
  "text": "老男孩python"
}

这个请求得到的分词结果其实很傻瓜。因为这样会自动把每一个文字都进行了分割。

所以我们使用postman发起一个新的请求:

GET    /_analyze?pretty

{
  "analyzer": "ik_smart",
  "text": "老男孩python"
}

效果:

{
    "tokens": [
        {
            "token": "老",
            "start_offset": 0,
            "end_offset": 1,
            "type": "CN_CHAR",
            "position": 0
        },
        {
            "token": "男孩",
            "start_offset": 1,
            "end_offset": 3,
            "type": "CN_WORD",
            "position": 1
        },
        {
            "token": "python",
            "start_offset": 3,
            "end_offset": 9,
            "type": "ENGLISH",
            "position": 2
        }
    ]
}

analyzer表示分词器 ,我们可以理解为分词的算法或者分析器。默认情况下,Elasticsearch内置了很多分词器。

以下两种举例,又兴趣可以访问文章来深入了解。

1. standard 标准分词器,单字切分。上面我们测试分词器时候没有声明analyzer参数,则默认调用标准分词器。
2. simple 简单分词器,按非字母字符来分割文本信息

综合上面的分词器,其实对于中文都不友好,所以我们前面安装的ik分词器就有了用武之地。

ik分词器在Elasticsearch内置分词器的基础上,新增了2种分词器。

ik_max_word:会将文本做最细粒度的拆分;尽可能多的拆分出词语

ik_smart:会做最粗粒度的拆分;已被分出的词语将不会再次被其它词语占有

我们使用下ik分词器,在postman中发起请求:

GET    /_analyze?pretty

{
  "analyzer": "ik_max_word",
  "text": "你好,老男孩python"
}

效果:

{
    "tokens": [
        {
            "token": "你好",
            "start_offset": 0,
            "end_offset": 2,
            "type": "CN_WORD",
            "position": 0
        },
        {
            "token": "老",
            "start_offset": 3,
            "end_offset": 4,
            "type": "CN_CHAR",
            "position": 1
        },
        {
            "token": "男孩",
            "start_offset": 4,
            "end_offset": 6,
            "type": "CN_WORD",
            "position": 2
        },
        {
            "token": "python",
            "start_offset": 6,
            "end_offset": 12,
            "type": "ENGLISH",
            "position": 3
        }
    ]
}

在Django中使用:

django-haystack 模块:

专门给 django 提供搜索功能的。 django-haystack 提供了一个统一的API搜索接口,底层可以根据自己需求更换搜索引擎( Solr, Elasticsearch, Whoosh, Xapian 等等),类似于 django 中的 ORM 插件,提供了一个操作数据库接口,但是底层具体使用哪个数据库是可以在配置文件中进行设置的。

在django中可以通过使用haystack来调用Elasticsearch搜索引擎。而在drf框架中,也有一个对应的drf-haystack模块,是django-haystack进行封装处理的。

1)安装模块

pip install drf-haystack          # django框架安装命令: pip install django-haystack
pip install elasticsearch==2.2.0         # 版本有问题6.0.0,5.0.0,7.5.1,可以装低版本2.2.0

2)注册应用

settings/dev.py

INSTALLED_APPS = [
    ...
    'haystack',
    ...
]

3)相关配置

在配置文件中配置haystack使用的搜索引擎后端,settings/dev.py,代码:

# Haystack
HAYSTACK_CONNECTIONS = {
    'default': {
        'ENGINE': 'haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine',
        # elasticsearch运行的服务器ip地址,端口号默认为9200
        'URL': 'http://192.168.252.168:9200/',
        # elasticsearch建立的索引库的名称,一般使用项目名作为索引库
        'INDEX_NAME': 'renran',
    },
}

# 设置在Django运行时,如果有数据产生变化(添加、修改、删除),
# haystack会自动让Elasticsearch实时生成新数据的索引
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'

4)创建索引类

通过创建索引类,来指明让搜索引擎对哪些字段建立索引,也就是可以通过哪些字段的关键字来检索数据。

在article子应用下创建索引类文件search_indexes.py,代码:

from haystack import indexes
from .models import Article

class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
    """
    文章索引数据模型类
    """
    text = indexes.CharField(document=True, use_template=True)
    id = indexes.IntegerField(model_attr='id')
    title = indexes.CharField(model_attr='title')
    content = indexes.CharField(model_attr='content')

    def get_model(self):
        """返回建立索引的模型类"""
        return Article

    def index_queryset(self, using=None):
        """返回要建立索引的数据查询集"""
        return self.get_model().objects.filter(is_public=True)

其中text字段我们声明为document=True,表名该字段是主要进行关键字查询的字段, 该字段的索引值可以由多个数据库模型类字段组成,具体由哪些模型类字段组成,我们用use_template=True表示后续通过模板来指明。其他字段都是通过model_attr选项指明引用数据库模型类的特定字段。

在REST framework中,索引类的字段会作为查询结果返回数据的来源。

5)在templates目录中创建text字段使用的模板文件

配置模板目录,settings/dev.py,代码:

# 模板引擎
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            os.path.join(BASE_DIR, "templates"),
        ],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

接着,在主目录renranapi中创建文件: templates/search/indexes/article/article_text.txt文件中定义,关键字索引查询:

{{ object.title }}
{{ object.content }}
{{ object.id }}

此模板指明当将关键词通过text参数名传递时,可以通过article的title、content、id来进行关键字索引查询。

6)手动重建索引

python manage.py rebuild_index

7)创建序列化器

在article/serializers.py中创建haystack序列化器

from drf_haystack.serializers import HaystackSerializer
from .search_indexes import ArticleIndex

class ArticleIndexSerializer(HaystackSerializer):
    """
    文章索引结果数据序列化器
    """
    class Meta:
        index_classes = [ArticleIndex]
        fields = ('text', 'id', 'title', 'content')

注意fields属性的字段名与ArticleIndex类的字段对应。

8)创建视图

在article/views.py中创建视图

from drf_haystack.viewsets import HaystackViewSet
from .serializers import ArticleIndexSerializer
from .paginations import ArticleSearchPageNumberPagination

class ArticleSearchViewSet(HaystackViewSet):
    """
    文章搜索
    """
    index_models = [Article]

    serializer_class = ArticleIndexSerializer
    pagination_class = ArticleSearchPageNumberPagination

给视图添加分页器

article/paginations.py,代码:

from rest_framework.pagination import PageNumberPagination
class ArticleSearchPageNumberPagination(PageNumberPagination):
    """文章搜索分页器"""
    page_size = 2
    max_page_size = 20
    page_size_query_param = "size"
    page_query_param = "page"

9)定义路由

通过REST framework的router来定义路由,article/urls.py,代码:

from django.urls import path,re_path
from . import views
# 。。。。

from rest_framework.routers import SimpleRouter
router = SimpleRouter()
router.register('search', views.ArticleSearchViewSet, basename='article_search')
urlpatterns += router.urls

10)测试

我们可以使用postman进行测试:

发送get请求,到http://api.renran.com:8000/article/search/?text=搜索数据

客户端

客户端提供搜索功能,在头部子组件Header.vie中完善输入搜索内容以后的点击跳转到搜索页面Search.vue效果,

<template>
  <div class="header">
    <nav class="navbar">
      <div class="width-limit">
        <!-- 左上方 Logo -->
        <a class="logo" href="/"><img src="/static/image/nav-logo.png" /></a>

        <!-- 右上角 -->
        <!-- 未登录显示登录/注册/写文章 -->
        <a class="btn write-btn" target="_blank" href="/writer"><img class="icon-write" src="/static/image/write.svg">写文章</a>
        <router-link class="btn sign-up" id="sign_up" to="/register">注册</router-link>
        <router-link class="btn log-in" id="sign_in" to="/login">登录</router-link>
        <div class="container">
          <div class="collapse navbar-collapse" id="menu">
            <ul class="nav navbar-nav">
              <li class="tab active">
                <a href="/">
                  <i class="iconfont ic-navigation-discover menu-icon"></i>
                  <span class="menu-text">首页</span>
                </a>
              </li>
              <li class="tab" v-for="(nav_top_value,nav_top_index) in nav_top_list" :key="nav_top_index">
                <router-link :to="nav_top_value.link" v-if="nav_top_value.is_http">
                  <i class="menu-icon" :class="nav_top_value.icon"></i> <!--图标-->
                  <span class="menu-text">{{ nav_top_value.name }}</span> <!--一级菜单名称-->
                </router-link>
                <a :href="nav_top_value.link" v-else>
                  <i class="menu-icon" :class="nav_top_value.icon"></i> <!--图标-->
                  <span class="menu-text">{{ nav_top_value.name }}</span> <!--一级菜单名称-->
                </a>
                <ul class="dropdown-menu" v-if="nav_top_value.son_list.length>0">
                  <li v-for="(nav_son_value,nav_son_index) in nav_top_value.son_list" :key="nav_son_index">
                    <router-link :to="nav_son_value.link" v-if="nav_son_value.http">
                      <i :class="nav_son_value.icon"></i>
                      <span>{{nav_son_value.name}}</span>
                    </router-link>
                    <a :href="nav_son_value.link" v-else>
                      <i :class="nav_son_value.icon"></i> <!--图标-->
                      <span>{{nav_son_value.name}}</span> <!--二级菜单名称-->
                    </a>
                  </li>
                </ul>
              </li>
              <li class="search">
                <form target="_blank" action="/search"  accept-charset="UTF-8" method="get">
                  <input type="text" v-model="search_text" id="q" value="" autocomplete="off" placeholder="搜索" class="search-input">
                  <input type="submit" @click="to_search" class="search-btn" href="javascript:void(0)"></input>
                </form>
              </li>
            </ul>
          </div>
        </div>

        <!-- 如果用户登录,显示下拉菜单 -->
      </div>
    </nav>
  </div>
</template>

<script>
    export default {
        name: "Header",
        data(){
          return{
            nav_top_list:[], //导航栏列表
            search_text:'', //搜索内容
          }
        },
       // 页面加载,自动加载数据
        created() {
          this.get_navtop_list();
        },
        methods:{
          // 点击搜索跳转页面
          to_search(){
            // 跳转到搜索页面
            if(this.search_text.length<1){
                return;
            }
            this.$router.push(`/search?text=${this.search_text}`);
        },

          // 获取头部导航栏信息
          get_navtop_list() {
            this.$axios.get(`${this.$settings.host}/home/nav/top/`)
              .then((res) => {
                this.nav_top_list = res.data  //响应回来的数据
              }).catch((error) => {
              this.$message.error("无法获取头部导航信息");
            })
          },
        },

    }
</script>

在前端创建搜索页面Search.vue,代码如下:

<template>
  <div class="container search">
    <div class="row">
      <div class="aside">
        <div>
          <ul class="menu">
            <li class="active"><a><div class="setting-icon"><i class="iconfont ic-search-note"></i></div> <span>文章</span></a></li>
            <li class=""><a><div class="setting-icon"><i class="iconfont ic-search-user"></i></div> <span>用户</span></a></li>
            <li class=""><a><div class="setting-icon"><i class="iconfont ic-search-collection"></i></div> <span>专题</span></a></li>
            <li class=""><a><div class="setting-icon"><i class="iconfont ic-search-notebook"></i></div> <span>文集</span></a></li>
          </ul>
        </div>
        <div class="search-recent">
          <div class="search-recent-header clearfix">
            <span>最近搜索</span> <a>清空</a></div>
          <ul class="search-recent-item-wrap">
            <li><a href="" target="_blank"><i class="iconfont ic-search-history"></i> <span>dd</span> <i class="iconfont ic-unfollow"></i></a></li>
            <li><a href="" target="_blank"><i class="iconfont ic-search-history"></i> <span>2020</span> <i class="iconfont ic-unfollow"></i></a></li>
          </ul>
        </div>
      </div>
      <div class="col-xs-16 col-xs-offset-8 main">
        <div class="search-content">
          <div class="sort-type">
            <a class="active">综合排序 · </a>
            <a class="">热门文章 ·</a>
            <a class="">最新发布 ·</a>
            <a class="">最新评论</a>
            <span>&nbsp;&nbsp;|&nbsp;</span>
            <div class="v-select-wrap">
              <div class="v-select-submit-wrap"><svg viewBox="0 0 10 6" aria-hidden="true"><path d="M8.716.217L5.002 4 1.285.218C.99-.072.514-.072.22.218c-.294.29-.294.76 0 1.052l4.25 4.512c.292.29.77.29 1.063 0L9.78 1.27c.293-.29.293-.76 0-1.052-.295-.29-.77-.29-1.063 0z"></path></svg>
              </div>
            </div>
          </div>
          <div class="result">16743 个结果</div>
          <ul class="note-list">
            <li v-for="(search_article_vlaue,search_article_index) in search_article_list" :key="search_article_index">
              <div class="content">
                <div class="author"><a href="" target="_blank" class="avatar"><img :src="search_article_vlaue.author_avatar"></a> <div class="info"><a href="" class="nickname">{{search_article_vlaue.author_name}}</a><span class="time">{{search_article_vlaue.pub_data}}</span>
                </div>
              </div>
              <router-link :to="`/article/${search_article_vlaue.id}`"  target="_blank" class="title" >{{search_article_vlaue.title}}</router-link>
              <p class="abstract">{{search_article_vlaue.content}}...</p>
                <div class="meta">
                  <a href="" target="_blank"><i class="iconfont ic-list-read"></i>{{search_article_vlaue.read_count }}</a>
                  <a href="" target="_blank"><i class="iconfont ic-list-comments"></i> {{search_article_vlaue.comment_count}}</a>
                  <span><i class="iconfont ic-list-like"></i> {{search_article_vlaue.like_count}}</span>
                  <span><i class="iconfont ic-list-money"></i> {{search_article_vlaue.reward_count}}</span>
                </div>
              </div>
            </li>
          </ul>
          <div>
            <ul class="pagination">
              <li><a href="" class="active">1</a></li>
              <li><a>2</a></li>
              <li><a>3</a></li>
              <li><a>4</a></li>
              <li><a>下一页</a></li>
              <router-link to="/login">baidu</router-link>
            </ul>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
    export default {
      name: "Search",
      data(){
        return{
          search_article_list:[], // 搜索文章列表
          search_text:'', // 索引内容
          search_count:0, // 搜索文章数量
        }
      },
      created() {
        this.search_text = this.$route.query.text;
        this.get_search_article_list()
      },
      methods:{
        // 获取搜索的文章
        get_search_article_list(){
          this.$axios.get(`${this.$settings.host}/article/search`,{
            params:{
              text:this.search_text
            }
          }).then((res)=>{
            this.search_article_list = res.data.results
            this.search_count = res.data.count
          }).catch((error)=>{
            this.$message.error('获取搜索内容失败!')
          })
        },
      },
    }
</script>

<style scoped>
    /* 这里的css在笔记的素材中找到Search.vue复制进去 */
</style>

路由,代码:router/index.js

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router);

// ....

import Search from "@/components/Search"


export default new Router({
  mode: "history",
  routes: [
    /// ...
      {
       name:"Search",
       path:"/search",
       component: Search,
     },
  ]
})

服务端

在搜索页面加载完成以后,对api数据进行搜索请求,因为客户端需要更多的返回搜索字段,所以我们重新调整api视图接口,返回用户信息和点赞等记录数值。

模型增加两个字段,代码,article/models.py,代码:

class Article(BaseModel):
    """文章模型"""
    title = models.CharField(max_length=200, verbose_name="文章标题")
    content = models.TextField(null=True, blank=True, verbose_name="文章内容")
    render = models.TextField(null=True, blank=True, verbose_name="文章内容(处理标签内容)")
    user = models.ForeignKey(User, on_delete=models.DO_NOTHING, verbose_name="用户")
    collection = models.ForeignKey(ArticleCollection, on_delete=models.CASCADE, verbose_name="文集")
    pub_date = models.DateTimeField(null=True, default=None, verbose_name="发布时间")
    access_pwd = models.CharField(max_length=15,null=True, blank=True, verbose_name="访问密码")
    read_count = models.IntegerField(default=0, null=True, blank=True, verbose_name="阅读量")
    like_count = models.IntegerField(default=0, null=True, blank=True, verbose_name="点赞量")
    collect_count = models.IntegerField(default=0, null=True, blank=True, verbose_name="收藏量")
    comment_count = models.IntegerField(default=0, null=True, blank=True, verbose_name="评论量")
    reward_count = models.IntegerField(default=0, null=True, blank=True, verbose_name="赞赏量")
    is_public = models.BooleanField(default=False, verbose_name="是否公开")
    class Meta:
        db_table = "rr_article"
        verbose_name = "文章"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.title

    # 新增字段
    @property
    def user_nickname(self):
        return self.user.nickname

    @property
    def user_avatar(self):
        try:
            image_url = self.user.avatar.url
            return image_url
        except:
            return ""

索引类代码,article/search_indexes.py,代码:

from haystack import indexes
from .models import Article

class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
    """
    文章索引数据模型类
    """
    # 全局索引,文档字段,这个字段不属于模型的,可以通过这个索引字段,到数据库中进行多个字段的搜索匹配
    text = indexes.CharField(document=True, use_template=True)
    id = indexes.IntegerField(model_attr='id')
    title = indexes.CharField(model_attr='title')
    content = indexes.CharField(model_attr='content')

    read_count = indexes.IntegerField(model_attr='read_count')
    like_count = indexes.IntegerField(model_attr='like_count')
    comment_count = indexes.IntegerField(model_attr='comment_count')
    reward_count = indexes.IntegerField(model_attr='reward_count')
    author_id = indexes.IntegerField(model_attr="user_id")
    author_name = indexes.CharField(model_attr="user_nickname")
    author_avatar = indexes.CharField(model_attr="user_avatar")
    pub_date = indexes.DateTimeField(model_attr="pub_date",null=True)

    def get_model(self):
        """返回建立索引的模型类"""
        return Article

    def index_queryset(self, using=None):
        """返回要建立索引的数据查询集"""
        return self.get_model().objects.filter(is_public=True)

序列化器,增加多个返回字段,article/serializers.py,代码:

# 文章索引结果数据序列化器
class ArticleIndexSerializer(HaystackSerializer):
    """
    文章索引结果数据序列化器
    """
    class Meta:
        index_classes = [ArticleIndex]
        # 注意fields属性的字段名与ArticleIndex类的字段相对应
        fields = ('text','id', 'title', 'content', "author_id", 'author_name', "author_avatar", 'read_count','like_count','comment_count','reward_count','pub_date')

原文地址:https://www.cnblogs.com/jia-shu/p/14677332.html