全文搜索(Full-Text Search)的一个优秀落地案例是 DivineDataGPT——一款离线优先的 AI 信仰伴侣(圣经应用),用户可以通过关键词、短语、书名或文本内容搜索经文。
如果你刚接触数据库,大概率会顺手用 LIKE 来实现。但对于圣经经文这样的庞大数据集,LIKE 的效率会急转直下,因为它本质上是在暴力扫描每一行。
好在,Room 深度集成了 SQLite 的全文搜索能力,只需通过 @Fts4 注解就能轻松启用。相比于传统的 SQL LIKE 查询(逐行线性扫描),FTS 利用先进的索引技术,不仅响应快如闪电,还自带排序能力。
主经文实体
首先,我们建立一个普通的 Room 表 verses,用于存储圣经经文的实际数据。
@Entity(
tableName = "verses",
indices = [
Index(value = ["translation", "book", "chapter", "verse"], unique = true)
]
)
data class VerseEntity(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val translation: String,
val bookName: String,
val book: Int,
val chapter: Int,
val verse: Int,
val text: String
)
这张表是应用展示真实数据的最终来源。
唯一索引保证同一译本下的同一节经文不会被重复插入。
比如:
KJV - 约翰福音 3:16
NIV - 约翰福音 3:16
ESV - 约翰福音 3:16
每个译本都可以有相同的书卷、章节和节数,但每条记录的组合必须独一无二。
FTS 实体
接下来,我们来创建 FTS 表。
@Fts4(contentEntity = VerseEntity::class)
@Entity(tableName = "verses_fts")
data class VersesFTS(
val bookName: String,
val text: String
)
这张表并不作为主数据源使用,而是充当特定文本字段的可搜索索引。
在这里,我们期望能按 bookName(书名)和 text(文本内容)这两个字段进行检索。
也就是说,用户既能用关键词搜经文内容,也能直接搜书名。
为什么使用 contentEntity?
关键在于这行注解:
@Fts4(contentEntity = VerseEntity::class)
它告诉 Room,VersesFTS 与 VerseEntity 是关联的。
verses 表负责存放原始数据,而 verses_fts 表只负责维护可搜索的文本索引。
这样我们就获得了“鱼与熊掌”兼得的方案:
verses = 真实数据
verses_fts = 极速搜索索引
FTS 搜索查询
现在,可以动笔写一个高效的搜索查询了。
@Query(
"""
SELECT verses.*
FROM verses
JOIN verses_fts ON verses.id = verses_fts.rowid
WHERE verses.translation = :translation
AND verses_fts MATCH :query
ORDER BY verses.book ASC, verses.chapter ASC, verses.verse ASC
LIMIT :limit
"""
)
fun searchVerses(
translation: String,
query: String,
limit: Int = 80
): Flow<List<VerseEntity>>
这个查询做了三件关键的事。
首先,它将真实的经文表与 FTS 表连接起来:
JOIN verses_fts ON verses.id = verses_fts.rowid
FTS 表内部使用 rowid,它会映射回原始经文行的主键。
其次,它按译本过滤:
WHERE verses.translation = :translation
当应用需要同时支持 KJV、NIV 或 ESV 等多种译本时,这一过滤条件就显得格外重要。
最后,它执行了真正的全文搜索:
AND verses_fts MATCH :query
这正是与 LIKE 的核心区别所在。
SQLite 不再逐行苦哈哈地扫描经文,而是直接在优化过的 FTS 索引里定位目标。
为什么这比 LIKE 更好?
如果用 LIKE 来实现,代码大概长这样:
@Query("""
SELECT *
FROM verses
WHERE translation = :translation
AND text LIKE '%' || :query || '%'
ORDER BY book ASC, chapter ASC, verse ASC
LIMIT :limit
""")
fun searchVersesWithLike(
translation: String,
query: String,
limit: Int = 80
): Flow<List<VerseEntity>>
乍看之下也能跑,可一旦数据量膨胀,它就会慢到让你怀疑人生。
罪魁祸首就在这一行:
LIKE '%' || :query || '%'
因为搜索模式以 % 打头,SQLite 根本无法利用普通索引来高效处理文本搜索。
结果就是,它可能要扫描海量行才能凑出你想要的几条结果。
FTS 对比 LIKE
| 特性 |
LIKE |
FTS |
| 适合小数据集 |
是 |
是 |
| 适合大型文本搜索 |
否 |
是 |
| 使用全文索引 |
否 |
是 |
| 支持短语搜索 |
有限 |
是 |
| 支持前缀搜索 |
有限 |
是 |
| 更适合离线搜索 |
不理想 |
是 |
| 可扩展性好 |
否 |
是 |
搜索输入示例
有了 FTS,用户可以自由地这样搜索:
searchVerses(
translation = "KJV",
query = "faith"
)
还能直接搜短语:
searchVerses(
translation = "KJV",
query = "\"love one another\""
)
或者使用前缀搜索:
searchVerses(
translation = "KJV",
query = "faith*"
)
这可以匹配出诸如 faith、faithful、faithfulness 等一系列单词。
为什么这里用 Flow 很合适
DAO 返回的是 Flow<List<VerseEntity>>。
这非常有用,因为一旦底层数据发生变化,Room 就能自动触发更新。
在 Jetpack Compose 的应用里,ViewModel 能把搜索结果暴露为状态,UI 层则会响应式地刷新界面。
示例流程:
搜索文本框
↓
ViewModel
↓
Repository
↓
Room FTS 查询
↓
Flow<List<VerseEntity>>
↓
Compose LazyColumn
重要细节:使用防抖
在构建实时搜索功能时,千万别让用户的每一次按键都直接触发数据库查询。
你应该在 ViewModel 里加上防抖,就像这样:
searchQuery
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { query ->
repository.searchVerses(
translation = selectedTranslation,
query = query
)
}
这段代码能有效防止用户在快速输入时触发无谓的数据库压力。
最后的思考
对于数据量不大的小表,LIKE 或许还撑得住。
但当你面对圣经经文、电子书、笔记或聊天消息这类大规模的离线数据集时,FTS 才是正确的打开方式。
LIKE 是在粗暴地扫描文本,而 FTS 是在优雅地搜索索引——正是这一本质差异,让全文搜索在 Room 中变得更快、更整洁、更具可扩展性。
原文链接:Effective Search in Room: FTS vs LIKE