Embeddings & RAG
Turn text into vectors for semantic search, then retrieve the most relevant chunks of your own documents to ground the model's answers — all on-device.
Overview
Retrieval-augmented generation (RAG) lets a small on-device model answer questions over data it was never trained on — your notes, your docs, your app's content. You embed the text into vectors, store them, and at query time retrieve the chunks most similar to the question and feed them to sendMessage() or generateText(). Because everything runs locally, your data never leaves the device.
expo-ai-kit gives you two pieces:
embed()— turns text into embedding vectors on both platforms: Apple's zero-downloadNLContextualEmbeddingon iOS, and EmbeddingGemma 300M via MediaPipe TextEmbedder on Android (opt-in).- A pure-JS toolkit —
chunkText,cosineSimilarity, andcreateVectorStore— that does the chunking and retrieval on both platforms, with any source of vectors.
Platform Support
One embedding backend per platform — with very different asset models:
| iOS (17+) | Android (opt-in) | |
|---|---|---|
| Model | Apple NLContextualEmbedding (script models: Latin / Cyrillic / CJK) | EmbeddingGemma 300M via MediaPipe TextEmbedder |
| Dimensions | e.g. 512 (per script model) | 768 (returned as-is) |
| Max sequence | model-dependent | 512 tokens (longer input is truncated) |
| Languages | selected via language (see below) | natively multilingual, single vector space — language is accepted and ignored |
| Assets | OS-managed, zero app-size cost, fetched on demand | ~184 MB Google-hosted download, stored per-app (SHA-256 pinned) |
| App size | zero | ~+25 MB APK (arm64) when the flag is enabled |
The toolkit runs everywhere
chunkText, cosineSimilarity, and createVectorStore are pure JavaScript and work on every platform — pair them with any embedding source (the built-in embed(), a cloud embedder, or your own native module). They only ever deal in plain number[] vectors.
Android Setup (opt-in)
The Android backend is off by default — zero bytes added to your APK, and embed() throws a typed EMBEDDINGS_NOT_ENABLED error explaining the flag. Enable it with the config plugin:
{
"expo": {
"plugins": [["expo-ai-kit", { "androidEmbeddings": true }]]
}
}Requires a new native build
The flag adds the MediaPipe tasks-text Gradle dependency at prebuild — rebuild your dev client or EAS build to pick it up (an OTA update is not enough). Enabling adds ~25 MB to the APK (arm64); the ~184 MB model itself downloads at runtime via prepareEmbeddingModel() and is stored per-app — unlike the iOS assets, it is Google-hosted, not OS-managed. EmbeddingGemma ships under the Gemma Terms of Use — review them before shipping.
Embedding Text
Pass an array of strings; get one vector back per string, in order, plus the shared dimensions and the model identity that produced them.
import { embed } from 'expo-ai-kit';
const { embeddings, dimensions, model } = await embed([
'The Eiffel Tower is in Paris.',
'Mount Fuji is in Japan.',
]);
embeddings.length; // 2 — one vector per input
dimensions; // 512 on iOS (per script model), 768 on Android
model; // { id, revision } — see Model Identity belowNot subject to INFERENCE_BUSY
Embeddings don't use the text-generation KV-cache, so embed() is not gated by the single-flight inference guard — you can embed while a generation is in flight. (On Android, concurrent embed() calls queue behind a native mutex rather than failing.)
Task Types
options.task says what the vectors are for. EmbeddingGemma (Android) is task-conditioned — telling it the task measurably improves retrieval — while iOS accepts the value as semantic intent only (vectors are identical across tasks).
| Task | Use for |
|---|---|
'semantic-similarity' (default) | symmetric text-to-text comparison |
'retrieval-query' | the question side of a RAG lookup |
'retrieval-document' | the corpus side — use when indexing chunks |
Languages (iOS)
options.language (BCP-47, default 'en') selects which iOS script model to load — Latin, Cyrillic, or CJK — and is passed through to the tokenizer. An unsupported language throws a typed LANGUAGE_NOT_SUPPORTED error naming the language — never a silent fall-back to the Latin model — and there is deliberately no auto-detection (a mixed batch would silently mix vector spaces).
import { embed, getSupportedEmbeddingLanguages } from 'expo-ai-kit';
await embed(['привет мир'], { language: 'ru' }); // Cyrillic model
await embed(['你好世界'], { language: 'zh-Hans' }); // CJK model
await embed(['hello'], { language: 'tlh' }); // throws LANGUAGE_NOT_SUPPORTED
// Enumerate what the running device actually supports:
const languages = await getSupportedEmbeddingLanguages();Verified on a physical iOS 27 device, the catalog covers: bg, cs, da, de, en, es, fi, fr, hr, hu, id, it, ja, kk, ko, nb, nl, pl, pt, ro, ru, sk, sv, tr, uk, vi, zh-Hans, zh-Hant. Always prefer getSupportedEmbeddingLanguages() at runtime — the list is OS-managed and can differ per device/OS. On Android the option is accepted and ignored: EmbeddingGemma is natively multilingual with a single vector space, so there is nothing to select (and getSupportedEmbeddingLanguages() returns []).
Model Identity
The index-compatibility rule
Every result carries model: { id, revision } for the exact model that produced it. Vectors and persisted indexes are only comparable under identical model identity — never across platforms, and never across iOS script models (the identity changes with language). Store it next to any persisted index and rebuild when it changes.
On iOS the identity is the resolved Apple model identifier and OS asset revision. On Android it pins every artifact — the MediaPipe version, model SHA-256, dimensions, and prompt-protocol version (e.g. 1.0.0-913b7a1edc7c-d768-tfc1) — so it changes whenever any of them does.
Model Lifecycle
embed() never downloads anything — on Android it throws a typed MODEL_NOT_DOWNLOADED until you prepare the model explicitly:
import {
getEmbeddingModelStatus,
prepareEmbeddingModel,
cancelEmbeddingModelDownload,
deleteEmbeddingModel,
} from 'expo-ai-kit';
const { status, sizeBytes, model } = await getEmbeddingModelStatus();
// status: 'not-downloaded' | 'downloading' | 'downloaded'
// sizeBytes: 183_816_181 on Android (~184 MB); 0 on iOS (OS-managed)
if (status !== 'downloaded') {
await prepareEmbeddingModel({ onProgress: (p) => console.log(p) });
// Android: SHA-256-verified atomic install; partial/corrupt downloads fail closed.
}
// cancelEmbeddingModelDownload() aborts an in-flight Android download;
// deleteEmbeddingModel() reclaims the ~184 MB. Both are safe no-ops on iOS,
// where the OS owns the assets.On iOS the same calls drive the OS-managed per-language asset flow: prepareEmbeddingModel({ language: 'ru' }) prefetches the Cyrillic model ahead of the first embed (no progress granularity — the OS doesn't expose it).
Retrieval-Augmented Generation
The full loop: index your document once, then retrieve and answer at query time.
import { embed, chunkText, createVectorStore, sendMessage } from 'expo-ai-kit';
// --- Index once -------------------------------------------------------------
const chunks = chunkText(document); // overlapping, sentence-aware
const { embeddings } = await embed(chunks, { task: 'retrieval-document' });
const store = createVectorStore<{ text: string }>();
store.addMany(
chunks.map((text, i) => ({ id: `c${i}`, vector: embeddings[i], metadata: { text } })),
);
// --- Answer at query time ---------------------------------------------------
async function ask(question: string) {
const { embeddings: [q] } = await embed([question], { task: 'retrieval-query' });
const hits = store.search(q, { topK: 4 });
const context = hits.map((h) => h.metadata!.text).join('\n\n');
return sendMessage([
{ role: 'system', content: `Answer using ONLY this context:\n${context}` },
{ role: 'user', content: question },
]);
}Chunking
chunkText() splits a document into overlapping pieces sized for embedding. It breaks on sentence and paragraph boundaries where possible so chunks read coherently, and repeats a little context (overlap) across boundaries so a fact split between two chunks still appears whole in at least one.
import { chunkText } from 'expo-ai-kit';
const chunks = chunkText(longDocument, {
chunkSize: 1000, // target characters of new content per chunk (default 1000)
overlap: 200, // characters repeated into the next chunk (default min(200, chunkSize / 5))
});Returns [] for empty input and a single chunk when the text already fits in chunkSize. Tune chunkSize to your retrieval granularity — smaller chunks pinpoint facts; larger chunks keep more context together.
The Vector Store
createVectorStore() is a lightweight in-memory store. Add records (id + vector + optional metadata), then search() by a query vector for the top matches by cosineSimilarity.
import { createVectorStore } from 'expo-ai-kit';
const store = createVectorStore<{ text: string; source: string }>();
store.add('a', vectorA, { text: '…', source: 'faq.md' });
store.addMany(records);
store.search(queryVector, { topK: 5, minScore: 0.3 });
// → [{ id, vector, metadata, score }, …] sorted by score, highest first
store.get('a');
store.remove('a');
store.size;
store.clear();Scale
The store does a linear scan per search — plenty fast for the thousands-of-chunks scale typical of on-device RAG. Reach for a dedicated vector database only beyond that.
Persistence
The store owns no I/O — persistence is yours. toJSON() hands you a plain-array snapshot to write anywhere (AsyncStorage, a file, SQLite); pass it back to createVectorStore() to rehydrate. Re-embedding on every launch would be slow and wasteful, so index once and persist.
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createVectorStore } from 'expo-ai-kit';
// Save
await AsyncStorage.setItem('kb', JSON.stringify(store.toJSON()));
// Restore
const saved = await AsyncStorage.getItem('kb');
const store = createVectorStore(saved ? JSON.parse(saved) : undefined);Tips
- Embed chunks and the query with the same model — vectors from different models aren't comparable (and a dimension mismatch throws from
cosineSimilarity). - Store the source text as
metadataso you can pass it straight into the prompt after a search. - Use
topKto bound how much context you inject — on-device models have small context windows, so 3–5 focused chunks usually beat stuffing in more. - Set a
minScoreto drop weak matches rather than padding the prompt with irrelevant text.
Private by construction
Embedding, storage, retrieval, and generation all run on-device — no API keys, no servers, and your documents never leave the phone.