背景
在前面的文章中,我们已经知道了倒排信息在内存中的存储结构以及如何读取。本文要介绍的是倒排信息持久化成索引文件的过程,逻辑并不复杂,只是要存储的信息比较多,在源码分析会有比较多的参数,一定程度上会造成源码的阅读障碍,而且有些变量我们虽然注释了,但是不一定能直接看明白,可以先略过,整体看完之后再来看一遍,应该就能明白了。
概述
为了快速定位term的在某个文档中的倒排信息,或者快速判断term是否在某个文档中出现过,不能简单使用遍历的方式访问整个倒排,Lucene中为了应对上面的需求使用了跳表结构来加速docID的查询,跳表中的每个节点是一个block,block中的具体信息后面详细介绍,需要先了解的是block代表了128个文档,block的id是block中最大的docID。所以可以通过跳表快速定位到doc所在的block,然后再根据block中的其他信息,读取doc相关的倒排信息。
我们先从整体上看一个简化的示意图,方便后面源码的理解。
如上图所示,跳表中的每一个节点就是一个block,对应的doc的一个block,一个doc的block对应了多个posBuffer的block,而且一个doc的block对应的position的block不一定是整数(如上图所示,有占用部分block的信息),因为doc中的position不一定是BLOCK_SIZE的整数倍,因此需要记录PosBlockOffset来判断一个doc block在position中的起始位置。offset和position是一一对应的,因此结构是相同的。payload的信息不是使用block粒度区分的,而是一个doc对应的所有payload存储在一起,因此也需要记录PayByteUpto,来区分一个doc block对应的payload信息的起始位置。
核心类
CompetitiveImpactAccumulator
针对一些特殊的查询,比如说topK的查询,如果一个block中得分最高的doc都无法进入topk,那就可以直接忽略这个block的查询。所以在构建的时候就为每个block筛选了一部分有竞争力的文档,至于怎么判断是有竞争力的,可以看lucene中打分函数org.apache.lucene.search.similarities.Similarity.SimScorer#score的注释:
/**
* Score a single document. {@code freq} is the document-term sloppy frequency and must be
* finite and positive. {@code norm} is the encoded normalization factor as computed by {@link
* Similarity#computeNorm(FieldInvertState)} at index time, or {@code 1} if norms are disabled.
* {@code norm} is never {@code 0}.
*
* <p>Score must not decrease when {@code freq} increases, ie. if {@code freq1 > freq2}, then
* {@code score(freq1, norm) >= score(freq2, norm)} for any value of {@code norm} that may be
* produced by {@link Similarity#computeNorm(FieldInvertState)}.
*
* <p>Score must not increase when the unsigned {@code norm} increases, ie. if {@code
* Long.compareUnsigned(norm1, norm2) > 0} then {@code score(freq, norm1) <= score(freq, norm2)}
* for any legal {@code freq}.
*
* <p>As a consequence, the maximum score that this scorer can produce is bound by {@code
* score(Float.MAX_VALUE, 1)}.
*
* @param freq sloppy term frequency, must be finite and positive
* @param norm encoded normalization factor or {@code 1} if norms are disabled
* @return document's score
*/
public abstract float score(float freq, long norm);
其中跟我们要讨论的有竞争力的文档相关的是:
Score must not decrease when freq increases, ie. if freq1 > freq2, then score(freq1, norm) >= score(freq2, norm) for any value of norm that may be produced by computeNorm(FieldInvertState).
Score must not increase when the unsigned norm increases, ie. if Long.compareUnsigned(norm1, norm2) > 0 then score(freq, norm1) <= score(freq, norm2) for any legal freq.
总结出来就两点:
- 当norm一样时,freq越大相关性得分越大。
- 当freq一样时,norm越大相关性得分越小。
因此有竞争力的文档就是freq越大,norm越小的文档。
在构建过程中,使用CompetitiveImpactAccumulator来收集有竞争力的文档的freq和norm对,用Impact对象封装。
接下来,我们详细看下CompetitiveImpactAccumulator。
成员变量
// 下标是norm,值是freq,对于norm相同的,只保留较大的freq
private final int[] maxFreqs;
// 这个为了处理那些norm值超出-128~127情况,默认BM25 similarity始终是空的,因为BM25把norm映射成一个byte。
private final TreeSet<Impact> otherFreqNormPairs;
构造方法
在构造方法中,主要是初始化maxFreqs数组,大小是一个byte的大小。另外,初始化一个TreeSet,用来存储norm值在-128~127的情况。
public CompetitiveImpactAccumulator() {
// 包含1个字节大小的数组
maxFreqs = new int[256];
Comparator<Impact> comparator =
new Comparator<Impact>() {
@Override
public int compare(Impact o1, Impact o2) {
// freq大的比较大
int cmp = Integer.compare(o1.freq, o2.freq);
if (cmp == 0) {
// freq相等,norm小的比较大
cmp = Long.compareUnsigned(o2.norm, o1.norm);
}
return cmp;
}
};
otherFreqNormPairs = new TreeSet<>(comparator);
}
核心方法
新加入Impact
如果norm值在-128~127之间,则存储在maxFreq数组中,相同norm只保留freq大的。
其他情况,会根据是否有竞争力存储在TreeSet中,同时也会把明确不具竞争力的删除。
public void add(int freq, long norm) {
if (norm >= Byte.MIN_VALUE && norm <= Byte.MAX_VALUE) { // 如果norm的范围在 -128~127之间
int index = Byte.toUnsignedInt((byte) norm); // 处理norm负数的情况
maxFreqs[index] = Math.max(maxFreqs[index], freq); // 相同norm只保留较大的freq
} else { // 其他情况直接加入TreeSet
add(new Impact(freq, norm), otherFreqNormPairs);
}
}
private void add(Impact newEntry, TreeSet<Impact> freqNormPairs) {
Impact next = freqNormPairs.ceiling(newEntry); // 获取大于等于newEntry的第一个元素
if (next == null) { // 如果没有比要新加入的大,则直接加入TreeSet
freqNormPairs.add(newEntry);
} else if (Long.compareUnsigned(next.norm, newEntry.norm) <= 0) {
// 走到这里说明next的freq大于等于newEntry,如果next的norm还比newEntry的小,则说明newEntry没有竞争力
return;
} else {
// 走到这里说明无法比较得分的优劣,则把newEntry加入
freqNormPairs.add(newEntry);
}
for (Iterator<Impact> it = freqNormPairs.headSet(newEntry, false).descendingIterator();
it.hasNext(); ) {
Impact entry = it.next();
if (Long.compareUnsigned(entry.norm, newEntry.norm) >= 0) {
// 把entry.freq <= newEntry.freq && entry.norm >= newEntry.norm的去掉,这些是明确没有竞争力的
it.remove();
} else {
// 后面的都无法判断了,无法判断的情况就是 entry.freq <= newEntry.freq && entry.norm <= newEntry.norm
break;
}
}
}
获取所有有竞争力的Impact
// 获取所有有竞争力的Impact
public Collection<Impact> getCompetitiveFreqNormPairs() {
List<Impact> impacts = new ArrayList<>();
int maxFreqForLowerNorms = 0;
for (int i = 0; i < maxFreqs.length; ++i) {
int maxFreq = maxFreqs[i];
if (maxFreq > maxFreqForLowerNorms) { // norm更小,freq更大,肯定是有竞争力的
impacts.add(new Impact(maxFreq, (byte) i));
maxFreqForLowerNorms = maxFreq;
}
}
if (otherFreqNormPairs.isEmpty()) {
return impacts;
}
TreeSet<Impact> freqNormPairs = new TreeSet<>(this.otherFreqNormPairs);
for (Impact impact : impacts) {
add(impact, freqNormPairs);
}
return Collections.unmodifiableSet(freqNormPairs);
}
MultiLevelSkipListWriter
lucene中的跳表是在构建倒排和查询的时候专用的,所以实现上比较定制化。
成员变量
// 跳表的层数
protected final int numberOfSkipLevels;
// 每处理skipInterval个文档生成一个第0层跳表的节点,其实就是每个跳表节点包含的文档数
private final int skipInterval;
// 从第0层开始,每层间隔skipMultiplier的节点就加入上一层
private final int skipMultiplier;
// 存储跳表每一层的数据的缓存
private ByteBuffersDataOutput[] skipBuffer;
构造方法
// df就是整个跳表要容纳的文档总数,根据这个参数可以算出跳表最高层数
protected MultiLevelSkipListWriter(
int skipInterval, int skipMultiplier, int maxSkipLevels, int df) {
this.skipInterval = skipInterval;
this.skipMultiplier = skipMultiplier;
int numberOfSkipLevels;
// 如果文档总数小于skipInterval,则只有一层
if (df <= skipInterval) {
numberOfSkipLevels = 1;
} else {
// df / skipInterval表示最底层有多少个节点
// skipMultiplier 表示下一层间隔多少个节点生成上一层的节点
numberOfSkipLevels = 1 + MathUtil.log(df / skipInterval, skipMultiplier);
}
// 不能超过最大层数限制
if (numberOfSkipLevels > maxSkipLevels) {
numberOfSkipLevels = maxSkipLevels;
}
this.numberOfSkipLevels = numberOfSkipLevels;
}
// df就是整个跳表要容纳的文档总数,根据这个参数可以算出跳表最高层数
protected MultiLevelSkipListWriter(int skipInterval, int maxSkipLevels, int df) {
this(skipInterval, skipInterval, maxSkipLevels, df);
}
核心方法
初始化跳表
初始化主要是为每一层建立一个缓存,用来存储每一层数据。最后对跳表的持久化就是持久化这些缓存。
protected void init() {
// 每一层一个buffer
skipBuffer = new ByteBuffersDataOutput[numberOfSkipLevels];
for (int i = 0; i < numberOfSkipLevels; i++) {
skipBuffer[i] = ByteBuffersDataOutput.newResettableInstance();
}
}
重置跳表
如果跳表的缓存还未创建,则为跳表每一层创建缓存。如果缓存已经创建,则清空每个缓存。
protected void resetSkip() {
// 如果没有初始化过,则直接初始化
if (skipBuffer == null) {
init();
} else {
for (int i = 0; i < skipBuffer.length; i++) {
skipBuffer[i].reset();
}
}
}
生成跳表
此方法是生成跳表的数据,把跳表数据暂存在缓存中。
df表示到目前为止的文档总数,根据df可以得到当前要生成的跳表节点最多可以到达第几层。
从bufferSkip方法中,我们可以看到子类实现的writeSkipData决定了skip节点存储了哪些信息,在节点的最后面,存储了指向下一层节点的指针。
public void bufferSkip(int df) throws IOException {
assert df % skipInterval == 0;
int numLevels = 1;
// 现在df表示最底层有多少个节点
df /= skipInterval;
// 计算当前的skip节点可以到达第几层,上限是 numberOfSkipLevels 层
while ((df % skipMultiplier) == 0 && numLevels < numberOfSkipLevels) {
numLevels++;
df /= skipMultiplier;
}
// 上层skip节点指向下层skip节点的指针,其实是相对于skip起始位置的相对位置
long childPointer = 0;
// 生成每一层的跳表数据
for (int level = 0; level < numLevels; level++) {
// 生成跳表数据写入 skipBuffer[level],具体有子类实现每个跳表节点存储的数据
writeSkipData(level, skipBuffer[level]);
// 下一层节点的指针是目前level层的大小,也就是相对跳表起始位置
long newChildPointer = skipBuffer[level].size();
if (level != 0) { // 除了最底层,其他层都记录下一层的起始位置
writeChildPointer(childPointer, skipBuffer[level]);
}
childPointer = newChildPointer;
}
}
持久化跳表
一个term一个跳表,当一个term在所有文档的倒排信息都处理完成之后,调用writeSkip持久化跳表,跳表数据持久化到doc索引文件中,因为跳表就是用来快速定位doc的位置的。
持久化就是把每一层的缓存数据持久化,从最高层开始处理。
public long writeSkip(IndexOutput output) throws IOException {
long skipPointer = output.getFilePointer();
if (skipBuffer == null || skipBuffer.length == 0) return skipPointer;
// 从最高层往下持久化
for (int level = numberOfSkipLevels - 1; level > 0; level--) {
long length = skipBuffer[level].size();
// 除了第一层,其他层都是先写长度再写内容
if (length > 0) {
writeLevelLength(length, output);
skipBuffer[level].copyTo(output);
}
}
skipBuffer[0].copyTo(output);
return skipPointer;
}
Lucene90SkipWriter
Lucene90SkipWriter是MultiLevelSkipListWriter的实现类,它具体作用是决定每个跳表节点存储的数据有哪些。
成员变量
// 下标是level,值是指定level的前一个跳表节点的docID
private int[] lastSkipDoc;
// 下标是level,值是指定level的前一个block的doc索引文件的结束位置,其实就是当前block的开始的位置
private long[] lastSkipDocPointer;
// 下标是level,值是指定level的前一个block的pos索引文件的结束位置,其实就是当前block的开始的位置
private long[] lastSkipPosPointer;
// 下标是level,值是指定level的前一个block的pay索引文件的结束位置,其实就是当前block的开始的位置
private long[] lastSkipPayPointer;
// 下标是level,值是指定level的前一个block的payload缓存的结束位置,其实就是当前block的开始的位置
private int[] lastPayloadByteUpto;
private final IndexOutput docOut;
private final IndexOutput posOut;
private final IndexOutput payOut;
private int curDoc; // 当前block的docID
private long curDocPointer; // 当前block在doc文件的结束位置
private long curPosPointer; // 当前block在pos文件的结束位置
private long curPayPointer; // 当前block在pay文件的结束位置
private int curPosBufferUpto;// 当前block在posBuffer中的结束位置
private int curPayloadByteUpto;// 当前block在payloadBuffer中的结束位置
private CompetitiveImpactAccumulator[] curCompetitiveFreqNorms;
private boolean fieldHasPositions;
private boolean fieldHasOffsets;
private boolean fieldHasPayloads;
核心方法
在下面的逻辑中主要生成以下字段:
DocSkip:当前节点和前一个节点的docID差值
DocFPSkip: 虽然名字是FP,像是文件位置指针,但是其实记录的当前节点在doc文件中的总大小
PosFPSkip:当前节点在pos文件中的总大小
PosBlockOffset:当前节点在pos最后一个block中的结束offset
PayByteUpto:当前节点在payload中的总大小
PayFPSkip:当前节点在payBuffer中的总大小
ImpactLength:当前节点所有的impact的总大小
CompetitiveFreqDelta:当前节点有竞争力的频率的值
CompetitiveNormDelta:当前节点有竞争力的norm的值
protected void writeSkipData(int level, DataOutput skipBuffer) throws IOException {
// 当前block的docID和第level层的前一个block的docID的差值
int delta = curDoc - lastSkipDoc[level];
// 写入block的docID差值
skipBuffer.writeVInt(delta);
lastSkipDoc[level] = curDoc;
// 写入block在doc索引文件中的大小
skipBuffer.writeVLong(curDocPointer - lastSkipDocPointer[level]);
lastSkipDocPointer[level] = curDocPointer;
if (fieldHasPositions) {
// 写入block在pos索引文件中的大小
skipBuffer.writeVLong(curPosPointer - lastSkipPosPointer[level]);
lastSkipPosPointer[level] = curPosPointer;
// 因为position是按block存储的,但是有可能一个block是多个doc共享的,所以需要记录当前skip在block的截止位置
skipBuffer.writeVInt(curPosBufferUpto);
if (fieldHasPayloads) {
skipBuffer.writeVInt(curPayloadByteUpto);
}
if (fieldHasOffsets || fieldHasPayloads) {
// 写入block在pay索引文件中的大小
skipBuffer.writeVLong(curPayPointer - lastSkipPayPointer[level]);
lastSkipPayPointer[level] = curPayPointer;
}
}
CompetitiveImpactAccumulator competitiveFreqNorms = curCompetitiveFreqNorms[level];
assert competitiveFreqNorms.getCompetitiveFreqNormPairs().size() > 0;
if (level + 1 < numberOfSkipLevels) { // 因为跳表中上层的节点在下层肯定是存在的,所以需要把impact先存到上一层
curCompetitiveFreqNorms[level + 1].addAll(competitiveFreqNorms);
}
writeImpacts(competitiveFreqNorms, freqNormOut);
skipBuffer.writeVInt(Math.toIntExact(freqNormOut.size()));
freqNormOut.copyTo(skipBuffer);
freqNormOut.reset();
competitiveFreqNorms.clear();
}
static void writeImpacts(CompetitiveImpactAccumulator acc, DataOutput out) throws IOException {
Collection<Impact> impacts = acc.getCompetitiveFreqNormPairs();
Impact previous = new Impact(0, 0);
for (Impact impact : impacts) {
// 存储的是差值-1
int freqDelta = impact.freq - previous.freq - 1;
long normDelta = impact.norm - previous.norm - 1;
if (normDelta == 0) {
out.writeVInt(freqDelta << 1);
} else {
out.writeVInt((freqDelta << 1) | 1);
out.writeZLong(normDelta);
}
previous = impact;
}
}
PostingsWriterBase
PostingsWriterBase是倒排索引构建的接口。
public abstract class PostingsWriterBase implements Closeable {
protected PostingsWriterBase() {}
public abstract void init(IndexOutput termsOut, SegmentWriteState state) throws IOException;
// 生成字段中某个term的倒排信息
public abstract BlockTermState writeTerm(
BytesRef term, TermsEnum termsEnum, FixedBitSet docsSeen, NormsProducer norms)
throws IOException;
// 在构建term词典索引文件的时候使用 (以后介绍tim,tip索引文件的时候详细介绍)
public abstract void encodeTerm(
DataOutput out, FieldInfo fieldInfo, BlockTermState state, boolean absolute)
throws IOException;
// 设置当前处理的字段信息
public abstract void setField(FieldInfo fieldInfo);
@Override
public abstract void close() throws IOException;
}
PushPostingsWriterBase
PushPostingsWriterBase是实现了PostingsWriterBase接口的抽象类,其中最最重要的是实现了一个构建的调度方法:writeTerm。
public abstract class PushPostingsWriterBase extends PostingsWriterBase {
// 获取倒排信息,这个迭代器封装了内存中的倒排数据
private PostingsEnum postingsEnum;
// 判断需要处理的倒排数据有哪些?docID,freq,position,offset,payload
private int enumFlags;
// 当前正在处理的字段
protected FieldInfo fieldInfo;
// 索引选项
protected IndexOptions indexOptions;
protected boolean writeFreqs; // 需要把频率信息构建进索引文件
protected boolean writePositions; // 需要把position信息构建进索引文件
protected boolean writePayloads; // 需要把payload信息构建进索引文件
protected boolean writeOffsets; // 需要把offset信息构建进索引文件
protected PushPostingsWriterBase() {}
// 返回term的元信息,比如在doc,pay,pos文件中的起始位置等,这些元信息会存储在term字典的索引文件中,
// 这样就能通过term字典查找term相关倒排信息
public abstract BlockTermState newTermState() throws IOException;
// 开始处理某个term
public abstract void startTerm(NumericDocValues norms) throws IOException;
// 结束处理某个term
public abstract void finishTerm(BlockTermState state) throws IOException;
// 根据fieldInfo设置当前处理的字段的一些信息
public void setField(FieldInfo fieldInfo) {
this.fieldInfo = fieldInfo;
indexOptions = fieldInfo.getIndexOptions();
writeFreqs = indexOptions.compareTo(IndexOptions.DOCS_AND_FREQS) >= 0;
writePositions = indexOptions.compareTo(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS) >= 0;
writeOffsets =
indexOptions.compareTo(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS) >= 0;
writePayloads = fieldInfo.hasPayloads();
if (writeFreqs == false) {
enumFlags = 0;
} else if (writePositions == false) {
enumFlags = PostingsEnum.FREQS;
} else if (writeOffsets == false) {
if (writePayloads) {
enumFlags = PostingsEnum.PAYLOADS;
} else {
enumFlags = PostingsEnum.POSITIONS;
}
} else {
if (writePayloads) {
enumFlags = PostingsEnum.PAYLOADS | PostingsEnum.OFFSETS;
} else {
enumFlags = PostingsEnum.OFFSETS;
}
}
}
// 模板模式实现的调度框架,一些具体操作由子类实现
public final BlockTermState writeTerm(
BytesRef term, TermsEnum termsEnum, FixedBitSet docsSeen, NormsProducer norms)
throws IOException {
NumericDocValues normValues;
if (fieldInfo.hasNorms() == false) {
normValues = null;
} else {
normValues = norms.getNorms(fieldInfo);
}
// 开始处理term了
startTerm(normValues);
// 获取倒排信息的迭代器
postingsEnum = termsEnum.postings(postingsEnum, enumFlags);
// 记录term出现的文档总数,一个文档贡献1次
int docFreq = 0;
// term在所有文档中出现的总频率
long totalTermFreq = 0;
while (true) {
int docID = postingsEnum.nextDoc();
if (docID == PostingsEnum.NO_MORE_DOCS) {
break;
}
docFreq++;
docsSeen.set(docID);
int freq;
if (writeFreqs) {
freq = postingsEnum.freq();
totalTermFreq += freq;
} else {
freq = -1;
}
// 开始处理文档
startDoc(docID, freq);
if (writePositions) {
for (int i = 0; i < freq; i++) { // 处理所有的位置信息
int pos = postingsEnum.nextPosition();
BytesRef payload = writePayloads ? postingsEnum.getPayload() : null;
int startOffset;
int endOffset;
if (writeOffsets) {
startOffset = postingsEnum.startOffset();
endOffset = postingsEnum.endOffset();
} else {
startOffset = -1;
endOffset = -1;
}
addPosition(pos, payload, startOffset, endOffset);
}
}
// 结束一个文档的处理
finishDoc();
}
if (docFreq == 0) { // 如果term没有出现过
return null;
} else { // 把一些统计及在文件中的位置作为元信息封装在state中,最终持久化到term字典索引文件中
BlockTermState state = newTermState();
state.docFreq = docFreq;
state.totalTermFreq = writeFreqs ? totalTermFreq : -1;
finishTerm(state);
return state;
}
}
// 开始处理term所在的某个doc
public abstract void startDoc(int docID, int freq) throws IOException;
// 添加term所在doc中的位置信息
public abstract void addPosition(int position, BytesRef payload, int startOffset, int endOffset)
throws IOException;
// 结束处理term所在的某个doc
public abstract void finishDoc() throws IOException;
}
Lucene90PostingsWriter
PushPostingsWriterBase调度方法中很多抽象方法都在Lucene90PostingsWriter中实现,Lucene90PostingsWriter是执行构建的底层类,会生成doc,pos和pay后缀的索引文件。
在看源码之前,需要先整体上了解整个倒排结构:
-
docID,freq,position,offset都是按block存储的,一个block是128个值,为什么要区分block呢?因为Lucene中针对小正数使用批量的压缩算法处理,如果不区分block,则如果有一个值特别大,就会导致压缩效果骤降,而分block处理,就把异常值的影响只落在它所在的block中。
-
对于同一个term而言,docID是递增的,并且在同一篇文档中position和startOffset也是递增的。
-
为了加速doc的查找,lucene使用了跳表来加速查询。每128个doc,也就是一个 block生成一个skip节点,一个skip节点的docID就是这个block中最大的docID。
成员变量
// doc索引文件输出流
IndexOutput docOut;
// pos索引文件输出流
IndexOutput posOut;
// pay索引文件输出流
IndexOutput payOut;
static final IntBlockTermState emptyState = new IntBlockTermState();
IntBlockTermState lastState;
// 当前处理的term在各个索引文件中的起始位置
private long docStartFP;
private long posStartFP;
private long payStartFP;
// docID差值缓存,最终持久化是进行批量压缩编码的
final long[] docDeltaBuffer;
// 频率缓存,最终持久化是进行批量压缩编码的
final long[] freqBuffer;
// docDeltaBuffer和freqBuffer中下一个可以写入的位置
private int docBufferUpto;
// position差值缓存,最终持久化是进行批量压缩编码的
final long[] posDeltaBuffer;
// payload长度缓存, 最终持久化是进行批量压缩编码的
final long[] payloadLengthBuffer;
// startOffset差值缓存,最终持久化是进行批量压缩编码的
final long[] offsetStartDeltaBuffer;
// term长度缓存,最终持久化是进行批量压缩编码的
final long[] offsetLengthBuffer;
// posDeltaBuffer,payloadLengthBuffer,offsetStartDeltaBuffer和offsetLengthBuffer下一个可以写入的位置
private int posBufferUpto;
// payload数据缓存
private byte[] payloadBytes;
// payloadBytes下一个可以写入的位置
private int payloadByteUpto;
// 上一个block中的最后一个docID
private int lastBlockDocID;
// 上一个block在pos中的结束位置
private long lastBlockPosFP;
// 上一个block在pay中的结束位置
private long lastBlockPayFP;
// 上一个block结束时posBufferUpto的值
private int lastBlockPosBufferUpto;
// 上一个block结束时payloadByteUpto的值
private int lastBlockPayloadByteUpto;
// 上一个处理的docID
private int lastDocID;
// 上一个处理的position
private int lastPosition;
// 上一个处理的startOffset
private int lastStartOffset;
// 到目前位置处理的文档总数
private int docCount;
// 上面一堆long数组缓存的压缩编码器
private final PForUtil pforUtil;
// term中所有block构建的跳表,可以加速查找term中某个文档在索引文件中的位置
private final Lucene90SkipWriter skipWriter;
private boolean fieldHasNorms; // 字段是否有标准化
private NumericDocValues norms; // 获取标准化数据
// norm和freq是参与相关性打分的,可以根据norm和freq判断哪些文档是比较有竞争力的
private final CompetitiveImpactAccumulator competitiveFreqNormAccumulator =
new CompetitiveImpactAccumulator();
核心方法
开始处理term
开始处理一个新的term,记录下term在doc,pos和pay索引文件的起始位置,初始化一些参数,为构建做准备。
public void startTerm(NumericDocValues norms) {
docStartFP = docOut.getFilePointer(); // 当前term在doc索引文件中的的起始位置
if (writePositions) {
posStartFP = posOut.getFilePointer(); // 当前term在pos索引文件中的起始位置
if (writePayloads || writeOffsets) {
payStartFP = payOut.getFilePointer(); // 当前term在pay索引文件中的起始位置
}
}
lastDocID = 0;
lastBlockDocID = -1;
skipWriter.resetSkip();
this.norms = norms;
competitiveFreqNormAccumulator.clear();
}
开始处理doc
如果当前已经处理了128个文档,则先生成一个跳表节点,插入到跳表中。
然后处理docID和freq,如果有128个,则生成一个block。
public void startDoc(int docID, int termDocFreq) throws IOException {
// 上一个文档已经处理完了,并且刚好完成了128个文档,需要按block进行处理。
// 注意,这里if判断是生成跳表的,暂存在缓存中,走到if里面的逻辑,说明前128个文档的相关倒排数据已经持久化完成了。
if (lastBlockDocID != -1 && docBufferUpto == 0) {
skipWriter.bufferSkip(
lastBlockDocID,
competitiveFreqNormAccumulator,
docCount,
lastBlockPosFP,
lastBlockPayFP,
lastBlockPosBufferUpto,
lastBlockPayloadByteUpto);
competitiveFreqNormAccumulator.clear();
}
final int docDelta = docID - lastDocID; // docID是差值存储
if (docID < 0 || (docCount > 0 && docDelta <= 0)) {
throw new CorruptIndexException(
"docs out of order (" + docID + " <= " + lastDocID + " )", docOut);
}
docDeltaBuffer[docBufferUpto] = docDelta; // 记录docID的差值
if (writeFreqs) {
freqBuffer[docBufferUpto] = termDocFreq; // 记录频率
}
docBufferUpto++;
docCount++;
if (docBufferUpto == BLOCK_SIZE) { // 如果已经暂存了128个文档ID和频率,则进行压缩编码持久化
pforUtil.encode(docDeltaBuffer, docOut);
if (writeFreqs) {
pforUtil.encode(freqBuffer, docOut);
}
// 注意:docBufferUpto没有在这里被重置为0,是放在了finishDoc()中重置的
}
lastDocID = docID;
lastPosition = 0;
lastStartOffset = 0;
// 下面处理标准化数据,什么是标准化数据我们以后单独介绍。
long norm;
if (fieldHasNorms) {
boolean found = norms.advanceExact(docID);
if (found == false) {
norm = 1L;
} else {
norm = norms.longValue();
assert norm != 0 : docID;
}
} else {
norm = 1L;
}
competitiveFreqNormAccumulator.add(writeFreqs ? termDocFreq : 1, norm);
}
处理doc中的一个position
上层调度中会根据term在doc中的freq,调用多少次addPosition。
public void addPosition(int position, BytesRef payload, int startOffset, int endOffset)
throws IOException {
if (position > IndexWriter.MAX_POSITION) {
throw new CorruptIndexException(
"position="
+ position
+ " is too large (> IndexWriter.MAX_POSITION="
+ IndexWriter.MAX_POSITION
+ ")",
docOut);
}
if (position < 0) {
throw new CorruptIndexException("position=" + position + " is < 0", docOut);
}
posDeltaBuffer[posBufferUpto] = position - lastPosition; // position也是差值存储
if (writePayloads) { // 如果需要存储payload数据
if (payload == null || payload.length == 0) { // 当前位置没有payload数据
payloadLengthBuffer[posBufferUpto] = 0;
} else {
payloadLengthBuffer[posBufferUpto] = payload.length; // 记录payload长度
if (payloadByteUpto + payload.length > payloadBytes.length) { // payload缓存需要扩容
payloadBytes = ArrayUtil.grow(payloadBytes, payloadByteUpto + payload.length);
}
// 存储payload数据
System.arraycopy(
payload.bytes, payload.offset, payloadBytes, payloadByteUpto, payload.length);
payloadByteUpto += payload.length;
}
}
if (writeOffsets) { // 需要存储offset
offsetStartDeltaBuffer[posBufferUpto] = startOffset - lastStartOffset;
offsetLengthBuffer[posBufferUpto] = endOffset - startOffset;
lastStartOffset = startOffset;
}
posBufferUpto++;
lastPosition = position;
if (posBufferUpto == BLOCK_SIZE) { // 如果一个block满了
pforUtil.encode(posDeltaBuffer, posOut); // 压缩编码position差值信息,写入pos索引文件
if (writePayloads) {
pforUtil.encode(payloadLengthBuffer, payOut); // payload长度信息写入pay索引文件
payOut.writeVInt(payloadByteUpto); // 写入payloadByteUpto
payOut.writeBytes(payloadBytes, 0, payloadByteUpto); // 写入payload数据
payloadByteUpto = 0;
}
if (writeOffsets) {
pforUtil.encode(offsetStartDeltaBuffer, payOut); // startOffset压缩编码写入pay索引文件
pforUtil.encode(offsetLengthBuffer, payOut); // term长度压缩编码写入pay索引文件
}
posBufferUpto = 0;
}
}
结束doc处理
public void finishDoc() throws IOException {
// 这里可以看到是每 BLOCK_SIZE = 128 个文档为一个block
if (docBufferUpto == BLOCK_SIZE) {
lastBlockDocID = lastDocID; // 记录上一个block的docID
if (posOut != null) {
if (payOut != null) {
lastBlockPayFP = payOut.getFilePointer(); // 记录上一个block在pay文件的位置
}
lastBlockPosFP = posOut.getFilePointer(); // 记录上一个block在pos文件的位置
lastBlockPosBufferUpto = posBufferUpto;
lastBlockPayloadByteUpto = payloadByteUpto;
}
docBufferUpto = 0; // 重置docID差值缓存的可写入位置
}
}
结束term处理
在结束的一个term的处理时,最核心的是处理那些不足128个无法组成一个block的数据,使用VInt类型存储这些数据。
public void finishTerm(BlockTermState _state) throws IOException {
IntBlockTermState state = (IntBlockTermState) _state;
final int singletonDocID;
if (state.docFreq == 1) { // 如果在最后一个block中term只出现在一个doc中
singletonDocID = (int) docDeltaBuffer[0]; // 用singletonDocID记录唯一出现的docID
} else {
singletonDocID = -1;
// 用vint编码最后不满足一个block部分的docID差值和频率
for (int i = 0; i < docBufferUpto; i++) {
final int docDelta = (int) docDeltaBuffer[i];
final int freq = (int) freqBuffer[i];
if (!writeFreqs) {
docOut.writeVInt(docDelta);
} else if (freq == 1) {
docOut.writeVInt((docDelta << 1) | 1);
} else {
docOut.writeVInt(docDelta << 1);
docOut.writeVInt(freq);
}
}
}
final long lastPosBlockOffset;
if (writePositions) {
if (state.totalTermFreq > BLOCK_SIZE) { // 超过一个block才需要记录lastPosBlockOffset
lastPosBlockOffset = posOut.getFilePointer() - posStartFP;
} else {
lastPosBlockOffset = -1;
}
if (posBufferUpto > 0) {
int lastPayloadLength = -1;
int lastOffsetLength = -1;
int payloadBytesReadUpto = 0;
for (int i = 0; i < posBufferUpto; i++) {
final int posDelta = (int) posDeltaBuffer[i];
if (writePayloads) {
final int payloadLength = (int) payloadLengthBuffer[i];
if (payloadLength != lastPayloadLength) {
lastPayloadLength = payloadLength;
posOut.writeVInt((posDelta << 1) | 1);
posOut.writeVInt(payloadLength);
} else {
posOut.writeVInt(posDelta << 1);
}
if (payloadLength != 0) {
posOut.writeBytes(payloadBytes, payloadBytesReadUpto, payloadLength);
payloadBytesReadUpto += payloadLength;
}
} else {
posOut.writeVInt(posDelta);
}
if (writeOffsets) { // 以vint写入剩余不满足一个block的offset数据
int delta = (int) offsetStartDeltaBuffer[i];
int length = (int) offsetLengthBuffer[i];
if (length == lastOffsetLength) {
posOut.writeVInt(delta << 1);
} else {
posOut.writeVInt(delta << 1 | 1);
posOut.writeVInt(length);
lastOffsetLength = length;
}
}
}
if (writePayloads) {
assert payloadBytesReadUpto == payloadByteUpto;
payloadByteUpto = 0;
}
}
} else {
lastPosBlockOffset = -1;
}
long skipOffset;
if (docCount > BLOCK_SIZE) { // 至少一个block说明肯定存在跳表,需要持久化跳表
skipOffset = skipWriter.writeSkip(docOut) - docStartFP;
} else {
skipOffset = -1;
}
// state中的这些信息就是term的元信息,会存储在term字典中
state.docStartFP = docStartFP;
state.posStartFP = posStartFP;
state.payStartFP = payStartFP;
state.singletonDocID = singletonDocID;
state.skipOffset = skipOffset;
state.lastPosBlockOffset = lastPosBlockOffset;
docBufferUpto = 0;
posBufferUpto = 0;
lastDocID = 0;
docCount = 0;
}
总结
doc
字段描述(字段名来自官方文档)
Header
文件头部信息,主要是包括:
- 文件头魔数(同一lucene版本所有文件相同)
- 该文件使用的codec名称:Lucene90PostingsWriterDoc
- codec版本
- segment后缀名(一般为空)
- segment id(也是Segment_N文件中的N)
Field
doc文件是按字段区分的,所有的Field存储在一起。每个Field的数据有:
- TermFreqs:存储term的所有的docID及其出现的频率。
- PackedBlock:128个文档一个block
- PackedDocDeltaBlock:docID差值的block
- PackedFreqBlock:频率的block
- VIntBlock:剩下不足128的使用VIntBlock,其实就是把docID和freq用vint存储。
- DocDelta:docID差值。如果频率是1的话,则是docDelta << 1 | 1,否则是docDelta << 0
- Freq:如果频率大于1,则freq单独存储。
- PackedBlock:128个文档一个block
- SkipData:跳表的数据,一个term一个跳表
- SkipLevelLength:跳表当前层的数据长度
- SkipLevel:跳表当前层的数据,由多个跳表节点组成,每个跳表节点是一个SkipDatum。
- SkipDatum:跳表一个节点的数据
- DocSkip:当前SkipDatum的文档号和上一个SkipDatum文档号的差值
- DocFPSkip:当前SkipDatum在doc文件中的总大小
- PosFPSkip:当前SkipDatum在pos文件中的总大小
- PosBlockOffset:SkipDatum结束的位置在pos的最后一个block的结束位置
- PayLength:源码中对应的是payloadByteUpto
- PayFPSkip:当前SkipDatum在pay文件中的总大小
- ImpactLength:有竞争力的freq和norm对的总大小
- CompetitiveFreqDelta:有竞争力的freq
- CompetitiveNormDelta:有竞争力的norm
- SkipChildLevelPointer:下一个跳表节点的指针
- SkipDatum:跳表一个节点的数据
Footer
文件尾,主要包括
- 文件尾魔数(同一个lucene版本所有文件一样)
- 0
- 校验码
pos
字段描述(字段名来自官方文档)
Header
文件头部信息,主要是包括:
- 文件头魔数(同一lucene版本所有文件相同)
- 该文件使用的codec名称:Lucene90PostingsWriterPos
- codec版本
- segment后缀名(一般为空)
- segment id(也是Segment_N文件中的N)
Field
- TermPosition:每一个term都会生成一个TermPosition
- PackedPostDeltaBlock:term的128个position信息存成一个block
- VIntBlock:剩下不足128个的存储成一个VIntBlock
- PositionDelta:term的position信息,使用差值存储。PositionDelta的最后一位表示是否有payload信息。
- PalyloadLength:如果存在payload信息,则存储payload的长度
- PayloadData:如果存在payload信息,则存储payload的数据
- OffsetDelta:存储offset信息,是startOffset,使用差值存储
- OffsetLength:term的endOffset和startOffset的差值,其实就是term的长度。
Footer
文件尾,主要包括
- 文件尾魔数(同一个lucene版本所有文件一样)
- 0
- 校验码
pay
字段描述(字段名来自官方文档)
Header
文件头部信息,主要是包括:
- 文件头魔数(同一lucene版本所有文件相同)
- 该文件使用的codec名称:Lucene90PostingsWriterPay
- codec版本
- segment后缀名(一般为空)
- segment id(也是Segment_N文件中的N)
Field
- TermPayloads:存储的是payload信息
- PackedPayLengthBlock:128个position的payload的长度存储成一个block
- SumPayLength:payload长度
- PayData:payload数据
- TermOffsets:存储的是startOffset和offsetLength的block
- PackedOffsetStartDeltaBlock:128个StartOffset差值存储成一个block
- PackedOffsetLengthBlock:128个offsetLength存储成一个block
Footer
文件尾,主要包括
- 文件尾魔数(同一个lucene版本所有文件一样)
- 0
- 校验码