基于Elastic Search的搜索广告召回方案
如果你对搜索广告,竞价排序,或者Elastic Search技术感兴趣,读读这篇文章或许多少能有所收获。作者不是计算广告领域的专家,如果作为读者的你是这个方面的专家发现本文浅薄,希望留下你宝贵的意见。
因为ES版本升级很快,很多功能支持程度也伴随版本的升级而改变,本文内容基于Elastic Search 5.4.1实现。
什么是搜索广告
举个最常见的例子,当我们在淘宝上购物搜索时候,例如输入“猫粮”
在搜索结果的第一个,你会看到有个小小的广告二字,这条返回结果就是搜索广告的“杰作”了。
不同的运营平台会提供给商家后台采买关键词,设置出价和匹配模式等。当用户发起搜索时,根据规则,首先召回采买关键词的商家,然后对这些召回商家排序,返回广告商家。
一般来说,这类广告的收费模式都是按照点击收费(CPC),所以排序肯定不能按照单纯的价高者得。因为即使商家出价再高,但是由于相关度和商家质量问题,而无人点击,平台依然没有任何营收,既浪费了平台流量,也没有给商家贡献转化。普遍来说,对于CPC广告,排序一般基于商户出价Bid * 预估CTR(点击率)。排序在计算广告中占据着举足轻重的地位,提高AUC,CTR等指标,也让无数青年才俊掉了不少头发。不过排序并不是本文介绍的重点,如果你感兴趣,可以搜索LR,GBDT,FM,OCPC等关键词,相信你会有很多的收获。如果有机会,笔者也希望可以写机器学习相关的文章,本文主要介绍搜索广告的召回部分的实现。
文档
每一条商户关键词的出价是一个文档,JSON描述如下:
{
"id":123456
"weight": 201,
"biding": "天润酸奶",
"lon": 117.60715739693345,
"shopId": 400,
"matchMode": "SpitContain",
"lat": 27.555006197000644,
"open": true
}
其中 id 代表推广计划ID,weight 是商家出价,biding 是商户出价的关键词,lon,lat 描述商户地理坐标,open 描述店铺当前状态。matchMode 是商户设置的匹配模式,匹配模式 的含义是,只有在用户搜索词和出价的关键词 之间的匹配满足一定条件的时候,才会生效(不能仅仅一直想完全一样的情况哦)。在不同业务场景下,文档需要的数据是不同的。
笔者提供了一个简单的Python程序可以生成一些测试文档,并索引到ES中,需要的朋友可以到这里下载 测试数据生成器,该程序会生成50万商家的2500万条采买记录,关键词词库含有2万条关键词。
匹配模式
笔者定义了四种搜索词和关键词匹配模式:
具体定义如下:
设定 Q 为用户搜索(Query),K 为商户出价的关键词(Keyword)
精准匹配:Q = K
例:糯米饭(Q) = 糯米饭(K)
精准包含:Q 是 K的子串
例:芒果(Q)= 芒果糯米饭(K)
短语包含:T 是 K的子串,其中 T 是 Q 的任意分词词项(Term)
例:芒果糯米饭(Q) = 芒果西米露(K),芒果糯米饭 的分词词项:芒果、糯米饭,其中 芒果 是关键词 芒果西米露 的子串。
模糊匹配:S是K的子串,其中S是T的同义词
例:麻辣黄闷鸡(Q)= 黄焖鸡米饭(K),麻辣黄闷鸡 分词为:麻辣、黄闷鸡,黄闷鸡的同义词为黄焖鸡(S),而S是K的子串。
关于匹配模式的工作模式可以举个例子
所以召回要解决两个问题:
a. 支持基于关键词文档的全文检索
b. 支持匹配模式
基于Elastic Search的搜索召回
召回的所有逻辑也可以使用Lucene拓展编写,好处是可以高度整合业务逻辑到索引,缺点是开发成本高。本文将采用Elastic Search作为搜索召回引擎,ES的默认配置,远远不能实现我们的需要的功能,所以需要做一些额外工作。
a. 中文和行业词库的扩展(本文采用美食词汇)
b. 同义词模糊匹配支持
c. 基于匹配模式的过滤器
1. 中文索引和行业词库扩展
这里我们使用到了大名鼎鼎的 IK - Analyzer 插件,IK的目录中存在
config/custom/mydict.dic
文件,把相关的行业词汇放入其中即可。验证如下:
curl -XGET 'localhost:9200/_analyze?pretty' -d '{
"analyzer":"ik_smart",
"text":"附近哪里有黄焖鸡米饭或者腾冲大救驾"
}'
分词结果如下
{
"tokens" : [
#省略若干.......
{
"token" : "黄焖鸡米饭",
"start_offset" : 5,
"end_offset" : 10,
"type" : "CN_WORD",
"position" : 3
},
{
"token" : "或者",
"start_offset" : 10,
"end_offset" : 12,
"type" : "CN_WORD",
"position" : 4
},
{
"token" : "腾冲大救驾",
"start_offset" : 12,
"end_offset" : 17,
"type" : "CN_WORD",
"position" : 5
}
]
}
可见 黄焖鸡米饭 和 腾冲大救驾 已经作为单独的词汇被识别出来了。
2. 同义词匹配支持
ES是支持同义词逻辑的,不过需要一些配置,这个配置可以在创建索引的时候指定。
curl -XPUT 'http://localhost:9200/search_ad_index' -d '{
"settings": {
"analysis": {
"filter": {
"my_synonym_filter": {
"type": "synonym",
"synonyms_path":"analysis/synonym.txt"
}
},
"analyzer": {
"ik_syno": {
"type":"custom",
"tokenizer": "ik_smart",
"search_analyzer": "ik_smart",
"filter": [
"lowercase",
"my_synonym_filter"
]
}
}
}
}
}'
同时还要把同义词库定义在如下文件
config/analysis/synonym.txt
为了说明问题,本文定义了一个很简单的同义词库
黄闷鸡,黄梦鸡,huangmenjimifan,huangmenji,黄焖鸡,黄焖鸡米饭
Dongyingong,冬阴功,冬阴功汤
然后在 biding 字段,配置支持同义词的Analyzer
curl -XPOST 'http://localhost:9200/search_ad_index/shop_keyword/_mapping' -d '
{
"properties": {
"biding": {
"type": "text",
"analyzer": "ik_syno",
"search_analyzer": "ik_syno"
}
}
}'
现在我们索引一条文档,然后测试一下同义词是否生效
curl -XPOST 'localhost:9200/search_ad_index/shop_keyword/1' -d '{
"weight" : 201,
"biding" : "黄焖鸡米饭",
"lon" : 117.60715739693345,
"shopId" : 400,
"matchMode" : "SpitContain",
"lat" : 27.555006197000644,
"open" : true
}'
搜索脚本如下:
curl -XPOST 'localhost:9200/search_ad_index/shop_keyword/_search?pretty' -d '{
"query":{
"match":{
"biding":"huangmenji"
}
}
}'
召回结果如下:
{
"took" : 3,
"timed_out" : false,
"_shards" : {
"total" : 5,
"successful" : 5,
"failed" : 0
},
"hits" : {
"total" : 1,
"max_score" : 0.58874476,
"hits" : [
{
"_index" : "search_ad_index",
"_type" : "shop_keyword",
"_id" : "1",
"_score" : 0.58874476,
"_source" : {
"weight" : 201,
"biding" : "黄焖鸡米饭",
"lon" : 117.60715739693345,
"shopId" : 400,
"matchMode" : "SpitContain",
"lat" : 27.555006197000644,
"open" : true
}
}
]
}
}
我们在同义词库定义了,huangmenji = 黄焖鸡 = 黄焖鸡米饭
用huangmenji做搜索词,召回了 biding = 黄焖鸡米饭 的文档,说明同义词已经被ES支持了。
3. 基于匹配模式的过滤器
既然ES已经支持了同义词和行业词汇分词,那么已经满足了匹配模式中最广泛的模糊匹配,基于模糊匹配的返回结果,把不满足匹配模式的文档过滤掉,就获得满足业务的结果了。笔者用一个例子说明 多匹配模式 的支持过程。
首先,使用ES的模糊搜索,获得所有匹配的文档,如 标记①所示,简化文档记录为:
商户ID,商户出价关键词,商户设置的匹配模式。例如第一条记录商户1002,Bid了关键词芒果西米露,但是只有在用户搜索和关键词精准匹配的时候,才生效。
基于①返回的文档,可以在内存中过滤,只不过笔者不希望ES返回太多文档,增加网络负担也可能将有用的文档截取掉,所以笔者用ES的Java Plugin实现了一个 PostFilter,可以在 ES 匹配文档后返回结果前,过滤掉不符合规则的文档,基于性能的考虑使用Java语言实现原生的Plugin。
这个PostFilter需要传入用户搜索 Q ,和基于用户搜索的分词列表 T_s ,他是分词项 T 的数组。PostFilter的伪代码如下:
IF doc.matchMode is 精准匹配 Then
return Q equalTo doc.biding
ELSE IF doc.matchMode is 精准包含 Then
return doc.biding substring Q
ELSE IF doc.matchMode is 分词包含 Then
return doc.biding substring T
ELSE
return true
②展示了过滤器工作的结果,那些不满足商户匹配模式的关键词条目被打分为 0, 将被过滤掉,而满足条件的被打分 1, 将在结果中保留。
③是经过过滤器后,返回的最终文档集合。
笔者把 PostFilter 的代码托管在 GitHub 上,可以在这里找到: MatchModePostFilter 需要实验的朋友,可以 Maven package 生成 .zip 文件,解压后放入 ES_HOME/plugins目录下即可生效。
将可以实现功能的上文所云,浓缩到一条搜索脚本如下:
POST /search_ad_index/_search?pretty
{
"size":100,
"query": {
"bool": {
"must": {
"match": {
"biding": "麻辣香锅冒菜" #用户查询
}
},
"filter": [ #其他业务过滤器,可以自己定义
{
"range": {
"lat": {
"gt": 31.5,
"lt": 32.6
}
}
},
{
"range": {
"lon": {
"gt": 118.3,
"lt": 119.4
}
}
},
{
"term": {
"open": true
}
}
]
}
},
"post_filter": {
"script": {
"script": {
"inline": "match_mode_scoring", #指定原生脚本名
"lang": "native",
"params": {
"query": "麻辣香锅冒菜", #用户查询
"tokens": "麻辣香锅;冒菜" #用户查询分词
}
}
}
}
}
部分返回结果如下:
{
"_index": "fuzzy_search_ad",
"_type": "shop_keyword",
"_id": "1",
"_score": 12.347956,
"_source": {
"weight": 139,
"biding": "麻辣香锅冒菜",
"lon": 118.31,
"shopId": 122,
"matchMode": "Exact",
"lat": 31.51,
"open": true
}
},
{
"_index": "fuzzy_search_ad",
"_type": "shop_keyword",
"_id": "2660337",
"_score": 6.4009247,
"_source": {
"weight": 338,
"biding": "蛋蛋麻辣香锅",
"lon": 119.21255552940255,
"shopId": 53206,
"matchMode": "Fuzzy",
"lat": 31.71452073144111,
"open": true
}
},
{
"_index": "fuzzy_search_ad",
"_type": "shop_keyword",
"_id": "3895216",
"_score": 6.3706484,
"_source": {
"weight": 196,
"biding": "蛋蛋麻辣香锅",
"lon": 118.58495088376293,
"shopId": 77904,
"matchMode": "Fuzzy",
"lat": 31.94751527254444,
"open": true
}
}
结束语
到这里,关于搜索广告文档召回就介绍到这里了。基于ES或者Lucence(ES的倒排索引实现)我们可以很轻易的实现倒排索引,如果深入挖掘还能实现很多制定化的需求。