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:
OneWay committed 2026-10-15 13:06:05 +08:00
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(