{
  "name": "Firmenwissen als durchsuchbaren RAG-Index aufbauen",
  "meta": {
    "beschreibung": "Baut naechtlich aus einer Dokumentenliste (Google Sheets) einen durchsuchbaren Wissensindex: Datei laden, Text extrahieren, in ueberlappende Abschnitte teilen, Embeddings erzeugen und in einen Vector Store (Qdrant, alternativ Supabase/pgvector) schreiben. Ueber einen Inhalts-Hash wird nur neu eingebettet, was sich tatsaechlich geaendert hat — das spart bei jedem Lauf den Grossteil der API-Kosten und der Laufzeit. Lohnt sich fuer Betriebe mit gewachsener Ablage aus Handbuechern, Preislisten und Arbeitsanweisungen. DSGVO-Hinweis: Personenbezogene Dokumente gehoeren nur in den Index, wenn die Zugriffsrechte im Vector Store abgebildet sind; fuer die Embeddings gilt ein AV-Vertrag mit dem Anbieter oder ein lokales Embedding-Modell.",
    "kategorie": "KI-Agenten & RAG",
    "schwierigkeit": "fortgeschritten",
    "benoetigt": [
      "Google Sheets",
      "OpenAI-API (Embeddings)",
      "Qdrant oder Supabase/pgvector"
    ],
    "quelle": "https://die-ki-agentur.de/n8n-agentur/"
  },
  "nodes": [
    {
      "parameters": {
        "content": "## Firmenwissen als RAG-Index aufbauen\n\n**Ablauf:** Naechtlicher Lauf → Dokumentenliste aus Google Sheets → Datei laden → Text extrahieren\n→ Hash vergleichen (nur geaenderte Dokumente weiter) → alte Abschnitte in Qdrant loeschen\n→ Text in ueberlappende Abschnitte teilen → Embeddings erzeugen → Abschnitte in Qdrant speichern\n→ neuen Hash in die Liste zurueckschreiben.\n\n**Vor dem Start:**\n1. Google-Sheet \"Dokumente\" anlegen, Spalten: dokument_id, name, url, inhalt_hash, abschnitte, stand\n   (inhalt_hash und abschnitte bleiben beim ersten Lauf leer — der Workflow fuellt sie)\n2. Qdrant-Collection anlegen (Vektorgroesse 1536 fuer text-embedding-3-small, Distanz Cosine),\n   Host in beiden HTTP-Nodes eintragen: HIER_QDRANT_HOST\n3. Zugangsdaten waehlen: OpenAI (Embeddings), Header-Auth fuer Qdrant (api-key), Google Sheets\n4. Supabase statt Qdrant? Dann nur die beiden HTTP-Nodes gegen die pgvector-Endpunkte tauschen —\n   der Rest des Workflows bleibt unveraendert.\n\n**Warum der Hash-Vergleich:** Ohne ihn werden bei jedem Lauf alle Dokumente neu eingebettet.\nBei 500 Dokumenten sind das jede Nacht ein paar Euro und viel Wartezeit fuer null Mehrwert —\nder teuerste Anfaengerfehler beim RAG-Aufbau.\n\n**DSGVO:** Personenbezogene Dokumente (Personalakten, Bewerbungen) gehoeren nicht in den Index,\nsolange die Zugriffsrechte nicht im Vector Store abgebildet sind.\n\n**Betreuung:** https://die-ki-agentur.de/n8n-agentur/",
        "height": 720,
        "width": 520
      },
      "id": "note-doku",
      "name": "Kurzanleitung",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -200,
        60
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 3,
              "triggerAtMinute": 15
            }
          ]
        }
      },
      "id": "trigger-nacht",
      "name": "Naechtlicher Lauf",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        380,
        300
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "HIER_SHEET_ID_EINTRAGEN",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Dokumente",
          "mode": "name"
        },
        "options": {}
      },
      "id": "sheet-liste",
      "name": "Dokumentenliste lesen",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        600,
        300
      ]
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {
          "reset": false
        }
      },
      "id": "loop-doks",
      "name": "Dokumente einzeln verarbeiten",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        820,
        300
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "fazit",
              "name": "meldung",
              "value": "=Wissensindex aktualisiert am {{ $now.format('dd.MM.yyyy') }}. Verarbeitete Dokumente: {{ $input.all().length }}.",
              "type": "string"
            },
            {
              "id": "hinweis",
              "name": "naechster_schritt",
              "value": "Bei Bedarf Spalte 'abschnitte' im Sheet pruefen — auffaellig wenige Abschnitte deuten auf ein Scan-PDF ohne Textebene hin.",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "set-fertig",
      "name": "Lauf abschliessen",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1040,
        120
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "datei"
            }
          },
          "timeout": 60000
        }
      },
      "id": "http-download",
      "name": "Datei herunterladen",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1040,
        460
      ]
    },
    {
      "parameters": {
        "operation": "pdf",
        "binaryPropertyName": "datei",
        "options": {}
      },
      "id": "pdf-text",
      "name": "Text extrahieren",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        1260,
        460
      ]
    },
    {
      "parameters": {
        "jsCode": "// Warum ein eigener Hash statt require('crypto'):\n// n8n erlaubt eingebaute Node-Module im Code-Node nur, wenn der Betreiber\n// NODE_FUNCTION_ALLOW_BUILTIN gesetzt hat — auf n8n Cloud geht das gar nicht.\n// Der Hash muss hier nicht faelschungssicher sein, er muss nur zuverlaessig\n// anzeigen, ob sich der Text seit dem letzten Lauf geaendert hat. Dafuer reicht FNV-1a.\nfunction fnv1a(s) {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 0x01000193) >>> 0;\n  }\n  return h.toString(16).padStart(8, '0');\n}\n\nconst doku = $('Dokumente einzeln verarbeiten').first().json;\nconst text = String($input.first().json.text || '');\n\n// Normalisieren, bevor gehasht wird: unterschiedliche Zeilenenden oder nachlaufende\n// Leerzeichen aus einem neuen PDF-Export wuerden sonst eine Aenderung vortaeuschen\n// und das ganze Dokument unnoetig neu einbetten.\nconst normalisiert = text.replace(/\\r/g, '').replace(/[ \\t]+\\n/g, '\\n').replace(/\\n{3,}/g, '\\n\\n').trim();\n\n// Laenge mit in den Hash: schuetzt gegen die (seltenen) FNV-Kollisionen,\n// kostet nichts und macht das Ergebnis im Sheet fuer Menschen nachvollziehbar.\nconst neuerHash = fnv1a(normalisiert) + '-' + normalisiert.length.toString(16);\nconst alterHash = String(doku.inhalt_hash || '').trim();\n\nif (normalisiert.length < 200) {\n  // Ein PDF ohne Textebene (reiner Scan) liefert leeren Text. Ohne diese Bremse\n  // wuerde der Workflow den Eintrag als \"geaendert\" behandeln und leere Abschnitte indexieren.\n  throw new Error('Dokument \"' + (doku.name || doku.dokument_id) + '\" liefert kaum Text (' +\n    normalisiert.length + ' Zeichen) — vermutlich ein Scan ohne OCR. Bitte OCR vorschalten.');\n}\n\nreturn [{\n  json: {\n    dokument_id: String(doku.dokument_id || doku.name || '').trim(),\n    dokument_name: doku.name,\n    quelle_url: doku.url,\n    inhalt_hash: neuerHash,\n    vorher_hash: alterHash,\n    geaendert: neuerHash !== alterHash,\n    zeichen: normalisiert.length,\n    stand: new Date().toISOString().slice(0, 10),\n    text: normalisiert,\n  },\n}];"
      },
      "id": "code-hash",
      "name": "Inhalt hashen und vergleichen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1480,
        460
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "version": 2
          },
          "conditions": [
            {
              "id": "geaendert",
              "leftValue": "={{ $json.geaendert }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-geaendert",
      "name": "Dokument geaendert?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1700,
        460
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://HIER_QDRANT_HOST:6333/collections/firmenwissen/points/delete?wait=true",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filter: { must: [ { key: 'dokument_id', match: { value: $json.dokument_id } } ] } }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "http-loeschen",
      "name": "Alte Abschnitte entfernen",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1920,
        380
      ]
    },
    {
      "parameters": {
        "jsCode": "// Warum ueberhaupt Abschnitte: Ein einziges Embedding ueber 30 Seiten verwaessert die\n// Bedeutung — die Suche findet dann alles ein bisschen und nichts genau.\n// ZIELGROESSE ~1200 Zeichen ist der Bereich, in dem ein Abschnitt noch genug Kontext\n// mitbringt, aber thematisch eindeutig bleibt (grob 300 Token).\n// UEBERLAPPUNG verhindert, dass eine Aussage, die genau auf der Schnittkante liegt,\n// in beiden Abschnitten nur halb vorkommt und damit von der Suche nicht mehr gefunden wird.\nconst ZIELGROESSE = 1200;\nconst UEBERLAPPUNG = 200;\nconst MIN_ABSCHNITT = 80;\n\nconst raus = [];\n\nfor (const eintrag of $input.all()) {\n  const dok = eintrag.json;\n  const text = String(dok.text || '');\n  let start = 0;\n  let nummer = 0;\n\n  while (start < text.length) {\n    let ende = Math.min(start + ZIELGROESSE, text.length);\n\n    if (ende < text.length) {\n      // Lieber an einer Absatz- oder Satzgrenze schneiden als mitten im Wort.\n      // Sonst beginnt ein Abschnitt mit einem Halbsatz und die KI zitiert spaeter Fragmente,\n      // die im Original gar nicht so stehen.\n      const fenster = text.slice(start, ende);\n      const absatz = fenster.lastIndexOf('\\n\\n');\n      const satz = Math.max(fenster.lastIndexOf('. '), fenster.lastIndexOf('! '), fenster.lastIndexOf('? '));\n      if (absatz > ZIELGROESSE * 0.5) {\n        ende = start + absatz;\n      } else if (satz > ZIELGROESSE * 0.5) {\n        ende = start + satz + 1;\n      }\n    }\n\n    const abschnitt = text.slice(start, ende).trim();\n\n    // Reste unter MIN_ABSCHNITT (Seitenzahlen, Fusszeilen) fliegen raus:\n    // sie erzeugen Treffer ohne Aussage und verdraengen echte Fundstellen aus dem Top-K.\n    if (abschnitt.length >= MIN_ABSCHNITT) {\n      raus.push({\n        json: {\n          dokument_id: dok.dokument_id,\n          dokument_name: dok.dokument_name,\n          quelle_url: dok.quelle_url,\n          inhalt_hash: dok.inhalt_hash,\n          stand: dok.stand,\n          abschnitt_nr: nummer,\n          abschnitt_id: dok.dokument_id + '#' + nummer,\n          text: abschnitt,\n          zeichen: abschnitt.length,\n        },\n      });\n      nummer++;\n    }\n\n    if (ende >= text.length) break;\n    // start + 1 als Untergrenze: verhindert eine Endlosschleife, falls die Satzgrenze\n    // den Schnitt weiter nach vorne zieht als die Ueberlappung zurueck.\n    start = Math.max(ende - UEBERLAPPUNG, start + 1);\n  }\n}\n\nif (raus.length === 0) {\n  throw new Error('Keine verwertbaren Abschnitte gebildet — Dokument pruefen.');\n}\n\nreturn raus;"
      },
      "id": "code-chunk",
      "name": "In Abschnitte teilen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2140,
        380
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/embeddings",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'text-embedding-3-small', input: $json.text }) }}",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 20,
              "batchInterval": 1000
            }
          },
          "timeout": 60000
        }
      },
      "id": "http-embed",
      "name": "Embeddings erzeugen",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2360,
        380
      ]
    },
    {
      "parameters": {
        "jsCode": "// Embeddings kommen in derselben Reihenfolge zurueck wie die Abschnitte hineingingen.\n// Trotzdem wird die Anzahl geprueft: verrutscht die Zuordnung (z. B. weil ein Request\n// gescheitert ist), landen Texte unter fremden Vektoren — ein Fehler, der im Betrieb\n// nur als \"die Suche liefert Unsinn\" auffaellt und stundenlang Suche kostet.\nconst abschnitte = $('In Abschnitte teilen').all();\nconst antworten = $input.all();\n\nif (abschnitte.length !== antworten.length) {\n  throw new Error('Abschnitte (' + abschnitte.length + ') und Embeddings (' + antworten.length +\n    ') passen nicht zusammen — Lauf abgebrochen, damit keine falsch verknuepften Vektoren entstehen.');\n}\n\nfunction fnv1a(s) {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 0x01000193) >>> 0;\n  }\n  return h >>> 0;\n}\n\nconst punkte = antworten.map((antwort, i) => {\n  const a = abschnitte[i].json;\n  const vektor = antwort.json?.data?.[0]?.embedding;\n  if (!Array.isArray(vektor) || vektor.length === 0) {\n    throw new Error('Kein Vektor fuer Abschnitt ' + a.abschnitt_id + ' erhalten.');\n  }\n  return {\n    // Deterministische ID aus Dokument + Abschnittsnummer: ein zweiter Lauf ueberschreibt\n    // denselben Punkt, statt eine Dublette anzulegen. Qdrant akzeptiert unsigned Integer.\n    id: fnv1a(String(a.dokument_id)) * 100000 + a.abschnitt_nr,\n    vector: vektor,\n    payload: {\n      dokument_id: a.dokument_id,\n      dokument_name: a.dokument_name,\n      quelle_url: a.quelle_url,\n      abschnitt_nr: a.abschnitt_nr,\n      stand: a.stand,\n      text: a.text,\n    },\n  };\n});\n\nconst erster = abschnitte[0].json;\n\nreturn [{\n  json: {\n    dokument_id: erster.dokument_id,\n    dokument_name: erster.dokument_name,\n    quelle_url: erster.quelle_url,\n    inhalt_hash: erster.inhalt_hash,\n    stand: erster.stand,\n    anzahl_abschnitte: punkte.length,\n    points: punkte,\n  },\n}];"
      },
      "id": "code-punkte",
      "name": "Vektorpunkte buendeln",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2580,
        380
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "http://HIER_QDRANT_HOST:6333/collections/firmenwissen/points?wait=true",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ points: $json.points }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "http-qdrant",
      "name": "Abschnitte in Vector Store schreiben",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2800,
        380
      ]
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "HIER_SHEET_ID_EINTRAGEN",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Dokumente",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "dokument_id"
          ],
          "value": {
            "dokument_id": "={{ $('Vektorpunkte buendeln').item.json.dokument_id }}",
            "name": "={{ $('Vektorpunkte buendeln').item.json.dokument_name }}",
            "url": "={{ $('Vektorpunkte buendeln').item.json.quelle_url }}",
            "inhalt_hash": "={{ $('Vektorpunkte buendeln').item.json.inhalt_hash }}",
            "abschnitte": "={{ $('Vektorpunkte buendeln').item.json.anzahl_abschnitte }}",
            "stand": "={{ $('Vektorpunkte buendeln').item.json.stand }}"
          }
        },
        "options": {}
      },
      "id": "sheet-hash",
      "name": "Neuen Hash zurueckschreiben",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        3020,
        380
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "grund",
              "name": "uebersprungen_grund",
              "value": "=Inhalt unveraendert (Hash {{ $json.inhalt_hash }}) — kein neues Embedding noetig.",
              "type": "string"
            },
            {
              "id": "dok",
              "name": "dokument_name",
              "value": "={{ $json.dokument_name }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "set-uebersprungen",
      "name": "Unveraendert uebersprungen",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1920,
        620
      ]
    }
  ],
  "connections": {
    "Naechtlicher Lauf": {
      "main": [
        [
          {
            "node": "Dokumentenliste lesen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dokumentenliste lesen": {
      "main": [
        [
          {
            "node": "Dokumente einzeln verarbeiten",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dokumente einzeln verarbeiten": {
      "main": [
        [
          {
            "node": "Lauf abschliessen",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Datei herunterladen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Datei herunterladen": {
      "main": [
        [
          {
            "node": "Text extrahieren",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Text extrahieren": {
      "main": [
        [
          {
            "node": "Inhalt hashen und vergleichen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inhalt hashen und vergleichen": {
      "main": [
        [
          {
            "node": "Dokument geaendert?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dokument geaendert?": {
      "main": [
        [
          {
            "node": "Alte Abschnitte entfernen",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Unveraendert uebersprungen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Alte Abschnitte entfernen": {
      "main": [
        [
          {
            "node": "In Abschnitte teilen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "In Abschnitte teilen": {
      "main": [
        [
          {
            "node": "Embeddings erzeugen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Embeddings erzeugen": {
      "main": [
        [
          {
            "node": "Vektorpunkte buendeln",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Vektorpunkte buendeln": {
      "main": [
        [
          {
            "node": "Abschnitte in Vector Store schreiben",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Abschnitte in Vector Store schreiben": {
      "main": [
        [
          {
            "node": "Neuen Hash zurueckschreiben",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Neuen Hash zurueckschreiben": {
      "main": [
        [
          {
            "node": "Dokumente einzeln verarbeiten",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Unveraendert uebersprungen": {
      "main": [
        [
          {
            "node": "Dokumente einzeln verarbeiten",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
