Künstliche IntelligenzProgrammierung

RAG-Ingestion-Pipeline für Qdrant mit Node.js in Docker

netcomputing.de Redaktion26. August 202611 Min. Lesezeit

Zusammenfassung

Firmeninterne Dokumente landen bei einer gemanagten Vektordatenbank oft bei einem SaaS-Anbieter im Ausland, was aus Datenschutzgründen für viele Unternehmen ausscheidet. Eine selbst gehostete Alternative kombiniert ein schlankes Node.js-Skript mit Qdrant: Das Skript zerlegt Dokumente in Chunks, embedded sie und schreibt die Vektoren per REST-Aufruf in Qdrant. Mit text-embedding-3-small und 1536-dimensionalen Vektoren antwortet die Suche bei einer Million Chunks in unter 100 Millisekunden.

Retrieval-Augmented Generation, kurz RAG, reichert eine Sprachmodell-Antwort mit Textausschnitten an, die vorher aus einer eigenen Dokumentensammlung abgerufen wurden, statt sich allein auf das Trainingswissen des Modells zu verlassen. Der Abruf läuft über Embeddings, numerische Vektor-Darstellungen von Text, bei denen inhaltlich ähnliche Abschnitte nah beieinander liegen und sich über eine Vektordatenbank durchsuchen lassen.

Systemvoraussetzungen

KomponenteAnforderung
Node.jsVersion 24.x LTS, Docker-Image node:24-alpine. Natives fetch ist seit Node 21 stabil, ein zusätzliches HTTP-Client-Paket ist nicht nötig.
QdrantVersion 1.19.0, Docker-Image qdrant/qdrant:v1.19.0
Docker Engineab Version 24.0, mit Compose-Plugin V2 (Befehl docker compose, nicht das alte eigenständige docker-compose)
Arbeitsspeichermindestens 2 GB RAM für den App-Container plus 4 GB für Qdrant im Testbetrieb bis rund 200.000 Chunks; ab 1 Million Vektoren mit 1536 Dimensionen mindestens 8 bis 10 GB für Qdrant
SpeicherplatzSSD empfohlen, mindestens 10 GB freier Platz für das Qdrant-Storage-Volume
Embedding-API-KeyOpenAI-API-Key mit Zugriff auf das Embedding-Modell text-embedding-3-small (alternativ ein anderes Modell mit bekannter, fest dokumentierter Vektordimension)
NetzwerkApp-Container und Qdrant im selben Docker-Compose-Netzwerk, damit das Skript Qdrant über den Servicenamen statt über eine öffentliche URL erreicht

Systemarchitektur

flowchart TD
  A[Dokumentenquelle - PDF oder Markdown-Datei] --> B[Ingest-Skript liest die Datei ein]
  B --> C[chunkText zerlegt den Text in Chunks]
  C --> D[embed ruft die Embedding-API per fetch auf]
  D --> E[upsertPoints schreibt gegen die Qdrant REST API]
  E --> F[Qdrant Collection]
  G[Suchanfrage per HTTP POST] --> H[Such-Endpunkt im selben Skript]
  H --> I[embed embedded die Anfrage]
  I --> J[queryPoints ruft die Qdrant Query-API auf]
  J --> F
  J --> K[JSON-Antwort mit Textausschnitt und Score]

Ein Dokument durchläuft die Pipeline über zwei getrennte Einstiegspunkte. Ein Ingest-Skript liest eine Datei ein und zerlegt sie mit einer eigenen chunkText-Funktion in Abschnitte fester Länge, weil ein Embedding-Modell nur eine begrenzte Textmenge auf einmal verarbeitet und kürzere Abschnitte bei der späteren Suche genauer treffen als ein komplettes Dokument. Für jeden Chunk ruft eine embed-Funktion per nativem fetch die Embedding-API auf, bekommt einen Vektor aus 1536 Zahlen zurück und schreibt Vektor und Originaltext gemeinsam über eine upsertPoints-Funktion in eine Qdrant-Collection, den benannten Speicherbereich für Vektoren mit fester Dimension und fester Distanzmetrik. Bei einer Suchanfrage nutzt ein kleiner HTTP-Server dieselbe embed-Funktion für die Frage selbst, und eine queryPoints-Funktion vergleicht den entstehenden Vektor über die Kosinus-Distanz mit allen gespeicherten Vektoren, ein Maß dafür, wie ähnlich sich zwei Vektoren in ihrer Richtung sind, unabhängig von ihrer Länge.

Ein eigenes Skript statt eines visuellen Workflow-Tools ist an dieser Stelle eine bewusste Entscheidung, keine reine Geschmacksfrage. Ein Unit-Test kann chunkText und embed einzeln prüfen, ein Git-Diff zeigt genau, welche Zeile sich zwischen zwei Versionen geändert hat, und eine CI-Pipeline kann die Ingestion vor jedem Deploy automatisch durchlaufen lassen. In einem knotenbasierten Werkzeug kann dagegen ein einzelner kaputter Schritt mitten in einer langen Kette schweigend aufhören, Daten zu liefern, ohne dass irgendwo ein Fehler auftaucht oder ein Test rot wird. Wer sich zusätzlich für Selbsthosting entscheidet statt für eine gemanagte Vektordatenbank wie Pinecone oder Weaviate Cloud, tut das meist aus vertraglichen Gründen: Verarbeitung ausschließlich in der EU oder ausschließlich auf eigener Infrastruktur lässt sich mit einem SaaS-Anbieter oft gar nicht vereinbaren. Bezahlt wird das mit eigenem Betriebsaufwand für Updates, Backups und Kapazitätsplanung, während ein gemanagter Dienst genau das gegen eine nutzungsabhängige Rechnung übernimmt.

Schritt-für-Schritt-Umsetzung

Docker-Compose-Datei für die App und Qdrant

Die App, das kompilierte Node.js-Skript, und Qdrant laufen als zwei Services in einer gemeinsamen docker-compose.yml. Die App braucht kein eigenes Volume, weil sie zustandslos ist. Qdrant dagegen braucht eines für seine Collections, sonst sind nach einem docker compose down alle Vektoren weg.

FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json tsconfig.json ./
COPY src ./src
RUN npm ci && npm run build

FROM node:24-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["node", "dist/server.js"]
services:
  app:
    build: .
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - QDRANT_URL=http://qdrant:6333
      - QDRANT_API_KEY=${QDRANT_API_KEY}
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - INGEST_SHARED_SECRET=${INGEST_SHARED_SECRET}
    depends_on:
      - qdrant

  qdrant:
    image: qdrant/qdrant:v1.19.0
    restart: unless-stopped
    ports:
      - "6333:6333"
      - "6334:6334"
    environment:
      - QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY}
    volumes:
      - qdrant_data:/qdrant/storage

volumes:
  qdrant_data:

Alle drei Secrets, QDRANT_API_KEY, OPENAI_API_KEY und INGEST_SHARED_SECRET, gehören in eine .env-Datei neben der docker-compose.yml, nicht in die Compose-Datei selbst, damit sie nicht versehentlich in ein Git-Repository geraten. Gestartet wird mit docker compose up -d --build.

Qdrant-Collection per REST-Aufruf anlegen

Bevor das Skript Vektoren schreiben kann, braucht Qdrant eine Collection mit fester Vektordimension, der Anzahl an Zahlen, aus denen jeder gespeicherte Vektor besteht. Diese Zahl muss zum Embedding-Modell passen: text-embedding-3-small liefert 1536-dimensionale Vektoren, das größere text-embedding-3-large 3072. Die Collection lässt sich einmalig per curl anlegen.

curl -X PUT "http://localhost:6333/collections/dokumente" \
  -H "Content-Type: application/json" \
  -H "api-key: $QDRANT_API_KEY" \
  -d '{
    "vectors": {
      "size": 1536,
      "distance": "Cosine"
    }
  }'
{
  "result": true,
  "status": "ok",
  "time": 0.002
}

Dieselbe Anfrage lässt sich auch aus dem Skript heraus stellen, etwa beim ersten Start, damit ein frisch aufgesetzter Server die Collection nicht von Hand braucht.

async function createCollection(): Promise<void> {
  const res = await fetch(`${QDRANT_URL}/collections/dokumente`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
      "api-key": QDRANT_API_KEY
    },
    body: JSON.stringify({
      vectors: { size: 1536, distance: "Cosine" }
    })
  });
  if (!res.ok) {
    throw new Error(`Qdrant createCollection failed: ${res.status} ${await res.text()}`);
  }
}

Dokumente chunken und embedden

Das Ingest-Skript zerlegt eingelesenen Text in Chunks fester Länge mit etwas Überlappung, damit ein Satz an der Chunk-Grenze nicht mitten durchgeschnitten wird und seinen Sinn verliert.

function chunkText(text: string, chunkSize = 1000, overlap = 150): string[] {
  const chunks: string[] = [];
  for (let i = 0; i < text.length; i += chunkSize - overlap) {
    chunks.push(text.slice(i, i + chunkSize));
  }
  return chunks;
}

Für jeden Chunk ruft eine embed-Funktion die Embedding-API von OpenAI per nativem fetch auf, ohne zusätzliches HTTP-Paket. Die Authentifizierung läuft über den Authorization-Header mit dem API-Key aus der Umgebungsvariable, nicht über einen fest eingetippten Wert im Code.

async function embed(text: string): Promise<number[]> {
  const res = await fetch("https://api.openai.com/v1/embeddings", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      model: "text-embedding-3-small",
      input: text
    })
  });
  if (!res.ok) {
    throw new Error(`OpenAI embeddings failed: ${res.status}`);
  }
  const data = await res.json();
  return data.data[0].embedding;
}
{
  "data": [
    { "embedding": [0.0123, -0.0456, 0.0789], "index": 0 }
  ],
  "model": "text-embedding-3-small",
  "usage": { "prompt_tokens": 42, "total_tokens": 42 }
}

Bei sehr vielen Chunks lohnt sich ein Concurrency-Limit, damit nicht alle Embedding-Aufrufe gleichzeitig starten. Fünf bis zehn parallele Aufrufe in Gruppen abzuarbeiten, statt einer riesigen Promise.all-Liste, hält die Fehlerquote gegenüber der Embedding-API niedrig (siehe Häufige Fehlerquellen).

Vektoren in Qdrant schreiben und wieder abfragen

Der letzte Schritt beim Einlesen ist eine upsertPoints-Funktion, die Chunk-Text, Herkunftsangaben und den Embedding-Vektor als Punkt in Qdrant schreibt. Herkunftsangaben und Text landen dabei im Payload, Qdrants Begriff für die Metadaten, die neben dem eigentlichen Vektor an jedem Punkt hängen. Ein Upsert fügt einen neuen Punkt ein oder überschreibt ihn, falls die ID schon existiert. Ein Dokument lässt sich so nach einer Änderung einfach erneut einlesen, ohne dass Dubletten entstehen.

interface QdrantPoint {
  id: string;
  vector: number[];
  payload: { text: string; source: string; chunk_index: number };
}

async function upsertPoints(points: QdrantPoint[]): Promise<void> {
  const res = await fetch(`${QDRANT_URL}/collections/dokumente/points?wait=true`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
      "api-key": QDRANT_API_KEY
    },
    body: JSON.stringify({ points })
  });
  if (!res.ok) {
    throw new Error(`Qdrant upsert failed: ${res.status} ${await res.text()}`);
  }
}

Für die Suche läuft derselbe embed-Aufruf für die Nutzerfrage, danach eine queryPoints-Funktion gegen die Query-API von Qdrant, den Nachfolger der älteren /points/search-Route seit Qdrant 1.10. Die Query-API deckt Vektorsuche, Filter und mehrstufige Anfragen über einen einzigen Endpunkt ab.

async function queryPoints(vector: number[], limit: number) {
  const res = await fetch(`${QDRANT_URL}/collections/dokumente/points/query`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "api-key": QDRANT_API_KEY
    },
    body: JSON.stringify({ query: vector, limit, with_payload: true })
  });
  if (!res.ok) {
    throw new Error(`Qdrant query failed: ${res.status}`);
  }
  return res.json();
}
{
  "result": {
    "points": [
      {
        "id": "handbuch.pdf-3",
        "score": 0.87,
        "payload": {
          "text": "Abschnitt 3 aus dem Handbuch...",
          "source": "handbuch.pdf",
          "chunk_index": 3
        }
      }
    ]
  },
  "status": "ok",
  "time": 0.004
}

Ingestion-Endpunkt und Suche per HTTP-Server

Ein schlanker HTTP-Server aus dem Node.js-Kernmodul http nimmt Dokumente entgegen und beantwortet Suchanfragen, ganz ohne zusätzliches Web-Framework. Der /ingest-Pfad prüft vor jeder Verarbeitung einen selbst gewählten Header x-ingest-secret gegen den Wert aus INGEST_SHARED_SECRET und antwortet ohne exakte Übereinstimmung mit 403, bevor überhaupt ein Byte des Bodys verarbeitet wird.

import { createServer } from "node:http";

const SHARED_SECRET = process.env.INGEST_SHARED_SECRET;

const server = createServer(async (req, res) => {
  if (req.method === "POST" && req.url === "/ingest") {
    if (req.headers["x-ingest-secret"] !== SHARED_SECRET) {
      res.writeHead(403, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: "forbidden" }));
      return;
    }
    const parts: Buffer[] = [];
    for await (const part of req) parts.push(part as Buffer);
    const body = JSON.parse(Buffer.concat(parts).toString("utf8"));
    await ingestDocument(body.text, body.filename);
    res.writeHead(202, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "accepted" }));
    return;
  }

  if (req.method === "POST" && req.url === "/search") {
    const parts: Buffer[] = [];
    for await (const part of req) parts.push(part as Buffer);
    const body = JSON.parse(Buffer.concat(parts).toString("utf8"));
    const vector = await embed(body.query);
    const results = await queryPoints(vector, 5);
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(results));
    return;
  }

  res.writeHead(404);
  res.end();
});

server.listen(3000);

Für den produktiven Betrieb kommt eine IP-Beschränkung dazu, entweder direkt im vorgeschalteten Reverse-Proxy mit allow- und deny-Regeln oder als zusätzliche Prüfung der Client-Adresse im Code, plus ein Rate-Limit, damit ein durchgesickerter Secret-Wert nicht sofort zu unbegrenztem Massen-Ingestion führt. Anders als ein fertiges Workflow-Tool bringt ein selbst geschriebener http-Server keine eingebaute Grenze für die Body-Größe mit; diese Grenze muss der Code selbst setzen, siehe die nächste Fehlerquelle.

Häufige Fehlerquellen

  • 400 Bad Request, "Wrong input: Vector dimension error: expected dim: 1536, got 384": Die Collection wurde für text-embedding-3-small mit 1536 Dimensionen angelegt, das Skript embedded aber inzwischen mit einem anderen, kleineren Modell. Fix: Collection-Dimension und Embedding-Modell müssen zusammenpassen. Bei einem Modellwechsel eine neue Collection mit passender Dimension anlegen, statt die alte weiterzuverwenden.
  • 403 Forbidden bei jedem Aufruf gegen Qdrant: QDRANT__SERVICE__API_KEY ist gesetzt, der fetch-Aufruf im Skript schickt den api-key-Header aber mit falscher Schreibweise oder gar nicht. Fix: Header-Namen exakt api-key schreiben und den Wert aus derselben Umgebungsvariable lesen, mit der der Qdrant-Container gestartet wurde.
  • ECONNREFUSED 127.0.0.1:6333 beim Start der App: QDRANT_URL zeigt noch auf localhost aus einem lokalen Testlauf außerhalb von Docker, im Compose-Netzwerk heißt der Service aber qdrant. Fix: QDRANT_URL in der .env-Datei auf http://qdrant:6333 setzen, sobald die App im selben Compose-Projekt wie Qdrant läuft.
  • Qdrant-Container wird vom Docker-Daemon beendet (OOM Killed): Ein Bulk-Import schreibt mehrere hunderttausend Punkte in einem Rutsch, während Qdrant den HNSW-Index, Hierarchical Navigable Small World, die Graphstruktur für die schnelle Ähnlichkeitssuche, vollständig im Arbeitsspeicher aufbaut. Fix: Punkte in Batches von einigen hundert upserten statt in einem einzigen Request, und dem Container ausreichend RAM zuteilen, als Faustregel rund 1 bis 1,5 GB je 250.000 Vektoren mit 1536 Dimensionen ohne Quantisierung.
  • Node-Prozess crasht mit "JavaScript heap out of memory" beim Hochladen eines großen Dokuments über /ingest: Die for-await-Schleife puffert den kompletten Body im Speicher, ein selbst geschriebener http-Server hat anders als ein fertiges Workflow-Tool keine eingebaute Obergrenze für die Body-Größe. Fix: Content-Length vor dem Einlesen prüfen und Anfragen über einem festen Limit, etwa 20 MB, sofort mit 413 ablehnen.
  • 429 Too Many Requests von der OpenAI-Embedding-API: Alle Chunks eines langen Dokuments werden gleichzeitig per Promise.all embedded, ohne Begrenzung der parallelen Aufrufe. Fix: Chunks in Gruppen von 5 bis 10 abarbeiten und zwischen den Gruppen kurz warten, statt Hunderte Aufrufe im selben Moment abzusetzen.

Häufig gestellte Fragen

Welche Vektordimension braucht eine Qdrant-Collection für Embeddings von OpenAIs text-embedding-3-small?

Eine Qdrant-Collection für OpenAIs text-embedding-3-small-Modell braucht die Vektordimension 1536, weil das Modell jeden Text unabhängig von seiner Länge auf genau 1536 Fließkommazahlen abbildet. Wird stattdessen text-embedding-3-large verwendet, muss die Collection mit 3072 angelegt werden, weil dieses Modell größere Vektoren liefert. Passt die in der Collection hinterlegte Größe nicht zur tatsächlichen Vektorlänge, lehnt Qdrant den Schreibvorgang mit einem 400-Fehler und der Meldung "Vector dimension error" ab, statt die Werte still zu kürzen oder aufzufüllen.

Wie viel Arbeitsspeicher braucht eine selbst gehostete Qdrant-Instanz für eine Million Dokumenten-Chunks?

Eine selbst gehostete Qdrant-Instanz braucht für eine Million Dokumenten-Chunks mit 1536-dimensionalen Vektoren ohne Quantisierung grob 6 bis 8 GB RAM. Zu den reinen Vektordaten kommt der HNSW-Index, die Graphstruktur für die schnelle Ähnlichkeitssuche, die zusätzlich etwa 30 bis 50 Prozent Speicher-Overhead verursacht. Mit skalarer Quantisierung, bei der jede Vektorzahl von 32-Bit- auf 8-Bit-Genauigkeit reduziert wird, sinkt der Bedarf auf ungefähr ein Viertel, mit einem kleinen Verlust an Suchgenauigkeit. Wer diese Marge nicht einplant, bekommt bei einem größeren Bulk-Import genau den OOM-Kill aus den häufigen Fehlerquellen oben.

Wie sichert man den Ingestion-Endpunkt eines selbst geschriebenen Node.js-Skripts ab, das Dokumente für eine RAG-Pipeline entgegennimmt?

Der Ingestion-Endpunkt eines selbst geschriebenen Node.js-Skripts lässt sich über einen selbst gewählten Secret-Header absichern, den der Server vor jeder Verarbeitung mit einem in der Umgebungsvariable hinterlegten Wert vergleicht und bei Abweichung mit 403 ablehnt. Diese Prüfung ersetzt keine vollwertige Authentifizierung mit Ablaufdatum oder Rollen, reicht aber für einen internen Ingestion-Pfad aus, der nur vom eigenen Cron-Job oder Backend aufgerufen wird. Für ein öffentlich erreichbares Setup kommen zusätzlich eine IP-Beschränkung im vorgeschalteten Reverse-Proxy und ein Rate-Limit dazu, damit ein durchgesickerter Secret-Wert nicht zu unbegrenztem Massen-Ingestion missbraucht werden kann. Ohne jede dieser Prüfungen nimmt der Endpunkt Daten von jedem entgegen, der die URL kennt oder errät.

Quellen

  1. OpenAI Embeddings-Leitfaden: Vektordimensionen von text-embedding-3-small und -large, OpenAI, abgerufen am 2026-08-26
  2. Qdrant-Dokumentation: Punkte, Payload und die Upsert-API, Qdrant, abgerufen am 2026-08-26
  3. Qdrant 1.10: Universal Query API ersetzt einzelne Such-Endpunkte, Qdrant, abgerufen am 2026-08-26
  4. Node.js 21 Release-Ankuendigung: Fetch-API wird stabil, Node.js (OpenJS Foundation), abgerufen am 2026-08-26
  5. Qdrant-Dokumentation: Quantisierung (Scalar Quantization), Qdrant, abgerufen am 2026-08-26

Der Content wurde mit Hilfe von KI erstellt, vor allem in der Recherche und Vorformulierung. Prüfung und Abnahme durch unsere Redaktion.