Build a Full-Stack RAG Assistant

Build and deploy a PDF Q&A app using RAG, ChromaDB, and Google Gemini.

Introduction

30 Second Summary

Ever spent ages searching through a long PDF for one specific piece of information? What if an AI could read the whole document and answer your questions about it instantly?

In this project, you will build and deploy a full-stack Document Q&A Assistant that lets users upload a PDF and ask questions about it in natural language. The AI responds with accurate, grounded answers and source citations pulled directly from the document content.

What You'll Build

A live, deployed web application with a Python/FastAPI backend implementing a complete RAG pipeline and a React frontend with PDF upload and chat interface.

By the end of this project, you'll have:

  • A working RAG pipeline that loads documents, creates vector embeddings, and retrieves relevant information to generate answers with source citations.
  • A full-stack application with a Python/FastAPI REST API and React chat interface, deployed live on the internet using Render and Vercel at zero cost.
  • Hands-on experience with LlamaIndex, ChromaDB, and the Gemini API for document processing, vector search, and LLM-powered answer generation.
  • Secret Mission: Add conversation memory so the assistant remembers previous questions in the same session, enabling follow-up questions.

Are there any prerequisites?

You should have basic familiarity with Python and React/JavaScript. You also need Python, Node.js, Git, and a GitHub account installed and ready to go.

You will also need a Google account to get a free Gemini API key. No billing or payment is required.

Set Up Your Development Environment

Before you can build the RAG pipeline, you need a workspace with the right tools installed.

In this step, you'll create the project folder structure, install backend dependencies, grab a free Gemini API key, and scaffold the React frontend.

In this step, get ready to:
  • Create the project structure and install Python backend dependencies.
  • Get a free Gemini API key from Google AI Studio.
  • Scaffold the React frontend with Vite and install axios.
Create the project structure and Python backend environment

Your project needs two halves: a Python backend for the RAG pipeline and a React frontend for the user interface.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir doc-qa-assistant
cd doc-qa-assistant
  • Create the backend/ and frontend/ subdirectories by running:
mkdir backend frontend
  • Confirm the folders exist by running:
ls

You should see backend and frontend listed in the output.

  • Move into the backend/ folder and create a Python virtual environment by running:
cd backend
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python installation folder inside your project.

It keeps the packages for this project separate from your system Python, preventing conflicts with other projects.

  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt.

Don't see (venv) in your prompt?

On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell, then try the activate command again.

On macOS, make sure you typed source before the path. Without it, the shell tries to execute the script differently.

help me activate my Python virtual environment

  • Create the requirements.txt file that lists all backend dependencies by running:
cat > requirements.txt << 'EOF'
fastapi==0.115.12
uvicorn==0.34.2
python-dotenv==1.1.0
python-multipart==0.0.20
llama-index-core==0.12.37
llama-index-llms-gemini==0.4.12
llama-index-embeddings-gemini==0.3.4
llama-index-vector-stores-chroma==0.4.3
chromadb==1.0.7
pypdf==5.6.0
EOF

What does this file do?

The requirements.txt file pins every dependency to an exact version. This ensures every learner gets the same working environment regardless of when they run the project.

  • fastapi and uvicorn power the REST API server.
  • llama-index-core provides the RAG pipeline framework.
  • chromadb is the vector database that stores document embeddings.
  • pypdf reads PDF files so they can be indexed.
  • Install all backend dependencies by running:
pip install -r requirements.txt

This will download and install all ten packages and their sub-dependencies. It may take a minute or two.

Seeing installation errors?

Make sure you see (venv) in your terminal prompt. If you missed the activate step, packages install globally and may conflict.

If you see a permissions error, confirm you are inside the virtual environment. Never use sudo pip install to work around this.

help me fix pip install errors in my virtual environment

Get your free Gemini API key

The Gemini API powers both the text generation and the embedding steps of the RAG pipeline. Google offers a free tier with no billing required.

  • Open your browser and navigate to aistudio.google.com.
  • Sign in with your Google account.
  • For new users, Google AI Studio creates a default project and API key automatically after you accept the Terms of Service.
  • Click Get API Key in the left sidebar.
  • Click Create API Key.
  • Copy the generated key. You will only see it once at creation time. Store it somewhere safe temporarily.

Why is the Gemini API free?

Google offers a free tier for the Gemini API that requires no billing account. The trade-off is that data you send may be used to improve Google products, and there are rate limits on requests per minute.

For a portfolio project with low traffic, these limits are more than sufficient.

Having trouble creating the API key?

If you see a permission error, try signing out and back in with a personal Gmail account. Workspace accounts sometimes have restrictions.

If no project appears, click Import Projects in the top right corner of the API Keys page to link an existing Google Cloud project.

help me get my Gemini API key from Google AI Studio

  • Back in your terminal (still inside the backend/ folder), create a .env file to store your key by running:
cat > .env << 'EOF'
GEMINI_API_KEY=your-api-key-here
EOF
  • Open .env in a text editor and replace your-api-key-here with the API key you copied from Google AI Studio.
  • Save the file.

Why use a .env file?

The .env file keeps your API key out of your source code. Your backend reads it at runtime using the python-dotenv library. You will add .env to .gitignore later so it never gets pushed to GitHub.

  • Create a .env.example file so other developers know what variables are needed, without exposing your real key:
cat > .env.example << 'EOF'
GEMINI_API_KEY=your-api-key-here
EOF

This example file gets committed to Git as a reference for collaborators.

Scaffold the React frontend

The frontend gives users a visual interface to upload PDFs and ask questions. You'll use Vite to scaffold a React project quickly.

  • Move into the frontend/ directory by running:
cd ../frontend
  • Scaffold a new React project in the current folder by running:
npm create vite@latest . -- --template react

What does this command do?

  • npm create vite@latest downloads and runs the Vite project scaffolding tool.
  • . tells it to scaffold into the current directory instead of creating a new folder.
  • -- --template react selects the React template so you get a working React starter with no extra configuration.

Vite may ask you to confirm scaffolding in a non-empty directory. Type y and press Enter to continue.

  • Install the default project dependencies by running:
npm install
  • Install axios for making API calls to the backend by running:
npm install axios

You should see a success message with no errors. Your package.json now includes axios as a dependency.

Seeing npm errors?

Confirm Node.js is installed by running node --version in your terminal. If you see a version number, Node.js is ready.

If the Vite scaffold command fails, make sure you are inside the frontend/ folder and that it is empty (no leftover files from a previous attempt).

help me fix npm errors when scaffolding a Vite React project

✔️ Awesome, I've got everything!

Great. Double check you've saved your .env file in the backend/ folder with your real API key.

ⓧ I'd like to double check the full code

Here is what your backend/requirements.txt should look like:

fastapi==0.115.12
uvicorn==0.34.2
python-dotenv==1.1.0
python-multipart==0.0.20
llama-index-core==0.12.37
llama-index-llms-gemini==0.4.12
llama-index-embeddings-gemini==0.3.4
llama-index-vector-stores-chroma==0.4.3
chromadb==1.0.7
pypdf==5.6.0

Your backend/.env.example should contain:

GEMINI_API_KEY=your-api-key-here

Your backend/.env should contain your real key:

GEMINI_API_KEY=<your-real-key-here>

Your development environment is ready. Next up, you'll build the RAG backend that ingests PDF documents and answers questions about them.

Build the RAG Backend

Your development environment is ready with all the dependencies installed. Now it's time to build the brain of your application.

The RAG pipeline is what makes your Q&A assistant intelligent. It processes uploaded documents, stores them as searchable vectors, and retrieves relevant context when a user asks a question.

In this step, get ready to:
  • Create the FastAPI application with AI and vector storage configuration.
  • Build a document upload endpoint that indexes PDFs into ChromaDB.
  • Build a query endpoint that retrieves relevant context and generates answers.
Create the FastAPI application shell

Your backend needs a web server that can accept requests from the React frontend. FastAPI is a modern Python framework that handles this with built-in support for async operations and automatic API documentation.

  • Create a new file called main.py inside your backend/ folder.
  • Add the imports, app initialization, and CORS middleware by pasting this code into main.py:
import os
import uuid
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, StorageContext, Settings
from llama_index.vector_stores.chroma import ChromaVectorStore
from llama_index.llms.gemini import Gemini
from llama_index.embeddings.gemini import GeminiEmbedding
import chromadb

# Load environment variables from .env
load_dotenv()

# Create the FastAPI app
app = FastAPI(title="Document Q&A API")

# Allow the React frontend to make requests to this backend
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

What does this code do?

The imports bring in FastAPI for the web server, LlamaIndex for the RAG pipeline, and ChromaDB for vector storage.

load_dotenv() reads your .env file so the app can access your Gemini API key. The CORSMiddleware allows your React frontend (running on a different port) to communicate with this backend.

  • Now add the AI configuration, vector database setup, data models, and health check endpoint below the CORS middleware:
# Configure LlamaIndex settings
Settings.llm = Gemini(model="models/gemini-2.5-flash", api_key=os.getenv("GEMINI_API_KEY"))
Settings.embed_model = GeminiEmbedding(model_name="models/text-embedding-004", api_key=os.getenv("GEMINI_API_KEY"))

# Initialize ChromaDB
chroma_client = chromadb.Client()
chroma_collection = chroma_client.get_or_create_collection("documents")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)

# Store the index globally
index = None

UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)


class QueryRequest(BaseModel):
    question: str


class QueryResponse(BaseModel):
    answer: str
    sources: list[str]


@app.get("/health")
def health_check():
    return {"status": "healthy"}

What does this code do?

Settings.llm tells LlamaIndex to use Gemini 2.5 Flash for generating answers. Settings.embed_model tells it to use Gemini's text embedding model for converting text into vectors.

ChromaDB creates an in-memory vector database with a collection called "documents". The QueryRequest and QueryResponse models define the shape of data the API accepts and returns.

  • Save main.py.
  • Test the health endpoint by starting the server with this command from inside your backend/ folder:
uvicorn main:app --reload
  • Open your browser and navigate to http://127.0.0.1:8000/health. You should see {"status":"healthy"} in the browser.
  • Press Ctrl+C in your terminal to stop the server for now.

Server won't start?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • Check that your .env file exists in the backend/ folder with a valid GEMINI_API_KEY value.
  • Confirm you are running the command from inside the backend/ directory, not the project root.

Help me troubleshoot why my FastAPI server isn't starting with uvicorn

Implement the document ingestion pipeline

When a user uploads a PDF, your backend needs to parse it, break it into chunks, convert those chunks into vector embeddings, and store them in ChromaDB. This is the indexing half of the RAG pipeline.

  • Add the /upload endpoint below the health_check function in main.py:
@app.post("/upload")
async def upload_document(file: UploadFile = File(...)):
    global index

    # Only accept PDF files
    if not file.filename.endswith(".pdf"):
        raise HTTPException(status_code=400, detail="Only PDF files are supported")

    # Save the uploaded file to disk
    file_path = UPLOAD_DIR / f"{uuid.uuid4()}_{file.filename}"
    content = await file.read()
    file_path.write_bytes(content)

    try:
        # Parse the PDF and split it into document chunks
        documents = SimpleDirectoryReader(input_files=[str(file_path)]).load_data()
        # Create a searchable vector index from the chunks
        index = VectorStoreIndex.from_documents(
            documents, storage_context=storage_context
        )
        return {"message": f"Successfully indexed {file.filename}", "pages": len(documents)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error processing document: {str(e)}")

What does this code do?

The endpoint saves the uploaded PDF to an uploads/ folder with a unique filename (using UUID to avoid collisions). Then SimpleDirectoryReader parses the PDF into text chunks.

VectorStoreIndex.from_documents() is where the magic happens. It takes each chunk, sends it to Gemini's embedding model to get a vector representation, and stores those vectors in ChromaDB. This creates a searchable index over the document.

What is a vector embedding?

A vector embedding is a list of numbers that represents the meaning of a piece of text. Similar ideas produce similar numbers. When a user asks a question, the system converts it into a vector and finds the document chunks with the most similar vectors.

  • Save main.py.

You won't test this endpoint yet. The next chunk adds the query endpoint, and you will test the full flow in Step 4.

Implement the query endpoint

The second half of the RAG pipeline is retrieval and generation. When a user asks a question, the backend finds the most relevant document chunks and sends them to Gemini along with the question.

  • Add the /query endpoint below the upload_document function in main.py:
@app.post("/query", response_model=QueryResponse)
async def query_document(request: QueryRequest):
    global index

    # Ensure a document has been uploaded first
    if index is None:
        raise HTTPException(status_code=400, detail="No document has been uploaded yet")

    # Create a query engine that retrieves the top 3 most relevant chunks
    query_engine = index.as_query_engine(similarity_top_k=3)
    response = query_engine.query(request.question)

    # Extract source text from the retrieved chunks
    sources = []
    for node in response.source_nodes:
        source_text = node.node.text[:200] + "..." if len(node.node.text) > 200 else node.node.text
        sources.append(source_text)

    return QueryResponse(answer=str(response), sources=sources)

What does this code do?

index.as_query_engine(similarity_top_k=3) creates an engine that, for each question, finds the 3 document chunks whose vectors are most similar to the question's vector. This is semantic search.

LlamaIndex then sends those chunks plus the user's question to Gemini, which generates a grounded answer. The sources list gives the user visibility into which parts of the document the answer came from.

Why top 3 chunks?

Retrieving 3 chunks balances relevance and context window size. Too few chunks might miss important information. Too many dilute the signal and use more tokens.

  • Save main.py.

Your complete backend is now written. You will test it end-to-end in Step 4.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py in the backend/ folder.

ⓧ I'd like to double check the full code

Here is the complete backend/main.py file. Compare it against yours:

import os
import uuid
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, StorageContext, Settings
from llama_index.vector_stores.chroma import ChromaVectorStore
from llama_index.llms.gemini import Gemini
from llama_index.embeddings.gemini import GeminiEmbedding
import chromadb

load_dotenv()

app = FastAPI(title="Document Q&A API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Settings.llm = Gemini(model="models/gemini-2.5-flash", api_key=os.getenv("GEMINI_API_KEY"))
Settings.embed_model = GeminiEmbedding(model_name="models/text-embedding-004", api_key=os.getenv("GEMINI_API_KEY"))

chroma_client = chromadb.Client()
chroma_collection = chroma_client.get_or_create_collection("documents")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)

index = None

UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)


class QueryRequest(BaseModel):
    question: str


class QueryResponse(BaseModel):
    answer: str
    sources: list[str]


@app.get("/health")
def health_check():
    return {"status": "healthy"}


@app.post("/upload")
async def upload_document(file: UploadFile = File(...)):
    global index

    if not file.filename.endswith(".pdf"):
        raise HTTPException(status_code=400, detail="Only PDF files are supported")

    file_path = UPLOAD_DIR / f"{uuid.uuid4()}_{file.filename}"
    content = await file.read()
    file_path.write_bytes(content)

    try:
        documents = SimpleDirectoryReader(input_files=[str(file_path)]).load_data()
        index = VectorStoreIndex.from_documents(
            documents, storage_context=storage_context
        )
        return {"message": f"Successfully indexed {file.filename}", "pages": len(documents)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error processing document: {str(e)}")


@app.post("/query", response_model=QueryResponse)
async def query_document(request: QueryRequest):
    global index

    if index is None:
        raise HTTPException(status_code=400, detail="No document has been uploaded yet")

    query_engine = index.as_query_engine(similarity_top_k=3)
    response = query_engine.query(request.question)

    sources = []
    for node in response.source_nodes:
        source_text = node.node.text[:200] + "..." if len(node.node.text) > 200 else node.node.text
        sources.append(source_text)

    return QueryResponse(answer=str(response), sources=sources)

Your backend is complete. It can accept PDF uploads, index them into searchable vectors, and answer questions using Gemini. Next up, you will build the React frontend that gives users a visual interface to upload documents and chat with them.

Build the React Frontend

Your backend is ready to accept PDFs and answer questions about them. Now you need something for users to actually interact with.

In this step, you will build a React frontend with two main views: a file upload screen and a chat interface. The app will switch between them automatically once a document is indexed.

In this step, get ready to:
  • Create a file upload component with PDF validation and progress feedback.
  • Build a chat interface with message history and source citations.
  • Wire everything together in App.jsx with conditional rendering and full styling.
Create the file upload component

The first screen the user sees is an upload box. This component needs to accept a PDF file, send it to the backend's /upload endpoint, and show progress while the document is being indexed.

  • Create a components folder inside frontend/src/ by running this command from your frontend directory:
mkdir src/components
  • Create a new file called FileUpload.jsx inside frontend/src/components/ and paste this code:
import { useState } from "react";
import axios from "axios";

const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";

function FileUpload({ onUploadSuccess }) {
  const [isUploading, setIsUploading] = useState(false);
  const [error, setError] = useState("");

  const handleFileChange = async (e) => {
    const file = e.target.files[0];
    if (!file) return;

    if (!file.name.endsWith(".pdf")) {
      setError("Please upload a PDF file");
      return;
    }

    setIsUploading(true);
    setError("");

    const formData = new FormData();
    formData.append("file", file);

    try {
      await axios.post(`${API_URL}/upload`, formData, {
        headers: { "Content-Type": "multipart/form-data" },
      });
      onUploadSuccess(file.name);
    } catch (err) {
      setError(err.response?.data?.detail || "Upload failed. Please try again.");
    } finally {
      setIsUploading(false);
    }
  };

  return (
    <div className="upload-container">
      <div className="upload-box">
        <p className="upload-icon">📁</p>
        <p>Drop your PDF here or click to browse</p>
        <input
          type="file"
          accept=".pdf"
          onChange={handleFileChange}
          disabled={isUploading}
          className="file-input"
        />
        {isUploading && <p className="upload-status">Indexing document...</p>}
        {error && <p className="upload-error">{error}</p>}
      </div>
    </div>
  );
}

export default FileUpload;

What does this component do?

  • API_URL reads from a VITE_API_URL environment variable, falling back to http://localhost:8000 for local development.
  • handleFileChange validates the file is a PDF, then sends it to your backend's /upload endpoint using FormData.
  • The invisible file-input overlays the styled box, so clicking anywhere in the box triggers the file picker.
  • On success, it calls onUploadSuccess to tell the parent app to switch to the chat view.
  • Save FileUpload.jsx.

You will not see a visual change yet because App.jsx does not import this component. That comes in substep 3.

Seeing a red squiggly under the import?

If your editor shows an error on import axios from "axios", make sure you ran npm install axios from inside the frontend/ folder in Step 1.

Help me fix my axios import error

Create the chat interface

Once a document is indexed, users need a way to ask questions and read answers. This component renders a scrollable message history, handles sending questions to the backend, and displays source citations under each AI response.

  • Create a new file called Chat.jsx inside frontend/src/components/ and paste this code:
import { useState } from "react";
import axios from "axios";

const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";

function Chat({ documentName }) {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  const handleSend = async () => {
    if (!input.trim() || isLoading) return;

    const userMessage = { role: "user", content: input };
    setMessages((prev) => [...prev, userMessage]);
    setInput("");
    setIsLoading(true);

    try {
      const response = await axios.post(`${API_URL}/query`, {
        question: input,
      });

      const aiMessage = {
        role: "assistant",
        content: response.data.answer,
        sources: response.data.sources,
      };
      setMessages((prev) => [...prev, aiMessage]);
    } catch (err) {
      const errorMessage = {
        role: "assistant",
        content: "Sorry, something went wrong. Please try again.",
        sources: [],
      };
      setMessages((prev) => [...prev, errorMessage]);
    } finally {
      setIsLoading(false);
    }
  };

  const handleKeyPress = (e) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="chat-container">
      <div className="chat-header">
        <p>Chatting with: <strong>{documentName}</strong></p>
      </div>
      <div className="chat-messages">
        {messages.length === 0 && (
          <p className="chat-placeholder">Ask a question about your document...</p>
        )}
        {messages.map((msg, idx) => (
          <div key={idx} className={`message ${msg.role}`}>
            <p className="message-content">{msg.content}</p>
            {msg.sources && msg.sources.length > 0 && (
              <div className="message-sources">
                <p className="sources-label">Sources:</p>
                {msg.sources.map((source, sIdx) => (
                  <p key={sIdx} className="source-item">{source}</p>
                ))}
              </div>
            )}
          </div>
        ))}
        {isLoading && (
          <div className="message assistant">
            <p className="message-content loading">Thinking...</p>
          </div>
        )}
      </div>
      <div className="chat-input">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleKeyPress}
          placeholder="Ask a question..."
          disabled={isLoading}
        />
        <button onClick={handleSend} disabled={isLoading || !input.trim()}>
          Send
        </button>
      </div>
    </div>
  );
}

export default Chat;

What does this component do?

  • handleSend adds the user's question to the message list, posts it to the backend's /query endpoint, then appends the AI response (including source citations).
  • handleKeyPress lets users press Enter to send instead of clicking the button.
  • Each assistant message checks for a sources array and renders document excerpts so users can see where the answer came from.
  • If the API call fails, a friendly error message appears in the chat instead of crashing the UI.
  • Save Chat.jsx.
  • Confirm both files exist by running this from the frontend directory:
ls src/components

You should see Chat.jsx and FileUpload.jsx listed.

Missing a file?

Make sure you created both files inside frontend/src/components/ and not in frontend/src/ directly. The folder path matters for the imports in the next substep.

Help me find my missing component files

Wire up the app and add styling

The two components exist, but nothing imports them yet. You need an App.jsx that manages the application state and decides which component to show based on whether a document has been uploaded.

  • Open frontend/src/App.jsx in your editor and replace its entire contents with this code:
import { useState } from "react";
import FileUpload from "./components/FileUpload";
import Chat from "./components/Chat";
import "./App.css";

function App() {
  const [isDocumentUploaded, setIsDocumentUploaded] = useState(false);
  const [documentName, setDocumentName] = useState("");

  const handleUploadSuccess = (fileName) => {
    setIsDocumentUploaded(true);
    setDocumentName(fileName);
  };

  return (
    <div className="app">
      <header className="app-header">
        <h1>📄 Document Q&A Assistant</h1>
        <p>Upload a PDF and ask questions about it</p>
      </header>
      <main className="app-main">
        {!isDocumentUploaded ? (
          <FileUpload onUploadSuccess={handleUploadSuccess} />
        ) : (
          <Chat documentName={documentName} />
        )}
      </main>
    </div>
  );
}

export default App;

How does the conditional rendering work?

isDocumentUploaded starts as false, so the app shows FileUpload first.

When the upload succeeds, handleUploadSuccess flips the state to true and stores the file name. React re-renders, and the user now sees the Chat component with the document name displayed in the header.

  • Save App.jsx.

Now add the full stylesheet so both components look polished.

  • Open frontend/src/App.css and replace its entire contents with this code:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #f5f5f5;
  color: #333;
}

.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem 1rem;
  min-height: 100vh;
}

.app-header {
  text-align: center;
  margin-bottom: 2rem;
}

.app-header h1 {
  font-size: 1.8rem;
  margin-bottom: 0.5rem;
}

.app-header p {
  color: #666;
}

.upload-container {
  display: flex;
  justify-content: center;
  padding: 2rem;
}

.upload-box {
  border: 2px dashed #ccc;
  border-radius: 12px;
  padding: 3rem 2rem;
  text-align: center;
  width: 100%;
  max-width: 500px;
  position: relative;
  cursor: pointer;
  transition: border-color 0.2s;
}

.upload-box:hover {
  border-color: #4a90d9;
}

.upload-icon {
  font-size: 3rem;
  margin-bottom: 1rem;
}

.file-input {
  position: absolute;
  inset: 0;
  opacity: 0;
  cursor: pointer;
}

.upload-status {
  margin-top: 1rem;
  color: #4a90d9;
  font-weight: 500;
}

.upload-error {
  margin-top: 1rem;
  color: #e74c3c;
}

.chat-container {
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  display: flex;
  flex-direction: column;
  height: 600px;
}

.chat-header {
  padding: 1rem;
  border-bottom: 1px solid #eee;
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
}

.chat-placeholder {
  text-align: center;
  color: #999;
  margin-top: 4rem;
}

.message {
  margin-bottom: 1rem;
  padding: 0.75rem 1rem;
  border-radius: 8px;
  max-width: 85%;
}

.message.user {
  background: #4a90d9;
  color: white;
  margin-left: auto;
}

.message.assistant {
  background: #f0f0f0;
}

.message-content.loading {
  font-style: italic;
  color: #999;
}

.message-sources {
  margin-top: 0.5rem;
  padding-top: 0.5rem;
  border-top: 1px solid #ddd;
}

.sources-label {
  font-size: 0.75rem;
  font-weight: 600;
  color: #666;
  margin-bottom: 0.25rem;
}

.source-item {
  font-size: 0.75rem;
  color: #888;
  padding: 0.25rem 0;
  border-left: 2px solid #4a90d9;
  padding-left: 0.5rem;
  margin-bottom: 0.25rem;
}

.chat-input {
  display: flex;
  gap: 0.5rem;
  padding: 1rem;
  border-top: 1px solid #eee;
}

.chat-input input {
  flex: 1;
  padding: 0.75rem 1rem;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 1rem;
  outline: none;
}

.chat-input input:focus {
  border-color: #4a90d9;
}

.chat-input button {
  padding: 0.75rem 1.5rem;
  background: #4a90d9;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
  transition: background 0.2s;
}

.chat-input button:hover:not(:disabled) {
  background: #357abd;
}

.chat-input button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

What do these styles do?

  • The .upload-box creates a dashed border drop zone that highlights blue on hover.
  • The .file-input is positioned absolutely and made invisible so the entire box is clickable.
  • The .chat-container uses flexbox with a fixed height of 600px so the message area scrolls while the input stays pinned at the bottom.
  • User messages align right with a blue background. Assistant messages align left with a grey background.
  • Save App.css.

You will not see the app running yet. In the next step, you will start both servers and test the full upload-to-answer flow.

Editor showing import errors in App.jsx?

If you see red underlines on the import FileUpload or import Chat lines, check that both component files are saved inside frontend/src/components/ with the exact filenames FileUpload.jsx and Chat.jsx.

Help me fix my component import errors

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: FileUpload.jsx, Chat.jsx, App.jsx, and App.css.

ⓧ I'd like to double check the full code

Here are the complete files for comparison.

import { useState } from "react";
import FileUpload from "./components/FileUpload";
import Chat from "./components/Chat";
import "./App.css";

function App() {
  const [isDocumentUploaded, setIsDocumentUploaded] = useState(false);
  const [documentName, setDocumentName] = useState("");

  const handleUploadSuccess = (fileName) => {
    setIsDocumentUploaded(true);
    setDocumentName(fileName);
  };

  return (
    <div className="app">
      <header className="app-header">
        <h1>📄 Document Q&A Assistant</h1>
        <p>Upload a PDF and ask questions about it</p>
      </header>
      <main className="app-main">
        {!isDocumentUploaded ? (
          <FileUpload onUploadSuccess={handleUploadSuccess} />
        ) : (
          <Chat documentName={documentName} />
        )}
      </main>
    </div>
  );
}

export default App;
import { useState } from "react";
import axios from "axios";

const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";

function FileUpload({ onUploadSuccess }) {
  const [isUploading, setIsUploading] = useState(false);
  const [error, setError] = useState("");

  const handleFileChange = async (e) => {
    const file = e.target.files[0];
    if (!file) return;

    if (!file.name.endsWith(".pdf")) {
      setError("Please upload a PDF file");
      return;
    }

    setIsUploading(true);
    setError("");

    const formData = new FormData();
    formData.append("file", file);

    try {
      await axios.post(`${API_URL}/upload`, formData, {
        headers: { "Content-Type": "multipart/form-data" },
      });
      onUploadSuccess(file.name);
    } catch (err) {
      setError(err.response?.data?.detail || "Upload failed. Please try again.");
    } finally {
      setIsUploading(false);
    }
  };

  return (
    <div className="upload-container">
      <div className="upload-box">
        <p className="upload-icon">📁</p>
        <p>Drop your PDF here or click to browse</p>
        <input
          type="file"
          accept=".pdf"
          onChange={handleFileChange}
          disabled={isUploading}
          className="file-input"
        />
        {isUploading && <p className="upload-status">Indexing document...</p>}
        {error && <p className="upload-error">{error}</p>}
      </div>
    </div>
  );
}

export default FileUpload;
import { useState } from "react";
import axios from "axios";

const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";

function Chat({ documentName }) {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  const handleSend = async () => {
    if (!input.trim() || isLoading) return;

    const userMessage = { role: "user", content: input };
    setMessages((prev) => [...prev, userMessage]);
    setInput("");
    setIsLoading(true);

    try {
      const response = await axios.post(`${API_URL}/query`, {
        question: input,
      });

      const aiMessage = {
        role: "assistant",
        content: response.data.answer,
        sources: response.data.sources,
      };
      setMessages((prev) => [...prev, aiMessage]);
    } catch (err) {
      const errorMessage = {
        role: "assistant",
        content: "Sorry, something went wrong. Please try again.",
        sources: [],
      };
      setMessages((prev) => [...prev, errorMessage]);
    } finally {
      setIsLoading(false);
    }
  };

  const handleKeyPress = (e) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="chat-container">
      <div className="chat-header">
        <p>Chatting with: <strong>{documentName}</strong></p>
      </div>
      <div className="chat-messages">
        {messages.length === 0 && (
          <p className="chat-placeholder">Ask a question about your document...</p>
        )}
        {messages.map((msg, idx) => (
          <div key={idx} className={`message ${msg.role}`}>
            <p className="message-content">{msg.content}</p>
            {msg.sources && msg.sources.length > 0 && (
              <div className="message-sources">
                <p className="sources-label">Sources:</p>
                {msg.sources.map((source, sIdx) => (
                  <p key={sIdx} className="source-item">{source}</p>
                ))}
              </div>
            )}
          </div>
        ))}
        {isLoading && (
          <div className="message assistant">
            <p className="message-content loading">Thinking...</p>
          </div>
        )}
      </div>
      <div className="chat-input">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleKeyPress}
          placeholder="Ask a question..."
          disabled={isLoading}
        />
        <button onClick={handleSend} disabled={isLoading || !input.trim()}>
          Send
        </button>
      </div>
    </div>
  );
}

export default Chat;
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #f5f5f5;
  color: #333;
}

.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem 1rem;
  min-height: 100vh;
}

.app-header {
  text-align: center;
  margin-bottom: 2rem;
}

.app-header h1 {
  font-size: 1.8rem;
  margin-bottom: 0.5rem;
}

.app-header p {
  color: #666;
}

.upload-container {
  display: flex;
  justify-content: center;
  padding: 2rem;
}

.upload-box {
  border: 2px dashed #ccc;
  border-radius: 12px;
  padding: 3rem 2rem;
  text-align: center;
  width: 100%;
  max-width: 500px;
  position: relative;
  cursor: pointer;
  transition: border-color 0.2s;
}

.upload-box:hover {
  border-color: #4a90d9;
}

.upload-icon {
  font-size: 3rem;
  margin-bottom: 1rem;
}

.file-input {
  position: absolute;
  inset: 0;
  opacity: 0;
  cursor: pointer;
}

.upload-status {
  margin-top: 1rem;
  color: #4a90d9;
  font-weight: 500;
}

.upload-error {
  margin-top: 1rem;
  color: #e74c3c;
}

.chat-container {
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  display: flex;
  flex-direction: column;
  height: 600px;
}

.chat-header {
  padding: 1rem;
  border-bottom: 1px solid #eee;
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
}

.chat-placeholder {
  text-align: center;
  color: #999;
  margin-top: 4rem;
}

.message {
  margin-bottom: 1rem;
  padding: 0.75rem 1rem;
  border-radius: 8px;
  max-width: 85%;
}

.message.user {
  background: #4a90d9;
  color: white;
  margin-left: auto;
}

.message.assistant {
  background: #f0f0f0;
}

.message-content.loading {
  font-style: italic;
  color: #999;
}

.message-sources {
  margin-top: 0.5rem;
  padding-top: 0.5rem;
  border-top: 1px solid #ddd;
}

.sources-label {
  font-size: 0.75rem;
  font-weight: 600;
  color: #666;
  margin-bottom: 0.25rem;
}

.source-item {
  font-size: 0.75rem;
  color: #888;
  padding: 0.25rem 0;
  border-left: 2px solid #4a90d9;
  padding-left: 0.5rem;
  margin-bottom: 0.25rem;
}

.chat-input {
  display: flex;
  gap: 0.5rem;
  padding: 1rem;
  border-top: 1px solid #eee;
}

.chat-input input {
  flex: 1;
  padding: 0.75rem 1rem;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 1rem;
  outline: none;
}

.chat-input input:focus {
  border-color: #4a90d9;
}

.chat-input button {
  padding: 0.75rem 1.5rem;
  background: #4a90d9;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
  transition: background 0.2s;
}

.chat-input button:hover:not(:disabled) {
  background: #357abd;
}

.chat-input button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

Your frontend is fully built. Next up, you will run both servers together and test the complete upload-to-answer pipeline.

Test Locally and Prepare for Deployment

You have a complete backend API and a polished React frontend. Now it's time to run both together and confirm the full pipeline works: upload a PDF, ask a question, and get a grounded answer with source citations.

Once you've verified everything locally, you'll prepare both apps for cloud deployment. This means confirming your dependency list, configuring environment-driven settings, and building a production-ready frontend bundle.

In this step, get ready to:
  • Run and test the full application end-to-end locally.
  • Confirm the backend is ready for Render deployment.
  • Build the frontend production bundle for Vercel deployment.
Run and test the full application locally

You need two processes running simultaneously: the FastAPI backend serving your API, and the Vite dev server rendering your React frontend. Each runs in its own terminal window.

  • Switch back to your terminal where the virtual environment is active.
  • Navigate into the backend/ directory and start the FastAPI server by running this command:
cd backend
uvicorn main:app --reload

What does this command do?

uvicorn main:app tells Uvicorn to look for the app object inside main.py. The --reload flag auto-restarts the server when you edit files, which is useful during development.

  • You should see output indicating the server is running on http://127.0.0.1:8000.

Server won't start?

Make sure your virtual environment is active (you should see the environment name in your terminal prompt). Also confirm you are inside the backend/ directory, not the project root.

If you see an import error, run pip install -r requirements.txt again to reinstall missing packages.

Help me debug why my FastAPI server won't start.

Now you need a second terminal for the frontend. Keep the backend running.

  • Open a new terminal window or tab.
  • Navigate into the frontend/ directory and start the React dev server by running:
cd frontend
npm run dev
  • You should see output showing the app is running at http://localhost:5173.
  • Open http://localhost:5173 in your browser.

You should see the Document Q&A Assistant interface with the file upload box.

Seeing a blank page or connection error?

Make sure both terminals are running. The backend must be on port 8000 and the frontend on port 5173. If port 5173 was already in use, Vite picks the next available port and shows it in the terminal output.

Help me debug my React frontend not connecting to the backend.

Time to test the full flow. You need any PDF file for this test. A short document (1-5 pages) works best for a quick test.

  • Click anywhere on the upload box and select a PDF file from your computer.
  • Wait for the "Indexing document..." message to disappear. The chat interface should appear with the document name displayed.
  • Type a question about your document in the input field and press Enter.
  • Verify that you receive an AI-generated answer with source citations displayed below it.

What should I see?

After uploading, the page transitions from the upload box to a chat interface. Your question appears as a blue bubble on the right, and the AI response appears as a grey bubble on the left with "Sources:" listed underneath.

The first query may take a few seconds as Gemini processes the request. Subsequent queries are typically faster.

Upload fails or no answer appears?

Check your backend terminal for error messages. A common issue is an invalid or missing GEMINI_API_KEY in your backend/.env file. Make sure the key has no extra spaces or quotes around it.

If you see a rate limit error, the Gemini free tier allows 10 requests per minute. Wait a moment and try again.

Help me debug why my RAG query is failing.

Prepare the backend for Render deployment

Render needs to know how to install your dependencies and start your application. You already have a requirements.txt with pinned versions. Now you need to confirm the start command that Render will use.

  • Open backend/requirements.txt and confirm it contains all your dependencies with pinned versions:
fastapi==0.115.12
uvicorn==0.34.2
python-dotenv==1.1.0
python-multipart==0.0.20
llama-index-core==0.12.37
llama-index-llms-gemini==0.4.12
llama-index-embeddings-gemini==0.3.4
llama-index-vector-stores-chroma==0.4.3
chromadb==1.0.7
pypdf==5.6.0

Why pin exact versions?

Pinning versions (using ==) ensures Render installs the exact same packages you tested locally. Without pinning, a newer release could introduce breaking changes that crash your deployed app.

Render's free tier runs your app using a start command that you'll configure in the Render dashboard. The command tells Uvicorn to bind to a port that Render assigns dynamically through the $PORT environment variable.

  • Make note of these two commands you'll use when configuring Render in the next step:
# Build command (Render runs this first to install dependencies)
pip install -r requirements.txt

# Start command (Render runs this to launch your app)
uvicorn main:app --host 0.0.0.0 --port $PORT

Why --host 0.0.0.0 and --port $PORT?

--host 0.0.0.0 makes your server accessible from outside the container (required for Render to route traffic to it). --port $PORT reads the port number Render assigns at runtime. The default is 10000 for Render web services.

Prepare the frontend for Vercel deployment

Your frontend components already read the API URL from the VITE_API_URL environment variable. Locally it falls back to http://localhost:8000, but on Vercel you'll set it to your deployed Render backend URL.

  • Open frontend/src/components/FileUpload.jsx and confirm you see this line near the top:
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";

How does this work for deployment?

Vite bakes environment variables prefixed with VITE_ into the production bundle at build time. When you set VITE_API_URL on Vercel, the build replaces import.meta.env.VITE_API_URL with your actual Render backend URL.

Now verify the production build compiles without errors.

  • In your frontend terminal, stop the dev server by pressing Ctrl+C.
  • Build the production bundle by running:
npm run build
  • You should see output showing the build completed successfully, with a dist/ folder created containing the optimized production files.
  • Confirm the build output by running:
ls dist
  • You should see index.html and an assets/ folder in the output.

Build failed?

Check the error output carefully. Common causes include a typo in an import path or a missing closing bracket in one of your components. The error message names the exact file and line number where the issue is.

Help me fix my React production build error.

✔️ Awesome, I've got everything!

Great. Make sure you've saved all files and that both your backend and frontend code are ready to push to GitHub.

ⓧ I'd like to double check the full code

Here is the complete backend/requirements.txt for reference:

fastapi==0.115.12
uvicorn==0.34.2
python-dotenv==1.1.0
python-multipart==0.0.20
llama-index-core==0.12.37
llama-index-llms-gemini==0.4.12
llama-index-embeddings-gemini==0.3.4
llama-index-vector-stores-chroma==0.4.3
chromadb==1.0.7
pypdf==5.6.0

Here is the complete backend/main.py for reference:

import os
import uuid
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, StorageContext, Settings
from llama_index.vector_stores.chroma import ChromaVectorStore
from llama_index.llms.gemini import Gemini
from llama_index.embeddings.gemini import GeminiEmbedding
import chromadb

load_dotenv()

app = FastAPI(title="Document Q&A API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Settings.llm = Gemini(model="models/gemini-2.5-flash", api_key=os.getenv("GEMINI_API_KEY"))
Settings.embed_model = GeminiEmbedding(model_name="models/text-embedding-004", api_key=os.getenv("GEMINI_API_KEY"))

chroma_client = chromadb.Client()
chroma_collection = chroma_client.get_or_create_collection("documents")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)

index = None

UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)


class QueryRequest(BaseModel):
    question: str


class QueryResponse(BaseModel):
    answer: str
    sources: list[str]


@app.get("/health")
def health_check():
    return {"status": "healthy"}


@app.post("/upload")
async def upload_document(file: UploadFile = File(...)):
    global index

    if not file.filename.endswith(".pdf"):
        raise HTTPException(status_code=400, detail="Only PDF files are supported")

    file_path = UPLOAD_DIR / f"{uuid.uuid4()}_{file.filename}"
    content = await file.read()
    file_path.write_bytes(content)

    try:
        documents = SimpleDirectoryReader(input_files=[str(file_path)]).load_data()
        index = VectorStoreIndex.from_documents(
            documents, storage_context=storage_context
        )
        return {"message": f"Successfully indexed {file.filename}", "pages": len(documents)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error processing document: {str(e)}")


@app.post("/query", response_model=QueryResponse)
async def query_document(request: QueryRequest):
    global index

    if index is None:
        raise HTTPException(status_code=400, detail="No document has been uploaded yet")

    query_engine = index.as_query_engine(similarity_top_k=3)
    response = query_engine.query(request.question)

    sources = []
    for node in response.source_nodes:
        source_text = node.node.text[:200] + "..." if len(node.node.text) > 200 else node.node.text
        sources.append(source_text)

    return QueryResponse(answer=str(response), sources=sources)

Here is the complete frontend/src/App.jsx for reference:

import { useState } from "react";
import FileUpload from "./components/FileUpload";
import Chat from "./components/Chat";
import "./App.css";

function App() {
  const [isDocumentUploaded, setIsDocumentUploaded] = useState(false);
  const [documentName, setDocumentName] = useState("");

  const handleUploadSuccess = (fileName) => {
    setIsDocumentUploaded(true);
    setDocumentName(fileName);
  };

  return (
    <div className="app">
      <header className="app-header">
        <h1>📄 Document Q&A Assistant</h1>
        <p>Upload a PDF and ask questions about it</p>
      </header>
      <main className="app-main">
        {!isDocumentUploaded ? (
          <FileUpload onUploadSuccess={handleUploadSuccess} />
        ) : (
          <Chat documentName={documentName} />
        )}
      </main>
    </div>
  );
}

export default App;

Here is the complete frontend/src/components/FileUpload.jsx for reference:

import { useState } from "react";
import axios from "axios";

const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";

function FileUpload({ onUploadSuccess }) {
  const [isUploading, setIsUploading] = useState(false);
  const [error, setError] = useState("");

  const handleFileChange = async (e) => {
    const file = e.target.files[0];
    if (!file) return;

    if (!file.name.endsWith(".pdf")) {
      setError("Please upload a PDF file");
      return;
    }

    setIsUploading(true);
    setError("");

    const formData = new FormData();
    formData.append("file", file);

    try {
      await axios.post(`${API_URL}/upload`, formData, {
        headers: { "Content-Type": "multipart/form-data" },
      });
      onUploadSuccess(file.name);
    } catch (err) {
      setError(err.response?.data?.detail || "Upload failed. Please try again.");
    } finally {
      setIsUploading(false);
    }
  };

  return (
    <div className="upload-container">
      <div className="upload-box">
        <p className="upload-icon">📁</p>
        <p>Drop your PDF here or click to browse</p>
        <input
          type="file"
          accept=".pdf"
          onChange={handleFileChange}
          disabled={isUploading}
          className="file-input"
        />
        {isUploading && <p className="upload-status">Indexing document...</p>}
        {error && <p className="upload-error">{error}</p>}
      </div>
    </div>
  );
}

export default FileUpload;

Here is the complete frontend/src/components/Chat.jsx for reference:

import { useState } from "react";
import axios from "axios";

const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";

function Chat({ documentName }) {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  const handleSend = async () => {
    if (!input.trim() || isLoading) return;

    const userMessage = { role: "user", content: input };
    setMessages((prev) => [...prev, userMessage]);
    setInput("");
    setIsLoading(true);

    try {
      const response = await axios.post(`${API_URL}/query`, {
        question: input,
      });

      const aiMessage = {
        role: "assistant",
        content: response.data.answer,
        sources: response.data.sources,
      };
      setMessages((prev) => [...prev, aiMessage]);
    } catch (err) {
      const errorMessage = {
        role: "assistant",
        content: "Sorry, something went wrong. Please try again.",
        sources: [],
      };
      setMessages((prev) => [...prev, errorMessage]);
    } finally {
      setIsLoading(false);
    }
  };

  const handleKeyPress = (e) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="chat-container">
      <div className="chat-header">
        <p>Chatting with: <strong>{documentName}</strong></p>
      </div>
      <div className="chat-messages">
        {messages.length === 0 && (
          <p className="chat-placeholder">Ask a question about your document...</p>
        )}
        {messages.map((msg, idx) => (
          <div key={idx} className={`message ${msg.role}`}>
            <p className="message-content">{msg.content}</p>
            {msg.sources && msg.sources.length > 0 && (
              <div className="message-sources">
                <p className="sources-label">Sources:</p>
                {msg.sources.map((source, sIdx) => (
                  <p key={sIdx} className="source-item">{source}</p>
                ))}
              </div>
            )}
          </div>
        ))}
        {isLoading && (
          <div className="message assistant">
            <p className="message-content loading">Thinking...</p>
          </div>
        )}
      </div>
      <div className="chat-input">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleKeyPress}
          placeholder="Ask a question..."
          disabled={isLoading}
        />
        <button onClick={handleSend} disabled={isLoading || !input.trim()}>
          Send
        </button>
      </div>
    </div>
  );
}

export default Chat;

Both halves of your application are tested and deployment-ready. Next up, you'll push your code to GitHub and deploy live to the internet with Render and Vercel.

Deploy Live to the Internet

Your application works locally. Now it is time to put it on the internet where anyone can access it.

In this step, you will push your code to GitHub, deploy the backend to Render, and deploy the frontend to Vercel. The result is a live full-stack AI application at a public URL that anyone can visit.

In this step, get ready to:
  • Push your project to a GitHub repository.
  • Deploy the FastAPI backend to Render (free tier).
  • Deploy the React frontend to Vercel (free tier).
Push your code to GitHub

Before deploying, you need your code in a Git repository on GitHub. Both Render and Vercel pull directly from GitHub to build and deploy your app.

  • Make sure your terminal is in the project root folder (the folder that contains both backend/ and frontend/).
  • Create a .gitignore file in the project root with the following content:
.env
node_modules/
__pycache__/
chroma_db/
uploads/
*.pyc
.DS_Store
dist/

What does this file do?

The .gitignore file tells Git which files to exclude from version control. You never want to commit your .env (it contains your API key), node_modules/ (too large), or __pycache__/ (auto-generated).

  • Initialize the Git repository and make your first commit by running these commands:
git init
git add -A
git commit -m "Initial commit: full-stack RAG document Q&A app"
  • Open GitHub in your browser and create a new repository.
  • Go to github.com/new.
  • Name it something like document-qa-assistant.
  • Leave all the initialization options unchecked (no README, no .gitignore, no license). You already have code locally.
  • Click Create repository.
  • Connect your local repository to GitHub and push by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/document-qa-assistant.git"]]
git branch -M main
git push -u origin main
  • Refresh your GitHub repository page in the browser. You should see all your project files including backend/ and frontend/ folders.

Push failed or permission denied?

Make sure you are signed in to the correct GitHub account in your terminal. If you see a permission error, you may need to configure a personal access token or SSH key for authentication.

help me fix a git push permission error to GitHub

Deploy the backend to Render

Render is a cloud platform that can host your FastAPI backend for free. It pulls your code from GitHub, installs dependencies, and runs your server at a public URL.

  • Open render.com in your browser.
  • Click Get Started for Free (or sign in if you already have an account).
  • Sign up using your GitHub account. This connects Render to your repositories automatically.

Why sign up with GitHub?

Signing up with GitHub authorizes Render to access your repositories. This lets Render pull your code to build and deploy it. Every push to your main branch will automatically trigger a new deployment.

  • Once signed in to the Render Dashboard, click New in the top-right corner.
  • Select Web Service from the dropdown.
  • Under Source, select Git Provider and connect your GitHub account if prompted.
  • Find your repository in the list and click Connect.

The service creation form appears. Fill in the following fields:

  • Set Name to something descriptive like document-qa-backend.
  • Set Root Directory to backend.
  • Set Build Command to:
pip install -r requirements.txt
  • Set Start Command to:
uvicorn main:app --host 0.0.0.0 --port $PORT
  • Scroll down to Instance Type and select Free.
  • Expand the Environment Variables section.
  • Add a new variable with the key GEMINI_API_KEY and paste your Gemini API key as the value.
  • Click Create Web Service.

Render kicks off your first build and deploy. This takes a few minutes. Watch the log feed until you see the deploy complete successfully.

What about the free tier limitations?

Render's free tier provides 512 MB RAM and 750 free instance hours per month. Your service spins down after 15 minutes without traffic and takes about a minute to spin back up on the next request.

The filesystem is also ephemeral. Uploaded documents are lost when the service restarts. This is fine for a portfolio demo since users re-upload documents per session.

  • Once the deploy finishes, find your service's URL at the top of the Render dashboard page. It looks like https://your-service-name.onrender.com.
  • Copy this URL. You will need it in the next substep.
  • Test the health endpoint by visiting your Render URL with /health appended in your browser.

You should see {"status":"healthy"} in the browser.

Deploy failed on Render?

Check the deploy logs for the specific error. Common issues include a missing dependency in requirements.txt or the GEMINI_API_KEY environment variable not being set. Make sure the Root Directory is set to backend (not backend/ with a trailing slash).

help me debug a failed Render deployment for my FastAPI backend

Deploy the frontend to Vercel

Vercel serves your React frontend on a global CDN. It auto-detects your Vite project, builds it, and deploys it at a public URL. You will point it at your live Render backend so the two services talk to each other.

  • Open vercel.com in your browser.
  • Click Sign Up and sign up with your GitHub account (or sign in if you already have one).
  • On the Vercel dashboard, click Add New... and select Project.
  • Find your repository in the list and click Import.

The project configuration page appears. Adjust the following settings:

  • Click Edit next to Root Directory and set it to frontend.
  • Expand the Environment Variables section.
  • Add a variable with the key VITE_API_URL and set the value to your Render backend URL (e.g. https://your-service-name.onrender.com). Do not include a trailing slash.
  • Click Deploy.

Vercel builds your React app and deploys it. This usually takes under a minute.

Why set VITE_API_URL here?

Your React code uses import.meta.env.VITE_API_URL to know where the backend lives. Locally it defaults to http://localhost:8000. In production, Vercel bakes the environment variable value into the built JavaScript bundle at deploy time.

  • Once the deploy succeeds, Vercel shows a Congratulations screen with your production URL (e.g. https://your-project.vercel.app).
  • Click the URL to open your live app in a new tab.
  • Upload a PDF and ask a question to verify the full flow works end-to-end.

First request takes about a minute

If your Render backend has spun down due to inactivity, the first request from your frontend will take about a minute while the service spins back up. Subsequent requests are fast.

Frontend deployed but API calls fail?

Double check that VITE_API_URL in Vercel's environment variables matches your Render URL exactly. Make sure there is no trailing slash. If you updated the variable after the first deploy, you need to redeploy for the change to take effect.

help me debug why my Vercel frontend cannot reach my Render backend

Your full-stack RAG application is now live on the internet. Anyone with the URL can upload a PDF and ask questions about it, powered by your Gemini-backed AI pipeline.

Secret mission

Add Conversation Memory

Your Q&A assistant answers each question in isolation. It has no idea what you asked 10 seconds ago. Add multi-turn conversation support so users can ask follow-up questions like "Can you explain that in simpler terms?" without re-stating the full context.

Clean Up Your Resources

Clean Up Your Resources

This project uses free-tier services for both Render and Vercel, so there are no ongoing costs while your app is deployed. The Gemini API free tier also costs nothing.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Render Web Service (backend deployment).
  • Vercel Project (frontend deployment).
  • Gemini API key (Google AI Studio).
  • GitHub repository.
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you want to keep your Document Q&A Assistant live and accessible at its public URL.

  • Your Render free web service will spin down after 15 minutes of inactivity and take about a minute to restart when someone visits.
  • Your Vercel frontend stays live on its global CDN with no cold start.
  • Your Gemini API key remains active. Because the free tier has no billing, there is no charge risk.
  • Your local project files, GitHub repository, and accounts all remain as they are.

Pause - I'll come back to this later

Shut down the live deployments to free up your free-tier resource slots, but keep your code and accounts intact for later.

  • In the Render Dashboard, click the checkbox next to your web service, then click Suspend in the action menu that appears. This stops the service without deleting it.
  • Your Vercel project can stay deployed at no cost. If you want to stop it from auto-deploying on future pushes, disconnect the Git repository in your project's Settings page under the Git section.
  • Leave your local files, GitHub repo, and Gemini API key in place for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each item below to fully clean up.

  • Delete the Render Web Service: In the Render Dashboard, click your web service to open it. Click Settings in the left sidebar. Scroll to the bottom of the page and click Delete Web Service. Type the service name to confirm, then confirm deletion.
  • Delete the Vercel Project: On the Vercel dashboard, select your project. Click Settings in the sidebar. At the bottom of the General page, click Delete in the Delete Project section. Enter the project name, type delete my project in the second field, then click Continue.
  • Delete your Gemini API key: Go to the Google AI Studio API Keys page at aistudio.google.com. Find your key in the list, click the trash icon next to it, and confirm deletion.
  • Delete the GitHub repository: On GitHub, navigate to your repository. Click Settings. Scroll down to the Danger Zone section and click Delete this repository. Click I want to delete this repository, then click I have read and understand these effects. Type the repository name to confirm, then click Delete this repository.
  • Delete local project files. On macOS or Linux, run:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]

On Windows, run:

cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your-project-folder-name"]]

Nice Work!

Nice Work!

You just built and deployed a full-stack AI application using the most in-demand pattern in production AI engineering.

Your Document Q&A Assistant is live on the internet, processing real PDFs and answering questions with source citations. That is a complete RAG pipeline from end to end.

You've learned how to:

  • Build a complete RAG pipeline covering document ingestion, chunking, vector embedding, semantic search, and LLM-powered answer generation with source citations.
  • Create a full-stack application with a Python/FastAPI REST API backend and a React frontend featuring file upload and real-time chat.
  • Deploy to the cloud at $0 cost using Render for the backend and Vercel for the frontend, with automatic GitHub-based deployments.
  • Added conversation memory so the assistant remembers previous questions in a session, enabling multi-turn follow-up dialogue (Secret Mission).

Ready to quiz yourself?