Apache Lucene/Top-K 命中收集

用三叉小顶堆保留搜索得分最高的 K 个文档

Lucene 在这份固定提交中把得分与文档编号编码为 long,使用三叉小顶堆维护当前最有竞争力的 K 个命中。

d150ba27d检索与计算核验 2026-10-12
相关概念

工程问题#

查询可能命中大量文档,但用户只需要前 K 个结果。保存并排序全部命中会放大内存占用;相同得分还需要稳定地按文档编号打破平局。

实际实现#

TopScoreDocCollector 预先填充 K 个最低竞争力哨兵,堆顶代表当前最差候选。按文档编号递增收集时,不超过堆顶得分的后续文档可直接拒绝;更好的命中把 score 和反向 docId 编码为 long 后替换堆顶,再沿三个子节点中最小的一支下沉。输出时连续弹出最小项,反向填充结果数组。

处理流程#

  1. 建立含 K 个哨兵的三叉小顶堆,读取堆顶阈值
  2. 在查询收集过程中拒绝不够好的命中,平分时保留较小文档编号
  3. 把新候选编码为 long,替换堆顶并下沉恢复堆序
  4. 弹出堆中有效命中,从数组末尾向前填充得到得分降序结果

源码片段#

固定版本 · 原文摘录
lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.javad150ba27d1192396a8a67a765bb2f05cf79375b0 · L40–L48
查看原文件
40  // prevents instantiation41  TopScoreDocCollector(42      int numHits, ScoreDoc after, int totalHitsThreshold, MaxScoreAccumulator minScoreAcc) {43    super(null);44    this.heap = new TernaryLongHeap(numHits, DocScoreEncoder.LEAST_COMPETITIVE_CODE);45    this.after = after;46    this.totalHitsThreshold = totalHitsThreshold;47    this.minScoreAcc = minScoreAcc;48  }
TopScoreDocCollector 构造

初始化容量为 K 的三叉 long 堆并填入最低竞争力哨兵,后续更新始终围绕最差候选进行。

lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.javad150ba27d1192396a8a67a765bb2f05cf79375b0 · L113–L134
查看原文件
113        if (score <= topScore) {114          // Note: for queries that match lots of hits, this is the common case: most hits are not115          // competitive.116          if (hitCountSoFar == totalHitsThreshold + 1) {117            // we just exceeded totalHitsThreshold, we can start setting the min118            // competitive score now119            updateMinCompetitiveScore(scorer);120          }121122          // Since docs are returned in-order (i.e., increasing doc Id), a document123          // with equal score to pqTop.score cannot compete since HitQueue favors124          // documents with lower doc Ids. Therefore reject those docs too.125        } else {126          collectCompetitiveHit(doc, score);127        }128      }129130      private void collectCompetitiveHit(int doc, float score) throws IOException {131        final long code = DocScoreEncoder.encode(doc + docBase, score);132        topCode = heap.updateTop(code);133        topScore = DocScoreEncoder.toScore(topCode);134        updateMinCompetitiveScore(scorer);
collect / collectCompetitiveHit

按文档编号递增的前提下,得分相同的后续文档无法胜过已有更小 docId。竞争命中编码后直接替换堆顶。

lucene/core/src/java/org/apache/lucene/search/DocScoreEncoder.javad150ba27d1192396a8a67a765bb2f05cf79375b0 · L31–L41
查看原文件
31  static long encode(int docId, float score) {32    return (((long) NumericUtils.floatToSortableInt(score)) << 32) | (Integer.MAX_VALUE - docId);33  }3435  static float toScore(long value) {36    return NumericUtils.sortableIntToFloat((int) (value >>> 32));37  }3839  static int docId(long value) {40    return Integer.MAX_VALUE - ((int) value);41  }
DocScoreEncoder

高位是可排序的浮点得分编码,低位反转文档编号,使较高得分和较小 docId 对应更大的编码。

lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.javad150ba27d1192396a8a67a765bb2f05cf79375b0 · L151–L155
查看原文件
151  public long updateTop(long value) {152    heap[1] = value;153    TernaryLongHeap.downHeap(heap, 1, size, ARITY);154    return heap[1];155  }
TernaryLongHeap.updateTop

替换堆顶后只需向下恢复堆序,无需先 pop 再 push。

lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.javad150ba27d1192396a8a67a765bb2f05cf79375b0 · L228–L248
查看原文件
228      int lastChild = Math.min(firstChild + arity - 1, size);229230      // find the smallest child in [firstChild, lastChild]231      int best = firstChild;232      long bestVal = heap[firstChild];233234      for (int c = firstChild + 1; c <= lastChild; c++) {235        final long v = heap[c];236        if (v < bestVal) {237          bestVal = v;238          best = c;239        }240      }241242      if (bestVal >= value) break;243244      heap[i] = bestVal;245      i = best;246    }247    heap[i] = value; // install saved value248  }
TernaryLongHeap.downHeap

firstChild 在前文按三叉堆下标公式计算;这里比较至多三个孩子,沿最小孩子移动,直到新值可以落位。

lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.javad150ba27d1192396a8a67a765bb2f05cf79375b0 · L187–L193
查看原文件
187  @Override188  protected void populateResults(ScoreDoc[] results, int howMany) {189    for (int i = howMany - 1; i >= 0; i--) {190      long encode = heap.pop();191      results[i] = new ScoreDoc(DocScoreEncoder.docId(encode), DocScoreEncoder.toScore(encode));192    }193  }
populateResults

堆按从差到好弹出,结果数组从后向前填写,避免再次对候选全排序。

片段保持固定提交的连续原文;完整源码与许可证保存在本地。每段说明交代所在分支,省略部分可通过出处查看。

收益与取舍#

工程分析

带来的收益

  • 候选空间为 O(K),每个竞争命中的堆更新为 O(log K),无需保存全部命中
  • 单个 long 同时表示得分和文档编号顺序,避免在堆内为每项维护 ScoreDoc 对象

付出的代价

  • 三叉堆每层需要比较多个孩子,较浅的高度不自动等于所有工作负载都更快
  • 当总命中计数超过阈值时,收集器可提高 scorer 的最低竞争得分,并把总数关系改成下界;精确总命中数与 Top-K 是不同目标
  • 每个竞争命中仍需堆调整;K 很大时堆内存和更新成本也会增长

可比较的选择

收集后全排序实现直接且能取得全部顺序,但对 N 个命中通常需要 O(N) 存储和 O(N log N) 排序。

二叉堆同样支持 O(log K) 更新;分支数、比较次数和内存访问常数不同,需实测选择。

版本与适用范围#

对应 d150ba2 中按得分排序的 TopScoreDocCollector;该版本已使用 TernaryLongHeap,不是通用 PriorityQueue 的二叉堆实现。字段排序、跨分片归并及其他收集器另有路径。复杂度只计候选维护,不含查询匹配和评分本身。

参考来源#

源码、官方文档与原始论文
01
Apache Lucene · TopScoreDocCollector 构造

lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java · TopScoreDocCollector 构造

源码d150ba27d1192396a8a67a765bb2f05cf79375b0
02
Apache Lucene · DocScoreEncoder

lucene/core/src/java/org/apache/lucene/search/DocScoreEncoder.java · DocScoreEncoder

源码d150ba27d1192396a8a67a765bb2f05cf79375b0
03
Apache Lucene · TernaryLongHeap.updateTop

lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.java · TernaryLongHeap.updateTop

源码d150ba27d1192396a8a67a765bb2f05cf79375b0

相关实现与比较#

4 个相关案例
SwarmKit·服务调度器1fd637ba5

SwarmKit 的调度决策树在叶子中使用 nodeMaxHeap,把候选节点整理为从优到劣的顺序。

附源码片段 · 调度与资源分配
systemd·.timer 单元与 sd-event 时间事件v257

systemd 的 sd-event 为每个时钟维护两个带位置索引的小顶堆,快速找到下一次到期时间,并利用允许的延迟余量合并唤醒。

附源码片段 · 调度与资源分配
19 个匹配案例