Ingénierie de l'IAJune 13, 2026·11 min de lecture·Miracle KaluMiracle Kalu

Construire un moteur d'articles connexes avec des embeddings vectoriels : une conception prête pour la production

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

Pourquoi les articles connexes basés sur les tags ont cessé de fonctionner

Pendant des années, le widget d'articles connexes sur notre site de contenu était une simple requête SQL. Il joignait les articles par des tags partagés, triait le résultat par date de publication et en restait là. L'implémentation était bon marché, prévisible et surtout hors sujet. Les lecteurs terminaient un article sur les stratégies d'invalidation de cache et se voyaient proposer le dernier post tagué "DevOps" parce que quelqu'un avait un jour utilisé le mot "déploiement" dans l'introduction.

Les tags décrivent l'intention éditoriale, pas le sens sémantique. Ils sont excellents pour la navigation, mais constituent un instrument grossier pour les recommandations. Deux articles peuvent partager tous leurs tags et traiter pourtant de problèmes totalement différents. Pire, deux articles qui résolvent le même problème dans des domaines différents peuvent ne partager aucun tag. Au fur et à mesure que notre archive dépassait les cinq cents articles, le widget est devenu un carrousel de ratages vaguement liés.

Nous avions besoin d'une couche de recommandation qui comprenait réellement le sujet d'un article. Les embeddings vectoriels se sont révélés être le bon outil, mais seulement après que nous avons cessé de les traiter comme une boîte de recherche magique et commencé à les traiter comme un pipeline de données en production.

Ce que les embeddings apportent réellement

Un embedding est un vecteur dense qui projette un texte dans un espace sémantique à haute dimension. Les textes de sens proche finissent proches les uns des autres, même s'ils utilisent des mots différents. Cette propriété fournit un signal de recommandation que les tags ne peuvent pas offrir : la parenté conceptuelle.

Le modèle que nous avons choisi voit "invalidation de cache" et "maintenir un CDN synchronisé avec l'état de l'origine" comme des voisins. Il considère "pipelines de déploiement" et "livraison continue" comme liés sans que personne ait besoin de les taguer comme tels. Le résultat est un widget d'articles connexes qui propose des lectures suivantes réellement utiles.

Les embeddings ne sont pas gratuits. Ils coûtent de l'argent à générer, occupent du stockage et ajoutent de la latence au chemin de requête. Le défi en production n'est pas de générer les vecteurs. C'est de garder le système rapide, bon marché et maintenable pendant que l'archive grandit.

Vue d'ensemble de l'architecture

Le système comporte trois chemins indépendants : l'ingestion, la requête et l'invalidation.

Une erreur courante consiste à placer le modèle d'embeddings directement sur le chemin de requête. Cela couple le trafic lecteur à la latence du modèle et à la disponibilité du fournisseur. Nous générons les embeddings au moment de l'écriture, les stockons dans une base de données vectorielle et gardons le chemin de requête comme une simple recherche des plus proches voisins avec un cache devant.

Choix du modèle et du fournisseur

Nous avons commencé avec text-embedding-3-small d'OpenAI pour trois raisons : il est bon marché, la fenêtre de contexte couvre la plupart de nos articles en un seul passage, et la dimension de sortie est configurable. Pour un widget de recommandation, nous avons constaté que 512 dimensions capturaient suffisamment de signal tout en gardant des coûts de stockage et de requête raisonnables. Passer à 1 536 dimensions n'améliorait la qualité que marginalement et doublait la taille de l'index.

Pour les équipes avec des politiques de données plus strictes, les modèles auto-hébergés comme sentence-transformers/all-MiniLM-L6-v2 sont une alternative viable. Le compromis est la charge opérationnelle. Vous gérez alors l'inférence GPU, la gestion des versions de modèle et la surveillance de la latence vous-même. Nous avons choisi le service managé car notre volume ne justifiait pas les coûts d'infrastructure, mais l'architecture fonctionne de la même manière dans les deux cas.

La décision importante n'est pas de savoir quel fournisseur choisir. C'est d'isoler le fournisseur derrière une interface pour pouvoir le changer plus tard sans toucher au code d'ingestion ou de requête.

Le pipeline d'ingestion

Chaque fois qu'un article est publié ou mis à jour, un job en arrière-plan découpe le contenu, génère des embeddings et écrit les vecteurs dans le magasin. Nous n'embeddons pas l'article entier comme un seul vecteur. Les titres, sous-titres et paragraphes ont des poids sémantiques différents, et un seul embedding d'un article de trois mille mots tend à diluer le signal.

Notre stratégie de découpage est simple mais tranchée :

  • Le titre a son propre embedding.
  • Chaque sous-titre H2 a son propre embedding.
  • Le corps est divisé en paragraphes de jusqu'à 256 tokens, avec un chevauchement de 32 tokens.
  • Chaque chunk conserve un pointeur vers l'article parent, sa langue et sa date de publication.

Cela nous donne plusieurs vecteurs par article, ce qui améliore le rappel. Quand un lecteur termine un article, nous interrogeons tous les vecteurs de cet article, agrégeons les voisins les plus proches par article parent et classons les candidats par fréquence et distance moyenne.

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;
  }
}

Le helper splitParagraphs est volontairement naïf. Nous découpons aux limites de phrases quand c'est possible, mais nous n'utilisons pas de fenêtres glissantes au-delà des limites de phrases. Pour la qualité des recommandations, préserver les frontières sémantiques compte plus que maximiser la densité de tokens.

Rattrapage sans faire exploser le budget

La première fois que vous activez ce système, vous devez embedder chaque article de votre archive. Si vous avez mille articles et plusieurs appels API par article, un rattrapage naïf peut rapidement coûter cher. Nous avons utilisé une file d'attente avec limitation de débit et suivi des coûts.

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("Budget de rattrapage épuisé");
      break;
    }

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

  await queue.onIdle();
}

Une concurrence de quatre était le bon équilibre pour notre fournisseur d'embeddings. Une concurrence plus élevée déclenchait des limites de débit sans améliorer sensiblement le débit. Nous avons aussi exécuté le rattrapage en dehors des heures de pointe et journalisé chaque échec pour pouvoir relancer des articles spécifiques sans recommencer tout le lot.

Requête d'articles connexes

Le chemin de requête est l'endroit où le cache et le filtrage deviennent critiques. Un lecteur sur la version allemande d'un article ne devrait pas voir de recommandations en anglais. Un lecteur sur un article sur les performances frontend ne devrait pas voir d'articles sur les bases de données backend juste parce qu'ils partagent le mot "requête".

Nous interrogeons le magasin de vecteurs avec tous les chunks de l'article courant, puis nous agrégeons les identifiants d'articles candidats à travers les résultats. Chaque candidat reçoit un score basé sur le nombre de ses chunks apparus parmi les k plus proches voisins et la proximité de ces voisins. Nous boostons légèrement les articles récents pour que le widget n'affiche pas toujours des articles vieux de cinq ans.

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("Requête d'embeddings échouée, 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 }));
  }
}

Le retour aux recommandations basées sur les tags n'est pas un aveu d'échec. C'est une garantie de fiabilité. Si le magasin de vecteurs est en panne, le fournisseur limite le débit, ou l'article n'a pas encore de vecteurs, le widget affiche toujours quelque chose de pertinent au lieu d'une boîte vide ou d'une erreur 500.

Stratégie de cache

Le cache fait la différence entre un widget d'articles connexes qui ajoute quelques dizaines de millisecondes et un qui ajoute des centaines. Nous utilisons Redis avec une clé structurée et un pattern stale-while-revalidate pour les articles populaires.

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;
  }
}

Nos clés de cache incluent l'identifiant de l'article, la langue et optionnellement le filtre de catégorie. Nous n'incluons pas l'identité de l'utilisateur car le widget est identique pour chaque lecteur d'un même article. Cela maintient un taux de cache élevé et une cardinalité faible.

La durée de vie par défaut est d'une heure, avec une fenêtre de péremption de cinq minutes. Quand une requête trouve une entrée périmée, nous la retournons immédiatement et déclenchons un rafraîchissement en arrière-plan. Cela empêche les articles peu consultés de bloquer un lecteur, tout en gardant les articles populaires à jour.

L'invalidation du cache se fait à la publication et à la suppression. Le worker d'ingestion supprime les clés de cache de l'article mis à jour et de tout article qui y faisait précédemment référence. Nous ne cherchons pas à être chirurgicaux. L'invalidation est bon marché ; les recommandations périmées sont coûteuses.

Filtrage et facettes

La similarité vectorielle brute ne suffit pas. Un blog de voyage peut avoir deux articles sur "Paris" qui sont sémantiquement proches, mais l'un est un guide budget et l'autre une critique d'hôtel de luxe. Si votre lecteur est sur le guide budget, vous ne voulez probablement pas l'envoyer vers la critique de luxe.

Nous supportons deux types de filtres : les filtres durs et les filtres doux. Les filtres durs excluent les candidats avant le retour de la requête vectorielle, comme la langue et le statut de publication. Les filtres doux s'appliquent après le classement, comme la préférence de catégorie ou le temps de lecture. Les filtres doux peuvent être outrepassés si la correspondance sémantique est suffisamment forte.

Les magasins de vecteurs varient beaucoup dans leur support du filtrage de métadonnées pendant les requêtes ANN. Qdrant et Pinecone le gèrent bien. PostgreSQL avec pgvector fonctionne pour les petites archives mais peine avec les requêtes combinant vecteurs et métadonnées à grande échelle. Nous avons choisi notre magasin précisément parce qu'il peut appliquer les filtres de langue et de catégorie à l'intérieur de la recherche ANN, évitant ainsi de devoir récupérer et filtrer de grands ensembles de candidats ensuite.

Surveillance et contrôle des coûts

Les systèmes de production qui dépendent d'une API tierce ont besoin de garde-fous. Nous suivons trois métriques : la latence d'embedding, la latence de requête et le coût par millier d'articles. La latence d'embedding concerne principalement les rattrapages et les mises à jour en masse. La latence de requête est critique pour les lecteurs et est la raison d'être du cache.

Nous plafonnons aussi les dépenses quotidiennes d'embedding. Si une migration de contenu ou un job d'import met soudainement en file des dizaines de milliers d'articles, nous ne voulons pas de facture surprise. Le worker vérifie un compteur de budget quotidien dans Redis avant chaque lot et se met en pause quand le plafond est atteint.

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;
}

Enfin, nous journalisons chaque requête qui retombe sur les recommandations par tags. Une augmentation soutenue du taux de fallback est généralement le premier signe d'un problème de magasin de vecteurs ou d'un rattrapage manquant.

Confidentialité et conservation des données

Envoyer le contenu d'articles à une API d'embeddings a des implications. Même si le contenu est déjà public, les fournisseurs d'embeddings peuvent conserver les entrées pour l'amélioration des modèles selon leurs conditions. Nous désactivons l'utilisation à des fins d'entraînement lorsque l'API le permet, et nous auditons la politique de données du fournisseur lors de la révision du contrat.

Pour les contenus internes ou payants, l'auto-hébergement est le choix par défaut le plus sûr. L'architecture ne change pas ; seule l'interface du fournisseur change. C'est pourquoi l'abstraction est si importante.

Résultats et mises en garde

Après six semaines, le taux de clics sur le widget d'articles connexes avait augmenté de 34 pour cent. Le temps passé sur le site s'est modérément amélioré. Le changement qualitatif le plus notable était moins de plaintes des rédacteurs indiquant que le widget affichait des recommandations hors sujet.

Cela dit, les embeddings ne sont pas toujours le bon choix. Si votre archive est petite, un bon index de recherche plein texte plus les tags peut donner de bien meilleurs résultats avec beaucoup moins de complexité. Si votre contenu est fortement structuré, les recommandations basées sur les entités peuvent surpasser les vecteurs. Les embeddings brillent quand la valeur réside dans le sens du texte, pas dans les métadonnées explicites.

Ils nécessitent aussi de la maintenance. Les modèles sont dépréciés. Les tarifs des fournisseurs changent. Les vecteurs dérivent à mesure que votre stratégie éditoriale évolue. Vous ajoutez un nouveau pipeline de données, pas seulement un widget.

Takeaways

  • Les tags décrivent l'intention éditoriale ; les embeddings capturent le sens sémantique. Utilisez le bon signal pour les recommandations.
  • Générez les embeddings au moment de l'écriture, pas au moment de la requête. Le chemin de requête doit être un lookup rapide avec un cache devant.
  • Découpez intelligemment le contenu. Les titres, sous-titres et paragraphes méritent des vecteurs séparés.
  • Offrez toujours un fallback vers les recommandations par tags ou par popularité. Les fonctionnalités visibles par les lecteurs ne doivent pas tomber en silence.
  • Mettez en cache agressivement avec des clés structurées et du stale-while-revalidate. Les articles connexes n'ont pas besoin d'être en temps réel.
  • Abstrayez le fournisseur d'embeddings et le magasin de vecteurs derrière des interfaces. Vous finirez par en échanger un.
  • Surveillez les coûts, la latence et le taux de fallback. Ce sont les métriques qui disent si le système est en bonne santé.
  • Traitez les embeddings comme un pipeline de données, pas comme un plugin. Il a besoin de rattrapages, d'invalidation, de budget et de politiques de conservation.

Partager :

XLinkedIn
Miracle Kalu

Écrit par

Miracle Kalu

Senior Full Stack Engineer

Vous avez aimé cet article ?

Je suis disponible pour des rôles d'ingénierie senior et du conseil technique. Parlons-en.

Prendre contact →

Publié le 13 juin 2026 · 11 min de lecture

Continuer la lecture