AutomationFlowsAI & RAG › Reindex Markdown RAG Chunks with Supabase Pgvector and Webhooks

Reindex Markdown RAG Chunks with Supabase Pgvector and Webhooks

ByFilip Mijic @demix42 on n8n.io

This workflow reindexes Markdown documentation into a Supabase Postgres pgvector table by fetching source docs from an HTTP API, chunking and embedding them via a Supabase Edge Function, upserting the vectors, and deleting stale chunks on a daily schedule or on-demand webhook.…

Cron / scheduled trigger★★★★☆ complexity11 nodesHTTP RequestPostgres
AI & RAG Trigger: Cron / scheduled Nodes: 11 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #16370 — we link there as the canonical source.

This workflow follows the HTTP Request → Postgres 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 →

Download .json
{
  "id": "VHF3xt11ExE3JiQW",
  "meta": {
    "builderVariant": "mcp",
    "aiBuilderAssisted": true
  },
  "name": "Reindex Markdown Docs into a Supabase pgvector RAG Store (Schedule + Webhook)",
  "tags": [],
  "nodes": [
    {
      "id": "8a1f68ea-817f-425d-afdc-8c0ab1c33ddb",
      "name": "Schedule: Daily Reindex",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -48,
        160
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 2
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "b9cca7ec-ab1e-4afc-9d68-94fd1f3e3e77",
      "name": "Fetch Sources (FAQ + Blog)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        176,
        272
      ],
      "parameters": {
        "url": "https://kairesume.fit/api/rag/sources",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "executeOnce": true,
      "typeVersion": 4.4
    },
    {
      "id": "149533b2-93a7-4298-a95f-de3f1ba112c1",
      "name": "Chunk and Batch",
      "type": "n8n-nodes-base.code",
      "position": [
        400,
        272
      ],
      "parameters": {
        "jsCode": "const TARGET_CHARS = 2800;\nconst OVERLAP_CHARS = 400;\nconst MIN_LETTERS_PER_CHUNK = 40;\nconst BATCH_SIZE = 32;\n\nfunction stripFrontmatter(md) {\n  if (!md.startsWith(\"---\")) return md;\n  const end = md.indexOf(\"\\n---\", 3);\n  if (end === -1) return md;\n  return md.slice(end + 4).replace(/^\\s+/, \"\");\n}\n\nfunction paragraphSplit(text, target) {\n  if (text.length <= target) return text.length;\n  const window = text.slice(0, target);\n  const paragraphCut = window.lastIndexOf(\"\\n\\n\");\n  if (paragraphCut > target * 0.6) return paragraphCut + 2;\n  const sentenceCut = window.lastIndexOf(\". \");\n  if (sentenceCut > target * 0.6) return sentenceCut + 2;\n  return target;\n}\n\nfunction subdivideLongSection(text) {\n  if (text.length <= TARGET_CHARS) return [text];\n  const out = [];\n  let i = 0;\n  while (i < text.length) {\n    const end = i + paragraphSplit(text.slice(i), TARGET_CHARS);\n    out.push(text.slice(i, end).trim());\n    if (end >= text.length) break;\n    i = Math.max(end - OVERLAP_CHARS, i + 1);\n  }\n  return out;\n}\n\nfunction letterCount(s) {\n  return (s.match(/[A-Za-z]/g) || []).length;\n}\n\nfunction chunkMarkdown(doc) {\n  const body = stripFrontmatter(doc.content);\n  const sections = body.split(/\\n(?=## )/).map(function (s) { return s.trim(); }).filter(Boolean);\n  const pieces = [];\n  for (const section of sections) {\n    for (const piece of subdivideLongSection(section)) pieces.push(piece);\n  }\n  const out = [];\n  let chunkIdx = 0;\n  for (const piece of pieces) {\n    if (letterCount(piece) < MIN_LETTERS_PER_CHUNK) continue;\n    out.push({ source: doc.source, chunk_idx: chunkIdx, content: piece });\n    chunkIdx++;\n  }\n  return out;\n}\n\nconst inputItem = items[0].json;\nconst sources = inputItem.sources || [];\nconst allChunks = [];\nfor (const doc of sources) {\n  for (const chunk of chunkMarkdown(doc)) allChunks.push(chunk);\n}\n\nconst runStart = new Date().toISOString();\nconst totalBatches = Math.max(1, Math.ceil(allChunks.length / BATCH_SIZE));\nconst batches = [];\nfor (let i = 0; i < allChunks.length; i += BATCH_SIZE) {\n  const batch = allChunks.slice(i, i + BATCH_SIZE);\n  batches.push({\n    json: {\n      batch: batch,\n      inputs: batch.map(function (c) { return c.content; }),\n      run_start: runStart,\n      batch_idx: batches.length,\n      total_batches: totalBatches,\n      total_chunks: allChunks.length,\n    },\n  });\n}\n\nreturn batches;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "ff79fd6e-7c61-4867-86ab-95b659420fbc",
      "name": "Delete Stale Chunks",
      "type": "n8n-nodes-base.postgres",
      "position": [
        848,
        80
      ],
      "parameters": {
        "query": "DELETE FROM public.rag_chunks WHERE updated_at < $1::timestamptz RETURNING source, chunk_idx;",
        "options": {
          "queryReplacement": "={{ $(\"Chunk and Batch\").first().json.run_start }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "61871abe-9ff3-4024-a3e4-b5b939407b70",
      "name": "Embed Batch (Supabase)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        848,
        272
      ],
      "parameters": {
        "url": "https://jhccfsyytbkfmdvduvds.supabase.co/functions/v1/embed",
        "method": "POST",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={{ JSON.stringify({ inputs: $json.inputs }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "2d212395-3bb1-43eb-89d9-767c258339e8",
      "name": "Build Upsert SQL",
      "type": "n8n-nodes-base.code",
      "position": [
        1072,
        272
      ],
      "parameters": {
        "jsCode": "const embedResponse = items[0].json;\nconst vectors = embedResponse.vectors || [];\n\nconst loopItem = $(\"Loop Batches\").item.json;\nconst batch = loopItem.batch;\nconst runStart = loopItem.run_start;\nconst batchIdx = loopItem.batch_idx;\n\nif (!Array.isArray(batch) || !Array.isArray(vectors) || batch.length !== vectors.length) {\n  throw new Error(\"Vector count \" + vectors.length + \" does not match chunk count \" + (batch ? batch.length : 0) + \" for batch \" + batchIdx);\n}\n\nfunction sqlString(s) {\n  return \"'\" + String(s).replace(/'/g, \"''\") + \"'\";\n}\n\nfunction vectorLiteral(v) {\n  return sqlString(\"[\" + v.map(function (x) { return Number(x).toFixed(6); }).join(\",\") + \"]\");\n}\n\nconst valuesRows = batch.map(function (c, i) {\n  return \"(\" + sqlString(c.source) + \", \" + Number(c.chunk_idx) + \", \" + sqlString(c.content) + \", \" + vectorLiteral(vectors[i]) + \"::vector, NOW())\";\n}).join(\",\\n  \");\n\nconst sql = \"INSERT INTO public.rag_chunks (source, chunk_idx, content, embedding, updated_at)\\nVALUES\\n  \" + valuesRows + \"\\nON CONFLICT (source, chunk_idx) DO UPDATE\\nSET content = EXCLUDED.content,\\n    embedding = EXCLUDED.embedding,\\n    updated_at = NOW();\";\n\nreturn [{ json: { sql: sql, batch_size: batch.length, run_start: runStart, batch_idx: batchIdx } }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "4ba0f657-7da2-4925-be4f-1cde35ffd794",
      "name": "Upsert Chunks",
      "type": "n8n-nodes-base.postgres",
      "position": [
        1296,
        352
      ],
      "parameters": {
        "query": "{{ $json.sql }}",
        "options": {
          "queryBatching": "single"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "ffd40eb7-91ba-4f6b-94a5-1b738cd26570",
      "name": "Loop Batches",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        624,
        272
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "1837434c-8804-4074-8472-14b47a9d9a79",
      "name": "Deploy Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -48,
        352
      ],
      "parameters": {
        "path": "kairesume-deploy",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 2.1
    },
    {
      "id": "b05c9c6b-c3a6-4cf7-801b-9890c80cf0d4",
      "name": "Sticky Note 1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -48,
        -480
      ],
      "parameters": {
        "width": 520,
        "height": 520,
        "content": "## \ud83d\udd01 RAG Corpus Reindex \u2192 Supabase pgvector\n\n**Who's it for:** Anyone running a retrieval-augmented (RAG) chatbot or search over their own docs who needs the vector store kept in sync automatically.\n\n**What it does:** On a daily schedule (and on a deploy webhook), it fetches your source markdown (FAQ + blog), chunks it on H2 boundaries (~2800 chars, 400 overlap), embeds each batch via a Supabase Edge Function (e.g. gte-small, 384-dim), bulk-UPSERTs the vectors into a Postgres `rag_chunks` table, then deletes stale rows from removed/shrunk sources.\n\n**How it works:**\n1. **Schedule** (daily) or **Deploy Webhook** triggers a run\n2. **Fetch Sources** pulls markdown docs from your API\n3. **Chunk and Batch** splits into ~32-chunk batches\n4. **Loop Batches** \u2192 **Embed Batch** \u2192 **Build Upsert SQL** \u2192 **Upsert Chunks** (ON CONFLICT updates)\n5. After the loop, **Delete Stale Chunks** removes rows older than this run\n\n**Idempotent** \u2014 re-running just refreshes `updated_at`. Runs on free tiers."
      },
      "typeVersion": 1
    },
    {
      "id": "7a95922a-8886-4ecc-ab79-2e21727f5f04",
      "name": "Sticky Note 2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        512,
        -480
      ],
      "parameters": {
        "color": 4,
        "width": 460,
        "height": 520,
        "content": "### \u2699\ufe0f Setup\n**Credentials (select placeholders after import):**\n- **[RAG_API]_HeaderAuth** \u2014 Header Auth for your sources API + Supabase embed function\n- **[SUPABASE]_Postgres** \u2014 Postgres connection to your Supabase database\n\n**Before running:**\n1. In **Fetch Sources**, set the URL to your docs API (returns `{ sources: [{ source, content }] }`)\n2. In **Embed Batch**, set your Supabase Edge Function URL (returns `{ vectors: [...] }`)\n3. Create the table:\n   `rag_chunks(source text, chunk_idx int, content text, embedding vector(384), updated_at timestamptz, primary key(source, chunk_idx))`\n4. Adjust the schedule hour / embedding dimension as needed\n\n**Error handling:** the network nodes use **retry-on-fail (3\u00d7)**. Optionally set an *Error Workflow* in workflow settings to get alerts."
      },
      "typeVersion": 1
    }
  ],
  "active": true,
  "settings": {
    "timezone": "America/New_York",
    "binaryMode": "separate",
    "errorWorkflow": "Rsh29fD5MByBp7SN",
    "availableInMCP": true,
    "executionOrder": "v1"
  },
  "versionId": "34741ee4-690e-47ca-9404-532116c338a0",
  "nodeGroups": [],
  "connections": {
    "Loop Batches": {
      "main": [
        [
          {
            "node": "Delete Stale Chunks",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Embed Batch (Supabase)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Chunks": {
      "main": [
        [
          {
            "node": "Loop Batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Deploy Webhook": {
      "main": [
        [
          {
            "node": "Fetch Sources (FAQ + Blog)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chunk and Batch": {
      "main": [
        [
          {
            "node": "Loop Batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Upsert SQL": {
      "main": [
        [
          {
            "node": "Upsert Chunks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Embed Batch (Supabase)": {
      "main": [
        [
          {
            "node": "Build Upsert SQL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule: Daily Reindex": {
      "main": [
        [
          {
            "node": "Fetch Sources (FAQ + Blog)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Sources (FAQ + Blog)": {
      "main": [
        [
          {
            "node": "Chunk and Batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

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.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow reindexes Markdown documentation into a Supabase Postgres pgvector table by fetching source docs from an HTTP API, chunking and embedding them via a Supabase Edge Function, upserting the vectors, and deleting stale chunks on a daily schedule or on-demand webhook.…

Source: https://n8n.io/workflows/16370/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

My Workflow. Uses outputParserStructured, httpRequest, lmChatGoogleGemini, chainLlm. Scheduled trigger; 82 nodes.

Output Parser Structured, HTTP Request, Google Gemini Chat +15
AI & RAG

Search Worflow Docker Complete. Uses documentDefaultDataLoader, textSplitterCharacterTextSplitter, vectorStoreSupabase, embeddingsOllama. Scheduled trigger; 71 nodes.

Document Default Data Loader, Text Splitter Character Text Splitter, Supabase Vector Store +14
AI & RAG

This workflow automates end-to-end customer journey management by intelligently routing queries through multiple AI models (OpenAI, Claude) based on complexity and context. Designed for customer succe

HTTP Request, Agent, OpenAI Chat +7
AI & RAG

This workflow implements a self-healing Retrieval-Augmented Generation (RAG) maintenance system that automatically updates document embeddings, evaluates retrieval quality, detects embedding drift, an

HTTP Request, Postgres, OpenAI Embeddings +5
AI & RAG

This workflow automates end-to-end e-commerce order processing from intake through fulfillment by orchestrating multiple AI-powered validation stages and external system integrations. Designed for e-c

HTTP Request, Agent, OpenAI Chat +10