Lucene查询分析-结果合并

    技术2025-10-26  9

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

    1:倒排索引

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

    idnameagesex1小明10女2小黑12男

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

    name

    id

    小明 1 ageid122

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

    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是顺序的,跳跃性不大,如果跳跃性很大并且词频超过128,是会走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,实际上是下面这种结构:

    0000000000min1010101010max

    然后会搜索所有的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的是没排序的,例如下面:

    point123455docId1045132

    对于不同的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的压缩存储也是很优秀额一部分,索引组织形式和储存形式,很优秀,这篇没有机会讲了,有时间我再挑时间说吧。

    Processed: 0.009, SQL: 9