ES在IM组的搜索实践分享

版权声明:本文为博主原创,欢迎转载,转载请注明链接地址。 https://blog.csdn.net/qq_30164225/article/details/84038267

前言—没什么用

项目用上es也有一阵子了,在统计那边不遗余力的贡献着自己的力量。IM组在去年制定的2018年度计划里,将es重构知识库存储及搜索纳入了计划。原定于9月重构的,因为各种原因,延到了10月底,终于在上周完成了搜索接口的重构。在重构过程,踩了一些坑,也听取了一些建议,最终的效果还算满意,所以拿来和大家分享一下,希望对大家以后的相关功能有所帮助。

背景—代码写诗

越人歌

我们的知识库因为不是普通的对外开放的知识库(像图灵寒暄库那种),当初的定位是专业领域特定知识库,相对封闭,数据来源需要人工录入或者Excel导入。当初选的是用mysql的KnowledgeBase表来存储用户的知识库内容,用KnowledgeBase_likeQuestion来存储相似问题,并使用Sphinx来实现两个表的关联查询,crontab脚本实现Sphinx每隔10分钟会更新一次底层的Lucene索引。后续又增加了智能学习模块,但是这些东西统统治标不治本。答案就在那里,但是搜索慢和不准的问题依然突出,用户吐槽较多,付费的觉着我们欺骗了他们的感情,我整理了下原因大致如下:

  1. 开源的中文分词解决方案coreseek对GBK的编码支持的不如UTF-8全面和友好,且早在2011年就停止了更新,最新版本是基于sphinx 0.9.8的,而sphinx已经更新到了3.1.1。
  2. 当前版本无法实现动态构建过滤和查询语句,只能靠增大搜索范围,来提高准确度,数据量一大,就捉襟见肘,想要的结果一旦落在区间之外,就无法获取结果,所以不是个可靠解决方案。
  3. 以前靠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/
}

还有客户对机器人客服比较看重,提了一系列需求,在他们看来
love you
,但是我们想去实现又实现不了的时候,只剩打人的冲动,所以我们需要一款童叟无欺的机器人。

难点—重构之前的思考

由于业务已经成型,我们想要釜底抽薪,脱胎换骨,就得有个万全之策。怎么能让用户无感知的,用上新搜索,还不影响老业务,是个挑战,也是我们最想要的结果。我整理了一下,大概有这么几个问题需要解决:

  1. ES如何接替Sphinx完成搜索任务?数据如何存储?如何索引?如何过滤和查询?
  2. 线上10w条历史数据,如何平安导入ES库?
  3. 项目里,生成知识库的接口,逻辑,多如牛毛?散落在各个文件中,怎么办?以后产生的新数据如何保持同步?
  4. 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端,通过远程调用实现搜索。这里有些小插曲,需要讲下。

  1. 远程调用引擎hprose在数据传输过程中,对混合类型的数据,会转化,不能保证原始数据结构不发生变化,最好序列化一下再传输。curl接口不存在这样的问题。
  2. 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":[

        ]
    }
}

实现的效果是,可以根据服务商、分类、状态、禁用分类、禁用条目,优先按短语搜索,非短语的按照单字匹配,结果先按照得分倒排,再按好评数倒排。

参考

[1] Elasticsearch官方文档-近似匹配

[2] REST客户端和传输客户端效率比较

猜你喜欢

转载自blog.csdn.net/qq_30164225/article/details/84038267
IM