为了提高搜索命中率和准确率,改善现有羸弱的搜索功能,公司决定搭建全文搜索服务。由于之前缺乏全文搜索使用经验,经过一番折腾,终于不负期望按期上线。总结了一些使用心得体会,希望对大家有所帮助。计划分三篇:
- 第一篇(使用篇),主要讲解基本概念、分词、数据同步、搜索API。
- 第二篇(配置及参数调优篇),主要围绕JVM参数调优、异常排查、安全性等方面讲解。
- 第三篇(倒排索引原理篇),知其然知其所以然。
一、技术选型
说到全文搜索大家肯定会想到solr和elasticsearch(以下简称es),两者都是基于lucence,到底有什么区别呢?主要列出四个方面:
对比项 | solr | elasticsearch |
分布式 | 利用zookeeper进行分布式协调 | 自带分布式协调能力 |
数据格式 | 支持更多的数据格式(XML、JSON、CSV等) | 仅支持JSON |
查询性能 | 更适合偏传统的搜索应用,单纯对已有数据进行搜索性能更高,但实时建立索引时查询性能较差。 | 在实时搜索应用中表现更好,数据导入性能更好 |
数据量对查询性能影响 | 明显下降 | 影响不大 |
最终选择es,主要原因:
- 作为后起之秀,吸收了solr的优秀设计,在实时搜索上性能更佳,大有超越solr之势。
- 社区非常活跃,文档齐全,越来越多的应用从solr迁移至es。典型案例较多:GitHub使用es来检索超过1300亿行代码、Wikipedia 使用es提供带有高亮片段的全文搜索。
二、基本概念
- 集群(cluster)和节点(node):一个集群里包含多个节点,其中一个主节点通过选举产生,集群中任一节点的通信与整个es集群通信是等价的。
- 索引(index):es包含一个或多个索引,相当于关系型数据库(以下简称RDS)里的数据库,可以向索引里写入或读取数据。
- 类型(type):一个索引包含一个或多个type,相当于RDS里的表。
- 文档(document):相当于RDS里的数据行,文档没有固定的格式(schemaless),与mongodb很类似。
- 分片(shards):可以把一个大索引拆分成多个分片,分布到不同的节点上,提高检索效率。分片数在创建索引时确定,无法更改。
- 副本(replicas):副本有两个作用,一是增加容错,当某个分片损坏或丢失时可以由其他副本恢复;二是增加系统负载,当搜索流量增加可以通过动态增加副本来满足要求。
- 倒排索引(inverted index):由文档中所有不重复词的列表构成,对于其中每个词,有一个包含它的文档列表。倒排索引时lucence核心数据存储结构。
三、中文分词
3.1、分词器选型
默认分词器对英文支持较好,但对中文不友好,会把中文拆分成一个个汉字,这显然不满足需求。
市面上中文分词器不少,该如何选择,主要考虑以下几点:
- 自带默认词库,支持自定义词库扩展。
- 词库支持热更新(不重启es服务,自动生效)。
- 社区活跃,使用较广,分词效果好。
基于以上几点,很容易想到IK分词器,IK提供了两种分词模式:
分词模式 | 描述 |
ik_max_word | 会将文本做最细粒度的拆分,比如会将“中华人民共和国国歌” 拆分为“中华人民共和国,中华人民,中华,华人,人民共和国,人民,人,民,共和国,共和,和,国国,国歌”, 会穷尽各种可能的组合 |
ik_smart | 会做最粗粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,国歌” |
IK分词器项目地址:https://github.com/medcl/elasticsearch-analysis-ik
3.2、词库更新
分词是否合理直接影响搜索结果的精确度,因此词库的更新尤为重要,由于es服务刚刚搭建完成,存在以下几个问题:
- 词库更新不便捷、不及时。词库虽然支持热更新,但是需要DBA操作,产品和运营人员无法自行更新。
- 自定义词库相对单一。目前只有疾病库。
- 线上由于分词不当影响搜索结果的比例不低。举个例子:用户搜索“浙二医院”,显然是想搜“浙大医学院附属第二医院”,但是现有词库利用ik_smart模式拆分成“浙”、“二医院”两个词,显然不符合需求。
- 重建索引不方便。由于词库更新后需要重建索引才能使已有数据按照新的词库分词,目前也是需要DBA手动操作,增加了风险。
针对以上问题,提出了几个解决方案,后续逐步优化解决:
- 某些专有名称(医生姓名、医院科室名称等)自动实时更新。
- 定期人为扩充词库,例如医院别名、科室别名、疾病症状等。
- 定期分析用户搜索记录,发现新词。
- 运营后台增加词库更新和重建索引功能,支持产品和运营人员自行维护词库。
抛出一个问题:由于词库更新后需要重建索引才能使已有数据按照新的词库分词,在数据量较小的情况下没有问题,一旦数据达到一定量级,重建索引的成本较高。百度这种量级的数据是如何应对词库更新的呢?可在评论区留言一起探讨。
四、数据同步
4.1、数据同步方式选择
这里的数据同步是指将数据从mysql同步到es。主要有几种方式:
- 调用es提供的api同步。这种方式最灵活、最实时,但是有一定的编码成本,主要适用于对索引数据实时性要求较高的场景。
- 同步工具。开源的同步工具也不少,主要有两种模式:
模式描述 | 代表 | 优点 | 缺点 |
服务定期扫表,通过时间戳字段实现同步 | logstash | 支持全量和增量同步,索引重建更方便 | 存在一定数据延迟,最少一分钟同步一次,且无法感知sql的delete操作 |
将自身伪装成mysql从库,监控binlog日志实现同步 | go-mysql-elasticsearch | 实时性较高 | 全量同步较困难,增加mysql服务器的同步成本 |
结合实际情况,会有定期重建索引需求,线上数据只允许逻辑删除,且对数据实时性要求并不高,公司的日志平台是通过logstash实现的日志收集,故选择logstash。
4.2、现有同步方式
公司正在做微服务拆分,且索引往往涉及多条业务线的数据。拿商品举例,主要包含基本信息(实时性要求较高)、统计数据(商品购买量、评论量、浏览量等,实时性要求不高)。所以最终决定借助大数据平台,实时数据10分钟做一次增量同步,统计数据一天一次同步,数据整理成宽表吐到mysql库,然后利用logstash将数据同步到es。
五、搜索API
搜索是全文索引的核心,下面列出了一些常用的搜索模式,为了便于理解,下面将各搜索语句类比成sql。
5.1、基本搜索(搜索骨架)
- Query。使用Query DSL(Domain Specific Language领域特定语言)定义一条搜索语句。
- From/Size。分页搜索,类似sql的limit子句。
- Sort。排序,支持一个或多个字段,类似sql的order by子句。
- Sourcing Filter。字段过滤,支持通配符,类似sql的select字段。
- Script Fields。使用脚本基于现有字段虚构出字段。例如索引里包含first name和second name两个字段,使用Script Fields可以虚构出一个full name是first name和second name的组合。
- Doc Value Fields。字段格式化,例如Date格式化成字符串,支持自定义格式化类型。
- Highlighting。高亮。
- Rescoring。再评分,仅对原始结果的Top N(默认10)进行二次评分。
- Explain。执行计划,主要列出文档评分的过程。类似mysql的explain查看执行计划。
- Min Score。指定搜索文档的最小分值,实现过滤。
- Count。返回符合条件的文档数量。
- ...
5.2、核心搜索(Query DSL)
如果说上面的基本搜索类比成整条sql语句的骨架,那么Query DSL就是where条件,主要有以下几种类型语句:
- 全文搜索(Full Text Query)。文档地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/full-text-queries.html
类型 | 描述 |
Match Query | 全文模糊匹配 |
Match Phrase Query | 短语匹配,和Match Query类似,但要求索引词的先后顺序与输入搜索词的顺序一致。完全一致条件似乎比较严苛,可通过slop参数控制短语相隔多久也能匹配。 |
Match Phrase Prefix Query | 与短语匹配一致,支持在输入文本的最后一个词项上的前缀匹配,常用于根据用户输入的即时查询,例如淘宝搜索框输入关键字后的下拉展示。 |
Multi Match Query | 多字段搜索。包含以下几种模式: |
... |
- 词条搜索(Term Level Query)。文档地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/term-level-queries.html
类型 | 描述 |
Term Query | 术语精确搜索,将关键字当成一个词来处理。 1、如果字段为keyword类型,即是字段的精确匹配。 2、如果字段为text类型,则仅当搜索词按ik_smart模式分词后只得到一个词的情况下才有可能搜索到文档。 |
Terms Query | 同上,允许入参多个词。 |
Range Query | 范围搜索,常用语数值和时间格式。类似sql的between子句。 |
Exists Query | 搜索包含指定字段的文档。 |
Prefix Query | 前缀搜索,常用于实现下拉框输入的即时搜索。 |
Wildcard Query | 通配符搜索。通过通配符匹配词条。 |
Regexp Query | 正则表达式搜索。通过正则表达式匹配词条。 |
... |
- 组合搜索(Compound Query)。主要是对以上搜索语句的各种组合,主要介绍Bool Query和Function Score Query,文档地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/compound-queries.html
模式 | 描述 | 参数介绍 |
Bool Query | 布尔搜索,由一个或多个类型化的Bool子句构成 | must:用于搜索命中文档,条件组合是“and”关系,并且影响评分。 |
Function Score Query | 自定义函数评分搜索 | score_mode:自定义函数分值计算模式,包含 Multiply(相乘)、Sum(求和)、Avg(平均)、First(第一个)、Max(最大)、Min(最小)。 boost_mode:搜索结果分值与自定义函数分值结合得到最终分值的模式,包含 Multiply(相乘)、Replace(仅使用函数分值)、Sum(求和)、Avg(平均)、Max(最大)、Min(最小)。 field_value_factor:字段值因素,例如文章阅读量、评论量影响分值。 其他:Weight(权重)、Decay functions(衰变函数)、Random score(随机评分) |
总结:以上对各种搜索模式做了简单介绍,每种模式里都包含一些搜索参数,没有具体展开。开发过程中往往需要结合实际情况,利用各种模式,设置搜索参数,配置字段权重,调优自定义函数分值,最终得到比较理想的搜索结果。
5.3、示例实战
Talk is cheap, show me the code。
1 GET doctor_index/doctor_info/_search 2 { 3 "query1": {
4 "function_score": { 5 "query": { 6 "bool5": { 7 "must6": [ 8 { 9 "multi_match7": { 10 "query": "张内科", 11 "fields": [ 12 "doctor_name^2", 13 "department_name^1.2", 14 "doctor_skill^0.8", 15 "institution_name^1.4" 16 ], 17 "type8": "cross_fields", 18 "operator9":"and", 19 "analyzer10": "ik_smart" 20 } 21 } 22 ], 23 "must_not11": [ 24 { 25 "term12": { 26 "doctor_is_del": { 27 "value": "1" 28 } 29 } 30 } ] 39 } 40 }, 41 "functions13": [ 42 43 { 44 "script_score14": { 45 46 "script": { 47 "source15": "return Math.log(_score)/Math.log(2);" 48 } 49 } 50 }, { 65 "script_score": { 66 "script": { 67 "source": "String doctorProfessional = doc['doctor_professional'].value; if (doctorProfessional == '主任医师') { return 1; } else if (doctorProfessional == '副主任医师') { return 0.8; } else if (doctorProfessional == '主治医师') { return 0.6; } else if (doctorProfessional == '住院医师') { return 0.4; } return 0;" 68 } 69 } 70 } ], 86 "boost_mode16": "replace", 87 "score_mode17": "sum" 90 }, 91 "min_score2":3, 92 "sort3": [ 93 { 94 "_score": { 95 "order": "desc" 96 } 97 }, 98 { 99 "doctor_name": { 100 "order": "desc" 101 } 102 } 103 ], 104 "explain4": true 105 }
分析如下:
- 1、定义一个Function Score Query子句。
- 2、指定筛选文档的最低分值为3。
- 3、文档优先按分值降序排,分值相同的情况下按doctor_name降序排。
- 4、展示评分过程的执行计划。
- 5、定义Bool Query的组合搜索模式。
- 6、定义Bool Query的must子句。
- 7、定义多字段搜索,搜索关键字“张内科”,搜索字段:doctor_name权重2、department_name权重1.2、doctor_skill权重0.8、institution_name权重1.4。
- 8、定义多字段搜索类型为cross_fields,将以上四个字段合并成一个大字段处理。
- 9、定义关键字and搜索,即只有分词后多字段同时出现才满足命中条件。
- 10、定义使用ik_smart分词模式拆分搜索词。
- 11、定义Bool Query的must_not子句。
- 12、过滤掉doctor_is_del=1的文档。
- 13、定义具体的自定义函数数组。
- 14、定义一条评分规则。
- 15、定义评分函数逻辑,将Query计算后的分值做对数运算。
- 16、指定使用自定义函数分值作为文档的最终分值。
- 17、指定多个自定义函数使用相加的方式计算分值。
一句话解释:使用自定义函数搜索模式,定义Bool组合搜索条件,将doctor_name等四个字段按照不同的权重组合成一个大字段,搜索同时满足“张内科”关键字按照ik_smart分词后的结果,将关键字搜索得到的分值取对数后加上医生职称的分值作为最终分值,然后过滤掉doctor_is_del=1和分值小于3分的文档,最后按照最终分值和doctor_name两个字段降序排列,默认取10条记录,并且展示分值计算过程。
是不是觉得很酸爽,这是提条相对复杂的语句,细细体会。
5.4、评分机制
评分计算主要跟以下三个因素相关:
- 词频。词在文档中出现的次数越多,分值越高。
- 逆向文档频率。词在所有文档里出现的频率越高,分值越低。
- 字段长度归一值。字段长度越短,分值越高。
5.5、其他API
es还提供了其他强大的API功能,在此就不一一赘述了,例如:
- 文档管理API
- 索引管理API
- 聚合搜索API
- 集群信息API
六、开发流程
建议使用官方推荐的RestHighLevelClient SDK按照以下流程开发。