ES在IM组的搜索实践分享
前言—没什么用
项目用上es也有一阵子了,在统计那边不遗余力的贡献着自己的力量。IM组在去年制定的2018年度计划里,将es重构知识库存储及搜索纳入了计划。原定于9月重构的,因为各种原因,延到了10月底,终于在上周完成了搜索接口的重构。在重构过程,踩了一些坑,也听取了一些建议,最终的效果还算满意,所以拿来和大家分享一下,希望对大家以后的相关功能有所帮助。
背景—代码写诗
我们的知识库因为不是普通的对外开放的知识库(像图灵寒暄库那种),当初的定位是专业领域特定知识库,相对封闭,数据来源需要人工录入或者Excel导入。当初选的是用mysql的KnowledgeBase表来存储用户的知识库内容,用KnowledgeBase_likeQuestion来存储相似问题,并使用Sphinx来实现两个表的关联查询,crontab脚本实现Sphinx每隔10分钟会更新一次底层的Lucene索引。后续又增加了智能学习模块,但是这些东西统统治标不治本。答案就在那里,但是搜索慢和不准的问题依然突出,用户吐槽较多,付费的觉着我们欺骗了他们的感情,我整理了下原因大致如下:
- 开源的中文分词解决方案coreseek对GBK的编码支持的不如UTF-8全面和友好,且早在2011年就停止了更新,最新版本是基于sphinx 0.9.8的,而sphinx已经更新到了3.1.1。
- 当前版本无法实现动态构建过滤和查询语句,只能靠增大搜索范围,来提高准确度,数据量一大,就捉襟见肘,想要的结果一旦落在区间之外,就无法获取结果,所以不是个可靠解决方案。
- 以前靠sphinx获取主键id,然后进一步拼接成一大堆复杂的sql语句,进行一次mysql查询还不一定能取到正确结果,不仅容易造成慢查询,前台迟迟不返回结果,用户二次搜索,还会造成多余的服务记录。
Sphinx索引配置:
source sphinx_knowledgeBase
{
type = mysql
sql_host = localhost
sql_user = root
sql_pass = ******
sql_db = test
sql_port = 3306
sql_sock = /tmp/mysql.sock
sql_query_pre = SET NAMES latin1
sql_query = SELECT a.kId,a.kId as Id, a.question, a.fId, b.likeQuestion from knowledgeBase a \
left join knowledgeBase_likeQuestions b on a.kId = b.kId \
WHERE a.kId >=$start and a.kId <=$end
sql_query_range = SELECT MIN(a.kId),MAX(a.kId) FROM knowledgeBase a
sql_attr_uint = Id
sql_attr_uint = fId
}
index sphinx_knowledgeBase
{
source = sphinx_knowledgeBase
path = /sphinx/var/data/knowledgeBase
docinfo = extern
mlock = 0
morphology = none
min_word_len = 1
charset_type = zh_cn.gbk
min_prefix_len = 0
html_strip = 1
charset_dictpath = /sphinx/etc/
}
还有客户对机器人客服比较看重,提了一系列需求,在他们看来
,但是我们想去实现又实现不了的时候,只剩打人的冲动,所以我们需要一款童叟无欺的机器人。
难点—重构之前的思考
由于业务已经成型,我们想要釜底抽薪,脱胎换骨,就得有个万全之策。怎么能让用户无感知的,用上新搜索,还不影响老业务,是个挑战,也是我们最想要的结果。我整理了一下,大概有这么几个问题需要解决:
- ES如何接替Sphinx完成搜索任务?数据如何存储?如何索引?如何过滤和查询?
- 线上10w条历史数据,如何平安导入ES库?
- 项目里,生成知识库的接口,逻辑,多如牛毛?散落在各个文件中,怎么办?以后产生的新数据如何保持同步?
- ES是否有现成的易用的PHP Client SDK或者Restful API可供调用?
每个问题要在短时间内找到最优解,也不是一件简单的事儿。
方案—车到山前必有路
针对上面各个问题,我们逐个寻找合适的应对方案。
首先是数据存储方面,由于ES不像Sphinx一样可以配置两个表联查,但是可以实现两个字段联合搜索,也可以将两个表数据合并成一张表只检索一个字段,两个方案各有利弊。
- knowledge_base 知识库(只列了部分字段)
字段 | 类型 | 注解 |
---|---|---|
kId | int(11) | 主键ID |
question | varchar(245) | 问题 |
answer | text | 答案 |
agent_id | int(11) | 服务商id |
common | tinyint(1) | (1-是常见问题,0-不是常见问题) |
fId | int(11) | 分类id |
state | tinyint(1) | 状态(0-未审核1-审核) |
good | int(11) | 好评数 |
relate_ids | varchar(255) | 关联问题 |
like_master_id | int(11) | 标准问题kId |
- knowledge_base_like 相似问题
字段 | 类型 | 注解 |
---|---|---|
qId | int(11) | 主键ID |
kId | int(11) | 被关联问题主键 |
like_question | varchar(255) | 问题标题 |
1. 多个相似问题合并之后加入基础表一个字段的方案
- 优点是不多占存储空间,不用改业务逻辑,只需要同步es数据即可
- 缺点是增删改,操作过于麻烦,同步数据需要监听两个表
2. 相似问题跟标准问题合并到一起,当成标准问题对待,类型区分
- 优点是数据格式简单,只需要同步一张表,只搜索一个字段,效率高
- 缺点是会重复占用存储空间,业务改动也比较多。
长远考虑的话,还是决定牺牲空间换时间,越简单的方案,越有助于在大数量检索式提高性能。另外也是为了方便数据的更新和删除同步,同时也兼顾了下线上的实际数据量(距离亿级还差9990万)所以也没有采用ES自增id的方案,而是采用了同步mysql逐渐id的方案,减少同步业务时的查询和聚合。
其次是如何保持mysql和es数据同步问题,开源解决方案有很多,主流语言go/python/java/php都有提供解析binlog日志同步功能,由于我们的需要转码,采用了一个php的方案(卢总进行了封装)。同步逻辑使用策略模式,将各个待同步的表,分成各个类文件,自行处理待同步的字段信息,最后调用Restful批量处理接口,实现高效同步。
/**
Trigger 定义同步策略接口
Context 用来指定同步上下文对象
Knowledgebase 实现Trigger接口
*
**/
public function indexAction($param=array()) {
$table=$param['table'];
if(empty($table)){
throw new Exception("70000:数据格式非法没有表名",70000);
}
$type = $param['type'];
$className = 'Rsync\\'.ucfirst($table);
if(!class_exists($className,false)){
throw new Exception("70002:没有处理".$table.'的类',70002);
}
try{
$this->context = new Context(new $className);
$data = $param['values'];
Log::getInstance()->write_log("info", "receive data:", $data);
switch($type){
case 'write':
$rsyncRes = $this->context->insert($data);
break;
case 'update':
$rsyncRes = $this->context->update($data);
break;
case 'delete':
$rsyncRes = $this->context->delete($data);
break;
default:
break;
}
return $rsyncRes;
}catch (Exception $e){
throw new Exception("70001:$table 没有对应处理方法",70001);
}
}
再来,历史数据的导入,通过编写批处理脚本,可以支持一到多个服务商数据导入,也可以自定义每次批量导入的数量。
最后,最重要的事情是动态过滤和查询。获取ES的搜索结果,既可以通过标准Restful接口实现,也可以通过各个语言的client端实现,效率上在数据量少的时候,几乎无差别,可以自由选择。这里我们在业务处理时选用了PHP Client端,通过远程调用实现搜索。这里有些小插曲,需要讲下。
- 远程调用引擎hprose在数据传输过程中,对混合类型的数据,会转化,不能保证原始数据结构不发生变化,最好序列化一下再传输。curl接口不存在这样的问题。
- ES的查询语句多样化,很多途径都可以拿到结果,值得一提的是,
构建查询语句时,各个条件的执行顺序,直接影响搜索的执行效率,这个方面和mysql类似,最好先执行过滤,一方面是可以缩小结果集,提高检索速度,另一方面过滤条件会缓存,多次查询同类或同一个问题,可以走缓存。
意义—纸上得来终觉浅,绝知此事要躬行
问题的解决离不开思考,只有想彻底想明白了,才能少走冤枉路,另外任何的理论,即时明知道它是对的,出于对知识的敬重,也应该去亲自尝试一番,一是对它进一步验证,二是可以理解的更深,增加自己的经验。
最后,送上我们最终使用的ES过滤查询语句。
{
"from":0,
"size":10,
"query":{
"bool":{
"filter":[
{
"terms":{
"agentId":[
12878,
128789
]
}
},
{
"terms":{
"fId":[
"10",
"12"
]
}
},
{
"term":{
"state":1
}
}
],
"must":{
"match_phrase":{
"question":{
"query":"测试",
"slop":10
}
}
},
"must_not":[
{
"terms":{
"fId":[
"1",
"2",
"3"
]
}
},
{
"terms":{
"kId":[
"1",
"2",
"3"
]
}
}
]
}
},
"sort":[
{
"_score":{
"order":"desc"
}
},
{
"goodC":{
"order":"desc"
}
}
],
"highlight":{
"fields":{
"question":{
}
}
},
"_source":{
"includes":[
"kId",
"fId",
"question",
"answer",
"agent_id",
"relate_ids",
"good"
],
"excludes":[
]
}
}
实现的效果是,可以根据服务商、分类、状态、禁用分类、禁用条目,优先按短语搜索,非短语的按照单字匹配,结果先按照得分倒排,再按好评数倒排。