fix(search): one ordering pass instead of double ranking
The search pipeline ranked and deduplicated matches twice: inside the searcher per dictionary and again after merging across dictionaries. The extra pass ran before the required ordering and duplicated the dedup work. The searcher now returns article-deduplicated matches and the paging source applies a single ordering pass: display length ascending, then natural numeric-aware order. Paging configuration knobs that a full-page source cannot use are dropped.
This commit is contained in:
1 parent
e20af9c1ff
commit
df505a71b1
5 files changed
+36
-50
No files matched your search
@@ -118,7 +118,12 @@ private fun compareNatural(a: String, b: String): Int {
|
|||||||
j = endB
|
j = endB
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
if (ca != cb) return ca.compareTo(cb)
|
if (ca != cb) {
|
||||||
|
val numeralA = !asciiA && isNumeralChar(ca)
|
||||||
|
val numeralB = !asciiB && isNumeralChar(cb)
|
||||||
|
if (numeralA != numeralB) return if (numeralA) -1 else 1
|
||||||
|
return ca.compareTo(cb)
|
||||||
|
}
|
||||||
i++
|
i++
|
||||||
j++
|
j++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import androidx.paging.PagingState
|
|||||||
import com.example.research.core.domain.model.Dictionary
|
import com.example.research.core.domain.model.Dictionary
|
||||||
import com.example.research.core.domain.model.IndexEntry
|
import com.example.research.core.domain.model.IndexEntry
|
||||||
import com.example.research.core.performance.ReSearchTrace
|
import com.example.research.core.performance.ReSearchTrace
|
||||||
import com.example.research.core.util.sanitizeQuery
|
|
||||||
import com.example.research.data.parser.IndexSearcher
|
import com.example.research.data.parser.IndexSearcher
|
||||||
import com.example.research.data.parser.SearchRankingContext
|
import com.example.research.data.parser.sortByCharCountThenNatural
|
||||||
import com.example.research.data.parser.rankBySearchRelevance
|
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@@ -68,9 +67,8 @@ class IndexEntryPagingSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
|
|
||||||
val ranked = rawResults
|
val ranked = rawResults
|
||||||
.rankBySearchRelevance(SearchRankingContext(normalizedQuery))
|
.sortByCharCountThenNatural()
|
||||||
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
|
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
|
||||||
|
|
||||||
LoadResult.Page(
|
LoadResult.Page(
|
||||||
|
|||||||
@@ -122,9 +122,7 @@ class IndexSearcher(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(prefixMatches + substringMatches)
|
(prefixMatches + substringMatches).distinctBy { it.articleKey() }
|
||||||
.rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
|
|
||||||
.distinctBy { it.articleKey() }
|
|
||||||
}
|
}
|
||||||
resultsCache.put(cacheKey, results)
|
resultsCache.put(cacheKey, results)
|
||||||
results
|
results
|
||||||
|
|||||||
@@ -2,50 +2,37 @@ package com.example.research.data.parser
|
|||||||
|
|
||||||
import com.example.research.core.domain.model.IndexEntry
|
import com.example.research.core.domain.model.IndexEntry
|
||||||
import com.example.research.core.util.NaturalStringComparator
|
import com.example.research.core.util.NaturalStringComparator
|
||||||
import com.example.research.core.util.foldLatinDiacritics
|
|
||||||
|
|
||||||
internal data class SearchRankingContext(
|
internal fun List<IndexEntry>.sortByCharCountThenNatural(): List<IndexEntry> {
|
||||||
val normalizedQuery: String,
|
|
||||||
val foldedQuery: String = normalizedQuery.foldLatinDiacritics(),
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class SortKey(
|
|
||||||
val matchTier: Int,
|
|
||||||
val exactMatch: Int,
|
|
||||||
val charCount: Int,
|
|
||||||
val word: String,
|
|
||||||
val entry: IndexEntry,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun List<IndexEntry>.rankBySearchRelevance(
|
|
||||||
ranking: SearchRankingContext
|
|
||||||
): List<IndexEntry> {
|
|
||||||
if (size < 2) return this
|
if (size < 2) return this
|
||||||
|
|
||||||
return map { entry ->
|
val charCounts = IntArray(size)
|
||||||
val word = entry.word
|
var minCount = Int.MAX_VALUE
|
||||||
|
var maxCount = Int.MIN_VALUE
|
||||||
|
for (i in indices) {
|
||||||
|
val entry = this[i]
|
||||||
val displayKey = entry.originalWord
|
val displayKey = entry.originalWord
|
||||||
.trim()
|
.trim()
|
||||||
.ifEmpty { entry.word.trim() }
|
.ifEmpty { entry.word.trim() }
|
||||||
|
val count = displayKey.codePointCount(0, displayKey.length)
|
||||||
|
charCounts[i] = count
|
||||||
|
if (count < minCount) minCount = count
|
||||||
|
if (count > maxCount) maxCount = count
|
||||||
|
}
|
||||||
|
|
||||||
SortKey(
|
val buckets = arrayOfNulls<ArrayList<IndexEntry>>(maxCount - minCount + 1)
|
||||||
matchTier = when {
|
for (i in indices) {
|
||||||
!entry.isAlias && word.startsWith(ranking.normalizedQuery) -> 0
|
val bucketIndex = charCounts[i] - minCount
|
||||||
entry.isAlias && word.startsWith(ranking.foldedQuery) -> 1
|
(buckets[bucketIndex] ?: ArrayList<IndexEntry>().also { buckets[bucketIndex] = it })
|
||||||
!entry.isAlias && word.foldLatinDiacritics().startsWith(ranking.foldedQuery) -> 1
|
.add(this[i])
|
||||||
else -> 2
|
|
||||||
},
|
|
||||||
exactMatch = if (word == ranking.normalizedQuery) 0 else 1,
|
|
||||||
charCount = displayKey.codePointCount(0, displayKey.length),
|
|
||||||
word = word,
|
|
||||||
entry = entry,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sortedWith(
|
|
||||||
compareBy<SortKey> { it.matchTier }
|
val naturalOrder = compareBy<IndexEntry, String>(NaturalStringComparator) { it.word }
|
||||||
.thenBy { it.exactMatch }
|
val result = ArrayList<IndexEntry>(size)
|
||||||
.thenBy { it.charCount }
|
for (bucket in buckets) {
|
||||||
.thenBy(NaturalStringComparator) { it.word }
|
if (bucket == null) continue
|
||||||
)
|
if (bucket.size > 1) bucket.sortWith(naturalOrder)
|
||||||
.map { it.entry }
|
result.addAll(bucket)
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
@@ -112,9 +112,7 @@ class SearchViewModel(
|
|||||||
Pager(
|
Pager(
|
||||||
config = PagingConfig(
|
config = PagingConfig(
|
||||||
pageSize = 12,
|
pageSize = 12,
|
||||||
prefetchDistance = 1,
|
enablePlaceholders = false
|
||||||
enablePlaceholders = false,
|
|
||||||
initialLoadSize = 12
|
|
||||||
),
|
),
|
||||||
pagingSourceFactory = {
|
pagingSourceFactory = {
|
||||||
IndexEntryPagingSource(
|
IndexEntryPagingSource(
|
||||||
|
|||||||
Reference in new issue
Block a user