Pinecone is a managed cloud vector database. Your workflow is simple:
// ═══════════════════════════════════════════════════════════════════════════
// IMPORTS
// ═══════════════════════════════════════════════════════════════════════════
import express from "express";
// express = Node.js web framework used to build the HTTP server
// provides methods like app.get(), app.post() to define API routes
// when a request comes in → express routes it to the correct handler function
import cors from "cors";
// cors = Cross-Origin Resource Sharing middleware
// browsers block requests between different origins by default
// example: Next.js on localhost:3000 calling Express on localhost:3001 → blocked without cors
// cors middleware adds the right headers so the browser allows the request
import { createAgent, tool } from "langchain";
// createAgent = builds a complete LangGraph agent (LLM + tools + memory + system prompt)
// tool = wraps any JavaScript function into a tool the agent can call
import { MemorySaver } from "@langchain/langgraph";
// MemorySaver = stores agent conversation state as checkpoints in memory
// each unique thread_id gets its own saved state
// when the same thread_id is used again → agent loads that state and remembers previous messages
import { ChatOpenAI } from "@langchain/openai";
// ChatOpenAI = LangChain wrapper around OpenAI's chat completion API
// internally calls: POST https://api.openai.com/v1/chat/completions
import { buildPineconeVectorStore, loadExistingVectorStore, getIndexStats } from "./pineconeStore.js";
// buildPineconeVectorStore = embeds text chunks and stores their vectors in Pinecone (cloud DB)
// loadExistingVectorStore = reconnects to previously stored vectors (used after server restart)
// getIndexStats = fetches how many vectors currently exist in the Pinecone index
import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
// PDFLoader = reads a PDF file from disk and extracts its text content
// returns an array of Document objects — one Document per page
// each Document looks like: { pageContent: "text...", metadata: { source, page } }
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
// RecursiveCharacterTextSplitter = splits large Document text into smaller chunks
// tries these separators in order: "\n\n" → "\n" → ". " → " " → ""
// this means it prefers clean paragraph splits over cutting mid-sentence
// preserves metadata (like page number) from the original Document into every chunk it creates
import { z } from "zod";
// zod = schema validation library
// used to define exactly what inputs each tool accepts
// example: z.string() means the input must be a string
// if the LLM passes the wrong type → zod throws an error before the tool runs
import { validateQuestion, formatErrorResponse } from "./errorHandling.js";
// validateQuestion = checks user input before sending it to the AI
// throws custom errors if question is empty / too short / too long
// formatErrorResponse = converts any error into a clean JSON object for the frontend
// example output: { success: false, error: "Please type a question", code: "EMPTY_QUERY" }
import * as dotenv from "dotenv";
dotenv.config();
// dotenv.config() reads the .env file and loads its values into process.env
// after this line runs: process.env.OPENAI_API_KEY = "sk-proj-..."
// LangChain and OpenAI clients automatically read credentials from process.env
// ═══════════════════════════════════════════════════════════════════════════
// EXPRESS APP SETUP
// ═══════════════════════════════════════════════════════════════════════════
const app = express();
// app = the main Express application object
// all routes and middleware are registered on this object
const PORT = process.env.PORT || 3001;
// PORT = which TCP port the server will listen on
// process.env.PORT = set this automatically in production (e.g. Render, Railway)
// fallback: 3001 so it doesn't clash with Next.js which usually runs on 3000
// example value: 3001
app.use(cors({
origin: ["http://localhost:3000", "http://localhost:3001"],
// origin = list of allowed frontend URLs that are permitted to call this API
// the browser checks this — if the request comes from a different origin → it gets blocked
// in production, replace these with your real domain, e.g. "https://yourdomain.com"
}));
// cors() returns a middleware function
// app.use() registers that middleware to run on every incoming request
// it adds this header to every response: Access-Control-Allow-Origin: http://localhost:3000
app.use(express.json());
// express.json() = middleware that parses the incoming request body as JSON
// without this: req.body = undefined
// with this: req.body = { question: "what is this doc about?", sessionId: "session_123" }
// ═══════════════════════════════════════════════════════════════════════════
// GLOBAL STATE
// These variables are shared across all incoming HTTP requests.
// They live in server memory (RAM) for as long as the process is running.
// ═══════════════════════════════════════════════════════════════════════════
let vectorStore = null;
// vectorStore = reference to the currently active Pinecone vector store / namespace info
// null = no PDF has been loaded yet
// after /api/load-pdf succeeds, it is set to the object returned by buildPineconeVectorStore()
// example value once set: { namespace: "sample" }
let isIndexing = false;
// isIndexing = flag used to prevent two PDF loads from running at the same time
// false = server is idle, ready to accept a new load request
// true = server is currently loading/embedding a PDF
// if a second /api/load-pdf request arrives while this is true → server responds with 409 Conflict
let ragAgent = null;
// ragAgent = the LangGraph agent created by createRAGAgent()
// null = no PDF loaded yet → agent cannot answer questions
// after /api/load-pdf succeeds (or after auto-connect on startup):
// ragAgent = CompiledStateGraph { ... } (a ready-to-use LangGraph agent object)
// this same agent instance is reused for every /api/chat and /api/chat/stream request
// ═══════════════════════════════════════════════════════════════════════════
// AGENT FACTORY FUNCTION
// Called once whenever a PDF is loaded (or existing data is auto-connected).
// Builds and returns a fully configured RAG (Retrieval-Augmented Generation) agent.
// ═══════════════════════════════════════════════════════════════════════════
function createRAGAgent(retriever) {
// retriever = an object with an .invoke(query, k) method
// calling retriever.invoke("some query", 5) returns the 5 most relevant Document chunks
// example return value of retriever.invoke("pension benefits", 5):
// [
// Document { pageContent: "subscribers get pension of 1000...", metadata: { page: 0 } },
// Document { pageContent: "pension continues to spouse...", metadata: { page: 1 } },
// ...3 more
// ]
// ── TOOL DEFINITION ──────────────────────────────────────────────────────
// A tool = a JavaScript function that the LLM can decide to call on its own.
// The agent reads the tool's `name` + `description` to decide WHEN to use it.
// The `schema` defines exactly what inputs the LLM must provide when calling it.
const searchTool = tool(
async ({ query }) => {
// this function body runs whenever the LLM decides to call this tool
// query = the search string chosen by the LLM
// example values: "pension benefits for subscriber", "how to file a complaint"
const docs = await retriever.invoke(query, 5);
// retriever.invoke(query, 5) does the following internally:
// 1. calls the embeddings API to convert the query text → a vector
// 2. compares that vector against all stored chunk vectors in Pinecone
// 3. returns the top 5 most similar Document chunks
//
// example docs value:
// [
// Document { pageContent: "Subscriber gets Rs.1000-5000 pension...", metadata: { page: 0 } },
// Document { pageContent: "Pension continues to spouse after...", metadata: { page: 1 } },
// Document { pageContent: "Nomination details must be provided...", metadata: { page: 0 } },
// ...
// ]
if (!docs || docs.length === 0) {
return "No relevant information found in the document.";
// this string is sent back to the LLM as the tool's result
// the LLM then uses it to tell the user that nothing relevant was found
}
return docs
.map((doc, i) =>
`Source ${i + 1} (Page ${(doc.metadata.page || 0) + 1}):\n${doc.pageContent.trim()}`
)
// .map() loops through every returned Document and formats it as a labeled source
// i = index in the array (0, 1, 2, 3, 4)
// doc.metadata.page = 0-indexed page number from the PDF → +1 makes it human-readable
// doc.pageContent.trim() = the actual chunk text with leading/trailing whitespace removed
//
// example single formatted source:
// "Source 1 (Page 1):\nSubscriber gets Rs.1000-5000 guaranteed pension..."
.join("\n\n");
// joins all formatted sources into one string, separated by a blank line
//
// example final return value (what the LLM receives as the tool result):
// "Source 1 (Page 1):\nSubscriber gets Rs.1000-5000...\n\nSource 2 (Page 2):\nPension continues..."
},
{
name: "search_document",
// name = the identifier the LLM uses internally when it decides to call this tool
// example LLM call: { "tool": "search_document", "args": { "query": "..." } }
description: `Search the indexed document for information relevant to a query.
ALWAYS use this tool before answering any question about the document.
The tool returns relevant text sections — READ them and write your answer in your own words.
Do not copy-paste the sections — summarize and explain.`,
// description = what the LLM reads to decide WHEN to call this tool
// a clear, specific description makes the LLM call it at the right time
// "ALWAYS use this tool" prevents the LLM from answering purely from its own training data
schema: z.object({
query: z.string().describe("search query to find relevant information"),
// z.string() = the "query" input must be a text string
// .describe() = tells the LLM what this field is for
// the LLM must always provide this field when calling the tool
// example: { query: "what are the pension benefits?" }
}),
}
);
// searchTool = the final LangChain Tool object
// it bundles the function above together with its name, description, and input schema
// the agent keeps a reference to this tool and can call it during reasoning
// ── MEMORY SETUP ─────────────────────────────────────────────────────────
const checkpointer = new MemorySaver();
// MemorySaver = an in-memory checkpoint store for conversation history
// internally it behaves like a Map: thread_id → saved conversation state
// example internal state after two conversation turns for one session:
// {
// "session_123": {
// messages: [
// HumanMessage { content: "what is this doc about?" },
// AIMessage { content: "This document is about..." },
// HumanMessage { content: "what are the benefits?" },
// AIMessage { content: "The benefits include..." }
// ]
// }
// }
// when the same thread_id is used again → the agent loads this state and keeps full context
// ── CREATE AND RETURN THE AGENT ───────────────────────────────────────────
return createAgent({
model: new ChatOpenAI({
model: "gpt-4o",
// "gpt-4o" = GPT-4 Omni model — supports tool calling, fast, high quality answers
temperature: 0.1,
// temperature = controls how random/deterministic the output is
// 0.0 = always picks the highest-probability token → same answer every time
// 0.1 = nearly deterministic with very slight variation → good for factual Q&A
// 1.0 = highly random → good for creative writing, bad for document Q&A
}),
tools: [searchTool],
// array of tools this agent is allowed to call
// the agent reads each tool's name + description to know what's available
// during reasoning, it decides which tool (if any) to call based on the user's question
checkpointer,
// connects the MemorySaver defined above to this agent
// every time agent.invoke() or agent.stream() runs:
// BEFORE running: loads the conversation history for this thread_id from checkpointer
// AFTER running: saves the updated conversation history back into checkpointer
systemPrompt: `You are a helpful document assistant.
STRICT RULES:
1. ALWAYS call the search_document tool first to find relevant information
2. Try multiple different search queries if the first search returns nothing useful
For example: search "document content", "main topic", "introduction", "overview"
3. After getting tool results — write a clear, natural answer IN YOUR OWN WORDS
4. Do NOT output the raw source text — only write your own summary
5. Write like ChatGPT — conversational, clear, well-structured
6. At the very end mention sources used: (Source 1, Source 2, etc.)
7. If document doesn't contain the answer — say so clearly
8. Keep answers concise — 3 to 5 sentences maximum for simple questions
9. The document may be in any language — search in that language too if needed
IMPORTANT: Your response should ONLY be your written answer.
Never output the raw retrieved text chunks. That is internal data only.`,
// systemPrompt = a hidden instruction sent to the LLM before every conversation turn
// the user never sees this text — it shapes how the agent behaves and answers
// Rule 1 makes sure the agent always retrieves data first (this prevents hallucination)
// Rule 3/4 stop the agent from copy-pasting raw chunks straight into the answer
// Rule 7 keeps answers short and focused, instead of overwhelming the user
});
// the return value is a compiled LangGraph agent object with two main methods:
// .invoke(input, config) = run the agent, wait for the full response, then return it
// .stream(input, config) = run the agent, yielding output tokens as they are generated
}
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE 1 — Health Check
// GET /api/health
// Frontend calls this to check if the server is running and whether a PDF is loaded
// ═══════════════════════════════════════════════════════════════════════════
app.get("/api/health", (req, res) => {
res.json({
status: "ok",
documentLoaded: vectorStore !== null && ragAgent !== null,
// true only when BOTH vectorStore AND ragAgent are ready to use
// ragAgent can become ready in two different ways:
// a) a new PDF was loaded through POST /api/load-pdf
// b) the server auto-connected to existing Pinecone data on startup
isIndexing,
// lets the frontend show a "still processing..." state if a PDF is currently being indexed
timestamp: new Date().toISOString(),
// current server time in ISO format, e.g. "2026-07-28T10:15:30.000Z"
});
});
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE 2 — Load PDF
// POST /api/load-pdf
// Body: { pdfPath: "./sample.pdf" }
// Loads the PDF from disk, splits it into chunks, embeds and stores those chunks
// in Pinecone, then builds the RAG agent. Must be called before any chat requests.
// ═══════════════════════════════════════════════════════════════════════════
app.post("/api/load-pdf", async (req, res) => {
const { pdfPath } = req.body;
// pdfPath = the file path string sent in the request body
// example req.body: { "pdfPath": "./sample.pdf" }
// example pdfPath value: "./sample.pdf"
if (!pdfPath) {
return res.status(400).json({
success: false,
error: "pdfPath is required in request body",
});
// 400 = HTTP Bad Request — client sent an incomplete request
// "return" stops the function here — nothing below this line runs
}
if (isIndexing) {
return res.status(409).json({
success: false,
error: "Already indexing a document. Please wait.",
});
// 409 = HTTP Conflict — another indexing operation is already running
// this guards against a race condition where two requests try to index at once
}
isIndexing = true;
// set the flag to true BEFORE starting the async work below
// any request that arrives now will see isIndexing = true and immediately get a 409
try {
console.log(`\n📄 Loading PDF: ${pdfPath}`);
const loader = new PDFLoader(pdfPath, { splitPages: true });
// PDFLoader opens the file located at pdfPath
// splitPages: true = each PDF page becomes its own separate Document object
// splitPages: false = the entire PDF becomes a single Document (harder to chunk well)
const docs = await loader.load();
// reads the PDF from disk and extracts the text content of every page
// example docs value for a 3-page PDF:
// [
// Document { pageContent: "page 1 text content...", metadata: { source: "./sample.pdf", page: 0 } },
// Document { pageContent: "page 2 text content...", metadata: { source: "./sample.pdf", page: 1 } },
// Document { pageContent: "page 3 text content...", metadata: { source: "./sample.pdf", page: 2 } },
// ]
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800,
// target size of each chunk, measured in characters
// 800 characters ≈ 150–200 words ≈ roughly 1–2 paragraphs
// smaller chunkSize = more precise retrieval but more chunks & more embedding calls
// larger chunkSize = less precise retrieval but fewer chunks & fewer embedding calls
chunkOverlap: 150,
// number of characters repeated between two consecutive chunks
// this prevents losing context right at the chunk boundary
// example: the last 150 characters of chunk 1 = the first 150 characters of chunk 2
});
const chunks = await splitter.splitDocuments(docs);
// splits each full-page Document into several smaller chunks
// automatically copies metadata (like page number) from the parent Document into every chunk
// example chunks produced from a single page:
// [
// Document { pageContent: "first 800 chars of page 1...", metadata: { source: "./sample.pdf", page: 0 } },
// Document { pageContent: "overlap + next 800 chars...", metadata: { source: "./sample.pdf", page: 0 } },
// Document { pageContent: "start of page 2...", metadata: { source: "./sample.pdf", page: 1 } },
// ...
// ]
const namespace = pdfPath
.split("/").pop()
.split("\\").pop()
.replace(/\.[^/.]+$/, "")
.replace(/[^a-zA-Z0-9-_]/g, "-")
.toLowerCase()
.substring(0, 50);
// namespace = a clean, URL/Pinecone-safe version of the filename
// used to keep this PDF's vectors separate from other PDFs stored in the same Pinecone index
//
// example transformations:
// "./sample.pdf" → "sample"
// "./my document.pdf" → "my-document"
// "./APY_Brochure_2025.pdf" → "apy-brochure-2025"
//
// step by step breakdown:
// .split("/").pop() → keeps only the filename, e.g. "sample.pdf"
// .split("\\").pop() → handles Windows-style paths too
// .replace(/\.[^/.]+$/, "") → removes the file extension, e.g. "sample"
// .replace(/[^a-zA-Z0-9-_]/g, "-") → replaces any special/space characters with "-"
// .toLowerCase() → converts to lowercase
// .substring(0, 50) → caps the length at 50 characters
vectorStore = await buildPineconeVectorStore(chunks, namespace);
// embeds every chunk using the OpenAI embeddings model
// stores the resulting vectors in Pinecone under the given namespace
// this data persists in Pinecone's cloud storage even after the server restarts
ragAgent = createRAGAgent(vectorStore);
// builds a brand-new RAG agent wired up to this freshly indexed data
console.log(`✅ Indexed ${chunks.length} chunks from ${docs.length} pages`);
// example log line: "✅ Indexed 25 chunks from 7 pages"
res.json({
success: true,
message: "PDF loaded and indexed successfully",
stats: {
pages: docs.length,
// total number of pages found in the PDF, e.g. 7
chunks: chunks.length,
// total number of chunks created after splitting, e.g. 25
fileName: pdfPath.split("/").pop(),
// extracts just the filename from the full path
// example: "./sample.pdf".split("/").pop() = "sample.pdf"
},
});
// example full response body:
// { "success": true, "message": "PDF loaded...", "stats": { "pages": 7, "chunks": 25, "fileName": "sample.pdf" } }
} catch (error) {
console.error("PDF load error:", error);
vectorStore = null;
ragAgent = null;
// reset both back to null so the server is left in a clean, consistent state
// this allows the next /api/load-pdf request to try again from scratch
const response = formatErrorResponse(error);
res.status(500).json(response);
// 500 = HTTP Internal Server Error
// example response: { "success": false, "error": "Something went wrong...", "code": "UNKNOWN_ERROR" }
} finally {
isIndexing = false;
// always reset this flag — whether the load succeeded or failed
// without this line, the server would get permanently stuck if an error happened mid-indexing
}
});
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE 3 — Chat (Non-Streaming)
// POST /api/chat
// Body: { question: "...", sessionId: "..." }
// Waits for the complete answer to be generated, then returns it all at once.
// Use this for simple integrations that don't need word-by-word streaming.
// ═══════════════════════════════════════════════════════════════════════════
app.post("/api/chat", async (req, res) => {
const { question, sessionId } = req.body;
// question = the text string the user typed
// example: "what are the pension benefits?"
// sessionId = a unique conversation identifier sent by the frontend
// example: "session_1752672000000"
// used to load/save the correct conversation history in MemorySaver
if (!ragAgent) {
return res.status(400).json({
success: false,
error: "No document loaded. Call /api/load-pdf first.",
code: "NO_DOCUMENT",
});
// 400 = Bad Request — user tried to chat before any PDF was loaded
}
let validatedQuestion;
try {
validatedQuestion = validateQuestion(question);
// validateQuestion performs these checks in order:
// null / undefined / non-string value → throws EmptyQueryError
// empty string "" → throws EmptyQueryError
// shorter than 3 characters → throws RAGError (QUERY_TOO_SHORT)
// longer than 2000 characters → throws RAGError (QUERY_TOO_LONG)
// all checks pass → returns the trimmed question string
//
// example: validateQuestion(" what is APY? ") returns "what is APY?"
} catch (error) {
return res.status(400).json(formatErrorResponse(error));
// example: { "success": false, "error": "Please type a question...", "code": "EMPTY_QUERY" }
}
try {
const config = {
configurable: {
thread_id: sessionId || "default",
// thread_id = which saved conversation history to load from MemorySaver
// example: "session_1752672000000"
// if sessionId is missing → falls back to "default" (all such users share one history)
},
};
// example config value:
// { configurable: { thread_id: "session_1752672000000" } }
const result = await ragAgent.invoke(
{ messages: [{ role: "user", content: validatedQuestion }] },
// input given to the agent:
// messages = an array of message objects in OpenAI chat format
// role: "user" = this message is from the human
// content = the actual question text
// example: { messages: [{ role: "user", content: "what are the pension benefits?" }] }
config
// tells the agent which thread_id's memory to load and update
);
// ragAgent.invoke() runs the full agent loop from start to finish:
// 1. loads the conversation history from MemorySaver for this thread_id
// 2. the LLM reads the system prompt + history + the current question
// 3. the LLM decides to call the search_document tool
// 4. the tool searches the vector store and returns relevant chunks
// 5. the LLM reads those chunks and writes a grounded answer
// 6. the updated conversation (all messages) is saved back into MemorySaver
// 7. the function returns { messages: [...every message generated in this run...] }
//
// example result value:
// {
// messages: [
// HumanMessage { content: "what are the pension benefits?" },
// AIMessage { content: "", tool_calls: [{ name: "search_document", args: { query: "pension benefits" } }] },
// ToolMessage { content: "Source 1 (Page 1):\nSubscriber gets pension..." },
// AIMessage { content: "The APY scheme provides a guaranteed pension of Rs.1000 to Rs.5000..." }
// ]
// }
const lastMessage = result.messages[result.messages.length - 1];
// result.messages.length - 1 = index of the final message in the array
// the last message is always the LLM's final text response to the user
// example lastMessage value:
// AIMessage { content: "The APY scheme provides a guaranteed monthly pension..." }
res.json({
success: true,
answer: lastMessage.content,
// lastMessage.content = the actual answer text as a string
// example: "The APY scheme provides a guaranteed monthly pension of Rs.1000..."
sessionId: sessionId || "default",
// echoes the sessionId back so the frontend can reuse it in future requests
});
// example full response body:
// {
// "success": true,
// "answer": "The APY scheme provides a guaranteed monthly pension...",
// "sessionId": "session_1752672000000"
// }
} catch (error) {
console.error("Chat error:", error);
const response = formatErrorResponse(error);
res.status(500).json(response);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE 4 — Chat with Streaming
// POST /api/chat/stream
// Body: { question: "...", sessionId: "..." }
// Sends the response tokens one by one using Server-Sent Events (SSE).
//
// HOW SSE WORKS:
// Normal HTTP: client requests → server sends one full response → connection closes
// SSE: client requests → server keeps the connection open
// → server writes small data chunks one at a time
// → client receives each chunk immediately as it arrives
// → this creates the word-by-word "typing" effect seen in ChatGPT
// ═══════════════════════════════════════════════════════════════════════════
app.post("/api/chat/stream", async (req, res) => {
const { question, sessionId } = req.body;
// question = the user's question string, e.g. "what is the document about?"
// sessionId = a unique conversation ID from the frontend, e.g. "session_1752672000000"
// typically generated on the client as: `session_${Date.now()}`
if (!ragAgent) {
return res.status(400).json({
success: false,
error: "No document loaded. Call /api/load-pdf first.",
});
}
let validatedQuestion;
try {
validatedQuestion = validateQuestion(question);
// identical validation logic to the /api/chat route above
// example output: "what is the document about?"
} catch (error) {
return res.status(400).json(formatErrorResponse(error));
}
// ── SET SSE RESPONSE HEADERS ──────────────────────────────────────────────
// these headers tell the browser: "this is a streaming connection — keep it open"
res.setHeader("Content-Type", "text/event-stream");
// "text/event-stream" is the MIME type that marks this as an SSE connection
// the browser will NOT close the connection after the first chunk arrives
// the browser will parse every incoming chunk as a separate SSE event
res.setHeader("Cache-Control", "no-cache");
// tells the browser and any proxies in between: do not cache this response
// streaming responses must always be delivered fresh — caching would break them
res.setHeader("Connection", "keep-alive");
// HTTP/1.1 normally closes the connection after each response
// "keep-alive" keeps the underlying TCP connection open so we can keep writing data to it
res.setHeader("Access-Control-Allow-Origin", "*");
// a CORS header specifically for this SSE endpoint
// allows a frontend running on any origin to read this streaming response
// ── SSE EVENT HELPER ──────────────────────────────────────────────────────
function sendEvent(eventType, data) {
// eventType = a label describing what kind of event this is
// examples: "start", "token", "done", "error"
//
// data = a JavaScript object containing the event's payload
// examples:
// { message: "Starting response..." }
// { content: "This" }
// { message: "Response complete" }
// { success: false, error: "Something went wrong" }
res.write(`event: ${eventType}\n`);
// res.write() sends data to the client WITHOUT closing the connection
// (unlike res.send() / res.json(), which close the connection immediately)
//
// SSE protocol: the "event:" line sets this event's type name
// the browser's EventSource API reads this to know which handler to trigger
// example line written to the stream: "event: token\n"
res.write(`data: ${JSON.stringify(data)}\n\n`);
// JSON.stringify(data) converts the JavaScript object into a JSON string
// example: JSON.stringify({ content: "This" }) = '{"content":"This"}'
//
// SSE protocol: the "data:" line carries the actual event payload
// the double newline (\n\n) marks the end of this complete SSE event
//
// example complete SSE event written to the stream:
// "event: token\n"
// 'data: {"content":"This"}\n\n'
//
// the browser receives these two lines and fires an event with data: '{"content":"This"}'
// the frontend parses that JSON and appends "This" to the message being displayed
}
try {
const config = {
configurable: { thread_id: sessionId || "default" },
};
// same purpose as in /api/chat — tells the agent which conversation history to use
// example: { configurable: { thread_id: "session_1752672000000" } }
sendEvent("start", { message: "Starting response..." });
// the first SSE event — tells the frontend that the agent has started processing
// the frontend can use this to show a "thinking..." indicator
// written to the stream:
// "event: start\n"
// 'data: {"message":"Starting response..."}\n\n'
console.log("\n🔍 Starting stream for:", validatedQuestion);
for await (const [token, metadata] of await ragAgent.stream(
{ messages: [{ role: "user", content: validatedQuestion }] },
// same input format used by ragAgent.invoke() in the non-streaming route
{ ...config, streamMode: "messages" }
// streamMode: "messages" makes the agent yield one [token, metadata] tuple per LLM token
// instead of waiting for the full response, it yields pieces as they are generated
//
// each yielded item is a tuple:
// token = an AIMessageChunk — one small piece of the LLM's output
// metadata = { langgraph_node: "..." } — which internal node produced this chunk
)) {
// token = an AIMessageChunk object
// possible token.content values seen across iterations:
// "This" → a plain text token — should be sent to the frontend
// " document" → another plain text token — should be sent
// "" → empty string — should be skipped
// [{ type: "tool_use", ... }] → an array — this is a tool call, not text — skip
// "Source 1 (Page 1):\ntext..." → raw text but from a TOOL result — skip via getType()
//
// example metadata: { langgraph_node: "agent", langgraph_step: 3 }
console.log(
"node:", metadata?.langgraph_node,
"| type:", typeof token?.content,
"| value:", JSON.stringify(token?.content)?.substring(0, 80)
);
// debug log — useful during development, safe to remove in production
// example console output while an agent run is streaming:
// node: tools | type: string | value: "Source 1 (Page 1):\nSubscriber gets..."
// node: agent | type: string | value: "The"
// node: agent | type: string | value: " APY"
// node: agent | type: string | value: " scheme"
const content = token?.content;
// optional chaining (?.) means this returns undefined instead of throwing if token is null
// content = the actual text payload of this streamed chunk
if (token?.getType && token.getType() === "tool") continue;
// token.getType() returns the message type: "human", "ai", "tool", or "system"
// "tool" = this chunk is a ToolMessage (i.e. the raw result returned by search_document)
// we do NOT want to stream raw tool results to the user — they are internal data
// "continue" skips the rest of this loop iteration and moves to the next token
if (Array.isArray(content)) {
// an Array content value means the LLM is making a tool call, not writing text
// example: [{ type: "tool_use", name: "search_document", input: { query: "..." } }]
// this is an internal action, not user-facing text — so it is skipped
continue;
}
if (typeof content === "string" && content.length > 0) {
// typeof content === "string" → confirms this is a real text token, not a tool-call array
// content.length > 0 → confirms it actually has characters (not an empty "")
//
// example content values that PASS this check and get sent: "The", " APY", " scheme"
// example values that FAIL this check and are skipped: "" (empty), [] (an array)
sendEvent("token", { content });
// sends this text token to the frontend immediately
// written to the HTTP stream:
// "event: token\n"
// 'data: {"content":"The"}\n\n'
//
// the frontend receives this event and appends `content` to the message on screen
// the user visually sees "The" appear, then " APY", then " scheme", one after another
}
}
// the for-await loop ends automatically once the agent finishes generating its full response
// by this point every text token has already been streamed to the frontend
sendEvent("done", { message: "Response complete" });
// the final SSE event — tells the frontend that the entire response has been sent
// written to the stream:
// "event: done\n"
// 'data: {"message":"Response complete"}\n\n'
//
// when the frontend receives this it typically:
// sets isStreaming = false
// removes the blinking cursor from the message
// re-enables the input box for the next question
res.end();
// closes the HTTP connection
// without this call, the browser would keep the connection open, waiting for more events
} catch (error) {
console.error("Stream error:", error);
// logs the full error details on the server, for debugging purposes
const errorResponse = formatErrorResponse(error);
// converts the raw error into a clean, user-friendly format
// example: { success: false, error: "Something went wrong. Please try again.", code: "UNKNOWN_ERROR" }
sendEvent("error", errorResponse);
// sends an "error" event to the frontend so it can display an error message to the user
// written to the stream:
// "event: error\n"
// 'data: {"success":false,"error":"Something went wrong..."}\n\n'
res.end();
// always close the connection on error too
// otherwise the browser would hang, waiting for events that will never arrive
}
});
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE 5 — Pinecone Stats
// GET /api/stats
// Shows how many vectors are currently stored in Pinecone.
// Conceptually similar to running: SELECT COUNT(*) FROM your_table in SQL.
// ═══════════════════════════════════════════════════════════════════════════
app.get("/api/stats", async (req, res) => {
try {
const stats = await getIndexStats();
// calls the Pinecone API to fetch current index statistics
//
// example stats value:
// {
// totalVectors: 25,
// namespaces: { "sample": { recordCount: 25 } },
// dimension: 1536
// }
res.json({
success: true,
pineconeIndex: process.env.PINECONE_INDEX,
// which Pinecone index this server is configured to use
// example: "pdf-chatbot"
...stats,
// spreads every field from `stats` directly into the response body:
// totalVectors: 25
// namespaces: { "sample": { recordCount: 25 } }
// dimension: 1536
});
// example full response:
// {
// "success": true,
// "pineconeIndex": "pdf-chatbot",
// "totalVectors": 25,
// "namespaces": { "sample": { "recordCount": 25 } },
// "dimension": 1536
// }
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// ═══════════════════════════════════════════════════════════════════════════
// AUTO-CONNECT ON STARTUP
// Checks whether Pinecone already has data from a previous session.
// If it does → automatically rebuilds the agent so the user doesn't have to
// re-upload the same PDF after every server restart.
// ═══════════════════════════════════════════════════════════════════════════
async function autoConnectIfDataExists() {
try {
console.log("\n🔍 Checking Pinecone for existing data...");
const stats = await getIndexStats();
// fetches vector counts from Pinecone
// example stats: { totalVectors: 12, namespaces: { "sample": { recordCount: 12 } } }
if (stats.totalVectors === 0) {
console.log(" No existing data found — please load a PDF\n");
return;
// nothing to connect to yet — exit early and wait for a manual /api/load-pdf call
}
const namespaces = Object.keys(stats.namespaces);
// extracts all namespace names found inside the index
// example: ["sample"] or ["my-document", "apy-guide"]
if (namespaces.length === 0) {
console.log(" No namespaces found\n");
return;
}
const namespace = namespaces[0];
// simply picks the first namespace found
// example: "sample"
console.log(` Found ${stats.totalVectors} vectors in namespace "${namespace}"`);
console.log(" Auto-connecting to existing data...");
const retriever = await loadExistingVectorStore(namespace);
// reconnects to the existing vectors already stored in Pinecone
// no re-embedding happens here — this just re-establishes the connection
ragAgent = createRAGAgent(retriever);
// builds a working agent using the existing, already-indexed data
vectorStore = { namespace };
// marks vectorStore as non-null so the /api/health route reports documentLoaded: true
console.log(" ✅ Auto-connected! Ready to answer questions\n");
} catch (err) {
console.log(" Could not auto-connect:", err.message, "\n");
// any failure here is non-fatal — the server still starts up normally
// the user can simply load a PDF manually via /api/load-pdf instead
}
}
// ═══════════════════════════════════════════════════════════════════════════
// START THE SERVER
// ═══════════════════════════════════════════════════════════════════════════
app.listen(PORT, async () => {
console.log("\n" + "=".repeat(55));
console.log(`🚀 RAG API Server running on port ${PORT}`);
console.log(` Health check: http://localhost:${PORT}/api/health`);
console.log(` Load PDF: POST http://localhost:${PORT}/api/load-pdf`);
console.log(` Chat: POST http://localhost:${PORT}/api/chat`);
console.log(` Stream Chat: POST http://localhost:${PORT}/api/chat/stream`);
console.log(` Pinecone Stats: GET http://localhost:${PORT}/api/stats`);
console.log("=".repeat(55) + "\n");
await autoConnectIfDataExists();
// runs once, right after the server starts listening
// if Pinecone already has data → the agent becomes ready without needing a PDF reload
});