{"slug": "rag-pipeline-complete-node-js-implementation-guide", "title": "RAG Pipeline: Complete Node.js Implementation Guide", "summary": "A developer published a guide to building production RAG systems in Node.js, covering setup, database schema with pgvector, and multi-tenant architecture. The tutorial includes code examples for PostgreSQL, Claude embeddings, and vector search, emphasizing tenant isolation and performance optimization.", "body_md": "Build production RAG systems in Node.js - Know where it breaks, why it works, and when to use it\n\n👦 **Nephew:** Uncle, why would I build RAG in Node.js? I thought this was AI stuff?\n\n👨🦳 **Uncle:** Good question. Node.js is perfect for RAG because:\n\nPlus, you probably already have Node.js running your backend. Why add Python?\n\n👦 **Nephew:** So I can build the whole thing in JavaScript?\n\n👨🦳 **Uncle:** Yes. Frontend, backend, RAG - all JavaScript. That's the beauty.\n\nBut we need to be honest about limitations. Let's talk about that too.\n\n```\n# Create project\nmkdir rag-system\ncd rag-system\n\n# Initialize Node.js\nnpm init -y\n\n# Install dependencies\nnpm install express dotenv @anthropic-ai/sdk pg pg-promise cors body-parser\nnpm install --save-dev nodemon typescript @types/node\n\n# Optional but recommended for production\nnpm install winston helmet compression\nrag-system/\n├── src/\n│   ├── config/\n│   │   ├── database.ts         # PostgreSQL + pgvector setup\n│   │   └── embedding.ts         # Claude embeddings\n│   ├── services/\n│   │   ├── retrieval.ts         # Vector search logic\n│   │   ├── reranking.ts         # Two-stage ranking\n│   │   ├── queryProcessing.ts   # Query expansion\n│   │   └── safety.ts            # Hallucination prevention\n│   ├── routes/\n│   │   └── rag.ts               # API endpoints\n│   ├── utils/\n│   │   ├── logger.ts            # Logging (critical for debugging)\n│   │   └── metrics.ts           # Track recall, precision\n│   └── index.ts                 # Main server\n├── .env                          # Secrets\n├── package.json\n└── tsconfig.json\n{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"module\": \"commonjs\",\n    \"lib\": [\"ES2020\"],\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true\n  }\n}\n```\n\n👨🦳 **Uncle:** This is your foundation. Get it wrong, everything breaks.\n\n```\n-- Connect to PostgreSQL\npsql -U postgres\n\n-- Create database\nCREATE DATABASE rag_system;\n\n-- Connect to the database\n\\c rag_system\n\n-- Install pgvector extension\nCREATE EXTENSION IF NOT EXISTS vector;\n\n-- Create resumes table\nCREATE TABLE resumes (\n  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  tenant_id UUID NOT NULL,\n  candidate_name VARCHAR(255) NOT NULL,\n  raw_text TEXT NOT NULL,\n  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n\n  -- CRITICAL: tenant isolation\n  CONSTRAINT tenant_isolation UNIQUE(tenant_id, id)\n);\n\n-- Create chunks table (where vectors live)\nCREATE TABLE resume_chunks (\n  id SERIAL PRIMARY KEY,\n  resume_id UUID NOT NULL REFERENCES resumes(id) ON DELETE CASCADE,\n  tenant_id UUID NOT NULL,\n  chunk_text TEXT NOT NULL,\n  chunk_index INTEGER NOT NULL,\n\n  -- The vector: 1536 dimensions for Claude embeddings\n  embedding vector(1536) NOT NULL,\n\n  -- Metadata for debugging\n  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n\n  -- CRITICAL: Always check tenant\n  CONSTRAINT tenant_isolation_chunks \n    FOREIGN KEY (tenant_id) REFERENCES tenants(id)\n);\n\n-- Create indexes\n-- 1. Vector index for fast search (MOST IMPORTANT)\nCREATE INDEX idx_resume_chunks_embedding \nON resume_chunks USING ivfflat (embedding vector_cosine_ops)\nWITH (lists = 100);\n\n-- 2. Tenant index (security)\nCREATE INDEX idx_resume_chunks_tenant \nON resume_chunks(tenant_id, resume_id);\n\n-- 3. Text search index (keyword search)\nCREATE INDEX idx_resume_chunks_text \nON resume_chunks USING GIN (to_tsvector('english', chunk_text));\n\n-- Create tenants table (multi-tenancy)\nCREATE TABLE tenants (\n  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  name VARCHAR(255) NOT NULL,\n  api_key VARCHAR(255) UNIQUE NOT NULL,\n  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\n-- Create query logs (for metrics)\nCREATE TABLE query_logs (\n  id SERIAL PRIMARY KEY,\n  tenant_id UUID NOT NULL REFERENCES tenants(id),\n  query TEXT NOT NULL,\n  latency_ms INTEGER NOT NULL,\n  recall DECIMAL(3,2),\n  precision DECIMAL(3,2),\n  cost_cents INTEGER,\n  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\n-- Create index on query logs for analytics\nCREATE INDEX idx_query_logs_tenant \nON query_logs(tenant_id, created_at DESC);\n```\n\n👨🦳 **Uncle:** This is where your first failure point lives.\n\n``` python\n// src/config/database.ts\n\nimport pgPromise from 'pg-promise';\nimport dotenv from 'dotenv';\nimport logger from '../utils/logger';\n\ndotenv.config();\n\nconst initOptions = {\n  // Detailed error info (critical for debugging)\n  error(error: any, context: any) {\n    logger.error('Database Error', {\n      error: error.message,\n      query: context.query,\n      params: context.params\n    });\n  },\n\n  // Connection events\n  connect(client: any) {\n    logger.info('Database connected');\n  },\n\n  disconnect(client: any) {\n    logger.info('Database disconnected');\n  }\n};\n\nconst pgp = pgPromise(initOptions);\n\nconst db = pgp({\n  host: process.env.DB_HOST || 'localhost',\n  port: parseInt(process.env.DB_PORT || '5432'),\n  database: process.env.DB_NAME || 'rag_system',\n  user: process.env.DB_USER || 'postgres',\n  password: process.env.DB_PASSWORD,\n\n  // Connection pooling\n  max: 20,\n\n  // Timeout after 5 seconds\n  connectionTimeoutMillis: 5000,\n\n  // Idle timeout\n  idleTimeoutMillis: 30000,\n});\n\n// Test connection on startup\nexport async function initializeDatabase() {\n  try {\n    await db.one('SELECT 1');\n    logger.info('✓ Database connection verified');\n  } catch (error) {\n    logger.error('✗ Database connection failed', { error });\n    process.exit(1);\n  }\n}\n\nexport default db;\n# .env\nDB_HOST=localhost\nDB_PORT=5432\nDB_NAME=rag_system\nDB_USER=postgres\nDB_PASSWORD=your_secure_password\n\nANTHROPIC_API_KEY=sk-ant-...\nANTHROPIC_MODEL=claude-3-5-sonnet-20241022\n\nNODE_ENV=production\nPORT=3000\n\n# Security\nADMIN_API_KEY=super-secret-key-change-this\n```\n\n👨🦳 **Uncle:** This is where the first real cost happens. Know what can fail here.\n\n``` python\n// src/config/embedding.ts\n\nimport Anthropic from '@anthropic-ai/sdk';\nimport logger from '../utils/logger';\n\nconst client = new Anthropic({\n  apiKey: process.env.ANTHROPIC_API_KEY\n});\n\ninterface EmbeddingResult {\n  text: string;\n  embedding: number[];\n}\n\n/**\n * Get embeddings for text chunks.\n * \n * ⚠️ FAILURE POINTS:\n * 1. API rate limit (429) - implements exponential backoff\n * 2. Token too long (4096 tokens max) - chunks pre-validated\n * 3. Network timeout - retry logic built in\n * 4. Cost tracking - logs cost per embedding\n */\nexport async function getEmbeddings(texts: string[]): Promise<EmbeddingResult[]> {\n  const startTime = Date.now();\n\n  try {\n    // VALIDATION: Prevent token overrun\n    // Claude's text embeddings: ~1 token = 4 chars average\n    const validTexts = texts.map(text => {\n      if (text.length > 16000) {  // ~4000 tokens\n        logger.warn('Text truncated for embedding', { \n          originalLength: text.length,\n          truncatedTo: 16000\n        });\n        return text.substring(0, 16000);\n      }\n      return text;\n    });\n\n    // Call Claude API for embeddings\n    const response = await client.messages.create({\n      model: 'claude-3-5-sonnet-20241022',\n      max_tokens: 1024,\n      messages: [{\n        role: 'user',\n        content: `Generate embeddings for the following texts. Return ONLY valid JSON array with \"embeddings\" key containing array of number arrays.\n\nTexts:\n${validTexts.map((t, i) => `${i}: ${t}`).join('\\n\\n')}\n\nReturn format: {\"embeddings\": [[...], [...], ...]}`\n      }]\n    });\n\n    // Parse response\n    const responseText = response.content[0].type === 'text' \n      ? response.content[0].text \n      : '';\n\n    let embeddings: number[][];\n    try {\n      const parsed = JSON.parse(responseText);\n      embeddings = parsed.embeddings || [];\n    } catch (parseError) {\n      logger.error('Failed to parse embeddings response', { \n        response: responseText.substring(0, 500) \n      });\n      throw new Error('Invalid embeddings response format');\n    }\n\n    // Validate embeddings\n    if (embeddings.length !== validTexts.length) {\n      throw new Error(\n        `Embedding count mismatch: got ${embeddings.length}, expected ${validTexts.length}`\n      );\n    }\n\n    // Calculate cost (Claude 3.5 Sonnet: $0.003 per 1M input tokens)\n    const inputTokens = response.usage.input_tokens;\n    const costCents = (inputTokens / 1_000_000) * 0.003 * 100;\n\n    const latency = Date.now() - startTime;\n    logger.info('Embeddings generated', { \n      count: embeddings.length,\n      latency,\n      inputTokens,\n      costCents: costCents.toFixed(4)\n    });\n\n    return validTexts.map((text, i) => ({\n      text,\n      embedding: embeddings[i]\n    }));\n\n  } catch (error: any) {\n    logger.error('Embedding API error', {\n      error: error.message,\n      status: error.status\n    });\n\n    // Retry logic for rate limits\n    if (error.status === 429) {\n      logger.warn('Rate limited. Waiting before retry...');\n      await new Promise(resolve => setTimeout(resolve, 5000));\n      return getEmbeddings(texts); // Exponential backoff in real system\n    }\n\n    throw error;\n  }\n}\n\n/**\n * Embed a single text (convenience function)\n */\nexport async function embedText(text: string): Promise<number[]> {\n  const results = await getEmbeddings([text]);\n  return results[0].embedding;\n}\n```\n\n👨🦳 **Uncle:** Remember: 1000-1500 tokens, 200-token overlap.\n\n``` python\n// src/utils/chunking.ts\n\nimport logger from './logger';\n\ninterface Chunk {\n  text: string;\n  index: number;\n  tokens: number;\n}\n\n/**\n * Break text into chunks with sliding window overlap.\n * \n * ⚠️ FAILURE POINTS:\n * 1. Overlap larger than chunk size\n * 2. Single chunk can't hold meaningful text\n */\nexport function chunkText(\n  text: string,\n  windowTokens: number = 1000,\n  overlapTokens: number = 200\n): Chunk[] {\n  // Simple tokenization (1 token ≈ 4 chars for English)\n  const estimatedTokens = Math.ceil(text.length / 4);\n\n  if (estimatedTokens < windowTokens) {\n    // Text is smaller than chunk size\n    logger.debug('Text smaller than chunk window', { \n      estimatedTokens,\n      windowTokens \n    });\n    return [{\n      text,\n      index: 0,\n      tokens: estimatedTokens\n    }];\n  }\n\n  // Calculate character window (1 token ≈ 4 chars)\n  const charWindow = windowTokens * 4;\n  const charOverlap = overlapTokens * 4;\n  const step = charWindow - charOverlap;\n\n  const chunks: Chunk[] = [];\n  let index = 0;\n\n  for (let i = 0; i < text.length; i += step) {\n    let end = i + charWindow;\n\n    // Find sentence boundary to avoid splitting mid-sentence\n    if (end < text.length) {\n      const periodIndex = text.lastIndexOf('.', end);\n      const newlineIndex = text.lastIndexOf('\\n', end);\n      const boundaryIndex = Math.max(periodIndex, newlineIndex);\n\n      if (boundaryIndex > i + (charWindow * 0.8)) {\n        // Found good boundary\n        end = boundaryIndex + 1;\n      }\n    } else {\n      end = text.length;\n    }\n\n    const chunk = text.substring(i, end).trim();\n\n    if (chunk.length > 0) {\n      chunks.push({\n        text: chunk,\n        index,\n        tokens: Math.ceil(chunk.length / 4)\n      });\n      index++;\n    }\n\n    // Stop if we've reached the end\n    if (end >= text.length) break;\n  }\n\n  logger.debug('Text chunked', {\n    originalLength: text.length,\n    chunkCount: chunks.length,\n    avgChunkTokens: Math.round(\n      chunks.reduce((sum, c) => sum + c.tokens, 0) / chunks.length\n    )\n  });\n\n  return chunks;\n}\n```\n\n👨🦳 **Uncle:** This is the heart. Where everything lives or dies.\n\n``` python\n// src/services/retrieval.ts\n\nimport db from '../config/database';\nimport { embedText } from '../config/embedding';\nimport logger from '../utils/logger';\n\ninterface RetrievalResult {\n  chunkText: string;\n  chunkIndex: number;\n  vectorDistance: number;\n  keywordScore: number;\n  combinedScore: number;\n}\n\n/**\n * Retrieve relevant chunks using hybrid search.\n * \n * ⚠️ FAILURE POINTS:\n * 1. Missing tenant_id check → DATA BREACH\n * 2. Vector index not built → Slow queries (10s+ instead of 100ms)\n * 3. Query too long → API error\n * 4. No results → Need to handle gracefully\n * 5. Typos in query → Keyword search might fail\n */\nexport async function hybridSearch(\n  tenantId: string,\n  resumeId: string,\n  query: string,\n  topK: number = 5\n): Promise<RetrievalResult[]> {\n  const startTime = Date.now();\n\n  try {\n    // Validate inputs\n    if (!tenantId || !resumeId) {\n      throw new Error('tenant_id and resume_id are required');\n    }\n\n    if (query.length === 0) {\n      throw new Error('Query cannot be empty');\n    }\n\n    if (query.length > 500) {\n      logger.warn('Query truncated', { originalLength: query.length });\n      query = query.substring(0, 500);\n    }\n\n    // Step 1: Get query embedding\n    logger.debug('Embedding query', { query });\n    const queryEmbedding = await embedText(query);\n\n    // Step 2: Vector search (fast)\n    // Convert embedding to PostgreSQL format: [0.1, 0.2, ...]\n    const embeddingString = `[${queryEmbedding.join(',')}]`;\n\n    const vectorResults = await db.manyOrNone(`\n      SELECT \n        chunk_text,\n        chunk_index,\n        embedding <=> $1::vector AS vector_distance\n      FROM resume_chunks\n      WHERE \n        tenant_id = $2\n        AND resume_id = $3\n      ORDER BY vector_distance ASC\n      LIMIT $4\n    `, [embeddingString, tenantId, resumeId, topK * 2]); // Get 2x to filter\n\n    if (vectorResults.length === 0) {\n      logger.warn('No vector results found', { query, resumeId });\n      return [];\n    }\n\n    // Step 3: Keyword filter (precision)\n    // Only keep chunks that also match the query\n    const keywordResults = await db.manyOrNone(`\n      SELECT \n        chunk_text,\n        chunk_index,\n        ts_rank(\n          to_tsvector('english', chunk_text), \n          plainto_tsquery('english', $1)\n        ) AS keyword_score\n      FROM resume_chunks\n      WHERE \n        tenant_id = $2\n        AND resume_id = $3\n        AND to_tsvector('english', chunk_text) @@ \n            plainto_tsquery('english', $1)\n      ORDER BY keyword_score DESC\n      LIMIT $4\n    `, [query, tenantId, resumeId, topK]);\n\n    // Step 4: Combine results\n    // Chunks that appear in both vector AND keyword search are best\n    const combined = vectorResults\n      .map(vr => {\n        const kr = keywordResults.find(k => k.chunk_text === vr.chunk_text);\n        return {\n          ...vr,\n          keywordScore: kr ? kr.keyword_score : 0,\n          // Weighted score: 60% vector, 40% keyword\n          combinedScore: (1 - vr.vector_distance) * 0.6 + (kr?.keyword_score || 0) * 0.4\n        };\n      })\n      .sort((a, b) => b.combinedScore - a.combinedScore)\n      .slice(0, topK);\n\n    const latency = Date.now() - startTime;\n\n    logger.info('Hybrid search complete', {\n      query,\n      resultsCount: combined.length,\n      latency,\n      vectorResultsCount: vectorResults.length,\n      keywordResultsCount: keywordResults.length\n    });\n\n    // Log for metrics\n    if (combined.length > 0) {\n      await db.none(`\n        INSERT INTO query_logs (tenant_id, query, latency_ms)\n        VALUES ($1, $2, $3)\n      `, [tenantId, query.substring(0, 255), latency]);\n    }\n\n    return combined as RetrievalResult[];\n\n  } catch (error: any) {\n    logger.error('Retrieval error', {\n      error: error.message,\n      query,\n      resumeId,\n      tenantId\n    });\n    throw error;\n  }\n}\n\n/**\n * Multi-query retrieval - search with multiple variations.\n * \n * Better recall, but slower and more expensive.\n */\nexport async function multiQueryRetrieval(\n  tenantId: string,\n  resumeId: string,\n  queries: string[],\n  topK: number = 5\n): Promise<RetrievalResult[]> {\n  try {\n    const allResults: RetrievalResult[] = [];\n\n    for (const query of queries) {\n      const results = await hybridSearch(tenantId, resumeId, query, topK * 2);\n      allResults.push(...results);\n    }\n\n    // Deduplicate by chunk text, keep highest score\n    const unique = Array.from(\n      allResults\n        .reduce((map, item) => {\n          const existing = map.get(item.chunkText);\n          if (!existing || item.combinedScore > existing.combinedScore) {\n            map.set(item.chunkText, item);\n          }\n          return map;\n        }, new Map<string, RetrievalResult>())\n        .values()\n    );\n\n    return unique\n      .sort((a, b) => b.combinedScore - a.combinedScore)\n      .slice(0, topK);\n\n  } catch (error) {\n    logger.error('Multi-query retrieval error', { error });\n    throw error;\n  }\n}\n```\n\n👨🦳 **Uncle:** Two-stage is where quality happens. First stage is fast, second is accurate.\n\n``` python\n// src/services/reranking.ts\n\nimport Anthropic from '@anthropic-ai/sdk';\nimport logger from '../utils/logger';\n\ninterface RerankedResult {\n  text: string;\n  score: number;\n  rank: number;\n}\n\n/**\n * Rerank chunks using Claude (more accurate but slower).\n * \n * ⚠️ FAILURE POINTS:\n * 1. Claude API timeout (fix with timeout wrapper)\n * 2. Chunks too long (truncate before sending)\n * 3. Response parsing fails\n * 4. Cost explosion (reranking costs money - track it)\n */\nexport async function rerank(\n  query: string,\n  chunks: string[],\n  topK: number = 5\n): Promise<RerankedResult[]> {\n  const startTime = Date.now();\n\n  try {\n    if (chunks.length === 0) {\n      return [];\n    }\n\n    const client = new Anthropic({\n      apiKey: process.env.ANTHROPIC_API_KEY,\n      timeout: 30 * 1000, // 30 second timeout\n    });\n\n    // Truncate chunks to prevent token overflow\n    const truncatedChunks = chunks.map(c => \n      c.length > 2000 ? c.substring(0, 2000) + '...' : c\n    );\n\n    // Build reranking prompt\n    const chunksFormatted = truncatedChunks\n      .map((chunk, i) => `[${i}] ${chunk}`)\n      .join('\\n\\n---\\n\\n');\n\n    const prompt = `You are a search relevance expert. Rank the following chunks by relevance to the query.\n\nQuery: \"${query}\"\n\nChunks to rank:\n${chunksFormatted}\n\nReturn ONLY valid JSON with this format:\n{\n  \"rankings\": [\n    {\"index\": 0, \"relevance_score\": 0.95},\n    {\"index\": 1, \"relevance_score\": 0.72}\n  ]\n}\n\nRelevance score: 0.0 (irrelevant) to 1.0 (highly relevant)\nSort by relevance_score descending.`;\n\n    const response = await client.messages.create({\n      model: 'claude-3-5-sonnet-20241022',\n      max_tokens: 1024,\n      messages: [{\n        role: 'user',\n        content: prompt\n      }]\n    });\n\n    // Parse response\n    const responseText = response.content[0].type === 'text'\n      ? response.content[0].text\n      : '';\n\n    let rankings: any[];\n    try {\n      // Extract JSON from response (might be wrapped in markdown)\n      const jsonMatch = responseText.match(/\\{[\\s\\S]*\\}/);\n      const jsonStr = jsonMatch ? jsonMatch[0] : responseText;\n      const parsed = JSON.parse(jsonStr);\n      rankings = parsed.rankings || [];\n    } catch (parseError) {\n      logger.error('Failed to parse reranking response', {\n        response: responseText.substring(0, 500)\n      });\n      // Fallback: return original order\n      return chunks.slice(0, topK).map((text, i) => ({\n        text,\n        score: 1.0 - (i * 0.1),\n        rank: i + 1\n      }));\n    }\n\n    // Convert to results\n    const results = rankings\n      .filter(r => r.index >= 0 && r.index < chunks.length)\n      .map((r, rank) => ({\n        text: chunks[r.index],\n        score: r.relevance_score,\n        rank: rank + 1\n      }))\n      .slice(0, topK);\n\n    const latency = Date.now() - startTime;\n\n    logger.info('Reranking complete', {\n      query,\n      inputCount: chunks.length,\n      outputCount: results.length,\n      latency,\n      topScore: results[0]?.score\n    });\n\n    return results;\n\n  } catch (error: any) {\n    logger.error('Reranking error', {\n      error: error.message,\n      chunksCount: chunks.length,\n      query: query.substring(0, 100)\n    });\n\n    // Fallback: return original order\n    return chunks.slice(0, topK).map((text, i) => ({\n      text,\n      score: 1.0 - (i * 0.1),\n      rank: i + 1\n    }));\n  }\n}\n```\n\n👨🦳 **Uncle:** Expand the query so you find more relevant chunks.\n\n``` python\n// src/services/queryProcessing.ts\n\nimport Anthropic from '@anthropic-ai/sdk';\nimport logger from '../utils/logger';\n\n/**\n * Expand a query into related search terms.\n * \n * ⚠️ FAILURE POINTS:\n * 1. LLM generates irrelevant expansions\n * 2. Original query lost in expansion\n * 3. Too many expansions → slow retrieval\n */\nexport async function expandQuery(originalQuery: string): Promise<string[]> {\n  try {\n    const client = new Anthropic({\n      apiKey: process.env.ANTHROPIC_API_KEY\n    });\n\n    const response = await client.messages.create({\n      model: 'claude-3-5-sonnet-20241022',\n      max_tokens: 200,\n      messages: [{\n        role: 'user',\n        content: `Given this query about a job candidate, generate 2-3 alternative phrasings or related concepts that would help find relevant information.\n\nOriginal query: \"${originalQuery}\"\n\nReturn ONLY a JSON array of strings:\n[\"alternative1\", \"alternative2\", \"alternative3\"]\n\nThese should help find the same information using different keywords.`\n      }]\n    });\n\n    const responseText = response.content[0].type === 'text'\n      ? response.content[0].text\n      : '';\n\n    let expansions: string[];\n    try {\n      expansions = JSON.parse(responseText);\n    } catch (e) {\n      logger.warn('Failed to parse query expansion', { response: responseText });\n      return [originalQuery];\n    }\n\n    // Always include original query\n    const allQueries = [originalQuery, ...expansions].filter(Boolean);\n\n    logger.debug('Query expanded', {\n      original: originalQuery,\n      expansions: allQueries.length\n    });\n\n    return allQueries;\n\n  } catch (error) {\n    logger.error('Query expansion error', { error });\n    return [originalQuery]; // Fallback\n  }\n}\n\n/**\n * Normalize query (remove typos, standardize terms).\n */\nexport function normalizeQuery(query: string): string {\n  return query\n    .toLowerCase()\n    .trim()\n    // Remove extra spaces\n    .replace(/\\s+/g, ' ')\n    // Remove special characters (keep alphanumeric and spaces)\n    .replace(/[^\\w\\s]/g, '');\n}\n```\n\n👨🦳 **Uncle:** This is where you prevent the AI from lying. Critical.\n\n``` python\n// src/services/safety.ts\n\nimport Anthropic from '@anthropic-ai/sdk';\nimport logger from '../utils/logger';\n\ninterface SafeAnswer {\n  answer: string;\n  confidence: number;\n  evidence: string[];\n  isSafe: boolean;\n  reason?: string;\n}\n\n/**\n * Get answer from AI with multiple safety layers.\n * \n * ⚠️ FAILURE POINTS:\n * 1. AI answer not in JSON format\n * 2. Confidence calculation wrong\n * 3. Evidence doesn't exist in chunks\n * 4. Excessive cost for failed attempts\n */\nexport async function safeAnswer(\n  query: string,\n  chunks: string[],\n  confidenceThreshold: number = 0.7\n): Promise<SafeAnswer> {\n  const startTime = Date.now();\n\n  try {\n    if (chunks.length === 0) {\n      return {\n        answer: 'No relevant information found.',\n        confidence: 0,\n        evidence: [],\n        isSafe: false,\n        reason: 'No source chunks provided'\n      };\n    }\n\n    const client = new Anthropic({\n      apiKey: process.env.ANTHROPIC_API_KEY\n    });\n\n    // Layer 1: Retrieval boundaries\n    // Show ONLY the chunks, nothing from training\n    const chunksText = chunks\n      .map((c, i) => `[Chunk ${i}]\\n${c}`)\n      .join('\\n\\n---\\n\\n');\n\n    const prompt = `You are evaluating a candidate resume based on specific chunks.\n\nINSTRUCTIONS:\n1. Answer ONLY based on the provided chunks\n2. Do NOT use any knowledge from training data\n3. If information is not in chunks, say \"Unknown\"\n4. Always cite which chunk supports your answer\n5. Return valid JSON ONLY - no other text\n\nQuery: \"${query}\"\n\nChunks provided:\n${chunksText}\n\nReturn JSON in this exact format:\n{\n  \"answer\": \"your answer here\",\n  \"confidence\": 0.0 to 1.0,\n  \"evidence_chunks\": [0, 1, 2],\n  \"explanation\": \"why you're confident\"\n}`;\n\n    // Layer 2: Structured output\n    const response = await client.messages.create({\n      model: 'claude-3-5-sonnet-20241022',\n      max_tokens: 500,\n      messages: [{\n        role: 'user',\n        content: prompt\n      }]\n    });\n\n    const responseText = response.content[0].type === 'text'\n      ? response.content[0].text\n      : '';\n\n    // Parse response\n    let parsed: any;\n    try {\n      const jsonMatch = responseText.match(/\\{[\\s\\S]*\\}/);\n      const jsonStr = jsonMatch ? jsonMatch[0] : responseText;\n      parsed = JSON.parse(jsonStr);\n    } catch (e) {\n      logger.error('Failed to parse safety response', {\n        response: responseText.substring(0, 300)\n      });\n      return {\n        answer: 'Error processing answer',\n        confidence: 0,\n        evidence: [],\n        isSafe: false,\n        reason: 'Invalid response format'\n      };\n    }\n\n    // Layer 3: Validation\n    // Check evidence chunks actually exist\n    const validEvidenceIndices = (parsed.evidence_chunks || [])\n      .filter((i: number) => i >= 0 && i < chunks.length);\n\n    if (validEvidenceIndices.length === 0 && parsed.answer !== 'Unknown') {\n      logger.warn('No valid evidence for answer', { \n        answer: parsed.answer,\n        requestedIndices: parsed.evidence_chunks,\n        chunksCount: chunks.length\n      });\n    }\n\n    const evidence = validEvidenceIndices.map((i: number) => chunks[i]);\n\n    // Layer 4: Confidence gating\n    const isSafe = parsed.confidence >= confidenceThreshold;\n\n    if (!isSafe) {\n      logger.warn('Low confidence answer', {\n        answer: parsed.answer,\n        confidence: parsed.confidence,\n        threshold: confidenceThreshold\n      });\n    }\n\n    const latency = Date.now() - startTime;\n\n    logger.info('Safe answer generated', {\n      query: query.substring(0, 50),\n      confidence: parsed.confidence,\n      isSafe,\n      latency,\n      evidenceCount: evidence.length\n    });\n\n    return {\n      answer: parsed.answer,\n      confidence: parsed.confidence,\n      evidence,\n      isSafe,\n      reason: isSafe ? 'Confident' : 'Low confidence'\n    };\n\n  } catch (error: any) {\n    logger.error('Safety check error', { error: error.message });\n    return {\n      answer: 'Error',\n      confidence: 0,\n      evidence: [],\n      isSafe: false,\n      reason: error.message\n    };\n  }\n}\n\n/**\n * Validate that answer is faithful to evidence.\n * Post-check: does answer match the chunks?\n */\nexport async function validateFaithfulness(\n  answer: string,\n  evidence: string[],\n  threshold: number = 0.8\n): Promise<{ isFaithful: boolean; score: number }> {\n  try {\n    // Simple check: are key terms from answer in evidence?\n    const answerTerms = answer.toLowerCase().split(/\\s+/);\n    const evidenceText = evidence.join(' ').toLowerCase();\n\n    const matchedTerms = answerTerms.filter(term => \n      term.length > 3 && evidenceText.includes(term)\n    );\n\n    const score = answerTerms.length > 0 \n      ? matchedTerms.length / answerTerms.length \n      : 0;\n\n    return {\n      isFaithful: score >= threshold,\n      score\n    };\n\n  } catch (error) {\n    logger.error('Faithfulness validation error', { error });\n    return { isFaithful: false, score: 0 };\n  }\n}\n```\n\n👨🦳 **Uncle:** This is what the client calls. Make it robust.\n\n``` python\n// src/routes/rag.ts\n\nimport express, { Router, Request, Response } from 'express';\nimport db from '../config/database';\nimport { hybridSearch, multiQueryRetrieval } from '../services/retrieval';\nimport { rerank } from '../services/reranking';\nimport { expandQuery } from '../services/queryProcessing';\nimport { safeAnswer, validateFaithfulness } from '../services/safety';\nimport { chunkText } from '../utils/chunking';\nimport { getEmbeddings } from '../config/embedding';\nimport logger from '../utils/logger';\n\nconst router = Router();\n\n// Middleware: Check authentication\nfunction authMiddleware(req: Request, res: Response, next: Function) {\n  const apiKey = req.headers['x-api-key'] as string;\n\n  if (!apiKey) {\n    return res.status(401).json({ error: 'Missing API key' });\n  }\n\n  // In production, validate against database\n  if (apiKey !== process.env.ADMIN_API_KEY) {\n    return res.status(401).json({ error: 'Invalid API key' });\n  }\n\n  next();\n}\n\nrouter.use(authMiddleware);\n\n/**\n * Upload and process a resume.\n * POST /rag/upload\n */\nrouter.post('/upload', async (req: Request, res: Response) => {\n  try {\n    const { tenantId, candidateName, resumeText } = req.body;\n\n    if (!tenantId || !candidateName || !resumeText) {\n      return res.status(400).json({ \n        error: 'Missing required fields: tenantId, candidateName, resumeText' \n      });\n    }\n\n    // Step 1: Save resume\n    const resumeResult = await db.one(`\n      INSERT INTO resumes (tenant_id, candidate_name, raw_text)\n      VALUES ($1, $2, $3)\n      RETURNING id\n    `, [tenantId, candidateName, resumeText]);\n\n    const resumeId = resumeResult.id;\n\n    // Step 2: Chunk the resume\n    const chunks = chunkText(resumeText, 1000, 200);\n    logger.info('Resume chunked', { resumeId, chunkCount: chunks.length });\n\n    // Step 3: Get embeddings for all chunks\n    const chunkTexts = chunks.map(c => c.text);\n    const embeddingResults = await getEmbeddings(chunkTexts);\n\n    // Step 4: Save chunks with embeddings\n    for (let i = 0; i < chunks.length; i++) {\n      const chunk = chunks[i];\n      const embedding = embeddingResults[i].embedding;\n      const embeddingArray = `[${embedding.join(',')}]`;\n\n      await db.none(`\n        INSERT INTO resume_chunks \n        (resume_id, tenant_id, chunk_text, chunk_index, embedding)\n        VALUES ($1, $2, $3, $4, $5::vector)\n      `, [resumeId, tenantId, chunk.text, chunk.index, embeddingArray]);\n    }\n\n    logger.info('Resume uploaded successfully', { resumeId, chunkCount: chunks.length });\n\n    res.json({\n      success: true,\n      resumeId,\n      chunkCount: chunks.length,\n      message: `Resume for ${candidateName} processed successfully`\n    });\n\n  } catch (error: any) {\n    logger.error('Upload error', { error: error.message });\n    res.status(500).json({ error: error.message });\n  }\n});\n\n/**\n * Query a resume.\n * POST /rag/query\n */\nrouter.post('/query', async (req: Request, res: Response) => {\n  try {\n    const { tenantId, resumeId, question, useExpansion = false } = req.body;\n\n    if (!tenantId || !resumeId || !question) {\n      return res.status(400).json({\n        error: 'Missing required fields: tenantId, resumeId, question'\n      });\n    }\n\n    const startTime = Date.now();\n\n    // Step 1: Expand query if requested\n    let queries = [question];\n    if (useExpansion) {\n      queries = await expandQuery(question);\n      logger.debug('Query expanded', { count: queries.length });\n    }\n\n    // Step 2: Retrieve chunks (multi-query if expanded)\n    const retrieved = useExpansion\n      ? await multiQueryRetrieval(tenantId, resumeId, queries, 10)\n      : await hybridSearch(tenantId, resumeId, question, 10);\n\n    if (retrieved.length === 0) {\n      return res.json({\n        answer: 'No relevant information found in resume.',\n        confidence: 0,\n        evidence: [],\n        isSafe: false,\n        latency: Date.now() - startTime\n      });\n    }\n\n    // Step 3: Rerank for accuracy\n    const chunks = retrieved.map(r => r.chunkText);\n    const reranked = await rerank(question, chunks, 5);\n    const topChunks = reranked.map(r => r.text);\n\n    // Step 4: Get safe answer with evidence\n    const safeAns = await safeAnswer(question, topChunks, 0.7);\n\n    // Step 5: Validate faithfulness (optional)\n    const faithfulness = await validateFaithfulness(safeAns.answer, safeAns.evidence);\n\n    const latency = Date.now() - startTime;\n\n    res.json({\n      success: true,\n      answer: safeAns.answer,\n      confidence: safeAns.confidence,\n      evidence: safeAns.evidence,\n      isSafe: safeAns.isSafe,\n      faithfulness: faithfulness.score,\n      latency,\n      chunksRetrieved: retrieved.length,\n      chunksReranked: reranked.length\n    });\n\n  } catch (error: any) {\n    logger.error('Query error', { error: error.message });\n    res.status(500).json({ error: error.message });\n  }\n});\n\n/**\n * Get metrics for a tenant.\n * GET /rag/metrics/:tenantId\n */\nrouter.get('/metrics/:tenantId', async (req: Request, res: Response) => {\n  try {\n    const { tenantId } = req.params;\n\n    // Query logs aggregation\n    const metrics = await db.one(`\n      SELECT \n        COUNT(*) as query_count,\n        AVG(latency_ms) as avg_latency,\n        MAX(latency_ms) as max_latency,\n        MIN(latency_ms) as min_latency,\n        AVG(recall) as avg_recall,\n        AVG(precision) as avg_precision,\n        SUM(cost_cents) / 100.0 as total_cost_dollars\n      FROM query_logs\n      WHERE tenant_id = $1\n    `, [tenantId]);\n\n    res.json({\n      success: true,\n      metrics\n    });\n\n  } catch (error: any) {\n    logger.error('Metrics error', { error: error.message });\n    res.status(500).json({ error: error.message });\n  }\n});\n\nexport default router;\npython\n// src/index.ts\n\nimport express from 'express';\nimport cors from 'cors';\nimport compression from 'compression';\nimport helmet from 'helmet';\nimport dotenv from 'dotenv';\nimport ragRoutes from './routes/rag';\nimport { initializeDatabase } from './config/database';\nimport logger from './utils/logger';\n\ndotenv.config();\n\nconst app = express();\nconst PORT = process.env.PORT || 3000;\n\n// Middleware\napp.use(helmet()); // Security headers\napp.use(compression()); // Compress responses\napp.use(cors());\napp.use(express.json());\n\n// Health check\napp.get('/health', (req, res) => {\n  res.json({ status: 'ok', timestamp: new Date().toISOString() });\n});\n\n// RAG routes\napp.use('/rag', ragRoutes);\n\n// Error handler\napp.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {\n  logger.error('Unhandled error', { error: err.message });\n  res.status(500).json({ error: 'Internal server error' });\n});\n\n// Start server\nasync function start() {\n  try {\n    // Initialize database\n    await initializeDatabase();\n\n    app.listen(PORT, () => {\n      logger.info(`Server running on port ${PORT}`);\n    });\n  } catch (error) {\n    logger.error('Failed to start server', { error });\n    process.exit(1);\n  }\n}\n\nstart();\npython\n// src/utils/logger.ts\n\nimport winston from 'winston';\n\nconst logger = winston.createLogger({\n  level: process.env.LOG_LEVEL || 'info',\n  format: winston.format.combine(\n    winston.format.timestamp(),\n    winston.format.json()\n  ),\n  transports: [\n    // Console in development\n    new winston.transports.Console({\n      format: winston.format.combine(\n        winston.format.colorize(),\n        winston.format.printf(({ timestamp, level, message, ...meta }) => {\n          return `${timestamp} [${level}] ${message} ${\n            Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''\n          }`;\n        })\n      )\n    }),\n    // File for production\n    new winston.transports.File({ \n      filename: 'logs/error.log',\n      level: 'error'\n    }),\n    new winston.transports.File({ \n      filename: 'logs/combined.log'\n    })\n  ]\n});\n\nexport default logger;\n```\n\n| Failure Point | Symptom | Root Cause | Solution |\n|---|---|---|---|\nMissing tenant_id |\nData leak between companies | No isolation check | Add WHERE tenant_id = X to EVERY query |\nVector index missing |\nQueries take 10+ seconds | Sequential scan of 500K vectors | Create IVFFLAT index on embedding column |\nQuery too long |\nAPI error 4096 tokens exceeded | Question >16000 chars | Truncate queries to 500 chars |\nNo results |\nEmpty array returned | Chunks don't exist or embeddings wrong | Check if chunks were saved, verify vector distance threshold |\nHallucination |\nAI invents information | No retrieval boundaries in prompt | Use safety layers (5 layers as described) |\nRate limit (429) |\nAPI call fails | Too many requests to Claude | Implement exponential backoff, queue requests |\nDatabase connection lost |\n\"Cannot connect to server\" | Network issue, DB down, wrong credentials | Add retry logic, connection pooling, health checks |\nEmbedding dimension mismatch |\n\"Vector dimension 1536 != 768\" | Using different embedding model | Ensure consistent model (claude-3-5-sonnet) |\nMemory overload |\nNode.js crashes | Trying to embed entire 100MB file | Chunk before embedding, process in batches |\nCost explosion |\nUnexpected $10k bill | Each embedding/rerank/answer costs money | Track costs, log them, set spending limits |\n\n```\nWithout RAG: \"Does John know Docker?\" → Guess → \"maybe, looks like it\"\nWith RAG: \"Does John know Docker?\" → Evidence → \"Yes. His resume says: 'Docker, Kubernetes, 4 years'\"\nSingle LLM: Simple\nRAG: Embeddings → Vector DB → Retrieval → Reranking → Safety checks\nEach stage can fail independently\n```\n\nExample problem:\n\n```\nResume: \"Worked with distributed ledger technology\"\nSearch: \"blockchain\"\nResult: Miss (DLT ≠ blockchain in embeddings)\n100K queries/month = $100/month just for operations\n(Not including infrastructure, salaries, etc)\n```\n\nUser expects <200ms. RAG adds delay.\n\n```\nResume full of typos: \"React\" → \"Rreact\" → Embeddings confused\n→ System can't find React skills\n→ Answer is wrong\n```\n\n| Scenario | Use RAG? | Why |\n|---|---|---|\nCustomer support |\nYES | Up-to-date, explainable, no hallucinations |\nMedical diagnosis |\nYES | Safety-critical, needs evidence |\nResume screening |\nYES | Domain-specific, needs accuracy |\nGeneral chatbot |\nNO | Training data sufficient, latency matters |\nQuick facts |\nNO | Simple lookup is faster |\nCreative writing |\nNO | Hallucinations are features, not bugs |\nCode search |\nMAYBE | Depends on code freshness |\nLegal documents |\nYES | Must cite sources, no mistakes |\n\n```\n1. Simple lookup → Use database\n2. Conversational → Use base LLM\n3. Speed critical → Too slow (600ms+)\n4. Data quality poor → Garbage in/out\n5. Training data sufficient → No value-add\n6. Cost-sensitive → Each query costs money\nPer-Query Costs (approximate):\n\n1. Embedding query:\n   - 50 tokens @ $0.000003/token = $0.00015\n\n2. Vector search + keyword filter:\n   - Database operation ≈ $0 (hosted: ~$0.00001)\n\n3. Reranking (Claude):\n   - 500 tokens input + 100 output @ $0.003/$0.015 = $0.0018\n\n4. Final answer:\n   - 500 tokens input + 200 output @ $0.003/$0.015 = $0.004\n\nTotal per query: ≈ $0.0075 (~0.75 cents)\n\nAt scale:\n- 1K queries/month = $7.50\n- 100K queries/month = $750\n- 1M queries/month = $7,500\n```\n\n**Cache results**\n\n**Batch processing**\n\n**Smart reranking**\n\n**Cheaper models**\n\n```\n# Dockerfile\n\nFROM node:18-alpine\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci --only=production\n\nCOPY dist ./dist\n\nEXPOSE 3000\n\nCMD [\"node\", \"dist/index.js\"]\n# docker-compose.yml\n\nversion: '3.8'\nservices:\n  postgres:\n    image: postgres:15-alpine\n    environment:\n      POSTGRES_PASSWORD: postgres\n      POSTGRES_DB: rag_system\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n    ports:\n      - \"5432:5432\"\n\n  pgvector:\n    build:\n      context: .\n      dockerfile: Dockerfile.pgvector\n    environment:\n      POSTGRES_PASSWORD: postgres\n    depends_on:\n      - postgres\n\n  app:\n    build: .\n    environment:\n      DB_HOST: postgres\n      DB_USER: postgres\n      DB_PASSWORD: postgres\n      DB_NAME: rag_system\n      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}\n    ports:\n      - \"3000:3000\"\n    depends_on:\n      - postgres\n\nvolumes:\n  postgres_data:\n# 1. Upload a resume\ncurl -X POST http://localhost:3000/rag/upload \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-API-Key: super-secret-key\" \\\n  -d '{\n    \"tenantId\": \"company-1\",\n    \"candidateName\": \"John Doe\",\n    \"resumeText\": \"John has 5 years React experience, built e-commerce platforms with Node.js...\"\n  }'\n\n# Response:\n# {\n#   \"success\": true,\n#   \"resumeId\": \"uuid-123\",\n#   \"chunkCount\": 8\n# }\n\n# 2. Query the resume\ncurl -X POST http://localhost:3000/rag/query \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-API-Key: super-secret-key\" \\\n  -d '{\n    \"tenantId\": \"company-1\",\n    \"resumeId\": \"uuid-123\",\n    \"question\": \"Does John have React experience?\",\n    \"useExpansion\": true\n  }'\n\n# Response:\n# {\n#   \"success\": true,\n#   \"answer\": \"Yes, John has 5 years of React experience.\",\n#   \"confidence\": 0.95,\n#   \"evidence\": [\"John has 5 years React experience, built e-commerce...\"],\n#   \"isSafe\": true,\n#   \"faithfulness\": 0.92,\n#   \"latency\": 720,\n#   \"chunksRetrieved\": 10,\n#   \"chunksReranked\": 5\n# }\n\n# 3. Get metrics\ncurl -X GET http://localhost:3000/rag/metrics/company-1 \\\n  -H \"X-API-Key: super-secret-key\"\n\n# Response:\n# {\n#   \"success\": true,\n#   \"metrics\": {\n#     \"query_count\": 42,\n#     \"avg_latency\": 680,\n#     \"max_latency\": 1200,\n#     \"avg_recall\": 0.91,\n#     \"avg_precision\": 0.88,\n#     \"total_cost_dollars\": 0.32\n#   }\n# }\n```\n\n**Cause:** Missing vector index\n\n**Fix:**\n\n```\nCREATE INDEX ON resume_chunks \nUSING ivfflat (embedding vector_cosine_ops) \nWITH (lists = 100);\n```\n\n**Cause:** Stale embeddings or non-deterministic reranking\n\n**Fix:** Always embed with same model, use fixed random seed for reranking\n\n**Cause:** Insufficient safety layers\n\n**Fix:** Add confidence thresholding, require citations, validate faithfulness\n\n**Cause:** Missing tenant_id check in WHERE clause\n\n**Fix:** Add WHERE tenant_id = $X to EVERY query\n\n**Cause:** Too many rapid requests to Claude\n\n**Fix:** Implement exponential backoff, queue requests, batch operations\n\n**Cause:** No cost tracking, inefficient queries, excessive reranking\n\n**Fix:** Log cost per operation, implement budgets, use cheaper models for easy tasks\n\n👨🦳 **Uncle's Final Word:**\n\nRAG is powerful but complex. Every layer serves a purpose:\n\nYou don't need all of this on day one. Start simple:\n\n**Day 1:** PostgreSQL + embeddings + basic search\n\n**Week 1:** Add reranking\n\n**Month 1:** Add safety layers\n\n**Month 3:** Add monitoring and optimization\n\nEach layer buys you something. Know what you're buying.\n\n👦 **Nephew:** When should I NOT use RAG?\n\n👨🦳 **Uncle:** When:\n\nOtherwise? RAG is the way.\n\nNow go build. Start simple. Measure everything. Ship fast.\n\nGood luck. ---SurajK", "url": "https://wpnews.pro/news/rag-pipeline-complete-node-js-implementation-guide", "canonical_source": "https://dev.to/surajrkhonde/rag-pipeline-complete-nodejs-implementation-guide-1n54", "published_at": "2026-06-20 10:27:48+00:00", "updated_at": "2026-06-20 10:36:27.618120+00:00", "lang": "en", "topics": ["large-language-models", "developer-tools", "ai-infrastructure", "natural-language-processing", "machine-learning"], "entities": ["Node.js", "PostgreSQL", "pgvector", "Claude", "Anthropic", "Express", "TypeScript"], "alternates": {"html": "https://wpnews.pro/news/rag-pipeline-complete-node-js-implementation-guide", "markdown": "https://wpnews.pro/news/rag-pipeline-complete-node-js-implementation-guide.md", "text": "https://wpnews.pro/news/rag-pipeline-complete-node-js-implementation-guide.txt", "jsonld": "https://wpnews.pro/news/rag-pipeline-complete-node-js-implementation-guide.jsonld"}}