Lucene源码系列(十五):倒排索引文件生成

1,094 阅读11分钟

背景

在前面的文章中,我们已经知道了倒排信息在内存中的存储结构以及如何读取。本文要介绍的是倒排信息持久化成索引文件的过程,逻辑并不复杂,只是要存储的信息比较多,在源码分析会有比较多的参数,一定程度上会造成源码的阅读障碍,而且有些变量我们虽然注释了,但是不一定能直接看明白,可以先略过,整体看完之后再来看一遍,应该就能明白了。

概述

为了快速定位term的在某个文档中的倒排信息,或者快速判断term是否在某个文档中出现过,不能简单使用遍历的方式访问整个倒排,Lucene中为了应对上面的需求使用了跳表结构来加速docID的查询,跳表中的每个节点是一个block,block中的具体信息后面详细介绍,需要先了解的是block代表了128个文档,block的id是block中最大的docID。所以可以通过跳表快速定位到doc所在的block,然后再根据block中的其他信息,读取doc相关的倒排信息。

doc&pos&pay&skip总体图.png

我们先从整体上看一个简化的示意图,方便后面源码的理解。

如上图所示,跳表中的每一个节点就是一个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

doc文件结构.png

字段描述(字段名来自官方文档)

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单独存储。
  • 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:下一个跳表节点的指针
Footer

文件尾,主要包括

  • 文件尾魔数(同一个lucene版本所有文件一样)
  • 0
  • 校验码

pos

pos文件结构.png

字段描述(字段名来自官方文档)

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

pay文件结构.png

字段描述(字段名来自官方文档)

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
  • 校验码