How to add semantic search to Strapi 5 with OpenAI embeddings
Step‑by‑step guide to building a Strapi 5 plugin that creates OpenAI embeddings for your content and exposes REST endpoints for semantic search, with configuration, code examples and best practices.
By the Revisionary team · · 4 min read
Strapi 5 can be extended with a custom plugin that generates OpenAI embeddings for each piece of content and then serves a clean REST API for semantic search. By following the steps below you will have:
- Automatic embedding creation on create / update of selected content types.
- A
/api/semantic-search/searchendpoint that returns the most similar entries. - Optional multi‑type search, usage statistics and cost‑awareness.
The guide assumes a fresh Strapi 5 installation (Node 18‑24, PostgreSQL/MySQL/SQLite) and an OpenAI API key. All code samples are written for Strapi 5’s plugin architecture and use the text‑embedding‑ada‑002 model.
1. Create the plugin scaffold
# Inside your Strapi project root
npx strapi generate
# Choose "plugin" and name it "semantic-search"Strapi creates a folder src/plugins/semantic-search with the typical structure (admin, server, package.json). Open the generated package.json and add the required dependencies:
{
"name": "strapi-plugin-semantic-search",
"version": "1.1.0",
"main": "./server",
"dependencies": {
"axios": "^1.7.0",
"openai": "^4.28.0"
}
}Run npm install (or yarn) to pull the packages.
2. Configure the plugin
Create or edit config/plugins.js (or config/plugins.ts if you use TypeScript) and enable the plugin:
module.exports = ({ env }) => ({
// other plugins …
"semantic-search": {
enabled: true,
config: {
// map content types to the fields that should be embedded
contentTypes: {
"api::article.article": ["title", "content"],
"api::product.product": ["name", "description"]
},
// optional: change the OpenAI model
embeddingModel: "text-embedding-ada-002",
// similarity threshold (0‑1) for search results
defaultThreshold: 0.6,
// maximum number of results returned per query
defaultLimit: 10
}
}
});Store your OpenAI key in the environment file:
# .env
OPENAI_API_KEY=sk‑your‑key‑here3. Initialise the OpenAI client (server side)
Create 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 is not set – semantic search disabled');
return;
}
client = new OpenAI({ apiKey });
strapi.log.info('OpenAI embedding service initialized');
},
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; // 1536‑dim array
}
};Register the init hook in server/index.js:
module.exports = (plugin) => {
const openaiService = require('./services/openai');
openaiService.init();
// expose the service for other parts of the plugin
plugin.services.openai = openaiService;
return plugin;
};4. Text preprocessing utility
Embedding quality drops with raw HTML or excessive whitespace. Add a helper in server/utils/preprocess.js:
'use strict';
module.exports = {
clean(text) {
if (!text) return '';
// strip HTML tags
const withoutHtml = text.replace(/<[^>]*>/g, ' ');
// normalise whitespace
const normalized = withoutHtml.replace(/\s+/g, ' ').trim();
// OpenAI token limit ≈ 8000 characters for ada‑002
return normalized.slice(0, 8000);
}
};5. Embedding generation lifecycle hook
We want embeddings to be stored whenever a versioned entry is created or updated. Add a bootstrap file server/bootstrap.js:
'use strict';
const preprocess = require('./utils/preprocess');
module.exports = async () => {
const config = strapi.plugin('semantic-search').config('contentTypes');
// Iterate over each configured content type
for (const [ct, fields] of Object.entries(config)) {
const model = strapi.db.query(ct);
// Subscribe to beforeCreate and beforeUpdate events
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;
// concatenate configured fields
const raw = fields.map(f => data[f] || '').join(' ');
const cleaned = preprocess.clean(raw);
const embedding = await openai.getEmbedding(cleaned);
if (!embedding) return;
// store embedding and optional metadata on the entry
data.embedding = embedding;
data.embeddingMetadata = { generatedAt: new Date().toISOString() };
}The hook adds two new columns (embedding, embeddingMetadata) to the content type schema. Run the migration once:
# Add the fields to the model (example for article)
strapi generate:model article title:string content:text embedding:json embeddingMetadata:jsonIf you already have a model, edit its schema in src/api/article/content-types/article/schema.json and add the fields manually, then restart Strapi.
6. Vector similarity helper
Create 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. Search service
server/services/search.js implements the core logic:
'use strict';
const vector = require('./vector');
module.exports = {
/**
* Perform a semantic search on a single content type.
* @param {string} query – user query string.
* @param {string} ct – content‑type UID (e.g. "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 [];
// Fetch entries that already have embeddings
const entries = await strapi.db.query(ct).findMany({
where: { embedding: { $notNull: true }, ...filters },
select: ['id', 'title', 'embedding'] // adjust fields per type
});
// Compute similarity and filter
const scored = entries.map(e => ({
entry: e,
score: vector.cosineSimilarity(queryEmbedding, e.embedding)
})).filter(item => item.score >= threshold);
// Sort descending and slice
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
}));
},
/**
* Parallel search across multiple content types.
*/
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);
// flatten and sort globally
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. REST controllers
Add two controller files under 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 and contentType are required');
}
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 and contentTypes array are required');
}
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. Test the endpoint
Start Strapi (npm run develop) and create a few articles. The lifecycle hook will automatically generate 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
}'You should receive a JSON payload like:
{
"results": [
{"id":12,"title":"Understanding React Hooks","similarity":0.89},
{"id":34,"title":"Hook patterns in modern React","similarity":0.84}
]
}10. Cost and performance considerations
| Aspect | Guidance |
|---|---|
| Embedding price | text‑embedding‑ada‑002 costs roughly $0.0001 per 1 000 tokens. A typical 500‑word article (~750 tokens) costs $0.000075. Track usage via the /stats endpoint. |
| Rate limiting | Protect the search endpoints with Strapi’s built‑in rate‑limit middleware or an API gateway to avoid runaway costs. |
| Vector storage size | Each embedding is 1536 float values (~12 KB). For 10 000 entries, storage is ≈ 120 MB – manageable in most databases, but consider archiving old content if needed. |
| Batch updates | When migrating existing content, run a one‑off script that calls the generateAndStore function for each entry to back‑fill embeddings. |
| Caching | Frequently repeated queries can be cached at the edge (Vercel, Cloudflare) because the response payload is small and deterministic. |
11. Extending the plugin
- Image/audio embeddings – swap the OpenAI client for
openai.images.createEmbedding(or a third‑party model) and store the vector in a dedicated field. - Vector database – for > 100 k records, offload vectors to a specialized DB (Pinecone, Qdrant, Upstash Vector). The plugin can still use the lifecycle hooks to push embeddings, while the search service queries the external store instead of the relational DB.
- Admin UI – add a custom page under the Strapi admin that visualises embedding coverage per content type, using the
/statsendpoint. - Webhooks – emit an event after each successful embedding generation; downstream services (e.g., analytics) can react.
12. Keeping embeddings in sync with Revisionary
If you already use the Revisionary plugin for versioned content, the embedding fields become part of each version. This means you can:
- Roll back an entry and retain its previous embedding.
- Compare how the semantic representation of a piece of text evolves over time via the Version history panel.
- Audit embedding creation dates through the Audit log.
No extra configuration is required; Revisionary automatically tracks the new embedding and embeddingMetadata fields like any other JSON column.
13. Common pitfalls and troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
No embedding column appears | Migration step missed or schema not re‑loaded. | Restart Strapi after editing the schema, or run strapi build. |
| Search returns empty array | Content entries lack embeddings (hook didn’t run). | Verify the beforeCreate/beforeUpdate lifecycles are registered; check plugin logs for “OpenAI embedding service initialized”. |
| High latency (> 2 s) | Large result set or many content types. | Use the limit parameter, add filters, or switch to a vector DB for faster nearest‑neighbor lookup. |
| Unexpected cost spikes | Frequent queries without rate limiting. | Enable Strapi rate‑limit middleware or an API gateway; monitor usage via the /stats endpoint. |
Conclusion
Adding semantic search to a Strapi 5 project is straightforward once you have a plugin that:
- Generates OpenAI embeddings on content write‑back.
- Stores the vectors alongside the entry.
- Provides REST endpoints that calculate cosine similarity on‑the‑fly.
The pattern scales from a handful of articles to tens of thousands of records, and it integrates cleanly with existing Strapi features such as Revisionary’s version history and audit log. By following this guide you gain a powerful, AI‑driven navigation layer for your headless CMS while keeping costs transparent and maintaining full control over the data pipeline.
Sources
The Revisionary team
Developers who build Revisionary, a version history plugin for Strapi 5. We write about Strapi, web development, AI and application security.
This article was written with the help of AI.