再论文档评分中提到可以不对所有文档的评分结果排序而直接选出Top K篇文档
计算出文档的得分以后,最后一步就是选出得分最高的K 篇文档呈现给用户。尽管可以先对上述所有得分进行排序然后再挑选出前K 个结果,但是一个更好的方法是通过某种堆结构只返回头K 篇文档。假定余弦相似度(或某种相似度计算方法)得分非零的文档数目是J,那么建立这样的堆结构需要2J 次比较,对于排名前K 的每篇文档从上述堆结构中返回时都需要进行logJ 次比较。
Wiki资料:
Binary heap: http://en.wikipedia.org/wiki/Binary_heap
Heap sort: http://en.wikipedia.org/wiki/Heapsort
堆排序图例
考虑实际的需求肯定不是对所有结果按照分数排序(布尔查询的结果可能有成千上万),而是找出总结果中的前20条、40条等。这里的20条、40条就要求有序了。如果堆的大小不限的话,就可以生成一个跟结果个数同样大的最大堆,然后不断的insert,adjust,最后pop前20条、40条,也许还要更多。但这是完全没有必要的,不仅空间上挥霍可能会比较大,而且对大数量数据每次adjust会相当漫长,效率极其低下。
所以,要采用一种“受限堆”(受限指堆的大小有一定的限制)方法。一种自然的想法是采用上面的最大堆方式,只是堆大小固定,这样在堆未满之前,新来的文档不断的insert,adjust即可。但在堆已经满时,对于新来的文档,要和堆中得分最小的文档进行比较,新文档得分小的话可以直接丢弃,比堆中得分最小的文档大时才插入堆。但对于经典Binary heap最大堆的实现来说,堆中最小的文档可能在四个叶子节点的任意一个位置上,因此如果采用这样的“受限最大堆”方法的话,对新文档的插入判断效率会比较低下。
进而我们可以采用最小堆的方法,新文档不断的insert,adjust,由于堆的size可能远远小于结果个数,因而很快堆就满了。这时,就需要把后续的ScoreDoc的得分跟堆顶文档比较。如果比堆顶文档小(因为咱们实际上要的是大得分文档)就直接抛弃,反之如果比堆顶文档大,则将堆顶文档替代掉,并做adjust。最后在输出top K ScoreDoc的时候,先吐出来的时候倒一下序就可以了。
Lucene top K ScoreDoc需要用到优先级队列PriorityQueue,他是基于上面理论的“受限最小堆”实现的。
注:java JDK也有个PriorityQueue,它采用的是不固定大小的最大堆实现的,这也是Lucene没使用它,而自己实现PriorityQueue的原因。
PriorityQueue代码实现分析
PriorityQueue成员
private int size ; //已存文档个数,也代表指针位置 private int maxSize ; //堆大小 protected Object[] heap; // 存储堆中文档
文档的插入
public Object insertWithOverflow(Object element) { //堆未满的时候 if (size < maxSize) { put(element); return null; } //新文档跟堆顶值比较,如果比堆顶值大,则替换 堆顶文档,并调整堆 else if (size > 0 && !lessThan(element, heap[1])) { Object ret = heap[1]; heap[1] = element; adjustTop(); return ret; } else { return element; } }
当加入新文档时,此时堆未满,则从下往上调整
private final void upHeap() { int i = size ; //保存最后一个结点,即刚才添加进去的叶子文档结点 Object node = heap[i]; // save bottom node //得到它的父结点 int j = i >>> 1; //有父节点,并且要插入的文档得分比父节点的得分要小,则要交换位置 while (j > 0 && lessThan(node, heap[j])) { heap[i] = heap [j]; // shift parents down i = j; //迭代父节点的父节点,以此类推 j = j >>> 1; } //要插入的文档放入合适位置 heap[i] = node; // install saved node }
当堆满的时候,从上往下调整
private final void downHeap() { int i = 1; // 第一个元素,也就是刚插入的文档 Object node = heap[i]; // save top node //左子结点 int j = i << 1; // find smaller child //右子结点 int k = j + 1; //将j设置为两个子结点中的得分较小的一个 if (k <= size && lessThan(heap[k], heap[j])) { j = k; } while (j <= size && lessThan(heap[j], node)) { //迭代进行中 heap[i] = heap [j]; // shift up child i = j; //左子结点 j = i << 1; //右子结点 k = j + 1; //将j设置为两个子结点中的得分较小的一个 if (k <= size && lessThan(heap[k], heap[j])) { j = k; } } //将结点放入合适的位置 heap[i] = node; // install saved node }
最后最小堆pop出文档
public final Object pop() { if (size > 0) { Object result = heap[1]; // save first value heap[1] = heap [size ]; // move last to first heap[size ] = null; // permit GC of objects size--; downHeap(); // adjust heap return result; } else return null ; }
相同得分文档的处理
堆排序是不稳定的,即同样“大小”的结果,排完序之后,本来排在前面的结果不一定还在“大小”与之相等的原来排在其后面的元素的前面了。
考虑对结果的分页处理,用户每一次翻页操作都是一次全新的搜索请求,请求参数与第一次完全雷同,只是请求结果的offset产生了变更。而服务器端对每次要求经过同样的逻辑搜索之后,依据“offset”截取用户想要的那段结果返回。因为翻页操作导致每次的offset不同,进而导致构建的最小堆的maxSize不同,这样之前如果相同得分文档的情况处置的比较随便的话,就会发生了不稳固的情况。即在某size的情况下,某文档排在20位,另一size的情形下,可能排在了21位,前后位次还老是很濒临,且呈现在前后页的边界处,进而导致用户翻页后某篇在前页出现的文档在后页中又再次出现。
综上对PriorityQueue中的abstract函数lessThan的实现要特别注意(注意相同得分文档的处理)。
Lucene中HitQueue的实现(得分相同的话,再比较文档doc id)
protected final boolean lessThan(Object a, Object b) { ScoreDoc hitA = (ScoreDoc) a; ScoreDoc hitB = (ScoreDoc) b; if (hitA.score == hitB.score) return hitA.doc > hitB.doc; else return hitA.score < hitB.score; }