背景
在全文匹配搜索引擎中,最核心的操作就是查找包含query词的文档。为了高效完成根据query词搜索相关文档,最最重要的就是要构造倒排索引。那倒排索引的结构是怎样的,其中到底有什么信息呢?Lucene的倒排信息有以下这些部分:
- docid:term所属的文档id
- freq:term在文档中出现的频率
- position:term在文档中的位置,term在文档中是可以出现多次的
- startOffset:term在文档中出现的某个位置的起始offset
- endOffset:term在文档中出现的某个位置的结束offset(真正含义应该是tokenizer切分的位置,term是经过二次处理的到,后面会详细介绍)
我们看个例子,如下图所示,我们有两个文档,每个文档只有一个字段field0,简易的倒排结构如下图所示。
本文就是介绍这些倒排信息在内存中的存储结构,为什么要特地强调是内存中的结构,因为lucene在索引构建过程中先把索引信息存储在内存中,flush的时候才进行持久化到索引文件中,而内存中和索引文件中的倒排数据存储格式是不一样的,本文介绍的是内存中的存储结构,索引文件中的存储结构我们后面介绍。
注意:本文源码解析基于Lucene 9.1.0版本。
必看工具类
ByteBlockPool
从底层数据结构看,ByteBlockPool就是一个二维字节数组。
从宏观使用上看,ByteBlockPool是一个buffer集合,每次要写入数据需要从集合中取出buffer来进行写入,二维字节数组的一行就是一个buffer,创建ByteBlockPool之后buffer的大小就是固定的,对ByteBlockPool来说也就是每一行的大小是固定的。
往ByteBlockPool中写入数据的数据源叫做stream。当只有一个数据源为ByteBlockPool提供数据时,则简单地按顺序写入,把一个个buffer填满,如果buffer不够再创建新buffer即可。
但是如果是多个数据源为ByteBlockPool提供数据时,怎么在写入ByteBlockPool之后分别读取每个数据源的数据呢?
一种方案是以buffer的粒度为每个数据源分配空间,多个buffer用buffer最后的一部分空间指定下一个buffer的地址,这样就把同一个数据源的buffer串联起来,我们记录数据源的第一个buffer的位置就能获取数据源的所有数据。
但是这种方案有个问题,我们buffer是固定的,如果数据源的数据远小于buffer大小,则浪费了内存资源。
为了改善这个问题,ByteBlockPool中引入了slice的概念,使用更小的粒度为不同的数据源分配空间。同样地,同一个数据源的slice通过尾指针就能把slice串起来,我们记录每个数据源的第一个slice的地址就能获取指定数据源的全部数据。如下图所示:
那slice应该分配多大呢,这个谁也说不准,所以为slice指定了level的概念,level的大小决定了slice的大小,先分配小的slice,不够再分配大一点的slice,大于一定程度slice大小就固定了,其实就是一种简单的内存分配策略。
需要格外注意的是,slice链表最后一个slice的最后一个字节是结束标记,值是: 16 | level。slice刚分配除了这个标记之外都是0,所以如果在当前的slice中碰到了一个非0的,则说明到了slice的结束位置了,需要创建一个新的slice,从这个标记中取出当前slice的level,Lucene中用NEXT_LEVEL_ARRAY数组记录每个level的下一个level值,在数组LEVEL_SIZE_ARRAY中记录了level对应的slice大小,通过当前level从NEXT_LEVEL_ARRAY中获取下一个level值,再根据下一个level值从LEVEL_SIZE_ARRAY获取下一个slice的大小了。
知道了ByteBlockPool是个什么东西,我们再来看它的源码就简单多了。
常量
// 控制buffer的大小,也就是每行的大小。
public static final int BYTE_BLOCK_SHIFT = 15;
// buffer的大小
public static final int BYTE_BLOCK_SIZE = 1 << BYTE_BLOCK_SHIFT;
// 用来做位运算定位buffer中的位置
public static final int BYTE_BLOCK_MASK = BYTE_BLOCK_SIZE - 1;
// 下标是当前的level,值是下一个level的值。可以看到是从level 0开始的,到level 9之后就不变了。
public static final int[] NEXT_LEVEL_ARRAY = {1, 2, 3, 4, 5, 6, 7, 8, 9, 9};
// 下标是level,值是level对应的slice的大小。
public static final int[] LEVEL_SIZE_ARRAY = {5, 14, 20, 30, 40, 40, 80, 80, 120, 200};
// level 0 的slice
public static final int FIRST_LEVEL_SIZE = LEVEL_SIZE_ARRAY[0];
成员变量
// 真正用来存数据的空间。buffers[i]可以理解成第i个buffer
public byte[][] buffers = new byte[10][];
// 当前正在写入的buffer是第几个buffer(从二维数组的角度就是第几行)
private int bufferUpto = -1;
// 在buffer中的偏移量(从二维数组的角度就是第几列)
public int byteUpto = BYTE_BLOCK_SIZE;
// 当前正在使用的buffer
public byte[] buffer;
// 当前正在使用的buffer的起始位置在整个pool中的偏移量
public int byteOffset = -BYTE_BLOCK_SIZE;
// 用来创建新buffer的,也就是字节数组
private final Allocator allocator;
对于下一个要写入的红色的位置,各个成员变量对应的值如下::
内部类
buffer分配器抽象类:
// 分配和回收字节数组
public abstract static class Allocator {
// buffer的大小
protected final int blockSize;
protected Allocator(int blockSize) {
this.blockSize = blockSize;
}
// 回收buffer
public abstract void recycleByteBlocks(byte[][] blocks, int start, int end);
public void recycleByteBlocks(List<byte[]> blocks) {
final byte[][] b = blocks.toArray(new byte[blocks.size()][]);
recycleByteBlocks(b, 0, b.length);
}
// 创建一个新buffer
public byte[] getByteBlock() {
return new byte[blockSize];
}
}
不支持回收的分配器实现类:
public static final class DirectAllocator extends Allocator {
public DirectAllocator() {
this(BYTE_BLOCK_SIZE);
}
public DirectAllocator(int blockSize) {
super(blockSize);
}
@Override
public void recycleByteBlocks(byte[][] blocks, int start, int end) {}
}
跟踪内存使用的分配器实现类:
public static class DirectTrackingAllocator extends Allocator {
// 内存大小占用统计器
private final Counter bytesUsed;
public DirectTrackingAllocator(Counter bytesUsed) {
this(BYTE_BLOCK_SIZE, bytesUsed);
}
public DirectTrackingAllocator(int blockSize, Counter bytesUsed) {
super(blockSize);
this.bytesUsed = bytesUsed;
}
@Override
public byte[] getByteBlock() {
// 更新内存占用统计
bytesUsed.addAndGet(blockSize);
return new byte[blockSize];
}
// 回收buffer
public void recycleByteBlocks(byte[][] blocks, int start, int end) {
bytesUsed.addAndGet(-((end - start) * blockSize));
for (int i = start; i < end; i++) {
blocks[i] = null;
}
}
}
核心方法
-
reset
重置整个ByteBlockPool
public void reset() { reset(true, true); } // zeroFillBuffers:是否把所有的buffer都填充为0 // reuseFirst:是否需要保留复用第一个buffer public void reset(boolean zeroFillBuffers, boolean reuseFirst) { // 如果pool使用过,才需要进行重置 if (bufferUpto != -1) { // 为所有的用过的buffer填充0 if (zeroFillBuffers) { for (int i = 0; i < bufferUpto; i++) { Arrays.fill(buffers[i], (byte) 0); } // 最后一个使用到的buffer只填充用过的部分 Arrays.fill(buffers[bufferUpto], 0, byteUpto, (byte) 0); } // 除了第一个buffer,其他buffer都回收 if (bufferUpto > 0 || !reuseFirst) { final int offset = reuseFirst ? 1 : 0; allocator.recycleByteBlocks(buffers, offset, 1 + bufferUpto); Arrays.fill(buffers, offset, 1 + bufferUpto, null); } // 如果需要复用第一个buffer,则重置相关参数 if (reuseFirst) { bufferUpto = 0; byteUpto = 0; byteOffset = 0; buffer = buffers[0]; } else { bufferUpto = -1; byteUpto = BYTE_BLOCK_SIZE; byteOffset = -BYTE_BLOCK_SIZE; buffer = null; } } }
-
nextBuffer
创建下一个buffer。这个方法重点关注的是新建buffer之后,放弃上一个buffer的剩余空间,下一次写入就直接写入到新buffer。
public void nextBuffer() { // 如果buffer数组满了,则进行扩容 if (1 + bufferUpto == buffers.length) { byte[][] newBuffers = new byte[ArrayUtil.oversize(buffers.length + 1, NUM_BYTES_OBJECT_REF)][]; System.arraycopy(buffers, 0, newBuffers, 0, buffers.length); buffers = newBuffers; } // 创建下一个buffer // 这里重点关注下,创建新buffer之后所有位置参数都定位到新buffer,所以放弃了上一个buffer的剩余空间。 buffer = buffers[1 + bufferUpto] = allocator.getByteBlock(); bufferUpto++; byteUpto = 0; byteOffset += BYTE_BLOCK_SIZE; }
-
nextSlice
通过指定的大小创建下一个slice。这个方法只会在创建数据源第一个slice的时候调用,size就是level 0的大小。对于同一个数据源,从第2个slice开始就是由数组NEXT_LEVEL_ARRAY和LEVEL_SIZE_ARRAY控制slice的大小了。
// size:要创建的slice大小 public int newSlice(final int size) { // 如果当前buffer剩余空间无法容纳新的slice,则创建一个新的buffer if (byteUpto > BYTE_BLOCK_SIZE - size) nextBuffer(); final int upto = byteUpto; byteUpto += size; // slice结束的哨兵,要理解成level=0,哨兵是 16|level buffer[byteUpto - 1] = 16; return upto; }
-
readBytes
从pool的offset处读取bytesLength个字节到bytes中bytesOffset的位置。可以处理跨buffer的情况。
public void readBytes(final long offset, final byte[] bytes, int bytesOffset, int bytesLength) { // 剩余需要读取的数据量 int bytesLeft = bytesLength; // 定位到从哪个buffer开始 int bufferIndex = (int) (offset >> BYTE_BLOCK_SHIFT); // 定位到buffer的位置 int pos = (int) (offset & BYTE_BLOCK_MASK); // 读取所需的数据 while (bytesLeft > 0) { byte[] buffer = buffers[bufferIndex++]; int chunk = Math.min(bytesLeft, BYTE_BLOCK_SIZE - pos); System.arraycopy(buffer, pos, bytes, bytesOffset, chunk); bytesOffset += chunk; bytesLeft -= chunk; pos = 0; } }
-
setBytesRef(BytesRefBuilder builder, BytesRef result, long offset, int length)
从pool的offset开始,取length个字节到result中。
void setBytesRef(BytesRefBuilder builder, BytesRef result, long offset, int length) { result.length = length; // offset是在哪个buffer int bufferIndex = (int) (offset >> BYTE_BLOCK_SHIFT); byte[] buffer = buffers[bufferIndex]; // offset在buffer中的位置 int pos = (int) (offset & BYTE_BLOCK_MASK); // 如果所需内容在一个buffer内 if (pos + length <= BYTE_BLOCK_SIZE) { // 直接设置buffer的offset和length result.bytes = buffer; result.offset = pos; } else { // 跨buffer通过readBytes方法读取 builder.grow(length); result.bytes = builder.get().bytes; result.offset = 0; readBytes(offset, result.bytes, 0, length); } }
-
setBytesRef(BytesRef term, int textStart)
在倒排的场景中,会把字段中所有的term都写入pool中(写入逻辑在BytesRefHash#add,后面我们会介绍BytesRefHash),这个方法就是从pool的textStart位置读取term,读取的时候先读term的长度,term的长度是1字节或者两字节,要根据当前的标志位来判断。
public void setBytesRef(BytesRef term, int textStart) { // 定位到 textStart 属于哪个buffer final byte[] bytes = term.bytes = buffers[textStart >> BYTE_BLOCK_SHIFT]; // 定位到 textStart 在buffer中的位置 int pos = textStart & BYTE_BLOCK_MASK; if ((bytes[pos] & 0x80) == 0) { // 如果是一个字节的长度 term.length = bytes[pos]; term.offset = pos + 1; } else { // 2字节长度 term.length = ((short) BitUtil.VH_BE_SHORT.get(bytes, pos)) & 0x7FFF; term.offset = pos + 2; } }
-
allocSlice
从给定的buffer和buffer的offset处分配一个新的slice。
注意这个方法被调用时说明我们已经到了上一个slice的最后一个字节了,这个字节就是结束的哨兵,值是 16 | currentLevel。
// 注意:slice就是当前要分配新slice所在的buffer,upto就是要分配slice的位置。 // 这里我觉得这个参数名取的非常不好,改成buffer好非常多。 public int allocSlice(final byte[] slice, final int upto) { // buffer中新分配的slice都是填充0,而且每个slice的最后一个字节是: 16 | currentLevel 。 // 所以,如果需要执行这个方法,肯定是碰到了当前slice的最后一个字节了,所以才需要执行这个方法。 // 因此,level其实是目前的已经达到的level。 final int level = slice[upto] & 15; // 获取下一个level final int newLevel = NEXT_LEVEL_ARRAY[level]; // 获取下一个level的slice的大小 final int newSize = LEVEL_SIZE_ARRAY[newLevel]; // 如果当前buffer无法容纳新slice的大小,则需要重新分配一个buffer if (byteUpto > BYTE_BLOCK_SIZE - newSize) { // 注意nextBuffer会丢弃当前buffer剩余空间 nextBuffer(); } // 当前buffer的在数组中的下标 final int newUpto = byteUpto; // 当前buffer中的offset final int offset = newUpto + byteOffset; byteUpto += newSize; // 获取当前upto前面三个字节,也就是哨兵前面的3个字节。这是为了把这部分空间留出来,设置下一个slice的地址。 // 注意是小端读取,通过与运算把哨兵字节(upto指向的字节)的过滤了。 int past3Bytes = ((int) BitUtil.VH_LE_INT.get(slice, upto - 3)) & 0xFFFFFF; // 把这三个字节写入新slice的开头 BitUtil.VH_LE_INT.set(buffer, newUpto, past3Bytes); // 把新slice的地址写入前一个slice腾出三个字节的地方,包括了最后一个哨兵字节,总共地址是4个字节。 BitUtil.VH_LE_INT.set(slice, upto - 3, offset); // 当前的slice加入哨兵,可以看到这里把当前的level也存起来了,和前面通过当前level获取新level呼应 buffer[byteUpto - 1] = (byte) (16 | newLevel); // 放回当前slice可以写的位置 return newUpto + 3; }
IntBlockPool
IntBlockPool在使用上和ByteBlockPool非常类似,这里我们就不重复分析了。这里列出来,是因为我们后面介绍倒排内存结构存储会用到。IntBlockPool在倒排构建中是用来存储每个term在ByteBlockPool中下一个要写入的位置,每个term对IntBlockPool都是一个数据源。
BytesRefHash
BytesRefHash就把它理解成是一个Map<BytesRef, Interge>。它有两个特点:
- 添加新pair时,只提供key,value是在新增的时候为key分配的id,是从0递增的整型。
- 不仅是O(1)时间通过key找value,也是O(1)时间通过value找key。这种实现我们可以借鉴。
成员变量
// 存储所有key值,也就是BytesRef
final ByteBlockPool pool;
// 下标是每个BytesRef的id,值是在pool中的起始位置,通过这个变量实现了O(1)时间通过value找key
int[] bytesStart;
// 哈希表的最大容量
private int hashSize;
// 超过这个值,需要rehash
private int hashHalfSize;
// 用来获取BytesRef哈希值在ids数组中的下标
private int hashMask;
// 目前哈希表中的总个数
private int count;
// 辅助变量,在清空或者压缩的时候使用,不用关注
private int lastCount = -1;
// 下标是BytesRef哈希值与hashMask的&操作,值是BytesRef的id
private int[] ids;
// bytesStart数组初始化,重置,扩容的辅助类,不用关注
private final BytesStartArray bytesStartArray;
// 统计内存占用,不用关注
private Counter bytesUsed;
核心方法
O(1)时间通过value找key的方法。
public BytesRef get(int bytesID, BytesRef ref) {
// 从pool的 bytesStart[bytesID] 的位置读取ref,注意会先读长度,再读内容,详见ByteBlockPool
pool.setBytesRef(ref, bytesStart[bytesID]);
return ref;
}
O(1)时间通过key找value的方法。
public int find(BytesRef bytes) {
return ids[findHash(bytes)];
}
因为底层数据是当成哈希表使用,所以数组中存在空隙。在数据全部处理完之后,要进行持久化,先压缩id数组返回进行下一步的处理:
public int[] compact() {
// 如果存在空节点,则upto会停留在空节点的下标,等待填充
int upto = 0;
// 从前往后找
for (int i = 0; i < hashSize; i++) {
// 如果当前的节点非空,则需要判断在其之前是否有空间点可以填充
if (ids[i] != -1) {
// upto < i 说明存在空节点,则把当前节点移动到空节点
if (upto < i) {
ids[upto] = ids[i];
ids[i] = -1;
}
// 非空隙才更新,碰到空隙就停留
upto++;
}
}
assert upto == count;
lastCount = count;
return ids;
}
按ByteRef对ids数组排序。在倒排最后要落盘持久化的时候,会先对所有的term排序,因为term字典(FST)需要term列表是有序的:
public int[] sort() {
final int[] compact = compact();
// lucene实现的排序算法的框架,get方法就是获取比较的值来进行比较
new StringMSBRadixSorter() {
BytesRef scratch = new BytesRef();
@Override
protected void swap(int i, int j) {
int tmp = compact[i];
compact[i] = compact[j];
compact[j] = tmp;
}
@Override
protected BytesRef get(int i) {
pool.setBytesRef(scratch, bytesStart[compact[i]]);
return scratch;
}
}.sort(0, count);
return compact;
}
id指定的BytesRef是否和b相等:
private boolean equals(int id, BytesRef b) {
final int textStart = bytesStart[id];
final byte[] bytes = pool.buffers[textStart >> BYTE_BLOCK_SHIFT];
int pos = textStart & BYTE_BLOCK_MASK;
final int length;
final int offset;
if ((bytes[pos] & 0x80) == 0) {
length = bytes[pos];
offset = pos + 1;
} else {
length = ((short) BitUtil.VH_BE_SHORT.get(bytes, pos)) & 0x7FFF;
offset = pos + 2;
}
return Arrays.equals(bytes, offset, offset + length, b.bytes, b.offset, b.offset + b.length);
}
在清空哈希表之后,需要收缩哈希表:
private boolean shrink(int targetSize) {
int newSize = hashSize;
// 如果现有的容量大于等于8才需要收缩
while (newSize >= 8 && newSize / 4 > targetSize) {
newSize /= 2;
}
// 如果需要收缩
if (newSize != hashSize) {
bytesUsed.addAndGet(Integer.BYTES * -(hashSize - newSize));
hashSize = newSize;
ids = new int[hashSize];
Arrays.fill(ids, -1);
hashHalfSize = newSize / 2;
hashMask = newSize - 1;
return true;
} else {
return false;
}
}
清空哈希表:
public void clear(boolean resetPool) {
lastCount = count;
count = 0;
if (resetPool) {
pool.reset(false, false);
}
bytesStart = bytesStartArray.clear();
// 收缩成功则ids数组就已经重置了
if (lastCount != -1 && shrink(lastCount)) {
return;
}
Arrays.fill(ids, -1);
}
public void clear() {
clear(true);
}
获取bytes在ids数组中的下标:
private int findHash(BytesRef bytes) {
// 获取bytes的哈希值
int code = doHash(bytes.bytes, bytes.offset, bytes.length);
// 获取在ids中的下标
int hashPos = code & hashMask;
int e = ids[hashPos];
// 如果哈希的下标已经有值了,并且不是bytes,则表示冲突了
if (e != -1 && !equals(e, bytes)) {
// 冲突了,采用线性探测循环往后找
do {
code++;
hashPos = code & hashMask;
e = ids[hashPos];
} while (e != -1 && !equals(e, bytes));
}
return hashPos;
}
哈希表新增一个BytesRef,如果是新加入的BytesRef,则返回返回为其分配的id,否则返回BytesRef的(-(id)- 1)。
public int add(BytesRef bytes) {
assert bytesStart != null : "Bytesstart is null - not initialized";
final int length = bytes.length;
// 获取在ids中的下标
final int hashPos = findHash(bytes);
int e = ids[hashPos];
// 如果bytes是新加入的
if (e == -1) {
// 加2是因为长度最多用两个字节存储
final int len2 = 2 + bytes.length;
// 如果当前buffer存不下,则获取下一个buffer
if (len2 + pool.byteUpto > BYTE_BLOCK_SIZE) {
if (len2 > BYTE_BLOCK_SIZE) {
throw new MaxBytesLengthExceededException(
"bytes can be at most " + (BYTE_BLOCK_SIZE - 2) + " in length; got " + bytes.length);
}
pool.nextBuffer();
}
final byte[] buffer = pool.buffer;
final int bufferUpto = pool.byteUpto;
// 当前的bytestarts满了,则扩容
if (count >= bytesStart.length) {
bytesStart = bytesStartArray.grow();
assert count < bytesStart.length + 1 : "count: " + count + " len: " + bytesStart.length;
}
// id是递增的
e = count++;
// 记录bytes在pool中的offset
bytesStart[e] = bufferUpto + pool.byteOffset;
// 先存term长度,再存term.
// 小于128用1个字节,否则用2个字节。
if (length < 128) {
// 1 byte to store length
buffer[bufferUpto] = (byte) length;
pool.byteUpto += length + 1;
assert length >= 0 : "Length must be positive: " + length;
System.arraycopy(bytes.bytes, bytes.offset, buffer, bufferUpto + 1, length);
} else {
// 2 byte to store length
BitUtil.VH_BE_SHORT.set(buffer, bufferUpto, (short) (length | 0x8000));
pool.byteUpto += length + 2;
System.arraycopy(bytes.bytes, bytes.offset, buffer, bufferUpto + 2, length);
}
assert ids[hashPos] == -1;
// 把id存起来
ids[hashPos] = e;
// 如果哈希表过半,则rehash
if (count == hashHalfSize) {
rehash(2 * hashSize, true);
}
return e;
}
// 如果bytes是已经存在的
return -(e + 1);
}
没有bytes,但是为offset分配一个id:
public int addByPoolOffset(int offset) {
assert bytesStart != null : "Bytesstart is null - not initialized";
// final position
int code = offset;
int hashPos = offset & hashMask;
int e = ids[hashPos];
if (e != -1 && bytesStart[e] != offset) {
do {
code++;
hashPos = code & hashMask;
e = ids[hashPos];
} while (e != -1 && bytesStart[e] != offset);
}
if (e == -1) {
if (count >= bytesStart.length) {
bytesStart = bytesStartArray.grow();
assert count < bytesStart.length + 1 : "count: " + count + " len: " + bytesStart.length;
}
e = count++;
bytesStart[e] = offset;
assert ids[hashPos] == -1;
ids[hashPos] = e;
if (count == hashHalfSize) {
rehash(2 * hashSize, false);
}
return e;
}
return -(e + 1);
}
ParallelPostingsArray
在介绍ParallelPostingsArray之前,需要理解一个stream的概念。stream可以理解成term倒排信息的数据源,在当前的版本中最多2个stream,最少1个。
一个term根据索引选项的不同,可以有多个stream。如果索引选项是DOCS_AND_FREQS_AND_POSITIONS或者DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS,则有两个stream,否则只有一个。stream中包含的信息如下:
stream 0:doc,freq
stream 1:position,payload,startOffset,endOffset
为什么要区分不同的stream呢?
因为对于同一个term的不同的文档中stream1的这些信息是存储在一起的,所以我们需要先知道freq是多少,才能正确获取term在指定文档中的相关倒排信息。但是doc和freq必须是一个文档处理完了才能正确得到,而其他的信息是在处理过程中就可以生成的,所以就区分这二者。
// 下标是termID,值是termID所对应的term在ByteBlockPool中起始offset
final int[] textStarts;
// 下标是termID,值是termID对应stream记录在IntBlockPool中当前写入位置的offset
final int[] addressOffset;
// 下标是termID,值是该term的stream在ByteBlockPool中的下一个可以写入的位置
final int[] byteStarts;
ParallelPostingsArray中这三个属性对于字段中的同一个term是不变的。
ParallelPostingsArray有两个子类,分别是用来处理倒排和词向量的,本文只关注倒排的部分。
在子类FreqProxPostingsArray中,有另外的一些信息,这些信息是term每处理一次都会变化的:
// 下标是termID,值是termID对应的在当前处理文档中的频率
int[] termFreqs;
// 下标是termID,值是上一个出现这个term的文档id
int[] lastDocIDs;
// 下标是termID,值是上一个出现这个term的文档id的编码:docId << 1
int[] lastDocCodes;
// 下标是termID,值是term在当前文档中上一次出现的position
int[] lastPositions;
// 下标是termID,值是term在当前文档中上一次出现的startOffset(注意这里源码注释不对)
int[] lastOffsets;
构建逻辑
从宏观来看,构建的流程非常清晰,分为3步走:
- 构建的入口在TermsHashPerField#add(BytesRef, int),输入是term和文档Id
- 如果term是第一次出现,则需要走TermsHashPerField#initStreamSlices初始化相关stream,然后走FreqProxTermsWriterPerField#newTerm处理倒排信息。
- 如果term之前出现过,则需要走TermsHashPerField#positionStreamSlice定位到term的写入位置,然后走FreqProxTermsWriterPerField#addTerm处理倒排信息。
TermsHashPerField
构建的核心逻辑在TermsHashPerField中,每个field对应了一个TermsHashPerField,field的所有倒排数据都存储在TermsHashPerField中。
成员变量
// 倒排和词向量都会处理term信息,所以倒排和词向量是使用责任链的模式实现,nextPerField就是下一个要处理term信息的组件
private final TermsHashPerField nextPerField;
// 记录term在bytePool中下一个要写入的位置
private final IntBlockPool intPool;
// 记录term和term的相关的倒排信息,倒排信息是以stream的方式写入的
final ByteBlockPool bytePool;
// intPool中当前使用的buffer
private int[] termStreamAddressBuffer;
// intPool中当前使用的buffer当前定位到的offset
private int streamAddressOffset;
// term的信息分为几个数据源
private final int streamCount;
// 字段名称
private final String fieldName;
// 索引选项:是否记录频率,是否记录position,是否记录offset等
final IndexOptions indexOptions;
// 为term分配唯一id,并且用来判断term是不是第一次出现
private final BytesRefHash bytesHash;
// 倒排内存结构构建的辅助类
ParallelPostingsArray postingsArray;
// 所有的文档都处理完之后,按照term大小对termid进行排序,持久化的时候是按照term顺序处理的
private int[] sortedTermIDs;
构建相关方法
倒排构建的入口
termBytes是term,docID是term所在的文档id。
void add(BytesRef termBytes, final int docID) throws IOException {
// bytesHash第一次遇到的term会返回大于等于0的termID
int termID = bytesHash.add(termBytes);
if (termID >= 0) {
// term第一次出现,则初始化各个stream的第一个slice
initStreamSlices(termID, docID);
} else {
// term已经出现过了,则定位到要继续写入的位置
termID = positionStreamSlice(termID, docID);
}
// 如果有需要则调用下一个TermsHashPerField组件,当前版本只有TermVectorsConsumerPerField
if (doNextCall) {
nextPerField.add(postingsArray.textStarts[termID], docID);
}
}
第一次出现term的处理逻辑
如果是第一个碰到的term,则需要为term初始化stream的slice,整体逻辑如下图所示:
private void initStreamSlices(int termID, int docID) throws IOException {
// intPool的当前buffer不足,则需要获取下一个buffer。
// 这里需要注意的是,这样判断确保了termID对应的intPool中的数据都是相连的,是为了读取的时候考虑的
if (streamCount + intPool.intUpto > IntBlockPool.INT_BLOCK_SIZE) {
intPool.nextBuffer();
}
// 这个2没明白,我的理解是不需要这个2。
// 这里需要注意的是,这样判断确保了termID对应的bytePool中的所有的stream的第一个slice都是相连的,
// 是为了读取的时候考虑的
if (ByteBlockPool.BYTE_BLOCK_SIZE - bytePool.byteUpto
< (2 * streamCount) * ByteBlockPool.FIRST_LEVEL_SIZE) {
bytePool.nextBuffer();
}
termStreamAddressBuffer = intPool.buffer;
streamAddressOffset = intPool.intUpto;
intPool.intUpto += streamCount;
// 记录termID在intPool中的位置
postingsArray.addressOffset[termID] = streamAddressOffset + intPool.intOffset;
for (int i = 0; i < streamCount; i++) {
final int upto = bytePool.newSlice(ByteBlockPool.FIRST_LEVEL_SIZE);
// intPool中的buffer记录的是每个stream下一个要写入的位置
termStreamAddressBuffer[streamAddressOffset + i] = upto + bytePool.byteOffset;
}
// 记录stream在bytePool中的起始位置,这个信息是用来读取时候使用的
postingsArray.byteStarts[termID] = termStreamAddressBuffer[streamAddressOffset];
// 第一次出现的term的处理逻辑,在TermsHashPerField是个抽象方法。
// 上面流程图中大部分逻辑在这个方法中。
newTerm(termID, docID);
}
已有term的处理逻辑
定位到term的stream所在的slice,把term的新增的倒排信息写入,整体逻辑如下图所示:
private int positionStreamSlice(int termID, final int docID) throws IOException {
termID = (-termID) - 1;
// termID在intPool中的起始位置
int intStart = postingsArray.addressOffset[termID];
// 根据 intStart 获取termID在intPool的buffer和offset
termStreamAddressBuffer = intPool.buffers[intStart >> IntBlockPool.INT_BLOCK_SHIFT];
streamAddressOffset = intStart & IntBlockPool.INT_BLOCK_MASK;
// 抽象方法,具体新增已有term的信息逻辑在子类实现
addTerm(termID, docID);
return termID;
}
写入stream的slice
final void writeByte(int stream, byte b) {
// streamAddress是stream在intPool中的位置
int streamAddress = streamAddressOffset + stream;
// 从intPool中读取stream下一个要写入的位置
int upto = termStreamAddressBuffer[streamAddress];
byte[] bytes = bytePool.buffers[upto >> ByteBlockPool.BYTE_BLOCK_SHIFT];
int offset = upto & ByteBlockPool.BYTE_BLOCK_MASK;
// 如果碰到了slice的哨兵
if (bytes[offset] != 0) {
// 创建下一个slice,并返回写入的地址
offset = bytePool.allocSlice(bytes, offset);
bytes = bytePool.buffer;
termStreamAddressBuffer[streamAddress] = offset + bytePool.byteOffset;
}
// 在slice写入数据
bytes[offset] = b;
(termStreamAddressBuffer[streamAddress])++;
}
FreqProxTermsWriterPerField
FreqProxTermsWriterPerField是TermsHashPerField的一个子类,具体的写入逻辑都在这个类中实现。
处理position和payLoad信息
如上图所示,当不存在payload信息时,单独存储pos的差值左移一位,这样用最后一位0表示后面没有payload数据。如果存在payload数据,则先存储pos差值左移一位 | 1,这样用最后一位1表示后面有payload数据,然后再写payload长度,类型是Vint,最后写入payload的数据。
// proxCode:term的position
void writeProx(int termID, int proxCode) {
// 如果该term没有payLoad信息,则把proxCode << 1 追加到stream 1,注意没有payLoad最后一位肯定是0
if (payloadAttribute == null) {
writeVInt(1, proxCode << 1);
} else {
BytesRef payload = payloadAttribute.getPayload();
if (payload != null && payload.length > 0) { // 如果该position的term存在payLoad信息
// 注意有payLoad的时候,最后一位肯定是1
writeVInt(1, (proxCode << 1) | 1);
// payLoad的长度
writeVInt(1, payload.length);
// payLoad的内容
writeBytes(1, payload.bytes, payload.offset, payload.length);
sawPayloads = true;
} else { // 如果该position的term不存在payLoad信息
writeVInt(1, proxCode << 1);
}
}
// 更新term在当前处理文档中上一次出现的position
freqProxPostingsArray.lastPositions[termID] = fieldState.position;
}
处理term的startOffset和endOffset
这里需要特别注意的一个点是,一开始很容易以为endOffset-startOffset就是term的长度,其实不是的。比如在同义词场景中,startOffset和endOffset记录的是原词的偏移位置,但是生成的token流中,原词的同义词的startOffset和endOffset是和原词一样的,但是endOffset-startOffset却不是term的长度,这也是为什么倒排表中还要额外存储term的长度信息。我们看个例子:
public class TokenStreamDemo {
public static void main(String[] args) throws IOException {
Analyzer analyzer = new Analyzer() {
@Override
protected TokenStreamComponents createComponents(String fieldName) {
LetterTokenizer letterTokenizer = new LetterTokenizer();
SynonymMap.Builder builder = new SynonymMap.Builder();
// 设置hello的两个同义词:hi和ha
builder.add(new CharsRef("hello"), new CharsRef("hi"), true);
builder.add(new CharsRef("hello"), new CharsRef("ha"), true);
SynonymMap synonymMap = null;
try {
synonymMap = builder.build();
} catch (IOException e) {
e.printStackTrace();
}
SynonymGraphFilter synonymGraphFilter = new SynonymGraphFilter(letterTokenizer, synonymMap, true);
return new TokenStreamComponents(letterTokenizer, synonymGraphFilter);
}
};
TokenStream tokenStream = analyzer.tokenStream("test", "hello world");
tokenStream.reset();
CharTermAttribute termAttr = tokenStream.addAttribute(CharTermAttribute.class);
OffsetAttribute offsetAttr = tokenStream.addAttribute(OffsetAttribute.class);
while(tokenStream.incrementToken()) {
System.out.printf("%s startOffset=%d endOffset=%d\n", termAttr.toString(), offsetAttr.startOffset(), offsetAttr.endOffset());
}
}
}
输出结果如下,我们可以看出“hello”被分成了3个词,包括“hello”自身和两个同义词,他们的startOffset和endOffset都是相同的,但是显然endOffset-startOffset并不是“hi”和“ha”的长度。
hi startOffset=0 endOffset=5
ha startOffset=0 endOffset=5
hello startOffset=0 endOffset=5
world startOffset=6 endOffset=11
理解了startOffset和endOffset之后,我们看下它们是怎么处理的:
// 有时候,我们对一个Document添加了相同的Field,则在处理时是把这些Field的内容拼起来,
// offsetAccum记录的就是当前field在拼接中的偏移量
void writeOffsets(int termID, int offsetAccum) {
// 记录的所有相同Field拼接之后的offset
final int startOffset = offsetAccum + offsetAttribute.startOffset();
final int endOffset = offsetAccum + offsetAttribute.endOffset();
// 差值存储startOffset
writeVInt(1, startOffset - freqProxPostingsArray.lastOffsets[termID]);
// 存储endOffset
writeVInt(1, endOffset - startOffset);
// 更新term在当前处理文档中的上一次出现的startOffset
freqProxPostingsArray.lastOffsets[termID] = startOffset;
}
第一次碰到term的处理逻辑
void newTerm(final int termID, final int docID) {
final FreqProxPostingsArray postings = freqProxPostingsArray;
// 记录term上一次出现的文档
postings.lastDocIDs[termID] = docID;
if (!hasFreq) {
assert postings.termFreqs == null;
postings.lastDocCodes[termID] = docID;
fieldState.maxTermFrequency = Math.max(1, fieldState.maxTermFrequency);
} else {
postings.lastDocCodes[termID] = docID << 1;
postings.termFreqs[termID] = getTermFreq();
if (hasProx) {
writeProx(termID, fieldState.position);
if (hasOffsets) {
writeOffsets(termID, fieldState.offset);
}
} else {
assert !hasOffsets;
}
fieldState.maxTermFrequency =
Math.max(postings.termFreqs[termID], fieldState.maxTermFrequency);
}
fieldState.uniqueTermCount++;
}
已有term新增倒排信息处理逻辑
如上图所示,当频率为1时,单独存储docID的差值左移一位 | 1,这样用最后一位1表示term在这个文档中的频率是1。如果频率大于1,则先存储docID差值左移一位,这样用最后一位0表示后面有单独的频率数据,然后再写频率,类型是Vint。
void addTerm(final int termID, final int docID) {
final FreqProxPostingsArray postings = freqProxPostingsArray;
if (!hasFreq) {
// 默认的实现TermFrequencyAttribute 是返回1,如果是自定义实现,则必须在索引选项中加入频率
if (termFreqAtt.getTermFrequency() != 1) {
throw new IllegalStateException(
"field \""
+ getFieldName()
+ "\": must index term freq while using custom TermFrequencyAttribute");
}
if (docID != postings.lastDocIDs[termID]) { // 上一个文档处理结束
writeVInt(0, postings.lastDocCodes[termID]);
postings.lastDocCodes[termID] = docID - postings.lastDocIDs[termID];
postings.lastDocIDs[termID] = docID;
fieldState.uniqueTermCount++;
}
} else if (docID != postings.lastDocIDs[termID]) { // 上一个文档处理结束
if (1 == postings.termFreqs[termID]) { // 如果频率是1,则和左移一位的文档id一起存储
writeVInt(0, postings.lastDocCodes[termID] | 1);
} else { // 如果频率大于1,则先存文档id,注意左移一位的文档id
writeVInt(0, postings.lastDocCodes[termID]);
writeVInt(0, postings.termFreqs[termID]);
}
// 记录在当前文档中出现的频率,默认实现是1
postings.termFreqs[termID] = getTermFreq();
// 更新最大的term的频率
fieldState.maxTermFrequency =
Math.max(postings.termFreqs[termID], fieldState.maxTermFrequency);
// 文档id编码也是差值存储
postings.lastDocCodes[termID] = (docID - postings.lastDocIDs[termID]) << 1;
postings.lastDocIDs[termID] = docID;
if (hasProx) {
writeProx(termID, fieldState.position);
if (hasOffsets) {
// 第一次出现的offset是0
postings.lastOffsets[termID] = 0;
writeOffsets(termID, fieldState.offset);
}
} else {
assert !hasOffsets;
}
// 出现的term的个数+1
fieldState.uniqueTermCount++;
} else { // 如果不是在处理新的文档,则追加term的信息即可
// 更新term的频率
postings.termFreqs[termID] = Math.addExact(postings.termFreqs[termID], getTermFreq());
// 更新最大的term的频率
fieldState.maxTermFrequency =
Math.max(fieldState.maxTermFrequency, postings.termFreqs[termID]);
if (hasProx) {
// position也是差值存储
writeProx(termID, fieldState.position - postings.lastPositions[termID]);
if (hasOffsets) {
writeOffsets(termID, fieldState.offset);
}
}
}
}
构建demo
public class InvertDemo {
public static void main(String[] args) throws IOException {
Directory directory = new ByteBuffersDirectory();
WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);
addDoc(indexWriter, "lucene written in java");
addDoc(indexWriter, "lucene action learn lucene");
indexWriter.flush();
indexWriter.commit();
}
private static void addDoc(IndexWriter indexWriter, String content) throws IOException {
FieldType fieldType = new FieldType();
fieldType.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
fieldType.setStored(true);
Document document = new Document();
document.add(new Field("field0", content, fieldType));
indexWriter.addDocument(document);
}
}
特殊说明
-
在我们的例子中,term都是小写英文,使用的是ISO-8859-1编码(ASCII编码的扩充集),使用的是单字节编码,后面我们直接说是ASCII编码,好理解一些。
-
我们的示例有两个文档,对于相同的term我们用相同的颜色标识。需要注意的是“lucene”在两个文档中出现了三次,其他term都是1次。
-
bytePool存储的是字段中所有term本身以及term的倒排信息
-
intPool存储的是term在bytePool中下一个可以写入的位置。注意term的多个stream是相连的。
-
postingsArray.addressOffset存储的是term第一个stream在intPool中的位置。
-
postingsArray.textStarts存储的是term本身在bytePool中的起始位置。
-
postingsArray.byteStarts存储的是term第一个stream在bytePool中的起始位置。
-
postingsArray.termFreqs存储的是term在当前文档中到目前为止出现的频率。
-
postingsArray.lastDocIDs存储的是term出现的上一个文档id。
-
postingsArray.lastDocCodes存储的是term出现的上一个文档id左移一位。
-
postingsArray.lastPositions存储的是term在当前文档中上一次出现的position。
-
postingsArray.lastOffsets存储的是term在当前文档中上一次出现的startOffset。
-
position存的是差值(和上一次出现的position的差值),然后position的差值和payload混合存储。
-
docID是差值存储,然后和freq一起存
处理doc0中的第1个term:lucene
-
doc0中的“lucene”是第1个出现的term,BytesRefHash#add中会为其分配termID=0。
-
bytePool的第0位是6,存储是term “lucene”的长度。第1~6位存储的是“lucene”的ASCII编码。
-
bytePool的第7~11位存储的是term “lucene” 的stream0的第一个slice,第11位的16是slice结束的哨兵: 16 << 1 | level,当前level是0,所以哨兵是16。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。
-
bytePool的第12~16位是term “lucene” 的stream1的第一个slice,第16位的16是slice结束的哨兵。
-
bytePool的第12位是 pos << 1 的值:0。
-
bytePool的第13位是startOffset的值:0。
-
bytePool的第14位是endOffset - startOffset的值:6。
-
intPool的第0位是7,表示term “lucene”在bytePool中的stream0下一次可以写入的位置。
-
intPool的第1位是15,表示term “lucene”在bytePool中的stream1下一次可以写入的位置。
-
postingsArray.addressOffset的第0位是0,表示term “lucene”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “lucene”的stream当前可以写入的位置。
-
postingsArray.textStarts的第0位是0,表示term “lucene”本身在在bytePool中存储的起始位置。
-
postingsArray.byteStarts的第0位是7,表示term “lucene”的stream0的第一个slice在bytePool中的位置。
-
postingsArray.termFreqs的第0位是1,表示目前位置,在当前字段中,term “lucene”出现了1次。
-
postingsArray.lastDocIds的第0位是0,表示在term “lucene”上一次出现的docId。
-
postingsArray.lastDocCodes的第0位是0,表示的是lastDocIds[0] << 1。
-
postingsArray.lastPoistions的第0位是0,表示的是term “lucene”在当前字段中上次出现的position。
-
postingsArray.lastOffset的第0位是0,表示的是term “lucene”在当前字段中上次出现的startOffset。
处理doc0中的第2个term:written
-
doc0中的“written”是第2个出现的term,BytesRefHash#add中会为其分配termID=1。
-
bytePool的第17位是7,存储是term “written”的长度。第18~24位存储的是“written”的ASCII编码。
-
bytePool的第25~29位存储的是term “written”的 stream0的第一个slice,第29位的16是slice结束的哨兵。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。
-
bytePool的第30~34位是term “written”的 stream1的第一个slice,第34位的16是slice结束的哨兵。
-
bytePool的第30位是 pos << 1 的值:2。
-
bytePool的第31位是term “written” 的 startOffset的值:7。
-
bytePool的第32位是term “written” 的endOffset - startOffset的值:7。
-
intPool的第2位是25,表示term “written”在bytePool中的stream0下一次可以写入的位置。
-
intPool的第3位是33,表示term “written”在bytePool中的stream1下一次可以写入的位置。
-
postingsArray.addressOffset的第1位是2,表示term “written”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “written”的stream当前可以写入的位置。
-
postingsArray.textStarts的第1位是17,表示term “written”本身在在bytePool中存储的起始位置。
-
postingsArray.byteStarts的第1位是25,表示term “written”的stream0的第一个slice在bytePool中的位置。
-
postingsArray.termFreqs的第1位是1,表示目前位置,在当前字段中,term “written”出现了1次。
-
postingsArray.lastDocIds的第1位是0,表示在term “written”上一次出现的docId。
-
postingsArray.lastDocCodes的第1位是0,表示的是lastDocIds[0] << 1。
-
postingsArray.lastPoistions的第1位是1,表示的是term “written”在当前字段中上次出现的position。
-
postingsArray.lastOffset的第1位是7,表示的是term “written”在当前字段中上次出现的startOffset。
处理doc0中的第3个term:in
-
doc0中的“in”是第3个出现的term,BytesRefHash#add中会为其分配termID=2。
-
bytePool的第35位是2,存储是term “in”的长度。第36~37位存储的是“in”的ASCII编码。
-
bytePool的第38~42位存储的是term “in”的 stream0的第一个slice,第42位的16是slice结束的哨兵。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。
-
bytePool的第43~47位是term “in”的 stream1的第一个slice,第47位的16是slice结束的哨兵。
-
bytePool的第43位是 pos << 1 的值:4。
-
bytePool的第44位是term “in” 的 startOffset的值:15。
-
bytePool的第45位是term “in” 的endOffset - startOffset的值:2。
-
intPool的第4位是38,表示term “in”在bytePool中的stream0下一次可以写入的位置。
-
intPool的第5位是46,表示term “in”在bytePool中的stream1下一次可以写入的位置。
-
postingsArray.addressOffset的第2位是4,表示term “in”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “in”的stream当前可以写入的位置。
-
postingsArray.textStarts的第2位是35,表示term “in”本身在在bytePool中存储的起始位置。
-
postingsArray.byteStarts的第2位是38,表示term “in”的stream0的第一个slice在bytePool中的位置。
-
postingsArray.termFreqs的第2位是1,表示目前位置,在当前字段中,term “in”出现了1次。
-
postingsArray.lastDocIds的第2位是0,表示在term “in”上一次出现的docId。
-
postingsArray.lastDocCodes的第2位是0,表示的是lastDocIds[0] << 1。
-
postingsArray.lastPoistions的第2位是2,表示的是term “in”在当前字段中上次出现的position。
-
postingsArray.lastOffset的第2位是15,表示的是term “in”在当前字段中上次出现的startOffset。
处理doc0中的第3个term:java
-
doc0中的“java”是第4个出现的term,BytesRefHash#add中会为其分配termID=3。
-
bytePool的第48位是4,存储是term “java”的长度。第49~52位存储的是“java”的ASCII编码。
-
bytePool的第53~57位存储的是term “java”的 stream0的第一个slice,第57位的16是slice结束的哨兵。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。
-
bytePool的第58~62位是term “java”的 stream1的第一个slice,第62位的16是slice结束的哨兵。
-
bytePool的第58位是 pos << 1 的值:6。
-
bytePool的第59位是term “java” 的 startOffset的值:18。
-
bytePool的第60位是term “java” 的endOffset - startOffset的值:4。
-
intPool的第6位是53,表示term “java”在bytePool中的stream0下一次可以写入的位置。
-
intPool的第7位是61,表示term “java”在bytePool中的stream1下一次可以写入的位置。
-
postingsArray.addressOffset的第3位是6,表示term “java”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “java”的stream当前可以写入的位置。
-
postingsArray.textStarts的第3位是48,表示term “java”本身在在bytePool中存储的起始位置。
-
postingsArray.byteStarts的第3位是53,表示term “java”的stream0的第一个slice在bytePool中的位置。
-
postingsArray.termFreqs的第3位是1,表示目前位置,在当前字段中,term “java”出现了1次。
-
postingsArray.lastDocIds的第3位是0,表示在term “java”上一次出现的docId。
-
postingsArray.lastDocCodes的第3位是0,表示的是lastDocIds[0] << 1。
-
postingsArray.lastPoistions的第3位是3,表示的是term “java”在当前字段中上次出现的position。
-
postingsArray.lastOffset的第3位是18,表示的是term “java”在当前字段中上次出现的startOffset。
处理doc1中的第1个term:lucene
-
term “lucene”在这个字段已经出现过了,因此我们只需要追加它的信息。
-
因为doc0已经处理结束了,我们能够准确得到term “lucene”在doc0中的频率,所以需要把这部分信息记录到term “lucene”的stream0中。
-
bytePool的第7位是1,存储是docID和频率的复合体:doc0 << 1 | 1。
-
intPool的第0位更新为8,表示term “lucene”在bytePool中的stream0下一次可以写入的位置。
-
bytePool的第15位是 pos << 1 的值:0。
-
当我们要写第16位时,发现第16位非空,到了slice的哨兵位置了,值为16, currentLevel=16 & 15 = 0,可以从NEXT_LEVEL_ARRAY得到下一个level是1,进一步从LEVEL_SIZE_ARRAY得到下一个slice的大小为14。所以我们从bytePool中申请新的slice,位置为第63~76位。此时,我们把当前slice哨兵位置的前三个字节(0,6,0)拷贝到新的slice中的第63~65位,哨兵清空,把第2个slice的起始位置以小端(63,0,0,0)的方式存储到当前slice的最后四个字节(第13~16位)。
-
bytePool的第66位是term “lucene”的 startOffset的值:0。
-
bytePool的第67位是term “lucene”的endOffset - startOffset的值:6。
-
intPool的第1位是68,表示term “lucene”在bytePool中的stream1下一次可以写入的位置。
-
postingsArray.termFreqs的第0位是1,表示目前位置,在当前字段中,term “lucene”出现了1次。
-
postingsArray.lastDocIds的第0位是1,表示在term “lucene”上一次出现的docId。
-
postingsArray.lastDocCodes的第0位是2,表示的是lastDocIds[0] << 1。
-
postingsArray.lastPoistions的第0位是0,表示的是term “lucene”在当前字段中上次出现的position。
-
postingsArray.lastOffset的第0位是0,表示的是term “lucene”在当前字段中上次出现的startOffset。
处理doc1中的第2个term:action
-
doc1中的“action”是第5个出现的term,BytesRefHash#add中会为其分配termID=4。
-
bytePool的第48位是6,存储是term “action”的长度。第78~83位存储的是“action”的ASCII编码。
-
bytePool的第84~88位存储的是term “action”的 stream0的第一个slice,第88位的16是slice结束的哨兵。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。
-
bytePool的第89~93位是term “action”的 stream1的第一个slice,第62位的16是slice结束的哨兵。
-
bytePool的第89位是 pos << 1 的值:2。
-
bytePool的第90位是term “action” 的 startOffset的值:7。
-
bytePool的第91位是term “action” 的endOffset - startOffset的值:6。
-
intPool的第8位是84,表示term “action”在bytePool中的stream0下一次可以写入的位置。
-
intPool的第9位是92,表示term “action”在bytePool中的stream1下一次可以写入的位置。
-
postingsArray.addressOffset的第4位是8,表示term “action”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “action”的stream当前可以写入的位置。
-
postingsArray.textStarts的第4位是77,表示term “action”本身在在bytePool中存储的起始位置。
-
postingsArray.byteStarts的第4位是84,表示term “action”的stream0的第一个slice在bytePool中的位置。
-
postingsArray.termFreqs的第4位是1,表示目前位置,在当前字段中,term “action”出现了1次。
-
postingsArray.lastDocIds的第4位是1,表示在term “action”上一次出现的docId。
-
postingsArray.lastDocCodes的第4位是2,表示的是lastDocIds[0] << 1。
-
postingsArray.lastPoistions的第4位是1,表示的是term “action”在当前字段中上次出现的position。
-
postingsArray.lastOffset的第4位是7,表示的是term “action”在当前字段中上次出现的startOffset。
处理doc1中的第3个term:learn
-
doc1中的“learn”是第6个出现的term,BytesRefHash#add中会为其分配termID=5。
-
bytePool的第94位是5,存储是term “learn”的长度。第95~99位存储的是“action”的ASCII编码。
-
bytePool的第100~104位存储的是term “learn”的 stream0的第一个slice,第104位的16是slice结束的哨兵。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。
-
bytePool的第105~109位是term “learn”的 stream1的第一个slice,第109位的16是slice结束的哨兵。
-
bytePool的第105位是 pos << 1 的值:4。
-
bytePool的第106位是term “learn” 的 startOffset的值:14。
-
bytePool的第107位是term “learn” 的endOffset - startOffset的值:5。
-
intPool的第10位是100,表示term “learn”在bytePool中的stream0下一次可以写入的位置。
-
intPool的第11位是108,表示term “learn”在bytePool中的stream1下一次可以写入的位置。
-
postingsArray.addressOffset的第5位是10,表示term “learn”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “learn”的stream当前可以写入的位置。
-
postingsArray.textStarts的第5位是94,表示term “learn”本身在在bytePool中存储的起始位置。
-
postingsArray.byteStarts的第5位是100,表示term “learn”的stream0的第一个slice在bytePool中的位置。
-
postingsArray.termFreqs的第5位是1,表示目前位置,在当前字段中,term “learn”出现了1次。
-
postingsArray.lastDocIds的第5位是1,表示在term “learn”上一次出现的docId。
-
postingsArray.lastDocCodes的第5位是2,表示的是lastDocIds[0] << 1。
-
postingsArray.lastPoistions的第5位是2,表示的是term “learn”在当前字段中上次出现的position。
-
postingsArray.lastOffset的第5位是14,表示的是term “learn”在当前字段中上次出现的startOffset。
处理doc1中的第4个term:lucene
-
doc1中的第2个“lucene”之前出现过。所以也是在stream中追加信息。
-
bytePool的第68位是 (pos - postingsArray.lastPoistions[0]) << 1 的值:6。
-
bytePool的第69位是term “lucene” 的 startOffset - postingsArray.lastOffset[0]的值:20。
-
bytePool的第70位是term “lucene” 的endOffset - startOffset的值:6。
-
intPool的第0位是8,表示term “lucene”在bytePool中的stream0下一次可以写入的位置。
-
intPool的第1位是71,表示term “lucene”在bytePool中的stream1下一次可以写入的位置。
-
postingsArray.termFreqs的第0位是2,表示目前位置,在当前字段中,term “lucene”出现了1次。
-
postingsArray.lastDocIds的第0位是1,表示在term “lucene”上一次出现的docId。
-
postingsArray.lastDocCodes的第0位是2,表示的是lastDocIds[0] << 1。
-
postingsArray.lastPoistions的第0位是3,表示的是term “lucene”在当前字段中上次出现的position。
-
postingsArray.lastOffset的第0位是20,表示的是term “lucene”在当前字段中上次出现的startOffset。
总结
到这里,倒排数据在内存中的构建过程就介绍结束了,不知道有没有人发现一个问题,在我们构建demo中,term在doc1中的文档id和频率没有写入bytePool,那这个信息是不是丢失了?这作为遗留问题,我们后面介绍读取的时候进行揭秘。