分类 ElasticSearch 下的文章

学习和使用Elasticsearch有一段时间了,项目中大量使用到了es,但对于我来说都是部分或者局部地去使用,所以得找个时间好好整理并且再完整实践一下es,于是就有了这篇文章。

首先系统架构是LNMP,很简单的个人博客网站(逐步前行STEP),
使用laravel框架,实现全文检索的引擎是elasticsearch,使用的分词工具是ik-analyzer然后是安装组件:elasticsearch/elasticsearch,以下列表是本次实践所用到的软件/框架/组件的版本:

  1. PHP 7.1.3
  2. Larvel 5.8
  3. Mysql 5.7
  4. elasticsearch 5.3
  5. elasticsearch/elasticsearch 7.2

以下默认上述环境已经准备完毕。

实战主要分为4部分:

  1. 创建索引
  2. 全量数据导入es
  3. 增量数据同步es
  4. 关键词检索

一、创建索引

博客的以下属性需要纳入检索:

字段备注属性
idIDint(11)
title标题varchar(255)
description摘要varchar(255)
content内容text
category_id分类IDint(11)
keyword_ids关键词varchar(255)
read_cnt阅读量int(11)
created_at发布时间TIMESTAMP
updated_at更新时间TIMESTAMP

其中,title、description、content既需要分词来做全文检索,又需要保留部分原字符串便于直接搜索,所以使用fields将字段映射出不同类型:

"title": {
    "type": "text",
    "fields": {
        "keyword": {
            "type": "keyword",
            "ignore_above": 256
        }
    }
},

而在分词器的选择上,为了既能对文档分词更细,又能对检索更精确,在对文档字段分词和对检索时的输入分词使用不同的分词器:

"title": {
   "type": "text",
     "fields": {
         "keyword": {
             "type": "keyword",
             "ignore_above": 256
         }
     },
     "analyzer": "ik_max_word",
     "search_analyzer": "ik_smart"
 },

比如,title为”重走丝绸之路“,ik_max_word分词如下:

{
    "tokens": [
        {
            "token": "重走",
            "start_offset": 0,
            "end_offset": 2,
            "type": "CN_WORD",
            "position": 0
        },
        {
            "token": "丝绸之路",
            "start_offset": 2,
            "end_offset": 6,
            "type": "CN_WORD",
            "position": 1
        },
        {
            "token": "丝绸",
            "start_offset": 2,
            "end_offset": 4,
            "type": "CN_WORD",
            "position": 2
        },
        {
            "token": "之路",
            "start_offset": 4,
            "end_offset": 6,
            "type": "CN_WORD",
            "position": 3
        }
    ]
}

而ik_smart分词粒度更粗:

{
    "tokens": [
        {
            "token": "重走",
            "start_offset": 0,
            "end_offset": 2,
            "type": "CN_WORD",
            "position": 0
        },
        {
            "token": "丝绸之路",
            "start_offset": 2,
            "end_offset": 6,
            "type": "CN_WORD",
            "position": 1
        }
    ]
}

键搜索词为”重走丝绸之路“,我们当然希望原文尽可能多匹配到这个检索词,而不是每个字都可能检索出一堆文档,这就是匹配的精确度。

对于keyword_ids、category_id,导入到es中时,就要装换成具体的内容了,才能要支持用户使用文本检索,而不是限制使用ID,这两个字段分别在es中字段名设置为keywords、category。
而且,一般来说关键词的检索,只考虑精确匹配,比如说关键词”全文检索“,如果要分词的话就会变成:

{
    "tokens": [
        {
            "token": "全文",
            "start_offset": 0,
            "end_offset": 2,
            "type": "CN_WORD",
            "position": 0
        },
        {
            "token": "检索",
            "start_offset": 2,
            "end_offset": 4,
            "type": "CN_WORD",
            "position": 1
        }
    ]
}

而实际上,全文可能匹配一部分文档,检索页匹配一部分文档,这对于关键词这个属性定义来说,是没有意义的,所以,我们对keywords、category使用”keyword“类型。

考虑到该实战只是最小实现,忽略别名(aliases),分片配置使用默认,相应的需建立索引articles如下:

{
        "mappings": {
            "doc": {
                "properties": {
                    "id": {
                        "type": "long"
                    },
                    "keywords": {
                        "type": "keyword",
                        "ignore_above": 256
                    },
                    "categorys": {
                        "type": "keyword",
                        "ignore_above": 256
                    },
                    "read_cnt": {
                        "type": "long"
                    },
                    "title": {
                        "type": "text",
                        "fields": {
                            "keyword": {
                                "type": "keyword",
                                "ignore_above": 256
                            }
                        },
                        "analyzer": "ik_max_word",
                        "search_analyzer": "ik_smart"
                    },
                    "description": {
                        "type": "text",
                        "fields": {
                            "keyword": {
                                "type": "keyword",
                                "ignore_above": 256
                            }
                        },
                        "analyzer": "ik_max_word",
                        "search_analyzer": "ik_smart"
                    },
                    "created_at": {
                        "type": "date",
                        "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
                    },
                    "updated_at": {
                        "type": "date",
                        "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
                    }
                }
            }
        }
    
}

使用 PUT /articles API创建索引成功后会返回:

{
    "acknowledged": true,
    "shards_acknowledged": true
}

二、全量数据导入es

因为是对已有的博客网站打造全文检索,所以首先需要进行一次全量导入ES。第一步的操作都是直接使用es api完成的,而这一步涉及到数据查询与转换,则需要在我们的项目内完成。

首先我们需要熟悉es组件elasticsearch/elasticsearch的使用,以下介绍本次实战涉及到的一些功能,更多可以直接看文档:Elasticsearch-PHP 中文文档

我们先在配置文件config/elastic.php定义好es的连接信息:

<?php

return array(
    'default' => [
         'hosts'     => [
            [
                 'host' => ‘xxx.xxx.xxx.xxx’,
                 'port' => '9200',
                 'scheme' => 'http',
             ]
         ],
        'retries'   => 1,

        /*
        |--------------------------------------------------------------------------
        | Default Index Name
        |--------------------------------------------------------------------------
        |
        | This is the index name that elasticquent will use for all
        */
        'default_index' => ‘default_index’,
    ],
);

再使用批量批量索引文档的方法:bulk,示例:

for($i = 0; $i < 100; $i++) {
    $params['body'][] = [
        'index' => [
            '_index' => 'my_index',
            '_type' => 'my_type',
        ]
    ];

    $params['body'][] = [
        'my_field' => 'my_value'
    ];
}

$responses = ClientBuilder::create()->build()->bulk($params);

这里不能直接使用查库后的数据,需要做一些转换工作,比如keyword_ids 转换成keywords,我们封装一个函数:getDoc()


public function getDoc()
{
    $fields = [
        'id',
        ’title,
        ‘description’,
        ‘read_cnt’,
        'created_at’,
        ‘updated_at’
    ];

    $data = array_only($this->getAttributes(), $fields);

    $data[‘keywords’] = ArticleKeyword::whereIn(‘id’, $this->keyword_ids)->pluck(‘name’)->toArray();

    $data[‘category’] = ArticleCategory::find($this->category_id);

    return $data;
}

直接调用该方法获取需要同步的文档数据。
注意使用该方法批量索引时,index + 一组数据是成对的。
按照第一步新建的索引,直接使用组件提供的批量索引功能全量将查询出的数据同步到es中。

3、增量数据同步es

对于新增的数据,需要在写入库中的同时同步到es,这里使用到的方案是Eloquent 的模型事件。

在 Eloquent 模型类上进行查询、插入、更新、删除操作时,会触发相应的模型事件,不管你有没有监听它们。这些事件包括:

retrieved 获取到模型实例后触发
creating 插入到数据库前触发
created 插入到数据库后触发
updating 更新到数据库前触发
updated 更新到数据库后触发
saving 保存到数据库前触发(插入/更新之前,无论插入还是更新都会触发)
saved 保存到数据库后触发(插入/更新之后,无论插入还是更新都会触发)
deleting 从数据库删除记录前触发
deleted 从数据库删除记录后触发
restoring 恢复软删除记录前触发
restored 恢复软删除记录后触发

而我们需要使用到的事件是:saved、deleted,监听这两个事件,在触发后同步到es,这样文章的增、改、删操作都能实时将数据变化同步到es。

我们使用fireModelEvent设置事件触发的同步操作,这里用到了组件中的单文档索引功能:index,示例:

$params = [
    'index' => 'my_index',
    'type' => 'my_type',
    'id' => 'my_id',
    'body' => [ 'testField' => 'abc']
];

$response = $client->index($params);

使用第2步中的getDoc()方法来获取待更新的数据。
具体实现如下:

    public function fireModelEvent($event, $halt = true)
    {
        if (in_array($event, ['saved', 'deleted']))
        {
            if($event == 'deleted')
            {
                ClientBuilder::create()->build()->delete(['id' => $this->id]);
            }

            if($event == 'saved')
            {
                $params = [
                    'index' => 'articles',
                    'type' => 'doc',
                    'id' => $this->id,
                    'body' => $this->getDoc()
                ];

                ClientBuilder::create()->build()->index($params);
            }
        }
    }

4、检索数据

通过2、3步骤,我们的文章已经实时同步到es上了,这一步我们需要将es的全文检索开放给用户使用,在我的网站中,我在文章列表增加了一个搜索框给用户输入需检索的文本:
在这里插入图片描述
这里有两个需求:
1、对title、description、keywords、category 做 query_string 查询
2、将查询结果转化为Eloquent集合,便于结果展示

封装的检索函数:

    public static function search($keyword, $page = 1, $per_page = 20, $conditions = [], $sort = null)
    {
        $page = max(1, intval($page));

        $from = ($page - 1) * $per_page;

        $query = [];
        //搜索文本字段
        $search_fields = ['title', 'keywords', 'category', 'description'];

        if($keyword)
        {
            foreach ($search_fields as $key => $search_field)
            {
                $query['must']['bool']['should'][] = [
                    'query_string' => [
                        'default_field' => $search_field,
                        'query' => strtolower($keyword),
                        'default_operator' => 'AND',
                    ]
                ];
            }
        }

        $params = [
            'index' => 'articles',
            'type' => 'doc',
            'body' => [
                'query' => $query
            ]
        ];

        $response = ClientBuilder::create()->build()->search($params);

        $total_count = array_get($response, 'hits.total', 0);

        $collection = new Collection();

        foreach (array_get($response, 'hits.hits', []) as $key => $item)
        {
            $self = new static;

            $self->setRawAttributes($item['_source'], true);

            $collection->add($self);
        }

        return new LengthAwarePaginator($collection, $total_count, $per_page, intval($from/$per_page) + 1);
    }

Elasticsearch 的相似度算法被定义为检索词频率/反向文档频率, TF/IDF 。

一. 相关概念:

  1. 检索词频率:tf

词 t 在文档 d 的词频( tf )是该词在文档中出现次数的平方根。

tf(t in d) = √frequency

检索词在该字段出现的频率?出现频率越高,相关性也越高。 字段中出现过 5 次要比只出现过 1 次的相关性高。

  1. 反向文档频率:idf

词 t 的逆向文档频率( idf )是:索引中文档数量除以所有包含该词的文档数,然后求其对数。

idf(t) = 1 + log ( numDocs / (docFreq + 1))

每个检索词在索引中出现的频率?频率越高,相关性越低。检索词出现在多数文档中会比出现在少数文档中的权重更低。

二、计算公式为:

score(q,d)  =  queryNorm(q)  · coord(q,d)  · ∑ (tf(t,d) · idf(t)² · t.getBoost() · norm(t,d))  

其它参数定义:

  1. 字段长度准则:norm

字段长度归一值( norm )是字段中词数平方根的倒数。

norm(d) = 1 / √numTerms

字段的长度是多少?长度越长,相关性越低。 检索词出现在一个短的 title 要比同样的词出现在一个长的 content 字段权重更大。

  1. 查询归一因子

查询归一因子queryNorm )试图将查询 归一化 ,这样就能将两个不同的查询结果相比较。

这个因子是在查询过程的最前面计算的,具体的计算依赖于具体查询

queryNorm = 1 / √sumOfSquaredWeights 

sumOfSquaredWeights 是查询里每个词的 IDF 的平方和。

以上是对于一个词项检索时的相关度计算,当查询多个词项时,得出多个相关度,则需要按照向量空间模型来计算整体的相似度:

向量空间模型:vector

向量空间模型(vector space model) 提供一种比较多词查询的方式,单个评分代表文档与查询的匹配程度

在向量空间模型里,向量空间模型里的每个数字都代表一个词的 权重 ,与 词频/逆向文档频率(term frequency/inverse document frequency) 计算方式类似。

3、控制相关度

一般来说,控制相关度的需求,分为两种:

  1. 忽略TF/IDF
    即不需要评分,可以使用constant_score来达成,在 constant_score 查询中,它可以包含查询或过滤,为任意一个匹配的文档指定评分 1 ,忽略 TF/IDF 信息。
  2. 定制评分
    function_score 查询 是用来控制评分过程的终极武器,它允许为每个与主查询匹配的文档应用一个函数,以达到改变甚至完全替换原始查询评分 _score 的目的。本文主要介绍使用script_score函数。

使用脚本计算评
script_score
自定义脚本可以完全控制评分计算:

{
    "function_score": {
        "functions": {
            "script_score": {
                "script": "doc['price'].value + doc['margin'].value"
            }
        }
    }
}

4、Painless

es脚本引擎,简单安全,无痛使用,Painless使用白名单来限制函数与字段的访问,针对es的场景来进行优化,只做es数据的操作,更加轻量级。

Painless中变量可以声明为基本数据类型、引用类型、字符串、void(不返回值)、数组以及动态类型。其支持下面基本类型:

byte, short, char, int, long, float, double, boolean.声明变量与java类似:

int i = 0; double a; boolean g = true;

数组类型支持一维和多维,初始值为null。与引用类型一样,使用new关键字,并为每个维度设置中括号

int[] x = new int[2];  
x[0] = 3;  
x[1] = 4;  

int[] b = new int[] {1,2,3,4,5};  

painless支持动态类型,elasticsearch会自动推断类型

def a = 1;  
def b = "foo";  
def[][] h = new def[2][2];  

条件语句和运算符

Painless包含完整的操作符列表,除了它们的优先级和结合性之外,这些操作符与其他高级语言几乎兼容。

if (doc['foo'].value = 5) {  
    doc['foo'].value *= 10;
} 
else {  
    doc['foo'].value += 10;
}

Painless支持if else,但不支持else ifswitch

循环

def total = 0;  
for (def i = 0; i < doc['scores'].length; i++) {  
    total += doc['scores'][i];
}
return total;  

5、控制相关度实践

该实例中将使用script_score,将评分设置为:
doc['download_cnt'].value * 2.5 +doc['replication_cnt'].value * 1.2

{
    "query": {
        "function_score": {
            "query": {
                "match": {
                    "name": "1"
                }
            },
            "functions": [
                {
                    "script_score": {
                        "script": {
                            "params": {
                                "download_ratio": 2.5,
                                "replication_ratio": 1.2
                            },
                            "lang": "painless",
                            "inline": "doc['download_cnt'].value * params.download_ratio + doc['replication_cnt'].value * params.replication_ratio"
                        }
                    }
                }
            ]
        }
    }
}

_search操作中所有的返回值,都可以通过一个map类型变量doc获取。和所有其他脚本语言一样,用[]获取map中的值。这里要强调的是,doc只可以在_search中访问到

我的个人博客:逐步前行STEP

1、elasticsearch 如何使文档可以被搜索

为了支持全文检索而采用倒排索引,倒排索引包含一个有序列表,列表包含所有文档出现过的词项 ,对于每一个词项,包含了它所有曾出现过文档的列表。
早期的倒排索引,会在文档变化时,重建新的索引,直到完成后替换掉旧的索引,这样新的变化就可以被搜索到。
倒排索引具有不变性,这使得索引一旦被读入文件系统缓存,便会留在那里,只要有足够的空间,就可以使大部分请求直接命中缓存,很大地提升了性能,缺点是,如果有新的文档需要被搜索,只能重建整个索引。

2、如何使用更好的方式实现倒排索引的更新

上面说了,由于索引的不变性,更新索引的方式是重建整个索引,这种方式对于数据量和更新频率的情况是有很大限制的,所以elasticsearch通过增加新的补充索引来反映新近的修改,而不是直接重写整个倒排索引。最终通过轮询每一个倒排索引并对结果进行合并才创建新的索引。

  • 按段搜索
  1. 基于lucene 引入了 按段搜索的概念,每一个段,都是一个倒排索引,而一个Lucene索引,除了包含多个段之外,还包含commit point、.liv文件。

其中,commit point记录了每一次的创建新段,当一个段存在文档被删除,会维护该信息在 .liv 文件里面。

逐段搜索会以如下流程进行工作:

  1. 新增的文档写入内存缓存
  2. 每隔一段时间,创建一个新段,将文档从内存缓存中被提交到新段
  3. 新段写入到磁盘(文件系统缓存)
  4. 新的commit point 也 写入到磁盘(文件系统缓存)
  5. 磁盘 fsync ,从文件系统缓存中价格写入操作同步到物理磁盘
  6. 这个新的段被开启, 使得段内文档对搜索可见
  7. 清空内存缓存,继续接受新的文档

3、如何实现近实时搜索

上面的写入到可检索过程,主要瓶颈在磁盘fync,大量文档需要写入磁盘可能造成很明显的延迟,而且每次的索引一个文档都要做磁盘fsync会造成性能问题。所以elasticsearch更轻量的方式来使一个文档可被搜索——将fsync从【写入到可检索】的步骤中移除——在新段写入文件系统缓存之后就可检索。
在第1部分中描述了倒排索引在文件系统缓存中被请求,所以在新段写入文件系统缓存之后就可以被打开和读取了。写入文件系统缓存和打开新段被称为refresh,默认情况下每秒进行一次refresh,也可以更改该项设置,或者手动执行。
有了refresh之后,索引文档的步骤更改为:

  1. 新增的文档写入内存缓存
  2. 每隔一段时间,创建一个新段,refresh,此时新段可被检索
  3. 通过fsync刷新磁盘(flush) ,从文件系统缓存中将所有写入操作同步到物理磁盘
  4. 清空内存缓存,继续接受新的文档

4、数据持久化

上面说到新段先写入文件系统缓存,再fsync到磁盘,在这个过程中,如果断电,则无法将未来得及同步的数据持久化到磁盘,所以elasticsearch 增加了一个 translog,也叫事务日志,记录了每一次的操作。

  1. 在文档写入内存缓存的同时,写入到translog中
  2. 执行refresh时,会清空内存缓存,且没有fsync到磁盘时,translog仍然保留
  3. 每隔一段时间或者translog文件太大,索引被刷新,所有在内存缓冲区的文档都被写入一个新的段,清空内存缓存,一个提交点被写入硬盘,新的traslog文件被创建,旧的被删除

而在执行refresh后,fsync之前,如果重启elasticsearch,它会从磁盘中使用最后一个提交点去恢复已知的段,并且会重放 translog 中所有在最后一次提交后发生的变更操作。

5、段合并

由于在自动刷新流程每秒会创建一个新的段,段时间内就会积累大量的段,每一个段都会消耗文件句柄、内存和cpu运行周期,而且在检索时需要遍历所有段来查找文档,这样无疑是一个巨大的性能瓶颈。elasticsearch通过在后台进行段合并来解决这个问题,小的段被合并到大的段,然后这些大的段再被合并到更大的段,段合并的时候会将那些旧的已删除文档从文件系统中清除。
段合并不会中断检索和索引的过程,在索引的时候,创建新段,段合并就会自动执行,合并的段将继续执行之后的步骤。
手动进行强制段合并可使用optimize api,它会将一个分片强制合并到 max_num_segments 个段,但该操作可能会无限制的使用资源,造成集群内短时间内无法响应。

查询文档数量时很常见的操作,一般可以直接使用count获取文档数,但是获取到数量信息,在【分页】应用中,意味着需要查询分页然后再查询总数。

有另一种方法,可以让我们在一次查询中获取分页数据并得到总量。

在搜索时,结果中的 hits.total 信息中会包含一个整数值表示文档数,当实际文档数小于10000时显示具体数量,当大于10000时,只显示10000并且包含另一个字段relation来表示是否大于10000:
在这里插入图片描述
而要在文档数大于10000时获取实际的数量,需要加入参数:track_total_hits
在这里插入图片描述
track_total_hits=true表示显示实际的文档数,此时hits.total的会变成:
在这里插入图片描述
relatiion为eq表示value值等于文档数值。

我的个人博客:逐步前行STEP

去重计数不是精确计数,数据量大的情况下会有误差,官方文档说的是,默认的情况下百万级数据会有5%的误差,实测如下:

实际文档数:1924920
在这里插入图片描述

去重计数:1912715在这里插入图片描述

误差率:(1924920 - 1912715)/ 1924920 = 0.006
%0.6的误差率还能接受,如果需要更高的准确度的话,需要调整precision_threshold的值,默认值是100,可选值在0~40000,值越大越准确但是越消耗内存,其内存使用满足公式:precision_threshold * 8 (字节)
,实测设为40000时计数为1926620:
在这里插入图片描述

误差率:(1924920 - 1926620)/ 1924920 = -0.0008
只有%0.08的误差率,在不需要完全准确的计数得情况下,完全可以接受。