{"slug": "building-a-document-rag-agent-on-gcp-s-agent-development-kit-adk", "title": "Building a Document-RAG Agent on GCP's Agent Development Kit (ADK)", "summary": "LogiChat rebuilt its document-RAG pipeline using Google's Agent Development Kit (ADK), replacing a manual Q&A curation system with a five-stage architecture that runs on Cloud Run and Firestore. The design choice of performing retrieval as a pre-model step rather than an ADK tool proved critical. The agent service is a 53-line Python 3.12 FastAPI app with five runtime dependencies, all running on GCP without API keys.", "body_md": "[LogiChat](https://logichat.io) is a chatbot platform. Customers upload their docs, get a chat widget, never touch a model. For two years, \"training the bot\" meant hand-curating a Q&A list in a dashboard form — `question`\n\nand `answer`\n\npairs, one row at a time, dumped into the prompt as few-shot pairs. I just deleted that and rebuilt the whole pipeline on ** Google's Agent Development Kit (ADK)**, with\n\nIt's not a \"hello world\" agent demo — it's the architecture I landed on after rebuilding the whole pipeline over the last few months, three Cloud Run services touched (the fourth, the Stripe top-up token subscriber, was untouched), and the one design choice that turned out to matter more than anything else: **retrieval as a pre-model step, not an ADK tool.**\n\nThe pipeline below touches three of those services — `apps/api`\n\n, `apps/subscribers/doc-processor`\n\n, and `apps/agent`\n\n— plus the Firestore vector index. `apps/api`\n\nappears twice in the diagram because it acts as both the upload ingress AND the gateway that mints ID tokens for the agent. Click through the five stages to see which service is active at each hop and what payload crosses each boundary:\n\nInteractive diagram:step through the five-stage pipeline —Upload → Process → Retrieve → Ground → Answer— on the[original post on dalenguyen.me]. Each hop shows which service is active (dashboard →`apps/api`\n\n→ Cloud Storage →`doc-processor`\n\n→ Firestore →`apps/agent`\n\n→ Vertex AI) and what payload crosses the boundary.\n\nTwo pieces are different from a textbook RAG stack:\n\n`apps/api`\n\n, Express) is a separate service that mints Cloud Run ID tokens to call the agent. The agent is not publicly reachable.Both are deliberate. Both are what this post is really about.\n\nI picked [Google's Agent Development Kit](https://github.com/google/adk-python) over LangGraph, CrewAI, and a hand-rolled loop for one reason: **the agent service runs entirely on GCP, and ADK is the only one of those that was built for Vertex AI from the ground up.** The Python SDK ships a `google.adk.agents.Agent`\n\nand a `google.adk.runners.InMemoryRunner`\n\nthat route through `google-genai`\n\n, which already speaks Vertex AI when `GOOGLE_GENAI_USE_VERTEXAI=TRUE`\n\n. No LangChain adapter, no provider shim, no surprise model name drift between staging and prod.\n\nConcretely, my `apps/agent`\n\nis a 53-line `pyproject.toml`\n\nPython 3.12 service:\n\n```\n[project]\nname = \"logichat-agent\"\nversion = \"0.1.0\"\ndescription = \"LogiChat ADK agent service (Python) — Gemini 2.5 Flash via Vertex AI.\"\nrequires-python = \">=3.12\"\n\ndependencies = [\n    \"fastapi>=0.115\",\n    \"pydantic>=2.7\",\n    \"uvicorn>=0.30\",\n    # google-adk is the Agent Development Kit Python SDK. Pinned loosely\n    # because ADK's API is still moving; follow-up issues will exercise\n    # the real Runner (sessions / tools / streaming).\n    \"google-adk>=0.1.0\",\n    # google-genai comes with ADK, but I import google.genai.types\n    # directly in the runner adapter, so declare it explicitly.\n    \"google-genai>=1.0\",\n    # google-cloud-firestore is the client I use for the per-app chunk\n    # vector search. Required by the retrieval layer.\n    \"google-cloud-firestore>=2.20\",\n]\n```\n\nFive runtime deps. FastAPI for the HTTP surface, Pydantic for the wire contract, uvicorn for the Cloud Run entrypoint, `google-adk`\n\nfor the agent, `google-genai`\n\nfor the typed message parts, `google-cloud-firestore`\n\nfor the KNN search. ADC works for all of it — no API keys anywhere.\n\n`apps/agent`\n\nis split into four files, each with one job:\n\n| File | Job |\n|---|---|\n`src/agent/main.py` |\nuvicorn entrypoint; binds `$PORT` (Cloud Run injects) |\n`src/agent/agent_definition.py` |\nBuilds the ADK `Agent` + `InMemoryRunner` , wraps in `RunnerProtocol`\n|\n`src/agent/retrieval.py` |\nFirestore `find_nearest` + `text-embedding-005` via Vertex AI |\n`src/agent/api/app.py` |\nFastAPI factory: `POST /run` , `GET /healthz`\n|\n\nThe `main.py`\n\nis six lines of orchestration:\n\n``` php\n# apps/agent/src/agent/main.py\ndef main() -> None:\n    runner = build_agent()           # builds the ADK-backed adapter\n    app = create_app(runner=runner)  # wires the runner into FastAPI\n    port = int(os.environ.get(\"PORT\", \"8080\"))\n    uvicorn.run(app, host=\"0.0.0.0\", port=port, log_level=\"info\")\n```\n\n`build_agent()`\n\nis where ADK gets wired up. Here's the whole thing:\n\n```\n# apps/agent/src/agent/agent_definition.py\nMODEL_ID = \"gemini-2.5-flash\"\n\nSYSTEM_INSTRUCTION = (\n    \"You are LogiChat's support assistant. Answer the user's question using \"\n    \"ONLY the context retrieved from the company's knowledge base. If the \"\n    \"retrieved context does not contain the answer, say you don't know and \"\n    \"suggest the user rephrase or contact a human. Never invent facts, \"\n    \"product names, or policies that are not present in the retrieved \"\n    \"context. Each retrieved block is prefixed with an internal \"\n    \"[doc:<id>] marker so you can tell sources apart while reasoning — \"\n    \"these markers are for your own use only and must never appear \"\n    \"anywhere in the text you show the user.\"\n)\n\ndef build_agent() -> RunnerProtocol:\n    _require_env(\"GOOGLE_GENAI_USE_VERTEXAI\")  # TRUE → route via Vertex AI\n    _require_env(\"GOOGLE_CLOUD_PROJECT\")       # logichat-dev / logichat-prod\n    _require_env(\"GOOGLE_CLOUD_LOCATION\")      # us-central1\n\n    # Inline import: lets the unit tests run without google-adk installed.\n    from google.adk.agents import Agent\n    from google.adk.runners import InMemoryRunner\n\n    root_agent = Agent(\n        name=\"logichat_faq_agent\",\n        model=MODEL_ID,\n        instruction=SYSTEM_INSTRUCTION,\n        # tools=[firestore_retrieval_tool]  ← intentionally NOT an ADK tool\n    )\n    runner = InMemoryRunner(agent=root_agent)\n    return _AdkRunnerAdapter(runner)\n```\n\nThe env vars are validated at startup so a misconfigured deploy fails the first request — not the first deploy. The model id is **pinned in code** so a stray env var override can't silently swap in a different family. The region and project still come from env so staging and prod can diverge without a rebuild.\n\n`RunnerProtocol`\n\nseam\nThe `google.adk.runners.InMemoryRunner`\n\nexposes more surface than my HTTP layer needs — sessions, event streams, function calls. I wrap it in a protocol so the route handler is stable while the upstream SDK moves:\n\n```\n# apps/agent/src/agent/api/dependencies.py\nclass RunnerProtocol(Protocol):\n    async def run(\n        self,\n        *,\n        app_id: str,\n        session_id: str,\n        question: str,\n        config: dict[str, Any],\n    ) -> RunnerResult: ...\n```\n\n`_AdkRunnerAdapter`\n\nimplements that protocol and forwards to the real ADK runner. The HTTP layer only knows about `RunnerProtocol`\n\n— it never imports `google.adk`\n\n. The reason this matters: **the ADK Python SDK's API is still in motion.** Wrapping it lets me swap implementations later (sessions, eval harness, streaming) without touching `app.py`\n\n.\n\nThe adapter runs retrieval *before* the ADK Runner call. This is the design choice worth understanding.\n\nThis is the single most important architectural decision in the whole system, and it's the one most ADK tutorials get wrong.\n\nThe \"obvious\" pattern in ADK is to expose retrieval as a tool:\n\n```\n# What I did NOT do.\nfirestore_retrieval_tool = FunctionTool(firestore_retrieval)\n\nroot_agent = Agent(\n    name=\"logichat_faq_agent\",\n    model=\"gemini-2.5-flash\",\n    instruction=...,\n    tools=[firestore_retrieval_tool],  # model invokes at its discretion\n)\n```\n\nI evaluated this and rejected it. Two reasons:\n\n**Threshold short-circuit.** When no chunk is close enough to the question, I want to skip the model call entirely and return `defaultAnswer`\n\n. ADK tools fire *inside* the model loop — by the time the tool runs, you've already paid the input-token cost. A tool-based design cannot enforce a \"no useful context\" short-circuit because the model has to decide to invoke the tool first.\n\n**Cross-tenant safety.** `app_id`\n\nmust never become a model-controlled argument. Exposing it as a tool input would let a crafted prompt pivot retrieval to another tenant's collection. I scope retrieval server-side from the trusted caller (`config`\n\n/ `RunnerProtocol.run`\n\n's `app_id`\n\n), not from the model.\n\nSo retrieval lives in the adapter, *before* the ADK runner is invoked:\n\n``` python\n# apps/agent/src/agent/agent_definition.py\nasync def run(self, *, app_id, session_id, question, config):\n    # 1. Retrieval — pre-model, NOT an ADK tool\n    chunks = await self._retrieve(app_id=app_id, question=question)\n\n    if not chunks:\n        # No useful context → short-circuit to defaultAnswer.\n        # Also covers the case where retrieval raised: _retrieve returns\n        # an empty list and a flag on failure.\n        return RunnerResult(answer=_default_answer(config), grounded=False, retrieval=[])\n\n    # 2. Compose the grounded user message\n    chunk_block = \"\\n\\n\".join(_format_chunk(c) for c in chunks)\n    user_text = f\"{chunk_block}\\n\\nUser question: {question.strip()}\"\n\n    # 3. Run the ADK flow against the grounded message\n    runner = self._adk_runner\n    user_id = app_id  # one ADK user per LogiChat app\n    session = await runner.session_service.get_session(\n        app_name=runner.app_name, user_id=user_id, session_id=session_id\n    )\n    if session is None:\n        await runner.session_service.create_session(\n            app_name=runner.app_name, user_id=user_id, session_id=session_id\n        )\n\n    message = types.Content(role=\"user\", parts=[types.Part(text=user_text)])\n    final_text = \"\"\n    async for event in runner.run_async(\n        user_id=user_id, session_id=session_id, new_message=message\n    ):\n        if event.is_final_response() and event.content and event.content.parts:\n            final_text = \"\".join(part.text or \"\" for part in event.content.parts)\n\n    return RunnerResult(\n        answer=sanitize_answer(final_text),\n        grounded=True,\n        retrieval=[\n            RetrievalHit(\n                chunk_id=c.chunk_id, document_id=c.document_id, distance=c.distance\n            )\n            for c in chunks\n        ],\n    )\n```\n\nThree things worth pointing out:\n\n`asyncio.to_thread`\n\n`google-cloud-firestore`\n\nand `google-genai`\n\ncalls so a slow Firestore round-trip doesn't block the uvicorn event loop. ADK itself is async-native; the retrieval layer is the only sync part of the request path.`_retrieve`\n\nswallows exceptions, logs a warning, returns `[]`\n\n. The request still succeeds via the ungrounded ADK flow. A transient Firestore outage degrades answer quality; it doesn't take the service down.`RunnerResult`\n\ncarries `grounded`\n\n+ `retrieval`\n\n`answer`\n\n. The gateway logs retrieval metadata on the conversation doc so I can evaluate retrieval quality offline. The original ADK Runner contract only returns text; I extended it through my adapter.`apps/agent/src/agent/retrieval.py`\n\nis small on purpose — it owns the two operations the adapter needs: embed the question, pull the most relevant chunks. Nothing else in the agent service touches Firestore or the embedding API directly.\n\n```\nEMBEDDING_MODEL = \"text-embedding-005\"\nEMBEDDING_DIMENSION = 768\nDEFAULT_DISTANCE_THRESHOLD = 0.5  # COSINE: 0 = identical, 2 = opposite\nDEFAULT_TOP_K = 5\n```\n\nThe KNN search uses COSINE distance:\n\n```\n# NB: neither symbol is re-exported from the package roots. Both\n# raise ImportError against the real google-cloud-firestore 2.x\n# install. Don't \"fix\" this to the package root — it'll silently\n# fall through to the ungrounded fallback on every request.\nfrom google.cloud.firestore_v1.base_vector_query import DistanceMeasure\nfrom google.cloud.firestore_v1.vector import Vector\n\nquery = db.collection(f\"logichat-apps/{app_id}/chunks\").find_nearest(\n    vector_field=\"embedding\",\n    query_vector=Vector(query_vector),\n    distance_measure=DistanceMeasure.COSINE,\n    limit=limit,\n    distance_result_field=\"vector_distance\",\n)\n```\n\nThe threshold is applied in Python, after KNN returns. The reason: `find_nearest`\n\nreturns the nearest `limit`\n\nchunks by distance; the threshold is a separate concern (recall vs precision). Conflating them in the query means every app owner has to know the right top-k to balance both. Better to keep the parameters independent.\n\n**Both write path and read path must agree on dimension.** The KNN index in `firestore.indexes.json`\n\nis built for 768 dimensions, flat index:\n\n```\n{\n  \"collectionGroup\": \"chunks\",\n  \"queryScope\": \"COLLECTION\",\n  \"fields\": [\n    {\n      \"fieldPath\": \"embedding\",\n      \"vectorConfig\": {\n        \"dimension\": 768,\n        \"flat\": {}\n      }\n    }\n  ]\n}\n```\n\nDrift here is silent and catastrophic — a chunk embedded with a 1536-dim model stored against a 768-dim index either fails the write or, worse, produces results that look reasonable but are nonsense. I pin `EMBEDDING_MODEL`\n\nand `EMBEDDING_DIMENSION`\n\nas module constants and fail loud if the API returns the wrong shape:\n\n```\nif len(values) != EMBEDDING_DIMENSION:\n    raise RuntimeError(\n        f\"embedding model {EMBEDDING_MODEL} returned {len(values)} dims, \"\n        f\"expected {EMBEDDING_DIMENSION}\"\n    )\n```\n\nThe agent's job is to answer questions. The doc-processor's job is to make sure there *are* chunks to answer from.\n\n`apps/subscribers/doc-processor`\n\nis a Cloud Run service subscribed to `google.cloud.storage.object.v1.finalized`\n\nevents on the project bucket. Trigger filter is the path prefix `apps/{appId}/docs/{documentId}/{filename}`\n\n— anything outside that prefix is ignored silently. Why drive off the Storage event instead of a Firestore write? Because the API writes the `pending`\n\ndoc *before* it returns the signed URL, so the metadata can lag the actual upload by seconds. Driving off Storage means I wait for the real bytes.\n\nWhen the event fires:\n\n```\n// apps/subscribers/doc-processor/src/app/doc-processor.handler.ts\nconst [buffer] = await storage.bucket(event.bucket).file(event.name).download()\nconst parsedDoc = await parseDocument(buffer, mimeType)\nconst chunks = chunkText(parsedDoc.text)\nconst vectors = await embedBatch(chunks)\nawait persistChunks(appId, documentId, uid, chunks, vectors, embeddingModel)\nawait markReady(appId, documentId, chunks.length, embeddingModel)\n```\n\nThree of those lines hide real choices.\n\n**Chunker ( libs/server/ai/src/lib/chunker.utils.ts).** 800-token chunks with 100-token overlap. Splits on paragraph boundaries first, falls back to sentence boundaries when a paragraph overflows, and applies overlap by prepending the tail of the previous chunk to the next. Pure function in\n\n`*.utils.ts`\n\n— no injected dependencies, no lifecycle, that's the repo convention.**Parser.** Plain text, markdown, HTML, and PDF. The HTML parser uses cheerio and **strips <script>, <style>, <nav>, <header>, <footer>, <noscript> before extracting text** — this was an actual bug I shipped and caught when a customer's \"About\" page nav ended up dominating their retrieval index.\n\n**Embeddings.** `text-embedding-005`\n\nvia Vertex AI, batched in groups of 50 with two retries on 429/5xx. A single 50-page PDF can produce 200+ chunks; I don't want one HTTP call per chunk.\n\n**Vector storage.** Each chunk goes into `logichat-apps/{appId}/chunks/{chunkId}`\n\nwith `embedding`\n\nstored as `FieldValue.vector(...)`\n\n— **not a plain number[]**. Firestore's KNN index can only query fields written as\n\n`Vector`\n\nvalues; a plain number array is invisible to vector search and you'll get zero hits on every query with no error:\n\n```\n// FieldValue.vector is the ONLY way the KNN index can see this field.\nembedding: FieldValue.vector(vectors[j]),\n```\n\nI use the modern `@google-cloud/firestore`\n\nv7 client *only* for chunk writes, because `firebase-admin@^9`\n\nbundles v4 which doesn't have `FieldValue.vector`\n\n. Everything else stays on firebase-admin — they share ADC so it works, but the type definitions conflict and the build/src deep import dodges that.\n\n`[doc:<id>]`\n\nmarkers\nEach retrieved chunk is prefixed with an internal `[doc:<id>]`\n\nmarker:\n\n``` php\ndef _format_chunk(chunk: RetrievedChunk) -> str:\n    return f\"[doc:{chunk.document_id}]\\n{chunk.content}\"\n```\n\nThe user message becomes:\n\n```\n[doc:abc123]\nOur return policy allows returns within 30 days of purchase...\n\n[doc:def456]\nShipping is free for orders over $50...\n\nUser question: how long do I have to return a dress?\n```\n\nThe marker lets the model cite by id in its reasoning, but the marker must **never** appear in the user-facing answer. I tell the model that in the system prompt:\n\nEach retrieved block is prefixed with an internal\n\n`[doc:<id>]`\n\nmarker ... these markers are for your own use only and must never appear anywhere in the text you show the user.\n\nAnd then I strip them in code anyway. Belt-and-braces is right when the prompt rule is the only thing keeping an internal token out of customer-facing text — there was a real incident where the marker leaked into the FAQ widget. The sanitiser is a few lines:\n\n```\n_DOC_MARKER_RE = re.compile(r\"\\s*\\[doc:[^\\]]*\\]\\s*\")\n_SPACE_BEFORE_PUNCT_RE = re.compile(r\" ([.,;:!?])\")\n\ndef sanitize_answer(text: str) -> str:\n    cleaned = _DOC_MARKER_RE.sub(\" \", text)\n    cleaned = re.sub(r\" {2,}\", \" \", cleaned)\n    cleaned = _SPACE_BEFORE_PUNCT_RE.sub(r\"\\1\", cleaned)\n    return cleaned.strip()\n```\n\nThe second regex is the subtle one — removing a marker that sat immediately before a period leaves a space, which looks worse than the leak. Always check for \"removal left whitespace adjacent to punctuation\" patterns.\n\nThe gateway and the agent are two Cloud Run services. The gateway calls the agent with a Google-signed ID token:\n\n```\n// libs/server/ai/src/lib/agent.service.ts\nconst { agentUrl } = getAgentConfig()\nconst client = isPlainHttpUrl(agentUrl)\n  ? plainHttpClient\n  : await authFactory().getIdTokenClient(agentUrl)\n\nconst response = await client.request({\n  url: `${agentUrl}/run`,\n  method: 'POST',\n  data: { appId, sessionId, question, config },\n})\n```\n\nThe agent service is deployed with `--no-allow-unauthenticated`\n\n, so the only way to reach it is with a valid ID token minted by a service account in the same project. `google-auth-library`\n\n's `GoogleAuth#getIdTokenClient(agentUrl)`\n\nhandles token refresh and attaches `Authorization: Bearer ...`\n\non every request, so I don't manage tokens myself.\n\nThe deploy target:\n\n```\ngcloud run deploy logichat-agent-run \\\n  --image us-central1-docker.pkg.dev/${PROJECT_ID}/logichat/agent \\\n  --region us-central1 \\\n  --platform managed \\\n  --no-allow-unauthenticated \\\n  --port 8080 \\\n  --service-account ${PROJECT_NUMBER}-compute@developer.gserviceaccount.com \\\n  --update-env-vars \\\n    GOOGLE_GENAI_USE_VERTEXAI=TRUE,\\\n    GOOGLE_CLOUD_PROJECT=${PROJECT_ID},\\\n    GOOGLE_CLOUD_LOCATION=us-central1,\\\n    VERTEX_CHAT_MODEL=gemini-2.5-flash\n```\n\nOne Cloud Run service, one service account (the default Compute Engine SA), three env vars that route through Vertex AI. The agent service account needs `roles/aiplatform.user`\n\non the project — same binding covers chat and embeddings, no second role required.\n\nI deleted the prompt-builder that read `examples`\n\n. The dashboard pages for managing Q&A examples are gone. The new upload/list/delete dialog replaced them.\n\nBut the **collection is still there.** Every existing customer has `examples`\n\ndocs they wrote over two years. Deleting those would have been a worse customer experience than the Q&A flow itself. So the fallback:\n\n```\n# When document retrieval comes up empty...\nchunks = await self._retrieve(app_id=app_id, question=question)\nif not chunks:\n    # ...try the legacy Q&A examples before giving up.\n    examples = await self._retrieve_examples(app_id=app_id)\n    if examples:\n        return self._answer_with_examples(examples, question, config)\n    return RunnerResult(answer=_default_answer(config), grounded=False, retrieval=[])\n\n# Otherwise: grounded answer from documents\n...\n```\n\n`retrieve_examples`\n\nis a thin wrapper:\n\n```\n@dataclass(frozen=True)\nclass Example:\n    question: str\n    answer: str\n\nDEFAULT_EXAMPLES_LIMIT = 20\n\ndef retrieve_examples(db, app_id, *, limit=DEFAULT_EXAMPLES_LIMIT) -> list[Example]:\n    \"\"\"Fetch up to `limit` legacy Q&A pairs for `app_id`.\n\n    No ordering, no scoring — these are small hand-curated sets, not\n    ranked results.\n    \"\"\"\n    ...\n```\n\nThe cap at 20 is important. The old prompt-builder had no cap; a customer with 200 examples was already paying for it in latency. The new prompt block looks like:\n\n```\nQ: How do I reset my password?\nA: Click \"Forgot password\" on the login page.\n\nQ: Do you ship internationally?\nA: Yes, we ship to 40+ countries. See our shipping page for details.\n\nUser question: can I return a dress after 30 days?\n```\n\nDocument RAG is still the primary path. Examples are the fallback that runs only when retrieval returned nothing — no useful chunks, empty collection, or retrieval itself broke. A customer who uploaded docs gets doc-RAG answers (better recall, fresh as the doc). A customer who hasn't migrated yet still gets answers from their hand-curated examples. A customer who has neither gets `defaultAnswer`\n\n.\n\nA few things I got wrong the first time and would change on a green-field rebuild.\n\n**The firebase-admin v4 / modern firestore v7 split is ugly.** Bumping\n\n`firebase-admin`\n\nto v13 is the right fix; it's a separate PR's worth of type churn. The `FieldValue.vector`\n\nworkaround works but it makes the doc-processor code look weirder than it should.`find_nearest`\n\nsymbol location.`google.cloud.firestore.Vector`\n\nand `firestore_v1.DistanceMeasure`\n\nare NOT re-exported from the package roots. The first two ships of this module both degraded silently to the ungrounded fallback on every request because the imports raised `ImportError`\n\nand the except-clause swallowed it. Inline the comment that warns future readers — it's not obvious.\n\n**Threshold and top-k should be data-driven, not env-driven.** `RETRIEVAL_DISTANCE_THRESHOLD`\n\nis fine for v1. The next step is per-app tuning from the dashboard — different customers have different \"useful\" thresholds, and 0.5 is a reasonable default, not the right answer for everyone. The KNN top-k belongs in the same config.\n\n**Agent and gateway should share a runner.** Right now `apps/api`\n\nPOSTs to `apps/agent`\n\nover Cloud Run service-to-service ID tokens. The round-trip is invisible to users but adds ~30–80ms of latency and one more thing that can fail. The long-term answer is inlining the ADK flow into the API service for the simple cases, or running both as one Cloud Run service with internal routing. I measured, decided the separation was worth the latency for now (independent deploys, simpler auth), and parked the unification.\n\nThe old prompt with 100 examples was 12–18K tokens per request, ~1.2s p50 latency, ~$0.003/request at GPT-4o-mini pricing. The new doc-RAG prompt is 1.5–3K tokens (system + 3–5 retrieved chunks + question), ~450ms p50, ~$0.0006/request. Recall on the customer's own docs is dramatically better because the bot now searches their actual content instead of paraphrased Q&A.\n\nThese numbers are **directional, not a controlled benchmark.** I didn't run a head-to-head A/B because the move changed three things at once: training data shape (Q&A pairs → retrieved chunks), model (GPT-4o-mini → Gemini 2.5 Flash), and provider (OpenAI → Vertex AI). The 4–5× cost-per-request delta below is dominated by the model/provider change, not by the retrieval change — don't read it as \"RAG is 4–5× cheaper than few-shot.\" All figures are per-request inference cost; document ingestion (one-time embedding on upload) is excluded. The qualitative claim on recall is the one that I'm confident in: doc-RAG on a customer's actual docs beats few-shot Q&A on paraphrased summaries every time, and that's the reason I did this in the first place.\n\nThe full set of changes spans the document pipeline, the agent service, the retrieval layer, the dashboard UI revamp, and a separate design pass for the Q&A fallback.\n\n`app_id`\n\nto model-controlled arguments.`build_agent()`\n\nshould fail fast on misconfiguration.`InMemoryRunner`\n\nin your own protocol.`FieldValue.vector`\n\n`EMBEDDING_DIMENSION`\n\n.`asyncio.to_thread`\n\n.", "url": "https://wpnews.pro/news/building-a-document-rag-agent-on-gcp-s-agent-development-kit-adk", "canonical_source": "https://dev.to/dalenguyen/building-a-document-rag-agent-on-gcps-agent-development-kit-adk-3lh5", "published_at": "2026-07-14 14:06:30+00:00", "updated_at": "2026-07-14 14:30:36.309583+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-infrastructure", "developer-tools", "ai-agents"], "entities": ["LogiChat", "Google", "Agent Development Kit", "Vertex AI", "Cloud Run", "Firestore", "Gemini 2.5 Flash"], "alternates": {"html": "https://wpnews.pro/news/building-a-document-rag-agent-on-gcp-s-agent-development-kit-adk", "markdown": "https://wpnews.pro/news/building-a-document-rag-agent-on-gcp-s-agent-development-kit-adk.md", "text": "https://wpnews.pro/news/building-a-document-rag-agent-on-gcp-s-agent-development-kit-adk.txt", "jsonld": "https://wpnews.pro/news/building-a-document-rag-agent-on-gcp-s-agent-development-kit-adk.jsonld"}}