Skip to content

Commit dfb5f99

Browse files
authored
Add support for autocompletion suggestions (#9)
* feat: Add support for autocompletion suggestions * lint: Fix ktlint violations
1 parent 008e768 commit dfb5f99

File tree

3 files changed

+97
-6
lines changed

3 files changed

+97
-6
lines changed

core/src/main/kotlin/FtsIndex.kt

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.haroldadmin.lucilla.core
22

3+
import com.haroldadmin.lucilla.core.rank.ld
34
import com.haroldadmin.lucilla.core.rank.tfIdf
45
import com.haroldadmin.lucilla.ir.Posting
56
import com.haroldadmin.lucilla.ir.extractDocumentId
@@ -32,6 +33,11 @@ public data class SearchResult(
3233
val matchTerm: String,
3334
)
3435

36+
public data class AutocompleteSuggestion(
37+
val score: Double,
38+
val suggestion: String,
39+
)
40+
3541
/**
3642
* Alias for a token in a document
3743
*/
@@ -239,10 +245,48 @@ public class FtsIndex<DocType : Any>(
239245
}
240246
}
241247

242-
results.sortByDescending { result -> result.score }
248+
results.sortBy { result -> result.score }
243249
return results
244250
}
245251

252+
/**
253+
* Fetches autocompletion suggestions for the given query.
254+
*
255+
* An autocompletion suggestion is a term present in the index that
256+
* has the same prefix as the given search query. The results are sorted
257+
* in order of their relevance score.
258+
* e.g. "foo" -> "fool", "foot", "football"
259+
*
260+
* **Autocompletion suggestions can be unexpected if stemming is a part
261+
* of your text processing pipeline.**
262+
*
263+
* For example, the Porter stemmer stems "football" to "footbal". Therefore,
264+
* even if your input text contains the word "football", you will see "footbal"
265+
* as an autocompletion suggestion instead.
266+
*
267+
* The simplest way around this is to use a [Pipeline] that does not contain
268+
* a stemming step. Alternatively you can use a custom stemmer that emits
269+
* both the original word and its stemmed variant to ensure the original
270+
* word appears in the suggestions.
271+
*
272+
* *Expect the autocompletion ranking algorithm to change in future releases*
273+
*
274+
* @param query The search query to fetch autocompletion suggestions for
275+
* @return List of autocompletion suggestions, sorted by their scores
276+
*/
277+
public fun autocomplete(query: String): List<AutocompleteSuggestion> {
278+
val suggestions = _index.prefixMap(query).keys
279+
.fold(mutableListOf<AutocompleteSuggestion>()) { suggestions, prefixKey ->
280+
val score = ld(query, prefixKey).toDouble() / prefixKey.length
281+
val suggestion = AutocompleteSuggestion(score, prefixKey)
282+
suggestions.apply { add(suggestion) }
283+
}
284+
285+
suggestions.sortByDescending { it.score }
286+
287+
return suggestions
288+
}
289+
246290
/**
247291
* Clears all documents added to the FTS index
248292
*/

core/src/test/kotlin/App.kt

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,47 @@ fun main() {
1212
}
1313
println("index:complete ($timeToBuildIndex ms)")
1414

15-
do {
16-
println("Enter search query (EXIT to stop)")
15+
println("s: search, a: autocompletion suggestions")
16+
when (readln().trim().lowercase()) {
17+
"s" -> search(index, books)
18+
"a" -> autocomplete(index, books)
19+
else -> println("Invalid input")
20+
}
21+
}
22+
23+
fun search(index: FtsIndex<Book>, data: Map<Int, Book>) {
24+
println("Search")
25+
println("Enter search query (EXIT to stop)")
26+
while (true) {
1727
val query = readln()
28+
if (query == "EXIT") {
29+
break
30+
}
31+
1832
println("Searching for '$query'")
19-
var results: List<SearchResult>
33+
val results: List<SearchResult>
2034
val searchTime = measureTimeMillis { results = index.search(query) }
2135
println("${results.size} results, $searchTime ms")
2236

2337
results.forEachIndexed { i, result ->
24-
val book = books[result.documentId]!!
38+
val book = data[result.documentId]!!
2539
println("$i\t(${result.score}, ${result.matchTerm})\t${book.title}, ${book.author}")
2640
}
27-
} while (query != "EXIT")
41+
}
42+
}
43+
44+
fun autocomplete(index: FtsIndex<Book>, data: Map<Int, Book>) {
45+
println("Autocomplete Suggestions")
46+
println("Enter search query (EXIT TO STOP)")
47+
while (true) {
48+
val query = readln()
49+
if (query == "EXIT") {
50+
break
51+
}
52+
53+
println("Suggestions for '$query'")
54+
val suggestions: List<AutocompleteSuggestion>
55+
val searchTime = measureTimeMillis { suggestions = index.autocomplete(query) }
56+
println("($searchTime ms) ${suggestions.joinToString { it.suggestion }}")
57+
}
2858
}

core/src/test/kotlin/FtsIndexTest.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,21 @@ class FtsIndexTest : DescribeSpec({
125125
fts.search("test") shouldHaveSize 0
126126
}
127127
}
128+
129+
context("Autocomplete suggestions") {
130+
it("should return zero results if there are no matches") {
131+
val index = useFts<Book>()
132+
val results = index.autocomplete("foo")
133+
results shouldHaveSize 0
134+
}
135+
136+
it("should return matching results") {
137+
val index = useFts<Sentence>()
138+
index.add(Sentence(0, "football"))
139+
index.add(Sentence(1, "foil"))
140+
141+
val results = index.autocomplete("fo")
142+
results shouldHaveSize 2
143+
}
144+
}
128145
})

0 commit comments

Comments
 (0)