Lucene查询分析-结果合并

elasticsearch是近年来比较火的分布式查询中间件了,elasticsearch的搜索引擎是LUCENE,前几年比较火的solr的搜索引擎也是lucene,能被那么多的中间件作为搜索引擎,我们看下他到底有什么神奇之处,先简单的说下lucene的数据结构:

1:倒排索引

常规的关系型数据库都是正排索引:

id name age sex
1 小明 10
2 小黑 12

从结构上来看,关系型数据库存储的结构化数据,对于扩展数据不太友好,增加一列的数据成本很高,特别是表中数据很多的时候,这种数据对于关联的查询体验非常好,同时也支持事务的概念,这点很重要,从这个方面来看,关系数据库更侧重于数据的完整性和一致性,强一致性的数据模型,如果要做分片支持的话不太容易,开源的组件不是特别多,下面看下非关系数据的特点:

name

id

小明  1
age id
12 2

从结构上看非关系型数据库的数据是列式存储,扩展数据是很简单的,增加一列的成本很低,非关系型数据库追求数据的最终一致性,所以对于事务是不支持的,并且天然支持数据的分片,适合数据关系不复杂的情况,如果说查询快的话,一般是非关系型数据库,相对来说插入就有点慢了,如何实现倒排索引这种结构呢,一般就是我们所熟知的结构,字典,怎么实现字典,字符类型和数字类型的不太一样,现在分别说一下。

2:字符类型-FST和倒排链表

字典分为key和value,key的实现是FST,value(docId)的实现是倒排链,为什么这么实现?数据不一样,key为字符类型,value(docId)是数字类型,这里的字符类型特指key,

2.1:FST前缀匹配

对于字符串alan和alice这二个字符串,共有了al这个共享的前缀,为什么要采用这个结构,这样就可以重复的利用共有的前缀,1,2的不明显,如果成千上万的话,可以节省很大一部分的空间,怎么实现FST,简单的说一下:比如我有alan和alice还有alert三个字符串,先对三个字符串进行排序,alan->alert->alice,然后通过char的字符数组,提取公共的字符,基本是这么实现的,如果有三个alan,docId为1,2,7,有二个alert,docId为3,5,有二个alice,docId为4,6,那么value该采用何种结构,就是下面要说的倒排链。

2.2:倒排链

所谓的倒排链就是保存了排序的一堆docId数字,这些docID的key是一样的,存储是怎么存储的,很多人可能说是保存1,2,3,5,7,8,10,12,15这种办法,很显然不可能,采用的是差值存储,1,1,1,2,2,1,2,2,3,那么这些差值怎么存储,很显然也不是直接存储,感兴趣的直接搜索下google的zigzag,但是这里因为只有正数,压缩稍有不同,但基本一样。这种压缩对于比较小的数字压缩比较好,大的就不行了,既然是顺序的结构,后面还需要合并结果集,必然要进行加速,这里加速的结构就是skiplist跳表。

2.3:skiplist

这种结构就类似于二分查找了,例如查询5,第一层是15,大于5,查找第0层的8,大于5,3小于5,位于3-8之间,再从小到大遍历原链接,就找到了5,后面会详细的说这个,为什么要这个,怎么构建skiplist,lucene里是每128算一个阶段,来了第一个128,也就是到了3的位置,在第0层插入128的docId,第二个128就到了8的位置,在第0层把第二个128的docId插入到8的位置,知道第8个128,在第0层插入第8个的128,通过时在第一层15的位置插入第8个128的docId,一层层的往上,除了原链接那一层是128作为一个阶段,上面的每一层都是以8位阶段,第0层代表128,第1层代表128*8,以此往上代表每隔阶段有的docId的个数。

3:数字类型-BKDTree

从上面也能看出字符类型结构的设计就是为了做精确查询,而现在我们说下数字类型,数字类型一般就是做range范围查询,所以结构就是为了range查询而设计,如果在实际的运用当中,你的数字类型不是为了范围查询,不放把数字类型设置为字符串类型,会给你一个惊喜的。

bkd主要的服务领域是多维数据,我们大多数用的是一维的模型,就是二叉树,大家都很熟悉,lucene都是以块存储的,1024作为一个块也就是一个leaf,就是最下面的那个,到此为止,本篇也不熟主要讲这些结构,讲这些每篇都可以单独讲,我在这只是简单的说了一下,下面到了本篇的重点:lucene查询分析-结果合并。

4:lucene查询分析-结果合并

下面先贴代码,给予lucene6.5.1,这些数据都是为了测试,后面会说查询,感兴趣可以运行一下:

public void testLucene2() throws Exception {
        Article article = new Article();
        article.setId(116L);
        article.setAuthor("lucene");
        article.setTitle("学习lucene");
        article.setContent("学lucene!");
        article.setUrl("https://blog.csdn.net/fly910905/article/details/81190382");
        String indexPath = "/Users/wangliyong1/wly/lucene";
        Path path = Paths.get(indexPath);
        FSDirectory fsDirectory = FSDirectory.open(path);
        IndexWriterConfig config=new IndexWriterConfig();
        config.setUseCompoundFile( false);
        //指定了写入数据目录和配置
        IndexWriter indexWriter = new IndexWriter(fsDirectory, config);
        String s = "";
        //通过IndexWriter写入
        //128 * 8 * 8
        for ( int i = 0; i < 128 * 8 * 8; i++) {
            if (i < 100) {
                s = "1010101010101010" + "0" + "大熊猫000";
            } else if (i % 10  == 1) {
                s = "1010101010101010" + i  + "大熊猫111";
            } else if (i % 10  == 2) {
                s = "1010101010101010" + i + "大熊猫222";
            } else if (i % 10  == 3) {
                s = "1010101010101010" + i + "大熊猫333";
            } else if (i % 10  == 4) {
                s = "1010101010101010" + i + "大熊猫444";
            } else if (i % 10  == 5) {
                s = "1010101010101010" + i + "大熊猫555";
            } else if (i % 10  == 6) {
                s = "1010101010101010" + i + "大熊猫666";
            } else if (i % 10  == 7) {
                s = "1010101010101010" + i + "大熊猫777";
            } else if (i % 10  == 8) {
                s = "1010101010101010" + i + "大熊猫888";
            } else if (i % 10  == 9) {
                s = "1010101010101010" + i + "大熊猫999";
            }
            article.setTitle(s);
            /* "学数据,像毕老师一样牛"+ s +"!" */
            article.setContent(s);
            if (i < 10 ) {
                article.setId(10L);
                article.setStudentId(10L);
            } else {
                article.setId((long) i);
                article.setStudentId((long) i);
            }
            //创建一个文档对象
            Document document = article.toDocument();
            System.out.println(document);
            indexWriter.addDocument(document);
        }
        indexWriter.close();
    }
public class Article {

    /**
     * 主键
     */
    private Long id;
    /**
     * 学生id
     */
    private Long studentId;
    /**
     * 标题
     */
    private String title;
    /**
     * 内容
     */
    private String content;
    /**
     * 作者
     */
    private String author;
    /**
     * 链接
     */
    private String url;

    public Article() {
    }

    public Article(Long id, String title, String content, String author,
                   String url) {
        super();
        this.id = id;
        this.title = title;
        this.content = content;
        this.author = author;
        this.url = url;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public Long getStudentId() {
        return studentId;
    }

    public void setStudentId(Long studentId) {
        this.studentId = studentId;
    }

    /**
     * 生成Lucene存储的格式
     */
    public Document toDocument() {
        //Lucene存储的格式(Map装的k,v)
        Document doc = new Document();
        //向文档中添加一个long类型的属性,建立索引    index point
        doc.add(new LongPoint("id", id));
        //在文档中存储                            存储
        doc.add(new StoredField("id", id));
        doc.add(new NumericDocValuesField("id", id));


        doc.add(new LongPoint("studentId", studentId));
        //在文档中存储                            存储
        doc.add(new StoredField("studentId", studentId));
        doc.add(new NumericDocValuesField("studentId", studentId));

        //设置一个文本类型,会对内容进行分词,建立索引,并将内容在文档中存储
        //TextField                             term index 存储
        doc.add(new StringField("title", title, Store.YES));
        //设置一个文本类型,会对内容进行分词,建立索引,存在文档中存储 / No代表不存储
        doc.add(new StringField("content", content, Store.YES));

        //StringField,不分词,建立索引,文档中存储
        doc.add(new StringField("author", author, Store.YES));

        //不分词,不建立索引,在文档中存储,          存储
        doc.add(new StoredField("url", url));
        return doc;
    }

    /**
     * 生成Lucene存储的格式
     */
    public Document toDocument2() {
        //Lucene存储的格式(Map装的k,v)
        Document doc = new Document();
        //向文档中添加一个long类型的属性,建立索引
        doc.add(new LongPoint("id", id));
        //在文档中存储
        doc.add(new StoredField("id", id));

        doc.add(new LongPoint("id111", id));
        //在文档中存储
        doc.add(new StoredField("id111", id));

        //设置一个文本类型,会对内容进行分词,建立索引,并将内容在文档中存储
        //TextField
        doc.add(new StringField("title", title, Store.YES));
        //设置一个文本类型,会对内容进行分词,建立索引,存在文档中存储 / No代表不存储
        doc.add(new StringField("content", content, Store.YES));

        //StringField,不分词,建立索引,文档中存储
        doc.add(new StringField("author", author, Store.YES));

        //不分词,不建立索引,在文档中存储,
        doc.add(new StoredField("url", url));
        return doc;
    }

    /**
     * 解析Lucene存储的格式
     */
    public static Article parseArticle(Document doc) {
        Long id = Long.parseLong(doc.get("id"));
        String title = doc.get("title");
        String content = doc.get("content");
        String author = doc.get("author");
        String url = doc.get("url");
        Article article = new Article(id, title, content, author, url);
        return article;
    }

    @Override
    public String toString() {
        return "id : " + id + " , title : " + title + " , content : " + content + " , author : " + author + " , url : " + url;
    }


}

4.1:单个字符类型term查询

 TermQuery query1 = new TermQuery(new Term("title", "10101010101010100大熊猫000"));
 BooleanQuery.Builder builder = new BooleanQuery.Builder();
 builder.add(query1, BooleanClause.Occur.MUST);

这个查询很简单,就是精确查询单个term,这里i<100,也就是前100个document,结果docId(1,2,3,4,5,6,7......100),然后遍历倒排链,查询所有的倒排链的document,在这里也用不上skiplist

4.2:多个字符类型的term查询

 TermQuery query1 = new TermQuery(new Term("title", "10101010101010100大熊猫000"));
 TermQuery query2 = new TermQuery(new Term("author", "lucene"));
 BooleanQuery.Builder builder = new BooleanQuery.Builder();
 builder.add(query1, BooleanClause.Occur.MUST);
 builder.add(query2, BooleanClause.Occur.MUST);

这个查询是二个term的查询,少的是lead1,大的是lead2,小的去查大的,查询次数少嘛,第一个i<100,第二个是全部的数据,lucene这个时候就会拿着100个数据查询在第二个里面存不存在,当然这个查询并不走skiplist,因为我的数据i<100是顺序的,跳跃性不大,如果跳跃性很大,是会走skiplist,比如我第一个查1,第二个查2000,就会构建skiplist,加速query1在query2的情况下的查询,当然不会拿query2去查query1,除非脑子有问题,上面的例如query1结果docId(1,2,3,4,5,6,7......100),query2的结果集(1,2,3,4,5,6,7.......100......1000.....3000.....8192),这样就会拿1,去查query中有没有,有的话就放到list,这样就过滤了结果集。

4.3:单个字符型term查询和单个字符型range查询

 TermQuery query1 = new TermQuery(new Term("title", "10101010101010100大熊猫000"));
 TermRangeQuery query2 = TermRangeQuery.newStringRange("title", "0",  
     "101010101010101099大熊猫999", true, true);
 BooleanQuery.Builder builder = new BooleanQuery.Builder();
 builder.add(query1, BooleanClause.Occur.MUST);
 builder.add(query2, BooleanClause.Occur.MUST);

在说之前先说一下,如非必要不要进行term的range查询,效果不好,上面的queri1查询的结果集docId(1,2,3,4,5,6,7......100),query2的查询怎么实现,query2县构建一个结构就是int数组,数组包含query2的range左边的值作为min,也就是0,右边的值作为max,实际上是下面这种结构:

0 0 0 0 0 0 0 0 0 0 min
1 0 1 0 1 0 1 0 1 0 max

然后会搜索所有的term然后和这个构建的结构比较,得到所有的term,然后把所有的term构建一个bitMap位图,也可以叫bitSet,叫法不一样,然后拿query1的结果集去查query2,这里不论多少,都是拿query1去查query2。

4.4:多个字符型term和单个字符型range查询

 TermQuery query1 = new TermQuery(new Term("title", "10101010101010100大熊猫000"));
 TermQuery query2 = new TermQuery(new Term("author", "lucene"));
 TermRangeQuery query3 = TermRangeQuery.newStringRange("title", "0",  
     "101010101010101099大熊猫999", true, true);
 BooleanQuery.Builder builder = new BooleanQuery.Builder();
 builder.add(query1, BooleanClause.Occur.MUST);
 builder.add(query2, BooleanClause.Occur.MUST);
 builder.add(query3, BooleanClause.Occur.MUST);
 

这个组合就是拿1个query1的值比如docId=1,查询query2中是不是存在docId=1,这里可能用到skiplist,看数据跨越性,然后返回docId=1,然后拿docId=1去查query3的bitSet里有没有,如果有的话就放到list,就这样遍历完所有的query1的数据,就把最后的结果集返回。

4.5:单个数字类型point的精确查询

Query query1 = LongPoint.newExactQuery("id", 116);
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.add(query1, BooleanClause.Occur.MUST);

这里是很具有迷惑性的一个行为,其实在point的精确查询是不存在的,所有的查询是被转化为:

Query query1 = LongPoint.newRangeQuery("id", 116, 116);

所有的精确查询都会被转化为range查询,数字类型和字符类型的range不太一样,因为term的倒排链是顺序的,而range的是没排序的,例如下面:

point 1 2 3 4 5 5
docId 10 4 5 1 3 2

对于不同的point(1,2,3,4,5),docId是无序的很好理解,对于同一个point怎么也是无序的,这就得说lucene的排序采用了基数排序(稳定排序)和内省排序(不稳定排序),因为在数据量小的情况采用内省排序,是不稳定的排序,会存在乱序的情况。查询的结果是无序的,因此会构建一个bitset,返回结果集。

4.6:数字类型point的range和term查询

Query query1 = LongPoint.newRangeQuery("id", 11, 200);
TermQuery query2 = new TermQuery(new Term("author", "lucene"));
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.add(query1, BooleanClause.Occur.MUST);
builder.add(query2, BooleanClause.Occur.MUST);

这个就和4.3完全一致,可以看上面的,不多做分析了。

其实这些就是lucene里的ConjunctionDISI,BitSetConjunctionDISI的实现,当然还有一个ConjunctionTwoPhaseIterator,原理基本上是一致的和上面,比如加入下面的语句:

Query query4 = LongPoint.newExactQuery("id", 116);
builder.add(query4, BooleanClause.Occur.MUST_NOT);

这句就会执行上面的ConjunctionTwoPhaseIterator,就是拿到上面的结果集再进行过滤一次,因为上面是must_not。

4.7:elastisearch对于point的优化:IndexOrDocValueQuery

lucene其实是有IndexOrDocValueQuery的,只是没有做优化,这也突出了lucene是查询引擎,底层的东西都有,根据实际情况,使用者可以自己优化,具体操作是下面:

Block k-d tree的特性可以看出,rangeQuery的结果集比较小的时候,其构造bitset的代价很低,不管是从他开始迭代做nextdoc(),或者从其他结果集开始迭代,对其做advance,都会比较快。 但是如果rangeQuery的结果集非常巨大,则构造bitset的过程会大大延缓scorer对象的构造过程,造成结果合并过程缓慢。
这个问题官方其实早已经意识到了,所以从ES5.4开始,引入了indexOrDocValuesQuery作为对RangeQuery的优化。(参考: better-query-planning-for-range-queries-in-elasticsearch)。 这个Query包装了上面的PointRangeQuery和SortedSetDocValuesRangeQuery,并且会根据Rang查询的数据集大小,以及要做的合并操作类型,决定用哪种Query。 如果Range的代价小,可以用来引领合并过程,就走PointRangeQuery,直接构造bitset来进行迭代。 而如果range的代价高,构造bitset太慢,就使用SortedSetDocValuesRangeQuery。 这个Query利用了DocValues这种全局docID序,并包含每个docid对应value的数据结构来做文档的匹配。 当给定一个docid的时候,一次随机磁盘访问就可以定位到该id对应的value,从而可以判断该doc是否match。 因此它非常适合从其他查询条件得到的一个小结果集作为迭代起点,对于每个docid依次调用其内部的matches()函数判断匹配与否。也就是说, 5.4新增的indexOrDocValuesQuery将Range查询过程中的顺序访问任务扔给Block k-d Tree索引,将随机访任务交给doc values。 值得注意的是目前这个优化只针对RangeQuery!对于TermQuery,因为实际的复杂性,还未做类似的优化,也就导致对于数值型字段,Term和Range Query的性能差异极大。

5:总结

lucene到这里可以小结一下了,当然lucene的优秀的东西不知这些,这些只是一部分,这些我讲的也比较粗,lucene的压缩存储也是很优秀额一部分,索引组织形式和储存形式,很优秀,这篇没有机会讲了,有时间我再挑时间说吧。

猜你喜欢

转载自blog.csdn.net/u014274324/article/details/107127456
今日推荐