Skip to content

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

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

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/search endpoint 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

bash
# 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:

json
{
  "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:

js
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:

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

3. Initialise the OpenAI client (server side)

Create 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 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:

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:

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:

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:

bash
# Add the fields to the model (example for article)
strapi generate:model article title:string content:text embedding:json embeddingMetadata:json

If 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:

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:

js
'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);
  }
};

a white robot holding a magnifying glass
Photo by Growtika on Unsplash

8. REST controllers

Add two controller files under 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 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)

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.

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

You should receive a JSON payload like:

json
{
  "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

AspectGuidance
Embedding pricetext‑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 limitingProtect the search endpoints with Strapi’s built‑in rate‑limit middleware or an API gateway to avoid runaway costs.
Vector storage sizeEach 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 updatesWhen migrating existing content, run a one‑off script that calls the generateAndStore function for each entry to back‑fill embeddings.
CachingFrequently 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 /stats endpoint.
  • 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

SymptomLikely causeFix
No embedding column appearsMigration step missed or schema not re‑loaded.Restart Strapi after editing the schema, or run strapi build.
Search returns empty arrayContent 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 spikesFrequent 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:

  1. Generates OpenAI embeddings on content write‑back.
  2. Stores the vectors alongside the entry.
  3. 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.

Tagssemantic searchstrapiopenaipluginheadless cms

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

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.

All articles