Aller au contenu

Ajouter une recherche sémantique à Strapi 5 avec OpenAI

Guide pas‑à‑pas pour créer un plugin Strapi 5 générant des embeddings OpenAI et exposant des endpoints REST de recherche sémantique, avec exemples de code.

Par l’équipe Revisionary · · 4 min de lecture

A black and white flowchart with text boxes and arrows on a wall
Photo de Hanna Morris sur Unsplash

Strapi 5 peut être étendu avec un plugin personnalisé qui génère des embeddings OpenAI pour chaque morceau de contenu, puis expose une API REST propre pour la recherche sémantique. En suivant les étapes ci‑dessous, vous obtiendrez :

  • Création automatique d’embeddings lors de la création / mise à jour des types de contenu sélectionnés.
  • Un endpoint /api/semantic-search/search qui renvoie les entrées les plus similaires.
  • Recherche multi‑type optionnelle, statistiques d’usage et prise de conscience des coûts.

Le guide part du principe d’une installation neuve de Strapi 5 (Node 18‑24, PostgreSQL/MySQL/SQLite) et d’une clé API OpenAI. Tous les extraits de code sont écrits pour l’architecture des plugins de Strapi 5 et utilisent le modèle text‑embedding‑ada‑002.


1. Créer l’échafaudage du plugin

bash
# Dans la racine de votre projet Strapi
npx strapi generate
# Choisissez « plugin » et nommez‑le « semantic-search »

Strapi crée le dossier src/plugins/semantic-search avec la structure habituelle (admin, server, package.json). Ouvrez le package.json généré et ajoutez les dépendances requises :

json
{
  "name": "strapi-plugin-semantic-search",
  "version": "1.1.0",
  "main": "./server",
  "dependencies": {
    "axios": "^1.7.0",
    "openai": "^4.28.0"
  }
}

Exécutez npm install (ou yarn) pour installer les paquets.


2. Configurer le plugin

Créez ou modifiez config/plugins.js (ou config/plugins.ts si vous utilisez TypeScript) et activez le plugin :

js
module.exports = ({ env }) => ({
  // autres plugins …
  "semantic-search": {
    enabled: true,
    config: {
      // associer les types de contenu aux champs à embarquer
      contentTypes: {
        "api::article.article": ["title", "content"],
        "api::product.product": ["name", "description"]
      },
      // optionnel : changer le modèle OpenAI
      embeddingModel: "text-embedding-ada-002",
      // seuil de similarité (0‑1) pour les résultats de recherche
      defaultThreshold: 0.6,
      // nombre maximal de résultats retournés par requête
      defaultLimit: 10
    }
  }
});

Enregistrez votre clé OpenAI dans le fichier d’environnement :

code
# .env
OPENAI_API_KEY=sk‑your‑key‑here

3. Initialiser le client OpenAI (côté serveur)

Créez server/services/openai.js :

js
'use strict';
const { OpenAI } = require('openai');

let client;

module.exports = {
  init() {
    const apiKey = process.env.OPENAI_API_KEY;
    if (!apiKey) {
      strapi.log.error('OPENAI_API_KEY n’est pas définie – recherche sémantique désactivée');
      return;
    }
    client = new OpenAI({ apiKey });
    strapi.log.info('Service d’embedding OpenAI initialisé');
  },
  async getEmbedding(text) {
    if (!client) return null;
    const response = await client.embeddings.create({
      model: strapi.plugin('semantic-search').config('embeddingModel'),
      input: text,
    });
    return response.data[0].embedding; // tableau de 1536 dimensions
  }
};

Enregistrez le hook d’initialisation dans server/index.js :

js
module.exports = (plugin) => {
  const openaiService = require('./services/openai');
  openaiService.init();
  // exposer le service aux autres parties du plugin
  plugin.services.openai = openaiService;
  return plugin;
};

4. Utilitaire de pré‑traitement du texte

La qualité des embeddings chute avec du HTML brut ou des espaces excessifs. Ajoutez un helper dans server/utils/preprocess.js :

js
'use strict';
module.exports = {
  clean(text) {
    if (!text) return '';
    // supprimer les balises HTML
    const withoutHtml = text.replace(/<[^>]*>/g, ' ');
    // normaliser les espaces
    const normalized = withoutHtml.replace(/\s+/g, ' ').trim();
    // limite de tokens OpenAI ≈ 8000 caractères pour ada‑002
    return normalized.slice(0, 8000);
  }
};

5. Hook de cycle de vie pour la génération d’embeddings

Nous voulons stocker les embeddings chaque fois qu’une entrée versionnée est créée ou mise à jour. Ajoutez le fichier de bootstrap server/bootstrap.js :

js
'use strict';
const preprocess = require('./utils/preprocess');

module.exports = async () => {
  const config = strapi.plugin('semantic-search').config('contentTypes');

  // Parcourir chaque type de contenu configuré
  for (const [ct, fields] of Object.entries(config)) {
    const model = strapi.db.query(ct);
    // S’abonner aux événements beforeCreate et beforeUpdate
    strapi.db.lifecycles.subscribe({
      models: [ct],
      async beforeCreate(event) {
        await generateAndStore(event.params.data, ct, fields);
      },
      async beforeUpdate(event) {
        await generateAndStore(event.params.data, ct, fields);
      }
    });
  }
};

async function generateAndStore(data, contentType, fields) {
  const openai = strapi.plugin('semantic-search').services.openai;
  if (!openai) return;
  // concaténer les champs configurés
  const raw = fields.map(f => data[f] || '').join(' ');
  const cleaned = preprocess.clean(raw);
  const embedding = await openai.getEmbedding(cleaned);
  if (!embedding) return;
  // stocker l’embedding et les métadonnées optionnelles sur l’entrée
  data.embedding = embedding;
  data.embeddingMetadata = { generatedAt: new Date().toISOString() };
}

Le hook ajoute deux nouvelles colonnes (embedding, embeddingMetadata) au schéma du type de contenu. Exécutez la migration une fois :

bash
# Ajouter les champs au modèle (exemple pour article)
strapi generate:model article title:string content:text embedding:json embeddingMetadata:json

Si vous avez déjà un modèle, éditez son schéma dans src/api/article/content-types/article/schema.json et ajoutez les champs manuellement, puis redémarrez Strapi.


6. Helper de similarité vectorielle

Créez server/services/vector.js :

js
'use strict';
module.exports = {
  cosineSimilarity(a, b) {
    let dot = 0, normA = 0, normB = 0;
    for (let i = 0; i < a.length; i++) {
      dot += a[i] * b[i];
      normA += a[i] * a[i];
      normB += b[i] * b[i];
    }
    return dot / (Math.sqrt(normA) * Math.sqrt(normB));
  }
};

7. Service de recherche

server/services/search.js implémente la logique principale :

js
'use strict';
const vector = require('./vector');

module.exports = {
  /**
   * Effectuer une recherche sémantique sur un seul type de contenu.
   * @param {string} query – chaîne de requête de l’utilisateur.
   * @param {string} ct – UID du type de contenu (ex. "api::article.article").
   * @param {object} opts – {limit, threshold, filters}.
   */
  async semanticSearch(query, ct, opts = {}) {
    const { limit = 10, threshold = 0.6, filters = {} } = opts;
    const openai = strapi.plugin('semantic-search').services.openai;
    const queryEmbedding = await openai.getEmbedding(query);
    if (!queryEmbedding) return [];

    // Récupérer les entrées disposant déjà d’embeddings
    const entries = await strapi.db.query(ct).findMany({
      where: { embedding: { $notNull: true }, ...filters },
      select: ['id', 'title', 'embedding'] // ajuster les champs selon le type
    });

    // Calculer la similarité et filtrer
    const scored = entries.map(e => ({
      entry: e,
      score: vector.cosineSimilarity(queryEmbedding, e.embedding)
    })).filter(item => item.score >= threshold);

    // Trier par ordre décroissant et tronquer
    scored.sort((a, b) => b.score - a.score);
    return scored.slice(0, limit).map(item => ({
      id: item.entry.id,
      title: item.entry.title,
      similarity: item.score
    }));
  },

  /**
   * Recherche parallèle sur plusieurs types de contenu.
   */
  async multiContentSearch(query, contentTypes, opts = {}) {
    const promises = contentTypes.map(ct =>
      this.semanticSearch(query, ct, opts).then(res => ({ ct, res }))
    );
    const results = await Promise.all(promises);
    // aplatir et trier globalement
    return results
      .flatMap(r => r.res.map(item => ({ ...item, contentType: r.ct })))
      .sort((a, b) => b.similarity - a.similarity)
      .slice(0, opts.limit || 10);
  }
};

a white robot holding a magnifying glass
Photo de Growtika sur Unsplash

8. Contrôleurs REST

Ajoutez deux fichiers de contrôleur sous server/controllers.

8.1 search.js

js
'use strict';
module.exports = {
  async search(ctx) {
    const { query, contentType, limit, threshold, filters } = ctx.request.body;
    if (!query || !contentType) {
      return ctx.badRequest('query et contentType sont requis');
    }
    const results = await strapi.plugin('semantic-search').services.search.semanticSearch(
      query,
      contentType,
      { limit, threshold, filters }
    );
    ctx.send({ results, searchTimeMs: ctx.state.searchTime || null });
  },

  async multiSearch(ctx) {
    const { query, contentTypes, limit, threshold, filters } = ctx.request.body;
    if (!query || !Array.isArray(contentTypes)) {
      return ctx.badRequest('query et contentTypes (tableau) sont requis');
    }
    const results = await strapi.plugin('semantic-search').services.search.multiContentSearch(
      query,
      contentTypes,
      { limit, threshold, filters }
    );
    ctx.send({ results });
  },

  async stats(ctx) {
    const config = strapi.plugin('semantic-search').config('contentTypes');
    const stats = {};
    for (const ct of Object.keys(config)) {
      const total = await strapi.db.query(ct).count();
      const withEmb = await strapi.db.query(ct).count({ where: { embedding: { $notNull: true } } });
      stats[ct] = { total, withEmbedding: withEmb, coverage: (withEmb / total) * 100 };
    }
    ctx.send({ stats });
  }
};

8.2 Routes (server/routes/index.js)

js
module.exports = {
  type: 'content-api',
  routes: [
    {
      method: 'POST',
      path: '/semantic-search/search',
      handler: 'search.search',
      config: { policies: [] }
    },
    {
      method: 'POST',
      path: '/semantic-search/multi-search',
      handler: 'search.multiSearch',
      config: { policies: [] }
    },
    {
      method: 'GET',
      path: '/semantic-search/stats',
      handler: 'search.stats',
      config: { policies: [] }
    }
  ]
};

9. Tester le endpoint

Démarrez Strapi (npm run develop) et créez quelques articles. Le hook de cycle de vie générera automatiquement les embeddings.

bash
curl -X POST http://localhost:1337/api/semantic-search/search \
  -H "Content-Type: application/json" \
  -d '{
    "query": "How to use React hooks",
    "contentType": "api::article.article",
    "limit": 5,
    "threshold": 0.65
  }'

Vous devez recevoir une réponse JSON du type :

json
{
  "results": [
    {"id":12,"title":"Understanding React Hooks","similarity":0.89},
    {"id":34,"title":"Hook patterns in modern React","similarity":0.84}
  ]
}

10. Considérations de coût et de performance

AspectRecommandation
Prix des embeddingstext‑embedding‑ada‑002 coûte environ 0,0001 $ / 1 000 tokens. Un article moyen de 500 mots (~750 tokens) coûte 0,000075 $. Suivez l’usage via le endpoint /stats.
Limitation de débitProtégez les endpoints de recherche avec le middleware de limitation de débit de Strapi ou une passerelle API afin d’éviter des coûts incontrôlés.
Taille du stockage vectorielChaque embedding contient 1536 valeurs flottantes (~12 KB). Pour 10 000 entrées, cela représente ≈ 120 MB – raisonnable pour la plupart des bases, mais pensez à archiver les contenus anciens si besoin.
Mises à jour par lotLors de la migration de contenus existants, lancez un script ponctuel qui appelle generateAndStore pour chaque entrée afin de rétro‑alimenter les embeddings.
CacheLes requêtes fréquentes peuvent être mises en cache en périphérie (Vercel, Cloudflare) ; la réponse est petite et déterministe.

11. Étendre le plugin

  • Embeddings d’image/audio – remplacez le client OpenAI par openai.images.createEmbedding (ou un modèle tiers) et stockez le vecteur dans un champ dédié.
  • Base vectorielle – pour plus de 100 k enregistrements, externalisez les vecteurs vers une base spécialisée (Pinecone, Qdrant, Upstash Vector). Le plugin continue d’utiliser les hooks pour pousser les embeddings, tandis que le service de recherche interroge le magasin externe.
  • UI admin – ajoutez une page personnalisée dans l’admin Strapi qui visualise la couverture des embeddings par type de contenu, en s’appuyant sur le endpoint /stats.
  • Webhooks – émettez un événement après chaque génération d’embedding ; des services en aval (ex. analytics) peuvent réagir.

12. Garder les embeddings synchronisés avec Revisionary

Si vous utilisez déjà le plugin Revisionary pour le contenu versionné, les champs d’embedding deviennent partie intégrante de chaque version. Ainsi vous pouvez :

  • Revenir à une version antérieure tout en conservant son embedding.
  • Comparer l’évolution de la représentation sémantique d’un texte grâce au panneau Historique des versions.
  • Auditer les dates de création des embeddings via le journal d’audit.

Aucune configuration supplémentaire n’est nécessaire ; Revisionary suit automatiquement les nouveaux champs embedding et embeddingMetadata comme n’importe quelle colonne JSON.


13. Problèmes courants et dépannage

SymptômeCause probableSolution
Aucun champ embedding n’apparaîtÉtape de migration manquée ou schéma non rechargé.Redémarrez Strapi après modification du schéma, ou exécutez strapi build.
La recherche renvoie un tableau videLes entrées ne contiennent pas d’embeddings (hook non exécuté).Vérifiez l’enregistrement des lifecycles beforeCreate/beforeUpdate ; consultez les logs du plugin pour « OpenAI embedding service initialized ».
Latence élevée (> 2 s)Jeu de résultats trop grand ou trop de types de contenu.Utilisez le paramètre limit, ajoutez des filtres, ou migrez vers une base vectorielle pour un NN plus rapide.
Pics de coûts inattendusRequêtes fréquentes sans limitation de débit.Activez le middleware de limitation de débit de Strapi ou une passerelle API ; surveillez l’usage via le endpoint /stats.

Conclusion

Ajouter une recherche sémantique à un projet Strapi 5 est simple dès que vous disposez d’un plugin qui :

  1. Génère des embeddings OpenAI lors de l’écriture du contenu.
  2. Stocke les vecteurs à côté de l’entrée.
  3. Expose des endpoints REST calculant la similarité cosinus à la volée.

Cette approche passe de quelques articles à plusieurs dizaines de milliers d’enregistrements, tout en s’intégrant proprement aux fonctionnalités natives de Strapi telles que l’historique de version de Revisionary et le journal d’audit. En suivant ce guide, vous obtenez une couche de navigation alimentée par l’IA pour votre CMS headless, tout en maîtrisant les coûts et en conservant le contrôle total sur le pipeline de données.

Mots-clésrecherche sémantiquestrapiopenaiplugincms headless

Sources

  1. Build a semantic‑search plugin with Strapi and OpenAI
  2. Upstash blog – Adding semantic search to Strapi
  3. strapi-plugin-semantic-search repository
  4. strapi-content-embeddings example
  5. Semantic search implementation guide

L’équipe Revisionary

Les développeurs de Revisionary, un plugin d’historique des versions pour Strapi 5. Nous écrivons sur Strapi, le développement web, l’IA et la sécurité des applications.

Cet article a été rédigé avec l'aide d'une IA.

Tous les articles

IA

RAG versionné avec Strapi et Revisionary

Apprenez à créer un pipeline RAG qui interroge le contenu de Strapi tout en respectant l’historique des versions, grâce à Revisionary pour des réponses à jour et traçables.

· 5 min de lecture