java实现搜索引擎

981 阅读16分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第25天,点击查看活动详情

JAVA现代搜索引擎

查询词提升是现代搜索引擎中广泛使用的一种技术,当用户输入查询词前缀时,会给出一系列相关的查询词推荐,例如在搜索框内输入"中国",会提升"中国好声音","中国银行", "中国联通"等,尝试设计一个查询词提示系统,回答以下问题:

1.给定一个查询词集合,用何种数据结构和算法来构建最基本的提示系统?要求输入中文和拼音都能正常工作

2.用户输入的前缀下可能有很多可提示的查询词,如何对这些查询词进行排序,将用户选择概率更高的词放在前面?

需求分析

  • 支持前缀匹配原则 在搜索框中输入“海底”,搜索框下面会以海底为前缀,展示“海底捞”、“海底捞火锅”、“海底世界”等等搜索词;输入“万达”,会提示“万达影城”、“万达广场”、“万达百货”等搜索词。
  • 同时支持汉字、拼音输入 由于中文的特点,如果搜索自动提示可以支持拼音的话会给用户带来更大的方便,免得切换输入法。比如,输入“haidi”提示的关键字和输入“海底”提示的一样,输入“wanda”与输入“万达”提示的关键字一样。
  • 支持多音字输入提示 比如输入“chongqing”或者“zhongqing”都能提示出“重庆火锅”、“重庆烤鱼”、“重庆小天鹅”。
  • 支持拼音缩写输入 对于较长关键字,为了提高输入效率,有必要提供拼音缩写输入。比如输入“hd”应该能提示出“haidi”相似的关键字,输入“wd”也一样能提示出“万达”关键字。
  • 基于用户的历史搜索行为,按照关键字热度进行排序 为了提供suggest关键字的准确度,最终查询结果,根据用户查询关键字的频率进行排序,如输入[重庆,chongqing,cq,zhongqing,zq] —> [“重庆火锅”(f1),“重庆烤鱼”(f2),“重庆小天鹅”(f3),…],查询频率f1 > f2 > f3。

解决方案

  • 关键字收集 当用户输入一个前缀时,碰到提示的候选词很多的时候,如何取舍,哪些展示在前面,哪些展示在后面?这就是一个搜索热度的问题。用户在使用搜索引擎查找商家时,会输入大量的关键字,每一次输入就是对关键字的一次投票,那么关键字被输入的次数越多,它对应的查询就比较热门,所以需要把查询的关键字记录下来,并且统计出每个关键字的频率,方便提示结果按照频率排序。搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。
  • 汉字转拼音 用户输入的关键字可能是汉字、数字,英文,拼音,特殊字符等等,由于需要实现拼音提示,我们需要把汉字转换成拼音,java中考虑使用pinyin4j组件实现转换。
  • 拼音缩写提取 考虑到需要支持拼音缩写,汉字转换拼音的过程中,顺便提取出拼音缩写,如“chongqing”,"zhongqing"--->"cq",”zq”。
  • 多音字全排列 要支持多音字提示,对查询串转换成拼音后,需要实现一个全排列组合,字符串多音字全排列算法如下:
    public static List getPermutationSentence(List> termArrays,int start) {
​
if (CollectionUtils.isEmpty(termArrays))
      return Collections.emptyList();
​
  int size = termArrays.size();
  if (start < 0 || start >= size) {
      return Collections.emptyList();
  }
​
  if (start == size-1) {
      return termArrays.get(start);
  }
​
  List<String> strings = termArrays.get(start);
​
  List<String> permutationSentences = getPermutationSentence(termArrays, start + 1);
​
  if (CollectionUtils.isEmpty(strings)) {
      return permutationSentences;
  }
​
  if (CollectionUtils.isEmpty(permutationSentences)) {
      return strings;
  }
​
  List<String> result = new ArrayList<String>();
  for (String pre : strings) {
      for (String suffix : permutationSentences) {
          result.add(pre+suffix);
      }
  }
​
  return result;
 }
索引与前缀查询

方案一 Trie树 + TopK算法 Trie树即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。Trie是一颗存储多个字符串的树。相邻节点间的边代表一个字符,这样树的每条分支代表一则子串,而树的叶节点则代表完整的字符串。和普通树不同的地方是,相同的字符串前缀共享同一条分支。例如,给出一组单词inn, int, at, age, adv, ant, 我们可以得到下面的Trie:

现代搜索引擎_搜索引擎

从上图可知,当用户输入前缀i的时候,搜索框可能会展示以i为前缀的“in”,“inn”,”int"等关键词,再当用户输入前缀a的时候,搜索框里面可能会提示以a为前缀的“ate”等关键词。如此,实现搜索引擎智能提示suggestion的第一个步骤便清晰了,即用trie树存储大量字符串,当前缀固定时,存储相对来说比较热的后缀。

TopK算法用于解决统计热词的问题。解决TopK问题主要有两种策略:hashMap统计+排序、堆排序 hashmap统计: 先对这批海量数据预处理。具体方法是:维护一个Key为Query字串,Value为该Query出现次数的HashTable,即hash_map(Query,Value),每次读取一个Query,如果该字串不在Table中,那么加入该字串,并且将Value值设为1;如果该字串在Table中,那么将该字串的计数加一即可,最终在O(N)的时间复杂度内用Hash表完成了统计。 堆排序:借助堆这个数据结构,找出Top K,时间复杂度为N‘logK。即借助堆结构,我们可以在log量级的时间内查找和调整/移动。因此,维护一个K(该题目中是10)大小的小根堆,然后遍历300万的Query,分别和根元素进行对比。所以,我们最终的时间复杂度是:O(N) + N' * O(logK),(N为1000万,N’为300万)。

该方案存在的问题是:

建索引和查询的时候都要把汉字转换成拼音,查询完成后还得把拼音转换成汉字显示,且需要考虑数字和特殊字符。
​
需要维护拼音、缩写两棵Trie树。

方案二 Solr自带Suggest智能提示 Solr作为一个应用广泛的搜索引擎系统,它内置了智能提示功能,叫做Suggest模块。该模块可选择基于提示词文本做智能提示,还支持通过针对索引的某个字段建立索引词库做智能提示。 (详见solr的wiki页面

该方案存在的问题是:

返回的结果是基于索引中字段的词频进行排序,不是用户搜索关键字的频率,因此不能将一些热门关键字排在前面。
​
拼音提示,多音字,缩写还是要另外加索引字段。

方案三 Solrcloud建立单独的collection,利用solr前缀查询实现 如前所述,以上两个方案在实施起来都存在一些问题,Trie树+TopK算法,在处理汉字suggest时不是很优雅,且需要维护两棵Trie树,实施起来比较复杂;Solr自带的suggest智能提示组件存在问题是使用freq排序算法,返回的结果完全基于索引中字符的出现次数,没有兼顾用户搜索词语的频率,因此无法将一些热门词排在更靠前的位置。于是,我们继续寻找一种解决这个问题更加优雅的方案。

至此,

我们考虑专门为关键字建立一个索引collection,利用solr前缀查询实现。solr中的copyField能很好解决我们同时索引多个字段(汉字、pinyin, abbre)的需求,且field的multiValued属性设置为true时能解决同一个关键字的多音字组合问题。配置如下:

schema.xml:
​
<field name="kw" type="string" indexed="true" stored="true" />  
<field name="pinyin" type="string" indexed="true" stored="false" multiValued="true"/>
<field name="abbre" type="string" indexed="true" stored="false" multiValued="true"/>
<field name="kwfreq" type="int" indexed="true" stored="true" />
<field name="_version_" type="long" indexed="true" stored="true"/>
<field name="suggest" type="suggest_text" indexed="true" stored="false" multiValued="true" />
------------------multiValued表示字段是多值的-------------------------------------
<uniqueKey>kw</uniqueKey>
<defaultSearchField>suggest</defaultSearchField>
​
说明:
kw为原始关键字
pinyin和abbre的multiValued=true,在使用solrj建此索引时,定义成集合类型即可:如关键字“重庆”的pinyin字段为{chongqing,zhongqing}, abbre字段为{cq, zq}
kwfreq为用户搜索关键的频率,用于查询的时候排序
​
-------------------------------------------------------
​
<copyField source="kw" dest="suggest" />
<copyField source="pinyin" dest="suggest" />
<copyField source="abbre" dest="suggest" />
​
------------------suggest_text----------------------------------
​
<fieldType name="suggest_text" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="true">
    <analyzer type="index">
            <tokenizer class="solr.KeywordTokenizerFactory" />
            &lt;filter class="solr.SynonymFilterFactory" 
                    synonyms="synonyms.txt" 
                    ignoreCase="true" 
                    expand="true" />
            <filter class="solr.StopFilterFactory" 
                    ignoreCase="true" 
                    words="stopwords.txt" 
                    enablePositionIncrements="true" />
            <filter class="solr.LowerCaseFilterFactory" />
            <filter class="solr.KeywordMarkerFilterFactory" protected="protwords.txt" />
    </analyzer>
    <analyzer type="query">
            <tokenizer class="solr.KeywordTokenizerFactory" />
            <filter class="solr.StopFilterFactory" 
                    ignoreCase="true" 
                    words="stopwords.txt" 
                    enablePositionIncrements="true" />
            <filter class="solr.LowerCaseFilterFactory" />
            <filter class="solr.KeywordMarkerFilterFactory" protected="protwords.txt" />
    </analyzer>
</fieldType>
​

KeywordTokenizerFactory:这个分词器不进行任何分词!整个字符流变为单个词元。String域类型也有类似的效果,但是它不能配置文本分析的其它处理组件,比如大小写转换。任何用于排序和大部分Faceting功能的索引域,这个索引域只有能一个原始域值中的一个词元。

前缀查询构造:

private SolrQuery getSuggestQuery(String prefix, Integer limit) {
    SolrQuery solrQuery = new SolrQuery();
    StringBuilder sb = new StringBuilder();
    sb.append(“suggest:").append(prefix).append("*");
    solrQuery.setQuery(sb.toString());
    solrQuery.addField("kw");
    solrQuery.addField("kwfreq");
    solrQuery.addSort("kwfreq", SolrQuery.ORDER.desc);
    solrQuery.setStart(0);
    solrQuery.setRows(limit);
    return solrQuery;
}
​

注:

1.1、什么是Trie树

Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。
​
Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。

它有3个基本性质:

根节点不包含字符,除根节点外每一个节点都只包含一个字符。
​
从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
​
每个节点的所有子节点包含的字符都不相同。

寻找热门查询,300万个查询字符串中统计最热门的10个查询

原题:搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是1千万,但如果除去重复后,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就是越热门),请你统计最热门的10个查询串,要求使用的内存不能超过1G。

所以我们放弃分而治之/hash映射的步骤,直接上hash统计,然后排序。So,针对此类典型的TOP K问题,采取的对策往往是:hashmap + 堆。如下所示:

hashmap统计:先对这批海量数据预处理。具体方法是:维护一个Key为Query字串,Value为该Query出现次数的HashTable,即hash_map(Query,Value),每次读取一个Query,如果该字串不在Table中,那么加入该字串,并且将Value值设为1;如果该字串在Table中,那么将该字串的计数加一即可。最终我们在O(N)的时间复杂度内用Hash表完成了统计;

堆排序:第二步、借助堆这个数据结构,找出Top K,时间复杂度为N‘logK。即借助堆结构,我们可以在log量级的时间内查找和调整/移动。因此,维护一个K(该题目中是10)大小的小根堆,然后遍历300万的Query,分别和根元素进行对比。所以,我们最终的时间复杂度是:O(N) + N' * O(logK),(N为1000万,N’为300万)。

堆排序思路:‘维护k个元素的最小堆,即用容量为k的最小堆存储最先遍历到的k个数,并假设它们即是最大的k个数,建堆费时O(k),并调整堆(费时O(logk))后,有k1>k2>...kmin(kmin设为小顶堆中最小元素)。继续遍历数列,每次遍历一个元素x,与堆顶元素比较,若x>kmin,则更新堆(x入堆,用时logk),否则不更新堆。这样下来,总费时O(klogk+(n-k) logk)=O(n*logk)。此方法得益于在堆中,查找等各项操作时间复杂度均为logk。

java实现简单的搜索引擎

记得java老师曾经说过百度的一个面试题目,大概意思是“有1W条无序的记录,如何从其中快速的查找到自己想要的记录”。这个就相当于一个简单的搜索引擎。最近在整理这一年的工作中,自己竟然已经把这个实现了,今天对其进一步的抽象,和大家分享下。

先写具体的实现代码,具体的实现思路和逻辑写在代码之后。

搜索时用于排序的Bean

 /**  
 *@Description:   
 */  
package cn.lulei.search.engine.model;  
  
public class SortBean { 
  private String id; 
  private int times; 
   
  public String getId() { 
    return id; 
  } 
  public void setId(String id) { 
    this.id = id; 
  } 
  public int getTimes() { 
    return times; 
  } 
  public void setTimes(int times) { 
    this.times = times; 
  } 
} 
​

构造的搜索数据结构以及简单的搜索算法

 /**  
 *@Description:   
 */  
package cn.lulei.search.engine;  
 
import java.util.ArrayList; 
import java.util.Collections; 
import java.util.Comparator; 
import java.util.HashMap; 
import java.util.HashSet; 
import java.util.List; 
 
import cn.lulei.search.engine.model.SortBean; 
  
public class SerachBase { 
  //details 存储搜素对象的详细信息,其中key作为区分Object的唯一标识 
  private HashMap<String, Object> details = new HashMap<String, Object>(); 
  //对于参与搜索的关键词,这里采用的稀疏数组存储,也可以采用HashMap来存储,定义格式如下 
  //private static HashMap<Integer, HashSet<String>> keySearch = new HashMap<Integer, HashSet<String>>(); 
  //HashMap中额key值相当于稀疏数组中的下标,value相当于稀疏数组在该位置的值 
  private final static int maxLength = Character.MAX_VALUE; 
  @SuppressWarnings("unchecked") 
  private HashSet<String>[] keySearch = new HashSet[maxLength]; 
   
  /** 
   *@Description: 实现单例模式,采用Initialization on Demand Holder加载 
   *@Version:1.1.0 
   */ 
  private static class lazyLoadSerachBase { 
    private static final SerachBase serachBase = new SerachBase(); 
  } 
   
  /** 
   * 这里把构造方法设置成私有为的是单例模式 
   */ 
  private SerachBase() { 
     
  } 
   
  /** 
   * @return  
   * @Description: 获取单例 
   */ 
  public static SerachBase getSerachBase() { 
    return lazyLoadSerachBase.serachBase; 
  } 
   
  /** 
   * @param id 
   * @return  
   * @Description: 根据id获取详细 
   */ 
  public Object getObject(String id) { 
    return details.get(id); 
  } 
   
  /** 
   * @param ids 
   * @return  
   * @Description: 根据ids获取详细,id之间用","隔开 
   */ 
  public List<Object> getObjects(String ids) { 
    if (ids == null || "".equals(ids)) { 
      return null; 
    } 
    List<Object> objs = new ArrayList<Object>(); 
    String[] idArray = ids.split(","); 
    for (String id : idArray) { 
      objs.add(getObject(id)); 
    } 
    return objs; 
  } 
   
  /** 
   * @param key 
   * @return  
   * @Description: 根据搜索词查找对应的id,id之间用","分割 
   */ 
  public String getIds(String key) { 
    if (key == null || "".equals(key)) { 
      return null; 
    } 
    //查找 
    //idTimes存储搜索词每个字符在id中是否出现 
    HashMap<String, Integer> idTimes = new HashMap<String, Integer>(); 
    //ids存储出现搜索词中的字符的id 
    HashSet<String> ids = new HashSet<String>(); 
     
    //从搜索库中去查找 
    for (int i = 0; i < key.length(); i++) { 
      int at = key.charAt(i); 
      //搜索词库中没有对应的字符,则进行下一个字符的匹配 
      if (keySearch[at] == null) { 
        continue; 
      } 
      for (Object obj : keySearch[at].toArray()) { 
        String id = (String) obj; 
        int times = 1; 
        if (ids.contains(id)) { 
          times += idTimes.get(id); 
          idTimes.put(id, times); 
        } else { 
          ids.add(id); 
          idTimes.put(id, times); 
        } 
      } 
    } 
     
    //使用数组排序 
    List<SortBean> sortBeans = new ArrayList<SortBean>(); 
    for (String id : ids) { 
      SortBean sortBean = new SortBean(); 
      sortBeans.add(sortBean); 
      sortBean.setId(id); 
      sortBean.setTimes(idTimes.get(id)); 
    } 
    Collections.sort(sortBeans, new Comparator<SortBean>(){ 
      public int compare(SortBean o1, SortBean o2){ 
        return o2.getTimes() - o1.getTimes(); 
      } 
    }); 
     
    //构建返回字符串 
    StringBuffer sb = new StringBuffer(); 
    for (SortBean sortBean : sortBeans) { 
      sb.append(sortBean.getId()); 
      sb.append(","); 
    } 
     
    //释放资源 
    idTimes.clear(); 
    idTimes = null; 
    ids.clear(); 
    ids = null; 
    sortBeans.clear(); 
    sortBeans = null; 
     
    //返回 
    return sb.toString(); 
  } 
   
  /** 
   * @param id 
   * @param searchKey 
   * @param obj 
   * @Description: 添加搜索记录 
   */ 
  public void add(String id, String searchKey, Object obj) { 
    //参数有部分为空,不加载 
    if (id == null || searchKey == null || obj == null) { 
      return; 
    } 
    //保存对象 
    details.put(id, obj); 
    //保存搜索词 
    addSearchKey(id, searchKey); 
  } 
   
  /** 
   * @param id 
   * @param searchKey 
   * @Description: 将搜索词加入到搜索域中 
   */ 
  private void addSearchKey(String id, String searchKey) { 
    //参数有部分为空,不加载 
    //这里是私有方法,可以不做如下判断,但为了设计规范,还是加上 
    if (id == null || searchKey == null) { 
      return; 
    } 
    //下面采用的是字符分词,这里也可以使用现在成熟的其他分词器 
    for (int i = 0; i < searchKey.length(); i++) { 
      //at值相当于是数组的下标,id组成的HashSet相当于数组的值 
      int at = searchKey.charAt(i); 
      if (keySearch[at] == null) { 
        HashSet<String> value = new HashSet<String>(); 
        keySearch[at] = value; 
      } 
      keySearch[at].add(id); 
    } 
  } 
   
   
 
} 
​

测试用例:

 /**  
 *@Description:   
 */  
package cn.lulei.search.engine.test;  
 
import java.util.List; 
 
import cn.lulei.search.engine.SerachBase; 
  
public class Test { 
  public static void main(String[] args) { 
    // TODO Auto-generated method stub  
    SerachBase serachBase = SerachBase.getSerachBase(); 
    serachBase.add("1", "你好!", "你好!"); 
    serachBase.add("2", "你好!我是张三。", "你好!我是张三。"); 
    serachBase.add("3", "今天的天气挺好的。", "今天的天气挺好的。"); 
    serachBase.add("4", "你是谁?", "你是谁?"); 
    serachBase.add("5", "高数这门学科很难", "高数确实很难。"); 
    serachBase.add("6", "测试", "上面的只是测试"); 
    String ids = serachBase.getIds("你的高数"); 
    System.out.println(ids); 
    List<Object> objs = serachBase.getObjects(ids); 
    if (objs != null) { 
      for (Object obj : objs) { 
        System.out.println((String) obj); 
      } 
    } 
  } 
 
} 
​

测试输出结果如下:

5,3,2,1,4,
高数确实很难。
今天的天气挺好的。
你好!我是张三。
你好!
你是谁?
​

这样一个简单的搜索引擎也就算是完成了。

问题一: 这里面的分词采用的是字符分词,对汉语的处理还是挺不错的,但是对英文的处理就很弱。

改进方法:采用现在成熟的分词方法,比如IKAnalyzer、StandardAnalyzer等,这样修改,keySearch的数据结构就需要做下修改,可以修改为 private HashMap<String, String>[] keySearch = new HashMap[maxLength]; 其中key存储分的词元,value存储唯一标识id。

问题二: 本文实现的搜索引擎对词元并没有像lucene设置权重,只是简单的判断词元是否在对象中出现。

改进方法:暂无。添加权重处理,使数据结构更加复杂,所以暂时没有对其做处理,在今后的文章中会实现权重的处理。

下面就简单的介绍一下搜索引擎的实现思路

在SerachBase类中设置details和keySearch两个属性,details用于存储Object的详细信息,keySearch用于对搜索域做索引。details数据格式为HashMap,keySearch的数据格式为稀疏数组(也可以为HashMap,HashMap中额key值相当于稀疏数组中的下标,value相当于稀疏数组在该位置的值)。

对于details我就不做太多的介绍。

keySearch中数组下标(如用HashMap就是key)的计算方法是获取词元的第一个字符int值(因为本文的分词采用的是字符分词,所以一个字符就是一个词元),该int值就是数组的下标,相应的数组值就是Object的唯一标识。这样keySearch的数据结构就如下图

图片.png

因此想添加新纪录的时候只需要调用add方法即可。

对于搜索的实现逻辑和上面的keySearch类似。对于id的搜索直接使用HashMap的get方法即可。对于搜索词的一个搜索,整体的过程也是采用先分词、其次查询、最后排序。当然这里面的分词要和创建采用的分词要一致(即创建的时候采用字符分词,查找的时候也采用字符分词)。

在getIds方法中,HashMap<String, Integer> idTimes = new HashMap<String, Integer>();idTimes 变量用来存储搜索词中的词元有多少个在keySearch中出现,key值为唯一标识id,value为出现的词元个数。HashSet ids = new HashSet(); ids变量用来存储出现的词元的ids。这样搜索的复杂度就是搜索词的词元个数n。获得包含词元的ids,构造SortBean数组,对其排序,排序规则是出现词元个数的降序排列。最后返回ids字符串,每个id用","分割。如要获取详细信息 再使用getObjects方法即可。

上述的只是一个简单的搜索引擎,并没有设计太多的计算方法