概述
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层
首先,对于根节点,需要计算分配给左右子树的叶子节点数,因为要满足完全二叉树的性质,所以左子树的叶子节点个数是有要求的(具体如何计算我们下一节再介绍,这里假设已经有了计算方法),这里直接给出答案左子树的叶子节点需要4个,右子树的叶子节点有3个,如果子树中的叶子节点数超过1,则需要继续处理。
-
第1层
在第1层中的两个内部节点分别作为根节点,递归处理。这两个根节点分别计算其左右子树的叶子节点个数,当叶子节点数只有1时,说明该子树处理完毕,如7号叶子节点。
-
第2层
在第2层中的3个内部节点分别作为根节点,递归处理。这两个根节点分别计算其左右子树的叶子节点个数,在当前层算出所有的子树叶子节点都是1,则构建结束,最终的完全二叉树如下所示:
上面的例子的叶子节点是单值,而我们今天要介绍的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
我们看上面的图,假设当前根节点要分配的叶子节点一共有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
我们看上面的图,假设当前根节点要分配的叶子节点一共有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中,主要做了三件事:
- 添加数据
- 持久化叶子节点
- 构建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
这种情况下,叶子节点中有比较多的重复的数据,因此可以在排序之后记录每个数据出现的次数以及数据本身就可以。
如上图所示,存在排好序的数据: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
这种情况是叶子节点中没有什么重复的数据,在这种情况下,试图寻找所有的值除了公共前缀之外,剩下的后缀中第一个字节相同的数据。
如上图所示,除了公共前缀之外的后缀数据,根据第一个字节是否相同可以分为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
- root节点
详细的构建逻辑见代码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树的所有叶子节点的数据。
整体结构
字段详解
如上图所示,每个Field都有一棵BKD树,kdd存储的是BKD树中所有叶子节点的数据。每个叶子节点的数据结构如下:
- Count:叶子节点中point的个数
- DocIDs:叶子节点中point对应的docID列表
- CommonPrefixes:所有point的所有维度的公共前缀
- DimPrefix:每一维的公共前缀结构
- PrefixLength:公共前缀长度
- Prefix:前缀值
- DimPrefix:每一维的公共前缀结构
- 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树的结构。
整体结构
字段详解
每个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:左子树的总大小
- Code
- 右节点
- DeltaFP:右节点起始位置和左兄弟起始位置的差值
- Code
- FirstDiffByteDelta:和上一层的split value对应的split dim维第一个不相等的字符的差值
- SplitPrefixLength:和上一层的split value的公共前缀长度
- SplitDim:split的维度
- SplitSuffix:和上一层的split value除了公共前缀的suffix
- root
kdm
kdm文件存储的是BKD树的一些元信息,辅助BKD树结构的还原。
整体结构
字段详解
- 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中的检索时再详细介绍。如有问题,欢迎指正!