KI-EngineeringJune 13, 2026·8 Min. Lesezeit·Miracle KaluMiracle Kalu

Aufbau einer verwandte-Artikel-Engine mit Vektor-Embeddings: Ein produktionsreifes Design

A glowing network of connected nodes representing vector embeddings and semantic similarity.

Warum tagbasierte verwandte Artikel nicht mehr funktioniert haben

Jahrelang war das Widget für verwandte Artikel auf unserer Content-Seite eine einfache SQL-Abfrage. Es verknüpfte Beiträge über gemeinsame Tags, sortierte das Ergebnis nach Veröffentlichungsdatum und nannte es einen Tag. Die Implementierung war billig, vorhersehbar und größtenteils irrelevant. Leser beendeten einen Artikel über Cache-Invalidierungsstrategien und bekamen den neuesten Post mit dem Tag "DevOps" angeboten, weil jemand einmal das Wort "Deployment" in der Einleitung verwendet hatte.

Tags beschreiben redaktionelle Absicht, nicht semantische Bedeutung. Sie sind großartig für die Navigation, aber ein stumpfes Instrument für Empfehlungen. Zwei Artikel können jeden Tag teilen und trotzdem völlig unterschiedliche Probleme behandeln. Schlimmer noch: Zwei Artikel, die dasselbe Problem in unterschiedlichen Domänen lösen, teilen möglicherweise überhaupt keine Tags. Als unser Archiv über fünfhundert Beiträge wuchs, wurde das Widget zur Karussell loses bezogener Fehltreffer.

Wir brauchten eine Empfehlungsebene, die verstand, worum es in einem Artikel tatsächlich ging. Vektor-Embeddings erwiesen sich als das richtige Werkzeug — aber erst, nachdem wir aufgehört hatten, sie wie eine magische Suchbox zu behandeln, und begannen, sie wie eine produktive Datenpipeline zu behandeln.

Was Embeddings tatsächlich bringen

Ein Embedding ist ein dichter Vektor, der einen Text in einen hochdimensionalen semantischen Raum abbildet. Texte mit ähnlicher Bedeutung landen nah beieinander, auch wenn sie unterschiedliche Worte verwenden. Diese Eigenschaft liefert ein Empfehlungssignal, das Tags nicht können: konzeptuelle Verwandtschaft.

Das von uns gewählte Modell sieht "Cache-Invalidierung" und "einen CDN mit dem Origin-State synchron halten" als Nachbarn. Es sieht "Deployment-Pipelines" und "Continuous Delivery" als verwandt an, ohne dass jemand sie als solche taggen muss. Das Ergebnis ist ein Widget für verwandte Artikel, das wirklich nützliche Folgelektüre anzeigt.

Embeddings sind nicht kostenlos. Sie kosten Geld zum Erzeugen, belegen Speicherplatz und erhöhen die Latenz im Abfragepfad. Die produktive Herausforderung besteht nicht darin, die Vektoren zu erzeugen. Sie besteht darin, das System schnell, günstig und wartbar zu halten, während das Archiv wächst.

Architekturübersicht

Das System hat drei unabhängige Pfade: Ingestion, Abfrage und Invalidierung.

Ein häufiger Fehler ist es, das Embedding-Modell direkt in den Request-Pfad zu legen. Das koppelt Leser-Traffic an Modell-Latenz und Provider-Verfügbarkeit. Wir erzeugen Embeddings zum Zeitpunkt des Schreibens, speichern sie in einer Vektor-Datenbank und halten den Abfragepfad als einfache Nearest-Neighbor-Abfrage mit einem Cache davor.

Auswahl des Modells und Providers

Wir begannen mit OpenAIs text-embedding-3-small aus drei Gründen: Es ist günstig, das Kontextfenster deckt die meisten unserer Artikel in einem einzigen Durchlauf ab, und die Ausgabedimension ist konfigurierbar. Für ein Empfehlungs-Widget stellten wir fest, dass 512 Dimensionen ausreichend Signal lieferten und gleichzeitig Speicher- und Abfragekosten vertretbar hielten. Der Sprung auf 1.536 Dimensionen verbesserte die Qualität nur marginal und verdoppelte die Indexgröße.

Für Teams mit strengeren Datenschutzrichtlinien sind selbst gehostete Modelle wie sentence-transformers/all-MiniLM-L6-v2 eine gangbare Alternative. Der Kompromiss ist der operative Aufwand. Man betreibt dann GPU-Inferenz, verwaltet Modellversionen und überwacht selbst die Latenz. Wir entschieden uns für den Managed-Service, weil unser Volumen die Infrastrukturkosten nicht rechtfertigte, aber die Architektur funktioniert in beiden Fällen gleich.

Die wichtige Entscheidung ist nicht, welchen Provider man wählt. Es ist die Isolierung des Providers hinter einer Schnittstelle, damit man ihn später tauschen kann, ohne Ingestion- oder Abfragecode anzufassen.

Die Ingestion-Pipeline

Jedes Mal, wenn ein Artikel veröffentlicht oder aktualisiert wird, läuft ein Hintergrundjob, der den Inhalt aufteilt, Embeddings erzeugt und die Vektoren in den Store schreibt. Wir embedden nicht den gesamten Artikel als einen einzigen Vektor. Überschriften, Zwischenüberschriften und Absätze haben unterschiedliches semantisches Gewicht, und ein einzelnes Embedding eines dreitausend Wörter langen Beitrags verwässert das Signal.

Unsere Chunking-Strategie ist einfach, aber meinungsstark:

  • Der Titel bekommt sein eigenes Embedding.
  • Jede H2-Überschrift bekommt ihr eigenes Embedding.
  • Der Body wird in Absätze von bis zu 256 Tokens aufgeteilt, mit einer Überlappung von 32 Tokens.
  • Jeder Chunk behält einen Zeiger auf den Elternartikel, seine Sprache und das Veröffentlichungsdatum.

Das gibt uns mehrere Vektoren pro Artikel, was den Recall verbessert. Wenn ein Leser einen Beitrag beendet, fragen wir gegen alle Vektoren dieses Artikels ab, aggregieren die nächsten Nachbarn nach Elternartikel und ranken die Kandidaten nach Häufigkeit und durchschnittlicher Distanz.

interface ArticleChunk {
  id: string;
  articleId: string;
  locale: string;
  kind: "title" | "heading" | "paragraph";
  text: string;
  publishedAt: string;
}

interface EmbeddedChunk extends ArticleChunk {
  embedding: number[];
}

class EmbeddingPipeline {
  constructor(
    private embedder: EmbeddingProvider,
    private store: VectorStore,
  ) {}

  async ingest(article: Article): Promise<void> {
    const chunks = this.chunk(article);
    const embeddings = await this.embedder.embed(
      chunks.map((c) => c.text),
    );

    const withVectors: EmbeddedChunk[] = chunks.map((chunk, i) => ({
      ...chunk,
      embedding: embeddings[i],
    }));

    await this.store.upsert(`article:${article.id}`, withVectors);
  }

  private chunk(article: Article): ArticleChunk[] {
    const base = {
      articleId: article.id,
      locale: article.locale,
      publishedAt: article.publishedAt,
    };

    const chunks: ArticleChunk[] = [
      {
        id: `${article.id}:title`,
        ...base,
        kind: "title",
        text: article.title,
      },
      ...article.headings.map((h, i) => ({
        id: `${article.id}:h:${i}`,
        ...base,
        kind: "heading",
        text: h,
      })),
      ...splitParagraphs(article.body, 256, 32).map((p, i) => ({
        id: `${article.id}:p:${i}`,
        ...base,
        kind: "paragraph",
        text: p,
      })),
    ];

    return chunks;
  }
}

Der splitParagraphs-Helper ist absichtlich simpel. Wir teilen nach Satzgrenzen, wenn möglich, verwenden aber keine gleitenden Fenster über Satzgrenzen hinweg. Für die Empfehlungsqualität ist die Wahrung semantischer Grenzen wichtiger als die Maximierung der Tokendichte.

Backfill ohne Budgetexplosion

Das erste Mal, wenn man das System einschaltet, muss man jeden Artikel des Archivs embedden. Bei tausend Beiträgen und mehreren API-Aufrufen pro Beitrag kann ein naiver Backfill schnell teuer werden. Wir verwendeten eine ratenlimitierte Warteschlange mit Kostenverfolgung.

import PQueue from "p-queue";

async function backfill(
  articles: Article[],
  pipeline: EmbeddingPipeline,
  budgetCents: number,
): Promise<void> {
  const queue = new PQueue({ concurrency: 4 });
  const costPer1kTokens = 0.02; // USD
  let estimatedCost = 0;

  for (const article of articles) {
    const tokens = estimateTokens(article.body);
    estimatedCost += (tokens / 1000) * costPer1kTokens;

    if (estimatedCost * 100 > budgetCents) {
      console.warn("Backfill-Budget erschöpft");
      break;
    }

    queue.add(() => pipeline.ingest(article));
  }

  await queue.onIdle();
}

Eine Parallelität von vier war der Sweet Spot für unseren Embedding-Provider. Höhere Parallelität löste Rate-Limits aus, ohne den Durchsatz spürbar zu verbessern. Wir führten den Backfill außerhalb der Spitzenzeiten durch und protokollierten jeden Fehler, sodass wir einzelne Artikel erneut verarbeiten konnten, ohne den gesamten Batch neu zu starten.

Abfrage verwandter Artikel

Der Abfragepfad ist der Punkt, an dem Caching und Filterung kritisch werden. Ein Leser der deutschen Version eines Artikels sollte keine englischen Empfehlungen sehen. Ein Leser eines Beitrags über Frontend-Performance sollte keine Backend-Datenbankartikel sehen, nur weil sie das Wort "Query" gemeinsam haben.

Wir fragen den Vektor-Store mit allen Chunks des aktuellen Artikels ab und aggregieren dann die Kandidaten-Artikel-IDs über die Ergebnisse. Jeder Kandidat erhält einen Score, der darauf basiert, wie viele seiner Chunks in den Top-k-Nachbarn erschienen und wie nah diese Nachbarn waren. Wir boosten außerdem neuere Artikel leicht, damit das Widget nicht immer fünf Jahre alte Beiträge anzeigt.

interface RelatedArticlesOptions {
  articleId: string;
  locale: string;
  category?: string;
  limit?: number;
}

class RelatedArticlesService {
  constructor(
    private store: VectorStore,
    private cache: Cache,
    private fallback: TagBasedFallback,
  ) {}

  async findRelated(opts: RelatedArticlesOptions): Promise<Article[]> {
    const cacheKey = `related:${opts.articleId}:${opts.locale}:${opts.category ?? "all"}`;
    const cached = await this.cache.get(cacheKey);
    if (cached) return cached;

    try {
      const chunks = await this.store.getArticleChunks(opts.articleId);
      if (chunks.length === 0) {
        return this.fallback.find(opts);
      }

      const candidates = await this.store.queryNeighbors(chunks, {
        excludeArticleId: opts.articleId,
        locale: opts.locale,
        category: opts.category,
        topK: 20,
      });

      const ranked = this.rank(candidates).slice(0, opts.limit ?? 5);
      await this.cache.set(cacheKey, ranked, { ttlSeconds: 3600 });
      return ranked;
    } catch (err) {
      console.error("Embedding-Abfrage fehlgeschlagen, Fallback", err);
      return this.fallback.find(opts);
    }
  }

  private rank(candidates: Candidate[]): Article[] {
    const byArticle = new Map<string, Candidate[]>();
    for (const c of candidates) {
      const list = byArticle.get(c.articleId) ?? [];
      list.push(c);
      byArticle.set(c.articleId, list);
    }

    const scored = Array.from(byArticle.entries()).map(([articleId, hits]) => {
      const avgDistance =
        hits.reduce((sum, h) => sum + h.distance, 0) / hits.length;
      const recencyBoost = recencyScore(hits[0].publishedAt);
      return {
        articleId,
        score: hits.length * 0.6 + (1 - avgDistance) * 0.3 + recencyBoost * 0.1,
      };
    });

    return scored
      .sort((a, b) => b.score - a.score)
      .map((s) => ({ id: s.articleId }));
  }
}

Der Fallback auf tagbasierte Empfehlungen ist kein Eingeständnis von Versagen. Er ist eine Zuverlägigkeitsgarantie. Wenn der Vektor-Store ausfällt, der Provider ratenlimitiert oder der Artikel noch keine Vektoren hat, zeigt das Widget immer noch etwas Relevantes anstatt einer leeren Box oder eines 500-Fehlers.

Caching-Strategie

Der Cache ist der Unterschied zwischen einem Empfehlungs-Widget, das Zehntelsekunden hinzufügt, und einem, das Hunderte von Millisekunden hinzufügt. Wir verwenden Redis mit einem strukturierten Schlüssel und einem Stale-While-Revalidate-Muster für beliebte Artikel.

interface CacheEntry<T> {
  data: T;
  staleAt: number;
  expiresAt: number;
}

class RedisRelatedCache {
  constructor(private redis: RedisClient) {}

  async get<T>(key: string): Promise<T | null> {
    const raw = await this.redis.get(key);
    if (!raw) return null;

    const entry: CacheEntry<T> = JSON.parse(raw);
    if (Date.now() > entry.expiresAt) {
      await this.redis.del(key);
      return null;
    }

    return entry.data;
  }

  async set<T>(
    key: string,
    data: T,
    opts: { ttlSeconds: number; staleSeconds?: number },
  ): Promise<void> {
    const now = Date.now();
    const entry: CacheEntry<T> = {
      data,
      staleAt: now + (opts.staleSeconds ?? opts.ttlSeconds) * 1000,
      expiresAt: now + opts.ttlSeconds * 1000,
    };

    await this.redis.set(key, JSON.stringify(entry), "EX", opts.ttlSeconds);
  }

  async isStale(key: string): Promise<boolean> {
    const raw = await this.redis.get(key);
    if (!raw) return false;

    const entry: CacheEntry<unknown> = JSON.parse(raw);
    return Date.now() > entry.staleAt;
  }
}

Unsere Cache-Schlüssel enthalten die Artikel-ID, die Sprache und optional den Kategorie-Filter. Wir schließen keine Nutzeridentität ein, weil das Widget für jeden Leser desselben Artikels identisch ist. Das hält die Cache-Hit-Rate hoch und die Kardinalität niedrig.

Die Standard-TTL beträgt eine Stunde, mit einem Stale-Fenster von fünf Minuten. Wenn eine Anfrage einen veralteten Eintrag findet, liefern wir ihn sofort aus und lösen im Hintergrund eine Aktualisierung aus. Das verhindert, dass selten aufgerufene Artikel jemals einen Leser blockieren, und hält beliebte Artikel aktuell.

Cache-Invalidierung erfolgt bei Veröffentlichung und Löschung. Der Ingestion-Worker löscht die Cache-Schlüssel für den aktualisierten Artikel und für alle Artikel, die vorher auf ihn zurückverwiesen haben. Wir versuchen nicht, chirurgisch zu sein. Invalidierung ist billig; veraltete Empfehlungen sind teuer.

Filterung und Facettierung

Rohe Vektorähnlichkeit allein reicht nicht. Ein Reiseblog könnte zwei Artikel über "Paris" haben, die semantisch nah beieinanderliegen, aber einer ist ein Budget-Guide und der andere ein Luxushotel-Review. Wenn dein Leser auf dem Budget-Guide ist, willst du ihn wahrscheinlich nicht zum Luxus-Review schicken.

Wir unterstützen zwei Arten von Filtern: Hard-Filter und Soft-Filter. Hard-Filter schließen Kandidaten vor der Rückgabe der Vektorabfrage aus, zum Beispiel Sprache und Veröffentlichungsstatus. Soft-Filter werden nach dem Ranking angewendet, zum Beispiel Kategoriepräferenz oder Lesezeit. Soft-Filter können überstimmt werden, wenn die semantische Übereinstimmung stark genug ist.

Vektor-Stores unterscheiden sich stark darin, wie gut sie Metadaten-Filterung während ANN-Abfragen unterstützen. Qdrant und Pinecone beherrschen das gut. PostgreSQL mit pgvector funktioniert für kleinere Archive, hat aber bei kombinierten Vektor- und Metadatenabfragen im großen Maßstab Probleme. Wir haben unseren Store gezielt deshalb gewählt, weil er Sprach- und Kategorie-Filter innerhalb der ANN-Suche anwenden kann, ohne große Kandidatenmengen abrufen und danach filtern zu müssen.

Monitoring und Kostenkontrolle

Produktionssysteme, die von einer Drittpartei-API abhängen, brauchen Absicherungen. Wir verfolgen drei Metriken: Embedding-Latenz, Abfrage-Latenz und Kosten pro tausend Artikel. Die Embedding-Latenz betrifft hauptsächlich Backfills und Bulk-Updates. Die Abfrage-Latenz ist leserrelevant und der Grund für den Cache.

Wir begrenzen außerdem die täglichen Embedding-Ausgaben. Wenn eine Content-Migration oder ein Import-Job plötzlich Zehntausende von Artikeln einreiht, wollen wir keine Überraschungsrechnung. Der Worker prüft vor jedem Batch einen täglichen Budgetzähler in Redis und pausiert, wenn das Limit erreicht ist.

async function checkDailyBudget(
  redis: RedisClient,
  costCents: number,
  maxCents: number,
): Promise<boolean> {
  const key = `embed:budget:${new Date().toISOString().slice(0, 10)}`;
  const spent = await redis.incrby(key, Math.ceil(costCents));
  if (spent <= maxCents) {
    await redis.expire(key, 86_400);
  }
  return spent <= maxCents;
}

Schließlich protokollieren wir jede Abfrage, die auf tagbasierte Empfehlungen zurückfällt. Ein anhaltender Anstieg der Fallback-Rate ist normalerweise das erste Anzeichen für ein Vektor-Store-Problem oder einen fehlenden Backfill.

Datenschutz und Aufbewahrung

Das Senden von Artikelinhalten an eine Embedding-API hat Implikationen. Auch wenn der Inhalt bereits öffentlich ist, können Embedding-Provider Eingaben zur Modellverbesserung speichern, je nach deren Bedingungen. Wir deaktivieren die Trainingsnutzung, wo die API es erlaubt, und prüfen die Datenrichtlinie des Providers während der Vertragsprüfung.

Für interne oder kostenpflichtige Inhalte ist Self-Hosting die sicherere Standardeinstellung. Die Architektur ändert sich nicht; nur die Provider-Schnittstelle ändert sich. Darum ist die Abstraktion so wichtig.

Ergebnisse und Einschränkungen

Nach sechs Wochen stieg die Click-Through-Rate des Widgets für verwandte Artikel um 34 Prozent. Die Verweildauer verbesserte sich moderat. Die größte qualitative Veränderung waren weniger Beschwerden von Redakteuren, dass das Widget themenfremde Empfehlungen anzeigte.

Dennoch sind Embeddings nicht immer die richtige Wahl. Wenn dein Archiv klein ist, kann ein guter Volltext-Suchindex plus Tags deutlich bessere Ergebnisse mit weit weniger Komplexität liefern. Wenn dein Inhalt stark strukturiert ist, können entitätsbasierte Empfehlungen Vektoren übertreffen. Embeddings glänzen, wenn der Wert in der Bedeutung des Textes liegt, nicht in expliziten Metadaten.

Sie erfordern auch Wartung. Modelle werden eingestellt. Providerpreise ändern sich. Vektoren driftieren, wenn sich deine Content-Strategie weiterentwickelt. Du fügst eine neue Datenpipeline hinzu, nicht nur ein Widget.

Takeaways

  • Tags beschreiben redaktionelle Absicht; Embeddings erfassen semantische Bedeutung. Verwende das richtige Signal für Empfehlungen.
  • Erzeuge Embeddings zum Zeitpunkt des Schreibens, nicht zum Zeitpunkt der Anfrage. Der Abfragepfad sollte ein schneller Lookup mit Cache davor sein.
  • Teile Inhalte intelligent auf. Titel, Überschriften und Absätze verdienen separate Vektoren.
  • Biete immer einen Fallback auf tagbasierte oder popularitätsbasierte Empfehlungen. Leser-relevante Features dürfen nicht lautlos ausfallen.
  • Cache aggressiv mit strukturierten Schlüsseln und Stale-While-Revalidate. Verwandte Artikel müssen nicht in Echtzeit sein.
  • Abstrahiere Embedding-Provider und Vektor-Store hinter Schnittstellen. Du wirst eines Tages eines davon austauschen.
  • Überwache Kosten, Latenz und Fallback-Rate. Das sind die Metriken, die dir sagen, ob das System gesund ist.
  • Behandle Embeddings als Datenpipeline, nicht als Plugin. Sie braucht Backfills, Invalidierung, Budgetierung und Aufbewahrungsrichtlinien.

Teilen:

XLinkedIn
Miracle Kalu

Verfasst von

Miracle Kalu

Senior Full Stack Engineer

Hat dir das Gefallen?

Ich bin verfügbar für Senior-Engineering-Rollen und technische Beratung. Lass uns reden.

Kontakt aufnehmen →

Veröffentlicht 13. Juni 2026 · 8 Min. Lesezeit

Weiterlesen