DEV Community

Cover image for Building an AI Question Paper Generator: Conquering Google Cloud Document AI, Firestore Vector Search, and Gemini
Saravana Dev
Saravana Dev

Posted on

Building an AI Question Paper Generator: Conquering Google Cloud Document AI, Firestore Vector Search, and Gemini

As part of the Gen AI Academy APAC, I set out to solve a major pain point for educators: manually sifting through textbooks to create grade-appropriate question papers.

I built an automated Question Paper Generator using a Serverless Next.js stack, a Retrieval-Augmented Generation (RAG) architecture, and the complete Google Cloud AI suite. Teachers simply upload a textbook chapter (PDF), specify the grade and subject, and let the AI generate a fully formatted assessment quiz.

While the architecture sounds straightforward, orchestrating these enterprise-grade APIs in a serverless environment presented several intense technical hurdles. Here is a deep dive into the architecture, the specific roadblocks I hit, and how I ultimately solved them.

๐Ÿ—๏ธ The RAG Architecture

The application is built on Next.js 15 and deployed to Google Cloud Run. The pipeline flows as follows:

  1. Document Extraction: The PDF is uploaded and sent to Google Cloud Document AI (Document OCR Processor) to extract the raw text.
  2. Chunking & Embeddings: The text is chunked into logical paragraphs and sent to Vertex AI (text-embedding-004) to generate dense vector embeddings.
  3. Vector Database: The embeddings and metadata (Grade, Subject) are stored seamlessly in Firestore using native VectorValue support.
  4. Retrieval & Generation: When a teacher requests a quiz, the query is embedded, and a findNearest Vector Search runs on Firestore. The retrieved context is passed to Google Gen AI (gemini-3.5-flash) to synthesize the structured question paper.

๐Ÿ› The Technical Challenges & How I Solved Them

Building an end-to-end pipeline using cutting-edge SDKs often means dealing with strict schema validations and opaque error codes. Here are the major technical gotchas I faced.

1. The Document AI Region Endpoint Mismatch

The Challenge:
I provisioned a Document OCR processor in the asia-south1 region. However, when my Node.js client attempted to send a processing request using the processor's full resource name, it threw a cryptic HTTP 400 error:
"7 PERMISSION_DENIED: Permission 'documentai.processors.processOnline' denied on resource... (or it may not exist)."

The Fix:
By default, the DocumentProcessorServiceClient routes all traffic to the global us-documentai.googleapis.com endpoint. The US gateway has no knowledge of processors in Asia, hence the "does not exist" error.
To fix this, I had to explicitly override the API endpoint when initializing the client:

const client = new DocumentProcessorServiceClient({
  apiEndpoint: 'asia-south1-documentai.googleapis.com',
});
Enter fullscreen mode Exit fullscreen mode

2. Strict gRPC Byte Transport

The Challenge:
When sending the PDF payload to Document AI, I initially converted the file to a base64 encoded string (a standard practice for JSON REST APIs). However, the API instantly rejected the request with INVALID_ARGUMENT.

The Fix:
The underlying transport for the Google Cloud Node SDK is heavily reliant on Protobuf and gRPC. The schema strictly expects a raw byte array for the document content, not a base64 encoded string. I resolved this by bypassing the string conversion and passing the raw Node Buffer directly:

const request = {
  name: processorName,
  rawDocument: {
    content: pdfBuffer, // Passed as raw Buffer, not base64 string
    mimeType: 'application/pdf',
  },
};
Enter fullscreen mode Exit fullscreen mode

3. Hitting the Document AI Synchronous Page Limits

The Challenge:
During testing, an 18-page textbook chapter completely crashed the pipeline with: Document pages exceed the limit: 15 got 18.
By default, the Document OCR processor restricts synchronous processing (processDocument) to a maximum of 15 pages to prevent timeout issues, forcing developers to use the asynchronous batch endpoint for larger files.

The Fix:
I didn't want to build a complex webhook-polling system for a synchronous UI just to process 3 extra pages. Digging into the API reference, I found a hidden gem: Imageless Mode. By injecting imagelessMode: true into the ProcessRequest, Document AI bypasses heavy image quality scoring, which effectively doubles the synchronous page limit to 30 pages!

const request = {
  // ...
  processOptions: {
    ocrConfig: {
      advancedOcrOptions: ['imagelessMode=true'] // Doubled the page limit!
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

4. Firestore Composite Vector Indexes

The Challenge:
Firestore now natively supports Vector Search! However, my RAG pipeline needed to filter document chunks by metadata before running the cosine similarity search:

const vectorQuery = coll
  .where('grade', '==', gradeLevel)
  .where('subject', '==', subject)
  .findNearest('embedding', FieldValue.vector(queryEmbedding), {
    limit: 5,
    distanceMeasure: 'COSINE',
  });
Enter fullscreen mode Exit fullscreen mode

This threw a FAILED_PRECONDITION error. While standard scalar queries can dynamically build indexes, Vector Search strictly requires a pre-built Composite Vector Index when combined with where clauses.

The Fix:
I had to drop into the gcloud CLI and construct a highly specific composite index command that included both the scalar fields (grade, subject) and the vector configuration:

gcloud firestore indexes composite create \
  --project=my-ai-project \
  --collection-group=document_chunks \
  --query-scope=COLLECTION \
  --field-config=order=ASCENDING,field-path=grade \
  --field-config=order=ASCENDING,field-path=subject \
  --field-config=vector-config='{"dimension":"768","flat": "{}"}',field-path=embedding
Enter fullscreen mode Exit fullscreen mode

Note: Building this index takes a few minutes, so grab a coffee after running the command!

5. Swapping ADK for the Native @google/genai SDK

The Challenge:
The original codebase utilized the Agent Development Kit (ADK) to stream the generation response. However, dealing with the internal, undocumented InvocationEvent schema of the InMemoryRunner caused unexpected TypeScript errors and silent failures (No content generated) when parsing the final text.

The Fix:
To ensure absolute robustness for production, I stripped out the complex ADK streaming loop and migrated the final generation step directly to the native @google/genai SDK. It took only 10 lines of code, completely bypassed the stream parsing nightmare, and natively supported the lightning-fast gemini-3.5-flash model.

import { GoogleGenAI } from '@google/genai';

const ai = new GoogleGenAI({ project: PROJECT_ID, location: REGION });

const response = await ai.models.generateContent({
  model: 'gemini-3.5-flash',
  contents: prompt,
});
return response.text;
Enter fullscreen mode Exit fullscreen mode

๐Ÿš€ The Final Result

Deploying this architecture to a fully serverless Next.js container on Cloud Run was incredibly rewarding. By combining Document AI's world-class OCR with Vertex AI's embedding models and Firestore's seamless Vector Search, the system can instantly generate hyper-relevant, grade-appropriate question papers backed directly by a teacher's syllabus!

If you're building RAG applications on Google Cloud, I highly recommend leveraging Firestore's native Vector Searchโ€”just remember to configure your API endpoints, pass your raw Buffers, and pre-build those composite indexes!

Have you built anything with the new Firestore Vector Search or Gemini? Drop a comment below!

Top comments (0)