Case Study: Document Q&A Bot Backend in Node.js
Let's build a complete, production-grade Document Q&A backend using Express.js, PostgreSQL with pgvector, and OpenAI.
Users can upload documents (.txt, .md, .pdf), the server automatically chunks and stores them with vector embeddings in the background, and users can ask questions with conversational chat history support.
1. System Architecture
2. Project Directory Structure
doc-qa-backend/
├── src/
│ ├── database.js # Postgres client & table schemas
│ ├── chunker.js # Paragraph-aware text chunker
│ ├── embeddings.js # OpenAI embeddings helper
│ ├── rag.js # Retrieval & grounded response generation
│ └── server.js # Express REST API routes
├── package.json
└── .envInstall the dependencies:
npm install express multer pg openai dotenv cors3. Step 1: Database Setup & Migrations (src/database.js)
import pg from "pg";
import dotenv from "dotenv";
dotenv.config();
const { Pool } = pg;
export const pool = new Pool({
connectionString: process.env.DATABASE_URL || "postgresql://localhost:5432/docqa_db"
});
export async function initDatabase() {
const client = await pool.connect();
try {
// 1. Enable pgvector extension
await client.query("CREATE EXTENSION IF NOT EXISTS vector;");
// 2. Documents table
await client.query(`
CREATE TABLE IF NOT EXISTS documents (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL,
status TEXT DEFAULT 'processing',
created_at TIMESTAMP DEFAULT NOW()
);
`);
// 3. Chunks table with 1536-dimensional vector embedding
await client.query(`
CREATE TABLE IF NOT EXISTS document_chunks (
id SERIAL PRIMARY KEY,
document_id INTEGER REFERENCES documents(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding vector(1536),
chunk_index INTEGER,
created_at TIMESTAMP DEFAULT NOW()
);
`);
// 4. Index for fast similarity queries
await client.query(`
CREATE INDEX IF NOT EXISTS chunks_vector_idx
ON document_chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
`);
console.log("âś… Database initialized with pgvector tables.");
} finally {
client.release();
}
}4. Step 2: Paragraph-Aware Chunker (src/chunker.js)
/**
* Splits raw text into cohesive chunks respecting paragraphs
*/
export function chunkDocument(text, maxWords = 350, overlapWords = 40) {
// Normalize extra whitespace and newlines
const cleaned = text.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n");
const paragraphs = cleaned.split("\n\n");
const chunks = [];
let currentChunk = [];
let currentWordCount = 0;
for (const para of paragraphs) {
const words = para.split(/\s+/).filter(Boolean);
if (words.length === 0) continue;
if (currentWordCount + words.length <= maxWords) {
currentChunk.push(para);
currentWordCount += words.length;
} else {
if (currentChunk.length > 0) {
chunks.push(currentChunk.join("\n\n"));
}
// Add overlap from the end of the previous chunk
if (chunks.length > 0 && overlapWords > 0) {
const lastChunkWords = chunks[chunks.length - 1].split(/\s+/);
const overlap = lastChunkWords.slice(-overlapWords).join(" ");
currentChunk = [overlap, para];
currentWordCount = overlapWords + words.length;
} else {
currentChunk = [para];
currentWordCount = words.length;
}
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk.join("\n\n"));
}
return chunks;
}5. Step 3: Embeddings Service (src/embeddings.js)
import OpenAI from "openai";
import dotenv from "dotenv";
dotenv.config();
const openai = new OpenAI();
export async function generateEmbedding(text) {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text
});
return response.data[0].embedding;
}
export async function generateBatchEmbeddings(textArray) {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: textArray
});
return response.data.map((item) => item.embedding);
}6. Step 4: RAG Engine with Chat History (src/rag.js)
import OpenAI from "openai";
import { pool } from "./database.js";
import { generateEmbedding } from "./embeddings.js";
const openai = new OpenAI();
export async function retrieveContext(query, topK = 4, threshold = 0.65) {
const embedding = await generateEmbedding(query);
const vectorStr = JSON.stringify(embedding);
const sql = `
SELECT
c.content,
d.filename,
(1 - (c.embedding <=> $1::vector)) AS similarity
FROM document_chunks c
JOIN documents d ON c.document_id = d.id
WHERE d.status = 'ready'
AND (1 - (c.embedding <=> $1::vector)) >= $2
ORDER BY c.embedding <=> $1::vector ASC
LIMIT $3;
`;
const { rows } = await pool.query(sql, [vectorStr, threshold, topK]);
return rows;
}
export async function answerQuestion(userQuestion, chatHistory = []) {
const contextChunks = await retrieveContext(userQuestion);
if (contextChunks.length === 0) {
return {
answer: "I could not find relevant information in the uploaded documents to answer your question.",
sources: []
};
}
const formattedContext = contextChunks
.map((c) => `[Source: ${c.filename}]\n${c.content}`)
.join("\n\n---\n\n");
const sources = [...new Set(contextChunks.map((c) => c.filename))];
// Construct conversation messages
const messages = [
{
role: "system",
content: `You are a helpful knowledge assistant.
Answer the question based ONLY on the provided document context snippets.
If the context does not contain enough information, honestly state that you cannot answer.
Always reference which source document provided the facts.`
}
];
// Append recent chat history (last 4 messages) for multi-turn conversations
if (Array.isArray(chatHistory) && chatHistory.length > 0) {
messages.push(...chatHistory.slice(-4));
}
// Append the active question with context
messages.push({
role: "user",
content: `DOCUMENT CONTEXT:\n${formattedContext}\n\nUSER QUESTION:\n${userQuestion}`
});
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0.2,
messages
});
return {
answer: response.choices[0].message.content,
sources
};
}7. Step 5: Express REST API Server (src/server.js)
import express from "express";
import multer from "multer";
import cors from "cors";
import { pool, initDatabase } from "./database.js";
import { chunkDocument } from "./chunker.js";
import { generateBatchEmbeddings } from "./embeddings.js";
import { answerQuestion } from "./rag.js";
const app = express();
const upload = multer({ storage: multer.memoryStorage() });
app.use(cors());
app.use(express.json());
// Background ingestion handler
async function processDocumentAsync(docId, text, filename) {
try {
const chunks = chunkDocument(text);
if (chunks.length === 0) return;
// Batch embedding generation for performance
const embeddings = await generateBatchEmbeddings(chunks);
for (let i = 0; i < chunks.length; i++) {
const vectorStr = JSON.stringify(embeddings[i]);
await pool.query(
`INSERT INTO document_chunks (document_id, content, embedding, chunk_index)
VALUES ($1, $2, $3, $4)`,
[docId, chunks[i], vectorStr, i]
);
}
await pool.query("UPDATE documents SET status = 'ready' WHERE id = $1", [docId]);
console.log(`âś… Successfully processed "${filename}" (${chunks.length} chunks)`);
} catch (error) {
console.error(`❌ Processing failed for "${filename}":`, error);
await pool.query("UPDATE documents SET status = 'failed' WHERE id = $1", [docId]);
}
}
// 1. Upload a Document
app.post("/upload", upload.single("file"), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded." });
}
const filename = req.file.originalname;
const rawText = req.file.buffer.toString("utf-8");
const { rows } = await pool.query(
"INSERT INTO documents (filename) VALUES ($1) RETURNING id",
[filename]
);
const docId = rows[0].id;
// Process chunking and vector storage in background
setImmediate(() => processDocumentAsync(docId, rawText, filename));
res.status(202).json({
message: "Document uploaded and is currently being processed.",
documentId: docId,
status: "processing"
});
});
// 2. List All Documents
app.get("/documents", async (req, res) => {
const { rows } = await pool.query("SELECT * FROM documents ORDER BY created_at DESC");
res.json({ documents: rows });
});
// 3. Ask a Question with RAG
app.post("/ask", async (req, res) => {
const { question, chatHistory } = req.body;
if (!question) {
return res.status(400).json({ error: "Field 'question' is required." });
}
try {
const result = await answerQuestion(question, chatHistory);
res.json(result);
} catch (err) {
console.error("RAG Query Error:", err);
res.status(500).json({ error: "Failed to generate answer." });
}
});
// Start the server
const PORT = process.env.PORT || 3000;
app.listen(PORT, async () => {
await initDatabase();
console.log(`🚀 AI Document Q&A server listening on port ${PORT}`);
});8. Testing Your API with curl
Upload a Document
curl -X POST http://localhost:3000/upload \
-F "file=@company_handbook.md"Ask a Grounded Question
curl -X POST http://localhost:3000/ask \
-H "Content-Type: application/json" \
-d '{
"question": "What is our policy on remote work and equipment expenses?",
"chatHistory": []
}'9. Production Hardening Checklist
When shipping an AI backend into production:
- Rate Limiting: Protect your endpoints with
express-rate-limitto prevent users from consuming your OpenAI API quota. - Redis Caching: Cache identical queries so you never pay OpenAI twice for the same answer.
- Authentication: Use JWT or session auth to ensure users can only query documents owned by their organization.
- Observability: Log token counts (
usage.total_tokens) into Datadog or Prometheus to track per-user AI costs.
🎉 Congratulations! You now have a complete understanding of how to build, deploy, and scale production AI backend features with JavaScript.