· 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.

Inhalt
- Was RAG wirklich bedeutet
- Die Architektur des RAG-Backends
- Was ich über Vektordatenbanken gelernt habe
- Warum das für Power Pages relevant ist
- Alle Artikel der Serie
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:
- Einbetten (Embed): Texte werden in hochdimensionale Zahlenvektoren umgewandelt, die semantische Bedeutung kodieren
- Abrufen (Retrieve): Wenn eine Anfrage kommt, wird sie ebenfalls eingebettet und die ähnlichsten gespeicherten Vektoren werden gesucht
- 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.
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.tsVom 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.
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
Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript (dieser Artikel)
AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
tsconfig und Vite:
Node16vs.bundler, warum Vite eigene Regeln hat: Artikel lesenInstagram Caption mit MutationObserver vollständig laden: Artikel lesen
Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
Notiz und Tags beim Screenshot-Speichern: Artikel lesen
Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
React Frontend mit react-oidc-context und Zitadel: Artikel lesen
Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen
Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen
Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen
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.



