elasticsearch实现联想输入搜索(like操作)
通常,在项目中需要联想输入(即输入关键字,提示相关词条,类似百度google的搜索)的需求,可能大家都是用的数据库的like '%关键字%‘来实现。但是这样实现有几个问题。
第一、这样的搜索无论是oracle还是mysql,都是无法使用索引的。在oracle中可能有全文检索可以使用,但是个人感觉效果不是很好。
第二、输入的关键字有like的通病,就是只有保含关键字的词条才会被命中。如果中间加个空格之类的,db就无能为力了。
第三、如果要想对命中结果进行相关度排序,这个在常规数据库是无法做到的。虽然,可以按照命中词条的长度进行升序排序,但是加上排序,性能不是很好。
下面介绍一下使用elasticsearch实现联想输入的搜索,因为是搜索引擎,天生就不具备上面的3个问题。
在具体介绍使用方法之前,我们先找个搜索数据。我找的是ICD(就是疾病名称的国标),谁让咱一生都在跟他做斗争。这个在网上一搜一堆。
有了数据,我们先要简单描述一下我们要达到的一个目的。一般的搜索都支持汉字 和拼音两种检索方法。我们的这个检索也满足这个需求。
搜索需求描述:
1、支持汉字和简拼两种搜索方法。
2、输入“高血压”时,按照相关度,将带“高血压”名称的疾病名称按照相关度降序排序。
3、输入“老年 高血压”,时,将带“老年”和“高血压”名称的疾病名称按照相关度降序排序。
4、输入拼音'gxy‘时,将拼音中带有gxy相关的疾病按照相关度降序排序。
....
类似测试用例的需求,到此打住。
那么,我们一步一步实现这种需求。
首先,我们定义了一个ICD的类,算作我们的模型,其实没有模型也可以,只要存入到es且知道各个field的名称就行。这个里面我们只需要关注疾病名称diseaseName及简拼pinyin字段即可,这个字段默认是字符串,ES默认会帮我们分词。
import java.io.Serializable; import java.math.BigDecimal; /** * ICD抽象对象 * @author donlianli@126.com */ public class ICD implements Serializable{ private static final long serialVersionUID = 6934803011248581109L; //疾病ID private int id; //疾病编码 private String code; //疾病名称 private String diseaseName; //疾病加拼音 private String mergeName; //汉语拼音简拼 private String pinyin; //是否恶心肿瘤 private boolean isTherioma; //是否住院特殊病种 private boolean isSpecialDisease; public ICD(BigDecimal id, String diseaseName, String code, String pinyin, String isTherioma, String isSpecialDisease) { this.id = id.intValue(); this.diseaseName = diseaseName; this.code = code; this.pinyin = pinyin; if("是".equals(isTherioma)){ this.isTherioma = true; } else { this.isTherioma = false; } if("是".equals(isSpecialDisease)){ this.isSpecialDisease = true; } else { this.isSpecialDisease = false; } this.mergeName = diseaseName + "," + pinyin; } //set,get ...... }
第二步,将数据存储到elasticsearch里面,我们取个名称叫code,起个type名称叫icd。ICD大概2w条数据,我使用默认的bulkIndex,存到es大概用了3秒。
我这里是把数据从oracle导入到elasticsearch。
import java.math.BigDecimal; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.util.ArrayList; import java.util.List; import org.elasticsearch.action.bulk.BulkRequestBuilder; import org.elasticsearch.action.bulk.BulkResponse; import org.elasticsearch.action.index.IndexRequestBuilder; import org.elasticsearch.client.Client; import com.donlianli.es.ESUtils; import com.donlianli.es.db.DatabaseUtils; public class ICDManager { public static void main(String[] argvs){ ICDManager manager = new ICDManager(); manager.indexDataDirect(); } /** * 直接将数据初始化到ES中 * 不创建mapping */ private void indexDataDirect() { List<ICD> icdList = getIcdListFromDB(); System.out.println(" get icd from db finish,size:" + icdList.size()); bulkIndex(icdList); } private void bulkIndex(List<ICD> icdList) { Client client = ESUtils.getCodeClient(); BulkRequestBuilder bulkRequest = client.prepareBulk(); long b = System.currentTimeMillis(); for(int i=0,l=icdList.size();i<l;i++){ //业务对象 ICD icd = icdList.get(i); String json = ESUtils.toJson(icd); IndexRequestBuilder indexRequest = client.prepareIndex("code","icd") .setSource(json).setId(String.valueOf(icd.getId())); //添加到builder中 bulkRequest.add(indexRequest); } BulkResponse bulkResponse = bulkRequest.execute().actionGet(); if (bulkResponse.hasFailures()) { System.out.println(bulkResponse.buildFailureMessage()); } long useTime = System.currentTimeMillis()-b; System.out.println("useTime:" + useTime); } private List<ICD> getIcdListFromDB() { Connection conn = DatabaseUtils.getOracleConnection(); String sql = "select * from icd_11"; PreparedStatement st = null; ResultSet rs = null; List<ICD> list = new ArrayList<ICD>(); try{ st = conn.prepareStatement(sql); rs = st.executeQuery(); while(rs.next()){ BigDecimal id = rs.getBigDecimal("ID"); String diseaseName = rs.getString("DISEASE_NAME"); String code = rs.getString("CODE"); String pinyin = rs.getString("PINYIN"); String isTherioma = rs.getString("THERIOMA_FLAG"); String isSpecialDisease = rs.getString("OTHER_FLAG"); list.add(new ICD(id,diseaseName,code,pinyin,isTherioma,isSpecialDisease)); } return list; } catch(Exception e){ e.printStackTrace(); } finally{ try{ if(rs!= null){ rs.close(); } if(st!= null){ st.close(); } conn.close(); } catch(Exception e){ e.printStackTrace(); } } return null; } }
第三步,搜索接口,跑测试用例。
import org.elasticsearch.action.search.SearchResponse; import org.elasticsearch.client.Client; import org.elasticsearch.index.query.MultiMatchQueryBuilder; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.search.SearchHit; import org.elasticsearch.search.SearchHits; import com.donlianli.es.ESUtils; public class PinyinSearchTest { public static void main(String[] args) { Client client = ESUtils.getCodeClient(); String keyWord = "高血压"; // String keyWord = "老年 高血压"; // String keyWord = "gxy"; //多个字段匹配 MultiMatchQueryBuilder query = QueryBuilders.multiMatchQuery(keyWord, "diseaseName","pinyin"); long b = System.currentTimeMillis(); SearchResponse response = client.prepareSearch("code").setTypes("icd") .setQuery(query) .setFrom(0) //前20个 .setSize(20) .execute().actionGet(); long useTime = System.currentTimeMillis()-b; System.out.println("search use time:" + useTime + " ms"); SearchHits shs = response.getHits(); for (SearchHit hit : shs) { System.out.println("分数:" + hit.getScore() + ",ID:" + hit.getId() + ", 疾病名称:" + hit.getSource().get("diseaseName") + ",拼音:" + hit.getSource().get("pinyin")); } client.close(); } }
3.1,关键字:'高血压'
search use time:174 ms 分数:2.3859928,ID:6904, 疾病名称:高血压病,拼音:gxyb 分数:2.136423,ID:6907, 疾病名称:高血压I期,拼音:gxyyq 分数:2.12253,ID:6908, 疾病名称:高血压Ⅱ期,拼音:gxyeq 分数:2.12253,ID:6910, 疾病名称:高血压危象,拼音:gxywx 分数:2.0906634,ID:6917, 疾病名称:肾性高血压,拼音:sxgxy 分数:2.0877438,ID:6909, 疾病名称:高血压Ⅲ期,拼音:gxysq 分数:2.0821526,ID:18767, 疾病名称:高原性高血压,拼音:gyxgxy 分数:1.9905697,ID:6906, 疾病名称:恶性高血压,拼音:exgxy 分数:1.9510978,ID:7260, 疾病名称:高血压脑出血,拼音:gxyncx 分数:1.9078629,ID:6923, 疾病名称:肾血管性高血压,拼音:sxgxgxy 分数:1.8312198,ID:6914, 疾病名称:高血压性肾病,拼音:gxyxsb 分数:1.8193114,ID:7367, 疾病名称:高血压性脑病,拼音:gxyxnb 分数:1.8193114,ID:13470, 疾病名称:妊娠引起高血压,拼音:rsyqgxy 分数:1.7919972,ID:6905, 疾病名称:临界性高血压,拼音:ljxgxy 分数:1.7919972,ID:6912, 疾病名称:高血压性心脏病,拼音:gxyxxzb 分数:1.7894946,ID:6928, 疾病名称:继发性高血压,拼音:jfxgxy 分数:1.7062025,ID:6913, 疾病名称:高血压性肾衰竭,拼音:gxyxssj 分数:1.7062025,ID:13485, 疾病名称:孕产妇高血压,拼音:ycfgxy 分数:1.7062025,ID:14534, 疾病名称:新生儿高血压,拼音:xsegxy 分数:1.7062025,ID:16181, 疾病名称:应激性高血压,拼音:yjxgxy
3.2关键字:'老年 高血压'
search use time:144 ms 分数:1.1089094,ID:6904, 疾病名称:高血压病,拼音:gxyb 分数:0.99291986,ID:6907, 疾病名称:高血压I期,拼音:gxyyq 分数:0.9864628,ID:6908, 疾病名称:高血压Ⅱ期,拼音:gxyeq 分数:0.9864628,ID:6910, 疾病名称:高血压危象,拼音:gxywx 分数:0.9716526,ID:6917, 疾病名称:肾性高血压,拼音:sxgxy 分数:0.97029567,ID:6909, 疾病名称:高血压Ⅲ期,拼音:gxysq 分数:0.96769714,ID:18767, 疾病名称:高原性高血压,拼音:gyxgxy 分数:0.9251333,ID:6906, 疾病名称:恶性高血压,拼音:exgxy 分数:0.9067884,ID:7260, 疾病名称:高血压脑出血,拼音:gxyncx 分数:0.8866946,ID:6923, 疾病名称:肾血管性高血压,拼音:sxgxgxy 分数:0.8510741,ID:6914, 疾病名称:高血压性肾病,拼音:gxyxsb 分数:0.8455395,ID:7367, 疾病名称:高血压性脑病,拼音:gxyxnb 分数:0.8455395,ID:13470, 疾病名称:妊娠引起高血压,拼音:rsyqgxy 分数:0.8328451,ID:6905, 疾病名称:临界性高血压,拼音:ljxgxy 分数:0.8328451,ID:6912, 疾病名称:高血压性心脏病,拼音:gxyxxzb 分数:0.831682,ID:6928, 疾病名称:继发性高血压,拼音:jfxgxy 分数:0.8074301,ID:6820, 疾病名称:老年耳聋,拼音:lnel 分数:0.80348647,ID:7612, 疾病名称:老年痣,拼音:lnz 分数:0.7929714,ID:6913, 疾病名称:高血压性肾衰竭,拼音:gxyxssj 分数:0.7929714,ID:13485, 疾病名称:孕产妇高血压,拼音:ycfgxy
高血压和老年的相关并都出来了。只可惜老年高血压,没有列入ICD.
3.3拼音:'gxy'
呃?怎么没有出来?
这个问题折腾了我一天。一开始我以为是被es列入了禁用词。后来,找到是因为没有设置analyzer导致,在设analyzer的过程中竟然还犯了好几个低级错误,导致我非常怀疑设置analyzer是否管用。
这个问题涉及到分词,而分词我还没有好好研究过。总之,在创建索引及mapping的时候,指定一个analyzer就可以解决这个问题。
创建index及mapping的代码如下:
import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; import org.elasticsearch.action.admin.indices.create.CreateIndexResponse; import org.elasticsearch.client.Client; import org.elasticsearch.common.settings.ImmutableSettings; import org.elasticsearch.common.settings.ImmutableSettings.Builder; import org.elasticsearch.common.xcontent.XContentBuilder; import com.donlianli.es.ESUtils; /** * 创建code的mapping * @author donlianli@126.com */ public class CodeMappingTest { static final String INDEX_NAME="code"; static final String TYPE_NAME="icd"; public static void main(String[] argv) throws Exception{ Client client = ESUtils.getCodeClient(); Builder settings = ImmutableSettings.settingsBuilder() .loadFromSource(getAnalysisSettings()); //首先创建索引库 CreateIndexResponse indexresponse = client.admin().indices() //这个索引库的名称还必须不包含大写字母 .prepareCreate(INDEX_NAME).setSettings(settings) //这里直接添加type的mapping .addMapping(TYPE_NAME, getMapping()) .execute().actionGet(); System.out.println("success:"+indexresponse.isAcknowledged()); } private static String getAnalysisSettings() throws Exception { XContentBuilder mapping = jsonBuilder() .startObject() //主分片数量 .field("number_of_shards",5) .field("number_of_replicas",0) .startObject("analysis") .startObject("filter") //创建分词过滤器 .startObject("pynGram") .field("type","nGram") //从1开始 .field("min_gram",1) .field("max_gram",15) .endObject() .endObject() .startObject("analyzer") //拼音analyszer .startObject("pyAnalyzer") .field("type","custom") .field("tokenizer","standard") .field("filter", new String[]{ "lowercase","pynGram"}) .endObject() .endObject() .endObject() .endObject(); System.out.println(mapping.string()); return mapping.string(); } /** * mapping 一旦定义,之后就不能修改。 * @return * @throws Exception */ private static XContentBuilder getMapping() throws Exception{ XContentBuilder mapping = jsonBuilder() .startObject() .startObject("icd") //指定分词器 .field("index_analyzer","pyAnalyzer") .startObject("properties") .startObject("id") .field("type", "long") .field("store", "yes") .endObject() .startObject("code") .field("type", "string") .field("store", "yes") .field("index", "analyzed") .endObject() .startObject("diseaseName") .field("type", "string") .field("store", "yes") .field("index", "analyzed") .endObject() .startObject("mergeName") .field("type", "string") .field("store", "yes") .field("index", "analyzed") .endObject() .startObject("pinyin") .field("type", "string") .field("store", "yes") .field("index", "analyzed") .endObject() .startObject("isTherioma") .field("type", "boolean") .field("store", "yes") .endObject() .startObject("isSpecialDisease") .field("type", "boolean") .field("store", "yes") .endObject() .endObject() .endObject() .endObject(); return mapping; } }
(PS:其实还有一种简单的方法,不用创建analyzer,在搜索的时候,使用'*gxy*'进行搜索也可以)
最后,我还把这个检索跟oracle的like进行了比较。结果发现oracle只用20ms就能算出结果,而es却用了将近100ms。可见这种吹捧的nosql,性能不见得比oracle强大啊,但是毋庸置疑的是,功能确实强大了。
更多elasticsearch的内容,请浏览http://www.iteye.com/blogs/subjects/elasticsearch-tutor