diff --git a/DESIGN.md b/DESIGN.md index 04d21a6..f0a2279 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -160,9 +160,9 @@ from the record it has already read rather than from a second lookup. `RowStoringIndex.isDeleted()` recognises a tombstone. A structure holding its own form of a row records the tombstone there too: the matrix index in the unilateral values it scores against, and an inverted index in the indexed form -verification reads. The unilateral values an inverted index orders its lists by are left -alone, since candidate generation prunes against them and an order it cannot -compare would stop pruning working. +verification reads. The unilateral values an inverted index orders its lists by +are left alone, since candidate generation prunes against them and an order it +cannot compare would stop pruning working. An update is a delete of the old version followed by an insert of the new one under the same `rowNum`, so the active cache always holds the latest version. @@ -597,6 +597,12 @@ the bound, so one adding no more rows than the bound never builds a queue. The dot products of a query are reused rather than allocated for each query, since they are as long as the matrix has rows. +Selecting raises its minimum similarity as its heap fills, as a scan does, so a +row that cannot reach what the heap already holds is rejected before a result +is made for it. A query asking for the best rows of the whole matrix otherwise +makes one result per row and lets the heap discard almost all of them, which +was measured to cost more than the multiply that produced the dot products. + Selecting runs on the thread that asked for the query, not on the thread that performed the multiply, so several queries select at once and overlap the following multiply. Performing it inside the multiply would serialize the one diff --git a/src/main/java/com/uber/ussi/searchablestructure/index/matrix/DotProductRows.java b/src/main/java/com/uber/ussi/searchablestructure/index/matrix/DotProductRows.java index d92c355..8debc72 100644 --- a/src/main/java/com/uber/ussi/searchablestructure/index/matrix/DotProductRows.java +++ b/src/main/java/com/uber/ussi/searchablestructure/index/matrix/DotProductRows.java @@ -1,6 +1,7 @@ /* AUTHOR: Ahmed Metwally (ametwally@uber.com) */ package com.uber.ussi.searchablestructure.index.matrix; +import com.uber.ussi.searchablestructure.result.ResultHeaps; import com.uber.ussi.searchablestructure.result.RowNumAndSimilarity; import com.uber.ussi.utils.BoundedSizeMaxHeap; @@ -12,18 +13,25 @@ private DotProductRows() {} /** * Adds every row reaching the minimum similarity. A deleted row reaches nothing, since the * similarity derived from its unilateral value is not a number. + * + *

The minimum rises as the heap fills, so a row that cannot reach what the heap already + * holds is rejected before a result is made for it. Without that, a query asking for the best + * rows of the whole matrix makes one result per row and lets the heap discard almost all of + * them, which was measured to cost more than the multiply that produced the dot products. */ static void addRows( float[] dotProducts, MatrixRows rows, RowSelection selection, BoundedSizeMaxHeap into) { + float minSimilarity = selection.getMinSimilarity(); for (int matrixRowIndex = 0; matrixRowIndex < rows.getNumRows(); ++matrixRowIndex) { float similarity = rows.getSimilarity( dotProducts[matrixRowIndex], selection.getQueryUniValue(), matrixRowIndex); - if (similarity >= selection.getMinSimilarity()) { + if (similarity >= minSimilarity) { into.add(new RowNumAndSimilarity(rows.getRowNum(matrixRowIndex), similarity)); + minSimilarity = ResultHeaps.tightenedMinSimilarity(into, minSimilarity); } } } diff --git a/src/test/java/com/uber/ussi/searchablestructure/index/matrix/DotProductRowsTest.java b/src/test/java/com/uber/ussi/searchablestructure/index/matrix/DotProductRowsTest.java new file mode 100644 index 0000000..f016aad --- /dev/null +++ b/src/test/java/com/uber/ussi/searchablestructure/index/matrix/DotProductRowsTest.java @@ -0,0 +1,62 @@ +package com.uber.ussi.searchablestructure.index.matrix; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.uber.ussi.searchablestructure.result.ResultHeaps; +import com.uber.ussi.searchablestructure.result.RowNumAndSimilarity; +import com.uber.ussi.utils.BoundedSizeMaxHeap; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Rising the minimum as the heap fills must change what it costs, never what it keeps. */ +class DotProductRowsTest { + + @Test + void keepsTheBestRows() { + List kept = addRows(new float[] {1f, 5f, 3f, 4f, 2f}, /* maxResults */ 2); + + assertEquals(List.of(1L, 3L), kept, "the two strongest rows, by row number"); + } + + /** + * The minimum the heap proves is one step below its weakest row, so a row scoring exactly as + * well is still offered to the heap rather than skipped, and the heap keeps as many as it is + * bounded by. The result is what it would be with no minimum at all. + */ + @Test + void keepsTheBoundWhenRowsTieWithTheWeakestKept() { + BoundedSizeMaxHeap into = ResultHeaps.newTopResults(2); + + DotProductRows.addRows( + new float[] {1f, 4f, 4f, 4f}, TestMatrixRows.of(4), + new RowSelection(0, Float.NEGATIVE_INFINITY, 2), into); + + List kept = + into.toList().stream().map(RowNumAndSimilarity::getSimilarity).sorted().toList(); + assertEquals(List.of(4f, 4f), kept); + } + + @Test + void keepsNothingBelowTheQuerysOwnMinimum() { + BoundedSizeMaxHeap into = ResultHeaps.newTopResults(10); + float[] dotProducts = {1f, 2f, 3f}; + + DotProductRows.addRows( + dotProducts, TestMatrixRows.of(3), new RowSelection(0, /* minSimilarity */ 2.5f, 10), + into); + + assertEquals(1, into.toList().size()); + } + + private static List addRows(float[] dotProducts, int maxResults) { + BoundedSizeMaxHeap into = ResultHeaps.newTopResults(maxResults); + + DotProductRows.addRows( + dotProducts, + TestMatrixRows.of(dotProducts.length), + new RowSelection(0, Float.NEGATIVE_INFINITY, maxResults), + into); + + return into.toList().stream().map(RowNumAndSimilarity::getRowNum).sorted().toList(); + } +}