Module 7.4 — Pinecone Cloud Vector Database

Replace in-memory storage with a real production database that persists forever


Why Pinecone — The Problem With What We Have Now

Your RAG system currently uses MemoryVectorStore. It stores all data in RAM.

Current problems:
→ Restart the server → all data is lost
→ 10 users load different PDFs → memory gets full
→ Deploy to production → every restart requires re-indexing everything
→ 1 million document chunks → RAM overflow

Pinecone solution:
→ Data is stored permanently in the cloud
→ Restart the server → data remains available
→ Billions of vectors → search in milliseconds
→ Free tier available → perfect for development
→ Web dashboard → view your data visually (similar to MongoDB Atlas)


What is Pinecone?

Pinecone is a managed cloud vector database. Your workflow is simple:

  1. Create a Pinecone account (free)
  2. Create an index (similar to a table in SQL)
  3. Upsert vectors (store embeddings)
  4. Query vectors (search)

What Pinecone does:
→ Stores your data on AWS, GCP, or Azure
→ Automatically manages HNSW indexing
→ Performs millisecond searches across billions of vectors
→ Provides a web dashboard to view and manage your data


Step 1 — Pinecone Account Setup

1. Go to: https://app.pinecone.io

2. Sign up — free account, no credit card needed

3. Get API Key:

Database → API Keys → Create API Key
Copy the key → looks like: "pcsk_abc123..."

4. Index banao:

Database → Indexes → Create Index

Name:      pdf-chatbot
Dimensions: 1536        ← must match text-embedding-3-small
Metric:    cosine       ← best for text
Type:      Serverless   ← free, auto-scales
Cloud:     AWS
Region:    us-east-1

Click Create Index — done.


Step 2 — Install Packages

In langchain-production-rag folder:

npm install @pinecone-database/pinecone @langchain/pinecone
@pinecone-database/pinecone  → official Pinecone Node.js SDK v8.x
@langchain/pinecone          → LangChain's Pinecone integration provides PineconeStore class


    {
        "name": "langchain-production-rag",
        "version": "1.0.0",
        "type": "module",
        "main": "index.js",
        "scripts": {
            "start": "node src/rag.js",
            "test": "echo \"Error: no test specified\" && exit 1"
        },
        "keywords": [],
        "author": "",
        "license": "ISC",
        "description": "",
        "dependencies": {
            "@langchain/community": "^1.1.29",
            "@langchain/core": "^1.2.3",
            "@langchain/langgraph": "^1.4.8",
            "@langchain/openai": "^1.5.5",
            "@langchain/pinecone": "^1.0.3",
            "@langchain/textsplitters": "^1.0.1",
            "@pinecone-database/pinecone": "^8.1.0",
            "cors": "^2.8.6",
            "dotenv": "^16.4.5",
            "express": "^5.2.1",
            "langchain": "^1.5.3",
            "pdf-parse": "^2.4.5"
        }
    }


Step 3 — Update .env

OPENAI_API_KEY=sk-proj-your-openai-key
PINECONE_API_KEY=pcsk_your-pinecone-key
PINECONE_INDEX=pdf-chatbot

Step 4 — Create src/pineconeStore.js

This file replaces MemoryVectorStore with PineconeStore:


    // ═══════════════════════════════════════════════════════════════════════════
    // IMPORTS
    // ═══════════════════════════════════════════════════════════════════════════

    import { Pinecone } from "@pinecone-database/pinecone";
    // Official Pinecone SDK (v8.x)
    // gives us a client to connect to, write to, and query a Pinecone vector database

    import { OpenAIEmbeddings } from "@langchain/openai";
    // OpenAIEmbeddings = converts text → a 1536-number vector
    // used both to embed the PDF chunks (once) and to embed the user's query (every search)

    import { Document } from "@langchain/core/documents";
    // Document = LangChain's standard document class
    // shape: { pageContent: "text...", metadata: { page: 0, source: "file.pdf" } }
    // keeping search results in this shape means the rest of the app (createRAGAgent,
    // searchTool in api.js) doesn't need to know or care that Pinecone is being used

    import * as dotenv from "dotenv";
    dotenv.config();
    // loads PINECONE_API_KEY and PINECONE_INDEX (and other secrets) from the .env file
    // into process.env, so they can be read below


    // ═══════════════════════════════════════════════════════════════════════════
    // SETUP
    // ═══════════════════════════════════════════════════════════════════════════

    const pineconeClient = new Pinecone({
        apiKey: process.env.PINECONE_API_KEY,
        // reads the API key from .env
        // example value: "pcsk_abc123..."
    });
    // pineconeClient = an authenticated connection object used to talk to Pinecone's cloud API

    const embeddings = new OpenAIEmbeddings({
        model: "text-embedding-3-small",
        // this model produces 1536-dimensional vectors
        // IMPORTANT: this must match the dimension configured when the Pinecone index was created,
        // otherwise upserts/queries will fail
    });


    // ═══════════════════════════════════════════════════════════════════════════
    // HELPER — Get Pinecone Index reference
    // Conceptually like opening a connection to one specific table in a database
    // ═══════════════════════════════════════════════════════════════════════════

    function getPineconeIndex() {
        const indexName = process.env.PINECONE_INDEX;
        // example value: "pdf-chatbot"

        if (!indexName) {
            throw new Error("PINECONE_INDEX is not set in .env file");
        }

        return pineconeClient.index(indexName);
        // returns an Index object pointing at the "pdf-chatbot" index
        // this does NOT create the index — it must already exist in the Pinecone dashboard
        //
        // example return value (internal object):
        // Index { name: "pdf-chatbot", host: "pdf-chatbot-xxx.svc.pinecone.io" }
    }


    // ═══════════════════════════════════════════════════════════════════════════
    // HELPER — Build a retriever object for a given namespace
    // Both buildPineconeVectorStore() and loadExistingVectorStore() need the exact
    // same "search this namespace" behavior, so it lives here once and gets reused.
    // ═══════════════════════════════════════════════════════════════════════════

    function createRetriever(pineconeIndex, namespace) {
        // pineconeIndex = the Index object returned by getPineconeIndex()
        // namespace     = which namespace inside that index to search within

        return {
            namespace,
            // exposed so callers can check/log which namespace this retriever is bound to

            invoke: async (query, k = 5) => {
                // invoke(query, k) = search for the top k most similar chunks to `query`
                // query = the search text (usually the user's question)
                // k     = how many results to return (defaults to 5)
                //
                // example call: retriever.invoke("what are the pension benefits?", 5)

                const queryVector = await embeddings.embedQuery(query);
                // embeds the query text using the SAME model used for the stored documents
                // this MUST match — otherwise the vectors are not comparable to each other
                // example queryVector: [0.19, -0.82, 0.38, ...] (1536 numbers total)

                const results = await pineconeIndex.namespace(namespace || "").query({
                    vector: queryVector,
                    // the query vector to compare against every stored vector

                    topK: k,
                    // return only the top k most similar vectors

                    includeMetadata: true,
                    // include the metadata we stored (text, source, page) in the results
                    // without this flag, Pinecone only returns ids + similarity scores
                });
                // results.matches = array of matching vectors, sorted by similarity score (highest first)
                // example results.matches:
                // [
                //   {
                //     id: "sample-chunk-2",
                //     score: 0.89,           // cosine similarity — 1.0 = identical meaning
                //     metadata: {
                //       text: "APY gives guaranteed pension...",
                //       source: "./sample.pdf",
                //       page: 0
                //     }
                //   },
                //   { id: "sample-chunk-7", score: 0.81, metadata: {...} },
                //   ...
                // ]

                return results.matches.map(match => new Document({
                    pageContent: match.metadata?.text || "",
                    // the original chunk text, pulled back out of the stored metadata

                    metadata: {
                        source: match.metadata?.source || "",
                        page: match.metadata?.page || 0,
                        score: match.score,
                        // similarity score for this match — higher means more relevant
                        // example: 0.89 (very relevant), 0.45 (weakly relevant)
                    },
                }));
                // returns an array of Document objects — same shape LangChain retrievers normally return
                // this is what lets createRAGAgent()'s searchTool work without any changes
            },
        };
        // example returned retriever object:
        // {
        //   namespace: "sample",
        //   invoke: async (query, k) => Document[]
        // }
    }


    // ═══════════════════════════════════════════════════════════════════════════
    // FUNCTION: buildPineconeVectorStore
    // Step 1: clear out any old vectors for this namespace
    // Step 2: embed all chunks using OpenAI
    // Step 3: upsert (insert/update) those vectors into Pinecone
    // Step 4: return a simple retriever interface for searching them
    // ═══════════════════════════════════════════════════════════════════════════

    export async function buildPineconeVectorStore(chunks, namespace) {
        // chunks    = array of LangChain Document objects produced by the text splitter
        //             example:
        //             [
        //               Document { pageContent: "APY gives pension...", metadata: { page: 0, source: "./sample.pdf" } },
        //               Document { pageContent: "subscriber gets Rs.1000...", metadata: { page: 1 } },
        //             ]
        //
        // namespace = string used to keep this PDF's vectors separate from other PDFs
        //             example: "sample" for sample.pdf
        //             all vectors for this PDF are written into this namespace only
        //             vectors belonging to other namespaces are never touched

        if (!chunks || chunks.length === 0) {
            throw new Error("No chunks provided to store in Pinecone");
        }

        console.log(`\n💾 Storing ${chunks.length} chunks in Pinecone...`);
        console.log(`   Index:     ${process.env.PINECONE_INDEX}`);
        console.log(`   Namespace: ${namespace || "default"}`);
        console.log(`   Embedding with OpenAI text-embedding-3-small...`);

        const pineconeIndex = getPineconeIndex();
        // get a reference to the "pdf-chatbot" index

        // ── STEP 1: Clear existing vectors in this namespace ────────────────────
        try {
            await pineconeIndex.namespace(namespace || "").deleteAll();
            // deletes ALL existing vectors currently in this namespace
            // this stops old PDF data from mixing together with newly uploaded PDF data
            // vectors in OTHER namespaces are completely unaffected
            console.log(`   Cleared existing vectors in namespace "${namespace}"`);
        } catch (err) {
            // this throws if the namespace is already empty / doesn't exist yet — that's fine, just continue
            console.log(`   Namespace was empty or doesn't exist yet — continuing`);
        }

        // ── STEP 2: Embed all chunks using OpenAI ───────────────────────────────
        const texts = chunks.map(chunk => chunk.pageContent);
        // pulls out just the plain text from each Document object
        // example texts:
        // [
        //   "APY gives guaranteed pension of Rs.1000-5000...",
        //   "Subscriber must be between 18-40 years of age...",
        //   ...12 more strings
        // ]

        const vectors = await embeddings.embedDocuments(texts);
        // embedDocuments() batch-embeds all texts in one (or a few) API calls
        // returns an array of vectors, one per input text, in the same order
        // example return value:
        // [
        //   [0.023, -0.089, 0.041, ...],  // 1536 numbers for chunk 0
        //   [0.071, 0.042, -0.013, ...],  // 1536 numbers for chunk 1
        //   ...
        // ]
        console.log(`   Got ${vectors.length} embeddings from OpenAI`);

        // ── STEP 3: Build records to upsert into Pinecone ───────────────────────
        const records = chunks.map((chunk, i) => ({
            id: `${namespace || "doc"}-chunk-${i}`,
            // unique ID for this vector inside Pinecone
            // example: "sample-chunk-0", "sample-chunk-1", "sample-chunk-2"
            // must be unique within the namespace

            values: vectors[i],
            // the 1536-number vector generated for this exact chunk
            // example: [0.023, -0.089, 0.041, ...]

            metadata: {
                text: chunk.pageContent,
                // stores the original chunk text so it can be retrieved again after a search
                // Pinecone stores this alongside the vector itself
                // example: "APY gives guaranteed pension of Rs.1000-5000..."

                source: chunk.metadata.source || "",
                // which file this chunk originally came from
                // example: "./sample.pdf"

                page: chunk.metadata.page ?? 0,
                // which page number in the PDF this chunk came from
                // example: 0, 1, 2, ...

                chunkIndex: i,
                // the position of this chunk within the overall sequence
                // example: 0, 1, 2, ..., 11
            },
        }));
        // records = array of plain objects ready to be sent to Pinecone
        // example single record:
        // {
        //   id: "sample-chunk-0",
        //   values: [0.023, -0.089, ...], // 1536 numbers
        //   metadata: {
        //     text: "APY gives guaranteed pension...",
        //     source: "./sample.pdf",
        //     page: 0,
        //     chunkIndex: 0
        //   }
        // }

        // ── STEP 4: Upsert records in batches ───────────────────────────────────
        const BATCH_SIZE = 100;
        // upserts 100 records at a time
        // Pinecone recommends batch sizes of roughly 100-200 for best performance
        // this also avoids "payload too large" errors on big documents with many chunks

        for (let i = 0; i < records.length; i += BATCH_SIZE) {
            const batch = records.slice(i, i + BATCH_SIZE);
            // takes the next 100 records (or fewer, for the final leftover batch)
            // example: records 0-99, then 100-199, then remaining ones, etc.

            await pineconeIndex.namespace(namespace || "").upsert({ records: batch });
            // .namespace(namespace) = targets this specific namespace inside the index
            // .upsert(batch)        = inserts new vectors or updates existing ones with the same id
            //
            // "upsert" = "update if it exists, insert if it doesn't"
            // if a vector with the same id already exists → it gets overwritten
            // if it doesn't exist yet → it gets inserted fresh

            console.log(`   Upserted batch ${Math.floor(i / BATCH_SIZE) + 1}: records ${i} to ${Math.min(i + BATCH_SIZE, records.length) - 1}`);
        }

        console.log(`   ✅ All ${records.length} chunks stored in Pinecone!\n`);

        // ── STEP 5: Return a simple retriever interface ─────────────────────────
        // we build our own retriever object (instead of using LangChain's PineconeStore wrapper)
        // so the exact same query/response shape is used everywhere in the app
        return createRetriever(pineconeIndex, namespace);
        // example returned retriever object:
        // {
        //   namespace: "sample",
        //   invoke: async (query, k) => Document[]
        // }
    }


    // ═══════════════════════════════════════════════════════════════════════════
    // FUNCTION: loadExistingVectorStore
    // Reconnects to data that was already indexed into Pinecone in a previous run.
    // Use this after a server restart — no re-embedding or re-uploading needed.
    // ═══════════════════════════════════════════════════════════════════════════

    export async function loadExistingVectorStore(namespace) {
        // namespace = which namespace to reconnect to
        // example: "sample"
        //
        // This is the key advantage of using Pinecone over an in-memory store:
        //   in-memory store → server restarts → data is gone → PDF must be re-indexed
        //   Pinecone store  → server restarts → data is still safe in the cloud → just reconnect

        console.log(`\n🔄 Reconnecting to Pinecone namespace: "${namespace}"`);

        const pineconeIndex = getPineconeIndex();
        // get a reference to the index — the data is already sitting there in the cloud

        const stats = await pineconeIndex.namespace(namespace || "").describeIndexStats();
        // checks how many vectors currently exist in this namespace
        // example stats: { namespaces: { "sample": { recordCount: 12 } }, dimension: 1536, totalRecordCount: 12 }

        console.log(`   Found vectors in Pinecone — reconnected\n`);

        // returns the exact same retriever interface used by buildPineconeVectorStore()
        return createRetriever(pineconeIndex, namespace);
    }


    // ═══════════════════════════════════════════════════════════════════════════
    // FUNCTION: getIndexStats
    // Shows how many vectors currently exist in Pinecone, broken down by namespace.
    // Conceptually similar to: SELECT COUNT(*) ... GROUP BY namespace in SQL
    // ═══════════════════════════════════════════════════════════════════════════

    export async function getIndexStats() {
        const pineconeIndex = getPineconeIndex();

        const stats = await pineconeIndex.describeIndexStats();
        // calls the Pinecone API to get index-level statistics
        //
        // example return value:
        // {
        //   namespaces: {
        //     "sample":       { recordCount: 12 },
        //     "my-document":  { recordCount: 25 }
        //   },
        //   dimension: 1536,
        //   indexFullness: 0.0,
        //   totalRecordCount: 37
        // }

        return {
            totalVectors: stats.totalRecordCount,
            // total number of vectors across every namespace combined
            // example: 37

            namespaces: stats.namespaces,
            // per-namespace breakdown
            // example: { "sample": { recordCount: 12 } }

            dimension: stats.dimension,
            // the vector dimension used by this index — should be 1536 for text-embedding-3-small
        };
    }


Step 5 — Update src/api.js


    // ═══════════════════════════════════════════════════════════════════════════
    // 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
    });


Step 6 — Run and Test

node src/api.js

Load a PDF:

curl -X POST http://localhost:3001/api/load-pdf \
  -H "Content-Type: application/json" \
  -d '{"pdfPath": "./sample.pdf"}'

Check Pinecone stats:

curl http://localhost:3001/api/stats

Expected output:

{
  "success": true,
  "pineconeIndex": "pdf-chatbot",
  "totalVectors": 25,
  "namespaces": {
    "sample": { "recordCount": 25 }
  },
  "dimension": 1536
}

Now restart the server — ask a question — data still works!

# Stop server (Ctrl+C)
# Restart
node src/api.js

frontend file changes

update usePDFLoader.js file


    "use client";

    import { useState, useEffect } from "react";

    export function usePDFLoader() {

        const [isLoading, setIsLoading] = useState(false);
        const [isLoaded, setIsLoaded] = useState(false);
        const [error, setError] = useState(null);
        const [pdfStats, setPdfStats] = useState(null);

        // ── CHECK ON STARTUP ────────────────────────────────────────
        // When frontend loads — ask server if document is already ready
        // This handles the case where server has Pinecone data from before
        useEffect(() => {
            async function checkServerStatus() {
                try {
                    const response = await fetch(
                        `${process.env.NEXT_PUBLIC_API_URL}/api/health`
                    );
                    const data = await response.json();
                    // example data: { status: "ok", documentLoaded: true, isIndexing: false }

                    if (data.documentLoaded) {
                        // server already has a document loaded (from Pinecone auto-connect)
                        setIsLoaded(true);
                        setPdfStats({
                            fileName: "Previously loaded document",
                            pages: "",
                            chunks: "",
                            // we don't have exact stats for auto-connected docs
                            // show generic message
                        });
                        console.log("Server already has document loaded — ready to chat");
                    }
                } catch (err) {
                    // server might not be running yet — ignore silently
                    console.log("Could not reach server:", err.message);
                }
            }

            checkServerStatus();
            // runs once when component mounts (page loads)
        }, []);
        // empty dependency array = run only once on mount


        async function loadPDF(pdfPath) {
            if (!pdfPath.trim()) {
                setError("Please enter a PDF file path");
                return;
            }

            setIsLoading(true);
            setError(null);

            try {
                const response = await fetch(
                    `${process.env.NEXT_PUBLIC_API_URL}/api/load-pdf`,
                    {
                        method: "POST",
                        headers: { "Content-Type": "application/json" },
                        body: JSON.stringify({ pdfPath }),
                    }
                );

                const data = await response.json();

                if (!response.ok || !data.success) {
                    throw new Error(data.error || "Failed to load PDF");
                }

                setPdfStats(data.stats);
                setIsLoaded(true);

            } catch (err) {
                setError(err.message);
                setIsLoaded(false);
            } finally {
                setIsLoading(false);
            }
        }

        function reset() {
            setIsLoaded(false);
            setPdfStats(null);
            setError(null);
        }

        return { isLoading, isLoaded, error, pdfStats, loadPDF, reset };
    }


Pinecone Dashboard — Visual View

Pinecone's dashboard shows many of the same things that MongoDB Atlas does.

app.pinecone.io → your project → Indexes → pdf-chatbot

┌─────────────────────────────────────────────────────┐
│  Index: pdf-chatbot                                 │
│  Dimension: 1536  │  Metric: cosine  │  Serverless  │
├─────────────────────────────────────────────────────┤
│  Total Vectors: 25                                  │
│                                                     │
│  Namespaces:                                        │
│  → sample        25 vectors                         │
│  → my-document   12 vectors                         │
├─────────────────────────────────────────────────────┤
│  [Browse Vectors]  [Query Console]  [Metrics]       │
└─────────────────────────────────────────────────────┘

Browse Vectors → View each vector's ID, metadata, and vector values.


MemoryVectorStore vs PineconeStore

Feature

MemoryVectorStore

PineconeStore

Storage

RAM (temporary)

Cloud (permanent)

Server restart

Data gone

Data safe

Max vectors

~50K (RAM limited)

Billions

Cost

Free

Free tier available

Setup

Zero setup

Account + index required

Speed

Fast (local)

~50–100 ms (network)

Dashboard

None

Full web UI

Production ready

No

Yes

Code change needed

N/A

Just swap the import


3-Line Summary

  1. Pinecone is a managed cloud vector database — vectors stored permanently in the cloud so data survives server restarts, scales to billions of vectors, and comes with a web dashboard to visually browse your stored data.
  2. The code change is minimal — replace MemoryVectorStore.fromDocuments() with PineconeStore.fromDocuments() and add a namespace parameter — everything else (retriever, agent, chat routes) stays exactly the same.
  3. Namespaces in Pinecone work like folders — different PDFs go into different namespaces within the same index so their vectors never mix, and you can delete one PDF's data without affecting others.

Module 7.4 — Complete ✅

Phase 7 — Complete 🎉

✅ Module 7.1 — Production RAG System with LangChain
✅ Module 7.2 — Streaming, Error Handling & Express API
✅ Module 7.3 — Next.js Frontend
✅ Module 7.4 — Pinecone Cloud Vector Database

Coming up — Phase 8: AI Agents

Advanced agent concepts including the ReAct pattern, multi-agent systems, agent memory, and real-world projects such as a Resume Analyzer and a Research Agent.

No comments:

Post a Comment

Module 7.4 — Pinecone Cloud Vector Database

Replace in-memory storage with a real production database that persists forever Why Pinecone — The Problem With What We Have Now Your RAG sy...