Android中的全文本搜索

在移动应用程序中,搜索功能非常流行。 而且,如果它在小型产品中可以忽略不计,那么在提供访问大量信息的应用程序中,您将无法进行搜索。 今天,我将告诉您如何在Android程序中正确实现此功能。



在移动应用程序中实现搜索的方法


  1. 搜索作为数据过滤器

    通常,它看起来像某个列表上方的搜索栏。 也就是说,我们只过滤完成的数据。
  2. 服务器搜索

    在这种情况下,我们将整个实现交给服务器,而应用程序则充当瘦客户端,只需要将其以正确的形式显示数据即可。
  3. 综合搜索

    • 该应用程序包含大量各种类型的数据;
    • 该应用程序可以离线工作;
    • 需要将搜索作为对应用程序的部分/内容的单个访问点。

在后一种情况下,SQLite内置的全文本搜索可以解决。 使用它,您可以非常快速地找到大量信息中的匹配项,这使我们能够在不牺牲性能的情况下对不同的表进行多次查询。

考虑使用特定示例实现这种搜索。

资料准备


假设我们需要实现一个显示themoviedb.org中的电影列表的应用程序。 为了简化(以免上线),请拍摄电影列表并从中形成JSON文件,将其放入资产中并在本地填充我们的数据库。

JSON文件结构示例:

[ { "id": 278, "title": "  ", "overview": "  ..." }, { "id": 238, "title": " ", "overview": " , ..." }, { "id": 424, "title": " ", "overview": "   ..." } ] 

数据库填充


SQLite使用虚拟表来实现全文搜索。 从表面上看,它们看起来像常规的SQLite表,但是对它们的任何访问都会做一些后台工作。

虚拟表使我们可以加快搜索速度。 但是,除了优点之外,它们还具有缺点:

  • 您不能在虚拟表上创建触发器;
  • 您不能对虚拟表执行ALTER TABLE和ADD COLUMN命令;
  • 虚拟表中的每个列都已建立索引,这意味着资源可能浪费在索引列上,而这些列不应该包含在搜索中。

要解决后一个问题,您可以使用包含部分信息的其他表,并在虚拟表中存储指向常规表的元素的链接。

创建表与标准表略有不同,我们使用关键字VIRTUALfts4

  CREATE VIRTUAL TABLE movies USING fts4(id, title, overview); 

评论fts5版本
它已经被添加到SQLite中。 此版本效率更高,更准确,并且包含许多新功能。 但是由于Android的碎片化,我们不能在所有设备上都使用fts5(API24提供)。 您可以为不同版本的操作系统编写不同的逻辑,但是这将使进一步的开发和支持严重复杂化。 我们决定采用更简单的方法,并使用大多数设备支持的fts4。

填充与平常没有什么不同:

 fun populate(context: Context) { val movies: MutableList<Movie> = mutableListOf() context.assets.open("movies.json").use { val typeToken = object : TypeToken<List<Movie>>() {}.type movies.addAll(Gson().fromJson(InputStreamReader(it), typeToken)) } try { writableDatabase.beginTransaction() movies.forEach { movie -> val values = ContentValues().apply { put("id", movie.id) put("title", movie.title) put("overview", movie.overview) } writableDatabase.insert("movies", null, values) } writableDatabase.setTransactionSuccessful() } finally { writableDatabase.endTransaction() } } 

基本版本


执行查询时,使用MATCH关键字代替LIKE

 fun firstSearch(searchString: String): List<Movie> { val query = "SELECT * FROM movies WHERE movies MATCH '$searchString'" val cursor = readableDatabase.rawQuery(query, null) val result = mutableListOf<Movie>() cursor?.use { if (!cursor.moveToFirst()) return result while (!cursor.isAfterLast) { val id = cursor.getInt("id") val title = cursor.getString("title") val overview = cursor.getString("overview") result.add(Movie(id, title, overview)) cursor.moveToNext() } } return result } 

为了在界面中实现文本输入处理,我们将使用RxJava

 RxTextView.afterTextChangeEvents(findViewById(R.id.editText)) .debounce(500, TimeUnit.MILLISECONDS) .map { it.editable().toString() } .filter { it.isNotEmpty() && it.length > 2 } .map(dbHelper::firstSearch) .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(movieAdapter::updateMovies) 

结果是一个基本的搜索选项。 在第一个元素中,在说明中找到了所需的单词,在标题和描述中找到了第二个元素。 显然,以这种形式还不清楚我们发现了什么。 让我们修复它。


添加重音


为了提高搜索的明显性,我们将使用辅助功能SNIPPET 。 它用于显示在其中找到匹配项的格式化文本片段。

 snippet(movies, '<b>', '</b>', '...', 1, 15) 

  • 电影-表格名称;
  • <b&gt和</ b>-这些参数用于突出显示已搜索的文本部分;
  • ...-对于文本的设计,如果结果是不完整的值;
  • 1-从中分配文本的表格的列号;
  • 15是包含在返回的文本值中的大约单词数。

该代码与第一个相同,不包括请求:

 SELECT id, snippet(movies, '<b>', '</b>', '...', 1, 15) title, snippet(movies, '<b>', '</b>', '...', 2, 15) overview FROM movies WHERE movies MATCH '' 

我们再试一次:


结果比以前的版本更清楚。 但这还不是终点。 让我们的搜索更加“完整”。 我们将使用词法分析并突出显示搜索查询的重要部分。

完成改善


SQLite具有内置标记,可让您执行词法分析并转换原始搜索查询。 如果在创建表时我们未指定特定的标记生成器,则将选择“简单”。 实际上,它只是将我们的数据转换为小写并丢弃不可读的字符。 它不太适合我们。

为了在搜索上进行质量改进,我们需要使用词干 -查找给定源词的词库的过程。

SQLite具有使用Porter Stemmer算法的附加内置标记器。 该算法顺序应用许多特定规则,通过截断结尾和后缀来突出显示单词的重要部分。 例如,当搜索“键”时,我们可以获得包含单词“ key”,“ keys”和“ key”的搜索。 我将在最后留下指向该算法的详细描述的链接。

不幸的是,SQLite内置的令牌生成器仅适用于英语,因此对于俄语,您需要编写自己的实现或使用现成的开发。 我们将从algorithmist.ru网站获取完成的实现。

我们将搜索查询转换为必要的形式:

  1. 删除多余的字符。
  2. 将短语分解成单词。
  3. 跳过词干分析器。
  4. 收集搜索查询。

波特算法

  object Porter { private val PERFECTIVEGROUND = Pattern.compile("((|||||)|((<=[])(||)))$") private val REFLEXIVE = Pattern.compile("([])$") private val ADJECTIVE = Pattern.compile("(|||||||||||||||||||||||||)$") private val PARTICIPLE = Pattern.compile("((||)|((?<=[])(||||)))$") private val VERB = Pattern.compile("((||||||||||||||||||||||||||||)|((?<=[])(||||||||||||||||)))$") private val NOUN = Pattern.compile("(|||||||||||||||||||||||||||||||||||)$") private val RVRE = Pattern.compile("^(.*?[])(.*)$") private val DERIVATIONAL = Pattern.compile(".*[^]+[].*?$") private val DER = Pattern.compile("?$") private val SUPERLATIVE = Pattern.compile("(|)$") private val I = Pattern.compile("$") private val P = Pattern.compile("$") private val NN = Pattern.compile("$") fun stem(words: String): String { var word = words word = word.toLowerCase() word = word.replace('', '') val m = RVRE.matcher(word) if (m.matches()) { val pre = m.group(1) var rv = m.group(2) var temp = PERFECTIVEGROUND.matcher(rv).replaceFirst("") if (temp == rv) { rv = REFLEXIVE.matcher(rv).replaceFirst("") temp = ADJECTIVE.matcher(rv).replaceFirst("") if (temp != rv) { rv = temp rv = PARTICIPLE.matcher(rv).replaceFirst("") } else { temp = VERB.matcher(rv).replaceFirst("") if (temp == rv) { rv = NOUN.matcher(rv).replaceFirst("") } else { rv = temp } } } else { rv = temp } rv = I.matcher(rv).replaceFirst("") if (DERIVATIONAL.matcher(rv).matches()) { rv = DER.matcher(rv).replaceFirst("") } temp = P.matcher(rv).replaceFirst("") if (temp == rv) { rv = SUPERLATIVE.matcher(rv).replaceFirst("") rv = NN.matcher(rv).replaceFirst("") } else { rv = temp } word = pre + rv } return word } } 

将短语分解为单词的算法

 val words = searchString .replace("\"(\\[\"]|.*)?\"".toRegex(), " ") .split("[^\\p{Alpha}]+".toRegex()) .filter { it.isNotBlank() } .map(Porter::stem) .filter { it.length > 2 } .joinToString(separator = " OR ", transform = { "$it*" }) 

转换后,短语“庭院和幽灵”看起来像“庭院*或幽灵* ”。

符号“ * ”表示将通过出现给定单词来进行搜索。 “ ”运算符表示将显示结果,其中包含至少一个来自搜索短语的单词。 我们看:



总结


全文搜索并不像乍看起来那样复杂。 我们分析了一个特定的示例,您可以在项目中快速轻松地实现该示例。 如果您需要更复杂的内容,则应查阅文档,因为其中有一个文档,而且编写得很好。

参考文献:


Source: https://habr.com/ru/post/zh-CN466787/


All Articles