跳槽后来公司,第一个项目就是做一个基于FAQ的智能客服问答系统。在召回阶段,直接采用了elasticsearch。感觉这个工具对NLP项目的落地很重要,当做一篇拓宽知识面的博客吧。
Elasticsearch 是一个分布式的免费开源搜索和分析引擎,适用于包括文本、数字、地理空间、结构化和非结构化数据等在内的所有类型的数据。更加准确的说明:
Elasticsearch是在Apache Lucene 的基础上开发而成,采用了倒排索引思想+B_Tree以及一些数据压缩和检索技术手段,保证数据的高效的写入和查询。
elasticsearch由于可以实现全文搜索,并且操作简单都是封装成了rest风格的API,在日志分析系统、客服问答系统等都可以作为一种实现方案。
常用术语
Node & Cluster
es的本质上是一个分布式的数据库,可以使单机单节点,也可以是一个多机集群式的。
索引(Index)
这个可以类比为Mysql等关系型数据库中的一个数据库名称,或者理解是一张表的名称(es高版本中由于没有type的类型)。Elasticsearch 数据管理的顶层单位就叫做 Index(索引),相当于关系型数据库里的数据库的概念。另外,每个Index的名字必须是小写,实战中经常会犯的错误。
文档(Document)
Index里面单条的记录称为 Document(文档)。许多条 Document 构成了一个 Index。Document 使用 JSON 格式表示。同一个 Index 里面的 Document,不要求有相同的结构(scheme),但是最好保持相同,这样有利于提高搜索效率,可以理解为数据库中的一条记录。
类型(Type)
Document 可以分组,比如employee这个 Index 里面,可以按部门分组,也可以按职级分组。这种分组就叫做 Type,它是虚拟的逻辑分组,用来过滤 Document,类似关系型数据库中的数据表。不同的 Type 应该有相似的结构(Schema),性质完全不同的数据(比如 products 和 logs)应该存成两个 Index,而不是一个 Index 里面的两个 Type(虽然可以做到)。
值得注意的是在高版本的es中并没有类型这样的概念了。高版本中一个index就是一个数据,其中只包含了一张表,表中有很多条数据。
文档数据
文档元数据为_index, _type, _id, 这三者可以唯一表示一个文档,_index表示文档在哪存放,_type表示文档的对象类别,_id为文档的唯一标识。
Fileds(字段)
每个Document都类似一个JSON结构,它包含了许多字段,每个字段都有其对应的值,多个字段组成了一个 Document,可以类比关系型数据库数据表中的字段。
当然这里的字段,也有自己的类型,分别是text和keyword,区别是数据存储的时候是否进行了分词,查询过程中对于text的类型只要有相同的部分就可以被检索出来,而keyword类型的则需要完全相同。
给一个es存储的示例截图
字段、id、索引具体样式如上图所示。
es支持很多版本的程序语言的操作,本人常用python和go语言。就python和go语言如何操作es进行一个实战演练。
1、数据写入
首先看看数据是如何存储近es的。从写入速度来区分,可以一条一条的比较缓慢的写入,也可以按批次快速写入。
具体的步骤可以分为如下步骤:
第一步:es连接
第二步:index创建,同时进行filed属性映射
es写入的数据是采用字典的格式,因此需要把数据封装成字典格式,同时做属性映射的时候需要指定每个字段的类型属性是keyword还是text(分词处理后)。
第三步:写入数据
a、正常写入
直接上代码
es = Elasticsearch(hosts=['127.0.0.1:9200'])
properties ={}
#带标签不带分词
for k in datas[0].keys():
if k != "Q_Sample":
properties[k] = {'type': 'keyword'}
else:
properties[k] = {
'type': 'text'
}
mapping = {'properties':properties}
print(mapping)
re = es.indices.create(index=index_name, ignore=400)
print(re)
#filed属性做映射
es.indices.put_mapping(index=index_name, doc_type='insurance', body=mapping,include_type_name=True)
#写入数据
for data in tqdm(datas,desc='insert data to es'):
es.index(index=index_name, doc_type='insurance', body=data)
注意高版本(8.0之后的吧)的es是没有doc_type属性的
es.indices.put_mapping(index=index_name, body=mapping)
这样的写入速度比较慢,要想快速写入就得使用批处理,需要用到
from elasticsearch import helpers
helpers.bulk(es, action)
b、批量写入
es的连接和index创建以及mapping映射通上面的代码是一样的,不同的是采用bulk来快速写入数据的代码,同时这里为了防止内存爆炸,采用分段以及列表生成器来完成写入。上代码:
from elasticsearch import Elasticsearch
from elasticsearch import helpers
from tqdm import tqdm
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
res = func(*args, **kwargs)
print('共耗时约 {:.2f} 秒'.format(time.time() - start))
return res
return wrapper
def create_es_index_insert_datas(index_name,datas):
# 测试环境
es = Elasticsearch(hosts=['127.0.0.1:9200'])
# 采用全量重写的方式
if es.indices.exists(index=index_name):
es.indices.delete(index=index_name)
properties ={}
#带标签不带分词
for k in datas[0].keys():
if k != "Q_Sample":
properties[k] = {'type': 'keyword'}
else:
properties[k] = {
'type': 'text'
}
mapping = {'properties':properties}
print(mapping)
es.indices.create(index=index_name, ignore=400)
#filed属性做映射
es.indices.put_mapping(index=index_name, doc_type='insurance', body=mapping,include_type_name=True)
return es
def get_texts():
datas_with_tags = []
......
......
for line in ......:
line = line.strip('\n').split('\t')
temp_with_tags ={}
for ele in line[0:-2]:
if ':' in ele:
k,v = ele.split(':')
if v=='null':
temp_with_tags[k] = ''
else:
temp_with_tags[k] = v
temp_with_tags['Q_Sample'] = line[-2]
temp_with_tags['A_Sample'] = line[-1]
datas_with_tags.append(temp_with_tags)
return datas_with_tags
@timer
def gen(index_name,es,datas_with_tags):
""" 使用生成器批量写入数据 分段写入 一批次1000条数据 """
print(len(datas_with_tags))
length = len(datas_with_tags)
print(length)
for i in range(length//1000):
action = ({
"_index": index_name,
"_type": "insurance",
"_source": datas_with_tags[k]
} for k in range(i*1000, (i+1)*1000))
helpers.bulk(es, action) #action 列表生成器
if length%1000 != 0:
action = ({
"_index": index_name,
"_type": "insurance",
"_source":datas_with_tags[k]
} for k in range(length//1000*1000, length))
helpers.bulk(es, action)
if __name__ == '__main__':
datas = get_texts()
# index_name = 'weikong_data' # 本地测试环境
index_name = 'es_hy_test_0825' #本地测试环境
# index_name = 'hy_search_test' #自己的测试es
es = create_es_index_insert_datas(index_name, datas)
gen(index_name, es, datas)
后面这种写入速度比第一种的速度快很多,1W条速度对比:
2、数据查询相关
es查询可以使python内置的API,也可以是REST风格的url配合编程语言中的http post模块来查询。es查询最重要的就是查询语句,常用的一些bool查询、term语句、match查询等等。这里展示一个python版本的查询代码:
from elasticsearch import Elasticsearch
es = Elasticsearch(hosts=['127.0.0.1:9200'])
body2 = {
"query": {
"match": {"question":"道路救援"}
},
'from': 0, # 从第二条数据开始
'size': 20 # 获取4条数据
}
result2 = es.search(index='questions', body=body2)
print('result2', result2)
复杂一点的bool、match加term联合查询语句:
{
"from":0,
"size":5,
"query":{
"bool":{
"must":[
{
"match":{
"Q_Sample":{
"query":"我去你们店里付款"
}
}
},
{
"term":{
"must一级分类":"支付类"
}
},
{
"term":{
"must二级分类":"支付方式"
}
},
{
"term":{
"must限定":""
}
},
{
"term":{
"must句式":""
}
},
{
"term":{
"must费用条款":""
}
},
{
"term":{
"must操作":""
}
},
{
"term":{
"must售后":""
}
},
{
"term":{
"must赠品相关":""
}
},
{
"term":{
"must险种":""
}
},
{
"term":{
"must推诿":""
}
},
{
"term":{
"must理赔询问":""
}
},
{
"term":{
"must产品服务名":""
}
},
{
"term":{
"must强匹配":""
}
},
{
"term":{
"must否定含义":""
}
}
],
"should":[
{
"term":{
"支付相关":"支付"
}
}
]
}
}
}
当查询的时候,超过了es最大窗口设置,需要修改窗口大小,假如有很多index的话就比较麻烦,直接在查询的时候分窗口查询:
sample = {'userId':'2795_search_226268','A_Sample': '', 'extension': '', 'Q_Sample': '你是到哪一步不好使了', 'createTime': '2021-10-08 19:10:59', 'cls': 'history', 'actions': '', 'answerDescs': ''}
result = {}
for key in sample.keys():
result[key] = []
es = Elasticsearch(hosts=['127.0.0.1:9200'])#对应的url
for index_name,count in tqdm(zip(all_es_indexs,all_datacounts)):
interval = 5000
for i in range(int(count/interval)):
body = {
"query": {"match_all": {}},
"from": i*interval,
"size": interval
}
searchs = es.search(index=index_name,body=body)
records = searchs['hits']['hits']
for record in records:
record['_source']['userId'] = index_name
if 'createTime' not in record['_source']:
record['_source']['createTime'] = '2021-10-27 11:22:59'
for key, v in record['_source'].items():
result[key].append(str(v))
if count - int((count/interval))*interval > 0:
body = {
"query": {"match_all": {}},
"from": int((count/interval))*interval,
"size": count - int((count/interval))*interval
}
searchs = es.search(index=index_name, body=body)
records = searchs['hits']['hits']
for record in records:
record['_source']['userId'] = index_name
if 'createTime' not in record['_source']:
record['_source']['createTime'] = '2021-10-27 11:22:59'
for key, v in record['_source'].items():
result[key].append(str(v))
df = pd.DataFrame()
for key,v in result.items():
print(key,'---',len(v))
df[key] = v
df.to_csv('all_pro_history_es_datas.csv',index=False,sep='\t')
具体的各种查询语句还需要使用的时候多多熟悉;
1、数据写入
es初始化,包含index的mapping设置,检查index是否存在等;
func initEsMappingsAndIndex(IndexName string, esFiledsList []string, esClient *elastic.Client) {
//注意number_of_shards 和 number_of_replicas的设置
mappings := "{\"settings\":{\"number_of_shards\": 1,\"number_of_replicas\": 2},\"mappings\":{\"properties\":{"
for _, filed := range esFiledsList {
//if filed != "Q_Sample" {
// mappings += "\"" + filed + "\":{\"type\":\"keyword\"},"
//} else {
// mappings += "\"" + filed + "\":{\"type\":\"text\"},"
//}
if filed == "createTime" {
mappings += "\"" + filed + "\":{\"type\":\"date\",\"format\":\"yyyy-MM-dd HH:mm:ss\"},"
} else if filed == "Q_Sample" {
mappings += "\"" + filed + "\":{\"type\":\"text\"},"
} else {
mappings += "\"" + filed + "\":{\"type\":\"keyword\"},"
}
}
mappings = mappings[0 : len(mappings)-1]
mappings += "}}}"
fmt.Println(mappings)
exists, err := esClient.IndexExists(IndexName).Do(context.Background())
if err != nil {
// Handle error
panic(err)
}
if !exists {
fmt.Println(IndexName + " is not exists ,creations")
// Create a new index.
createIndex, err := esClient.CreateIndex(IndexName).BodyString(mappings).Do(context.Background())
if err != nil {
// Handle error
log.Error("esClient build Index and put mappings errors", err)
panic(err)
}
if !createIndex.Acknowledged {
fmt.Println("mappings puting failure!")
} else {
fmt.Println("mappings puting success!")
}
}
}
数据插入代码:
bodyJson := make(map[string]string)
body, _ := json.Marshal(bodyJson)
bodyString := string(body)
log.Infof("bodyString: %s", bodyString)
esInsert, err := esClient.Index().Index(EsIndexName).Type("_doc").BodyString(bodyString).Do(context.Background())
if err != nil {
log.Errorf("esClient insert data errors %s", err)
panic(err)
}
//插入成功会返回对应的Id
ids = append(ids, esInsert.Id)
2、数据查询相关
直接采用resful API风格来查询,请求体配置好了,直接发起http post请求
//插入之前对问题做一次去重,针对问题做去重
targetBody := "{\"from\":0,\"size\":" + strconv.Itoa(1) + ",\"query\":{\"bool\":{\"must\":[{\"match\":{\"Q_Sample\":{\"query\":\"" + question + "\"}}},{\"term\":{\"userId\":\"" + userId + "\"}}]}}}"
log.Infof("targetBody: %s", targetBody)
ElkRspBody := Manager.httpPost(session, config.Config().Common.EsServerAddress+"/"+EsIndexName+"/_search", "application/json", string(targetBody))
//查询es库得到结果
ElkRsp := WkELKRsp{}
err = json.Unmarshal(ElkRspBody, &ElkRsp)
if err != nil {
log.Error(err.Error())
panic(constrant.JSONFormatError)
}
res := ""
for _, hit := range ElkRsp.Hits.Hits {
res = hit.Source.Q_Sample
}
得到的结果进行json解码即可。
查询——post请求
http://127.0.0.1:9202/weikong_search_shanghai/_search
{"from":0,"size":10}
{"from":0,"size":100,"query":{"bool":{"must":[{"match":{"Q_Sample":{"query":"小保养怎么预约"}}}]}}}
{"from":0,"size":100,"query":{"bool":{"must":[{"match":{"Q_Sample":{"query":"保单帮我寄过来就可以了"}}},{"term":{"must二级分类":"获取"}},{"term":{"must理赔询问":""}},{"term":{"must售后":""}},{"term":{"must否定含义":""}},{"term":{"must险种":""}},{"term":{"must推诿":""}},{"term":{"must产品服务名":""}},{"term":{"must强匹配":""}},{"term":{"must限定":""}},{"term":{"must一级分类":"保单发票"}},{"term":{"must操作":""}},{"term":{"must句式":""}},{"term":{"must费用条款":""}},{"term":{"must赠品相关":""}}],"should":[{"term":{"关键要素":"保单"}}]}}}
查询index数据总数——get请求
http://127.0.0.1:9202/weikong_search_recommend/_count
更新——post请求
根据时间字段修改
http://127.0.0.1:9202/16326_search_9900002058/_update_by_query
{"script": {"lang": "painless", "source": "if (ctx._source.createTime == null) {ctx._source.createTime= '2021-10-08 15:25:48'}"}}
集群shards设置——put接口
http://127.0.0.1:9202/_cluster/settings
{
"transient": {
"cluster": {
"max_shards_per_node":10000
}
}
}
es最大单次查询结果限制——put接口
http://127.0.0.1:9202/_cluster/settings
{ "index.max_result_window" :"50000"}
查询es的健康状态——所有索引情况get请求
http://127.0.0.1:9202/_cat/indices