天天看點

elasticsearch 實作聯想輸入搜尋

通常,在項目中需要聯想輸入(即輸入關鍵字,提示相關詞條,類似百度google的搜尋)的需求,可能大家都是用的資料庫的like '%關鍵字%‘來實作。但是這樣實作有幾個問題。

第一、這樣的搜尋無論是oracle還是mysql,都是無法使用索引的。在oracle中可能有全文檢索可以使用,但是個人感覺效果不是很好。

第二、輸入的關鍵字有like的通病,就是隻有保含關鍵字的詞條才會被命中。如果中間加個空格之類的,db就無能為力了。

第三、如果要想對命中結果進行相關度排序,這個在正常資料庫是無法做到的。雖然,可以按照命中詞條的長度進行升序排序,但是加上排序,性能不是很好。

下面介紹一下使用elasticsearch實作聯想輸入的搜尋,因為是搜尋引擎,天生就不具備上面的3個問題。

在具體介紹使用方法之前,我們先找個搜尋資料。我找的是ICD(就是疾病名稱的國标),誰讓咱一生都在跟他做鬥争。這個在網上一搜一堆。

有了資料,我們先要簡單描述一下我們要達到的一個目的。一般的搜尋都支援漢字 和拼音兩種檢索方法。我們的這個檢索也滿足這個需求。

搜尋需求描述:

1、支援漢字和簡拼兩種搜尋方法。

2、輸入“高血壓”時,按照相關度,将帶“高血壓”名稱的疾病名稱按照相關度降序排序。

3、輸入“老年 高血壓”,時,将帶“老年”和“高血壓”名稱的疾病名稱按照相關度降序排序。

4、輸入拼音'gxy‘時,将拼音中帶有gxy相關的疾病按照相關度降序排序。

....

類似測試用例的需求,到此打住。

那麼,我們一步一步實作這種需求。

首先,我們定義了一個ICD的類,算作我們的模型,其實沒有模型也可以,隻要存入到es且知道各個field的名稱就行。這個裡面我們隻需要關注疾病名稱diseaseName及簡拼pinyin字段即可,這個字段預設是字元串,ES預設會幫我們分詞。

Java代碼

  1. import java.io.Serializable;  
  2. import java.math.BigDecimal;  
  3. public class ICD implements Serializable{  
  4.    private static final long serialVersionUID = 6934803011248581109L;  
  5.    //疾病ID  
  6.    private int id;  
  7.    //疾病編碼  
  8.    private String code;  
  9.    //疾病名稱  
  10.    private String diseaseName;  
  11.    //疾病加拼音  
  12.    private String mergeName;  
  13.    //漢語拼音簡拼  
  14.    private String pinyin;  
  15.    //是否惡心惡性良性腫瘤  
  16.    private boolean isTherioma;  
  17.    //是否住院特殊病種  
  18.    private boolean isSpecialDisease;  
  19.    public ICD(BigDecimal id, String diseaseName, String code,  
  20.            String pinyin, String isTherioma, String isSpecialDisease) {  
  21.        this.id = id.intValue();  
  22.        this.diseaseName = diseaseName;  
  23.        this.code = code;  
  24.        this.pinyin = pinyin;  
  25.        if("是".equals(isTherioma)){  
  26.            this.isTherioma = true;  
  27.        }  
  28.        else {  
  29.            this.isTherioma = false;  
  30.        }  
  31.        if("是".equals(isSpecialDisease)){  
  32.            this.isSpecialDisease = true;  
  33.        }  
  34.        else {  
  35.            this.isSpecialDisease = false;  
  36.        }  
  37.        this.mergeName = diseaseName + "," + pinyin;  
  38.    }  
  39.    //set,get ......  
  40. }  

第二步,将資料存儲到elasticsearch裡面,我們取個名稱叫code,起個type名稱叫icd。ICD大概2w條資料,我使用預設的bulkIndex,存到es大概用了3秒。

我這裡是把資料從oracle導入到elasticsearch。

Java代碼

  1. import java.math.BigDecimal;  
  2. import java.sql.Connection;  
  3. import java.sql.PreparedStatement;  
  4. import java.sql.ResultSet;  
  5. import java.util.ArrayList;  
  6. import java.util.List;  
  7. import org.elasticsearch.action.bulk.BulkRequestBuilder;  
  8. import org.elasticsearch.action.bulk.BulkResponse;  
  9. import org.elasticsearch.action.index.IndexRequestBuilder;  
  10. import org.elasticsearch.client.Client;  
  11. import com.donlianli.es.ESUtils;  
  12. import com.donlianli.es.db.DatabaseUtils;  
  13. public class ICDManager {  
  14.    public static void main(String[] argvs){  
  15.        ICDManager manager = new ICDManager();  
  16.        manager.indexDataDirect();  
  17.    }  
  18.    private void indexDataDirect() {  
  19.        List<ICD> icdList = getIcdListFromDB();    
  20.        System.out.println(" get icd from db finish,size:" + icdList.size());  
  21.        bulkIndex(icdList);  
  22.    }  
  23.    private void bulkIndex(List<ICD> icdList) {  
  24.        Client client = ESUtils.getCodeClient();  
  25.        BulkRequestBuilder bulkRequest = client.prepareBulk();  
  26.        long b = System.currentTimeMillis();  
  27.        for(int i=0,l=icdList.size();i<l;i++){  
  28.            //業務對象  
  29.            ICD icd = icdList.get(i);  
  30.            String json = ESUtils.toJson(icd);  
  31.            IndexRequestBuilder indexRequest = client.prepareIndex("code","icd")  
  32.            .setSource(json).setId(String.valueOf(icd.getId()));  
  33.            //添加到builder中  
  34.            bulkRequest.add(indexRequest);  
  35.        }  
  36.        BulkResponse bulkResponse = bulkRequest.execute().actionGet();  
  37.        if (bulkResponse.hasFailures()) {  
  38.            System.out.println(bulkResponse.buildFailureMessage());  
  39.        }  
  40.        long useTime = System.currentTimeMillis()-b;  
  41.        System.out.println("useTime:" + useTime);  
  42.    }  
  43.    private List<ICD> getIcdListFromDB() {  
  44.        Connection conn = DatabaseUtils.getOracleConnection();  
  45.        String sql = "select * from icd_11";  
  46.        PreparedStatement st = null;  
  47.        ResultSet rs = null;  
  48.        List<ICD> list = new ArrayList<ICD>();  
  49.        try{  
  50.            st = conn.prepareStatement(sql);  
  51.            rs = st.executeQuery();  
  52.            while(rs.next()){  
  53.                BigDecimal id = rs.getBigDecimal("ID");  
  54.                String diseaseName = rs.getString("DISEASE_NAME");  
  55.                String code = rs.getString("CODE");  
  56.                String pinyin = rs.getString("PINYIN");  
  57.                String isTherioma = rs.getString("THERIOMA_FLAG");  
  58.                String isSpecialDisease = rs.getString("OTHER_FLAG");  
  59.                list.add(new ICD(id,diseaseName,code,pinyin,isTherioma,isSpecialDisease));  
  60.            }  
  61.            return list;  
  62.        }  
  63.        catch(Exception e){  
  64.            e.printStackTrace();  
  65.        }  
  66.        finally{  
  67.            try{  
  68.            if(rs!= null){  
  69.                rs.close();  
  70.            }  
  71.            if(st!= null){  
  72.                st.close();  
  73.            }  
  74.            conn.close();  
  75.            }  
  76.            catch(Exception e){  
  77.                e.printStackTrace();  
  78.            }  
  79.        }  
  80.        return null;  
  81.    }  
  82. }  

第三步,搜尋接口,跑測試用例。

Java代碼

  1. import org.elasticsearch.action.search.SearchResponse;  
  2. import org.elasticsearch.client.Client;  
  3. import org.elasticsearch.index.query.MultiMatchQueryBuilder;  
  4. import org.elasticsearch.index.query.QueryBuilders;  
  5. import org.elasticsearch.search.SearchHit;  
  6. import org.elasticsearch.search.SearchHits;  
  7. import com.donlianli.es.ESUtils;  
  8. public class PinyinSearchTest {  
  9.    public static void main(String[] args) {  
  10.        Client client = ESUtils.getCodeClient();  
  11.        String keyWord = "高血壓";  
  12. //      String keyWord = "老年 高血壓";  
  13. //      String keyWord = "gxy";  
  14.        //多個字段比對  
  15.        MultiMatchQueryBuilder query = QueryBuilders.multiMatchQuery(keyWord, "diseaseName","pinyin");  
  16.        long b = System.currentTimeMillis();  
  17.        SearchResponse response = client.prepareSearch("code").setTypes("icd")  
  18.                .setQuery(query)  
  19.                .setFrom(0)  
  20.                //前20個  
  21.                .setSize(20)  
  22.                .execute().actionGet();  
  23.        long useTime = System.currentTimeMillis()-b;  
  24.        System.out.println("search use time:" + useTime + " ms");  
  25.        SearchHits shs = response.getHits();  
  26.        for (SearchHit hit : shs) {  
  27.            System.out.println("分數:"  
  28.                    + hit.getScore()  
  29.                    + ",ID:"  
  30.                    + hit.getId()  
  31.                    + ", 疾病名稱:"  
  32.                    + hit.getSource().get("diseaseName")  
  33.                    + ",拼音:" + hit.getSource().get("pinyin"));  
  34.        }  
  35.        client.close();  
  36.    }  
  37. }  

3.1,關鍵字:'高血壓'

[java]  view plain  copy

  1. search use time:174 ms  
  2. 分數:2.3859928,ID:6904, 疾病名稱:高血壓病,拼音:gxyb  
  3. 分數:2.136423,ID:6907, 疾病名稱:高血壓I期,拼音:gxyyq  
  4. 分數:2.12253,ID:6908, 疾病名稱:高血壓Ⅱ期,拼音:gxyeq  
  5. 分數:2.12253,ID:6910, 疾病名稱:高血壓危象,拼音:gxywx  
  6. 分數:2.0906634,ID:6917, 疾病名稱:腎性高血壓,拼音:sxgxy  
  7. 分數:2.0877438,ID:6909, 疾病名稱:高血壓Ⅲ期,拼音:gxysq  
  8. 分數:2.0821526,ID:18767, 疾病名稱:高原性高血壓,拼音:gyxgxy  
  9. 分數:1.9905697,ID:6906, 疾病名稱:惡性高血壓,拼音:exgxy  
  10. 分數:1.9510978,ID:7260, 疾病名稱:高血壓腦出血,拼音:gxyncx  
  11. 分數:1.9078629,ID:6923, 疾病名稱:腎血管性高血壓,拼音:sxgxgxy  
  12. 分數:1.8312198,ID:6914, 疾病名稱:高血壓性腎病,拼音:gxyxsb  
  13. 分數:1.8193114,ID:7367, 疾病名稱:高血壓性腦病,拼音:gxyxnb  
  14. 分數:1.8193114,ID:13470, 疾病名稱:妊娠引起高血壓,拼音:rsyqgxy  
  15. 分數:1.7919972,ID:6905, 疾病名稱:臨界性高血壓,拼音:ljxgxy  
  16. 分數:1.7919972,ID:6912, 疾病名稱:高血壓性心髒病,拼音:gxyxxzb  
  17. 分數:1.7894946,ID:6928, 疾病名稱:繼發性高血壓,拼音:jfxgxy  
  18. 分數:1.7062025,ID:6913, 疾病名稱:高血壓性腎衰竭,拼音:gxyxssj  
  19. 分數:1.7062025,ID:13485, 疾病名稱:孕産婦高血壓,拼音:ycfgxy  
  20. 分數:1.7062025,ID:14534, 疾病名稱:新生兒高血壓,拼音:xsegxy  
  21. 分數:1.7062025,ID:16181, 疾病名稱:應激性高血壓,拼音:yjxgxy  

3.2關鍵字:'老年 高血壓'

[java]  view plain  copy

  1. search use time:144 ms  
  2. 分數:1.1089094,ID:6904, 疾病名稱:高血壓病,拼音:gxyb  
  3. 分數:0.99291986,ID:6907, 疾病名稱:高血壓I期,拼音:gxyyq  
  4. 分數:0.9864628,ID:6908, 疾病名稱:高血壓Ⅱ期,拼音:gxyeq  
  5. 分數:0.9864628,ID:6910, 疾病名稱:高血壓危象,拼音:gxywx  
  6. 分數:0.9716526,ID:6917, 疾病名稱:腎性高血壓,拼音:sxgxy  
  7. 分數:0.97029567,ID:6909, 疾病名稱:高血壓Ⅲ期,拼音:gxysq  
  8. 分數:0.96769714,ID:18767, 疾病名稱:高原性高血壓,拼音:gyxgxy  
  9. 分數:0.9251333,ID:6906, 疾病名稱:惡性高血壓,拼音:exgxy  
  10. 分數:0.9067884,ID:7260, 疾病名稱:高血壓腦出血,拼音:gxyncx  
  11. 分數:0.8866946,ID:6923, 疾病名稱:腎血管性高血壓,拼音:sxgxgxy  
  12. 分數:0.8510741,ID:6914, 疾病名稱:高血壓性腎病,拼音:gxyxsb  
  13. 分數:0.8455395,ID:7367, 疾病名稱:高血壓性腦病,拼音:gxyxnb  
  14. 分數:0.8455395,ID:13470, 疾病名稱:妊娠引起高血壓,拼音:rsyqgxy  
  15. 分數:0.8328451,ID:6905, 疾病名稱:臨界性高血壓,拼音:ljxgxy  
  16. 分數:0.8328451,ID:6912, 疾病名稱:高血壓性心髒病,拼音:gxyxxzb  
  17. 分數:0.831682,ID:6928, 疾病名稱:繼發性高血壓,拼音:jfxgxy  
  18. 分數:0.8074301,ID:6820, 疾病名稱:老年耳聾,拼音:lnel  
  19. 分數:0.80348647,ID:7612, 疾病名稱:老年痣,拼音:lnz  
  20. 分數:0.7929714,ID:6913, 疾病名稱:高血壓性腎衰竭,拼音:gxyxssj  
  21. 分數:0.7929714,ID:13485, 疾病名稱:孕産婦高血壓,拼音:ycfgxy  

高血壓和老年的相關并都出來了。隻可惜老年高血壓,沒有列入ICD.

3.3拼音:'gxy'

呃?怎麼沒有出來?

這個問題折騰了我一天。一開始我以為是被es列入了禁用詞。後來,找到是因為沒有設定analyzer導緻,在設analyzer的過程中竟然還犯了好幾個低級錯誤,導緻我非常懷疑設定analyzer是否管用。

這個問題涉及到分詞,而分詞我還沒有好好研究過。總之,在建立索引及mapping的時候,指定一個analyzer就可以解決這個問題。

建立index及mapping的代碼如下:

Java代碼

  1. import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;  
  2. import org.elasticsearch.action.admin.indices.create.CreateIndexResponse;  
  3. import org.elasticsearch.client.Client;  
  4. import org.elasticsearch.common.settings.ImmutableSettings;  
  5. import org.elasticsearch.common.settings.ImmutableSettings.Builder;  
  6. import org.elasticsearch.common.xcontent.XContentBuilder;  
  7. import com.donlianli.es.ESUtils;  
  8. public class CodeMappingTest {  
  9.    static final String INDEX_NAME="code";  
  10.    static final String TYPE_NAME="icd";  
  11.    public static void  main(String[] argv) throws Exception{  
  12.        Client client = ESUtils.getCodeClient();  
  13.        Builder settings = ImmutableSettings.settingsBuilder()  
  14.                .loadFromSource(getAnalysisSettings());  
  15.        //首先建立索引庫  
  16.        CreateIndexResponse  indexresponse = client.admin().indices()  
  17.        //這個索引庫的名稱還必須不包含大寫字母  
  18.        .prepareCreate(INDEX_NAME).setSettings(settings)  
  19.        //這裡直接添加type的mapping  
  20.        .addMapping(TYPE_NAME, getMapping())  
  21.        .execute().actionGet();  
  22.        System.out.println("success:"+indexresponse.isAcknowledged());  
  23.    }  
  24.    private static String getAnalysisSettings() throws Exception {  
  25.        XContentBuilder mapping = jsonBuilder()    
  26.                   .startObject()    
  27.                   //主分片數量  
  28.                   .field("number_of_shards",5)  
  29.                   .field("number_of_replicas",0)  
  30.                     .startObject("analysis")    
  31.                        .startObject("filter")  
  32.                            //建立分詞過濾器  
  33.                            .startObject("pynGram")  
  34.                                .field("type","nGram")  
  35.                                //從1開始  
  36.                                .field("min_gram",1)  
  37.                                .field("max_gram",15)  
  38.                            .endObject()  
  39.                        .endObject()      
  40.                        .startObject("analyzer")  
  41.                                //拼音analyszer  
  42.                                .startObject("pyAnalyzer")  
  43.                                .field("type","custom")  
  44.                                .field("tokenizer","standard")  
  45.                                .field("filter", new String[]{"lowercase","pynGram"})  
  46.                                .endObject()  
  47.                        .endObject()      
  48.                    .endObject()    
  49.                  .endObject();    
  50.        System.out.println(mapping.string());  
  51.        return mapping.string();  
  52.    }  
  53.    private static XContentBuilder getMapping() throws Exception{  
  54.        XContentBuilder mapping = jsonBuilder()    
  55.                   .startObject()    
  56.                     .startObject("icd")    
  57.                     //指定分詞器  
  58.                     .field("index_analyzer","pyAnalyzer")  
  59.                     .startObject("properties")          
  60.                       .startObject("id")  
  61.                            .field("type", "long")  
  62.                            .field("store", "yes")  
  63.                        .endObject()      
  64.                       .startObject("code")  
  65.                            .field("type", "string")  
  66.                            .field("store", "yes")  
  67.                            .field("index", "analyzed")  
  68.                        .endObject()    
  69.                         .startObject("diseaseName")  
  70.                            .field("type", "string")  
  71.                            .field("store", "yes")  
  72.                            .field("index", "analyzed")  
  73.                        .endObject()    
  74.                         .startObject("mergeName")  
  75.                            .field("type", "string")  
  76.                            .field("store", "yes")  
  77.                            .field("index", "analyzed")  
  78.                        .endObject()  
  79.                        .startObject("pinyin")  
  80.                            .field("type", "string")  
  81.                            .field("store", "yes")  
  82.                            .field("index", "analyzed")  
  83.                        .endObject()    
  84.                       .startObject("isTherioma")  
  85.                            .field("type", "boolean")  
  86.                            .field("store", "yes")  
  87.                       .endObject()    
  88.                        .startObject("isSpecialDisease")  
  89.                            .field("type", "boolean")  
  90.                            .field("store", "yes")  
  91.                       .endObject()    
  92.                     .endObject()    
  93.                    .endObject()    
  94.                  .endObject();    
  95.        return mapping;  
  96.    }  

(PS:其實還有一種簡單的方法,不用建立analyzer,在搜尋的時候,使用'*gxy*'進行搜尋也可以)

最後,我還把這個檢索跟oracle的like進行了比較。結果發現oracle隻用20ms就能算出結果,而es卻用了将近100ms。可見這種吹捧的nosql,性能不見得比oracle強大啊,但是毋庸置疑的是,功能确實強大了。