---
title: "Add semantic search to WordPress with AI embeddings: a plugin you can build today"
description: WordPress keyword search ranks by word frequency, not meaning. This plugin replaces it with OpenAI embeddings so visitors find content by intent, not exact phrasing.
source: https://steem.dev/blog/wordpress-semantic-search-plugin
published: 2026-09-21
updated: 2026-09-21
alternative_to: SearchWP
site: Steem — AI WordPress plugin generator
---
# Add semantic search to WordPress with AI embeddings: a plugin you can build today
WordPress's default search is keyword search. It scans post titles and content for the words a visitor typed, counts how often those words appear, and ranks results by frequency. This works when someone types the exact phrase that appears in a post. It breaks when they describe what they want in their own words, use a synonym, or phrase a question differently from the way you answered it.
Plugins like Relevanssi improve this significantly by adding stemming, fuzzy matching, and configurable field weights. But they are still keyword-based. A visitor searching for 'how to cancel my account' will not find your 'membership termination policy' page unless you have manually configured synonyms or both phrases happen to share common words. The gap only widens as a site grows to thousands of posts, each answer phrased slightly differently from the way users ask.
This plugin replaces the WordPress search results with OpenAI embedding-based ranking. When you save a post, it calls the OpenAI API to generate a 1,536-number vector that encodes the meaning of the content and stores it as post meta. When a visitor searches, it embeds the search term the same way, computes cosine similarity against every stored embedding, and returns the posts whose meaning most closely matches rather than the ones that share the most words. A single API key is the only external dependency.
## What one build covers
- Generates and stores OpenAI embeddings for published posts and pages when content is saved or updated
- Replaces WordPress search results with posts ranked by cosine similarity to the search query
- Admin settings page at Settings > Semantic Search with API key field and one-click bulk re-index button
- Falls back to WordPress keyword search if the API call fails, so search always returns something
## What it does not cover
- WooCommerce products: targets posts and pages only; product search requires additional hooks
- Large sites: cosine similarity runs in PHP over all indexed posts, which is slow past a few thousand
- Pagination: returns a fixed top ten results; found_posts is not updated for multi-page result sets
- Custom fields or ACF data: embeddings are generated from post title and post content only
- Result caching: each search calls the OpenAI API in real time, so repeated identical queries each incur an API call
## The prompt
```text
Build a WordPress plugin called WP Semantic Search. Plugin Name: WP Semantic Search. Version: 1.0.0. Description: Replace WordPress keyword search with OpenAI embedding-based semantic search. Wrap all code in a class WP_Semantic_Search with a public init() method. Instantiate the class and call init() on plugins_loaded.
Settings page: In init(), add_action('admin_menu', [$this, 'add_settings_page']). Register the page using add_options_page('Semantic Search', 'Semantic Search', 'manage_options', 'semantic-search', [$this, 'render_settings_page']). In init(), call register_setting('wp_semantic_search', 'wp_semantic_search_options'), add_settings_section('main', '', null, 'semantic-search'), and add_settings_field('api_key', 'OpenAI API Key', [$this, 'render_api_key_field'], 'semantic-search', 'main'). render_api_key_field(): $opts = get_option('wp_semantic_search_options', []); echo ''; render_settings_page(): outputs a div.wrap with h1 'Semantic Search', a form with method post, settings_fields('wp_semantic_search'), do_settings_sections('semantic-search'), submit_button(), closing form. Then a second form with method post, wp_nonce_field('semantic_reindex'), hidden input name=semantic_reindex value=1, submit_button('Re-index all published posts and pages', 'secondary'). Show a notice if $_GET['reindexed'] is set: '
'.
Generating embeddings: In init(), add_action('save_post', [$this, 'index_post'], 10, 3). In index_post($post_id, $post, $update): return early if wp_is_post_revision($post_id), if $post->post_status !== 'publish', or if !in_array($post->post_type, ['post', 'page']). $text = substr($post->post_title . "\n" . wp_strip_all_tags($post->post_content), 0, 8000). $embedding = $this->get_embedding($text). If !empty($embedding), update_post_meta($post_id, '_semantic_embedding', $embedding).
In get_embedding(string $text): array: $opts = get_option('wp_semantic_search_options', []); $key = $opts['api_key'] ?? ''; if (empty($key)) return []; $resp = wp_remote_post('https://api.openai.com/v1/embeddings', ['headers' => ['Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $key], 'body' => wp_json_encode(['model' => 'text-embedding-3-small', 'input' => $text]), 'timeout' => 20]); if (is_wp_error($resp)) return []; $body = json_decode(wp_remote_retrieve_body($resp), true); return $body['data'][0]['embedding'] ?? [];
Re-index action: In init(), add_action('admin_init', [$this, 'handle_reindex']). In handle_reindex(): if (empty($_POST['semantic_reindex'])) return; check_admin_referer('semantic_reindex'); $posts = get_posts(['post_type' => ['post', 'page'], 'posts_per_page' => -1, 'post_status' => 'publish']); $n = 0; foreach ($posts as $post) { $text = substr($post->post_title . "\n" . wp_strip_all_tags($post->post_content), 0, 8000); $emb = $this->get_embedding($text); if (!empty($emb)) { update_post_meta($post->ID, '_semantic_embedding', $emb); $n++; } } wp_redirect(add_query_arg(['page' => 'semantic-search', 'reindexed' => $n], admin_url('options-general.php'))); exit;
Search override: In init(), add_filter('posts_pre_query', [$this, 'semantic_search'], 10, 2). In semantic_search($posts, WP_Query $query): if (!$query->is_main_query() || !$query->is_search() || is_admin()) return $posts; $s = trim($query->get('s')); if (empty($s)) return $posts; $qv = $this->get_embedding($s); if (empty($qv)) return $posts; $ids = get_posts(['post_type' => ['post', 'page'], 'posts_per_page' => -1, 'post_status' => 'publish', 'meta_key' => '_semantic_embedding', 'fields' => 'ids', 'no_found_rows' => true]); if (empty($ids)) return $posts; $scores = []; foreach ($ids as $id) { $emb = get_post_meta($id, '_semantic_embedding', true); if (is_array($emb) && count($emb)) $scores[$id] = $this->cosine_similarity($qv, $emb); } if (empty($scores)) return $posts; arsort($scores); $results = []; foreach (array_slice(array_keys($scores), 0, 10) as $id) { $p = get_post($id); if ($p) $results[] = $p; } return $results;
In cosine_similarity(array $a, array $b): float: $dot = $na = $nb = 0.0; for ($i = 0, $len = count($a); $i < $len; $i++) { $ai = $a[$i]; $bi = $b[$i] ?? 0.0; $dot += $ai * $bi; $na += $ai * $ai; $nb += $bi * $bi; } return ($na && $nb) ? $dot / (sqrt($na) * sqrt($nb)) : 0.0;
No custom database table, no JavaScript, no stylesheet.
```
## Why keyword search struggles at scale
The default WordPress search runs SQL LIKE queries on post_title and post_content. A match requires the search term to appear literally in the text. MySQL full-text search, which Relevanssi uses, adds stemming and relevance scoring but the fundamental model is the same: terms in the query must appear in the document.
This works for navigational searches where someone types the exact name of something. It fails for conceptual queries: a visitor searching for 'refund policy' will miss a page titled 'returns and cancellations'; someone searching for 'setup guide' will miss a post tagged 'getting started'. On a site with a few dozen posts you can work around this with careful titling and tagging. On a site with thousands of posts, you cannot cover every combination of how someone might phrase the same question.
Embedding-based search solves this at the model level rather than through manual synonym management. The OpenAI text-embedding-3-small model converts text to a point in a 1,536-dimensional space where semantically similar text lands near each other. A search for 'refund policy' and a post about 'returns and cancellations' will be close in that space even though they share no words.
## How embeddings work in this plugin
The OpenAI embeddings API takes a string and returns an array of 1,536 floating-point numbers. Two pieces of text that mean similar things produce arrays that are numerically close, measured by cosine similarity: the cosine of the angle between the two vectors in that high-dimensional space. A similarity score near 1.0 means closely related meaning; near 0.0 means unrelated.
The plugin stores each post's embedding as serialized post meta under the key _semantic_embedding. When a visitor searches, it calls the same API with the search string to produce a query embedding, then loops over every stored embedding and computes similarity. The posts are returned in descending similarity order, bypassing the SQL keyword search entirely.
The model used is text-embedding-3-small. It costs roughly $0.02 per million tokens. A 500-word post is around 700 tokens. Indexing 1,000 posts costs under a cent; each search query is a single short API call costing a fraction of a cent. For a site that does not serve millions of searches per day, the API cost is negligible.
## What the plugin builds
The plugin hooks into save_post to generate and store an embedding whenever a post or page is published or updated. The settings page at Settings > Semantic Search has a single field for your OpenAI API key and a button to bulk re-index all existing published content. The re-index button is needed once after activation, before any embeddings exist for previously published posts.
On the front end, the plugin intercepts the main WordPress search query using the posts_pre_query filter before it hits the database. It gets the embedding for whatever the visitor typed, computes cosine similarity against all stored embeddings, and returns the top ten posts as WP_Post objects. These flow into your theme's search.php template the same way keyword results would.
There is no custom database table, no JavaScript, and no external service beyond OpenAI. The embeddings live in WordPress's standard post meta table and survive theme switches, caching plugin changes, and hosting migrations.
## Limits of this build
The similarity scoring loop runs in PHP and touches every stored embedding on each search request. For a site with a few hundred posts this is fast. For a site with tens of thousands of posts it will be slow, and beyond that it may time out on shared hosting. If you have more than a few thousand indexed posts, a search service with native vector indexing such as Typesense or Algolia is the right tool.
The plugin targets posts and pages only. WooCommerce products use a different post type and their search has its own hooks; the initial build does not include them. Custom post types registered by other plugins are also excluded. Extending to additional post types requires adding them to the post_type array in two places in the code.
The build returns a fixed top ten results with no pagination. The WordPress found_posts count will not reflect the full result set, so themes that show a result count or paginate search results will display incorrect numbers. If the OpenAI API call fails, the plugin falls back to WordPress's standard keyword search rather than returning nothing.
## When to use a dedicated search service instead
For sites where search performance matters at scale, a service that stores embeddings in a vector database and queries them with approximate nearest-neighbour search is the right choice. Typesense supports hybrid search combining keyword and semantic ranking, and has a self-hosted option. Algolia has a WordPress connector plugin and handles high query volumes. Both cost money above a free tier and require integration work beyond a single plugin file.
SearchWP is the most widely used paid WordPress search plugin and has a semantic search add-on that runs embeddings through a managed cloud service. If you are already using SearchWP for its other features, the add-on is a simpler path than building from scratch.
For most content-heavy sites where the problem is result quality rather than query volume, the plugin described here is a practical starting point. Paste the prompt into Steem to build it. To extend it to WooCommerce products, the follow-up is: 'Extend WP Semantic Search to index WooCommerce products: add product to the post_type array in both the save_post hook and the search pre-query, and include the product short description concatenated to the post content when building the embedding text.'
## Questions
### Does this add ongoing API costs?
Yes, but they are small. OpenAI charges per token at both index time and query time. A 500-word post is around 700 tokens and costs a fraction of a cent to embed. Each search query is one short API call. For a site with thousands of posts and moderate traffic the monthly cost is typically under a dollar. High-traffic sites should review OpenAI pricing before relying on per-query API calls.
### What happens to posts that existed before the plugin was installed?
They are not indexed automatically. Use the Re-index button in Settings > Semantic Search to process all existing published posts and pages. The button runs synchronously, so on sites with many posts it may take a minute or two before the page redirects back.
### Will this conflict with Relevanssi or SearchWP?
Yes. All three override the WordPress search hooks. Run only one at a time. Deactivate Relevanssi or SearchWP before activating this plugin.
### Does it search custom fields or ACF data?
Not in the initial build. The embedding is generated from the post title and stripped post_content only. Custom field values are not included. To add them, modify the $text variable in index_post to concatenate the field values you want indexed.
### What happens if the OpenAI API call fails during a search?
The plugin returns null from the filter, which causes WordPress to fall back to its standard keyword search. If you see keyword results instead of semantic ones, check your API key in Settings > Semantic Search and confirm the key has not hit its usage limit.
---
Source: https://steem.dev/blog/wordpress-semantic-search-plugin
Build this plugin: https://steem.dev/app