Dieses Projekt ermöglicht das Hochladen von Dokumenten (PDF, TXT, MD) in eine Vektordatenbank via Webformular. OpenWebUI nutzt die gespeicherten Chunks als RAG-Kontext für Chat-Anfragen. Alle Komponenten sind self-hosted und über Docker betrieben. LiteLLM ist empfehlenswert, aber optional – wer nur einen Provider hat, kann den Embedding-Call auch direkt zur API führen.

Stack

  • n8n v2.10+ — Orchestrierung und Upload-Flow
  • LiteLLM — API-Gateway für Embeddings (optional, empfohlen wenn mehrere LLM-Provider im Einsatz)
  • Qdrant — Vektordatenbank
  • OpenWebUI — Chat-Frontend mit RAG-Integration
  • text-embedding-3-small (OpenAI/Azure) — Embedding-Modell, 1536 Dimensionen

Architektur
Der Flow besteht aus zwei Pfaden:
Upload-Pfad: Browser sendet Datei per POST an n8n. n8n extrahiert und chunked den Text (zeichenbasiert, ~2000 Zeichen / Chunk, Satz-Split mit Normalisierung). Jeder Chunk wird via LiteLLM eingebettet. Der resultierende 1536D-Vektor wird mit Payload (Text, Source, tenant_id, created_at) in Qdrant gespeichert.
Retrieval-Pfad: OpenWebUI embedded die User-Query via LiteLLM, sucht semantisch in Qdrant (Cosine-Similarity, Top-K=3) und reicht die Treffer als Kontext an das Chat-Modell weiter.

Warum LiteLLM?
LiteLLM sitzt als Gateway zwischen allen Komponenten und dem eigentlichen Embedding-Modell. Das bringt drei Vorteile: Erstens können Provider (OpenAI, Azure, Ollama, lokal) jederzeit gewechselt werden ohne Flow-Anpassung. Zweitens lassen sich pro Applikation virtuelle API-Keys mit Budget-Limits konfigurieren. Drittens können einfache Guardrails — z.B. Content-Filtering oder Rate-Limiting — zentral in LiteLLM hinterlegt werden, ohne dass n8n oder OpenWebUI angepasst werden müssen.
Wer nur einen Provider hat und keine Budget-Kontrolle braucht, kann den Embedding-Call in n8n auch direkt an die OpenAI-API schicken.

Bekannte Stolpersteine
Modellname: OpenWebUI sendet intern «embedding-3-small» statt «text-embedding-3-small». LiteLLM gibt 400 Bad Request zurück. Fix: In OpenWebUI Admin -> Documents -> Embedding Model den korrekten Namen eintragen.
Async Embedding: Bei aktiviertem Async Embedding Processing und einzelnen fehlschlagenden Batches wirft OpenWebUI «list index out of range». Fix: Async deaktivieren oder Batch Size auf 10+ setzen.
API-Keys nach Container-Update: OpenWebUI-Updates können bestehende API-Keys invalidieren. Fix: via POST /api/v1/auths/signin einen Session-Token holen, dann neuen Key generieren.
Qdrant Collection-Dimensionen: Wenn die Collection mit 384D erstellt wurde (Standard bei all-MiniLM-L6-v2) und der Flow 1536D schreibt, schlägt der Upsert still fehl. Fix: Collection löschen, OpenWebUI neu indexieren lassen — sie wird mit den korrekten Dimensionen neu erstellt.

Konfiguration des Flows
Nach dem Import in n8n müssen vier Platzhalter ersetzt werden:

OPENWEBUI_HOST  ->  z.B. 192.168.1.10:3000
LITELLM_HOST    ->  z.B. 192.168.1.11:4000
QDRANT_HOST     ->  z.B. 192.168.1.12:6333
QDRANT_COLLECTION -> open-webui_knowledge

Dazu zwei Header-Auth Credentials in n8n anlegen (Authorization: Bearer ) — eine für OpenWebUI, eine für LiteLLM.

Payload-Struktur in Qdrant

{
  "text": "Chunk-Inhalt",
  "metadata": { "source": "datei.pdf", "name": "datei.pdf",
    "embedding_config": { "engine": "openai", "model": "text-embedding-3-small" } },
  "tenant_id": "<knowledge-store-id>",
  "created_at": 1234567890
}

Download
RAG-Upload-Flow-v18-sanitised.json — Text unten in Code-Box kopieren und als “Dateiname.json” abspeichern in n8n Flow importieren. Alle IPs/Keys durch Platzhalter ersetzt. active: false damit der Flow nicht sofort startet

Dieses Projekt bedingt, das in OpenWebUI die Qdrant Instanz als Knowledge Store hinterlegt worden ist . Reddit Artikel dazu ist hier zu finden -> open-webui with qdrant

RAG-Upload-Flow-v18-sanitised.json
{
  "name": "Fischerman-RAG-Upload-Flow-v1-template",
  "nodes": [
    {
      "parameters": {
        "path": "rag-upload",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "dc8f6c9b-0203-468b-88f6-302d88fbcc26",
      "name": "Webhook: HTML ausliefern",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        3632,
        1904
      ],
      "webhookId": "rag-upload-form"
    },
    {
      "parameters": {
        "url": "http://OPENWEBUI_HOST/api/v1/knowledge/",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": []
        },
        "options": {}
      },
      "id": "e6ae011a-efaf-469f-afb7-cb1a2c1ad01c",
      "name": "OpenWebUI: Knowledge-Stores laden",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3872,
        2112
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_WITH_OPENWEBUI_CREDENTIAL_ID",
          "name": "OpenWebUI Authorization"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\nconst options = [];\nfor (const item of items) {\n  const j = item.json;\n  const list = j.items || [j];\n  for (const s of list) {\n    if (s.id && s.name) {\n      options.push({ id: String(s.id), name: String(s.name) });\n    }\n  }\n}\nconst optionsJson = JSON.stringify(options);\nconst html = '<!DOCTYPE html><html lang=\"de\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>RAG Dokument hochladen</title><style>*{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;background:#0f0f0f;color:#e0e0e0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;width:100%;max-width:480px}h1{font-size:1.3rem;font-weight:600;margin-bottom:8px;color:#fff}p.sub{font-size:.85rem;color:#888;margin-bottom:28px}label{display:block;font-size:.8rem;font-weight:500;color:#aaa;margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em}select,input[type=number]{width:100%;background:#111;border:1px solid #333;border-radius:8px;color:#e0e0e0;padding:10px 14px;font-size:.95rem;margin-bottom:20px;appearance:none}.file-drop{border:2px dashed #333;border-radius:8px;padding:32px 20px;text-align:center;cursor:pointer;margin-bottom:20px}.file-drop:hover,.file-drop.drag{border-color:#666}.hint{font-size:.8rem;color:#666;margin-top:6px}.file-name{font-size:.85rem;color:#4ade80;margin-top:6px}.row{display:grid;grid-template-columns:1fr 1fr;gap:16px}button{width:100%;background:#2563eb;color:#fff;border:none;border-radius:8px;padding:12px;font-size:1rem;font-weight:500;cursor:pointer;margin-top:8px}button:hover{background:#1d4ed8}button:disabled{background:#333;color:#666;cursor:not-allowed}.status{margin-top:16px;padding:12px;border-radius:8px;font-size:.9rem;display:none}.status.ok{background:#052e16;color:#4ade80;display:block}.status.err{background:#2d0a0a;color:#f87171;display:block}.status.loading{background:#1e1b4b;color:#a5b4fc;display:block}</style></head><body><div class=\"card\"><h1>RAG Dokument hochladen</h1><p class=\"sub\">PDF, TXT oder MD in einen Knowledge-Store einlesen</p><label>Knowledge-Store</label><select id=\"store\"></select><label>Datei</label><div class=\"file-drop\" id=\"dropzone\"><div style=\"font-size:2rem;margin-bottom:8px\">&#128196;</div><div>Datei hier ablegen oder klicken</div><div class=\"hint\">.pdf, .txt, .md</div><div class=\"file-name\" id=\"fname\"></div></div><input type=\"file\" id=\"fileInput\" accept=\".pdf,.txt,.md\" style=\"display:none\"><div class=\"row\"><div><label>Chunk-Gr&#246;sse (Token)</label><input type=\"number\" id=\"chunkSize\" value=\"500\" min=\"100\" max=\"2000\"></div><div><label>&#220;berlappung (Token)</label><input type=\"number\" id=\"chunkOverlap\" value=\"50\" min=\"0\" max=\"500\"></div></div><button id=\"submitBtn\" disabled>Hochladen</button><div class=\"status\" id=\"status\"></div></div><script>const stores = ' + optionsJson + ';const sel=document.getElementById(\"store\");stores.forEach(s=>{const o=document.createElement(\"option\");o.value=s.id;o.textContent=s.name;sel.appendChild(o)});const dropzone=document.getElementById(\"dropzone\");const fileInput=document.getElementById(\"fileInput\");const fname=document.getElementById(\"fname\");const submitBtn=document.getElementById(\"submitBtn\");const status=document.getElementById(\"status\");let selectedFile=null;dropzone.addEventListener(\"click\",()=>fileInput.click());dropzone.addEventListener(\"dragover\",e=>{e.preventDefault();dropzone.classList.add(\"drag\")});dropzone.addEventListener(\"dragleave\",()=>dropzone.classList.remove(\"drag\"));dropzone.addEventListener(\"drop\",e=>{e.preventDefault();dropzone.classList.remove(\"drag\");handleFile(e.dataTransfer.files[0])});fileInput.addEventListener(\"change\",()=>handleFile(fileInput.files[0]));function handleFile(f){if(!f)return;const ext=f.name.split(\".\").pop().toLowerCase();if(![\"pdf\",\"txt\",\"md\"].includes(ext)){showStatus(\"Nur PDF, TXT oder MD erlaubt.\",\"err\");return}selectedFile=f;fname.textContent=f.name;submitBtn.disabled=false;status.className=\"status\"}submitBtn.addEventListener(\"click\",async()=>{if(!selectedFile)return;submitBtn.disabled=true;showStatus(\"Wird verarbeitet...\",\"loading\");const fd=new FormData();fd.append(\"tenant_id\",sel.value);fd.append(\"store_name\",sel.options[sel.selectedIndex].text);fd.append(\"chunk_size\",document.getElementById(\"chunkSize\").value);fd.append(\"chunk_overlap\",document.getElementById(\"chunkOverlap\").value);fd.append(\"file\",selectedFile);try{const res=await fetch(\"/webhook/rag-ingest\",{method:\"POST\",body:fd});const data=await res.json();if(res.ok&&data.success){showStatus(data.message,\"ok\")}else{showStatus(data.message||\"Fehler.\",\"err\")}}catch(e){showStatus(\"Netzwerkfehler: \"+e.message,\"err\")}submitBtn.disabled=false});function showStatus(msg,type){status.textContent=msg;status.className=\"status \"+type}</script></body></html>';\nreturn [{ json: { html } }];"
      },
      "id": "668131a0-60ed-4897-8205-8fb6b3dcb6fd",
      "name": "HTML bauen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4112,
        1904
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ $json.html }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/html; charset=utf-8"
              }
            ]
          }
        }
      },
      "id": "299e5069-a1fc-4240-9f37-033b6f9b83c4",
      "name": "HTML Response",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        4352,
        1904
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "rag-ingest",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "aaedbd15-ff63-4283-8094-19c4c86bded4",
      "name": "Webhook: Ingest",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        3632,
        2416
      ],
      "webhookId": "rag-ingest"
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first();\nconst body = item.json.body || item.json;\n\nconst tenant_id = body.tenant_id || '';\nconst store_name = body.store_name || tenant_id;\nconst chunkSize = parseInt(body.chunk_size) || 500;\nconst chunkOverlap = parseInt(body.chunk_overlap) || 50;\n\nconst binaryKey = Object.keys(item.binary || {})[0];\nif (!binaryKey) throw new Error('Keine Datei empfangen.');\n\nconst fileObj = item.binary[binaryKey];\nconst fileName = fileObj.fileName || fileObj.filename || 'upload';\nconst ext = fileName.split('.').pop().toLowerCase();\n\nreturn [{\n  json: { tenant_id, store_name, chunkSize, chunkOverlap, fileName, ext },\n  binary: { data: fileObj }\n}];"
      },
      "id": "ad4bb721-5f7e-408b-9e99-a0ed2d0a66c8",
      "name": "Ingest: Daten parsen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3872,
        2416
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 3
                },
                "conditions": [
                  {
                    "id": "573f7d0e-41ae-45da-ab2e-ab9f252ac8e4",
                    "leftValue": "={{ $json.ext }}",
                    "rightValue": "pdf",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "PDF"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 3
                },
                "conditions": [
                  {
                    "id": "3b9bbd92-ae29-4581-bc5a-d9cea8f5e8a5",
                    "leftValue": "={{ $json.ext }}",
                    "rightValue": "txt",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "TXT_MD"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 3
                },
                "conditions": [
                  {
                    "id": "34faeb30-81e3-41ea-9634-c7d4a694f9f4",
                    "leftValue": "={{ $json.ext }}",
                    "rightValue": "md",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "TXT_MD"
            }
          ]
        },
        "options": {}
      },
      "id": "b7b8415e-7679-4e72-bfa0-ecdd0f0c35af",
      "name": "Switch: Dateityp",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.4,
      "position": [
        4112,
        2416
      ]
    },
    {
      "parameters": {
        "operation": "pdf",
        "options": {}
      },
      "id": "4c738893-615f-4f9d-beca-1a6169f17e69",
      "name": "PDF extrahieren",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        4352,
        2288
      ]
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first();\nconst prev = $('Ingest: Daten parsen').first().json;\nconst rawText = (item.json.text || '').replace(/\\n/g, ' ').trim();\n\nif (!rawText) {\n  throw new Error('PDF enthaelt keinen extrahierbaren Text (moeglicherweise gescannt oder als Kurven gespeichert). Bitte OCR-verarbeitetes PDF verwenden.');\n}\n\nreturn [{\n  json: {\n    rawText,\n    tenant_id: prev.tenant_id,\n    store_name: prev.store_name,\n    chunkSize: prev.chunkSize,\n    chunkOverlap: prev.chunkOverlap,\n    source: prev.fileName\n  }\n}];"
      },
      "id": "7aaa349c-2910-42fe-ac19-0e1f7504bb24",
      "name": "PDF normalisieren",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4592,
        2288
      ]
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first();\nconst { tenant_id, store_name, chunkSize, chunkOverlap, fileName } = item.json;\n\nconst binaryKey = Object.keys(item.binary || {})[0];\nconst fileObj = item.binary[binaryKey];\nconst buffer = Buffer.from(fileObj.data, 'base64');\nconst rawText = buffer.toString('utf8');\n\nif (!rawText || rawText.trim().length === 0) throw new Error('Datei ist leer.');\n\nreturn [{ json: { rawText, tenant_id, store_name, chunkSize, chunkOverlap, source: fileName } }];"
      },
      "id": "f027678c-3860-4bff-a3d7-949bd67f24ff",
      "name": "TXT/MD dekodieren",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4352,
        2528
      ]
    },
    {
      "parameters": {
        "jsCode": "const allItems = $input.all();\nconst results = [];\n\nfor (const item of allItems) {\n  const rawText      = item.json.rawText || '';\n  const tenant_id   = item.json.tenant_id || '';\n  const store_name  = item.json.store_name || tenant_id;\n  const chunkTokens  = parseInt(item.json.chunkSize) || 500;\n  const overlapTokens = parseInt(item.json.chunkOverlap) || 50;\n  const source       = item.json.source || 'unbekannt';\n\n  const chunkSize    = chunkTokens * 4;\n  const chunkOverlap = overlapTokens * 4;\n\n  if (!rawText.trim()) continue;\n\n  const normalized = rawText.replace(/[\\r\\n]+/g, ' ').replace(/\\s{2,}/g, ' ').trim();\n  const sentences = normalized.match(/[^.!?]+[.!?]+/g) || [normalized];\n\n  const chunks = [];\n  let current = '';\n\n  for (const sentence of sentences) {\n    const candidate = current ? current + ' ' + sentence.trim() : sentence.trim();\n    if (candidate.length <= chunkSize) {\n      current = candidate;\n    } else {\n      if (current) chunks.push(current);\n      if (sentence.length > chunkSize) {\n        let pos = 0;\n        while (pos < sentence.length) {\n          chunks.push(sentence.slice(pos, pos + chunkSize));\n          pos += chunkSize;\n        }\n        current = '';\n      } else {\n        current = sentence.trim();\n      }\n    }\n  }\n  if (current) chunks.push(current);\n\n  for (let i = 0; i < chunks.length; i++) {\n    let text = chunks[i];\n    if (i > 0 && chunkOverlap > 0) {\n      const overlap = chunks[i - 1].slice(-chunkOverlap);\n      text = overlap + ' ' + text;\n    }\n\n    const b = Array.from({length:16}, () => Math.floor(Math.random()*256));\n    b[6] = (b[6] & 0x0f) | 0x40;\n    b[8] = (b[8] & 0x3f) | 0x80;\n    const hex = b.map(x => x.toString(16).padStart(2,'0'));\n    const point_id = hex[0]+hex[1]+hex[2]+hex[3]+'-'+hex[4]+hex[5]+'-'+hex[6]+hex[7]+'-'+hex[8]+hex[9]+'-'+hex[10]+hex[11]+hex[12]+hex[13]+hex[14]+hex[15];\n\n    results.push({ text: text.trim(), chunkIndex: i, totalChunks: chunks.length, source, tenant_id, store_name, point_id });\n  }\n}\n\nif (results.length === 0) throw new Error('Kein Text zum Chunken gefunden.');\nreturn results.map(r => ({ json: r }));"
      },
      "id": "4cf2677c-d3d3-43b9-ba0e-01e48da4b675",
      "name": "Chunken",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4832,
        2416
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://LITELLM_HOST/v1/embeddings",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={ \"model\": \"text-embedding-3-small\", \"input\": {{ JSON.stringify($json.text) }} }",
        "options": {}
      },
      "id": "3a8dbd1b-e15a-4a46-baaf-3220f63d7979",
      "name": "Chunk embedden",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5072,
        2416
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_WITH_LITELLM_CREDENTIAL_ID",
          "name": "LiteLLM Authorization"
        }
      }
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "v1",
              "name": "embedding",
              "value": "={{ $json.data[0].embedding }}",
              "type": "array"
            },
            {
              "id": "v2",
              "name": "text",
              "value": "={{ $('Chunken').item.json.text }}",
              "type": "string"
            },
            {
              "id": "v3",
              "name": "source",
              "value": "={{ $('Chunken').item.json.source }}",
              "type": "string"
            },
            {
              "id": "v4",
              "name": "chunkIndex",
              "value": "={{ $('Chunken').item.json.chunkIndex }}",
              "type": "number"
            },
            {
              "id": "v5",
              "name": "totalChunks",
              "value": "={{ $('Chunken').item.json.totalChunks }}",
              "type": "number"
            },
            {
              "id": "v6",
              "name": "tenant_id",
              "value": "={{ $('Chunken').item.json.tenant_id }}",
              "type": "string"
            },
            {
              "id": "v7",
              "name": "store_name",
              "value": "={{ $('Chunken').item.json.store_name }}",
              "type": "string"
            },
            {
              "id": "v8",
              "name": "point_id",
              "value": "={{ $('Chunken').item.json.point_id }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "b89acf1f-ffd1-49d8-bfa7-1691aec4a65d",
      "name": "Embedding mergen",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        5312,
        2416
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "http://QDRANT_HOST/collections/QDRANT_COLLECTION/points",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"points\": [{\n    \"id\": {{ JSON.stringify($json.point_id) }},\n    \"vector\": {{ JSON.stringify($json.embedding) }},\n    \"payload\": {\n      \"text\":        {{ JSON.stringify($json.text) }},\n      \"metadata\": {\n        \"source\":    {{ JSON.stringify($json.source) }},\n        \"name\":      {{ JSON.stringify($json.source) }},\n        \"embedding_config\": { \"engine\": \"openai\", \"model\": \"text-embedding-3-small\" }\n      },\n      \"tenant_id\":   {{ JSON.stringify($json.tenant_id) }},\n      \"created_at\":  {{ Math.floor(Date.now() / 1000) }}\n    }\n  }]\n}",
        "options": {}
      },
      "id": "5383e2d6-54dd-4860-86d3-738ec89faa43",
      "name": "Qdrant: Upsert",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5552,
        2416
      ]
    },
    {
      "parameters": {
        "jsCode": "const all = $input.all();\nconst total = all.length;\nconst storeName = $('Ingest: Daten parsen').first().json.store_name || '?';\nconst src = $('Ingest: Daten parsen').first().json.fileName || '?';\nreturn [{\n  json: {\n    success: true,\n    message: `${total} Chunks aus «${src}» in Store «${storeName}» gespeichert.`,\n    chunks: total\n  }\n}];"
      },
      "id": "0561b0c9-fff7-4625-816a-bb831bd870e6",
      "name": "Erfolgs-Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5792,
        2416
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify($json) }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "14e4eb08-61b6-4bb2-b6b7-887b13159212",
      "name": "JSON Response",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        6032,
        2416
      ]
    },
    {
      "parameters": {
        "content": "## RAG Upload Flow v18 — README\n\n### Konfiguration\n\n**1. Credentials anlegen** (n8n → Settings → Credentials → New → Header Auth)\n\n| Credential Name | Header Name | Header Value |\n|-----------------|-------------|------------------|\n| OpenWebUI Authorization | Authorization | Bearer <openwebui-api-key> |\n| LiteLLM Authorization | Authorization | Bearer <litellm-api-key> |\n\n**2. URLs ersetzen**\n\n| Node | Platzhalter | Ersetzen mit |\n|------|-------------|------------------|\n| OpenWebUI: Knowledge-Stores laden | OPENWEBUI_HOST | z.B. 192.168.1.10:3000 |\n| Chunk embedden | LITELLM_HOST | z.B. 192.168.1.11:4000 |\n| Qdrant: Upsert | QDRANT_HOST | z.B. 192.168.1.12:6333 |\n| Qdrant: Upsert | QDRANT_COLLECTION | z.B. open-webui_knowledge |\n\n**3. Embedding Model Name**\nDer LiteLLM-Modellname muss exakt `text-embedding-3-small` heissen (nicht `embedding-3-small`).\n\n**4. Async Embedding in OpenWebUI**\nAdmin Settings → Documents → Async Embedding Processing deaktivieren oder Batch Size auf 10+ setzen.\n\n### Endpunkte\n- GET /webhook/rag-upload → Upload-Formular\n- POST /webhook/rag-ingest → Dokument verarbeiten\n\n### Unterstuetzte Dateitypen\nPDF · TXT · MD\n\n### Chunk-Parameter (Standard)\n- Chunk Size: 500 Token (~2000 Zeichen)\n- Chunk Overlap: 50 Token (~200 Zeichen)",
        "height": 900,
        "width": 800,
        "color": 4
      },
      "id": "8cd4fe38-f030-4867-8e0b-b9d2ff656dfd",
      "name": "README",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2752,
        1248
      ]
    }
  ],
  "pinData": {},
  "connections": {
    "Webhook: HTML ausliefern": {
      "main": [
        [
          {
            "node": "OpenWebUI: Knowledge-Stores laden",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenWebUI: Knowledge-Stores laden": {
      "main": [
        [
          {
            "node": "HTML bauen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTML bauen": {
      "main": [
        [
          {
            "node": "HTML Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook: Ingest": {
      "main": [
        [
          {
            "node": "Ingest: Daten parsen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ingest: Daten parsen": {
      "main": [
        [
          {
            "node": "Switch: Dateityp",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Switch: Dateityp": {
      "main": [
        [
          {
            "node": "PDF extrahieren",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "TXT/MD dekodieren",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "TXT/MD dekodieren",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PDF extrahieren": {
      "main": [
        [
          {
            "node": "PDF normalisieren",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PDF normalisieren": {
      "main": [
        [
          {
            "node": "Chunken",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "TXT/MD dekodieren": {
      "main": [
        [
          {
            "node": "Chunken",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chunken": {
      "main": [
        [
          {
            "node": "Chunk embedden",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chunk embedden": {
      "main": [
        [
          {
            "node": "Embedding mergen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Embedding mergen": {
      "main": [
        [
          {
            "node": "Qdrant: Upsert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Qdrant: Upsert": {
      "main": [
        [
          {
            "node": "Erfolgs-Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Erfolgs-Response": {
      "main": [
        [
          {
            "node": "JSON Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": false
  },
  "tags": [],
  "id": "c8a91f7f-1547-45ab-abb5-34698b3b669d",
  "versionId": "4dcc642b-662c-4581-805d-cf007cad04f5"
}
Quick Start

JSON in n8n importieren

  • n8n öffnen → Workflows → Import
  • Das JSON aus dem Download-Block einfügen oder die Datei hochladen.
  • Der Flow erscheint danach als neuer Workflow und ist standardmässig deaktiviert.

Zwei Credentials erstellen

  • In n8n:
    • Settings → Credentials → New
      • Credential 1
        • Name: OpenWebUI Authorization
        • Type: HTTP Header Auth
        • Header Name: Authorization
        • Header Value: Bearer <openwebui-api-key>
      • Credential 2
        • Name: LiteLLM Authorization
        • Type: HTTP Header Auth
        • Header Name: Authorization
        • Header Value: Bearer <litellm-api-key>

Drei URLs im Flow ersetzen

Im Workflow müssen drei Platzhalter angepasst werden.

Embedding Modell prüfen

Der Modellname muss exakt sein: text-embedding-3-small

Ein falscher Name führt zu einem 400 Fehler.

Flow aktivieren

Workflow speichern und auf “Active” setzen.

Upload testen

Browser öffnen: http://<n8n-host>/webhook/rag-upload

Dort kann ein Dokument hochgeladen werden:

  • PDF
  • TXT
  • MD

Der Flow extrahiert den Text, erstellt Embeddings und speichert die Chunks in Qdrant.

In OpenWebUI testen

In OpenWebUI eine Frage zum Dokument stellen.

Wenn alles korrekt funktioniert, werden passende Textstellen aus der Vektordatenbank als Kontext verwendet.

Diese Konfiguration bedingt, das in OpenWebUI die Qdrant Instanz als Knowledge Store hinterlegt worden ist . Reddit Artikel dazu ist hier zu finden -> open-webui with qdrant