From f22028d126252ce3cb9dbb7da25fb38d907a863e Mon Sep 17 00:00:00 2001 From: OneWay Date: Thu, 15 Oct 2026 13:06:05 +0800 Subject: [PATCH] Search results: 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. --- .../example/research/core/util/NaturalSort.kt | 7 +- .../data/paging/IndexEntryPagingSource.kt | 8 +-- .../research/data/parser/IndexSearcher.kt | 4 +- .../research/data/parser/SearchRanking.kt | 65 ++++++++----------- .../feature/search/SearchViewModel.kt | 4 +- 5 files changed, 37 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/com/example/research/core/util/NaturalSort.kt b/app/src/main/java/com/example/research/core/util/NaturalSort.kt index 581ed6c..680e226 100644 --- a/app/src/main/java/com/example/research/core/util/NaturalSort.kt +++ b/app/src/main/java/com/example/research/core/util/NaturalSort.kt @@ -118,7 +118,12 @@ private fun compareNatural(a: String, b: String): Int { j = endB } 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++ j++ } diff --git a/app/src/main/java/com/example/research/data/paging/IndexEntryPagingSource.kt b/app/src/main/java/com/example/research/data/paging/IndexEntryPagingSource.kt index ec29031..6e6efe6 100644 --- a/app/src/main/java/com/example/research/data/paging/IndexEntryPagingSource.kt +++ b/app/src/main/java/com/example/research/data/paging/IndexEntryPagingSource.kt @@ -5,10 +5,9 @@ import androidx.paging.PagingState import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.IndexEntry 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.SearchRankingContext -import com.example.research.data.parser.rankBySearchRelevance +import com.example.research.data.parser.sortByCharCountThenNatural + import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -68,9 +67,8 @@ class IndexEntryPagingSource( } } - val normalizedQuery = query.sanitizeQuery().lowercase().trim() val ranked = rawResults - .rankBySearchRelevance(SearchRankingContext(normalizedQuery)) + .sortByCharCountThenNatural() .distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) } LoadResult.Page( diff --git a/app/src/main/java/com/example/research/data/parser/IndexSearcher.kt b/app/src/main/java/com/example/research/data/parser/IndexSearcher.kt index 6b5efd9..0cf6817 100644 --- a/app/src/main/java/com/example/research/data/parser/IndexSearcher.kt +++ b/app/src/main/java/com/example/research/data/parser/IndexSearcher.kt @@ -122,9 +122,7 @@ class IndexSearcher(private val context: Context) { } } } - (prefixMatches + substringMatches) - .rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery)) - .distinctBy { it.articleKey() } + (prefixMatches + substringMatches).distinctBy { it.articleKey() } } resultsCache.put(cacheKey, results) results diff --git a/app/src/main/java/com/example/research/data/parser/SearchRanking.kt b/app/src/main/java/com/example/research/data/parser/SearchRanking.kt index e3887ef..70ae872 100644 --- a/app/src/main/java/com/example/research/data/parser/SearchRanking.kt +++ b/app/src/main/java/com/example/research/data/parser/SearchRanking.kt @@ -2,50 +2,37 @@ package com.example.research.data.parser import com.example.research.core.domain.model.IndexEntry import com.example.research.core.util.NaturalStringComparator -import com.example.research.core.util.foldLatinDiacritics -internal data class SearchRankingContext( - 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.rankBySearchRelevance( - ranking: SearchRankingContext -): List { +internal fun List.sortByCharCountThenNatural(): List { if (size < 2) return this - return map { entry -> - val word = entry.word + val charCounts = IntArray(size) + var minCount = Int.MAX_VALUE + var maxCount = Int.MIN_VALUE + for (i in indices) { + val entry = this[i] val displayKey = entry.originalWord .trim() .ifEmpty { entry.word.trim() } - - SortKey( - matchTier = when { - !entry.isAlias && word.startsWith(ranking.normalizedQuery) -> 0 - entry.isAlias && word.startsWith(ranking.foldedQuery) -> 1 - !entry.isAlias && word.foldLatinDiacritics().startsWith(ranking.foldedQuery) -> 1 - else -> 2 - }, - exactMatch = if (word == ranking.normalizedQuery) 0 else 1, - charCount = displayKey.codePointCount(0, displayKey.length), - word = word, - entry = entry, - ) + val count = displayKey.codePointCount(0, displayKey.length) + charCounts[i] = count + if (count < minCount) minCount = count + if (count > maxCount) maxCount = count } - .sortedWith( - compareBy { it.matchTier } - .thenBy { it.exactMatch } - .thenBy { it.charCount } - .thenBy(NaturalStringComparator) { it.word } - ) - .map { it.entry } + + val buckets = arrayOfNulls>(maxCount - minCount + 1) + for (i in indices) { + val bucketIndex = charCounts[i] - minCount + (buckets[bucketIndex] ?: ArrayList().also { buckets[bucketIndex] = it }) + .add(this[i]) + } + + val naturalOrder = compareBy(NaturalStringComparator) { it.word } + val result = ArrayList(size) + for (bucket in buckets) { + if (bucket == null) continue + if (bucket.size > 1) bucket.sortWith(naturalOrder) + result.addAll(bucket) + } + return result } diff --git a/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt b/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt index 0621eca..a02cc58 100644 --- a/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt +++ b/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt @@ -112,9 +112,7 @@ class SearchViewModel( Pager( config = PagingConfig( pageSize = 12, - prefetchDistance = 1, - enablePlaceholders = false, - initialLoadSize = 12 + enablePlaceholders = false ), pagingSourceFactory = { IndexEntryPagingSource(