AI Document Q&A Platform with RAG
Build a RAG pipeline with pgvector, Gemini, and React to answer questions from documents.
Introduction
30 Second Summary
You have probably used an AI chatbot that confidently gave you a wrong answer. The fix used in production systems is to ground the AI in real documents so it can only answer from verified sources.
In this project, you will build a document Q&A platform where you paste text, the system chunks and embeds it into a database, and then you ask natural-language questions that get answered with cited sources.
What You'll Build
You paste a document into your app, ask it a question in plain English, and watch it return a grounded answer with highlighted citations proving exactly which parts of the document the answer came from.
By the end of this project, you'll have:
- A working RAG pipeline where you paste any text document and watch it get chunked, embedded into vector embeddings, and stored in PostgreSQL in seconds.
- A Q&A interface where you ask questions in plain English and get AI-generated answers that cite exactly which chunks of the document support each claim, with similarity scores showing match quality.
- A fullstack app with React on the front end and Express on the back end that you can demo end-to-end to prove you understand the AI pattern hiring managers screen for most in 2026: retrieval-augmented generation.
- Secret Mission: Add a confidence threshold that warns users when the document might not contain the answer, showing you think about AI reliability beyond the happy path.
Are there any prerequisites?
This project assumes you are comfortable with React, Node.js/Express, PostgreSQL basics, and REST APIs. You do not need prior experience with AI, embeddings, or vector databases.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
You're building a fullstack RAG (Retrieval-Augmented Generation) document Q&A platform. This is the production AI pattern hiring managers screen for most in 2026: grounding AI answers in real data instead of letting the model hallucinate.
Set Up the Project
A RAG pipeline needs three foundations before you can build anything: a database that understands vectors, an AI provider for embeddings and text generation, and a project scaffold to wire them together.
In this step, you'll create a free Neon PostgreSQL database with pgvector enabled, grab a free Gemini API key, and scaffold the Express backend and React frontend that will power your RAG system.
In this step, get ready to:
- Create a Neon PostgreSQL project with pgvector enabled.
- Get a Gemini API key from Google AI Studio.
- Scaffold the Express server and React client with all dependencies installed.
Create a Neon database with pgvector
Your RAG system stores vector embeddings alongside the text they represent. Neon is a serverless PostgreSQL provider with a free tier that includes the pgvector extension for vector similarity search.
- Open your browser and navigate to neon.tech.
- Click Sign Up and create an account using your preferred method (GitHub, Google, or email).
- After signing in, navigate to the Projects page in the Neon Console.
- Click New Project.
- Set the Postgres version to 17 and leave other settings as defaults.
- Click Create Project.
Neon creates a ready-to-use database named neondb. You now need to grab the connection string and enable the vector extension.
- On your Project Dashboard, click Connect to open the connection modal.
- Copy the full connection string (it starts with postgresql://). Save it somewhere safe for the next substep.
- In the left sidebar of the Neon Console, click SQL Editor.
- Enable the pgvector extension by running this SQL command:
CREATE EXTENSION IF NOT EXISTS vector;
- Click Run. You should see a success message confirming the extension was created.
What is pgvector?
pgvector adds a vector data type to PostgreSQL so you can store embeddings directly in your database. It also provides operators like <=> for cosine distance, letting you find similar vectors without needing a separate vector database like Pinecone or Weaviate.
Don't see the SQL Editor?
Make sure you have your project selected in the top-left dropdown of the Neon Console. The SQL Editor appears in the left sidebar once a project is active.
help me find the SQL Editor in Neon
Get a Gemini API key
Google's Gemini API provides both embeddings and text generation on its free tier with no billing required. You'll use it for embedding document chunks and generating answers.
- Open Google AI Studio and sign in with your Google account.
- Click Get API Key in the left navigation panel.
- Click Create API key. If prompted to select a project, use the default one AI Studio creates for you.
- Copy the generated API key and save it somewhere safe. You'll need it in a moment.
Is the Gemini free tier really free?
Yes. The Gemini API free tier charges nothing for both input and output tokens, including embeddings. No credit card is required. As long as your API key is not attached to a billing account, you remain on the free tier.
Create API key button not working?
If you see a permissions error, make sure you're signed in with a personal Google account (not a workplace or school account with restricted permissions). New users get a default project created automatically after accepting the Terms of Service.
help me create a Gemini API key
Scaffold the project
Your project has two halves: an Express server that handles ingestion and queries, and a React client for the UI. You'll create both now and verify they run.
- 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 rag-document-qa
cd rag-document-qa
- Create the server directory by running:
mkdir server
- Open this project in VS Code by running:
code .
What if code . doesn't work?
If VS Code doesn't open, launch VS Code manually. Click File in the top menu bar, click Open Folder, and select the rag-document-qa folder on your Desktop.
- In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Create the server/package.json file. In the VS Code Explorer sidebar, right-click the server folder and select New File. Name it package.json.
- Paste the following into server/package.json:
{
"name": "rag-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch server.js"
},
"dependencies": {
"@google/genai": "2.13.0",
"cors": "2.8.5",
"dotenv": "16.4.7",
"express": "4.21.2",
"pg": "8.13.1"
}
}
What does this package.json do?
This defines your server project as an ES module ("type": "module") and pins the exact dependency versions. The node --watch flag auto-restarts the server when you save changes, so you don't need nodemon.
- Install the server dependencies by running this in your VS Code terminal:
cd server
npm install
You should see output listing the installed packages. The node_modules folder appears inside server/.
Seeing npm errors?
The @google/genai package requires Node.js 20 or later. Run node --version to check your version. If it's below 20, update Node.js before continuing.
help me fix npm install errors
- Create a new file inside server/ called .env. Right-click the server folder in Explorer, select New File, and name it .env.
- Paste the following into server/.env, replacing the placeholder values with your actual Neon connection string and Gemini API key:
DATABASE_URL=your-neon-connection-string-here
GEMINI_API_KEY=your-gemini-api-key-here
Keep .env out of version control
The .env file holds your credentials. Never commit it to Git. If you add Git later, create a .gitignore file that excludes .env.
- Create another new file inside server/ called server.js. This is the Express application entry point.
- Paste the following into server/server.js:
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import pg from 'pg';
import { GoogleGenAI } from '@google/genai';
const app = express();
app.use(cors());
app.use(express.json({ limit: '1mb' }));
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: false } });
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
app.get('/health', (req, res) => res.json({ status: 'ok' }));
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
What does this server do?
This is the minimal Express skeleton. It loads environment variables from .env, creates a PostgreSQL connection pool pointed at your Neon database, initializes the Gemini AI client, and exposes a single /health endpoint to prove the server runs.
The ssl: { rejectUnauthorized: false } setting is required because Neon enforces SSL connections.
Before you run the server, what do you expect to see when you visit http://localhost:3001/health in your browser?
- Start the server by running:
npm run dev
You should see Server running on port 3001 printed in the terminal.
- Open your browser and navigate to http://localhost:3001/health.
You should see the JSON response {"status":"ok"}. Your server is alive and connected.
Seeing a connection error on startup?
If the terminal shows a PostgreSQL connection error, double-check the DATABASE_URL value in your .env file. It should be the full connection string from the Neon dashboard starting with postgresql://. The server will still respond to /health even if the database connection fails initially, but you'll see warning logs.
help me fix my server startup error
Now set up the React client. Keep the server running in its current terminal.
- Open a new terminal in VS Code by clicking Terminal in the top menu, then New Terminal.
- Navigate back to the project root by running:
cd ~/Desktop/rag-document-qa
- Create the client directory structure by running:
mkdir -p client/src
- Create client/package.json. Right-click the client folder in Explorer, select New File, name it package.json, and paste the following:
{
"name": "rag-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "4.4.1",
"vite": "6.3.5"
}
}
- Install the client dependencies by running:
cd client
npm install
You should see the packages install successfully. Now create the remaining client files.
- Create client/vite.config.js and paste the following:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()]
});
- Create client/index.html (in the client root, not inside src) and paste:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RAG Document Q&A</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
- Create client/src/main.jsx and paste:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
- Create client/src/App.jsx with a placeholder so the app can render:
function App() {
return <h1>RAG Document Q&A</h1>;
}
export default App;
Before you run the client, what do you expect to see at http://localhost:5173?
- Start the React development server by running:
npm run dev
Vite prints a local URL in the terminal. Open http://localhost:5173 in your browser. You should see the heading "RAG Document Q&A" displayed on the page.
Blank page or module error?
Make sure index.html is in the client/ root (not inside src/). Vite uses index.html as its entry point. Also confirm that src/App.jsx exists, since main.jsx imports it.
help me fix my Vite React app
✔️ Awesome, I've got everything!
Great. Make sure both terminals are still running (server on port 3001, client on port 5173). You'll need both active for the rest of the project.
ⓧ I'd like to double check the full code
Here are the key files to verify. Scroll up and confirm each one matches.
server/package.json:
{
"name": "rag-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch server.js"
},
"dependencies": {
"@google/genai": "2.13.0",
"cors": "2.8.5",
"dotenv": "16.4.7",
"express": "4.21.2",
"pg": "8.13.1"
}
}
server/server.js:
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import pg from 'pg';
import { GoogleGenAI } from '@google/genai';
const app = express();
app.use(cors());
app.use(express.json({ limit: '1mb' }));
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: false } });
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
app.get('/health', (req, res) => res.json({ status: 'ok' }));
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
client/package.json:
{
"name": "rag-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "4.4.1",
"vite": "6.3.5"
}
}
client/vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()]
});
client/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RAG Document Q&A</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
client/src/main.jsx:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
Both halves of your project are running. Next up, you'll build the document ingestion pipeline that chunks text, generates embeddings with Gemini, and stores everything in your Neon database.
Build the Document Ingestion Pipeline
Before you can answer questions about a document, the system needs to break that document into searchable pieces. This is the foundational engineering decision in any RAG system.
How you chunk a document determines how well retrieval works later. In this step, you'll create the database schema for storing document chunks and their vector embeddings, then build the pipeline that splits text, generates embeddings via Gemini, and stores everything in PostgreSQL.
In this step, get ready to:
- Create the PostgreSQL schema with pgvector for storing document chunks and their embeddings.
- Build a chunking function that splits text into overlapping pieces.
- Wire up the ingestion endpoint that orchestrates chunking, embedding, and storage.
Create the database schema
Your RAG pipeline needs two tables. One stores the original documents. The other stores individual chunks with their vector embeddings, so you can run similarity searches later.
- Create a new file called schema.sql inside your server directory by running this command from the server folder:
touch schema.sql
- Open schema.sql in VS Code and paste the following SQL:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE chunks (
id SERIAL PRIMARY KEY,
document_id INTEGER REFERENCES documents(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding vector(768),
chunk_index INTEGER NOT NULL
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
What does this schema do?
- The documents table stores the original text you ingest, along with a title and timestamp.
- The chunks table stores each piece of a document alongside its 768-dimension vector embedding. The document_id foreign key links each chunk back to its parent document.
- The HNSW index on the embedding column enables fast approximate nearest-neighbor search using cosine distance. Without it, every query would scan every row.
- Save schema.sql.
- Open the Neon Console in your browser and click SQL Editor in the left sidebar.
- Copy the entire contents of schema.sql and paste it into the SQL Editor.
- Click Run to execute the SQL.
You should see confirmation messages indicating the extension, tables, and index were created successfully.
- Verify the tables exist by running this query in the SQL Editor:
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';
You should see both documents and chunks listed in the results.
Seeing an error about the vector extension?
If you see an error like "could not open extension control file", the pgvector extension may not be enabled on your Neon project. Go back to the SQL Editor and run CREATE EXTENSION IF NOT EXISTS vector; on its own first, then run the rest of the schema.
Help me troubleshoot my pgvector schema creation in Neon
Build the chunking logic
Embedding an entire document as one vector loses detail. Instead, you split text into smaller overlapping chunks so each piece carries focused meaning for retrieval.
- In server.js, find the health check line:
app.get('/health', (req, res) => res.json({ status: 'ok' }));
- Add the following chunking function directly below the health check:
// Split text into overlapping chunks for embedding
function chunkText(text, chunkSize = 500, overlap = 50) {
const chunks = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
chunks.push(text.slice(start, end));
start += chunkSize - overlap;
}
return chunks;
}
Why overlapping chunks?
Each chunk is approximately 500 characters long, but the window slides forward by only 450 characters (500 minus the 50-character overlap). This means neighboring chunks share 50 characters of context at their boundaries.
Without overlap, a sentence that spans two chunks would be split in half and neither chunk would carry its full meaning. The overlap ensures context is preserved across boundaries.
- Save server.js.
- Check your terminal where the server is running. Because you're using --watch, the server auto-restarts on save. You should see Server running on port 3001 printed again with no errors.
Seeing a syntax error on restart?
Double-check that the function is placed after the app.get('/health', ...) line and before the const PORT line at the bottom. Make sure all curly braces and parentheses are balanced.
Help me fix my chunkText function syntax error
Build the ingestion endpoint
The ingestion endpoint ties everything together. It receives text, creates a document record, chunks the content, generates a vector embedding for each chunk via Gemini, and stores them in PostgreSQL.
- In server.js, add the embedding helper function directly below the chunkText function you just added:
// Generate a vector embedding for a text string
async function getEmbedding(text, taskType) {
const result = await ai.models.embedContent({
model: 'gemini-embedding-001',
contents: text,
config: { taskType, outputDimensionality: 768 }
});
return result.embedding.values;
}
What does getEmbedding do?
This function calls Gemini's gemini-embedding-001 model to convert text into a 768-dimension vector. The taskType parameter tells Gemini whether the text is a document being stored (RETRIEVAL_DOCUMENT) or a query being searched (RETRIEVAL_QUERY). Using the right task type improves retrieval accuracy.
- Now add the ingestion endpoint below the getEmbedding function:
// Ingest a document: chunk it, embed each chunk, store in PostgreSQL
app.post('/api/ingest', async (req, res) => {
try {
const { title, content } = req.body;
if (!title || !content) return res.status(400).json({ error: 'Title and content are required' });
const docResult = await pool.query(
'INSERT INTO documents (title, content) VALUES ($1, $2) RETURNING id',
[title, content]
);
const documentId = docResult.rows[0].id;
const chunks = chunkText(content);
for (let i = 0; i < chunks.length; i++) {
const embedding = await getEmbedding(chunks[i], 'RETRIEVAL_DOCUMENT');
const embeddingStr = `[${embedding.join(',')}]`;
await pool.query(
'INSERT INTO chunks (document_id, content, embedding, chunk_index) VALUES ($1, $2, $3::vector, $4)',
[documentId, chunks[i], embeddingStr, i]
);
}
res.json({ message: 'Document ingested', documentId, chunks: chunks.length });
} catch (error) {
console.error('Ingest error:', error);
res.status(500).json({ error: 'Failed to ingest document' });
}
});
What does the ingest endpoint do?
- It validates that both title and content are present in the request body.
- It inserts a row into the documents table and captures the new id.
- It splits the content into chunks using chunkText.
- For each chunk, it generates a RETRIEVAL_DOCUMENT embedding and inserts the chunk plus its vector into the chunks table.
- It returns the document ID and the number of chunks created.
- Save server.js. Confirm the server restarts cleanly in your terminal with no errors.
Before you test the endpoint, what do you think will happen when you POST a short paragraph to it? How many chunks would a 1200-character document produce with 500-character chunks and 50-character overlap?
- Test the ingestion endpoint by running this command in a new terminal:
curl -X POST http://localhost:3001/api/ingest \
-H "Content-Type: application/json" \
-d '{"title": "Test Document", "content": "Retrieval-Augmented Generation (RAG) is a technique that combines information retrieval with text generation. Instead of relying solely on what a language model memorized during training, RAG fetches relevant documents from a knowledge base and includes them in the prompt. This grounds the model response in actual source material, reducing hallucinations and improving factual accuracy. The typical RAG pipeline involves three stages: document ingestion where text is chunked and embedded, retrieval where a query is matched against stored embeddings, and generation where retrieved context is fed to an LLM. Vector databases enable the similarity search step by storing embeddings and supporting nearest-neighbor queries efficiently."}'
You should see a response like this:
{"message":"Document ingested","documentId":1,"chunks":2}
Why 2 chunks?
The sample text is about 700 characters. With a 500-character chunk size and 50-character overlap, the first chunk covers characters 0-500 and the second covers characters 450-700. Two chunks is correct for this length.
- Verify the data landed in your database. Switch to the Neon SQL Editor and run:
SELECT COUNT(*) FROM chunks;
You should see a count of 2, confirming both chunks and their embeddings were stored successfully.
Getting a 500 error from the ingest endpoint?
Check your terminal for the logged error. Common causes:
- If you see a connection error, verify your DATABASE_URL in .env matches the connection string from your Neon dashboard.
- If you see "relation chunks does not exist", you haven't run the schema SQL yet. Go back to the Neon SQL Editor and execute schema.sql.
- If you see an API key error, confirm your GEMINI_API_KEY in .env is correct and has no extra whitespace.
Help me debug my /api/ingest 500 error
✔️ Awesome, I've got everything!
Great. Make sure both schema.sql and server.js are saved.
ⓧ I'd like to double check the full code
Here is server/schema.sql in full:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE chunks (
id SERIAL PRIMARY KEY,
document_id INTEGER REFERENCES documents(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding vector(768),
chunk_index INTEGER NOT NULL
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
For server/server.js, confirm these sections are present in order:
- Imports and app setup (dotenv, express, cors, pg, GoogleGenAI).
- Pool and AI client initialization.
- Health check endpoint (GET /health).
- The chunkText function (500-char size, 50-char overlap).
- The getEmbedding function (gemini-embedding-001, 768 dimensions).
- The POST /api/ingest endpoint.
- The PORT and app.listen at the bottom.
Your ingestion pipeline is live. Documents go in, chunks and embeddings come out. Next up, you'll build the retrieval and generation side so you can actually ask questions and get grounded answers from those stored chunks.
Build the Retrieval and Generation API
You have embedded chunks sitting in PostgreSQL, but no way to search them. The ingestion pipeline from the previous step is only half the story.
Now you need to close the loop: embed the user's question, find the most similar chunks via cosine distance, and feed those chunks to Gemini as context so it generates a grounded answer instead of hallucinating.
In this step, get ready to:
- Embed a user's question and retrieve the most relevant chunks from pgvector.
- Construct a grounded prompt and generate an answer with Gemini.
- Add a documents list endpoint for the frontend to use later.
Build the retrieval logic
Retrieval is the R in RAG. When a user asks a question, you first convert that question into a vector embedding, then use pgvector's cosine distance operator to find the stored chunks closest in meaning.
- In server.js, find the closing }); of your /api/ingest endpoint.
- Below it, start building the query endpoint by adding this code:
// Query endpoint
app.post('/api/query', async (req, res) => {
try {
const { question, documentId } = req.body;
if (!question || !documentId) return res.status(400).json({ error: 'Question and documentId are required' });
// Embed the question using the RETRIEVAL_QUERY task type
const queryEmbedding = await getEmbedding(question, 'RETRIEVAL_QUERY');
const embeddingStr = `[${queryEmbedding.join(',')}]`;
// Find the top-5 most similar chunks using cosine distance
const result = await pool.query(
`SELECT content, chunk_index, 1 - (embedding <=> $1::vector) AS similarity
FROM chunks
WHERE document_id = $2
ORDER BY embedding <=> $1::vector
LIMIT 5`,
[embeddingStr, documentId]
);
const sources = result.rows;
What does this code do?
This is the first half of the query endpoint. It handles the retrieval step of RAG.
- getEmbedding(question, 'RETRIEVAL_QUERY') converts the user's question into a 768-dimensional vector. The RETRIEVAL_QUERY task type tells Gemini this embedding will be used for searching, not for storage.
- The SQL query uses <=> (pgvector's cosine distance operator) to find the 5 chunks whose embeddings are closest in meaning to the question.
- 1 - (embedding <=> $1::vector) converts distance into a similarity score. A score of 1.0 means identical, 0.0 means unrelated.
This code has unclosed braces. You will complete the endpoint in the next code block.
Build the generation logic and complete the endpoint
With the relevant chunks retrieved, you now build a prompt that injects them as context and instructs Gemini to answer only from what it was given. This grounding step is what separates RAG from a naive chatbot that hallucinates freely.
- Directly below const sources = result.rows; (the last line you just added), paste this code to complete the endpoint:
// Build context string from retrieved chunks
const context = sources.map((s, i) => `[Chunk ${i + 1}]: ${s.content}`).join('\n\n');
const prompt = `You are a helpful assistant. Answer the question using ONLY the provided context. If the context does not contain the answer, say so. Cite which chunk(s) support your answer.\n\nContext:\n${context}\n\nQuestion: ${question}\n\nAnswer:`;
// Generate answer using Gemini
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: prompt
});
// Return the answer with source chunks and similarity scores
res.json({
answer: response.text,
sources: sources.map(s => ({
content: s.content,
chunkIndex: s.chunk_index,
similarity: parseFloat(s.similarity).toFixed(4)
}))
});
} catch (error) {
console.error('Query error:', error);
res.status(500).json({ error: 'Failed to process query' });
}
});
What does this code do?
This is the generation half of RAG. It takes the retrieved chunks and uses them to produce a grounded answer.
- The context variable formats each chunk with a label like [Chunk 1] so the model can cite them in its answer.
- The prompt explicitly says "Answer using ONLY the provided context." This constraint prevents the model from drawing on its general training data.
- The response includes both the generated answer and the source chunks with their similarity scores, so the caller can verify grounding.
- Save server.js.
Your server will auto-restart thanks to --watch. Before you test the full query, you need a document ingested from Step 2.
Before you run this, what do you think the response will contain? Will it include the AI-generated answer, the raw chunks, or both?
- Test the query endpoint by sending a question about a document you ingested earlier. Run this in a new terminal:
curl -X POST http://localhost:3001/api/query \
-H 'Content-Type: application/json' \
-d '{"question": "What is this document about?", "documentId": 1}'
You should see a JSON response containing an answer field (a generated response referencing specific chunks) and a sources array with chunk objects that include similarity scores.
Getting a 500 error?
- Make sure you have at least one document ingested from Step 2. If you restarted your Neon project, the data may have been cleared.
- Check that the documentId in your curl command matches an actual document ID. Try 1 if you only ingested one document.
- Look at your terminal running the server for the full error message after Query error:.
help me debug my /api/query endpoint error
Add the documents list endpoint
The React frontend you will build next needs a way to fetch all ingested documents so the user can select which one to query against.
- In server.js, add this endpoint below the closing }); of your /api/query endpoint:
// Get documents list
app.get('/api/documents', async (req, res) => {
try {
const result = await pool.query('SELECT id, title, created_at FROM documents ORDER BY created_at DESC');
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch documents' });
}
});
What does this code do?
This is a simple GET endpoint that returns all ingested documents ordered by newest first. The frontend will use this list to populate a dropdown so the user can choose which document to ask questions about.
- Save server.js.
- Verify the documents endpoint works by running:
curl http://localhost:3001/api/documents
You should see a JSON array containing the document(s) you ingested earlier, each with an id, title, and created_at field.
Seeing an empty array?
An empty array [] means no documents are stored. Go back and re-run your ingestion curl command from Step 2 to add a document.
help me figure out why my documents endpoint returns empty results
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js and that the server restarted without errors in your terminal.
ⓧ I'd like to double check the full code
Your server.js file is now long, so confirm these sections are present in order:
- Imports and setup (dotenv, express, cors, pg, GoogleGenAI).
- The /health GET endpoint.
- The chunkText function.
- The getEmbedding function.
- The /api/ingest POST endpoint.
- The /api/query POST endpoint (retrieval + generation logic).
- The /api/documents GET endpoint.
- The app.listen call at the bottom.
Your RAG pipeline is now fully functional from end to end. You can ingest documents, ask questions, and get AI-generated answers grounded in the actual document content. Next up, you will build a React frontend so you can interact with this system through a proper UI instead of curl commands.
Build the React Frontend
Your backend is humming along. It can ingest documents, chunk them, embed them, store them, and answer questions with grounded citations. But right now the only way to use it is through raw HTTP requests.
In this step you will build a React frontend that lets you paste a document, submit it for ingestion, then ask questions and see AI-generated answers with source citations and similarity scores.
In this step, get ready to:
- Build a document ingestion panel with a form that calls your ingest API.
- Build a Q&A panel that displays answers with expandable source citations.
- Wire up state management so ingested documents appear in a selector for querying.
Build the document ingestion panel
The ingestion panel gives users a way to paste text content and send it to your /api/ingest endpoint. It needs form state, a submit handler, and feedback showing how many chunks were created.
- In VS Code, open client/src/App.jsx.
- Select all the default Vite content and delete it.
- Replace it with the following code that sets up your component state and the document-fetching logic:
import { useState, useEffect } from 'react';
const API_URL = 'http://localhost:3001';
function App() {
const [documents, setDocuments] = useState([]);
const [selectedDoc, setSelectedDoc] = useState(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [question, setQuestion] = useState('');
const [answer, setAnswer] = useState(null);
const [loading, setLoading] = useState(false);
const [ingestLoading, setIngestLoading] = useState(false);
const [ingestResult, setIngestResult] = useState(null);
useEffect(() => { fetchDocuments(); }, []);
async function fetchDocuments() {
const res = await fetch(`${API_URL}/api/documents`);
const data = await res.json();
setDocuments(data);
}
What does this code do?
You declare nine state variables that track everything the UI needs: the list of ingested documents, which document is selected for querying, form inputs, API responses, and loading flags.
The useEffect hook calls fetchDocuments() once on mount so the document dropdown is populated immediately.
- Add the ingestion handler function directly below fetchDocuments:
async function handleIngest(e) {
e.preventDefault();
setIngestLoading(true);
setIngestResult(null);
try {
const res = await fetch(`${API_URL}/api/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content })
});
const data = await res.json();
setIngestResult(data);
setTitle('');
setContent('');
fetchDocuments();
} catch (err) {
setIngestResult({ error: 'Failed to ingest document' });
}
setIngestLoading(false);
}
What does this code do?
When the user submits the form, handleIngest sends the title and content to your Express server's /api/ingest endpoint.
On success it clears the form fields and calls fetchDocuments() again so the new document immediately appears in the dropdown.
Build the Q&A panel
The Q&A panel embeds the user's question, retrieves similar chunks, and displays the AI-generated answer alongside the source citations. This is the part that proves your RAG system is grounded in fact.
- Add the query handler function directly below handleIngest:
async function handleQuery(e) {
e.preventDefault();
if (!selectedDoc) return;
setLoading(true);
setAnswer(null);
try {
const res = await fetch(`${API_URL}/api/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, documentId: selectedDoc })
});
const data = await res.json();
setAnswer(data);
} catch (err) {
setAnswer({ error: 'Failed to get answer' });
}
setLoading(false);
}
What does this code do?
This handler sends the question and the selected documentId to your /api/query endpoint. The server embeds the question, performs cosine similarity search, and returns a generated answer with sources.
The early return on if (!selectedDoc) prevents the API call if no document is selected.
- Now add the return statement with the ingestion panel JSX below handleQuery:
return (
<div style={{ maxWidth: '900px', margin: '0 auto', padding: '2rem', fontFamily: 'system-ui' }}>
<h1>RAG Document Q&A</h1>
<section style={{ marginBottom: '2rem', padding: '1.5rem', border: '1px solid #ddd', borderRadius: '8px' }}>
<h2>Ingest Document</h2>
<form onSubmit={handleIngest}>
<input
type="text"
placeholder="Document title"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ width: '100%', padding: '0.5rem', marginBottom: '0.5rem', boxSizing: 'border-box' }}
/>
<textarea
placeholder="Paste your document content here..."
value={content}
onChange={(e) => setContent(e.target.value)}
rows={8}
style={{ width: '100%', padding: '0.5rem', marginBottom: '0.5rem', boxSizing: 'border-box' }}
/>
<button type="submit" disabled={ingestLoading || !title || !content}
style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}>
{ingestLoading ? 'Ingesting...' : 'Ingest Document'}
</button>
</form>
{ingestResult && (
<p style={{ marginTop: '0.5rem', color: ingestResult.error ? 'red' : 'green' }}>
{ingestResult.error || `Ingested ${ingestResult.chunks} chunks`}
</p>
)}
</section>
- Directly below the ingestion section (no blank line between), add the Q&A panel with its document selector and question form:
<section style={{ padding: '1.5rem', border: '1px solid #ddd', borderRadius: '8px' }}>
<h2>Ask a Question</h2>
<select
value={selectedDoc || ''}
onChange={(e) => setSelectedDoc(Number(e.target.value))}
style={{ width: '100%', padding: '0.5rem', marginBottom: '0.5rem' }}>
<option value="">Select a document...</option>
{documents.map(doc => (
<option key={doc.id} value={doc.id}>{doc.title}</option>
))}
</select>
<form onSubmit={handleQuery}>
<input
type="text"
placeholder="Ask a question about the document..."
value={question}
onChange={(e) => setQuestion(e.target.value)}
style={{ width: '100%', padding: '0.5rem', marginBottom: '0.5rem', boxSizing: 'border-box' }}
/>
<button type="submit" disabled={loading || !question || !selectedDoc}
style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}>
{loading ? 'Thinking...' : 'Ask'}
</button>
</form>
- Finally, add the answer display section with source citations and the component's closing brackets:
{answer && !answer.error && (
<div style={{ marginTop: '1rem' }}>
<h3>Answer</h3>
<p style={{ whiteSpace: 'pre-wrap', lineHeight: '1.6' }}>{answer.answer}</p>
<details style={{ marginTop: '1rem' }}>
<summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
Sources ({answer.sources.length} chunks retrieved)
</summary>
<ul style={{ listStyle: 'none', padding: 0 }}>
{answer.sources.map((source, i) => (
<li key={i} style={{ padding: '0.75rem', margin: '0.5rem 0', background: '#f5f5f5', borderRadius: '4px', borderLeft: '3px solid #4CAF50' }}>
<strong>Chunk {source.chunkIndex + 1}</strong>
<span style={{ float: 'right', color: '#666' }}>{source.similarity} match</span>
<p style={{ margin: '0.5rem 0 0', fontSize: '0.9rem' }}>{source.content}</p>
</li>
))}
</ul>
</details>
</div>
)}
{answer && answer.error && <p style={{ color: 'red' }}>{answer.error}</p>}
</section>
</div>
);
}
export default App;
What does the answer display do?
The <details> element creates an expandable "Sources" section. Each retrieved chunk shows its content alongside a similarity score (e.g. 0.87 match). This is the citation pattern that proves your AI is grounded in the document rather than hallucinating.
The green left border on each source chunk makes it easy to scan which parts of the document the AI relied on.
- Save App.jsx.
- Switch to your browser and open http://localhost:5173.
You should see the "RAG Document Q&A" heading with two panels: an Ingest Document form at the top and an Ask a Question section below it.
Seeing a blank page or errors?
- Open your browser's developer console (F12). If you see a syntax error, check that you pasted all six code blocks in sequence with no gaps or duplicated lines.
- Make sure your Express server is still running in its terminal. The useEffect calls /api/documents on load. If the server is down you will see a network error in the console.
- Confirm App.jsx ends with export default App; on its own line.
Why isn't my React app rendering after pasting the App.jsx code?
Test the full RAG flow end to end
The state management tying both panels together is the documents array and the selectedDoc value. When you ingest a document, fetchDocuments() re-runs and the new title appears in the dropdown. Selecting it sets selectedDoc so the query knows which document to search against.
- In the Document title field, type Test Document.
- In the textarea, paste a few paragraphs of any text you like (a Wikipedia paragraph, a recipe, meeting notes).
- Click Ingest Document.
The button text changes to "Ingesting..." while it processes. After a few seconds you should see a green message like Ingested 3 chunks (the exact number depends on the length of your text).
- In the Select a document... dropdown, select Test Document.
- Type a question about the text you pasted into the question field.
Before you click Ask, predict: will the answer cite specific chunks from your document, or will it give a generic response?
- Click Ask.
The AI-generated answer appears below the form. Click the Sources toggle to expand it. You will see the retrieved chunks listed with their similarity scores (e.g. 0.8743 match). Each chunk shows the exact text from your document that the AI used to form its answer.
Why do similarity scores matter?
Similarity scores tell you how relevant each chunk is to the question. A score of 1.0 means a perfect match. Scores above 0.7 are typically strong matches. Scores below 0.5 suggest the document might not have relevant content for that question.
Surfacing these scores in the UI is what separates a production RAG system from a toy demo. Users can verify the AI's sources rather than trusting it blindly.
Not seeing an answer after clicking Ask?
- Check that you selected a document from the dropdown before asking. The button stays disabled if no document is selected.
- Look at your Express server terminal for error logs. If you see a Gemini API error, confirm your GEMINI_API_KEY is set correctly in your .env file.
- If the answer appears but Sources shows 0 chunks, the document may not have been ingested correctly. Try ingesting again with longer text (at least a full paragraph).
My RAG Q&A panel is not returning answers or sources after I click Ask.
✔️ Awesome, I've got everything!
Great. Double check you have saved App.jsx and that your browser shows the working Q&A interface.
ⓧ I'd like to double check the full code
Your client/src/App.jsx file is built from six consecutive blocks you pasted above. Scroll up and confirm each one is present in this order:
- Imports, state declarations, fetchDocuments (the first code block).
- The handleIngest function (second code block).
- The handleQuery function (third code block).
- The return statement with the ingestion panel (fourth code block).
- The Q&A panel with the document selector and question form (fifth code block).
- The answer display, source citations, closing brackets, and export default App; (sixth code block).
You now have a fullstack RAG application running locally. The frontend proves you can build end-to-end, and the citation pattern shows hiring managers you think about AI reliability. Next up, you will push this further with a Secret Mission that handles the case where the AI does not have a confident answer.
Secret mission
Add a Confidence Threshold Warning
Your RAG system answers every question confidently, even when the document has nothing relevant to say. Production AI systems need to know when they don't know. Can you add a confidence threshold that warns users when the AI is guessing?
Clean Up Your Resources
Clean Up Your Resources
This project runs on Neon's free tier and Google Gemini's free tier, so there are no ongoing costs while resources are active. Neon auto-suspends compute after 5 minutes of inactivity.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Neon PostgreSQL project (free tier, auto-suspends after inactivity).
- Google AI Studio API key (free tier, no charges).
- Local project files (server/ and client/ directories on your Desktop).
Keep everything running
No action needed. Choose this if you plan to keep building on this project.
- Neon's free tier stays free indefinitely. Compute auto-suspends after 5 minutes of inactivity and resumes automatically on the next connection.
- Your Gemini API key has no charges on the free tier.
- Your local project files remain on your Desktop ready to use.
Pause - I'll come back to this later
Free up local resources while keeping your cloud services intact.
- Neon auto-suspends compute after inactivity. No manual action is required.
- Stop the Express server and Vite dev server by pressing Ctrl+C in each terminal window.
- Your API key and database data will be waiting when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete the Neon project: open the Neon Console, select your project, click Settings, then click Delete. Confirm the deletion in the dialog. This permanently removes all databases, branches, and data.
- Delete your Gemini API key: open the Google Cloud API Credentials page, find your API key, click the Actions icon, select Delete API key, then click Delete in the confirmation modal.
- Delete your local project files by running the following commands in your terminal:
macOS
cd ~/Desktop
rm -rf server client
Windows
cd ~\Desktop
rmdir /s /q server
rmdir /s /q client
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade RAG system from scratch, covering every stage from document ingestion to AI-generated answers with source citations.
You've learned how to:
- Build a complete RAG pipeline that chunks documents, generates vector embeddings with Gemini, stores them in PostgreSQL using pgvector, and retrieves the most relevant chunks via cosine similarity search.
- Integrate Google Gemini for both embeddings (gemini-embedding-001) and text generation (gemini-2.5-flash) on the free tier, with no separate vector database required.
- Design a fullstack app with clean REST API separation between ingestion and query endpoints, and a React frontend that displays source citations proving every answer is grounded in the document.
- (Secret Mission) Added confidence threshold logic to detect when retrieved chunks have low similarity scores, warning users that the document may not contain the answer.
Ready to quiz yourself?