Topic 16 of 16 · Bonus · Android

RAG on Android.

"Upload a document, ask questions about it" is Retrieval-Augmented Generation with a mobile front end — the retrieval, embedding, and generation from Topics 6, 7, 11, and 12 don't change. What's new here is entirely on the Kotlin side: picking a file, extracting its text, uploading it, and rendering a streamed, cited answer in a chat UI.

The mental model

Same RAG pipeline. The client just got a file picker.

Nothing about retrieval or grounding changes because the caller is a phone instead of a script. The only genuinely new problems are mobile-specific: reading a PDF/DOCX the user picked from their device, keeping a large upload off the main thread, and rendering a stream of tokens into a chat bubble instead of a terminal. Everything else is Topic 7's pipeline behind a FastAPI wall, exactly as Topic 11 built it.

Section 0

Two Flavors of RAG on Android

Cloud-backed RAGOn-device RAG
Where embedding/retrieval/generation runYour server (Topics 6, 7, 11, 12)Entirely on the phone
Works offlineNoYes
Model qualityFull-size frontier modelsSmaller on-device models — noticeably weaker
Device supportAny device with network accessLimited to devices with NPU/AICore support (e.g. Gemini Nano)
Data leaves the deviceYes — a privacy and compliance considerationNo
Backend to build and runYes (Topics 11-13)None
Default to cloud-backed unless offline capability or on-device privacy is an explicit product requirement — it reuses everything already built in this series and isn't constrained to specific hardware. Section 7 covers the on-device path for when those requirements apply.
Section 1

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")
}
Parsing on the main threadA large PDF can take seconds to parse — withContext(Dispatchers.IO) is not optional here, the same discipline as any other slow I/O in an Android app.
Trusting the file extensionA 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.
Section 2

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)
}
Section 3

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)}
Section 4

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.

Android request
val response = ragApi.ask(
    AskRequest(
        documentId = documentId,
        question = "What's the refund window?",
    )
)
println(response.answer)
println(response.citations)
FastAPI handler
@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)
Section 5

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
        }
    }
}
Route the callback back onto the main thread before touching Compose state — EventSourceListener callbacks arrive on an OkHttp dispatcher thread, not the main thread, the same rule as any other background callback in Android.
Section 6

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()) },
                )
            }
        }
    }
}
Tapping a citation chip is a good moment to show the actual retrieved chunk text (Section 4's 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.
Section 7

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 pieceOn-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)
The prompt construction, grounding instructions, and injection-defense delimiting from Topics 3 and 7 don't change at all moving on-device — only where the embedding, storage, and generation calls physically execute. This is the same architecture, with different implementations behind the same seams.
Section 8 · Checkpoint

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())
Expected behavior: pick a PDF, see it upload, ask a question about its content, and watch the answer stream in with tappable citation chips — backed entirely by the Topic 11/12 service running locally or deployed via Topic 13.
  • Ran the FastAPI ingestion and ask endpoints from Sections 3-4 locally, confirmed with curl before wiring up the Android client
  • Uploaded a real PDF from the Android app and confirmed chunkCount in the response is greater than zero
  • Asked a question with a clear answer in the document and confirmed the response streams token-by-token, not all at once
  • Asked a question not covered by the document and confirmed it says so rather than inventing an answer — Topic 7 Section 2's retrieval floor, now visible in a real UI
  • Can explain out loud what would need to change to scope questions across multiple uploaded documents instead of one — which section's filter logic handles that
  • Series complete

    All 16 topics done.

    From Python syntax to a deployed RAG backend to a working Android client that talks to it. Revisit any topic from the series hub whenever you need the reference back.