Lucene源码系列(二十七):BKD树构建

1,907 阅读15分钟

概述

BKD树的来源需要追溯到KD树,需要详细了解演变的过程以及BKD树的全部细节,可以参考以下链接:

Multidimensional Binary Search Trees Used for Associative Searching

The K-D-B-tree: a search structure for large multidimensional dynamic indexes

Bkd-Tree: A Dynamic Scalable kd-Tree

根据论文中的描述,BKD树一共有两种形式,一种是二叉BKD树,还有一种是网状的,在lucene的实现中,是基于二叉BKD树来实现的,实现逻辑比较简单。需要注意的是,Lucene的实现中,强制保证了二叉BKD树是一棵完全二叉树。后文描述中不再强调这是完全二叉BKD树,直接描述为BKD树。

题外话:如果偷懒不想看论文的,可以简单查阅些kd树的知识也可以无障碍阅读本文。

从一个面试题开始

在正式介绍BKD树的构建过程之前,我们先看一个问题,需要手写代码的面试官尽管拿去:

给定从左到右排好序的叶子节点,怎么构建一棵完全二叉树?

对于树的构建,一般是使用递归的算法,不停地分为左右子树进行构建,直到叶子节点为止。我们以一个例子来描述构建算法,假设给定的叶子节点是:

完全二叉树构建-0.png

  • 第0层

    首先,对于根节点,需要计算分配给左右子树的叶子节点数,因为要满足完全二叉树的性质,所以左子树的叶子节点个数是有要求的(具体如何计算我们下一节再介绍,这里假设已经有了计算方法),这里直接给出答案左子树的叶子节点需要4个,右子树的叶子节点有3个,如果子树中的叶子节点数超过1,则需要继续处理。

    完全二叉树构建-1.png

  • 第1层

    在第1层中的两个内部节点分别作为根节点,递归处理。这两个根节点分别计算其左右子树的叶子节点个数,当叶子节点数只有1时,说明该子树处理完毕,如7号叶子节点。

    完全二叉树构建-2.png

  • 第2层

    在第2层中的3个内部节点分别作为根节点,递归处理。这两个根节点分别计算其左右子树的叶子节点个数,在当前层算出所有的子树叶子节点都是1,则构建结束,最终的完全二叉树如下所示:

    完全二叉树构建-3.png

上面的例子的叶子节点是单值,而我们今天要介绍的BKD的叶子节点都是向量,但是整体的构建逻辑是一样的,区别在于每个内部节点分配左右子树叶子节点的时候,需要根据一定的策略选择某一个维数,以这个维数的值来排序分配叶子节点,这种策略下一节详细介绍。所以构建的结果就额外需要记录每个内部节点所选用的排序维度。

构建流程

一个非常粗略,极度简化忽略所有细节的构建逻辑如下:

build (allLeafNodeSet) {
	if (allLeafNodeSet.size() == 1) {
		存储所有的叶子节点数据;
		return;
	}
	
	根据策略获取对当前叶子节点集合用来排序的维度;
	根据完全二叉树的要求,计算左子树的叶子节点总数:numLeftLeafNodes;
	使用选择排序算法获取左子树的叶子节点集合:leftLeafNodeSet和右子树的叶子节点集合:rightLeafNodeSet;
	build (leftLeafNodeSet)
	build (rightLeafNodeSet)
}

其中最核心的两个问题是如何计算左子树的叶子节点个数,以及如何选择排序的维度:

  • 如何计算左子树的叶子节点个数?

    lucene中的源码实现在org.apache.lucene.util.bkd.BKDWriter#getNumLeftLeafNodes:

    // numLeaves:总的叶子节点数
    private int getNumLeftLeafNodes(int numLeaves) {
      // 叶子节点从哪一层开始存在的
      int lastFullLevel = 31 - Integer.numberOfLeadingZeros(numLeaves);
      // how many leaf nodes are in the full level
      int leavesFullLevel = 1 << lastFullLevel;
      // 当前层左子树的节点个数
      int numLeftLeafNodes = leavesFullLevel / 2;
      // 还有几个叶子节点必须到下一层
      int unbalancedLeafNodes = numLeaves - leavesFullLevel;
      // 抱枕左子树肯定是满的,左子树最多的叶子节点就是numLeftLeafNodes*2
      numLeftLeafNodes += Math.min(unbalancedLeafNodes, numLeftLeafNodes);
      return numLeftLeafNodes;
    }
    

    上面这个方法直接看,其实不太容易理解,如果你觉得懵逼,不要着急,我自己在看方法时也卡了很久。这里可以先看两个例子,再去细读就好理解了。左子树叶子节点的计算需要分为两种情况:

    情况一:unbalancedLeafNodes <= numLeftLeafNodes

    左子树节点数计算.png

    我们看上面的图,假设当前根节点要分配的叶子节点一共有5个,通过31 - Integer.numberOfLeadingZeros(5)=2可以得到叶子节点是从第2层开始分布的,第2层可以容纳的叶子节点总数是1<<2=4。在第2层,左子树最多可以容纳的叶子节点numLeftLeafNodes=4/2=2。当前层分配满叶子节点后,剩下unbalancedLeafNodes =5-4=1个叶子节点,因为要保证是满二叉树,剩下的1个叶子节点必须放在左子树的下一层,所以左子树分配的总叶子节点数就是2+1=3。

    情况二:unbalancedLeafNodes > numLeftLeafNodes

    左子树节点数计算2.png

    我们看上面的图,假设当前根节点要分配的叶子节点一共有7个,通过31 - Integer.numberOfLeadingZeros(7)=2可以得到叶子节点是从第2层开始分布的,第2层可以容纳的叶子节点总数是1<<2=4。在第2层,左子树最多可以容纳的叶子节点numLeftLeafNodes=4/2=2。当前层分配满叶子节点后,剩下unbalancedLeafNodes =7-4=3个叶子节点,因为要保证是满二叉树,剩下的3个叶子节点必须放在左子树的下一层,但是左子树的下一层可以容纳的最多叶子节点是当前层的2倍,所以左子树分配的总叶子节点数就是numLeftLeafNodes + Math.min(unbalancedLeafNodes, numLeftLeafNodes)=2+2=4。

  • 如何选择排序的维度?

    lucene中的源码实现在org.apache.lucene.util.bkd.BKDWriter#split,选择排序的维度逻辑比较简单,一共有两种策略,可以直接看代码:

    // minPackedValue:所有数据中的每一维的最小值
    // maxPackedValue:所有数据中的每一维的最大值
    // parentSplits:在上层中各个维度成为排序维度的次数
    protected int split(byte[] minPackedValue, byte[] maxPackedValue, int[] parentSplits) {
      // 第一种: 寻找排序的维度是用来排序的次数小于最多排序次数维度的一半,并且所有的值并不是都相等的维度,这种策略是为了确保如果BKD树比较高,所有的维度都有机会用于排序
      int maxNumSplits = 0;
      for (int numSplits : parentSplits) { // 寻找目前用来排序次数最多的维度
        maxNumSplits = Math.max(maxNumSplits, numSplits);
      }
      for (int dim = 0; dim < config.numIndexDims; ++dim) {
        final int offset = dim * config.bytesPerDim;
        if (parentSplits[dim] < maxNumSplits / 2
            && comparator.compare(minPackedValue, offset, maxPackedValue, offset) != 0) {
          // 如果某个维度用来排序的次数小于最多排序次数维度的一半,并且所有的值并不是都相等
          return dim;
        }
      }
    
      // 第二种:寻找最大值和最小值差距最大的维度
      int splitDim = -1;
      for (int dim = 0; dim < config.numIndexDims; dim++) {
        NumericUtils.subtract(config.bytesPerDim, dim, maxPackedValue, minPackedValue, scratchDiff);
        // scratch1是临时变量,存储当前已找到的用户排序维度的最大值和最小值的差值  
        if (splitDim == -1 || comparator.compare(scratchDiff, 0, scratch1, 0) > 0) {
          System.arraycopy(scratchDiff, 0, scratch1, 0, config.bytesPerDim);
          splitDim = dim;
        }
      }
    
      return splitDim;
    }
    

索引构建

point数据的索引构建最终会生成3个索引文件:

  • kdd:存储BKD树所有的叶子节点中的point数据。

  • kdi:整棵BKD树最终是被序列化存储的,因此需要额外的索引信息,为了方便在BKD树中从当前节点定位到左右子节点,或者是兄弟节点的位置。

  • kdm:存储的是整棵BKD树的一些元信息,方便在反序列化的时候还原。

在索引过程中,先把point数据序列化成字节数组存储在PointValuesWriter中,flush成segment的时候,org.apache.lucene.codecs.lucene90.Lucene90PointsWriter#writeField会创建BKDWriter对象,然后使用org.apache.lucene.util.bkd.BKDWriter#writeField方法进入构建流程。

下面我们详细介绍BKDWriter中的逻辑。

成员变量

// 关于BKD树构建过程的一些配置信息
protected final BKDConfig config;

private final ByteArrayComparator comparator;
private final ByteArrayPredicate equalsPredicate;
private final ByteArrayComparator commonPrefixComparator;

final TrackingDirectoryWrapper tempDir;
final String tempFileNamePrefix;
final double maxMBSortInHeap;

// 以下这些都是临时变量
final byte[] scratchDiff;
final byte[] scratch1;
final byte[] scratch2;
final BytesRef scratchBytesRef1 = new BytesRef();
final BytesRef scratchBytesRef2 = new BytesRef();

// 下标是维度,值是对应维度的所有值的最长公共前缀
final int[] commonPrefixLengths;

// 存储docID
protected final FixedBitSet docsSeen;
// 存储所有的数据
private PointWriter pointWriter;
// 是否结束数据添加,开始构建
private boolean finished;

private IndexOutput tempInput;
private final int maxPointsSortInHeap;

// 下标是维度,值是这一维中的最小值
protected final byte[] minPackedValue;

// 下标是维度,值是这一维中的最大值
protected final byte[] maxPackedValue;

// 总的point数
private final long totalPointCount;

private final int maxDoc;

构建入口

public Runnable writeField(
    IndexOutput metaOut,
    IndexOutput indexOut,
    IndexOutput dataOut,
    String fieldName,
    MutablePointTree reader)
    throws IOException {
  if (config.numDims == 1) {
    return writeField1Dim(metaOut, indexOut, dataOut, fieldName, reader);
  } else {
    return writeFieldNDims(metaOut, indexOut, dataOut, fieldName, reader);
  }
}

构建BKD按照point数据是一维还是多维分成了两种逻辑来完成。

一维数据

一维数据的kdd文件,直接把所有的数据进行排序之后,直接按序存储。kdi文件中的各个叶子节点的起始位置,可以通过每个叶子节点最多可以容纳的数据总数计算得知。一维数据的处理主要逻辑在OneDimensionBKDWriter中。

private Runnable writeField1Dim(
    IndexOutput metaOut,
    IndexOutput indexOut,
    IndexOutput dataOut,
    String fieldName,
    MutablePointTree reader)
    throws IOException {
  // 排序  
  MutablePointTreeReaderUtils.sort(config, maxDoc, reader, 0, Math.toIntExact(reader.size()));
  // 创建 OneDimensionBKDWriter
  final OneDimensionBKDWriter oneDimWriter =
      new OneDimensionBKDWriter(metaOut, indexOut, dataOut);

  reader.visitDocValues(
      new IntersectVisitor() {

        @Override
        public void visit(int docID, byte[] packedValue) throws IOException {
          // 入口在这个方法,把所有的数据都添加到oneDimWriter
          oneDimWriter.add(packedValue, docID);
        }

        @Override
        public void visit(int docID) {
          throw new IllegalStateException();
        }

        @Override
        public Relation compare(byte[] minPackedValue, byte[] maxPackedValue) {
          return Relation.CELL_CROSSES_QUERY;
        }
      });
  // 返回一个构建kdi的Runnable方法
  return oneDimWriter.finish();
}

一维数据的kdd文件,直接把所有的数据进行排序之后,直接按序存储。kdi文件中的各个叶子节点的起始位置,可以通过每个叶子节点最多可以容纳的数据总数计算得知。一维数据的处理主要逻辑在OneDimensionBKDWriter中,主要做了三件事:

  1. 添加数据
  2. 持久化叶子节点
  3. 构建kdi索引

下面我们分别来介绍。

添加数据

void add(byte[] packedValue, int docID) throws IOException {
  if (leafCount == 0
      || equalsPredicate.test(leafValues, (leafCount - 1) * config.bytesPerDim, packedValue, 0)
          == false) { // 统计有多少个不一样的值,只要跟前一个值相比就行,因为是排好序的
    leafCardinality++;
  }
  // 当前正在处理的point拷贝到leafValues  
  System.arraycopy(
      packedValue,
      0,
      leafValues,
      leafCount * config.packedBytesLength,
      config.packedBytesLength);
  // 记录docID  
  leafDocs[leafCount] = docID;
  docsSeen.set(docID);
  // 当前叶子节点的point个数  
  leafCount++;

  if (valueCount + leafCount > totalPointCount) {
    throw new IllegalStateException(
        "totalPointCount="
            + totalPointCount
            + " was passed when we were created, but we just hit "
            + (valueCount + leafCount)
            + " values");
  }

  if (leafCount == config.maxPointsInLeafNode) { // 一个叶子节点满了
    // 持久化叶子节点的数据  
    writeLeafBlock(leafCardinality);
    leafCardinality = 0;
    leafCount = 0;
  }
}

持久化叶子节点

  private void writeLeafBlock(int leafCardinality) throws IOException {
    if (valueCount == 0) { // minPackedValue就是第一个叶子节点的第一个point
      System.arraycopy(leafValues, 0, minPackedValue, 0, config.packedIndexBytesLength);
    }
    // 当前叶子节点的最后一个point就是maxPackedValue
    System.arraycopy(
        leafValues,
        (leafCount - 1) * config.packedBytesLength,
        maxPackedValue,
        0,
        config.packedIndexBytesLength);
    // 更新当前已经处理的point个数
    valueCount += leafCount;

    if (leafBlockFPs.size() > 0) { // 每个叶子节点的第一个point当做是叶子节点之间的分割点
      leafBlockStartValues.add(ArrayUtil.copyOfSubArray(leafValues, 0, config.packedBytesLength));
    }
    // 当前叶子节点在kdd文件中的起始位置  
    leafBlockFPs.add(dataOut.getFilePointer());
    checkMaxLeafNodeCount(leafBlockFPs.size());

    // 获取所有值的公共前缀
    commonPrefixLengths[0] =
        commonPrefixComparator.compare(
            leafValues, 0, leafValues, (leafCount - 1) * config.packedBytesLength);
    // 记录当前叶子节点中point对应的docID集合
    writeLeafBlockDocs(dataOut, leafDocs, 0, leafCount);
    // 记录公共前缀  
    writeCommonPrefixes(dataOut, commonPrefixLengths, leafValues);

    scratchBytesRef1.length = config.packedBytesLength;
    scratchBytesRef1.bytes = leafValues;

    final IntFunction<BytesRef> packedValues =
        new IntFunction<BytesRef>() {
          @Override
          public BytesRef apply(int i) {
            scratchBytesRef1.offset = config.packedBytesLength * i;
            return scratchBytesRef1;
          }
        };
    // 持久化所有的point数据,有两种方式,后面详细介绍
    writeLeafBlockPackedValues(
        dataOut, commonPrefixLengths, leafCount, 0, packedValues, leafCardinality);
  }
}
writeLeafBlockPackedValues
private void writeLeafBlockPackedValues(
    DataOutput out,
    int[] commonPrefixLengths,
    int count,
    int sortedDim,
    IntFunction<BytesRef> packedValues,
    int leafCardinality)
    throws IOException {
  int prefixLenSum = Arrays.stream(commonPrefixLengths).sum();
  if (prefixLenSum == config.packedBytesLength) {
    // 所有的point数据都相等,直接写个-1做标记
    out.writeByte((byte) -1);
  } else {
    // 每个point是从compressedByteOffset开始记录的
    int compressedByteOffset = sortedDim * config.bytesPerDim + commonPrefixLengths[sortedDim];
    int highCardinalityCost;
    int lowCardinalityCost;
    if (count == leafCardinality) {
      // 所有的值都不相等,直接用highCardinality方式进行存储,所以随便设个highCardinalityCost < lowCardinalityCost就行
      highCardinalityCost = 0;
      lowCardinalityCost = 1;
    } else {
      int numRunLens = 0;
      for (int i = 0; i < count; ) {
        // runLen是从第i个point开始,suffix的第一个字节相同的point有多少个
        int runLen = runLen(packedValues, i, Math.min(i + 0xff, count), compressedByteOffset);
        numRunLens++;
        i += runLen;
      }
      // numRunLens就是下面例子图中,可以按suffix的第一个字节相同的分为多少组
      // 存储suffix占用的空间是:count * (config.packedBytesLength - prefixLenSum - 1)
      // 存储每一组个数需要: numRunLens个字节
      // 存储每一组中suffix的第一个字节需要: numRunLens个字节 
      highCardinalityCost =
          count * (config.packedBytesLength - prefixLenSum - 1) + 2 * numRunLens;

      // leafCardinality就是我们下面例子图中,可以按值相同分为多少组
      // 每一组占用的空间是:suffix+count
      // suffix占用的字节数:config.packedBytesLength - prefixLenSum
      // count占用的字节数:1  
      lowCardinalityCost = leafCardinality * (config.packedBytesLength - prefixLenSum + 1);
    }
    // 使用cost小的存储策略  
    if (lowCardinalityCost <= highCardinalityCost) {
      out.writeByte((byte) -2);
      writeLowCardinalityLeafBlockPackedValues(out, commonPrefixLengths, count, packedValues);
    } else {
      out.writeByte((byte) sortedDim);
      writeHighCardinalityLeafBlockPackedValues(
          out, commonPrefixLengths, count, sortedDim, packedValues, compressedByteOffset);
    }
  }
}

持久化所有point数据的方法writeLeafBlockPackedValues是一维和多维数据共用的。叶子节点数据两种存储方式:LowCardinality和HighCardinality。writeLeafBlockPackedValues中主要是计算两种方式占用的空间,然后选择存储空间占用比较小的那一种,存储空间的计算跟存储策略相关,看完下面的例子就明白了。

不管是哪种存储方式,前提就是所有的数据是有序的,并且数据长度相同。下面的例子中,我们会以单值为例。

  • LowCardinality

    这种情况下,叶子节点中有比较多的重复的数据,因此可以在排序之后记录每个数据出现的次数以及数据本身就可以。

    LowCardinality.png

    如上图所示,存在排好序的数据:aaa,aaa,abb,abb,abb,abc,abc。这些数据的公共前缀为a,所有数据的公共前缀可以单独存储。去掉公共前缀之后的数据列表为:aa,aa,bb,bb,bb,bc,bc,这些数据可以分为值都相等的三组,每一组可以编码为值的个数和值,如第一组(aa,aa)编码为(2,aa),其他的结果如上图所示。代码实现如下:

    private void writeLowCardinalityLeafBlockPackedValues(
        DataOutput out, int[] commonPrefixLengths, int count, IntFunction<BytesRef> packedValues)
        throws IOException {
      if (config.numIndexDims != 1) { // 记录每一维的最小值和最大值
        writeActualBounds(out, commonPrefixLengths, count, packedValues);
      }
      // 获取叶子节点中的第一个point  
      BytesRef value = packedValues.apply(0);
      // 把第一个point拷贝到scratch1中,作为用来判断后续值是否相等的key  
      System.arraycopy(value.bytes, value.offset, scratch1, 0, config.packedBytesLength);
      int cardinality = 1;
      for (int i = 1; i < count; i++) { // 遍历所有的point
        value = packedValues.apply(i);
        for (int dim = 0; dim < config.numDims; dim++) { // 遍历每一维
          final int start = dim * config.bytesPerDim;
          if (equalsPredicate.test(value.bytes, value.offset + start, scratch1, start) == false) { // 如果这一维不相等,就说明这个point和前面一个point不相等了,已经找到一个值都相等的区间了
            // 记录有多少个值相等  
            out.writeVInt(cardinality);
            for (int j = 0; j < config.numDims; j++) { // 记录每一维除了公共前缀之外的其他数据
              out.writeBytes(
                  scratch1,
                  j * config.bytesPerDim + commonPrefixLengths[j],
                  config.bytesPerDim - commonPrefixLengths[j]);
            }
            // 把当前值拷贝到scratch1中,作为下一轮对比的key  
            System.arraycopy(value.bytes, value.offset, scratch1, 0, config.packedBytesLength);
            // 重置相等值的统计  
            cardinality = 1;
            break;
          } else if (dim == config.numDims - 1) { // 值和key相同
            cardinality++;
          }
        }
      }
      // 处理最后一批值相等的数据  
      out.writeVInt(cardinality);
      for (int i = 0; i < config.numDims; i++) {
        out.writeBytes(
            scratch1,
            i * config.bytesPerDim + commonPrefixLengths[i],
            config.bytesPerDim - commonPrefixLengths[i]);
      }
    }
    
  • HighCardinality

    这种情况是叶子节点中没有什么重复的数据,在这种情况下,试图寻找所有的值除了公共前缀之外,剩下的后缀中第一个字节相同的数据。

    HighCaidinality.png

    如上图所示,除了公共前缀之外的后缀数据,根据第一个字节是否相同可以分为4组,每一组可以编码为(第一个字节,全组数量,suffix1,suffix2,。。。,suffixN)。比如我们看(aa,ab)这一组,可以编码成(a,2,a,b),全部编码结果可以看上图。代码实现如下:

    private void writeHighCardinalityLeafBlockPackedValues(
        DataOutput out,
        int[] commonPrefixLengths,
        int count,
        int sortedDim,
        IntFunction<BytesRef> packedValues,
        int compressedByteOffset)
        throws IOException {
      if (config.numIndexDims != 1) { // 记录每一维的最小值和最大值
        writeActualBounds(out, commonPrefixLengths, count, packedValues);
      }
      commonPrefixLengths[sortedDim]++;
      for (int i = 0; i < count; ) {
        // 计算从i开始,总共有几个数据除了公共前缀之外的第一个字节是相等的
        int runLen = runLen(packedValues, i, Math.min(i + 0xff, count), compressedByteOffset);
        assert runLen <= 0xff;
        BytesRef first = packedValues.apply(i);
        byte prefixByte = first.bytes[first.offset + compressedByteOffset];
        // 记录前缀  
        out.writeByte(prefixByte);
        // 记录个数  
        out.writeByte((byte) runLen);
        // 记录除了前缀剩下的数据  
        writeLeafBlockPackedValuesRange(out, commonPrefixLengths, i, i + runLen, packedValues);
        i += runLen;
        assert i <= count;
      }
    }
    

构建kdi索引

因为叶子节点的数据是单独记录的,并不是一个BKD树结构,真正体现BKD树结构的是kdi索引文件,其中记录了BKD树各个节点的索引信息,可以从根节点遍历找到目标节点或者区域。

private void writeIndex(
    IndexOutput metaOut,
    IndexOutput indexOut,
    int countPerLeaf,
    BKDTreeLeafNodes leafNodes,
    long dataStartFP)
    throws IOException {
  byte[] packedIndex = packIndex(leafNodes);
  writeIndex(metaOut, indexOut, countPerLeaf, leafNodes.numLeaves(), packedIndex, dataStartFP);
}

构建kdi索引的方法writeIndex也是一维数据和多维数据共用的,分为两步:

  • packedIndex:构建满二叉的索引树
  • writeIndex:持久化索引树
packedIndex
private byte[] packIndex(BKDTreeLeafNodes leafNodes) throws IOException {
  // 临时存储
  ByteBuffersDataOutput writeBuffer = ByteBuffersDataOutput.newResettableInstance();

  // 递归过程中,构建好的索引树片段存储在blocks中
  List<byte[]> blocks = new ArrayList<>();
  byte[] lastSplitValues = new byte[config.bytesPerDim * config.numIndexDims];
  // 递归构建索引树
  int totalSize =
      recursePackIndex(
          writeBuffer,
          leafNodes,
          0l,
          blocks,
          lastSplitValues,
          new boolean[config.numIndexDims],
          false,
          0,
          leafNodes.numLeaves());

  // 完整的索引树存储到index
  byte[] index = new byte[totalSize];
  int upto = 0;
  for (byte[] block : blocks) {
    System.arraycopy(block, 0, index, upto, block.length);
    upto += block.length;
  }

  return index;
}

在packedIndex中主要是通过recursePackIndex递归构建各个节点的索引数据,在recursePackIndex对不同的节点生成的索引数据不一样,具体如下:

  • 叶子节点

    • 左节点

      不生成任何索引数据

    • 右节点

      deltaFP:右节点起始位置和左兄弟起始位置的差值

  • 内部节点

    • root节点
      • startFP:在kdd文件中的起始位置
      • code:是firstDiffByteDelta(和前一个split value的第一个不相等的字节差值),prefix(和前一个split value的公共前缀长度),splitDim混合编码的结果
      • split value的suffix
      • leftNumBytes:左节点占用的总空间
    • 左节点
      • code:是firstDiffByteDelta(和前一个split value的第一个不相等的字节差值),prefix(和前一个split value的公共前缀长度),splitDim混合编码的结果
      • split value的suffix
      • leftNumBytes:左节点占用的总空间
    • 右节点
      • deltaFP:右节点起始位置和左兄弟起始位置的差值
      • code:是firstDiffByteDelta(和前一个split value的第一个不相等的字节差值),prefix(和前一个split value的公共前缀长度),splitDim混合编码的结果
      • split value的suffix

详细的构建逻辑见代码recursePackIndex:

private int recursePackIndex(
    ByteBuffersDataOutput writeBuffer, // 用来临时存储数据,满足一个block的时候会加入到block中
    BKDTreeLeafNodes leafNodes, // 获取叶子节点的相关数据
    long minBlockFP, // 当前处理叶子节点范围中最左边叶子节点在kdd中的位置
    List<byte[]> blocks, // 
    byte[] lastSplitValues, // 每一维的上一次分割点的值
    boolean[] negativeDeltas,
    boolean isLeft, // 是否是左子树
    int leavesOffset, // 从第几个叶子节点开始
    int numLeaves) // 要处理多少个叶子节点
    throws IOException {
  if (numLeaves == 1) { // 叶子节点
    if (isLeft) {
      return 0;
    } else {
      long delta = leafNodes.getLeafLP(leavesOffset) - minBlockFP;
      writeBuffer.writeVLong(delta);
      return appendBlock(writeBuffer, blocks);
    }
  } else { // 内部节点
    long leftBlockFP;
    if (isLeft) {
      leftBlockFP = minBlockFP;
    } else {
      leftBlockFP = leafNodes.getLeafLP(leavesOffset);
      long delta = leftBlockFP - minBlockFP;
      writeBuffer.writeVLong(delta);
    }
    // 左子树的叶子节点个数
    int numLeftLeafNodes = getNumLeftLeafNodes(numLeaves);
    // 右子树的叶子节点起始编号  
    final int rightOffset = leavesOffset + numLeftLeafNodes;
    // 分割点的编号  
    final int splitOffset = rightOffset - 1;
    // 用来分割的值的维度
    int splitDim = leafNodes.getSplitDimension(splitOffset);
    // 分割点的值,注意值是从分割维度开始的  
    BytesRef splitValue = leafNodes.getSplitValue(splitOffset);
    int address = splitValue.offset;
    // 和前一个分割值相比,前缀长度
    int prefix =
        commonPrefixComparator.compare(
            splitValue.bytes, address, lastSplitValues, splitDim * config.bytesPerDim);

    // 第一个不相等的值的差值  
    int firstDiffByteDelta;
    if (prefix < config.bytesPerDim) {
      firstDiffByteDelta =
          (splitValue.bytes[address + prefix] & 0xFF)
              - (lastSplitValues[splitDim * config.bytesPerDim + prefix] & 0xFF);
      if (negativeDeltas[splitDim]) { // 左子树和split value的差值是负的,需要转化下符号
        firstDiffByteDelta = -firstDiffByteDelta;
      }
      assert firstDiffByteDelta > 0;
    } else {
      firstDiffByteDelta = 0;
    }
    // 把  firstDiffByteDelta,prefix,splitDim编码成code
    int code =
        (firstDiffByteDelta * (1 + config.bytesPerDim) + prefix) * config.numIndexDims + splitDim;

    writeBuffer.writeVInt(code);
    // 存储splitvalue的后缀
    int suffix = config.bytesPerDim - prefix;
    byte[] savSplitValue = new byte[suffix];
    if (suffix > 1) {
      writeBuffer.writeBytes(splitValue.bytes, address + prefix + 1, suffix - 1);
    }

    byte[] cmp = lastSplitValues.clone();
    // 更新lastSplitValues
    System.arraycopy(
        lastSplitValues, splitDim * config.bytesPerDim + prefix, savSplitValue, 0, suffix);

    System.arraycopy(
        splitValue.bytes,
        address + prefix,
        lastSplitValues,
        splitDim * config.bytesPerDim + prefix,
        suffix);

    int numBytes = appendBlock(writeBuffer, blocks);
    // 当前位置空出来,留着存储左子树的总大小,这样就能快速定位到兄弟节点
    int idxSav = blocks.size();
    blocks.add(null);

    boolean savNegativeDelta = negativeDeltas[splitDim];
    negativeDeltas[splitDim] = true;
    // 递归构建左子树的索引树
    int leftNumBytes =
        recursePackIndex(
            writeBuffer,
            leafNodes,
            leftBlockFP,
            blocks,
            lastSplitValues,
            negativeDeltas,
            true,
            leavesOffset,
            numLeftLeafNodes);

    if (numLeftLeafNodes != 1) {
      writeBuffer.writeVInt(leftNumBytes);
    } else {
      assert leftNumBytes == 0 : "leftNumBytes=" + leftNumBytes;
    }

    byte[] bytes2 = writeBuffer.toArrayCopy();
    writeBuffer.reset();
    blocks.set(idxSav, bytes2);

    negativeDeltas[splitDim] = false;
    // 递归构建右子树的索引树  
    int rightNumBytes =
        recursePackIndex(
            writeBuffer,
            leafNodes,
            leftBlockFP,
            blocks,
            lastSplitValues,
            negativeDeltas,
            false,
            rightOffset,
            numLeaves - numLeftLeafNodes);

    negativeDeltas[splitDim] = savNegativeDelta;

    System.arraycopy(
        savSplitValue, 0, lastSplitValues, splitDim * config.bytesPerDim + prefix, suffix);

    return numBytes + bytes2.length + leftNumBytes + rightNumBytes;
  }
}
writeIndex

持久化索引信息的时候也会生成kdm元信息索引文件。

private void writeIndex(
    IndexOutput metaOut,
    IndexOutput indexOut,
    int countPerLeaf,
    int numLeaves,
    byte[] packedIndex,
    long dataStartFP)
    throws IOException {
  CodecUtil.writeHeader(metaOut, CODEC_NAME, VERSION_CURRENT);
  metaOut.writeVInt(config.numDims);
  metaOut.writeVInt(config.numIndexDims);
  metaOut.writeVInt(countPerLeaf);
  metaOut.writeVInt(config.bytesPerDim);

  metaOut.writeVInt(numLeaves);
  metaOut.writeBytes(minPackedValue, 0, config.packedIndexBytesLength);
  metaOut.writeBytes(maxPackedValue, 0, config.packedIndexBytesLength);

  metaOut.writeVLong(pointCount);
  metaOut.writeVInt(docsSeen.cardinality());
  metaOut.writeVInt(packedIndex.length);
  metaOut.writeLong(dataStartFP);
  metaOut.writeLong(indexOut.getFilePointer() + (metaOut == indexOut ? Long.BYTES : 0));

  indexOut.writeBytes(packedIndex, 0, packedIndex.length);
}

多维数据

多维数据需要按照第二节介绍的构建方法来确认各个叶子节点中的point分布。

writeFieldNDims

private Runnable writeFieldNDims(
    IndexOutput metaOut,
    IndexOutput indexOut,
    IndexOutput dataOut,
    String fieldName,
    MutablePointTree values)
    throws IOException {
  if (pointCount != 0) {
    throw new IllegalStateException("cannot mix add and writeField");
  }

  if (finished == true) {
    throw new IllegalStateException("already finished");
  }

  finished = true;

  pointCount = values.size();
  // 通过point总数和每个叶子节点最多有多少个point计算有多少个叶子节点
  final int numLeaves =
      Math.toIntExact((pointCount + config.maxPointsInLeafNode - 1) / config.maxPointsInLeafNode);
  // 需要多少个分裂点  
  final int numSplits = numLeaves - 1;

  checkMaxLeafNodeCount(numLeaves);

  // 下标是第几次分裂,值是split的value  
  final byte[] splitPackedValues = new byte[numSplits * config.bytesPerDim];
  // 下标是第几次分裂,值是split用的维度  
  final byte[] splitDimensionValues = new byte[numSplits];
  // 每个叶子节点在kdd文件中的起始位置  
  final long[] leafBlockFPs = new long[numLeaves];

  // 所有point的所有维度的最大值和最小值分别存储在maxPackedValue和minPackedValue中
  computePackedValueBounds(
      values, 0, Math.toIntExact(pointCount), minPackedValue, maxPackedValue, scratchBytesRef1);
  // 记录所有的docID  
  for (int i = 0; i < Math.toIntExact(pointCount); ++i) {
    docsSeen.set(values.getDocID(i));
  }

  final long dataStartFP = dataOut.getFilePointer();
  // 记录的是每一维已经用作split的次数  
  final int[] parentSplits = new int[config.numIndexDims];
  // 递归获取每个叶子节点并进行存储在kdd中
  build(
      0,
      numLeaves,
      values,
      0,
      Math.toIntExact(pointCount),
      dataOut,
      minPackedValue.clone(),
      maxPackedValue.clone(),
      parentSplits,
      splitPackedValues,
      splitDimensionValues,
      leafBlockFPs,
      new int[config.maxPointsInLeafNode]);

  scratchBytesRef1.length = config.bytesPerDim;
  scratchBytesRef1.bytes = splitPackedValues;

  BKDTreeLeafNodes leafNodes =
      new BKDTreeLeafNodes() {
        // 获取第index个叶子节点在kdd中的起始位置
        public long getLeafLP(int index) {
          return leafBlockFPs[index];
        }

        // 获取第index个叶子节点的分裂值
        public BytesRef getSplitValue(int index) {
          scratchBytesRef1.offset = index * config.bytesPerDim;
          return scratchBytesRef1;
        }

        // 获取第index个叶子节点的分裂维度
        public int getSplitDimension(int index) {
          return splitDimensionValues[index] & 0xff;
        }

        @Override
        public int numLeaves() {
          return leafBlockFPs.length;
        }
      };

  return () -> {
    try {
      // 持久化索引树  
      writeIndex(metaOut, indexOut, config.maxPointsInLeafNode, leafNodes, dataStartFP);
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  };
}
构建叶子节点

多维数据因为在内部节点中,可能选择的排序的维度是不同的,所以需要走一遍BKD树构建逻辑来分配每个叶子节点中的point。

private void build(
    int leavesOffset, // 从第几个叶子节点开始
    int numLeaves, // 要处理几个叶子节点
    MutablePointTree reader, // 所有的point数据
    int from, // 从第几个point开始
    int to, // 从第几个point结束
    IndexOutput out,
    byte[] minPackedValue, // 当前要处理范围内的所有point的各个维度的最小值
    byte[] maxPackedValue, // 当前要处理范围内的所有point的各个维度的最大值
    int[] parentSplits, // 每一维作为split的次数
    byte[] splitPackedValues, // split的value
    byte[] splitDimensionValues, // split的dim
    long[] leafBlockFPs,
    int[] spareDocIds)
    throws IOException {

  if (numLeaves == 1) { // 叶子节点
    final int count = to - from;

    // 计算每一维的公共前缀  
    Arrays.fill(commonPrefixLengths, config.bytesPerDim);
    reader.getValue(from, scratchBytesRef1);
    for (int i = from + 1; i < to; ++i) {
      reader.getValue(i, scratchBytesRef2);
      for (int dim = 0; dim < config.numDims; dim++) {
        final int offset = dim * config.bytesPerDim;
        int dimensionPrefixLength = commonPrefixLengths[dim];
        commonPrefixLengths[dim] =
            Math.min(
                dimensionPrefixLength,
                commonPrefixComparator.compare(
                    scratchBytesRef1.bytes,
                    scratchBytesRef1.offset + offset,
                    scratchBytesRef2.bytes,
                    scratchBytesRef2.offset + offset));
      }
    }

    // 寻找每一维后缀的第一个字节,不一样值最少的dim
    // 这里应该是为了高效排序用的,我猜的,没有深入去看排序的逻辑  
    FixedBitSet[] usedBytes = new FixedBitSet[config.numDims];
    for (int dim = 0; dim < config.numDims; ++dim) {
      if (commonPrefixLengths[dim] < config.bytesPerDim) {
        usedBytes[dim] = new FixedBitSet(256);
      }
    }
    for (int i = from + 1; i < to; ++i) {
      for (int dim = 0; dim < config.numDims; dim++) {
        if (usedBytes[dim] != null) {
          byte b = reader.getByteAt(i, dim * config.bytesPerDim + commonPrefixLengths[dim]);
          usedBytes[dim].set(Byte.toUnsignedInt(b));
        }
      }
    }
    int sortedDim = 0;
    int sortedDimCardinality = Integer.MAX_VALUE;
    for (int dim = 0; dim < config.numDims; ++dim) {
      if (usedBytes[dim] != null) {
        final int cardinality = usedBytes[dim].cardinality();
        if (cardinality < sortedDimCardinality) {
          sortedDim = dim;
          sortedDimCardinality = cardinality;
        }
      }
    }

    // 按sortedDim排序
    MutablePointTreeReaderUtils.sortByDim(
        config,
        sortedDim,
        commonPrefixLengths,
        reader,
        from,
        to,
        scratchBytesRef1,
        scratchBytesRef2);

    // 统计叶子节点中有多少个不一样的值  
    BytesRef comparator = scratchBytesRef1;
    BytesRef collector = scratchBytesRef2;
    reader.getValue(from, comparator);
    int leafCardinality = 1;
    for (int i = from + 1; i < to; ++i) {
      reader.getValue(i, collector);
      for (int dim = 0; dim < config.numDims; dim++) {
        final int start = dim * config.bytesPerDim;
        if (equalsPredicate.test(
                collector.bytes,
                collector.offset + start,
                comparator.bytes,
                comparator.offset + start)
            == false) {
          leafCardinality++;
          BytesRef scratch = collector;
          collector = comparator;
          comparator = scratch;
          break;
        }
      }
    }
    // 记录叶子节点在kdd中的起始位置
    leafBlockFPs[leavesOffset] = out.getFilePointer();

    // 记录docID
    int[] docIDs = spareDocIds;
    for (int i = from; i < to; ++i) {
      docIDs[i - from] = reader.getDocID(i);
    }
    writeLeafBlockDocs(out, docIDs, 0, count);

    // 记录前缀 
    reader.getValue(from, scratchBytesRef1);
    System.arraycopy(
        scratchBytesRef1.bytes, scratchBytesRef1.offset, scratch1, 0, config.packedBytesLength); 
    writeCommonPrefixes(out, commonPrefixLengths, scratch1);

    IntFunction<BytesRef> packedValues =
        new IntFunction<BytesRef>() {
          @Override
          public BytesRef apply(int i) {
            reader.getValue(from + i, scratchBytesRef1);
            return scratchBytesRef1;
          }
        };
    // 持久化叶子节点的point数据,和前面一维数据的处理相同
    writeLeafBlockPackedValues(
        out, commonPrefixLengths, count, sortedDim, packedValues, leafCardinality);
  } else { // 内部节点
    // 分割左右子树的维度  
    final int splitDim;
    if (config.numIndexDims == 1) {
      splitDim = 0;
    } else {
      if (numLeaves != leafBlockFPs.length
          && config.numIndexDims > 2
          && Arrays.stream(parentSplits).sum() % SPLITS_BEFORE_EXACT_BOUNDS == 0) {
        // 计算当前的所有的point的各个维度的最大值和最小值  
        computePackedValueBounds(
            reader, from, to, minPackedValue, maxPackedValue, scratchBytesRef1);
      }
      // 寻找的策略有两种,具体方法我们前面介绍过了
      splitDim = split(minPackedValue, maxPackedValue, parentSplits);
    }

    // 计算左子树的叶子节点个数,方法我们前面介绍过了
    int numLeftLeafNodes = getNumLeftLeafNodes(numLeaves);
    // 左子树中有多少个point
    final int mid = from + numLeftLeafNodes * config.maxPointsInLeafNode;
    // 每个维度的最小值和最大值的最长公共前缀
    final int commonPrefixLen =
        commonPrefixComparator.compare(
            minPackedValue,
            splitDim * config.bytesPerDim,
            maxPackedValue,
            splitDim * config.bytesPerDim);
    // 分裂左右子树
    MutablePointTreeReaderUtils.partition(
        config,
        maxDoc,
        splitDim,
        commonPrefixLen,
        reader,
        from,
        to,
        mid,
        scratchBytesRef1,
        scratchBytesRef2);

    final int rightOffset = leavesOffset + numLeftLeafNodes;
    final int splitOffset = rightOffset - 1;
    // 分裂的value在splitPackedValues的位置
    final int address = splitOffset * config.bytesPerDim;
    // 记录分裂的维度  
    splitDimensionValues[splitOffset] = (byte) splitDim;
    reader.getValue(mid, scratchBytesRef1);
    // 记录分裂的value  
    System.arraycopy(
        scratchBytesRef1.bytes,
        scratchBytesRef1.offset + splitDim * config.bytesPerDim,
        splitPackedValues,
        address,
        config.bytesPerDim);

    // 更新左右子树各个维度的最大值和最小值  
    byte[] minSplitPackedValue =
        ArrayUtil.copyOfSubArray(minPackedValue, 0, config.packedIndexBytesLength);
    byte[] maxSplitPackedValue =
        ArrayUtil.copyOfSubArray(maxPackedValue, 0, config.packedIndexBytesLength);
    System.arraycopy(
        scratchBytesRef1.bytes,
        scratchBytesRef1.offset + splitDim * config.bytesPerDim,
        minSplitPackedValue,
        splitDim * config.bytesPerDim,
        config.bytesPerDim);
    System.arraycopy(
        scratchBytesRef1.bytes,
        scratchBytesRef1.offset + splitDim * config.bytesPerDim,
        maxSplitPackedValue,
        splitDim * config.bytesPerDim,
        config.bytesPerDim);

    // 递归处理
    parentSplits[splitDim]++;
    build(
        leavesOffset,
        numLeftLeafNodes,
        reader,
        from,
        mid,
        out,
        minPackedValue,
        maxSplitPackedValue,
        parentSplits,
        splitPackedValues,
        splitDimensionValues,
        leafBlockFPs,
        spareDocIds);
    build(
        rightOffset,
        numLeaves - numLeftLeafNodes,
        reader,
        mid,
        to,
        out,
        minSplitPackedValue,
        maxPackedValue,
        parentSplits,
        splitPackedValues,
        splitDimensionValues,
        leafBlockFPs,
        spareDocIds);
    parentSplits[splitDim]--;
  }
}

索引文件格式

构建好的BKD树的信息由kdd,kdi,kdm三个索引文件组成,这三个文件的具体结构下面来总结下,其中Header和Footer所有的索引文件都差不多直接略过不分析。

kdd

kdd文件存储的是BKD树的所有叶子节点的数据。

整体结构

kdd.png

字段详解

如上图所示,每个Field都有一棵BKD树,kdd存储的是BKD树中所有叶子节点的数据。每个叶子节点的数据结构如下:

  • Count:叶子节点中point的个数
  • DocIDs:叶子节点中point对应的docID列表
  • CommonPrefixes:所有point的所有维度的公共前缀
    • DimPrefix:每一维的公共前缀结构
      • PrefixLength:公共前缀长度
      • Prefix:前缀值
  • Points
    • 所有的值都相等:这种情况直接写个-1,真正的值可以在CommonPrefixes获取
    • LowCardinality
      • -2:标记位,表示使用的是LowCardinality
      • Min:该叶子节点中所有point各个维度的最小值
      • Max:该叶子节点中所有point各个维度的最大值
      • Group:排序之后值相同的被分为一组
        • Cardinality:组中值的个数
        • Suffix:各个值的suffix,prefix存储在CommonPrefixes中了
    • HighCardinality
      • SortedDim:排序的dim
      • Min:该叶子节点中所有point各个维度的最小值
      • Max:该叶子节点中所有point各个维度的最大值
      • Group:组中的所有值的suffix的第一个字节相等
        • PrefixByte:相等的第一个字节
        • RunLength:组中值的个数
        • PointSuffix:多个PointSuffix,存储的是对应point的suffix

kdi

kdi文件存储的是BKD树各个节点的索引信息,可以还原整个BKD树的结构。

整体结构

kdi.png

字段详解

每个Field的BKD树都有一棵对应的kdi索引树,索引树存储的是每个节点的索引信息。

  • 叶子节点
    • 左节点:无数据
    • 右节点
      • DeltaFP:右节点起始位置和左兄弟起始位置的差值
  • 内部节点
    • root
      • StartFP:在kdd文件中的起始位置
      • Code
        • FirstDiffByteDelta:和上一层的split value对应的split dim维第一个不相等的字符的差值
        • SplitPrefixLength:和上一层的split value的公共前缀长度
        • SplitDim:split的维度
      • SplitSuffix:和上一层的split value除了公共前缀的suffix
      • LeftNumBytes:左子树的总大小
    • 左节点
      • Code
        • FirstDiffByteDelta:和上一层的split value对应的split dim维第一个不相等的字符的差值
        • SplitPrefixLength:和上一层的split value的公共前缀长度
        • SplitDim:split的维度
      • SplitSuffix:和上一层的split value除了公共前缀的suffix
      • LeftNumBytes:左子树的总大小
    • 右节点
      • DeltaFP:右节点起始位置和左兄弟起始位置的差值
      • Code
        • FirstDiffByteDelta:和上一层的split value对应的split dim维第一个不相等的字符的差值
        • SplitPrefixLength:和上一层的split value的公共前缀长度
        • SplitDim:split的维度
      • SplitSuffix:和上一层的split value除了公共前缀的suffix

kdm

kdm文件存储的是BKD树的一些元信息,辅助BKD树结构的还原。

整体结构

kdm.png

字段详解

  • NumDims:point的维度
  • NumIndexDims:用来split的维度的范围是从0到numIndexDims-1,默认numIndexDims=numDims
  • CountPerLeaf:叶子节点可以容纳的point的数量
  • BytesPerDim:每一维占用几个字节
  • NumLeaves:几个叶子节点
  • MinPackedValue:每一维的最小值
  • MaxPackedValue:每一维的最大值
  • PointCount:总的point个数
  • DocCount:doc总数
  • IndexLength:kdi的总长度
  • DataStartFP:当前field在kdd文件中的起始位置
  • IndexStartFP:当前field在kdi文件中的起始位置

总结

关于Lucene中BKD树的构建我们就先介绍到这里了,至于构建出来的索引文件在检索的时候怎么使用,我们后面讲到Lucene中的检索时再详细介绍。如有问题,欢迎指正!