
This guide walks you through how to implement on-device semantic search, a search technique that finds relevant content based on query meaning, rather than purely by keywords. Semantic search removes the need to manually build and maintain custom synonym tables or tagging taxonomies. To implement semantic search on-device, use the following libraries:
- ML Kit SDK, specifically the Embeddings API, to generate vector embeddings
- AndroidX AppSearch to store the vector embeddings, and search structured data using cosine distance metrics to find content by their semantics
For a working example, see the ML Kit GenAI sample app on GitHub.
Prerequisites
To use on-device semantic search, you must meet the following device and account requirements:
- Your device supports Android AICore. Most AICore-supported devices use NPU-accelerated models, while the remaining supported devices fall back to a CPU model (with XNNPACK acceleration).
- The Google account on your device is part of the AICore Developer Preview program.
- Your device has the latest version of the AICore Developer Preview APK: On your device, open the Android AICore Play Store page and tap Update to install the latest version of the APK. Alternatively go to your device Settings, search for System services or Google services, tap Android AICore (Beta), and tap Update if an update is available.
- Your device has the latest version of the Private Compute Services app: On your device, open the Private Compute Services Play Store page and tap Update to install the latest version of the app. Alternatively go to Play Store > Manage apps & device > Manage > Private Compute Services and tap Update if an update is available.
Supported models and configuration
The ML Kit Embeddings API uses Google's EmbeddingGemma model family, managed by
Android AICore. Specifically, the Embeddings API uses the
EmbeddingGemma 2
(embedding_gemma_v2) for generating embeddings for text and image inputs.
Here are the input specifications and limits:
| Input property | EmbeddingGemma 2 | Usage notes |
|---|---|---|
| Supported modalities | Text and image (Modality.TEXT, Modality.IMAGE) |
To confirm that image input is supported, check at runtime that model.getMetadata()?.supportedModalities.contains(Modality.IMAGE) returns true. |
| Token limit | 256 tokens | Inspect using the model.getTokenLimit() method. Each image is encoded into 140 soft-tokens, leaving 116 tokens for accompanying text. |
| Image resolution | Up to 768 px on the shorter side | The constructor for the ImagePart class accepts Bitmap, Uri, or ByteArray argument types. Images with a shorter dimension greater than 768 px are automatically downscaled by the SDK while preserving aspect ratio. For optimal performance and memory usage, avoid extreme aspect ratios. |
The output vector dimension is 768 (a FloatArray object of 32-bit floats). You
can inspect the output vector dimension at runtime using the
model.getMetadata()?.dimension method.
Get started
To configure your project to use semantic search, add the following dependencies
to your module-level build.gradle.kts (or build.gradle) file.
Kotlin
dependencies {
// ...
implementation("com.google.mlkit:genai-embedding:1.0.0-alpha1")
implementation("androidx.appsearch:appsearch:1.2.0-alpha01")
implementation("androidx.appsearch:appsearch-local-storage:1.2.0-alpha01")
implementation("androidx.concurrent:concurrent-futures-ktx:1.3.0")
// ...
}
Groovy
dependencies {
// ...
implementation 'com.google.mlkit:genai-embedding:1.0.0-alpha1'
implementation 'androidx.appsearch:appsearch:1.2.0-alpha01'
implementation 'androidx.appsearch:appsearch-local-storage:1.2.0-alpha01'
implementation 'androidx.concurrent:concurrent-futures-ktx:1.3.0'
// ...
}
Get an embedding model
Use
EmbeddingGeneration.getEmbeddingModels()
to list the embedding models available on the device, and select one by its
model signature. Close any models you don't use.
// Prefer EmbeddingGemma 2 (text and image), then EmbeddingGemma 1 (text only).
val preferredSignatures = listOf("embedding_gemma_v2", "embedding_gemma_v1")
suspend fun selectEmbeddingModel(): EmbeddingModel? {
val models = EmbeddingGeneration.getEmbeddingModels()
val selected = preferredSignatures.firstNotNullOfOrNull { signature ->
models.firstOrNull { signature in it.getModelSignature() }
} ?: models.firstOrNull()
models.filter { it !== selected }.forEach { it.close() }
return selected
}
To get a specific model, pass its signature:
EmbeddingGeneration.getEmbeddingModels("embedding_gemma_v2").
Before generating embeddings, download the model if needed and warm it up:
suspend fun prepareModel(model: EmbeddingModel): Boolean {
when (model.checkStatus()) {
FeatureStatus.UNAVAILABLE -> return false
FeatureStatus.DOWNLOADABLE, FeatureStatus.DOWNLOADING -> model.download().collect()
}
model.warmup()
return true
}
Call model.close() when you no longer need the model.
Define an AppSearch schema
To enable semantic similarity searching, you must define an AppSearch schema where the vector property is explicitly configured for similarity indexing.
val documentSchema = AppSearchSchema.Builder("my_schema_type")
.addProperty(
AppSearchSchema.StringPropertyConfig.Builder("text")
.setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES)
.setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_PLAIN)
.build()
)
.addProperty(AppSearchSchema.StringPropertyConfig.Builder("imageUri").build())
.addProperty(
AppSearchSchema.EmbeddingPropertyConfig.Builder("embedding")
.setIndexingType(AppSearchSchema.EmbeddingPropertyConfig.INDEXING_TYPE_SIMILARITY)
.setQuantizationType(AppSearchSchema.EmbeddingPropertyConfig.QUANTIZATION_TYPE_8_BIT)
.build()
)
.build()
Index document content
To index document content, you must generate a vector embedding for each
Content object, wrap it in a GenericDocument instance, and save it to your
AppSearch database.
When constructing an EmbeddingVector object, you must also pass the active
modelSignature value. AppSearch uses this modelSignature string to prevent
incompatible search queries.
suspend fun syncDatabase(
model: EmbeddingModel,
session: AppSearchSession,
documents: List<Pair<String, Uri?>>,
) {
val modelSignature = model.getModelSignature()
val isImageSupported =
model.getMetadata()?.supportedModalities?.contains(Modality.IMAGE) == true
val docsWithEmbedding = mutableListOf<GenericDocument>()
for ((index, document) in documents.withIndex()) {
val (text, imageUri) = document
// Generate the embedding from the text and/or image.
val imagePart = if (imageUri != null && isImageSupported) ImagePart(imageUri) else null
val vector = try {
val parts = listOfNotNull(if (text.isNotBlank()) TextPart(text) else null, imagePart)
if (parts.isEmpty()) continue
model.generateEmbedding(EmbeddingRequest(Content(parts))).embedding.vector
} finally {
imagePart?.close()
}
// Associate the vector with the signature of the model that generated it.
val docBuilder = GenericDocument.Builder<GenericDocument.Builder<*>>(
"my_namespace",
"doc_$index",
"my_schema_type"
)
.setPropertyString("text", text)
.setPropertyEmbedding("embedding", EmbeddingVector(vector, modelSignature))
if (imageUri != null) {
docBuilder.setPropertyString("imageUri", imageUri.toString())
}
docsWithEmbedding.add(docBuilder.build())
}
// Clear the old database by force-overriding the schema, then set the schema again.
session.setSchemaAsync(SetSchemaRequest.Builder().setForceOverride(true).build()).await()
session.setSchemaAsync(SetSchemaRequest.Builder().addSchemas(documentSchema).build()).await()
val putRequest = PutDocumentsRequest.Builder().addGenericDocuments(docsWithEmbedding).build()
session.putAsync(putRequest).await()
}
Execute semantic search
To perform a semantic query, convert the search query (text and/or image) into an embedding vector, configure the vector-search parameters, and query your AppSearch database.
Recommendations:
- AppSearch uses the cosine metric
(
SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_COSINE) by default, so you don't need to set it. - Rank results by the
"sum(this.matchedSemanticScores(getEmbeddingParameter(0)))"formula. - Use the logical
"semanticSearch(getEmbeddingParameter(0), $threshold)"string to filter results where the similarity meets your threshold value (typically 0.5 or higher).
data class Match(val text: String, val imageUri: Uri?, val score: Double)
suspend fun search(
model: EmbeddingModel,
session: AppSearchSession,
queryText: String,
imageUri: Uri? = null,
threshold: Double = 0.5,
): List<Match> {
val modelSignature = model.getModelSignature()
val isImageSupported =
model.getMetadata()?.supportedModalities?.contains(Modality.IMAGE) == true
// Generate the query embedding from the text and/or image.
val imagePart = if (imageUri != null && isImageSupported) ImagePart(imageUri) else null
val queryVector = try {
val parts = listOfNotNull(if (queryText.isNotBlank()) TextPart(queryText) else null, imagePart)
if (parts.isEmpty()) return emptyList()
model.generateEmbedding(EmbeddingRequest(Content(parts))).embedding.vector
} finally {
imagePart?.close()
}
val searchSpec = SearchSpec.Builder()
.addFilterSchemas("my_schema_type")
.setRankingStrategy("sum(this.matchedSemanticScores(getEmbeddingParameter(0)))")
.setListFilterQueryLanguageEnabled(true)
.setResultCountPerPage(5)
// Add the query vector as parameter 0.
.addEmbeddingParameters(EmbeddingVector(queryVector, modelSignature))
.build()
val queryExpression = "semanticSearch(getEmbeddingParameter(0), $threshold)"
val results = session.search(queryExpression, searchSpec)
return try {
results.nextPageAsync.await().map {
Match(
text = it.genericDocument.getPropertyString("text") ?: "",
imageUri = it.genericDocument.getPropertyString("imageUri")?.let(Uri::parse),
// rankingSignal contains the computed similarity score.
score = it.rankingSignal,
)
}
} finally {
results.close()
}
}
Use a hybrid search index
For the best user experience, we recommend using a hybrid strategy involving both semantic and keyword search. When the user is actively typing in a search bar, use keyword search to show instant type-ahead suggestions; when they enter the completed query, or even at word breaks, use semantic search to read, understand, and fetch the best conceptual matches.
Reindex your vector database
Embeddings generated by different model versions reside in incompatible vector spaces and can't be compared directly. You must reindex your AppSearch database whenever you update the embedding model.
We recommend to start reindexing the embeddings as soon as the model you're
using enters its deprecation period. You can use the Embeddings API to check a
model's deprecation schedule. Call the model.getMetadata() method to retrieve
the EmbeddingMetadata object and check these lifecycle date properties
(returned as LocalDate objects in UTC):
- The
deprecationDatevalue marks the beginning of the model deprecation period. You should start reindexing while the model continues to work and finish reindexing before its official shutdown. - The
shutdownDatevalue is when the model will be decommissioned and no longer served. After this date, the model is inaccessible for inference and existing embeddings of this model are forced to reindex with the latest available model.
We also communicate upcoming model deprecations, migration timelines, and target shutdown dates through the ML Kit release notes and developer documentation updates.
Best practices
For a good user experience, follow these practices when integrating vector embeddings into your Android app:
- Schedule indexing workloads: Always schedule background indexing workloads using the WorkManager library. Configure your background tasks to run only when the device is charging and idle. ML Kit imposes quota limits for model usage, similar to LLM APIs, with more liberal limits when the device is charging. In Developer Preview mode, you can bypass quota limitations for testing purposes.
- Storage and query optimization: Optimize your database footprint and
query latency by using quantization and dimensionality truncation:
- Configure your
AppSearchSchemaschema to use theQUANTIZATION_TYPE_8_BITconstant, as shown at Define an AppSearch schema. This reduces memory usage with minimal impact on quality by shrinking the precision of each coordinate to an 8-bit integer. - Use Matryoshka Representation Learning (MRL), supported by the EmbeddingGemma 2 model, to truncate the embedding vector. To learn more, see EmbeddingGemma 2 on HuggingFace.
- Configure your
- Signature consistency: Don't query a vector space using a mismatched signature. If you upgrade the embedding model version, you must wipe the AppSearch database and regenerate embeddings for all documents using the new model signature.
- Safety and content moderation: You are responsible for implementing
content moderation and safety filtering in your app. Unlike generative text
or image APIs, the ML Kit Embeddings API doesn't include built-in safety
filters. Because the model outputs numerical vector representations
(
FloatArraycoordinates) of caller-provided input rather than generating new text or media, numerical vectors don't inherently present generative content safety risks. Omitting on-device safety filtering also preserves low-latency indexing and search performance by avoiding CPU contention during high-throughput document indexing and real-time queries. To improve safety, follow these guidelines:- Moderate untrusted inputs and indexed content: If your app indexes or searches untrusted user-generated content or external data, apply content moderation and policy filters to the source text or images before indexing them or displaying search results.
- Safeguard downstream generative AI pipelines: If retrieved documents or passages are passed as context to a downstream generative AI model, verify that safety filters and content policies are enabled on that downstream model and its user-facing output.