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
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/searchqui 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
# 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 :
{
"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 :
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 :
# .env
OPENAI_API_KEY=sk‑your‑key‑here3. Initialiser le client OpenAI (côté serveur)
Créez server/services/openai.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 :
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 :
'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 :
'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 :
# Ajouter les champs au modèle (exemple pour article)
strapi generate:model article title:string content:text embedding:json embeddingMetadata:jsonSi 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 :
'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 :
'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);
}
};8. Contrôleurs REST
Ajoutez deux fichiers de contrôleur sous server/controllers.
8.1 search.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)
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.
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 :
{
"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
| Aspect | Recommandation |
|---|---|
| Prix des embeddings | text‑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ébit | Proté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 vectoriel | Chaque 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 lot | Lors de la migration de contenus existants, lancez un script ponctuel qui appelle generateAndStore pour chaque entrée afin de rétro‑alimenter les embeddings. |
| Cache | Les 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ôme | Cause probable | Solution |
|---|---|---|
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 vide | Les 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 inattendus | Requê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 :
- Génère des embeddings OpenAI lors de l’écriture du contenu.
- Stocke les vecteurs à côté de l’entrée.
- 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.
Sources
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.