{
  "name": "RAG index freshness monitor",
  "nodes": [
    {
      "parameters": {
        "content": "## RAG freshness monitor: find the stale answers before a customer does\n\nA retrieval assistant that answers from last quarter's documents looks exactly like one\nthat works. The index does not report its own age.\n\nThis compares the source documents with what is actually in the vector store every\nmorning, and flags three problems: documents changed but not re-indexed, documents\ndeleted but still in the index, and documents that were never indexed at all.\n\n**Setup**\n1. Point **Fetch Source Documents** at wherever the truth lives: a CMS, Drive, S3 or a\n   database listing with ids and modified dates.\n2. Point **Fetch Indexed Documents** at your vector store's listing endpoint.\n3. Set the staleness window in **Compare Source To Index**.\n4. Replace the webhook URL in **Alert**.",
        "height": 382,
        "width": 1400
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -943
      ],
      "id": "stage-overview",
      "name": "Sticky Overview"
    },
    {
      "parameters": {
        "content": "### 1. Daily 06:30\n\nBefore the working day. A drift found at 06:30 can be re-indexed before anybody asks\nthe assistant a question that depends on it.",
        "height": 361,
        "width": 280
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -481
      ],
      "id": "stage-0",
      "name": "Sticky Daily 06:30"
    },
    {
      "parameters": {
        "content": "### 2. Fetch the two sides\n\n**Fetch Source Documents** reads the truth: document id and last modified date.\n\n**Fetch Indexed Documents** reads what the vector store believes it holds.\n\nBoth are placeholders. Replace with your own endpoints and credentials.",
        "height": 361,
        "width": 280
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        320,
        -481
      ],
      "id": "stage-1",
      "name": "Sticky Fetch Source Documents"
    },
    {
      "parameters": {
        "content": "### 3. Compare Source To Index\n\nThe whole point of the workflow. Produces three lists:\n\n- **stale**: in the index, but the source changed after it was indexed\n- **orphaned**: in the index, but gone from the source\n- **missing**: in the source, never indexed\n\nSet the staleness window here.",
        "height": 361,
        "width": 280
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        640,
        -481
      ],
      "id": "stage-2",
      "name": "Sticky Compare Source To Index"
    },
    {
      "parameters": {
        "content": "### 4. Drift Found?\n\nContinues to the alert only when one of the three lists is non-empty, so a clean index\nis silent.",
        "height": 361,
        "width": 280
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        960,
        -481
      ],
      "id": "stage-3",
      "name": "Sticky Drift Found?"
    },
    {
      "parameters": {
        "content": "### 5. Alert and Record Clean Run\n\n**Alert** names the counts and the first few document ids in each category, which is\nusually enough to see what happened without opening anything.\n\n**Record Clean Run** is the healthy path.",
        "height": 361,
        "width": 280
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1280,
        -481
      ],
      "id": "stage-4",
      "name": "Sticky Alert"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "30 6 * * *"
            }
          ]
        }
      },
      "id": "daily-06:30",
      "name": "Daily 06:30",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "url": "YOUR_SOURCE_LISTING_URL",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "fetch-source-documents",
      "name": "Fetch Source Documents",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        320,
        0
      ],
      "onError": "continueRegularOutput",
      "notes": "Whatever holds the truth: a Drive folder listing, a CMS API, a database view. Shape each item as { id, title, updatedAt, hash }."
    },
    {
      "parameters": {
        "url": "YOUR_VECTOR_STORE_LISTING_URL",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "fetch-indexed-documents",
      "name": "Fetch Indexed Documents",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        320,
        200
      ],
      "onError": "continueRegularOutput",
      "notes": "The metadata your vector store keeps per document. Pinecone, Qdrant and pgvector can all list this; shape it the same way."
    },
    {
      "parameters": {
        "jsCode": "// Compare what the source of truth holds against what the index holds.\n//\n// A RAG assistant does not announce that its index is stale: it answers\n// confidently from the version it has. This finds the gap before a customer\n// does, in three shapes.\n//\n// Feed it two lists. Shape them in the two HTTP nodes above so each item is\n// { id, title, updatedAt } and, where you can get one, { hash }.\n\nconst sources = $('Fetch Source Documents').all().map((i) => i.json);\nconst indexed = $('Fetch Indexed Documents').all().map((i) => i.json);\n\nconst STALE_HOURS = 24;      // how far behind the index may fall before it counts\nconst byId = new Map(indexed.map((d) => [String(d.id), d]));\n\nconst missing = [];   // in the source, never indexed\nconst stale = [];     // indexed, but from an older version\nconst orphaned = [];  // in the index, gone from the source\n\nfor (const src of sources) {\n  const idx = byId.get(String(src.id));\n  if (!idx) {\n    missing.push({ id: src.id, title: src.title });\n    continue;\n  }\n  const srcTime = new Date(src.updatedAt).getTime();\n  const idxTime = new Date(idx.updatedAt ?? idx.indexedAt ?? 0).getTime();\n  const hashDiffers = src.hash && idx.hash && src.hash !== idx.hash;\n  const behindHours = (srcTime - idxTime) / 3600000;\n  if (hashDiffers || behindHours > STALE_HOURS) {\n    stale.push({\n      id: src.id,\n      title: src.title,\n      behindHours: Math.round(behindHours),\n      reason: hashDiffers ? 'content hash differs' : 'index older than source',\n    });\n  }\n}\n\nconst sourceIds = new Set(sources.map((s) => String(s.id)));\nfor (const idx of indexed) {\n  if (!sourceIds.has(String(idx.id))) {\n    orphaned.push({ id: idx.id, title: idx.title });\n  }\n}\n\n// Orphans matter more than they look. A deleted policy that stays in the index\n// is the one case where the assistant answers with something the company has\n// explicitly withdrawn.\nconst drift = missing.length + stale.length + orphaned.length;\nconst lines = [];\nif (missing.length) lines.push(`${missing.length} never indexed: ${missing.slice(0,5).map(d=>d.title).join(', ')}`);\nif (stale.length) lines.push(`${stale.length} stale: ${stale.slice(0,5).map(d=>`${d.title} (${d.behindHours}h behind)`).join(', ')}`);\nif (orphaned.length) lines.push(`${orphaned.length} withdrawn but still answerable: ${orphaned.slice(0,5).map(d=>d.title).join(', ')}`);\n\nreturn [{\n  json: {\n    checkedAt: new Date().toISOString(),\n    sourceCount: sources.length,\n    indexedCount: indexed.length,\n    missing,\n    stale,\n    orphaned,\n    drift,\n    hasDrift: drift > 0,\n    summary: drift\n      ? `RAG index drift: ${lines.join(' | ')}`\n      : `RAG index clean: ${sources.length} documents, all current.`,\n  },\n}];"
      },
      "id": "compare-source-to-index",
      "name": "Compare Source To Index",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        640,
        100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "has-drift",
              "leftValue": "={{ $json.hasDrift }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      },
      "id": "drift-found",
      "name": "Drift Found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        960,
        100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "YOUR_WEBHOOK_URL",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: $json.summary }) }}",
        "options": {}
      },
      "id": "alert",
      "name": "Alert",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1280,
        100
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {},
      "id": "record-clean-run",
      "name": "Record Clean Run",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1280,
        300
      ],
      "notes": "A clean run still leaves an execution, so the history answers 'when was the index last verified' without anyone remembering."
    }
  ],
  "connections": {
    "Daily 06:30": {
      "main": [
        [
          {
            "node": "Fetch Source Documents",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Indexed Documents",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Source Documents": {
      "main": [
        [
          {
            "node": "Compare Source To Index",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Indexed Documents": {
      "main": [
        [
          {
            "node": "Compare Source To Index",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare Source To Index": {
      "main": [
        [
          {
            "node": "Drift Found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drift Found?": {
      "main": [
        [
          {
            "node": "Alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Record Clean Run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "instanceId": "lurtoagency.com/fix/rag-assistant-answers-from-old-documents"
  }
}
