Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· Architektur  · 7 minuten Lesezeit

RAG-System mit Qdrant, Embeddings und Node.js aufbauen

Retrieval-Augmented Generation ist keine Theorie. Es ist eine konkrete Architektur aus drei Schritten: Einbetten, Suchen, Generieren. Ich zeige, wie ich das mit Qdrant, nomic-embed-text und llama3.2 komplett lokal und ohne Cloud-Kosten umgesetzt habe.

Retrieval-Augmented Generation ist keine Theorie. Es ist eine konkrete Architektur aus drei Schritten: Einbetten, Suchen, Generieren. Ich zeige, wie ich das mit Qdrant, nomic-embed-text und llama3.2 komplett lokal und ohne Cloud-Kosten umgesetzt habe.

Inhalt

Was RAG wirklich bedeutet

RAG steht für Retrieval-Augmented Generation. Das klingt nach einem Marketingbegriff. Ist es nicht.

Es ist eine Architektur, die ein konkretes Problem löst: LLMs wissen nichts über deine privaten Daten.

ChatGPT weiß nicht, welche Instagram-Posts du letzte Woche erfasst hast. GPT-4o kann nicht auswerten, welche Accounts in den letzten 30 Tagen am häufigsten über ein bestimmtes Thema gepostet haben. Kein Sprachmodell kann das, es sei denn, du gibst ihm den Kontext.

RAG löst das in drei Schritten:

  1. Einbetten (Embed): Texte werden in hochdimensionale Zahlenvektoren umgewandelt, die semantische Bedeutung kodieren
  2. Abrufen (Retrieve): Wenn eine Anfrage kommt, wird sie ebenfalls eingebettet und die ähnlichsten gespeicherten Vektoren werden gesucht
  3. Generieren (Generate): Die gefundenen Texte werden als Kontext an ein LLM übergeben, das daraus eine kohärente Antwort formuliert

Das ist kein Finetuning. Das ist kein Training. Es ist zur Laufzeit injizierter Kontext. Und genau das macht es so praktisch.

RAG-Pipeline: Embed, Retrieve, Generate als dreistufiger Prozess Abbildung: Die drei Schritte im Ablauf: Ein Instagram-Post wird als Text eingebettet und in Qdrant gespeichert. Bei einer Anfrage wird der Fragetext ebenfalls eingebettet, die ähnlichsten Einträge werden abgerufen und als Kontext an das Sprachmodell übergeben.

Die Architektur des RAG-Backends

Das Backend ist ein Node.js/Express-Server in TypeScript. Drei Endpunkte, zwei Services, eine klare Verantwortlichkeit:

src/
├── index.ts           # entry point, collection initialization
├── routes/
│   ├── ingest.ts      # POST /ingest: store
│   ├── query.ts       # POST /query: search + generate
│   └── health.ts      # GET /health: status
└── services/
    ├── qdrant.ts       # vector database operations
    ├── ai-provider.ts  # provider abstraction (Ollama | OpenAI)
    └── providers/
        ├── ollama-provider.ts
        └── openai-provider.ts

Vom Instagram-Post zum Vektor

Wenn die Chrome Extension einen Screenshot und Metadaten schickt, passiert folgendes:

// src/routes/ingest.ts
router.post('/', async (req, res) => {
  const payload = req.body as PlatformAnalysisPayload;

  // 1. Build embedding text from metadata
  const embeddingText = buildEmbeddingText(payload);
  // result: "some_account Amazing watch #watches Photo of a golden watch"

  // 2. Generate vector (768 dimensions)
  const vector = await generateEmbedding(embeddingText);

  // 3. Store in Qdrant
  const id = crypto.randomUUID();
  await storePoint(id, vector, payload);

  res.status(201).json({ success: true, id, embeddingText });
});

Der Text, der eingebettet wird, ist entscheidend. Kombiniert werden:

  • metadata.username: wer hat gepostet?
  • metadata.caption: was steht dabei?
  • metadata.altTexts.join(" "): was sehen Screenreader?

Alt-Texte sind besonders wertvoll, weil sie oft eine präzise, KI-generierte Bildbeschreibung enthalten, gerade auf Instagram.

Qdrant als Vektordatenbank

Qdrant ist eine speziell für Vektoren optimierte Datenbank. Im Gegensatz zu PostgreSQL mit pgvector oder Elasticsearch ist sie ausschließlich für diesen Anwendungsfall gebaut.

Die Collection wird beim Serverstart automatisch angelegt:

// src/services/qdrant.ts
export async function ensureCollection(): Promise<void> {
  const exists = await client.collectionExists(COLLECTION_NAME);
  if (!exists) {
    await client.createCollection(COLLECTION_NAME, {
      vectors: {
        size: parseInt(process.env.VECTOR_SIZE ?? '768'),
        distance: 'Cosine',
      },
    });
  }
}

Warum Cosine-Ähnlichkeit? Bei hochdimensionalen Textvektoren ist die Richtung wichtiger als die absolute Distanz. Cosine misst den Winkel zwischen zwei Vektoren. Zwei Texte, die semantisch ähnlich sind, zeigen in dieselbe Richtung, unabhängig von ihrer Länge. Das ist für Text-Retrieval die richtige Metrik.

768 Dimensionen: Das ist die Ausgabegröße von nomic-embed-text und von text-embedding-3-small mit dem dimensions-Parameter. Details dazu gibt es im Artikel zur AI Provider Abstraktion.

Suchen und Generieren

// src/routes/query.ts
router.post('/', async (req, res) => {
  const { query, limit = 5 } = req.body;

  // 1. Embed the query
  const queryVector = await generateEmbedding(query);

  // 2. Find most similar points in Qdrant
  const results = await searchSimilar(queryVector, limit);

  // 3. Build context from results
  const context = results
    .map((r, i) => {
      const m = (r.payload as any).metadata;
      return `[${i + 1}] @${m.username}: "${m.caption}" (${m.altTexts?.join(', ')})`;
    })
    .join('\n');

  // 4. Generate LLM answer
  const answer = await generateAnswer(query, context);

  res.json({
    answer,
    sources: results.map((r) => ({
      id: r.id,
      score: r.score,
      payload: r.payload,
    })),
  });
});

Der Kontext-String, der an das LLM übergeben wird, ist bewusst simpel. Er listet die relevanten Posts mit Username, Caption und Alt-Texten auf. Das Modell hat damit genug Information, um eine präzise Antwort zu generieren.

Das Prompt-Design:

// in ollama-provider.ts
const prompt = `Du bist ein Analyse-Assistent für gespeicherte Social-Media-Posts.
Hier sind die relevanten Einträge aus dem Datenbestand:

${context}

Frage: ${query}

Antworte präzise und auf Basis der obigen Einträge.`;

Kein komplexes Chain-of-Thought-Prompting. Für diesen Anwendungsfall reicht es.

Abfrage-Ergebnis im RAG-System: Anfrage, Kontextabruf und LLM-Antwort Abbildung: Eine Analyst-Anfrage durchläuft das System: Qdrant liefert die relevantesten gespeicherten Posts als Kontext, Ollama formuliert daraus eine strukturierte Antwort.

Was ich über Vektordatenbanken gelernt habe

Vektorgröße ist eine Systementscheidung, keine Modellentscheidung.

Wenn eine Collection einmal mit 768-dimensionalen Vektoren angelegt ist, kann nicht einfach auf ein Modell mit 1536 Dimensionen gewechselt werden, ohne alle Daten neu einzubetten und die Collection neu anzulegen. Das ist der Grund, warum in der Provider-Abstraktion sichergestellt wird, dass beide Modelle (Ollama und OpenAI) exakt 768 Dimensionen produzieren.

OpenAI bietet dafür den dimensions-Parameter in text-embedding-3-small:

const response = await this.client.embeddings.create({
  model: 'text-embedding-3-small',
  input: text,
  dimensions: 768, // explicitly reduced to 768
});

Das ist eine strategische Entscheidung: Provider-Wechsel ohne Datenmigration.

Cosine-Ähnlichkeit ist kein prozentualer Score.

Ein Score von 0.91 klingt nach „91% ähnlich”. Das ist nicht korrekt. Es ist der Kosinus des Winkels zwischen zwei Vektoren. Ein Score von 0.7 kann bereits hochrelevant sein. Die absolute Zahl ist weniger wichtig als die relative Rangfolge innerhalb einer Abfrage.

Qdrants JavaScript-Client hat loses Typing.

Das Feld points_count in der Collection-Info ist im TypeScript-Client als any typisiert:

// cast required due to loose typing in Qdrant JS client
const count = (collectionInfo as any).points_count as number;

Kein schöner Code, aber wer mit externen Clients arbeitet, muss mit solchen Inkonsistenzen umgehen können.

Warum das für Power Pages relevant ist

Viele Power Pages Portale verwalten große Mengen an Dokumenten, FAQs, Produktbeschreibungen oder Support-Tickets in Dataverse. Die klassische Suche ist keyword-basiert und findet nur exakte Treffer.

Semantische Suche auf Basis von RAG würde ermöglichen:

  • „Welche Produkte haben ähnliche Eigenschaften wie dieser Artikel?”
  • „Finde alle Support-Tickets, die mit diesem Problem zusammenhängen”
  • „Welche FAQ-Einträge sind für diese Anfrage relevant?”

Die Technologie dafür ist dieselbe. Der Stack wäre identisch. Was sich ändert, ist nur die Datenquelle (Dataverse statt Instagram) und die Einbettungslogik.

Nächster Artikel in der Serie: Die Entscheidung, die alles verändert hat: Provider-Abstraktion für Ollama und OpenAI


Alle Artikel der Serie

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen

  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript (dieser Artikel)

  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen

  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen

  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen

  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen

  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen

  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen

  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen

  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen

  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen

  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen

  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen

  14. Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen

  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen

  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen

  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen

  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen

  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen

  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen

  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen

  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen

  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen

  24. access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen

  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen

  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen

  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen

  28. Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen

  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen

  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen

  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen

  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen

  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen

  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen

  35. Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen

  36. Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen

  37. Frontend gesund machen wenn der nginx Healthcheck an localhost scheitert: Artikel lesen


Du willst semantische Suche in ein Power Pages Portal einbauen, ohne auf AI Builder oder Copilot Studio angewiesen zu sein? Lass uns den Use Case gemeinsam durchdenken.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen
Event-Driven Ingestion mit BullMQ und Redis

Event-Driven Ingestion mit BullMQ und Redis

POST /ingest blockierte die Extension, bis Embedding und Qdrant-Upsert fertig waren. Mit BullMQ und Redis wird der Ingest asynchron: 202 sofort, Verarbeitung im Hintergrund, Statusabfrage über GET /captures/:id/status.