On-Device Embeddings for Local Search

AIMobileSearchOn-Device
Share on LinkedIn

Users expected instant search across 12,000 saved recipe notes. Sending every keystroke to our API for embedding added 300ms RTT and leaked query text we promised stayed on-device. Moving to on-device embeddings cut perceived latency to under 50ms and let airplane-mode search work — same recall@10 on our eval set after quantizing MiniLM to INT8. On-device embeddings for local search is a systems problem: model size, index structure, and sync strategy matter as much as model accuracy.

Architecture overview

User query ──► [Embedding model on device] ──► query vector
                                                    │
Corpus chunks ──► [Same model at index time] ──► vector index (SQLite/USearch)
                                                    │
                                                    ▼
                                            Top-k cosine similarity
                                                    │
                                                    ▼
                                            Rank + optional BM25 hybrid

Everything runs in the app process. No network unless syncing new documents from backend.

Choosing an embedding model

Model Dims Size (quantized) Mobile latency (A14, 128 tokens)
all-MiniLM-L6-v2 384 ~22 MB INT8 ~30ms
multilingual-e5-small 384 ~30 MB ~40ms
bge-small-en-v1.5 384 ~24 MB ~35ms
gte-small 384 ~24 MB ~35ms

Convert to mobile runtimes:

# Export pipeline (server-side prep)
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
model.save("minilm_export")
# Then convert to TFLite / Core ML with optimum or onnxruntime

Validate quantized model against FP32 on your domain — recipe titles lost 2% recall@10; legal clause search lost 8% (needed bigger model).

Indexing pipeline

Chunk documents for embedding:

data class SearchChunk(
    val id: String,
    val docId: String,
    val text: String,
    val vector: FloatArray,  // 384 dims
    val updatedAt: Long
)

fun chunkNote(note: Note): List<SearchChunk> {
    return note.body.splitIntoParagraphs(maxTokens = 128).mapIndexed { i, para ->
        SearchChunk(
            id = "${note.id}:$i",
            docId = note.id,
            text = para,
            vector = embedder.encode(para),
            updatedAt = note.updatedAt
        )
    }
}

Index on write — user saves note → embed chunks → upsert vectors. Background job re-embeds on model upgrade.

Delete handling — remove all chunks for docId before re-indexing edited docs.

Vector storage options.

sqlite-vec (extension):

CREATE VIRTUAL TABLE chunks USING vec0(
  id TEXT PRIMARY KEY,
  embedding FLOAT[384]
);

INSERT INTO chunks(id, embedding) VALUES (?, ?);

SELECT id, distance
FROM chunks
WHERE embedding MATCH ?
ORDER BY distance
LIMIT 20;

Good for <500k vectors on phone storage.

USearch — HNSW index, faster at scale:

// Swift USearch example pattern
var index = USearchIndex.make(
    metric: .cosine,
    dimensions: 384,
    connectivity: 16
)
index.add(key: chunkId, vector: vector)
let results = index.search(query: queryVector, count: 10)

Persist index to disk; rebuild on corruption via background full re-index.

Query path.

Future<List<SearchResult>> search(String query) async {
  final queryVec = await embedder.encode(query);
  final hits = await vectorIndex.search(queryVec, k: 20);

  // Optional hybrid: combine with SQLite FTS5 keyword score
  final ftsHits = await db.rawQuery('''
    SELECT doc_id, bm25(notes_fts) AS score
    FROM notes_fts WHERE notes_fts MATCH ?
    ORDER BY score LIMIT 20
  ''', [query]);

  return reciprocalRankFusion(hits, ftsHits);
}

Hybrid retrieval rescues exact-match queries ("ISO-27001") where pure semantic search drifts. Reciprocal Rank Fusion (RRF) merges ranked lists without score normalization.

Performance tuning.

Battery test: continuous search while scrolling killed 8% battery/hour with unbatched embed on main thread — fixed with queue + debounce 150ms.

Sync and multi-device.

On-device index is local truth for search UX. Sync strategies:

  1. Embed on each device — sync raw documents via CRDT/CloudKit/Firestore; each device builds own index (consistent after sync, no vector transfer)
  2. Sync precomputed vectors — faster first open, but model version must match exactly across fleet
  3. Server embed + download index snapshot — bulk import for initial corpus; incremental local embed for edits

We embed locally after doc sync — avoids 150MB vector blob download on cellular.

Privacy and compliance.

When not to go on-device.

Ship an index rebuild path: model upgrades change vector geometry — plan full re-embed on app update with progress UI for large libraries. Benchmark search on minimum supported hardware with a full index, not empty state. Privacy reviews should note embeddings are not encryption; sensitive corpora need SQLCipher or file-level encryption for the vector store. A/B test hybrid vs pure semantic on real queries — legal and SKU-style searches often need keyword recall. Monitor index size in settings so users understand storage impact. If sync brings documents from server, re-embed locally rather than trusting server vectors unless model version bytes match exactly.

Common production mistakes

Teams get on device embeddings mobile search wrong in predictable ways:

Production implementations of on device embeddings mobile search fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Resources

Frequently asked questions

Can mobile devices run embedding models locally?

Yes. Small models like all-MiniLM-L6-v2 (~80MB FP32, ~22MB quantized) produce 384-dim vectors on mid-range phones in 20–80ms per short text chunk. Larger models trade accuracy for latency and battery. Match model size to query frequency and hardware floor.

How do you store vectors on device for search?

SQLite with sqlite-vec or USearch for brute-force cosine on <100k vectors. For larger corpora, use HNSW indexes (USearch, hnswlib mobile builds) or partition by category to keep search sub-100ms. Store raw text alongside vectors for result display.

When is on-device semantic search better than cloud?

Private notes, medical records, enterprise docs under air-gap policy, offline travel apps, and latency-sensitive typeahead where 200ms network RTT dominates. Cloud wins when corpus exceeds device storage or requires frequent global updates.

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 →