基于word2vec和Elasticsearch实现个性化搜索

标签: Elasticsearch Elasticsearch word2vec 个性化 搜索 | 发表时间:2017-03-28 07:51 | 作者:
出处:http://ginobefunny.com/

word2vec学习小记一文中我们曾经学习了word2vec这个工具,它基于神经网络语言模型并在其基础上进行优化,最终能获取词向量和语言模型。在我们的商品搜索系统里,采用了word2vec的方式来计算用户向量和商品向量,并通过Elasticsearch的function_score评分机制和自定义的脚本插件来实现个性化搜索。

背景介绍

先来看下 维基百科上对于个性化搜索的定义和介绍:

Personalized search refers to web search experiences that are tailored specifically to an individual’s interests by incorporating information about the individual beyond specific query provided. Pitkow et al. describe two general approaches to personalizing search results, one involving modifying the user’s query and the other re-ranking search results.

由此我们可以得到两个重要的信息:

  1. 个性化搜索需要充分考虑到用户的偏好,将 用户感兴趣的内容优先展示给用户;
  2. 另外是对于实现个性化的方式上主要有 查询修改和对搜索结果的重排序两种。

而对我们电商网站来说,个性化搜索的重点是当用户搜索某个关键字,如【卫衣】时,能将用户最感兴趣最可能购买的商品(如用户偏好的品牌或款式)优先展示给用户,以提升用户体验和点击转化。

设计思路

  1. 在此之前我们曾经有一般的个性化搜索实现,其主要是通过计算用户和商品的一些重要属性(比如品牌、品类、性别等)的权重,然后得到一个用户和商品之间的关联系数,然后根据该系数进行重排序。
  2. 但是这一版从效果来看并不是很好,我个人觉得主要的原因有以下几点:用户对商品的各个属性的重视程度并不是一样的,另外考虑的商品的属性并不全,且没有去考虑商品和商品直接的关系;
  3. 在新的版本的设计中,我们考虑通过 用户的浏览记录这种时序数据来获取用户和商品以及商品和商品直接的关联关系,其核心就是通过类似于语言模型的词出现的顺序来训练向量表示结果;
  4. 在获取用户向量和商品向量表示后,我们就可以 根据向量直接的距离来计算相关性,从而将用户感兴趣的商品优先展示;

实现细节

商品向量的计算

  • 根据用户最近某段时间(如30天内)的浏览记录,获取得到浏览SKN的列表并使用空格分隔;核心的逻辑如下面的SQL所示:
  select concat_ws(' ', collect_set(product_skn)) as skns 
from 
 (select uid, cast(product_skn as string) as product_skn, click_time_stamp 
  from product_click_record 
  where date_id <= $date_id and date_id >= $date_id_30_day_ago
  order by uid, click_time_stamp) as a 
group by uid;
  • 将该SQL的执行结果写入文件作为word2vec训练的输入;
  • 调用word2vec执行训练,并保存训练的结果:
  time ./word2vec -train $prepare_file -output $result_file -cbow 1 -size 20 
-window 8 -negative 25 -hs 0 -sample 1e-4 -threads 20 -iter 15 
  • 读取训练结果的向量,保存到搜索库的商品向量表。

用户向量的计算

  • 在计算用户向量时采用了一种简化的处理,即通过用户最近某段时间(如30天内)的商品浏览记录,根据这些商品的向量进行每一维的求平均值处理来计算用户向量,核心的逻辑如下:
  vec_list = []
for i in range(feature_length):
    vec_list.append("avg(coalesce(b.vec[%s], 0))" % (str(i)))
vec = ', '.join(vec_list)


select a.uid as uid, array(%s) as vec 
from 
 (select * from product_click_record where date_id <= $date_id and date_id >= $date_id_30_day_ago) as a
left outer join
 (select * from product_w2v where date_id = $date_id) as b
on a.product_skn = b.product_skn
group by a.uid;
  • 将计算获取的用户向量,保存到Redis里供搜索服务获取。

搜索服务时增加个性化评分

  • 商品索引重建构造器在重建索引时设置商品向量到product_index的某个字段中,比如下面例子的productFeatureVector字段;
  • 搜索服务在默认的cross_fields相关性评分的机制下,需要增加个性化的评分,这个可以通过 function_score来实现。
     
1
2
3
4
5
6
     
Map<String, Object> scriptParams = new HashMap<>();
scriptParams.put("field", "productFeatureVector");
scriptParams.put("inputFeatureVector", userVector);
scriptParams.put("version", version);
Script script = new Script("feature_vector_scoring_script", ScriptService.ScriptType.INLINE, "native", scriptParams);
functionScoreQueryBuilder.add(ScoreFunctionBuilders.scriptFunction(script));
  • 这里采用了 elasticsearch-feature-vector-scoring插件来进行相关性评分,其核心是向量的余弦距离表示,具体见下面一小节的介绍。在脚本参数中,field表示索引中保存商品特征向量的字段;inputFeatureVector表示输入的向量,在这里为用户的向量;
  • 这里把version参数单独拿出来解释一下,因为每天计算出来的向量是不一样的,向量的每一维并没有对应商品的某个具体的属性(至少我们现在看不出来这种关联),因此我们要特别避免不同时间计算出来的向量的之间计算相关性。在实现的时候,我们是通过一个中间变量来表示最新的版本,即在完成商品向量和用户向量的计算和推送给搜索之后,再更新这个中间向量;搜索的索引构造器定期轮询这个中间变量,当发现发生更新之后,就将商品的特征向量批量更新到ES中,在后面的搜索中就可以采用新版本的向量了;

elasticsearch-feature-vector-scoring插件

这是我自己写的一个插件,具体的使用可以看下 项目主页,其核心也就一个类,我将其主要的代码和注释贴一下:

     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
     
public class FeatureVectorScoringSearchScript extends AbstractSearchScript {
public static final ESLogger LOGGER = Loggers.getLogger("feature-vector-scoring");
public static final String SCRIPT_NAME = "feature_vector_scoring_script";
private static final double DEFAULT_BASE_CONSTANT = 1.0D;
private static final double DEFAULT_FACTOR_CONSTANT = 1.0D;
// field in index to store feature vector
private String field;
// version of feature vector, if it isn't null, it should match version of index
private String version;
// final_score = baseConstant + factorConstant * cos(X, Y)
private double baseConstant;
// final_score = baseConstant + factorConstant * cos(X, Y)
private double factorConstant;
// input feature vector
private double[] inputFeatureVector;
// cos(X, Y) = Σ(Xi * Yi) / ( sqrt(Σ(Xi * Xi)) * sqrt(Σ(Yi * Yi)) )
// the inputFeatureVectorNorm is sqrt(Σ(Xi * Xi))
private double inputFeatureVectorNorm;
public static class ScriptFactory implements NativeScriptFactory {
@Override
public ExecutableScript newScript(@Nullable Map<String, Object> params) throws ScriptException {
return new FeatureVectorScoringSearchScript(params);
}
@Override
public boolean needsScores() {
return false;
}
}
private FeatureVectorScoringSearchScript(Map<String, Object> params) throws ScriptException {
this.field = (String) params.get("field");
String inputFeatureVectorStr = (String) params.get("inputFeatureVector");
if (this.field == null || inputFeatureVectorStr == null || inputFeatureVectorStr.trim().length() == 0) {
throw new ScriptException("Initialize script " + SCRIPT_NAME + " failed!");
}
this.version = (String) params.get("version");
this.baseConstant = params.get("baseConstant") != null ? Double.parseDouble(params.get("baseConstant").toString()) : DEFAULT_BASE_CONSTANT;
this.factorConstant = params.get("factorConstant") != null ? Double.parseDouble(params.get("factorConstant").toString()) : DEFAULT_FACTOR_CONSTANT;
String[] inputFeatureVectorArr = inputFeatureVectorStr.split(",");
int dimension = inputFeatureVectorArr.length;
double sumOfSquare = 0.0D;
this.inputFeatureVector = new double[dimension];
double temp;
for (int index = 0; index < dimension; index++) {
temp = Double.parseDouble(inputFeatureVectorArr[index].trim());
this.inputFeatureVector[index] = temp;
sumOfSquare += temp * temp;
}
this.inputFeatureVectorNorm = Math.sqrt(sumOfSquare);
LOGGER.debug("FeatureVectorScoringSearchScript.init, version:{}, norm:{}, baseConstant:{}, factorConstant:{}."
, this.version, this.inputFeatureVectorNorm, this.baseConstant, this.factorConstant);
}
@Override
public Object run() {
if (this.inputFeatureVectorNorm == 0) {
return this.baseConstant;
}
if (!doc().containsKey(this.field) || doc().get(this.field) == null) {
LOGGER.error("cannot find field {}.", field);
return this.baseConstant;
}
String docFeatureVectorStr = ((ScriptDocValues.Strings) doc().get(this.field)).getValue();
return calculateScore(docFeatureVectorStr);
}
public double calculateScore(String docFeatureVectorStr) {
// 1. check docFeatureVector
if (docFeatureVectorStr == null) {
return this.baseConstant;
}
docFeatureVectorStr = docFeatureVectorStr.trim();
if (docFeatureVectorStr.isEmpty()) {
return this.baseConstant;
}
// 2. check version and get feature vector array of document
String[] docFeatureVectorArr;
if (this.version != null) {
String versionPrefix = version + "|";
if (!docFeatureVectorStr.startsWith(versionPrefix)) {
return this.baseConstant;
}
docFeatureVectorArr = docFeatureVectorStr.substring(versionPrefix.length()).split(",");
} else {
docFeatureVectorArr = docFeatureVectorStr.split(",");
}
// 3. check the dimension of input and document
int dimension = this.inputFeatureVector.length;
if (docFeatureVectorArr == null || docFeatureVectorArr.length != dimension) {
return this.baseConstant;
}
// 4. calculate the relevance score of the two feature vector
double sumOfSquare = 0.0D;
double sumOfProduct = 0.0D;
double tempValueInDouble;
for (int i = 0; i < dimension; i++) {
tempValueInDouble = Double.parseDouble(docFeatureVectorArr[i].trim());
sumOfProduct += tempValueInDouble * this.inputFeatureVector[i];
sumOfSquare += tempValueInDouble * tempValueInDouble;
}
if (sumOfSquare == 0) {
return this.baseConstant;
}
double cosScore = sumOfProduct / (Math.sqrt(sumOfSquare) * inputFeatureVectorNorm);
return this.baseConstant + this.factorConstant * cosScore;
}
}

总结与后续改进

  • 基于word2vec、Elasticsearch和自定义的脚本插件,我们就实现了一个个性化的搜索服务,相对于原有的实现,新版的点击率和转化率都有大幅的提升;
  • 基于word2vec的商品向量还有一个可用之处,就是可以用来实现相似商品的推荐;
  • 但是以我个人的理解,使用word2vec来实现个性化搜索或个性化推荐是有一定局限性的,因为它只能处理用户点击历史这样的时序数据,而无法全面的去考虑用户偏好,这个还是有很大的改进和提升的空间;
  • 后续的话我们会更多的参考业界的做法,更多地更全面地考虑用户的偏好,另外还需要考虑时效性的问题,以优化商品排序和推荐。

参考资料

相关 [word2vec elasticsearch 个性] 推荐:

基于word2vec和Elasticsearch实现个性化搜索

- - GinoBeFunny
在 word2vec学习小记一文中我们曾经学习了word2vec这个工具,它基于神经网络语言模型并在其基础上进行优化,最终能获取词向量和语言模型. 在我们的商品搜索系统里,采用了word2vec的方式来计算用户向量和商品向量,并通过Elasticsearch的function_score评分机制和自定义的脚本插件来实现个性化搜索.

词向量工具word2vec的学习

- - 标点符
word2vec是Google在2013年开源的一款将词表征为实数值向量(word vector)的高效工具,采用的模型有CBOW(Continuous Bag-Of-Words,即连续的词袋模型)和Skip-Gram两种. word2vec通过训练,可以把对文本内容的处理简化为K维向量空间中的向量运算,而向量空间上的相似度可以用来表示文本语义上的相似度.

[译]elasticsearch mapping

- - an74520的专栏
es的mapping设置很关键,mapping设置不到位可能导致索引重建. 请看下面各个类型介绍^_^. 每一个JSON字段可以被映射到一个特定的核心类型. JSON本身已经为我们提供了一些输入,支持 string,  integer/ long,  float/ double,  boolean, and  null..

Elasticsearch as Database - taowen - SegmentFault

- -
【北京上地】滴滴出行基础平台部招聘 Elasticsearch 与 Mysql binlog databus 开发工程师. 内推简历投递给: taowen@didichuxing.com. 推销Elasticsearch. 时间序列数据库的秘密(1)—— 介绍. 时间序列数据库的秘密(2)——索引.

深度学习word2vec笔记之应用篇

- - 我爱机器学习
1)该博文是Google专家以及多位博主所无私奉献的论文资料整理的. 2)本文仅供学术交流,非商用. 所以每一部分具体的参考资料并没有详细对应,更有些部分本来就是直接从其他博客复制过来的. 如果某部分不小心侵犯了大家的利益,还望海涵,并联系老衲删除或修改,直到相关人士满意为止. 3)本人才疏学浅,整理总结的时候难免出错,还望各位前辈不吝指正,谢谢.

ElasticSearch 2 的节点调优(ElasticSearch性能)

- - 行业应用 - ITeye博客
一个ElasticSearch集群需要多少个节点很难用一种明确的方式回答,但是,我们可以将问题细化成一下几个,以便帮助我们更好的了解,如何去设计ElasticSearch节点的数目:. 打算建立多少索引,支持多少应用. elasticsearch版本: elasticsearch-2.x. 需要回答的问题远不止以上这些,但是第五个问题往往是容易被我们忽视的,因为单个ElasticSearch集群有能力支持多索引,也就能支持多个不同应用的使用.

elasticsearch的javaAPI之query

- - CSDN博客云计算推荐文章
elasticsearch的javaAPI之query API. the Search API允许执行一个搜索查询,返回一个与查询匹配的结果(hits). 它可以在跨一个或多个index上执行, 或者一个或多个types. 查询可以使用提供的 query Java API 或filter Java API.

Elasticsearch基础教程

- - 开源软件 - ITeye博客
转自:http://blog.csdn.net/cnweike/article/details/33736429.     Elasticsearch有几个核心概念. 从一开始理解这些概念会对整个学习过程有莫大的帮助.     接近实时(NRT).         Elasticsearch是一个接近实时的搜索平台.

ElasticSearch索引优化

- - 行业应用 - ITeye博客
ES索引的过程到相对Lucene的索引过程多了分布式数据的扩展,而这ES主要是用tranlog进行各节点之间的数据平衡. 所以从上我可以通过索引的settings进行第一优化:. 这两个参数第一是到tranlog数据达到多少条进行平衡,默认为5000,而这个过程相对而言是比较浪费时间和资源的. 所以我们可以将这个值调大一些还是设为-1关闭,进而手动进行tranlog平衡.

elasticsearch集群搭建

- - zzm
之前对于CDN的日志处理模型是从 . logstash agent==>>redis==>>logstash index==>>elasticsearch==>>kibana3,对于elasticsearch集群搭建,可以把索引进行分片存储,一个索引可以分成若干个片,分别存储到集群里面,而对于集群里面的负载均衡,副本分配,索引动态均衡(根据节点的增加或者减少)都是elasticsearch自己内部完成的,一有情况就会重新进行分配.