Two Flavors of RAG on Android
| Cloud-backed RAG | On-device RAG | |
|---|---|---|
| Where embedding/retrieval/generation run | Your server (Topics 6, 7, 11, 12) | Entirely on the phone |
| Works offline | No | Yes |
| Model quality | Full-size frontier models | Smaller on-device models — noticeably weaker |
| Device support | Any device with network access | Limited to devices with NPU/AICore support (e.g. Gemini Nano) |
| Data leaves the device | Yes — a privacy and compliance consideration | No |
| Backend to build and run | Yes (Topics 11-13) | None |
Picking & Extracting Documents
The Storage Access Framework hands you a content:// URI, not a file path — Android's sandboxing means you read through ContentResolver, not File. Extract text off the main thread; a multi-page PDF is real parsing work.
val pickDocument = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
uri?.let { viewModel.onDocumentPicked(it) }
}
Button(onClick = {
pickDocument.launch(arrayOf("application/pdf", "text/plain"))
}) {
Text("Upload a document")
}
// build.gradle.kts
dependencies {
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
}
suspend fun extractText(context: Context, uri: Uri): String = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { stream ->
PDDocument.load(stream).use { document ->
PDFTextStripper().getText(document)
}
} ?: throw IOException("Could not open document")
}
withContext(Dispatchers.IO) is not optional here, the same discipline as any other slow I/O in an Android app.content:// URI's display name can lie about the actual format. Check the resolver's reported MIME type, and handle a parse failure gracefully rather than crashing.Chunking & Uploading
Send the extracted text as-is and let the backend chunk it (Topic 6 Section 5) — chunking strategy is something you'll tune server-side, and duplicating that logic in Kotlin just means keeping two implementations in sync for no benefit.
interface RagApi {
@Multipart
@POST("documents")
suspend fun uploadDocument(
@Part("title") title: RequestBody,
@Part file: MultipartBody.Part,
): DocumentResponse
@POST("ask")
suspend fun ask(@Body request: AskRequest): AskResponse
}
data class DocumentResponse(val documentId: String, val chunkCount: Int)
data class AskRequest(val documentId: String, val question: String)
data class AskResponse(val answer: String, val citations: List<Citation>)
data class Citation(val index: Int, val text: String, val page: Int?)
suspend fun uploadDocument(context: Context, uri: Uri, title: String): DocumentResponse {
val text = extractText(context, uri)
val requestBody = text.toRequestBody("text/plain".toMediaType())
val filePart = MultipartBody.Part.createFormData("file", "$title.txt", requestBody)
return ragApi.uploadDocument(title.toRequestBody("text/plain".toMediaType()), filePart)
}
Backend Ingestion
Exactly Topic 11's FastAPI patterns and Topic 12's pgvector store, with one addition: track which chunks belong to which document_id so retrieval (Section 4) can be scoped to the document — or documents — the user is actually asking about.
from fastapi import FastAPI, UploadFile, Form
import uuid
app = FastAPI()
@app.post("/documents")
async def upload_document(title: str = Form(...), file: UploadFile = None,
index: PgVectorIndex = Depends(get_index)):
text = (await file.read()).decode("utf-8")
document_id = str(uuid.uuid4())
chunks = chunk_text(text, chunk_size=400) # Topic 6 Section 5
for chunk in chunks:
embedding = get_embedding(chunk)
index.conn.execute(
"""INSERT INTO document_chunks (document_id, title, content, embedding)
VALUES (%s, %s, %s, %s)""",
(document_id, title, chunk, embedding),
)
index.conn.commit()
return {"documentId": document_id, "chunkCount": len(chunks)}
Asking a Question
Topic 7's answer_question, scoped to one document via the metadata filter from Topic 12 Section 5, returning citations the client can render — same request/response contract from Section 2's Retrofit interface, now implemented.
val response = ragApi.ask(
AskRequest(
documentId = documentId,
question = "What's the refund window?",
)
)
println(response.answer)
println(response.citations)
@app.post("/ask", response_model=AskResponse)
async def ask(request: AskRequest,
index: PgVectorIndex = Depends(get_index)):
results = index.search(
request.question, top_k=4,
filter={"document_id": request.document_id},
)
chunks = [c for c, score in results if score >= 0.3]
answer, citations = generate_grounded_answer(
request.question, chunks) # Topic 7 Section 3-4
return AskResponse(answer=answer, citations=citations)
Streaming Into a Chat UI
Topic 11 Section 3's SSE endpoint, consumed on Android with OkHttp's EventSource — tokens arrive incrementally and get appended to a mutable chat message, the same "emit as it arrives" idea as a Kotlin Flow from Topic 1 Section 7.
// build.gradle.kts
dependencies {
implementation("com.squareup.okhttp3:okhttp-sse:4.12.0")
}
fun streamAnswer(question: String, documentId: String, onToken: (String) -> Unit) {
val request = Request.Builder()
.url("$baseUrl/ask/stream")
.post(Json.encodeToString(AskRequest(documentId, question))
.toRequestBody("application/json".toMediaType()))
.build()
EventSources.createFactory(okHttpClient).newEventSource(
request,
object : EventSourceListener() {
override fun onEvent(source: EventSource, id: String?, type: String?, data: String) {
onToken(data) // append this chunk to the current chat bubble
}
override fun onFailure(source: EventSource, t: Throwable?, response: Response?) {
Log.e("RagChat", "stream failed", t)
}
},
)
}
// ViewModel: accumulate streamed tokens into the last message's state
fun ask(question: String) {
val placeholder = ChatMessage(role = "assistant", content = "")
_messages.update { it + placeholder }
streamAnswer(question, documentId) { token ->
_messages.update { messages ->
val last = messages.last().copy(content = messages.last().content + token)
messages.dropLast(1) + last
}
}
}
EventSourceListener callbacks arrive on an OkHttp dispatcher thread, not the main thread, the same rule as any other background callback in Android.Citations Back to the Source
Topic 7 Section 4's numbered citations become tappable in a mobile UI — the payoff of returning structured Citation objects (Section 2) instead of citation markers baked into plain text.
@Composable
fun AnswerWithCitations(answer: String, citations: List<Citation>, onCitationClick: (Citation) -> Unit) {
Column {
Text(answer)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
citations.forEach { citation ->
AssistChip(
onClick = { onCitationClick(citation) },
label = { Text("[${citation.index}]" + citation.page?.let { " p.$it" }.orEmpty()) },
)
}
}
}
}
Citation.text) in a bottom sheet — letting the user verify the answer against the source directly, the same "grounding should be checkable" principle from Topic 7 Section 4, made concrete in the UI.On-Device RAG
Every server-side piece from Sections 3-4 gets replaced with a local equivalent. Worth doing when offline capability or on-device privacy is a hard requirement — not as a default.
| Cloud piece | On-device replacement |
|---|---|
| OpenAI/Gemini embeddings (Topic 6 Section 1) | An on-device embedding model via ML Kit or a small ONNX/LiteRT model bundled with the app |
| pgvector (Topic 12) | A local vector store — ObjectBox has native on-device vector search, or brute-force cosine similarity (Topic 6 Section 3) for small personal document sets |
| OpenAI/Gemini/Claude chat completion (Topic 2) | Gemini Nano via Android's AICore, exposed through ML Kit GenAI APIs |
| FastAPI service (Topic 11) | Nothing — the app calls its local model and store directly |
// Illustrative — ML Kit GenAI's on-device API shape val generativeModel = GenerativeModel.getClient(context) val prompt = buildGroundedPrompt(question, retrievedChunks) // same shape as Topic 7 Section 3 val response = generativeModel.generateContent(prompt) onToken(response.text)
Capstone: A Document Q&A Chat Screen
Combine Sections 1, 2, 5, and 6 into one Compose screen: pick a document, upload it, ask questions, and see streamed, cited answers.
@Composable
fun DocumentChatScreen(viewModel: DocumentChatViewModel) {
val state by viewModel.uiState.collectAsState()
val pickDocument = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { viewModel.onDocumentPicked(it) } }
Column(Modifier.fillMaxSize().padding(16.dp)) {
if (state.documentId == null) {
Button(onClick = { pickDocument.launch(arrayOf("application/pdf", "text/plain")) }) {
Text("Upload a document")
}
} else {
LazyColumn(Modifier.weight(1f)) {
items(state.messages) { message ->
if (message.role == "assistant") {
AnswerWithCitations(
answer = message.content,
citations = message.citations,
onCitationClick = viewModel::onCitationClick,
)
} else {
Text(message.content, fontWeight = FontWeight.Bold)
}
}
}
var input by remember { mutableStateOf("") }
Row {
TextField(value = input, onValueChange = { input = it }, modifier = Modifier.weight(1f))
Button(onClick = { viewModel.ask(input); input = "" }) { Text("Ask") }
}
}
if (state.error != null) {
Text(state.error, color = MaterialTheme.colorScheme.error)
}
}
}
class DocumentChatViewModel(private val ragApi: RagApi, private val context: Context) : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
fun onDocumentPicked(uri: Uri) = viewModelScope.launch {
try {
val text = extractText(context, uri)
val response = ragApi.uploadDocument(
"document".toRequestBody("text/plain".toMediaType()),
MultipartBody.Part.createFormData(
"file", "document.txt",
text.toRequestBody("text/plain".toMediaType()),
),
)
_uiState.update { it.copy(documentId = response.documentId) }
} catch (e: IOException) {
_uiState.update { it.copy(error = "Could not read that document") }
}
}
fun ask(question: String) {
val documentId = _uiState.value.documentId ?: return
_uiState.update { it.copy(messages = it.messages + ChatMessage("user", question)) }
val placeholder = ChatMessage("assistant", "")
_uiState.update { it.copy(messages = it.messages + placeholder) }
streamAnswer(question, documentId) { token ->
_uiState.update { state ->
val last = state.messages.last().copy(content = state.messages.last().content + token)
state.copy(messages = state.messages.dropLast(1) + last)
}
}
}
fun onCitationClick(citation: Citation) { /* show citation.text in a bottom sheet */ }
}
data class ChatUiState(
val documentId: String? = null,
val messages: List<ChatMessage> = emptyList(),
val error: String? = null,
)
data class ChatMessage(val role: String, val content: String, val citations: List<Citation> = emptyList())
curl before wiring up the Android clientchunkCount in the response is greater than zero