Rank an exact title above a longer one that starts the same (#80)
'test' and 'test2' scored identically for the query 'test' — both have it at the start of the title — so the order fell through to the date, and 'test2' being still to come put it above a 'test' that had already passed. Equal scores now compare how much of the title the match covers, which is 1.0 only for an exact hit, before date is consulted.
This commit is contained in:
@@ -42,6 +42,8 @@ data class SearchHit(
|
|||||||
* sat in that field (whole-field start > word start > mid-word), and an event
|
* sat in that field (whole-field start > word start > mid-word), and an event
|
||||||
* ranks by the *weakest* field the query needed — one token findable only in the
|
* ranks by the *weakest* field the query needed — one token findable only in the
|
||||||
* description drags the whole event below the title matches, however soon it is.
|
* description drags the whole event below the title matches, however soon it is.
|
||||||
|
* Equal scores are separated by how much of the title the match covers, so an
|
||||||
|
* exact title beats one that merely begins the same way, and only then by date.
|
||||||
*/
|
*/
|
||||||
object EventSearchRanker {
|
object EventSearchRanker {
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ object EventSearchRanker {
|
|||||||
.sortedWith(
|
.sortedWith(
|
||||||
compareByDescending<Scored> { it.weakest }
|
compareByDescending<Scored> { it.weakest }
|
||||||
.thenByDescending { it.total }
|
.thenByDescending { it.total }
|
||||||
|
.thenByDescending { it.titleCoverage }
|
||||||
.thenBy { if (it.hit.event.end >= now) 0 else 1 }
|
.thenBy { if (it.hit.event.end >= now) 0 else 1 }
|
||||||
.thenComparator { a, b ->
|
.thenComparator { a, b ->
|
||||||
val aStart = a.hit.event.start
|
val aStart = a.hit.event.start
|
||||||
@@ -121,10 +124,11 @@ object EventSearchRanker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
||||||
|
val titleSpans = spansIn(title, tokens)
|
||||||
return Scored(
|
return Scored(
|
||||||
hit = SearchHit(
|
hit = SearchHit(
|
||||||
event = candidate.event,
|
event = candidate.event,
|
||||||
titleSpans = spansIn(title, tokens),
|
titleSpans = titleSpans,
|
||||||
locationSpans = location?.let { spansIn(it, tokens) }.orEmpty(),
|
locationSpans = location?.let { spansIn(it, tokens) }.orEmpty(),
|
||||||
descriptionSnippet = description
|
descriptionSnippet = description
|
||||||
?.takeIf { descriptionSpans.isNotEmpty() }
|
?.takeIf { descriptionSpans.isNotEmpty() }
|
||||||
@@ -132,9 +136,21 @@ object EventSearchRanker {
|
|||||||
),
|
),
|
||||||
weakest = weakest,
|
weakest = weakest,
|
||||||
total = total,
|
total = total,
|
||||||
|
titleCoverage = coverage(title, titleSpans),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of [text] the match accounts for — 1.0 when the query *is* the
|
||||||
|
* title. What separates an exact hit from one that merely starts the same
|
||||||
|
* way: "test" and "test2" score identically otherwise, and the shorter title
|
||||||
|
* is the closer answer whichever of the two happens to be sooner.
|
||||||
|
*/
|
||||||
|
private fun coverage(text: String, spans: List<MatchSpan>): Double {
|
||||||
|
if (text.isEmpty() || spans.isEmpty()) return 0.0
|
||||||
|
return spans.sumOf { it.end - it.start }.toDouble() / text.length
|
||||||
|
}
|
||||||
|
|
||||||
/** The best a single token does across the fields, or null if it is absent. */
|
/** The best a single token does across the fields, or null if it is absent. */
|
||||||
private fun bestScore(
|
private fun bestScore(
|
||||||
token: String,
|
token: String,
|
||||||
@@ -217,5 +233,10 @@ object EventSearchRanker {
|
|||||||
return DescriptionSnippet(text, rebased)
|
return DescriptionSnippet(text, rebased)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class Scored(val hit: SearchHit, val weakest: Int, val total: Int)
|
private data class Scored(
|
||||||
|
val hit: SearchHit,
|
||||||
|
val weakest: Int,
|
||||||
|
val total: Int,
|
||||||
|
val titleCoverage: Double,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,28 @@ class EventSearchRankerTest {
|
|||||||
.inOrder()
|
.inOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exact title beats one that only starts the same way, whenever it is`() {
|
||||||
|
// Reported: "test" at 19:00 had already passed and "test2" at 23:00 was
|
||||||
|
// still to come, so the date key put the inexact match first.
|
||||||
|
val exact = candidate(1L, "test", startMillis = now.toEpochMilliseconds() - 3 * 3_600_000L)
|
||||||
|
val longer = candidate(2L, "test2", startMillis = now.toEpochMilliseconds() + 3_600_000L)
|
||||||
|
|
||||||
|
assertThat(titlesFor(longer, exact, query = "test"))
|
||||||
|
.containsExactly("test", "test2")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shorter title wins when both merely contain the query`() {
|
||||||
|
val shorter = candidate(1L, "Team Meeting")
|
||||||
|
val longer = candidate(2L, "Team Meeting with the whole department")
|
||||||
|
|
||||||
|
assertThat(titlesFor(longer, shorter, query = "meeting"))
|
||||||
|
.containsExactly("Team Meeting", "Team Meeting with the whole department")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ties keep the nearest-first order, upcoming before past`() {
|
fun `ties keep the nearest-first order, upcoming before past`() {
|
||||||
val soon = candidate(1L, "Test", startMillis = now.toEpochMilliseconds() + 86_400_000L)
|
val soon = candidate(1L, "Test", startMillis = now.toEpochMilliseconds() + 86_400_000L)
|
||||||
|
|||||||
Reference in New Issue
Block a user