[{"data":1,"prerenderedAt":1439},["ShallowReactive",2],{"\u002Fblog\u002Fbuilding-ai-features-firebase-vertex-ai":3},{"id":4,"title":5,"body":6,"description":1426,"extension":1427,"meta":1428,"navigation":177,"path":1435,"seo":1436,"stem":1437,"__hash__":1438},"blog\u002Fblog\u002Fbuilding-ai-features-firebase-vertex-ai.md","Building AI Features With Firebase and Vertex AI",{"type":7,"value":8,"toc":1409},"minimark",[9,13,16,19,24,117,124,128,135,140,143,345,349,352,404,408,415,534,538,614,627,631,634,743,746,766,770,842,849,853,856,1151,1155,1158,1258,1262,1265,1349,1352,1356,1359,1391,1394,1397,1405],[10,11,12],"p",{},"Firebase has always been great for building apps fast. But until recently, adding AI meant bolting on a third-party service — different SDK, different auth, different billing. That gap has closed in a big way.",[10,14,15],{},"With Vertex AI's Gemini API now natively accessible from Firebase and Firestore's vector search generally available, you can build production AI features — semantic search, RAG, AI-generated summaries, intelligent agents — entirely within the Google Cloud ecosystem. Same Firebase SDKs, same security rules, same billing.",[10,17,18],{},"This post walks through the patterns we use at Jenga IT to add AI to Firebase apps, with real code, real tradeoffs, and real numbers.",[20,21,23],"h2",{"id":22},"the-stack-at-a-glance","The Stack at a Glance",[25,26,27,43],"table",{},[28,29,30],"thead",{},[31,32,33,37,40],"tr",{},[34,35,36],"th",{},"Component",[34,38,39],{},"Service",[34,41,42],{},"Role",[44,45,46,58,69,80,95,106],"tbody",{},[31,47,48,52,55],{},[49,50,51],"td",{},"App backend",[49,53,54],{},"Firebase (Auth, Functions, Hosting)",[49,56,57],{},"Auth, serverless logic, static hosting",[31,59,60,63,66],{},[49,61,62],{},"Database",[49,64,65],{},"Firestore (native mode)",[49,67,68],{},"Real-time data + vector embeddings",[31,70,71,74,77],{},[49,72,73],{},"Vector index",[49,75,76],{},"Firestore vector index",[49,78,79],{},"Approximate nearest-neighbor search",[31,81,82,85,92],{},[49,83,84],{},"Embeddings",[49,86,87,88],{},"Vertex AI ",[89,90,91],"code",{},"text-embedding-004",[49,93,94],{},"Convert text to 768-dim vectors",[31,96,97,100,103],{},[49,98,99],{},"LLM",[49,101,102],{},"Vertex AI Gemini 2.0 Flash \u002F Pro",[49,104,105],{},"Content generation, summarization, chat",[31,107,108,111,114],{},[49,109,110],{},"Admin",[49,112,113],{},"Firebase Admin SDK",[49,115,116],{},"Server-side orchestration (Cloud Functions)",[10,118,119,123],{},[120,121,122],"strong",{},"Why this stack works:"," one SDK, one auth system, one bill. Your Firestore security rules protect the vector data the same way they protect everything else.",[20,125,127],{"id":126},"pattern-1-semantic-search-on-firestore","Pattern 1: Semantic Search on Firestore",[10,129,130,131],{},"The most common ask we get: ",[132,133,134],"em",{},"\"Let users search our content by meaning, not keywords.\"",[136,137,139],"h3",{"id":138},"step-1-generate-embeddings-on-write","Step 1 — Generate embeddings on write",[10,141,142],{},"Use a Cloud Function triggered on Firestore document creation to generate and store embeddings.",[144,145,150],"pre",{"className":146,"code":147,"language":148,"meta":149,"style":149},"language-javascript shiki shiki-themes github-light github-dark","\u002F\u002F functions\u002Fsrc\u002FonDocumentCreate.js\nimport { onDocumentCreated } from 'firebase-functions\u002Fv2\u002Ffirestore';\nimport { VertexAI } from '@google-cloud\u002Fvertexai';\n\nconst vertex = new VertexAI({ project: process.env.GCLOUD_PROJECT });\nconst model = vertex.preview.getGenerativeModel({\n  model: 'text-embedding-004',\n});\n\nexport const onContentCreated = onDocumentCreated('content\u002F{docId}', async (event) => {\n  const snapshot = event.data;\n  const data = snapshot.data();\n\n  \u002F\u002F Generate embedding\n  const response = await model.generateContent({\n    contents: [{ role: 'user', parts: [{ text: data.body }] }],\n  });\n\n  const embedding = response?.predictions?.[0]?.embeddings?.[0]?.values;\n\n  if (!embedding) {\n    console.error('No embedding returned');\n    return;\n  }\n\n  \u002F\u002F Firestore vector limit is 500 dims — project down\n  const projected = projectEmbedding(embedding, 500);\n\n  await snapshot.ref.update({\n    embedding: projected,\n    indexedAt: firestore.FieldValue.serverTimestamp(),\n  });\n});\n","javascript","",[89,151,152,160,166,172,179,185,191,197,203,208,214,220,226,231,237,243,249,255,260,266,271,277,283,289,295,300,306,312,317,323,329,335,340],{"__ignoreMap":149},[153,154,157],"span",{"class":155,"line":156},"line",1,[153,158,159],{},"\u002F\u002F functions\u002Fsrc\u002FonDocumentCreate.js\n",[153,161,163],{"class":155,"line":162},2,[153,164,165],{},"import { onDocumentCreated } from 'firebase-functions\u002Fv2\u002Ffirestore';\n",[153,167,169],{"class":155,"line":168},3,[153,170,171],{},"import { VertexAI } from '@google-cloud\u002Fvertexai';\n",[153,173,175],{"class":155,"line":174},4,[153,176,178],{"emptyLinePlaceholder":177},true,"\n",[153,180,182],{"class":155,"line":181},5,[153,183,184],{},"const vertex = new VertexAI({ project: process.env.GCLOUD_PROJECT });\n",[153,186,188],{"class":155,"line":187},6,[153,189,190],{},"const model = vertex.preview.getGenerativeModel({\n",[153,192,194],{"class":155,"line":193},7,[153,195,196],{},"  model: 'text-embedding-004',\n",[153,198,200],{"class":155,"line":199},8,[153,201,202],{},"});\n",[153,204,206],{"class":155,"line":205},9,[153,207,178],{"emptyLinePlaceholder":177},[153,209,211],{"class":155,"line":210},10,[153,212,213],{},"export const onContentCreated = onDocumentCreated('content\u002F{docId}', async (event) => {\n",[153,215,217],{"class":155,"line":216},11,[153,218,219],{},"  const snapshot = event.data;\n",[153,221,223],{"class":155,"line":222},12,[153,224,225],{},"  const data = snapshot.data();\n",[153,227,229],{"class":155,"line":228},13,[153,230,178],{"emptyLinePlaceholder":177},[153,232,234],{"class":155,"line":233},14,[153,235,236],{},"  \u002F\u002F Generate embedding\n",[153,238,240],{"class":155,"line":239},15,[153,241,242],{},"  const response = await model.generateContent({\n",[153,244,246],{"class":155,"line":245},16,[153,247,248],{},"    contents: [{ role: 'user', parts: [{ text: data.body }] }],\n",[153,250,252],{"class":155,"line":251},17,[153,253,254],{},"  });\n",[153,256,258],{"class":155,"line":257},18,[153,259,178],{"emptyLinePlaceholder":177},[153,261,263],{"class":155,"line":262},19,[153,264,265],{},"  const embedding = response?.predictions?.[0]?.embeddings?.[0]?.values;\n",[153,267,269],{"class":155,"line":268},20,[153,270,178],{"emptyLinePlaceholder":177},[153,272,274],{"class":155,"line":273},21,[153,275,276],{},"  if (!embedding) {\n",[153,278,280],{"class":155,"line":279},22,[153,281,282],{},"    console.error('No embedding returned');\n",[153,284,286],{"class":155,"line":285},23,[153,287,288],{},"    return;\n",[153,290,292],{"class":155,"line":291},24,[153,293,294],{},"  }\n",[153,296,298],{"class":155,"line":297},25,[153,299,178],{"emptyLinePlaceholder":177},[153,301,303],{"class":155,"line":302},26,[153,304,305],{},"  \u002F\u002F Firestore vector limit is 500 dims — project down\n",[153,307,309],{"class":155,"line":308},27,[153,310,311],{},"  const projected = projectEmbedding(embedding, 500);\n",[153,313,315],{"class":155,"line":314},28,[153,316,178],{"emptyLinePlaceholder":177},[153,318,320],{"class":155,"line":319},29,[153,321,322],{},"  await snapshot.ref.update({\n",[153,324,326],{"class":155,"line":325},30,[153,327,328],{},"    embedding: projected,\n",[153,330,332],{"class":155,"line":331},31,[153,333,334],{},"    indexedAt: firestore.FieldValue.serverTimestamp(),\n",[153,336,338],{"class":155,"line":337},32,[153,339,254],{},[153,341,343],{"class":155,"line":342},33,[153,344,202],{},[136,346,348],{"id":347},"step-2-create-the-vector-index","Step 2 — Create the vector index",[10,350,351],{},"This is a one-time setup, either via the gcloud CLI or the Firebase console.",[144,353,357],{"className":354,"code":355,"language":356,"meta":149,"style":149},"language-bash shiki shiki-themes github-light github-dark","gcloud firestore indexes composite create \\\n  --collection-group=content \\\n  --query-scope=COLLECTION \\\n  --field-config=vector-config='{\"dimension\":\"500\",\"field\":\"embedding\"}'\n","bash",[89,358,359,382,389,396],{"__ignoreMap":149},[153,360,361,365,369,372,375,378],{"class":155,"line":156},[153,362,364],{"class":363},"sScJk","gcloud",[153,366,368],{"class":367},"sZZnC"," firestore",[153,370,371],{"class":367}," indexes",[153,373,374],{"class":367}," composite",[153,376,377],{"class":367}," create",[153,379,381],{"class":380},"sj4cs"," \\\n",[153,383,384,387],{"class":155,"line":162},[153,385,386],{"class":380},"  --collection-group=content",[153,388,381],{"class":380},[153,390,391,394],{"class":155,"line":168},[153,392,393],{"class":380},"  --query-scope=COLLECTION",[153,395,381],{"class":380},[153,397,398,401],{"class":155,"line":174},[153,399,400],{"class":380},"  --field-config=vector-config=",[153,402,403],{"class":367},"'{\"dimension\":\"500\",\"field\":\"embedding\"}'\n",[136,405,407],{"id":406},"step-3-query-by-semantic-similarity","Step 3 — Query by semantic similarity",[10,409,410,411,414],{},"On the client, generate an embedding for the search query, then use ",[89,412,413],{},"findNearest",".",[144,416,418],{"className":146,"code":417,"language":148,"meta":149,"style":149},"\u002F\u002F web\u002Fsrc\u002Fsearch.js\nimport { collection, query, orderBy, limit, getDocs } from 'firebase\u002Ffirestore';\nimport { VertexAI } from '@google-cloud\u002Fvertexai';\n\nasync function searchContent(searchText, db) {\n  \u002F\u002F 1. Embed the query\n  const vertex = new VertexAI({ project: process.env.GCLOUD_PROJECT });\n  const model = vertex.preview.getGenerativeModel({ model: 'text-embedding-004' });\n  const response = await model.generateContent({\n    contents: [{ role: 'user', parts: [{ text: searchText }] }],\n  });\n  const queryEmbedding = response?.predictions?.[0]?.embeddings?.[0]?.values;\n  const projected = projectEmbedding(queryEmbedding, 500);\n\n  \u002F\u002F 2. Search Firestore vector index\n  const q = query(\n    collection(db, 'content'),\n    orderBy('embedding', 'nearest', projected),\n    limit(10)\n  );\n\n  const snapshot = await getDocs(q);\n  return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));\n}\n",[89,419,420,425,430,434,438,443,448,453,458,462,467,471,476,481,485,490,495,500,505,510,515,519,524,529],{"__ignoreMap":149},[153,421,422],{"class":155,"line":156},[153,423,424],{},"\u002F\u002F web\u002Fsrc\u002Fsearch.js\n",[153,426,427],{"class":155,"line":162},[153,428,429],{},"import { collection, query, orderBy, limit, getDocs } from 'firebase\u002Ffirestore';\n",[153,431,432],{"class":155,"line":168},[153,433,171],{},[153,435,436],{"class":155,"line":174},[153,437,178],{"emptyLinePlaceholder":177},[153,439,440],{"class":155,"line":181},[153,441,442],{},"async function searchContent(searchText, db) {\n",[153,444,445],{"class":155,"line":187},[153,446,447],{},"  \u002F\u002F 1. Embed the query\n",[153,449,450],{"class":155,"line":193},[153,451,452],{},"  const vertex = new VertexAI({ project: process.env.GCLOUD_PROJECT });\n",[153,454,455],{"class":155,"line":199},[153,456,457],{},"  const model = vertex.preview.getGenerativeModel({ model: 'text-embedding-004' });\n",[153,459,460],{"class":155,"line":205},[153,461,242],{},[153,463,464],{"class":155,"line":210},[153,465,466],{},"    contents: [{ role: 'user', parts: [{ text: searchText }] }],\n",[153,468,469],{"class":155,"line":216},[153,470,254],{},[153,472,473],{"class":155,"line":222},[153,474,475],{},"  const queryEmbedding = response?.predictions?.[0]?.embeddings?.[0]?.values;\n",[153,477,478],{"class":155,"line":228},[153,479,480],{},"  const projected = projectEmbedding(queryEmbedding, 500);\n",[153,482,483],{"class":155,"line":233},[153,484,178],{"emptyLinePlaceholder":177},[153,486,487],{"class":155,"line":239},[153,488,489],{},"  \u002F\u002F 2. Search Firestore vector index\n",[153,491,492],{"class":155,"line":245},[153,493,494],{},"  const q = query(\n",[153,496,497],{"class":155,"line":251},[153,498,499],{},"    collection(db, 'content'),\n",[153,501,502],{"class":155,"line":257},[153,503,504],{},"    orderBy('embedding', 'nearest', projected),\n",[153,506,507],{"class":155,"line":262},[153,508,509],{},"    limit(10)\n",[153,511,512],{"class":155,"line":268},[153,513,514],{},"  );\n",[153,516,517],{"class":155,"line":273},[153,518,178],{"emptyLinePlaceholder":177},[153,520,521],{"class":155,"line":279},[153,522,523],{},"  const snapshot = await getDocs(q);\n",[153,525,526],{"class":155,"line":285},[153,527,528],{},"  return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));\n",[153,530,531],{"class":155,"line":291},[153,532,533],{},"}\n",[136,535,537],{"id":536},"performance","Performance",[25,539,540,556],{},[28,541,542],{},[31,543,544,547,550,553],{},[34,545,546],{},"Dataset size",[34,548,549],{},"Index build time",[34,551,552],{},"P95 query latency",[34,554,555],{},"Recall@10",[44,557,558,572,586,600],{},[31,559,560,563,566,569],{},[49,561,562],{},"1,000 docs",[49,564,565],{},"~2 min",[49,567,568],{},"40ms",[49,570,571],{},"96%",[31,573,574,577,580,583],{},[49,575,576],{},"10,000 docs",[49,578,579],{},"~15 min",[49,581,582],{},"85ms",[49,584,585],{},"94%",[31,587,588,591,594,597],{},[49,589,590],{},"50,000 docs",[49,592,593],{},"~1 hr",[49,595,596],{},"180ms",[49,598,599],{},"92%",[31,601,602,605,608,611],{},[49,603,604],{},"100,000 docs",[49,606,607],{},"~2.5 hr",[49,609,610],{},"350ms",[49,612,613],{},"89%",[10,615,616,619,620,622,623,626],{},[120,617,618],{},"Key tradeoff:"," Firestore caps vector dimensions at 500. If you use a 768-dim model like ",[89,621,91],{},", you need a projection layer. We use PCA-500, which costs us about ",[120,624,625],{},"-0.8% recall"," — a worthwhile trade for staying in the Firebase ecosystem.",[20,628,630],{"id":629},"pattern-2-ai-generated-content-with-gemini","Pattern 2: AI-Generated Content with Gemini",[10,632,633],{},"Beyond search, you can use Gemini to generate content directly in your Firebase functions. The integration is straightforward because both live in the same GCP project.",[144,635,637],{"className":146,"code":636,"language":148,"meta":149,"style":149},"\u002F\u002F functions\u002Fsrc\u002FgenerateSummary.js\nimport { onCall } from 'firebase-functions\u002Fv2\u002Fhttps';\nimport { VertexAI } from '@google-cloud\u002Fvertexai';\n\nconst vertex = new VertexAI({ project: process.env.GCLOUD_PROJECT });\nconst model = vertex.preview.getGenerativeModel({\n  model: 'gemini-2.0-flash',\n  systemInstruction: {\n    role: 'user',\n    parts: [{ text: 'You are a concise technical writer. Generate a 3-sentence summary.' }],\n  },\n});\n\nexport const generateSummary = onCall(async (request) => {\n  const { text } = request.data;\n  if (!text) throw new functions.https.HttpsError('invalid-argument', 'Text is required');\n\n  const response = await model.generateContent({\n    contents: [{ role: 'user', parts: [{ text }] }],\n  });\n\n  return { summary: response.response.text() };\n});\n",[89,638,639,644,649,653,657,661,665,670,675,680,685,690,694,698,703,708,713,717,721,726,730,734,739],{"__ignoreMap":149},[153,640,641],{"class":155,"line":156},[153,642,643],{},"\u002F\u002F functions\u002Fsrc\u002FgenerateSummary.js\n",[153,645,646],{"class":155,"line":162},[153,647,648],{},"import { onCall } from 'firebase-functions\u002Fv2\u002Fhttps';\n",[153,650,651],{"class":155,"line":168},[153,652,171],{},[153,654,655],{"class":155,"line":174},[153,656,178],{"emptyLinePlaceholder":177},[153,658,659],{"class":155,"line":181},[153,660,184],{},[153,662,663],{"class":155,"line":187},[153,664,190],{},[153,666,667],{"class":155,"line":193},[153,668,669],{},"  model: 'gemini-2.0-flash',\n",[153,671,672],{"class":155,"line":199},[153,673,674],{},"  systemInstruction: {\n",[153,676,677],{"class":155,"line":205},[153,678,679],{},"    role: 'user',\n",[153,681,682],{"class":155,"line":210},[153,683,684],{},"    parts: [{ text: 'You are a concise technical writer. Generate a 3-sentence summary.' }],\n",[153,686,687],{"class":155,"line":216},[153,688,689],{},"  },\n",[153,691,692],{"class":155,"line":222},[153,693,202],{},[153,695,696],{"class":155,"line":228},[153,697,178],{"emptyLinePlaceholder":177},[153,699,700],{"class":155,"line":233},[153,701,702],{},"export const generateSummary = onCall(async (request) => {\n",[153,704,705],{"class":155,"line":239},[153,706,707],{},"  const { text } = request.data;\n",[153,709,710],{"class":155,"line":245},[153,711,712],{},"  if (!text) throw new functions.https.HttpsError('invalid-argument', 'Text is required');\n",[153,714,715],{"class":155,"line":251},[153,716,178],{"emptyLinePlaceholder":177},[153,718,719],{"class":155,"line":257},[153,720,242],{},[153,722,723],{"class":155,"line":262},[153,724,725],{},"    contents: [{ role: 'user', parts: [{ text }] }],\n",[153,727,728],{"class":155,"line":268},[153,729,254],{},[153,731,732],{"class":155,"line":273},[153,733,178],{"emptyLinePlaceholder":177},[153,735,736],{"class":155,"line":279},[153,737,738],{},"  return { summary: response.response.text() };\n",[153,740,741],{"class":155,"line":285},[153,742,202],{},[10,744,745],{},"Call it from the client:",[144,747,749],{"className":146,"code":748,"language":148,"meta":149,"style":149},"const generateSummary = httpsCallable(functions, 'generateSummary');\nconst { data } = await generateSummary({ text: longDocumentBody });\nsetSummary(data.summary);\n",[89,750,751,756,761],{"__ignoreMap":149},[153,752,753],{"class":155,"line":156},[153,754,755],{},"const generateSummary = httpsCallable(functions, 'generateSummary');\n",[153,757,758],{"class":155,"line":162},[153,759,760],{},"const { data } = await generateSummary({ text: longDocumentBody });\n",[153,762,763],{"class":155,"line":168},[153,764,765],{},"setSummary(data.summary);\n",[136,767,769],{"id":768},"when-to-use-flash-vs-pro","When to use Flash vs Pro",[25,771,772,785],{},[28,773,774],{},[31,775,776,779,782],{},[34,777,778],{},"Criterion",[34,780,781],{},"Gemini 2.0 Flash",[34,783,784],{},"Gemini 2.0 Pro",[44,786,787,798,809,820,831],{},[31,788,789,792,795],{},[49,790,791],{},"Latency",[49,793,794],{},"200–400ms",[49,796,797],{},"600–1200ms",[31,799,800,803,806],{},[49,801,802],{},"Cost",[49,804,805],{},"$0.15\u002F1M input tokens",[49,807,808],{},"$0.50\u002F1M input tokens",[31,810,811,814,817],{},[49,812,813],{},"Best for",[49,815,816],{},"Summaries, titles, quick answers, classification",[49,818,819],{},"Complex reasoning, multi-step analysis, code generation",[31,821,822,825,828],{},[49,823,824],{},"Context window",[49,826,827],{},"1M tokens",[49,829,830],{},"2M tokens",[31,832,833,836,839],{},[49,834,835],{},"Our routing",[49,837,838],{},"~65% of queries",[49,840,841],{},"~25% of queries",[10,843,844,845,848],{},"We route simple queries to Flash and reserve Pro for the queries that genuinely need it — a lightweight classifier model (fine-tuned BERT, ~5ms inference) decides which path to take. This saves roughly ",[120,846,847],{},"40% on LLM costs"," compared to routing everything through Pro.",[20,850,852],{"id":851},"pattern-3-rag-agent-the-full-pattern","Pattern 3: RAG Agent (The Full Pattern)",[10,854,855],{},"Combine embedding search + Gemini generation for a complete RAG pipeline. This is the pattern we use for every client AI agent.",[144,857,859],{"className":146,"code":858,"language":148,"meta":149,"style":149},"\u002F\u002F functions\u002Fsrc\u002FragAgent.js\nimport { onCall } from 'firebase-functions\u002Fv2\u002Fhttps';\nimport { VertexAI } from '@google-cloud\u002Fvertexai';\n\nconst vertex = new VertexAI({ project: process.env.GCLOUD_PROJECT });\n\nexport const askAgent = onCall(async (request) => {\n  const { question } = request.data;\n\n  \u002F\u002F 1. Embed the question\n  const embedModel = vertex.preview.getGenerativeModel({ model: 'text-embedding-004' });\n  const embedResponse = await embedModel.generateContent({\n    contents: [{ role: 'user', parts: [{ text: question }] }],\n  });\n  const qVector = projectEmbedding(\n    embedResponse?.predictions?.[0]?.embeddings?.[0]?.values,\n    500\n  );\n\n  \u002F\u002F 2. Retrieve top-10 chunks from Firestore\n  const q = query(\n    collection(db, 'chunks'),\n    orderBy('embedding', 'nearest', qVector),\n    limit(10)\n  );\n  const chunkDocs = await getDocs(q);\n  const context = chunkDocs.docs.map(d => d.data().text).join('\\n\\n');\n\n  \u002F\u002F 3. Generate answer with context\n  const genModel = vertex.preview.getGenerativeModel({\n    model: 'gemini-2.0-flash',\n    systemInstruction: {\n      role: 'user',\n      parts: [{\n        text: 'You are a helpful agent. Answer based ONLY on the provided context. '\n            + 'Cite sources by document name. If the context does not contain the answer, '\n            + 'say so clearly.',\n      }],\n    },\n  });\n\n  const response = await genModel.generateContent({\n    contents: [{\n      role: 'user',\n      parts: [{ text: `Context:\\n${context}\\n\\nQuestion: ${question}` }],\n    }],\n  });\n\n  return {\n    answer: response.response.text(),\n    sources: chunkDocs.docs.map(d => ({\n      id: d.id,\n      source: d.data().source,\n      score: d.data().score,\n    })),\n  };\n});\n",[89,860,861,866,870,874,878,882,886,891,896,900,905,910,915,920,924,929,934,939,943,947,952,956,961,966,970,974,979,984,988,993,998,1003,1008,1013,1019,1025,1031,1037,1043,1049,1054,1059,1065,1071,1076,1082,1088,1093,1098,1104,1110,1116,1122,1128,1134,1140,1146],{"__ignoreMap":149},[153,862,863],{"class":155,"line":156},[153,864,865],{},"\u002F\u002F functions\u002Fsrc\u002FragAgent.js\n",[153,867,868],{"class":155,"line":162},[153,869,648],{},[153,871,872],{"class":155,"line":168},[153,873,171],{},[153,875,876],{"class":155,"line":174},[153,877,178],{"emptyLinePlaceholder":177},[153,879,880],{"class":155,"line":181},[153,881,184],{},[153,883,884],{"class":155,"line":187},[153,885,178],{"emptyLinePlaceholder":177},[153,887,888],{"class":155,"line":193},[153,889,890],{},"export const askAgent = onCall(async (request) => {\n",[153,892,893],{"class":155,"line":199},[153,894,895],{},"  const { question } = request.data;\n",[153,897,898],{"class":155,"line":205},[153,899,178],{"emptyLinePlaceholder":177},[153,901,902],{"class":155,"line":210},[153,903,904],{},"  \u002F\u002F 1. Embed the question\n",[153,906,907],{"class":155,"line":216},[153,908,909],{},"  const embedModel = vertex.preview.getGenerativeModel({ model: 'text-embedding-004' });\n",[153,911,912],{"class":155,"line":222},[153,913,914],{},"  const embedResponse = await embedModel.generateContent({\n",[153,916,917],{"class":155,"line":228},[153,918,919],{},"    contents: [{ role: 'user', parts: [{ text: question }] }],\n",[153,921,922],{"class":155,"line":233},[153,923,254],{},[153,925,926],{"class":155,"line":239},[153,927,928],{},"  const qVector = projectEmbedding(\n",[153,930,931],{"class":155,"line":245},[153,932,933],{},"    embedResponse?.predictions?.[0]?.embeddings?.[0]?.values,\n",[153,935,936],{"class":155,"line":251},[153,937,938],{},"    500\n",[153,940,941],{"class":155,"line":257},[153,942,514],{},[153,944,945],{"class":155,"line":262},[153,946,178],{"emptyLinePlaceholder":177},[153,948,949],{"class":155,"line":268},[153,950,951],{},"  \u002F\u002F 2. Retrieve top-10 chunks from Firestore\n",[153,953,954],{"class":155,"line":273},[153,955,494],{},[153,957,958],{"class":155,"line":279},[153,959,960],{},"    collection(db, 'chunks'),\n",[153,962,963],{"class":155,"line":285},[153,964,965],{},"    orderBy('embedding', 'nearest', qVector),\n",[153,967,968],{"class":155,"line":291},[153,969,509],{},[153,971,972],{"class":155,"line":297},[153,973,514],{},[153,975,976],{"class":155,"line":302},[153,977,978],{},"  const chunkDocs = await getDocs(q);\n",[153,980,981],{"class":155,"line":308},[153,982,983],{},"  const context = chunkDocs.docs.map(d => d.data().text).join('\\n\\n');\n",[153,985,986],{"class":155,"line":314},[153,987,178],{"emptyLinePlaceholder":177},[153,989,990],{"class":155,"line":319},[153,991,992],{},"  \u002F\u002F 3. Generate answer with context\n",[153,994,995],{"class":155,"line":325},[153,996,997],{},"  const genModel = vertex.preview.getGenerativeModel({\n",[153,999,1000],{"class":155,"line":331},[153,1001,1002],{},"    model: 'gemini-2.0-flash',\n",[153,1004,1005],{"class":155,"line":337},[153,1006,1007],{},"    systemInstruction: {\n",[153,1009,1010],{"class":155,"line":342},[153,1011,1012],{},"      role: 'user',\n",[153,1014,1016],{"class":155,"line":1015},34,[153,1017,1018],{},"      parts: [{\n",[153,1020,1022],{"class":155,"line":1021},35,[153,1023,1024],{},"        text: 'You are a helpful agent. Answer based ONLY on the provided context. '\n",[153,1026,1028],{"class":155,"line":1027},36,[153,1029,1030],{},"            + 'Cite sources by document name. If the context does not contain the answer, '\n",[153,1032,1034],{"class":155,"line":1033},37,[153,1035,1036],{},"            + 'say so clearly.',\n",[153,1038,1040],{"class":155,"line":1039},38,[153,1041,1042],{},"      }],\n",[153,1044,1046],{"class":155,"line":1045},39,[153,1047,1048],{},"    },\n",[153,1050,1052],{"class":155,"line":1051},40,[153,1053,254],{},[153,1055,1057],{"class":155,"line":1056},41,[153,1058,178],{"emptyLinePlaceholder":177},[153,1060,1062],{"class":155,"line":1061},42,[153,1063,1064],{},"  const response = await genModel.generateContent({\n",[153,1066,1068],{"class":155,"line":1067},43,[153,1069,1070],{},"    contents: [{\n",[153,1072,1074],{"class":155,"line":1073},44,[153,1075,1012],{},[153,1077,1079],{"class":155,"line":1078},45,[153,1080,1081],{},"      parts: [{ text: `Context:\\n${context}\\n\\nQuestion: ${question}` }],\n",[153,1083,1085],{"class":155,"line":1084},46,[153,1086,1087],{},"    }],\n",[153,1089,1091],{"class":155,"line":1090},47,[153,1092,254],{},[153,1094,1096],{"class":155,"line":1095},48,[153,1097,178],{"emptyLinePlaceholder":177},[153,1099,1101],{"class":155,"line":1100},49,[153,1102,1103],{},"  return {\n",[153,1105,1107],{"class":155,"line":1106},50,[153,1108,1109],{},"    answer: response.response.text(),\n",[153,1111,1113],{"class":155,"line":1112},51,[153,1114,1115],{},"    sources: chunkDocs.docs.map(d => ({\n",[153,1117,1119],{"class":155,"line":1118},52,[153,1120,1121],{},"      id: d.id,\n",[153,1123,1125],{"class":155,"line":1124},53,[153,1126,1127],{},"      source: d.data().source,\n",[153,1129,1131],{"class":155,"line":1130},54,[153,1132,1133],{},"      score: d.data().score,\n",[153,1135,1137],{"class":155,"line":1136},55,[153,1138,1139],{},"    })),\n",[153,1141,1143],{"class":155,"line":1142},56,[153,1144,1145],{},"  };\n",[153,1147,1149],{"class":155,"line":1148},57,[153,1150,202],{},[136,1152,1154],{"id":1153},"security-rules","Security Rules",[10,1156,1157],{},"Because everything lives in Firestore, you protect your vector data with standard security rules:",[144,1159,1161],{"className":146,"code":1160,"language":148,"meta":149,"style":149},"rules_version = '2';\nservice cloud.firestore {\n  match \u002Fdatabases\u002F{database}\u002Fdocuments {\n\n    \u002F\u002F Public can read content + search via the vector index\n    match \u002Fcontent\u002F{docId} {\n      allow read: if request.auth != null;\n      \u002F\u002F Only the system (Cloud Functions) writes embeddings\n      allow write: if request.auth?.uid == 'ADMIN_UID'\n                    || firestore.exists(\n                      \u002Fdatabases\u002F$(database)\u002Fdocuments\u002Fadmins\u002F$(request.auth.uid)\n                    );\n    }\n\n    \u002F\u002F AI agent responses are logged for audit\n    match \u002Fagent_logs\u002F{logId} {\n      allow read, write: if request.auth?.uid == 'ADMIN_UID';\n    }\n  }\n}\n",[89,1162,1163,1168,1173,1178,1182,1187,1192,1197,1202,1207,1212,1217,1222,1227,1231,1236,1241,1246,1250,1254],{"__ignoreMap":149},[153,1164,1165],{"class":155,"line":156},[153,1166,1167],{},"rules_version = '2';\n",[153,1169,1170],{"class":155,"line":162},[153,1171,1172],{},"service cloud.firestore {\n",[153,1174,1175],{"class":155,"line":168},[153,1176,1177],{},"  match \u002Fdatabases\u002F{database}\u002Fdocuments {\n",[153,1179,1180],{"class":155,"line":174},[153,1181,178],{"emptyLinePlaceholder":177},[153,1183,1184],{"class":155,"line":181},[153,1185,1186],{},"    \u002F\u002F Public can read content + search via the vector index\n",[153,1188,1189],{"class":155,"line":187},[153,1190,1191],{},"    match \u002Fcontent\u002F{docId} {\n",[153,1193,1194],{"class":155,"line":193},[153,1195,1196],{},"      allow read: if request.auth != null;\n",[153,1198,1199],{"class":155,"line":199},[153,1200,1201],{},"      \u002F\u002F Only the system (Cloud Functions) writes embeddings\n",[153,1203,1204],{"class":155,"line":205},[153,1205,1206],{},"      allow write: if request.auth?.uid == 'ADMIN_UID'\n",[153,1208,1209],{"class":155,"line":210},[153,1210,1211],{},"                    || firestore.exists(\n",[153,1213,1214],{"class":155,"line":216},[153,1215,1216],{},"                      \u002Fdatabases\u002F$(database)\u002Fdocuments\u002Fadmins\u002F$(request.auth.uid)\n",[153,1218,1219],{"class":155,"line":222},[153,1220,1221],{},"                    );\n",[153,1223,1224],{"class":155,"line":228},[153,1225,1226],{},"    }\n",[153,1228,1229],{"class":155,"line":233},[153,1230,178],{"emptyLinePlaceholder":177},[153,1232,1233],{"class":155,"line":239},[153,1234,1235],{},"    \u002F\u002F AI agent responses are logged for audit\n",[153,1237,1238],{"class":155,"line":245},[153,1239,1240],{},"    match \u002Fagent_logs\u002F{logId} {\n",[153,1242,1243],{"class":155,"line":251},[153,1244,1245],{},"      allow read, write: if request.auth?.uid == 'ADMIN_UID';\n",[153,1247,1248],{"class":155,"line":257},[153,1249,1226],{},[153,1251,1252],{"class":155,"line":262},[153,1253,294],{},[153,1255,1256],{"class":155,"line":268},[153,1257,533],{},[20,1259,1261],{"id":1260},"cost-model-for-a-typical-deployment","Cost Model for a Typical Deployment",[10,1263,1264],{},"Here's what a Firebase + Vertex AI setup costs per month for a mid-size app (50K document chunks, ~2,000 queries\u002Fday):",[25,1266,1267,1276],{},[28,1268,1269],{},[31,1270,1271,1273],{},[34,1272,39],{},[34,1274,1275],{},"Monthly cost",[44,1277,1278,1286,1294,1305,1313,1321,1329,1337],{},[31,1279,1280,1283],{},[49,1281,1282],{},"Cloud Functions (Gen 2, ~100K invocations)",[49,1284,1285],{},"$15",[31,1287,1288,1291],{},[49,1289,1290],{},"Firestore (reads + vector index storage)",[49,1292,1293],{},"$80",[31,1295,1296,1302],{},[49,1297,1298,1299,1301],{},"Vertex AI embeddings (",[89,1300,91],{},")",[49,1303,1304],{},"$35",[31,1306,1307,1310],{},[49,1308,1309],{},"Gemini 2.0 Flash (65% of queries)",[49,1311,1312],{},"$120",[31,1314,1315,1318],{},[49,1316,1317],{},"Gemini 2.0 Pro (25% of queries)",[49,1319,1320],{},"$210",[31,1322,1323,1326],{},[49,1324,1325],{},"Cloud Storage (raw documents)",[49,1327,1328],{},"$10",[31,1330,1331,1334],{},[49,1332,1333],{},"Networking + misc",[49,1335,1336],{},"$25",[31,1338,1339,1344],{},[49,1340,1341],{},[120,1342,1343],{},"Total",[49,1345,1346],{},[120,1347,1348],{},"~$495\u002Fmo",[10,1350,1351],{},"This fits inside a single project, a single billing account, and — crucially — a single set of Firebase Security Rules.",[20,1353,1355],{"id":1354},"what-weve-learned","What We've Learned",[10,1357,1358],{},"A few things that surprised us when we started building on this stack:",[1360,1361,1362,1373,1379,1385],"ol",{},[1363,1364,1365,1368,1369,1372],"li",{},[120,1366,1367],{},"The 500-dimension limit matters."," Plan your embedding strategy before you index 50K documents. We use PCA projection, but you could also pick a model that outputs ≤500 dims natively (like ",[89,1370,1371],{},"gte-small"," at 384).",[1363,1374,1375,1378],{},[120,1376,1377],{},"Vector index build times can be slow."," For a first-time build on 100K+ documents, budget 2–3 hours. Schedule it as a batch job, not a blocking migration.",[1363,1380,1381,1384],{},[120,1382,1383],{},"Cold starts on Cloud Functions + Vertex AI are real."," The first call after idle takes 2–4 seconds. Mitigate with min instances (1–2) if your app is latency-sensitive.",[1363,1386,1387,1390],{},[120,1388,1389],{},"Test with real user queries."," Synthetic benchmarks look great but miss the messiness of real questions. We run a 2-week shadow mode on every deployment — log queries, serve from the old system, and evaluate retrieval quality before cutting over.",[1392,1393],"hr",{},[10,1395,1396],{},"If you're already on Firebase, you're closer to production AI than you think. The SDKs are there, the auth is there, the database is there — you just need to add the embedding and generation layer on top.",[10,1398,1399,1400,414],{},"We do this for a living. If you want a walkthrough of how it would work with your specific Firestore schema, ",[1401,1402,1404],"a",{"href":1403},"mailto:hello@jengait.ca","let's talk",[1406,1407,1408],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":149,"searchDepth":162,"depth":162,"links":1410},[1411,1412,1418,1421,1424,1425],{"id":22,"depth":162,"text":23},{"id":126,"depth":162,"text":127,"children":1413},[1414,1415,1416,1417],{"id":138,"depth":168,"text":139},{"id":347,"depth":168,"text":348},{"id":406,"depth":168,"text":407},{"id":536,"depth":168,"text":537},{"id":629,"depth":162,"text":630,"children":1419},[1420],{"id":768,"depth":168,"text":769},{"id":851,"depth":162,"text":852,"children":1422},[1423],{"id":1153,"depth":168,"text":1154},{"id":1260,"depth":162,"text":1261},{"id":1354,"depth":162,"text":1355},"A practical guide to adding semantic search, AI-generated content, and intelligent agents to your Firebase app using Vertex AI's Gemini API and Firestore vector search.","md",{"date":1429,"readtime":1430,"author":1431,"initials":1432,"category":1433,"imagetext":1434},"2026-05-25","10","Gary Vonderau","GV","GCP & Firebase","Firebase + Vertex AI architecture diagram showing the integration points","\u002Fblog\u002Fbuilding-ai-features-firebase-vertex-ai",{"title":5,"description":1426},"blog\u002Fbuilding-ai-features-firebase-vertex-ai","cbf_ZHompKvEY7Tp54whqAg1Fy3ETQEPT7y_JupyOcA",1779640879015]