Full-Text Search in Room with FTS4

AndroidRoomSQLiteSearch
Share on LinkedIn

Users expect instant search in note apps, messaging clients, and offline catalogs. Naive LIKE '%query%' scans every row and ignores word boundaries — slow on 50k messages and useless for prefix matching. SQLite's FTS (Full-Text Search) module tokenizes text into an inverted index so MATCH queries return in milliseconds. Room has first-class FTS support through @Fts4 entities, which removes most of the raw SQL boilerplate.

FTS vs LIKE

Feature LIKE FTS4
Index Full table scan Inverted index
Prefix search Awkward Native (term*)
Word boundaries No Yes
Ranking Manual bm25 / matchinfo
Storage None extra Index overhead ~30–50%

For any table where users type free-text queries, FTS pays for itself above a few thousand rows.

Basic FTS entity

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val title: String,
    val body: String,
    val updatedAt: Long
)

@Fts4(contentEntity = Note::class)
@Entity(tableName = "notes_fts")
data class NoteFts(
    val title: String,
    val body: String
)

Room creates an external content FTS4 table linked to notes. You must keep them synchronized — Room does not auto-sync on content updates unless you use the recommended pattern below.

DAO queries

@Dao
interface NoteDao {
    @Insert
    suspend fun insert(note: Note): Long

    @Query("""
        SELECT notes.* FROM notes
        JOIN notes_fts ON notes.rowid = notes_fts.rowid
        WHERE notes_fts MATCH :query
        ORDER BY notes.updatedAt DESC
        LIMIT :limit
    """)
    suspend fun search(query: String, limit: Int = 50): List<Note>
}

Sanitize user input before passing to MATCH. Strip ", *, and - unless you intentionally support advanced FTS syntax:

fun sanitizeFtsQuery(raw: String): String {
    return raw.trim()
        .split("\\s+".toRegex())
        .filter { it.isNotBlank() }
        .joinToString(" ") { token ->
            "${token.replace("\"", "")}*"
        }
}

The trailing * enables prefix matching so "kot" finds "kotlin".

Syncing content and FTS

When using contentEntity, Room expects you to manage sync. The simplest approach: use @Upsert on the content table and let Room's FTS triggers handle it if you declare the relationship correctly, or explicitly rebuild:

@Query("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')")
suspend fun rebuildFtsIndex()

Call rebuildFtsIndex() after bulk imports. For incremental updates, Room 2.5+ with external content sets up triggers automatically when you use @Fts4(contentEntity = ...).

Ranking with bm25

On devices with SQLite 3.20+ (API 24+ effectively everywhere you ship):

@Query("""
    SELECT notes.* FROM notes
    JOIN notes_fts ON notes.rowid = notes_fts.rowid
    WHERE notes_fts MATCH :query
    ORDER BY bm25(notes_fts) ASC
    LIMIT :limit
""")
suspend fun searchRanked(query: String, limit: Int = 50): List<Note>

Lower bm25 scores mean better relevance. Combine with recency by sorting in Kotlin or adding a weighted SQL expression if title matches should outweigh body matches.

Migration from non-FTS schema

Adding FTS to an existing table requires a migration:

val MIGRATION_3_4 = object : Migration(3, 4) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("""
            CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts
            USING fts4(title, body, content='notes')
        """)
        db.execSQL("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')")
    }
}

Test migrations with Room migration testing — FTS rebuilds on large tables can take seconds and block the main thread if run synchronously at startup.

UI integration

Debounce search input (300ms is my default), run queries on Dispatchers.IO, and expose results via Flow:

fun searchNotes(query: String): Flow<List<Note>> =
    query.debounce(300)
        .map { sanitizeFtsQuery(it) }
        .filter { it.isNotBlank() }
        .distinctUntilChanged()
        .flatMapLatest { q ->
            flow { emit(noteDao.searchRanked(q)) }
        }
        .flowOn(Dispatchers.IO)

Highlight matching terms in the UI with AnnotatedString — FTS doesn't return match offsets in Room, so highlight the raw query tokens client-side.

FTS5 vs FTS4 on Android

Room supports both via @Fts4 annotation. FTS5 improvements:

@Fts4(contentEntity = Note::class, tokenizer = FtsOptions.TOKENIZER_UNICODE61)
@Entity(tableName = "notes_fts")
data class NoteFts(
    @ColumnInfo(name = "title") val title: String,
    @ColumnInfo(name = "body") val body: String,
)

UNICODE61 tokenizer handles accented characters — SIMPLE breaks international search.

Performance at scale

FTS tables grow with content. Mitigations:

Rebuild FTS after bulk delete: INSERT INTO notes_fts(notes_fts) VALUES('rebuild') — schedule during maintenance window for 100K+ rows.

Multilingual search

FTS tokenizers are language-specific. Options:

Pair with Room migrations testing before shipping FTS schema changes to production users.

Common production mistakes

Teams get room full text search fts wrong in predictable ways:

Shipping room full text search fts on Android fails quietly when you test only on flagship devices, skip process-death scenarios, or assume minSdk behavior matches latest API docs. Emulator-only validation misses OEM-specific battery optimizations and background execution limits.

Resources

Frequently asked questions

Should I use FTS3, FTS4, or FTS5 with Room?

Room's @Fts4 annotation maps to SQLite FTS4, which ships on all Android versions Room supports. FTS5 offers better ranking and auxiliary functions but requires SQLite 3.9+ and isn't exposed through Room's FTS annotations. FTS4 is the practical choice for Android apps using Room today.

What is external content FTS?

An external content FTS table indexes columns from a regular content table without duplicating all data inline. You maintain the content table as usual and keep the FTS index in sync via triggers or explicit insert/update calls. This avoids storing text twice while still getting tokenized search.

How do I rank search results by relevance?

SQLite FTS4 supports bm25() in newer SQLite builds bundled with Android. Where available, ORDER BY bm25(table_name) ASC ranks better matches first. Fallback: use MATCH with prefix queries and sort by recency or a custom weight column in your content table.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →