DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

Wiring Android's Room Database to a Local LLM for Semantic Query Rewriting

---
title: "Android Room + On-Device Embeddings: The Hybrid Search Pipeline That Hits Sub-100ms"
published: true
description: "Wire Android Room to on-device embeddings with FTS5 for a hybrid retrieval pipeline hitting sub-100ms on mid-range hardware. Stop letting keyword search fail your users."
tags: kotlin, android, architecture, mobile
canonical_url: https://blog.mvpfactory.co/android-room-fts5-on-device-embeddings-hybrid-search
---
Enter fullscreen mode Exit fullscreen mode

What We Are Building

By the end of this tutorial you will have a working hybrid retrieval pipeline for Android: FTS5 for high-recall candidate selection, a quantized on-device embedding model for intent rewriting, and a cosine similarity re-ranker that keeps you in the 50ms tier instead of the 300ms tier. This runs on mid-range hardware, fits a 30MB memory budget, and delivers semantic accuracy that pure SQL cannot touch.

Let me show you a pattern I use in every project where local search matters.


Prerequisites

  • Android project with Room 2.5+
  • ONNX Runtime for Android dependency
  • A quantized INT8 embedding model (MiniLM-style, ~23MB after quantization)
  • Kotlin coroutines

Step 1 — The Problem With Pure SQL Search

Most teams treat Room like a key-value store with a WHERE clause. That is the mistake.

A user typing "meeting notes from last Tuesday about the budget" returns zero results when your stored record says "Q3 planning session - finance review." Same semantic content, zero lexical overlap. FTS5 improves recall with BM25 ranking, but it still operates on surface tokens. The moment you introduce synonyms or natural-language phrasing, you are back to zero.

The fix is semantic query rewriting: intercept the raw query, project it into an embedding space, and use that vector to rerank FTS5 candidates.


Step 2 — Define Your FTS5 Virtual Table

FTS5 is your high-recall, low-latency first pass. Note the annotation — @Fts4 without ftsVersion defaults to FTS4, not FTS5.

@Entity(tableName = "notes")
data class NoteEntity(
    @PrimaryKey val id: Long,
    val title: String,
    val body: String,
    val createdAt: Long
)

// ftsVersion = FTS_VERSION_5 required — @Fts4 defaults to FTS4 without it
@Fts4(contentEntity = NoteEntity::class, ftsVersion = FtsOptions.FTS_VERSION_5)
@Entity(tableName = "notes_fts")
data class NoteFts(
    val title: String,
    val body: String
)
Enter fullscreen mode Exit fullscreen mode

FTS5 with BM25 on a 100K-row corpus on a mid-range Snapdragon 6-series device completes in the 15–40ms window. That is your floor.


Step 3 — Wire the On-Device Embedding Model

Quantize your model to INT8 before you even think about shipping. A typical 90MB MiniLM-style model drops to ~23MB and inference falls from ~180ms to ~55ms on CPU.

class EmbeddingEngine(context: Context) {
    private val session: OrtSession = OrtEnvironment
        .getEnvironment()
        .createSession(loadModel(context, "minilm_int8.onnx"))

    suspend fun embed(text: String): FloatArray = withContext(Dispatchers.Default) {
        val tokens = tokenizer.encode(text, maxLength = 128)
        val inputTensor = OnnxTensor.createTensor(env, tokens)
        session.run(mapOf("input_ids" to inputTensor))
            .get("last_hidden_state")
            .meanPooling()
            .l2Normalize()
    }
}
Enter fullscreen mode Exit fullscreen mode

Store embeddings as BLOB columns in Room, quantized to INT8 at write time. At 128 dimensions, each vector costs 128 bytes — negligible at scale.


Step 4 — The Hybrid Ranker

Run FTS5 for the top-50 candidates, then re-rank with cosine similarity against the query embedding. The cosine pass only touches the candidate set, not the full corpus.

suspend fun hybridSearch(query: String): List<NoteEntity> {
    val queryVec = embeddingEngine.embedWithCache(query)
    val ftsResults = noteDao.ftsSearch(query, limit = 50)

    return ftsResults
        .map { note ->
            val storedVec = note.embedding.dequantize()
            val score = cosineSimilarity(queryVec, storedVec)
            note to score
        }
        .sortedByDescending { it.second }
        .take(10)
        .map { it.first }
}
Enter fullscreen mode Exit fullscreen mode

Here is the minimal LRU cache that eliminates inference on repeated queries — five lines that save you 55ms per hit:

private val queryCache = object : LinkedHashMap<String, FloatArray>(16, 0.75f, true) {
    override fun removeEldestEntry(eldest: Map.Entry<String, FloatArray>) = size > 100
}

suspend fun embedWithCache(query: String): FloatArray {
    val key = query.trim().lowercase()
    return queryCache.getOrPut(key) { embed(key) }
}
Enter fullscreen mode Exit fullscreen mode

A 100-entry INT8 cache adds ~8MB overhead and collapses cache-hit latency to single-digit milliseconds.


Latency Reality Check

Strategy P50 Latency (Snapdragon 6xx) Memory Recall
Pure FTS5 BM25 20–40ms ~2MB Low–Medium
Pure Vector Scan (full corpus) 280–400ms 50–200MB High
Hybrid (FTS5 + re-rank top-50) 55–95ms ~25MB High
Hybrid + result cache 5–15ms (hit) +8MB LRU High

Gotchas

Skip the FTS5 recall phase and the query planner destroys you. A full in-memory cosine scan over 100K vectors runs 280ms before you add model inference. Keep FTS5 as the gating layer.

Never run embedding inference on the main thread. Always dispatch to Dispatchers.Default. Debounce your query input with a 200ms delay — this alone cuts unnecessary embedding calls by ~60% on incremental search UIs.

INT8 quality drop is marginal. Typically less than 2% on MTEB benchmarks. The 4x memory savings and 60–70% inference speedup are not optional — they are the difference between shipping and not shipping on mid-range devices.


Conclusion

The hybrid FTS5 + on-device embedding pipeline runs on mid-range Android hardware today, fits within a 30MB memory budget, and delivers retrieval quality that pure SQL cannot match.

Three things to internalize before you ship: use FTS5 as your recall layer (top-50), not your final ranking signal. Quantize to INT8 before shipping. Cache query embeddings aggressively — the docs do not stress this enough, but it is the single highest-leverage optimization in the pipeline.

Top comments (0)