用三叉小顶堆保留搜索得分最高的 K 个文档
Lucene 在这份固定提交中把得分与文档编号编码为 long,使用三叉小顶堆维护当前最有竞争力的 K 个命中。
工程问题#
查询可能命中大量文档,但用户只需要前 K 个结果。保存并排序全部命中会放大内存占用;相同得分还需要稳定地按文档编号打破平局。
实际实现#
TopScoreDocCollector 预先填充 K 个最低竞争力哨兵,堆顶代表当前最差候选。按文档编号递增收集时,不超过堆顶得分的后续文档可直接拒绝;更好的命中把 score 和反向 docId 编码为 long 后替换堆顶,再沿三个子节点中最小的一支下沉。输出时连续弹出最小项,反向填充结果数组。
处理流程#
- 建立含 K 个哨兵的三叉小顶堆,读取堆顶阈值
- 在查询收集过程中拒绝不够好的命中,平分时保留较小文档编号
- 把新候选编码为 long,替换堆顶并下沉恢复堆序
- 弹出堆中有效命中,从数组末尾向前填充得到得分降序结果
源码片段#
固定版本 · 原文摘录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 }初始化容量为 K 的三叉 long 堆并填入最低竞争力哨兵,后续更新始终围绕最差候选进行。
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);按文档编号递增的前提下,得分相同的后续文档无法胜过已有更小 docId。竞争命中编码后直接替换堆顶。
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 }高位是可排序的浮点得分编码,低位反转文档编号,使较高得分和较小 docId 对应更大的编码。
151 public long updateTop(long value) {152 heap[1] = value;153 TernaryLongHeap.downHeap(heap, 1, size, ARITY);154 return heap[1];155 }替换堆顶后只需向下恢复堆序,无需先 pop 再 push。
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 }firstChild 在前文按三叉堆下标公式计算;这里比较至多三个孩子,沿最小孩子移动,直到新值可以落位。
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 }堆按从差到好弹出,结果数组从后向前填写,避免再次对候选全排序。
片段保持固定提交的连续原文;完整源码与许可证保存在本地。每段说明交代所在分支,省略部分可通过出处查看。
收益与取舍#
工程分析带来的收益
- 候选空间为 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 的二叉堆实现。字段排序、跨分片归并及其他收集器另有路径。复杂度只计候选维护,不含查询匹配和评分本身。
参考来源#
源码、官方文档与原始论文lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java · TopScoreDocCollector 构造
lucene/core/src/java/org/apache/lucene/search/DocScoreEncoder.java · DocScoreEncoder
lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.java · TernaryLongHeap.updateTop
相关实现与比较#
4 个相关案例SwarmKit 的调度决策树在叶子中使用 nodeMaxHeap,把候选节点整理为从优到劣的顺序。
systemd 的 sd-event 为每个时钟维护两个带位置索引的小顶堆,快速找到下一次到期时间,并利用允许的延迟余量合并唤醒。
etcd 用按检查时间排序的小顶堆寻找待检查租约,用租约 ID 索引直接定位堆元素;到期检测只发起撤销,实际删除还要经过一致性处理。
libuv 用侵入式二叉最小堆索引定时器;到期项先移入就绪队列,再重新设置周期并调用回调。