This workflow follows the Error Trigger → Google Sheets recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "02 \u2014 RAG Chatbot: Ingestion + Grounded Q&A",
"nodes": [
{
"parameters": {
"content": "## WORKFLOW A \u2014 INGESTION\nManual/scheduled trigger \u2192 pull PDFs from a Google Drive folder \u2192 extract text \u2192 chunk into ~800 char segments with 100 char overlap \u2192 embed each chunk (text-embedding-3-small) \u2192 upsert to Pinecone with metadata (doc_name, chunk_index, source_url).",
"height": 260,
"width": 660,
"color": 4
},
"id": "sticky-ingest",
"name": "Sticky: Ingestion",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-100,
-340
]
},
{
"parameters": {
"content": "## WORKFLOW B \u2014 QUERY\nWebhook receives a question \u2192 embed it \u2192 Pinecone top-K=5 \u2192 confidence check on max similarity score \u2192 if OK, assemble context and call GPT-4o with strict system prompt \u2192 return JSON { answer, source_document, confidence_score, chunk_references }. Every Q&A is logged to Sheets.",
"height": 260,
"width": 900,
"color": 5
},
"id": "sticky-query",
"name": "Sticky: Query",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-100,
260
]
},
{
"parameters": {},
"id": "node-manual-ingest",
"name": "Manual Trigger \u2014 Ingest",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-40,
0
]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 3 * * *"
}
]
}
},
"id": "node-cron-ingest",
"name": "Cron \u2014 Daily 03:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
-40,
-160
]
},
{
"parameters": {
"resource": "fileFolder",
"queryString": "mimeType='application/pdf' and trashed=false",
"filter": {
"folderId": {
"__rl": true,
"value": "DRIVE_FOLDER_ID_PLACEHOLDER",
"mode": "id"
}
},
"options": {}
},
"id": "node-drive-list",
"name": "Drive \u2014 List PDFs",
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
180,
-80
],
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"operation": "download",
"fileId": {
"__rl": true,
"value": "={{$json.id}}",
"mode": "id"
},
"options": {}
},
"id": "node-drive-download",
"name": "Drive \u2014 Download PDF",
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
400,
-80
],
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"operation": "pdf",
"binaryPropertyName": "data",
"options": {}
},
"id": "node-pdf-extract",
"name": "Extract PDF Text",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1,
"position": [
620,
-80
]
},
{
"parameters": {
"language": "javaScript",
"jsCode": "// Chunk extracted text into ~800 char segments with 100 char overlap.\n// Emit one item per chunk with metadata so downstream embedding + upsert stays 1:1.\nconst CHUNK_SIZE = 800;\nconst OVERLAP = 100;\nconst items = $input.all();\nconst output = [];\n\nfor (const item of items) {\n const text = item.json.text || '';\n const docName = item.json.fileName || item.json.name || 'unknown.pdf';\n const docId = item.json.fileId || item.json.id || docName;\n const cleaned = text.replace(/\\s+/g, ' ').trim();\n\n if (!cleaned) continue;\n\n let start = 0;\n let idx = 0;\n while (start < cleaned.length) {\n const end = Math.min(start + CHUNK_SIZE, cleaned.length);\n const chunk = cleaned.slice(start, end);\n output.push({\n json: {\n doc_name: docName,\n doc_id: docId,\n chunk_index: idx,\n chunk_text: chunk,\n char_start: start,\n char_end: end\n }\n });\n idx += 1;\n if (end === cleaned.length) break;\n start = end - OVERLAP;\n }\n}\n\nreturn output;"
},
"id": "node-chunk",
"name": "Chunk Text",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
840,
-80
]
},
{
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"value": "text-embedding-3-small",
"mode": "list"
},
"messages": {
"values": [
{
"role": "user",
"content": "={{$json.chunk_text}}"
}
]
},
"options": {}
},
"id": "node-embed-chunk",
"name": "OpenAI \u2014 Embed Chunk",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.6,
"position": [
1060,
-80
],
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://YOUR-INDEX.svc.YOUR-REGION.pinecone.io/vectors/upsert",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Api-Key",
"value": "={{$credentials.pineconeApi.apiKey}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"vectors\": [\n {\n \"id\": \"{{$('Chunk Text').item.json.doc_id}}_{{$('Chunk Text').item.json.chunk_index}}\",\n \"values\": {{JSON.stringify($json.data[0].embedding)}},\n \"metadata\": {\n \"doc_name\": \"{{$('Chunk Text').item.json.doc_name}}\",\n \"chunk_index\": {{$('Chunk Text').item.json.chunk_index}},\n \"chunk_text\": {{JSON.stringify($('Chunk Text').item.json.chunk_text)}}\n }\n }\n ],\n \"namespace\": \"docs\"\n}",
"options": {
"timeout": 15000
}
},
"id": "node-pinecone-upsert",
"name": "Pinecone \u2014 Upsert Vector",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1280,
-80
],
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"httpMethod": "POST",
"path": "rag-query",
"responseMode": "responseNode",
"options": {}
},
"id": "node-webhook-query",
"name": "Webhook \u2014 Question",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-40,
600
]
},
{
"parameters": {
"language": "javaScript",
"jsCode": "// Validate incoming question payload.\nconst body = $input.first().json.body ?? $input.first().json;\nconst question = String(body.question || '').trim();\nif (question.length < 3) throw new Error('Question is empty or too short (min 3 chars).');\nif (question.length > 1000) throw new Error('Question exceeds 1000 char limit.');\nreturn [{ json: { question, asked_at: new Date().toISOString(), session_id: body.session_id || null } }];"
},
"id": "node-validate-q",
"name": "Validate Question",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
180,
600
]
},
{
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"value": "text-embedding-3-small",
"mode": "list"
},
"messages": {
"values": [
{
"role": "user",
"content": "={{$json.question}}"
}
]
},
"options": {}
},
"id": "node-embed-q",
"name": "OpenAI \u2014 Embed Question",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.6,
"position": [
400,
600
],
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://YOUR-INDEX.svc.YOUR-REGION.pinecone.io/query",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Api-Key",
"value": "={{$credentials.pineconeApi.apiKey}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"vector\": {{JSON.stringify($json.data[0].embedding)}},\n \"topK\": 5,\n \"includeMetadata\": true,\n \"namespace\": \"docs\"\n}",
"options": {
"timeout": 15000
}
},
"id": "node-pinecone-query",
"name": "Pinecone \u2014 Top 5",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
620,
600
],
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"language": "javaScript",
"jsCode": "// Assemble retrieved context and evaluate confidence.\nconst CONFIDENCE_THRESHOLD = 0.75;\nconst question = $('Validate Question').first().json.question;\nconst asked_at = $('Validate Question').first().json.asked_at;\nconst matches = $input.first().json.matches || [];\n\nif (matches.length === 0) {\n return [{ json: { question, asked_at, confident: false, top_score: 0, matches: [], context: '' } }];\n}\n\nconst topScore = Number(matches[0].score) || 0;\nconst confident = topScore >= CONFIDENCE_THRESHOLD;\n\nconst context = matches.map((m, i) => {\n const md = m.metadata || {};\n return `[Chunk ${i + 1} | ${md.doc_name} #${md.chunk_index} | score=${m.score.toFixed(3)}]\\n${md.chunk_text}`;\n}).join('\\n\\n');\n\nconst chunk_references = matches.map(m => ({\n doc_name: m.metadata?.doc_name,\n chunk_index: m.metadata?.chunk_index,\n score: m.score\n}));\n\nreturn [{\n json: {\n question,\n asked_at,\n confident,\n top_score: topScore,\n context,\n chunk_references,\n source_document: matches[0]?.metadata?.doc_name || null\n }\n}];"
},
"id": "node-assemble-context",
"name": "Assemble Context",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
840,
600
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{$json.confident}}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
]
},
"renameOutput": true,
"outputKey": "CONFIDENT"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{$json.confident}}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "false",
"singleValue": true
}
}
]
},
"renameOutput": true,
"outputKey": "LOW_CONFIDENCE"
}
]
},
"options": {}
},
"id": "node-switch-confidence",
"name": "Switch \u2014 Confidence",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1060,
600
]
},
{
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"value": "gpt-4o",
"mode": "list"
},
"messages": {
"values": [
{
"role": "system",
"content": "You are a strict, grounded document Q&A assistant. You MUST answer using only the provided context. If the answer is not in the context, say so \u2014 do not invent facts. Cite the source document name in your answer. Return STRICT JSON: { \"answer\": string, \"used_chunks\": integer[] } where used_chunks is the 1-indexed list of chunks you actually relied on."
},
{
"role": "user",
"content": "=Question:\n{{$json.question}}\n\nContext (retrieved chunks):\n{{$json.context}}"
}
]
},
"jsonOutput": true,
"options": {
"temperature": 0.1
}
},
"id": "node-gpt-answer",
"name": "GPT-4o \u2014 Grounded Answer",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.6,
"position": [
1280,
500
],
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"language": "javaScript",
"jsCode": "// Build final response payload from the GPT-4o answer + Pinecone metadata.\nconst ctx = $('Assemble Context').first().json;\nconst aiRaw = $input.first().json;\n\nlet parsed;\nif (aiRaw.message && aiRaw.message.content) {\n parsed = typeof aiRaw.message.content === 'string' ? JSON.parse(aiRaw.message.content) : aiRaw.message.content;\n} else if (aiRaw.content) {\n parsed = typeof aiRaw.content === 'string' ? JSON.parse(aiRaw.content) : aiRaw.content;\n} else {\n parsed = aiRaw;\n}\n\nreturn [{\n json: {\n question: ctx.question,\n answer: parsed.answer,\n source_document: ctx.source_document,\n confidence_score: ctx.top_score,\n chunk_references: ctx.chunk_references,\n used_chunks: parsed.used_chunks || [],\n answered_at: new Date().toISOString(),\n asked_at: ctx.asked_at,\n status: 'answered'\n }\n}];"
},
"id": "node-build-answer",
"name": "Build Answer Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1500,
500
]
},
{
"parameters": {
"language": "javaScript",
"jsCode": "// Low confidence \u2014 return a graceful refusal instead of hallucinating.\nconst ctx = $input.first().json;\nreturn [{\n json: {\n question: ctx.question,\n answer: \"I don't have enough information in the source documents to answer that confidently. Please rephrase or add more context.\",\n source_document: null,\n confidence_score: ctx.top_score,\n chunk_references: ctx.chunk_references,\n used_chunks: [],\n answered_at: new Date().toISOString(),\n asked_at: ctx.asked_at,\n status: 'low_confidence_refusal'\n }\n}];"
},
"id": "node-low-conf-response",
"name": "Low-Confidence Refusal",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1280,
720
]
},
{
"parameters": {
"operation": "append",
"documentId": {
"__rl": true,
"value": "1XXXXXXXXXXXXXXXXXXXXXXXXX",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "QA Log",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"Asked At": "={{$json.asked_at}}",
"Answered At": "={{$json.answered_at}}",
"Question": "={{$json.question}}",
"Answer": "={{$json.answer}}",
"Source Document": "={{$json.source_document}}",
"Confidence Score": "={{$json.confidence_score}}",
"Status": "={{$json.status}}"
}
}
},
"id": "node-log-qa",
"name": "Sheets \u2014 Log Q&A",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
1720,
600
],
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={ \"answer\": {{JSON.stringify($json.answer)}}, \"source_document\": {{JSON.stringify($json.source_document)}}, \"confidence_score\": {{$json.confidence_score}}, \"chunk_references\": {{JSON.stringify($json.chunk_references)}}, \"status\": \"{{$json.status}}\" }",
"options": {
"responseCode": 200
}
},
"id": "node-respond-q",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1940,
600
]
},
{
"parameters": {},
"id": "node-error-trigger",
"name": "Error Trigger",
"type": "n8n-nodes-base.errorTrigger",
"typeVersion": 1,
"position": [
-40,
1000
]
},
{
"parameters": {
"select": "channel",
"channelId": {
"__rl": true,
"value": "C0XXXXXXXXX",
"mode": "id"
},
"text": "=:rotating_light: *RAG pipeline error*\n*Workflow:* {{$json.workflow.name}}\n*Node:* {{$json.execution.lastNodeExecuted}}\n*Error:* {{$json.execution.error.message}}\n*Execution URL:* {{$json.execution.url}}",
"otherOptions": {}
},
"id": "node-error-slack",
"name": "Slack \u2014 Error Alert",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
200,
1000
],
"credentials": {
"slackApi": {
"name": "<your credential>"
}
}
}
],
"connections": {
"Manual Trigger \u2014 Ingest": {
"main": [
[
{
"node": "Drive \u2014 List PDFs",
"type": "main",
"index": 0
}
]
]
},
"Cron \u2014 Daily 03:00": {
"main": [
[
{
"node": "Drive \u2014 List PDFs",
"type": "main",
"index": 0
}
]
]
},
"Drive \u2014 List PDFs": {
"main": [
[
{
"node": "Drive \u2014 Download PDF",
"type": "main",
"index": 0
}
]
]
},
"Drive \u2014 Download PDF": {
"main": [
[
{
"node": "Extract PDF Text",
"type": "main",
"index": 0
}
]
]
},
"Extract PDF Text": {
"main": [
[
{
"node": "Chunk Text",
"type": "main",
"index": 0
}
]
]
},
"Chunk Text": {
"main": [
[
{
"node": "OpenAI \u2014 Embed Chunk",
"type": "main",
"index": 0
}
]
]
},
"OpenAI \u2014 Embed Chunk": {
"main": [
[
{
"node": "Pinecone \u2014 Upsert Vector",
"type": "main",
"index": 0
}
]
]
},
"Webhook \u2014 Question": {
"main": [
[
{
"node": "Validate Question",
"type": "main",
"index": 0
}
]
]
},
"Validate Question": {
"main": [
[
{
"node": "OpenAI \u2014 Embed Question",
"type": "main",
"index": 0
}
]
]
},
"OpenAI \u2014 Embed Question": {
"main": [
[
{
"node": "Pinecone \u2014 Top 5",
"type": "main",
"index": 0
}
]
]
},
"Pinecone \u2014 Top 5": {
"main": [
[
{
"node": "Assemble Context",
"type": "main",
"index": 0
}
]
]
},
"Assemble Context": {
"main": [
[
{
"node": "Switch \u2014 Confidence",
"type": "main",
"index": 0
}
]
]
},
"Switch \u2014 Confidence": {
"main": [
[
{
"node": "GPT-4o \u2014 Grounded Answer",
"type": "main",
"index": 0
}
],
[
{
"node": "Low-Confidence Refusal",
"type": "main",
"index": 0
}
]
]
},
"GPT-4o \u2014 Grounded Answer": {
"main": [
[
{
"node": "Build Answer Payload",
"type": "main",
"index": 0
}
]
]
},
"Build Answer Payload": {
"main": [
[
{
"node": "Sheets \u2014 Log Q&A",
"type": "main",
"index": 0
}
]
]
},
"Low-Confidence Refusal": {
"main": [
[
{
"node": "Sheets \u2014 Log Q&A",
"type": "main",
"index": 0
}
]
]
},
"Sheets \u2014 Log Q&A": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Error Trigger": {
"main": [
[
{
"node": "Slack \u2014 Error Alert",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"saveManualExecutions": true,
"callerPolicy": "workflowsFromSameOwner"
},
"tags": [
{
"name": "ai"
},
{
"name": "rag"
},
{
"name": "chatbot"
}
],
"_meta": {
"description": "Two-workflow RAG system in a single file. Ingestion path pulls PDFs from Google Drive, chunks them (~800 chars, 100 char overlap), embeds with text-embedding-3-small, and upserts to Pinecone with metadata. Query path accepts a webhook question, embeds it, retrieves top-5 chunks, checks confidence against a 0.75 threshold, and either answers with GPT-4o (grounded strictly in context) or returns a graceful refusal. Every Q&A is logged to Google Sheets. Response JSON includes answer, source_document, confidence_score, and chunk_references.",
"tools_used": [
"n8n-nodes-base.manualTrigger",
"n8n-nodes-base.scheduleTrigger",
"n8n-nodes-base.googleDrive",
"n8n-nodes-base.extractFromFile",
"n8n-nodes-base.code",
"@n8n/n8n-nodes-langchain.openAi (text-embedding-3-small + gpt-4o)",
"n8n-nodes-base.httpRequest (Pinecone REST)",
"n8n-nodes-base.webhook",
"n8n-nodes-base.switch",
"n8n-nodes-base.googleSheets",
"n8n-nodes-base.respondToWebhook",
"n8n-nodes-base.errorTrigger",
"n8n-nodes-base.slack"
],
"credentials_needed": [
{
"id": "openai-cred-001",
"type": "openAiApi",
"purpose": "Embeddings + GPT-4o answers"
},
{
"id": "gdrive-cred-001",
"type": "googleDriveOAuth2Api",
"purpose": "List + download source PDFs"
},
{
"id": "pinecone-cred-001",
"type": "httpHeaderAuth",
"purpose": "Pinecone upsert + query (Api-Key header)"
},
{
"id": "gsheets-cred-001",
"type": "googleSheetsOAuth2Api",
"purpose": "Q&A log"
},
{
"id": "slack-cred-001",
"type": "slackApi",
"purpose": "Error alerts"
}
],
"how_to_import": "1) Import this JSON in n8n. 2) In the Pinecone HTTP nodes replace YOUR-INDEX.svc.YOUR-REGION.pinecone.io with your actual index host, and swap the credential to your own Pinecone Api-Key header credential. 3) In Drive \u2014 List PDFs, set the source folder ID. 4) In Sheets \u2014 Log Q&A, set the spreadsheet ID and confirm the sheet is named 'QA Log'. 5) Fire the Manual Trigger to ingest a small folder first and verify vectors appear in Pinecone. 6) POST to /webhook/rag-query with { \"question\": \"...\" } and confirm the response JSON. 7) In Workflow Settings \u2192 Error Workflow, select this same workflow so the Error Trigger fires."
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
googleDriveOAuth2ApigoogleSheetsOAuth2ApihttpHeaderAuthopenAiApislackApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
02 — RAG Chatbot: Ingestion + Grounded Q&A. Uses googleDrive, openAi, httpRequest, googleSheets. Event-driven trigger; 23 nodes.
Source: https://github.com/Ahmad-Ali-121/rag-document-qa/blob/main/workflow.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This advanced n8n workflow automates the full lead enrichment, qualification, and personalized outreach process tailored specifically for the B2B real estate sector. Integrating top platforms like Api
This n8n template automatically classifies incoming emails (Sales, Support, Internal, Finance, Promotions) and routes them to a dedicated OpenAI LLM Agent for processing. Depending on the category, th
WooriFisa 최종. Uses memoryMongoDbChat, agent, httpRequest, documentDefaultDataLoader. Scheduled trigger; 68 nodes.
Auto repost job with RAG is a workflow designed to automatically extract, process, and publish job listings from monitored sources using Google Drive, OpenAI, Supabase, and WordPress. This integration
Automatically extract job listings from any website URL, format them with AI, and publish directly to WordPress. Just send a URL via Telegram, and watch as the workflow scrapes the job details, enhanc