similarity-against-current-query (v1.26.0).
* Reset at the start of each filter_search_results call so terms shared
* across many posts in the same batch are scored exactly once.
*/
private $term_sim_cache = [];
/**
* Whether search logging is enabled (v1.31.0).
*/
private $logging_enabled;
/**
* Search to write on shutdown, or null. Collected during the search so
* the request itself never waits on a database write (v1.31.0).
*
* @var array|null
*/
private $pending_log = null;
/**
* Details of the last embedding failure from get_embedding(), or null.
* Shape: [ 'code' => string, 'message' => string, 'status' => int|null ].
*
* @since 1.27.0
* @var array|null
*/
private $last_embedding_error = null;
/**
* Maximum posts to process per search (for performance on large sites).
*/
private $max_posts_to_process = 2000;
/**
* Batch size for processing posts (memory optimization).
*/
private $batch_size = 100;
/**
* Initialize the plugin.
*/
private function __construct() {
$this->api_key = get_option( 'ai_search_api_key', '' );
$this->similarity_threshold = get_option( 'ai_search_similarity_threshold', 0.30 );
$this->provider = get_option( 'ai_search_provider', 'ai_service' );
$this->service_token = get_option( 'ai_search_service_token', '' );
$this->service_client = new AI_Search_Service();
// v1.26.0: per-term semantic embeddings. Default ON only on Woo stores.
$default_term_enabled = class_exists( 'WooCommerce' );
$this->term_embeddings_enabled = (bool) get_option( 'ai_search_term_embeddings_enabled', $default_term_enabled );
$this->term_weight = (int) get_option( 'ai_search_term_weight', 20 );
$this->disabled_taxonomies = (array) get_option( 'ai_search_disabled_taxonomies', [] );
// v1.31.0: search logging (Insights tab).
$this->logging_enabled = (bool) get_option( 'ai_search_logging_enabled', true );
$this->register_hooks();
// Initialize REST API
new AI_Search_REST_API();
// v1.33.0: opt-in search box block. Registering it costs nothing
// on sites that never place it - no scripts are enqueued unless
// the block is on the page.
add_action( 'init', [ $this, 'register_blocks' ] );
// Initialize admin interface if in admin area
if ( is_admin() ) {
$this->admin_manager = new AI_Search_Admin_Manager();
}
}
/**
* Register the plugin's editor blocks.
*
* @since 1.33.0
*/
public function register_blocks() {
if ( ! function_exists( 'register_block_type' ) ) {
return;
}
// The overlay renders the same result list as the box, so the box's
// stylesheet is registered under its own handle and listed as a
// second style on the button block. Duplicating the rules in both
// files means fixing every dropdown tweak twice.
wp_register_style(
'ai-search-box',
AI_SEARCH_URL . 'blocks/search-box/style.css',
[],
self::VERSION
);
register_block_type( AI_SEARCH_PATH . 'blocks/search-box' );
register_block_type( AI_SEARCH_PATH . 'blocks/search-button' );
}
/**
* Get the singleton instance.
*
* @return AI_Search
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Get admin manager instance
*/
public function get_admin_manager() {
return $this->admin_manager;
}
/**
* Register hooks.
*/
private function register_hooks() {
// Load text domain for translations
add_action( 'init', [ $this, 'load_textdomain' ] );
// Core functionality hooks
// Generate embedding automatically when post is published
add_action( 'transition_post_status', [ $this, 'generate_embedding_on_publish' ], 10, 3 );
// v1.43.0: cron target for async publish-hook embedding.
add_action( 'ai_search_cron_generate_embedding', [ $this, 'cron_generate_embedding' ], 10, 1 );
// v1.43.0: bust the Sync-with-AI tab's index-status transient when
// the corpus actually changes. Transient TTL is 1h as a safety net.
add_action( 'transition_post_status', [ $this, 'bust_index_status_cache' ], 99 );
add_action( 'deleted_post', [ $this, 'bust_index_status_cache' ], 99 );
add_filter( 'posts_results', [ $this, 'filter_search_results' ], 10, 2 );
// Fast Search (Pro tier, 1.42.0+, opt-in). Priority 20 on
// transition_post_status keeps the embedding write ahead of
// the cloud upsert so the payload always has fresh vector data.
add_action( 'transition_post_status', [ 'AI_Search_Fast_Search', 'on_publish' ], 20, 3 );
add_action( 'delete_post', [ 'AI_Search_Fast_Search', 'on_delete' ], 20, 1 );
// v1.26.0: keep term embeddings in sync with taxonomy edits.
add_action( 'created_term', [ $this, 'on_term_created' ], 10, 3 );
add_action( 'edited_term', [ $this, 'on_term_edited' ], 10, 3 );
add_action( 'delete_term', [ $this, 'on_term_deleted' ], 10, 4 );
// Add similarity class to search results
add_filter( 'post_class', [ $this, 'add_similarity_class' ], 10, 3 );
// Add AI Search badge to results
add_filter( 'the_title', [ $this, 'add_ai_search_badge' ], 10, 2 );
add_action( 'wp_head', [ $this, 'add_ai_search_badge_styles' ] );
// AJAX handlers.
// The frontend threshold slider that used to appear on search
// results pages for admins is gone in 1.38.0 — the Test search
// tab in wp-admin (also 1.38.0) is the safer place to tune
// thresholds. Removed hook: `wp_footer add_frontend_threshold_slider`.
// Removed AJAX: `wp_ajax_ai_search_update_threshold`.
add_action( 'wp_ajax_ai_search_clear_feedback', [ $this, 'clear_search_feedback' ] );
add_action( 'wp_ajax_nopriv_ai_search_clear_feedback', [ $this, 'clear_search_feedback' ] );
// v1.31.0: search logging. The write happens on shutdown, after the
// response has been sent, so a search never waits on the database.
add_action( 'shutdown', [ $this, 'flush_search_log' ] );
add_action( 'plugins_loaded', [ $this, 'maybe_create_log_table' ] );
add_action( 'ai_search_purge_logs', [ $this, 'purge_old_logs' ] );
// v1.44.1 security migration: force autoload=no on secret options
// (`ai_search_api_key`, `ai_search_service_token`) so they are no
// longer loaded into $wp_options' shared alloptions cache on every
// request. Older installs had them with autoload=yes by default.
add_action( 'plugins_loaded', [ $this, 'maybe_disable_secret_autoload' ] );
// v1.45.0: schedule the daily cron that migrates v1 (JSON 3072-dim)
// post vectors into v2 (packed binary 1024-dim). Fully background,
// zero wp-admin / frontend impact — the migration runs in the cron
// spawn, not any user-facing request. The UI card on the General
// tab is still available for admins who want to force completion.
add_action( 'plugins_loaded', [ $this, 'maybe_boot_v2_migration' ] );
add_action( AI_Search_Vector_Migration::CRON_HOOK, [ 'AI_Search_Vector_Migration', 'cron_tick' ] );
// Admin hooks are now handled by AI_Search_Admin_Manager
}
/**
* v1.45.0 — on every boot, decide whether the migration cron needs to
* be scheduled. Idempotent: `ensure_scheduled()` unschedules once v2
* is active and skips if there are no v1 rows left to migrate. New
* installs on 1.45.0+ never see this cron because they never had v1
* data in the first place.
*/
public function maybe_boot_v2_migration() {
if ( class_exists( 'AI_Search_Vector_Migration' ) ) {
AI_Search_Vector_Migration::ensure_scheduled();
}
}
/**
* Load plugin text domain for translations.
*/
public function load_textdomain() {
load_plugin_textdomain(
'ai-search',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
}
/**
* Generate embedding when post transitions to publish status
*
* @param string $new_status New post status.
* @param string $old_status Old post status.
* @param WP_Post $post Post object.
*/
public function generate_embedding_on_publish( $new_status, $old_status, $post ) {
// Only generate when transitioning TO publish status
if ( 'publish' !== $new_status ) {
return;
}
// Don't generate for revisions or auto-saves
if ( wp_is_post_revision( $post->ID ) || wp_is_post_autosave( $post->ID ) ) {
return;
}
$post_id = (int) $post->ID;
/**
* Return false to force the embedding to run synchronously inside
* the publish request (legacy v1.42.x behaviour). Default true in
* v1.43.0+: the embedding is queued via wp_schedule_single_event
* so the admin's Save/Update returns immediately. The embedding
* runs on the next WP cron tick, typically within seconds — more
* than fast enough for search coverage, far better for admin UX
* on shared hosts where the service call used to add 500ms-2s to
* every post save.
*
* @since 1.43.0
*
* @param bool $async Whether to schedule the embedding.
* @param int $post_id Post ID being published.
*/
$async = (bool) apply_filters( 'ai_search_async_publish_embedding', true, $post_id );
if ( $async ) {
if ( ! wp_next_scheduled( 'ai_search_cron_generate_embedding', [ $post_id ] ) ) {
wp_schedule_single_event( time(), 'ai_search_cron_generate_embedding', [ $post_id ] );
}
return;
}
$this->generate_embedding( $post_id );
}
/**
* Cron handler for the deferred publish-hook embedding (v1.43.0).
* Wired in init() so `wp_schedule_single_event` has a target.
*
* @since 1.43.0
*
* @param int $post_id Post id captured when the publish event was queued.
*/
public function cron_generate_embedding( $post_id ) {
$this->generate_embedding( (int) $post_id );
}
/**
* Invalidate the Sync-with-AI tab's index-status transient (v1.43.0).
* Wired to `transition_post_status` + `deleted_post` so that the next
* admin page load regenerates the counts from fresh data.
*
* @since 1.43.0
*/
public function bust_index_status_cache() {
delete_transient( 'ai_search_index_status_v1' );
}
// ----------------------------------------------------------------
// v1.43.0: plan-tier gating helpers used by paid-only settings UI.
// ----------------------------------------------------------------
/**
* Resolve the current site's plan key from the AI Search Service
* quota data. Cached statically for the duration of the request —
* the quota manager itself transient-caches 6h, so this adds no
* network cost.
*
* @since 1.43.0
* @return string 'free' | 'standard' | 'pro'
*/
public static function current_plan_key() {
static $cached = null;
if ( null !== $cached ) {
return $cached;
}
$cached = 'free';
if ( class_exists( 'AI_Search_Quota_Manager' ) ) {
$qm = new AI_Search_Quota_Manager();
$quota = $qm->get_quota_data();
if ( is_array( $quota ) && ! empty( $quota['plan']['key'] ) ) {
$cached = strtolower( (string) $quota['plan']['key'] );
}
}
return $cached;
}
/**
* True when the current plan is >= $required ('standard' or 'pro').
* Filterable via `ai_search_plan_meets` so dev / beta flows can flip
* individual features without changing the live quota data.
*
* @since 1.43.0
*
* @param string $required 'standard' | 'pro'
* @return bool
*/
public static function plan_meets( $required ) {
$hierarchy = [ 'free' => 0, 'standard' => 1, 'pro' => 2 ];
$current = self::current_plan_key();
$cur_rank = isset( $hierarchy[ $current ] ) ? $hierarchy[ $current ] : 0;
$need_rank = isset( $hierarchy[ strtolower( (string) $required ) ] ) ? $hierarchy[ strtolower( (string) $required ) ] : 0;
return (bool) apply_filters( 'ai_search_plan_meets', $cur_rank >= $need_rank, $required, $current );
}
/**
* Render the "Available on Standard — Upgrade" badge next to a
* locked setting. Echoes by default; pass echo=false to return.
*
* @since 1.43.0
*
* @param string $required 'standard' | 'pro'
* @param array $args { echo?: bool, label?: string, url?: string }
* @return void|string
*/
/**
* True when Smart embeddings should be used on content-side embedding
* calls (publish hook + bulk sync). Both conditions must hold: the
* merchant flipped the toggle AND the plan allows the feature.
* Filterable via `ai_search_smart_embeddings_enabled` for dev / beta.
*
* @since 1.43.0
* @return bool
*/
public static function is_smart_embeddings_enabled() {
$default = (bool) get_option( 'ai_search_smart_embeddings', false ) && self::plan_meets( 'standard' );
return (bool) apply_filters( 'ai_search_smart_embeddings_enabled', $default );
}
public static function feature_lock_badge( $required = 'standard', $args = [] ) {
$args = wp_parse_args( $args, [
'echo' => true,
'label' => '',
'url' => 'https://wp-search.ai/pricing/?utm_source=plugin&utm_medium=badge&utm_campaign=feature_lock',
] );
if ( '' === $args['label'] ) {
$args['label'] = sprintf(
/* translators: %s: plan name, e.g. "Standard" */
__( 'Available on %s — Upgrade', 'ai-search' ),
ucfirst( strtolower( (string) $required ) )
);
}
$html = sprintf(
'%s',
esc_url( $args['url'] ),
esc_html( $args['label'] )
);
if ( $args['echo'] ) {
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- url + label escaped above.
return;
}
return $html;
}
/**
* Generate embedding for a post and save it to post meta.
*
* @param int $post_id Post ID.
* @return true|WP_Error True when an embedding was stored; WP_Error
* describing why not (since 1.27.0). Codes:
* `revision`, `not_found`, `not_published`,
* `skipped_by_filter`, `empty_content`, or any
* provider code from get_last_embedding_error().
*/
public function generate_embedding( $post_id ) {
if ( wp_is_post_revision( $post_id ) ) {
return new WP_Error( 'revision', __( 'Revisions are not embedded.', 'ai-search' ) );
}
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'not_found', __( 'Post not found.', 'ai-search' ) );
}
if ( 'publish' !== $post->post_status ) {
return new WP_Error( 'not_published', __( 'Only published posts are embedded (drafts, pending and private posts are skipped).', 'ai-search' ) );
}
/**
* Filter whether to generate an embedding for a given post.
*
* Return false to skip embedding generation — useful for excluding
* drafts in certain categories, posts tagged "noindex", or posts
* under a minimum word count.
*
* @since 1.25.0
*
* @param bool $should_generate Whether to generate the embedding. Default true.
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
*/
if ( ! apply_filters( 'ai_search_should_generate_embedding', true, $post_id, $post ) ) {
return new WP_Error( 'skipped_by_filter', __( 'Skipped by the ai_search_should_generate_embedding filter.', 'ai-search' ) );
}
// Sanitize post title and content
$post_title = sanitize_text_field( $post->post_title );
// Extract plain text from content (remove HTML, shortcodes, etc.)
$post_content = wp_strip_all_tags( strip_shortcodes( $post->post_content ) );
// Remove extra whitespace and normalize
$post_content = preg_replace( '/\s+/', ' ', trim( $post_content ) );
// Get custom fields for this post type
$custom_fields_content = $this->get_custom_fields_content( $post_id, $post->post_type );
// Combine context with content for embedding and convert to lowercase for case-insensitive search
$content = $post_title . ' ' . $post_content . ' ' . $custom_fields_content;
/**
* Filter the text that will be sent to the AI provider for embedding.
*
* Useful for injecting taxonomy names, SEO keywords, stripping
* boilerplate, or normalizing terminology before the vector is
* computed.
*
* @since 1.25.0
*
* @param string $content The combined embedding content (title + body + custom fields).
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
*/
$content = apply_filters( 'ai_search_embedding_content', $content, $post_id, $post );
$content = strtolower( $content );
if ( trim( $content ) === '' ) {
return new WP_Error( 'empty_content', __( 'The post has no text to embed (empty title, content and custom fields).', 'ai-search' ) );
}
$embedding = $this->get_embedding( $content );
if ( $embedding ) {
// v1.41.1: round each dimension to 4 decimal places before
// serialising. Cosine similarity between 4-decimal and full-
// precision vectors differs by <0.0001, which is far below the
// typical 0.3 similarity threshold - imperceptible for ranking.
// But the resulting JSON is ~30% smaller, so both MySQL data
// transfer and PHP json_decode() on the hot search path get a
// proportional speedup. Backward compat: old JSON with 6+
// decimals decodes exactly as before, no migration needed.
$rounded = array_map( function( $f ) { return round( (float) $f, 4 ); }, $embedding );
update_post_meta( $post_id, '_ai_search_embedding', wp_json_encode( $rounded ) );
// v1.35.0: record which model produced this vector + when.
// Inferred from the vector dimension (1536 = small, 3072 = large);
// this is 100% reliable, no dependency on quota state. Used by the
// post meta box display and by the "regenerate on current model"
// bulk-generation option.
$model = self::infer_embedding_model( $embedding );
if ( $model ) {
update_post_meta( $post_id, '_ai_search_embedding_model', $model );
} else {
delete_post_meta( $post_id, '_ai_search_embedding_model' );
}
update_post_meta( $post_id, '_ai_search_embedding_generated_at', time() );
return true;
}
$error = $this->get_last_embedding_error();
return new WP_Error(
$error['code'] ?? 'provider_error',
$error['message'] ?? __( 'The AI provider did not return an embedding.', 'ai-search' ),
[ 'status' => $error['status'] ?? null ]
);
}
/**
* Bulk-embed a set of posts with ONE service round-trip.
*
* On the ai_service provider (Free/Standard/Pro), the batch is sent
* as a single POST /embeddings with `{ texts: [...] }`, which cuts
* a 5000-post sync from thousands of HTTP calls to hundreds. On the
* openai provider we degrade to a single-text loop — OpenAI's own
* batch is a valid target for a future release, but there is no
* real-world demand today.
*
* The pre-flight (revision/publish/filter/text-build) and the store
* step (round + meta writes) are byte-identical to generate_embedding()
* so any post that survived the singular path survives here too.
*
* @since 1.42.0
*
* @param int[] $post_ids Posts to embed.
* @return array Result per post_id, in input order.
*/
public function generate_embeddings_batch( array $post_ids ) {
$results = [];
$to_embed = []; // post_id => prepared text
// Phase 1: pre-flight every post. Same filters, same text-build
// logic as generate_embedding(). A WP_Error here is a per-post
// problem (revision, private, empty, filter veto) and never
// aborts the batch — the other posts still ship.
foreach ( $post_ids as $post_id ) {
$post_id = (int) $post_id;
$prep = $this->prepare_embedding_text( $post_id );
if ( is_wp_error( $prep ) ) {
$results[ $post_id ] = $prep;
continue;
}
$to_embed[ $post_id ] = $prep;
}
if ( empty( $to_embed ) ) {
return $results;
}
// Phase 2: OpenAI-direct provider stays on the singular path —
// one HTTP call per text. Rare in practice (most sites use the
// AI Search Service) and keeps the OpenAI-key edge case simple.
if ( 'ai_service' !== $this->provider ) {
foreach ( $to_embed as $pid => $text ) {
$vec = $this->get_embedding( $text );
if ( ! $vec ) {
$err = $this->get_last_embedding_error();
$results[ $pid ] = new WP_Error(
$err['code'] ?? 'provider_error',
$err['message'] ?? __( 'The AI provider did not return an embedding.', 'ai-search' ),
[ 'status' => $err['status'] ?? null ]
);
continue;
}
$this->store_embedding_meta( $pid, $vec );
$results[ $pid ] = true;
}
return $results;
}
// Phase 3: AI Search Service path. One HTTP call for the whole
// batch. If the call fails at the network / auth / quota level,
// every post in the batch inherits the same WP_Error — the
// caller sees the same fatal_code it would have seen on the
// singular path and can stop the run early instead of retrying
// 20× for the same underlying reason.
$this->last_embedding_error = null;
// v1.43.0: route product content through /smart-embed when the
// merchant has Smart embeddings on AND their plan supports it.
// Search queries never go through smart (they live on the other
// path, which keeps the search hot path lean).
$this->service_client->set_smart_mode( self::is_smart_embeddings_enabled() );
$vectors = $this->service_client->get_embeddings_batch( array_values( $to_embed ) );
$this->service_client->set_smart_mode( false );
if ( false === $vectors ) {
$svc_err = method_exists( $this->service_client, 'get_last_error' ) ? $this->service_client->get_last_error() : null;
$err = new WP_Error(
$svc_err['code'] ?? 'service_error',
$svc_err['message'] ?? __( 'The AI Search Service did not return an embedding.', 'ai-search' ),
[ 'status' => $svc_err['status'] ?? null ]
);
// Mirror onto the shared last_embedding_error so
// get_last_embedding_error() reports the same detail the
// singular path would.
if ( is_array( $svc_err ) ) {
$this->set_embedding_error(
$svc_err['code'] ?? 'service_error',
$svc_err['message'] ?? __( 'Batch embedding failed.', 'ai-search' ),
$svc_err['status'] ?? null
);
}
foreach ( $to_embed as $pid => $_ ) {
$results[ $pid ] = $err;
}
return $results;
}
// Phase 4: walk the returned map (indexed by position within
// array_values($to_embed)) and store each vector against its
// original post_id. Any position missing from the map (edge:
// service dropped a text) is reported as invalid_response for
// that post, not the whole batch.
$original_pids = array_keys( $to_embed );
foreach ( $original_pids as $pos => $pid ) {
if ( ! isset( $vectors[ $pos ] ) || ! is_array( $vectors[ $pos ] ) ) {
$results[ $pid ] = new WP_Error( 'invalid_response', __( 'No embedding returned for this post.', 'ai-search' ) );
continue;
}
$this->store_embedding_meta( $pid, $vectors[ $pos ] );
$results[ $pid ] = true;
}
return $results;
}
/**
* Pre-flight + text build for a post, extracted from generate_embedding()
* so the batch path can reuse it. Returns the string ready to be sent
* to the provider, or a WP_Error explaining why this post is not
* embeddable (revision, private, filter veto, empty content).
*
* NOT called by generate_embedding() itself in v1.42.0 — the singular
* path stays byte-identical to v1.41.1 to avoid any regression in the
* publish hook, which fires on every post save. A later cleanup can
* dedupe once this batch path has run through a release cycle.
*
* @since 1.42.0
*
* @param int $post_id
* @return string|WP_Error
*/
private function prepare_embedding_text( $post_id ) {
if ( wp_is_post_revision( $post_id ) ) {
return new WP_Error( 'revision', __( 'Revisions are not embedded.', 'ai-search' ) );
}
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'not_found', __( 'Post not found.', 'ai-search' ) );
}
if ( 'publish' !== $post->post_status ) {
return new WP_Error( 'not_published', __( 'Only published posts are embedded (drafts, pending and private posts are skipped).', 'ai-search' ) );
}
if ( ! apply_filters( 'ai_search_should_generate_embedding', true, $post_id, $post ) ) {
return new WP_Error( 'skipped_by_filter', __( 'Skipped by the ai_search_should_generate_embedding filter.', 'ai-search' ) );
}
$post_title = sanitize_text_field( $post->post_title );
$post_content = wp_strip_all_tags( strip_shortcodes( $post->post_content ) );
$post_content = preg_replace( '/\s+/', ' ', trim( $post_content ) );
$custom_fields_content = $this->get_custom_fields_content( $post_id, $post->post_type );
$content = $post_title . ' ' . $post_content . ' ' . $custom_fields_content;
$content = apply_filters( 'ai_search_embedding_content', $content, $post_id, $post );
$content = strtolower( $content );
if ( trim( $content ) === '' ) {
return new WP_Error( 'empty_content', __( 'The post has no text to embed (empty title, content and custom fields).', 'ai-search' ) );
}
return $content;
}
/**
* Round + persist an embedding vector against a post_id. Mirrors the
* store step at the tail of generate_embedding().
*
* @since 1.42.0
*
* @param int $post_id
* @param array $embedding Raw vector as returned by the provider.
*/
private function store_embedding_meta( $post_id, array $embedding ) {
// v1.41.1: 4-decimal rounding shrinks stored JSON by ~30% with no
// measurable ranking impact.
$rounded = array_map( function ( $f ) { return round( (float) $f, 4 ); }, $embedding );
update_post_meta( $post_id, '_ai_search_embedding', wp_json_encode( $rounded ) );
// v1.45.0: once the merchant is on v2 reads, also write the packed
// binary postmeta so new publishes are indexed in the format the
// search hot path reads. v1 is intentionally kept as a dual-write
// (safety net): a merchant who rolls back to format version '1'
// loses nothing published while on '2'. The v1 write cost is tiny
// (~1ms) and we drop it in a future release once v2 has settled.
if ( class_exists( 'AI_Search_Vector_Migration' )
&& AI_Search_Vector_Migration::is_v2_active() ) {
$truncated = AI_Search_Vector_Migration::truncate_and_normalize(
$rounded,
AI_Search_Vector_Migration::TARGET_DIM
);
if ( ! empty( $truncated ) ) {
update_post_meta(
$post_id,
AI_Search_Vector_Migration::META_KEY_V2,
AI_Search_Vector_Migration::pack_vector( $truncated )
);
}
}
$model = self::infer_embedding_model( $embedding );
if ( $model ) {
update_post_meta( $post_id, '_ai_search_embedding_model', $model );
} else {
delete_post_meta( $post_id, '_ai_search_embedding_model' );
}
update_post_meta( $post_id, '_ai_search_embedding_generated_at', time() );
}
/**
* Infer which embedding model produced a vector by its dimension count.
*
* OpenAI v3 embeddings: 1536 dims = text-embedding-3-small,
* 3072 dims = text-embedding-3-large. Any other length is treated as
* unknown and returns null. Doing this from the vector length is 100%
* reliable, whereas resolving from the plan/quota state would be stale
* for a post that was embedded before an upgrade or during a network
* blip.
*
* @since 1.35.0
*
* @param array|mixed $embedding The stored embedding vector (array of floats).
* @return string|null 'text-embedding-3-small' | 'text-embedding-3-large' | null.
*/
public static function infer_embedding_model( $embedding ) {
$dims = is_array( $embedding ) ? count( $embedding ) : 0;
if ( $dims === 1536 ) {
return 'text-embedding-3-small';
}
if ( $dims === 3072 ) {
return 'text-embedding-3-large';
}
return null;
}
/**
* Details of the last get_embedding() failure.
*
* @since 1.27.0
*
* @return array|null [ 'code', 'message', 'status' ] or null if the last call succeeded.
*/
public function get_last_embedding_error() {
return $this->last_embedding_error;
}
/**
* Record an embedding failure for callers to surface.
*
* @since 1.27.0
*/
private function set_embedding_error( $code, $message, $status = null ) {
$this->last_embedding_error = [
'code' => (string) $code,
'message' => (string) $message,
'status' => $status !== null ? (int) $status : null,
];
}
/**
* Generate an embedding for a taxonomy term and save it as term meta.
*
* Term embeddings let the search compare the query against each term
* individually (e.g. the "Sneakers" tag's own vector) instead of only
* the post's bulk content embedding — meaning a query for "trainers"
* still surfaces products tagged "Sneakers".
*
* @since 1.26.0
*
* @param int $term_id Term ID.
* @param string $taxonomy Taxonomy slug.
* @return bool True on success, false if disabled / skipped / failed.
*/
public function generate_term_embedding( $term_id, $taxonomy ) {
if ( ! $this->term_embeddings_enabled ) {
return false;
}
if ( in_array( $taxonomy, $this->disabled_taxonomies, true ) ) {
return false;
}
$term = get_term( $term_id, $taxonomy );
if ( ! $term || is_wp_error( $term ) ) {
return false;
}
$content = $this->get_term_embedding_content( $term );
if ( '' === $content ) {
return false;
}
$embedding = $this->get_embedding( $content );
if ( ! $embedding ) {
return false;
}
update_term_meta( $term_id, '_ai_search_term_embedding', wp_json_encode( $embedding ) );
return true;
}
/**
* Build the text that gets embedded for a taxonomy term.
*
* Includes the taxonomy label as a hint so cross-taxonomy collisions
* (e.g. a "Red" category vs. a "Red" attribute) get disambiguated in
* the vector space.
*
* @since 1.26.0
*
* @param WP_Term $term Term object.
* @return string Text fed to the embedding provider.
*/
private function get_term_embedding_content( $term ) {
$taxonomy_obj = get_taxonomy( $term->taxonomy );
$tax_label = $taxonomy_obj ? $taxonomy_obj->labels->singular_name : $term->taxonomy;
$content = $tax_label . ': ' . $term->name;
$description = trim( wp_strip_all_tags( $term->description ) );
if ( '' !== $description ) {
$content .= ' — ' . $description;
}
/**
* Filter the text fed to the AI provider for a term embedding.
*
* Parallel to `ai_search_embedding_content` for posts. Useful for
* injecting synonym hints, parent-term names, or stripping
* boilerplate from term descriptions.
*
* @since 1.26.0
*
* @param string $content The default content (": — ").
* @param WP_Term $term The term being embedded.
* @param string $taxonomy Taxonomy slug.
*/
$content = apply_filters( 'ai_search_term_embedding_content', $content, $term, $term->taxonomy );
return strtolower( trim( $content ) );
}
/**
* Hook callback: generate an embedding when a new term is created.
*
* @since 1.26.0
*/
public function on_term_created( $term_id, $tt_id, $taxonomy ) {
$this->generate_term_embedding( $term_id, $taxonomy );
}
/**
* Hook callback: regenerate the embedding when a term is renamed or
* its description changes.
*
* @since 1.26.0
*/
public function on_term_edited( $term_id, $tt_id, $taxonomy ) {
$this->generate_term_embedding( $term_id, $taxonomy );
}
/**
* Hook callback: drop the stored embedding when a term is deleted.
*
* @since 1.26.0
*/
public function on_term_deleted( $term, $tt_id, $taxonomy, $deleted_term ) {
// $term here is the term_id (per `delete_term` action signature).
delete_term_meta( (int) $term, '_ai_search_term_embedding' );
}
/**
* Get custom fields content for a post.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
*
* @return string Custom fields content.
*/
private function get_custom_fields_content( $post_id, $post_type ) {
$content = '';
// Get selected custom fields for this post type
$selected_fields = get_option( 'ai_search_custom_fields_' . $post_type, [] );
if ( empty( $selected_fields ) || ! is_array( $selected_fields ) ) {
return $content;
}
// Get custom field values
foreach ( $selected_fields as $field_key ) {
$field_value = get_post_meta( $post_id, $field_key, true );
if ( ! empty( $field_value ) ) {
// Get field label (support ACF if available)
$field_label = $field_key;
// Try to get ACF field label
if ( function_exists( 'get_field_object' ) ) {
$field_object = get_field_object( $field_key, $post_id );
if ( $field_object && isset( $field_object['label'] ) ) {
$field_label = $field_object['label'];
}
}
// Handle different field value types
if ( is_array( $field_value ) ) {
// Handle ACF relationship, post object, or other array fields
$processed_values = [];
foreach ( $field_value as $item ) {
if ( is_object( $item ) && isset( $item->post_title ) ) {
// ACF Post Object or Relationship field
$processed_values[] = $item->post_title;
} elseif ( is_string( $item ) ) {
$processed_values[] = $item;
} elseif ( is_array( $item ) && isset( $item['label'] ) ) {
// ACF select/checkbox with labels
$processed_values[] = $item['label'];
} elseif ( is_array( $item ) && isset( $item['title'] ) ) {
// ACF image/file field
$processed_values[] = $item['title'];
}
}
if ( ! empty( $processed_values ) ) {
$field_value = implode( ' ', $processed_values );
} else {
continue; // Skip if we couldn't extract meaningful data
}
} elseif ( is_object( $field_value ) ) {
// Handle ACF User field or other object types
if ( isset( $field_value->display_name ) ) {
$field_value = $field_value->display_name;
} elseif ( isset( $field_value->post_title ) ) {
$field_value = $field_value->post_title;
} else {
continue; // Skip objects we can't process
}
}
// Sanitize and add to content (without field label)
if ( is_string( $field_value ) ) {
// Strip HTML and shortcodes from field value
$clean_value = wp_strip_all_tags( strip_shortcodes( $field_value ) );
$clean_value = preg_replace( '/\s+/', ' ', trim( $clean_value ) );
if ( ! empty( $clean_value ) ) {
$content .= ' ' . $clean_value;
}
}
}
}
// Handle WooCommerce products if applicable
if ( $post_type === 'product' && class_exists( 'WooCommerce' ) ) {
$woo_fields = get_option( 'ai_search_woocommerce_fields', self::default_woocommerce_fields() );
if ( ! empty( $woo_fields ) && is_array( $woo_fields ) ) {
$product = wc_get_product( $post_id );
if ( $product ) {
foreach ( $woo_fields as $woo_field ) {
switch ( $woo_field ) {
case 'product_description':
$desc = $product->get_description();
if ( ! empty( $desc ) ) {
// Strip HTML and shortcodes from description
$desc = wp_strip_all_tags( strip_shortcodes( $desc ) );
$desc = preg_replace( '/\s+/', ' ', trim( $desc ) );
if ( ! empty( $desc ) ) {
$content .= ' ' . $desc;
}
}
break;
case 'product_short_description':
$short_desc = $product->get_short_description();
if ( ! empty( $short_desc ) ) {
// Strip HTML and shortcodes from short description
$short_desc = wp_strip_all_tags( strip_shortcodes( $short_desc ) );
$short_desc = preg_replace( '/\s+/', ' ', trim( $short_desc ) );
if ( ! empty( $short_desc ) ) {
$content .= ' ' . $short_desc;
}
}
break;
case 'product_sku':
$sku = $product->get_sku();
if ( ! empty( $sku ) ) {
$content .= ' ' . sanitize_text_field( $sku );
}
break;
case 'product_categories':
$categories = wp_get_post_terms( $post_id, 'product_cat', [ 'fields' => 'names' ] );
if ( ! is_wp_error( $categories ) && ! empty( $categories ) ) {
$content .= ' ' . implode( ' ', array_map( 'sanitize_text_field', $categories ) );
}
break;
case 'product_tags':
$tags = wp_get_post_terms( $post_id, 'product_tag', [ 'fields' => 'names' ] );
if ( ! is_wp_error( $tags ) && ! empty( $tags ) ) {
$content .= ' ' . implode( ' ', array_map( 'sanitize_text_field', $tags ) );
}
break;
}
}
// Handle product attributes
$attributes = $product->get_attributes();
foreach ( $attributes as $attribute ) {
$attribute_key = 'attribute_' . $attribute->get_name();
if ( in_array( $attribute_key, $woo_fields ) ) {
$values = $attribute->get_options();
if ( ! empty( $values ) ) {
$attribute_values = [];
if ( is_array( $values ) ) {
foreach ( $values as $value ) {
$term = get_term( $value );
if ( $term && ! is_wp_error( $term ) ) {
$attribute_values[] = sanitize_text_field( $term->name );
}
}
} else {
$attribute_values[] = sanitize_text_field( $values );
}
if ( ! empty( $attribute_values ) ) {
$content .= ' ' . implode( ' ', $attribute_values );
}
}
}
}
}
}
}
return trim( $content );
}
/**
* Get embedding from OpenAI.
*
* @param string $content Content to embed.
*
* @return array|false Embedding data or false on failure.
*/
private function get_embedding( $content, $is_query = false ) {
$this->last_embedding_error = null;
$content = sanitize_textarea_field( $content );
// Check for Cached Embedding
$cached_embedding = get_transient( 'ai_search_embedding_' . md5( $content ) );
if ( $cached_embedding ) {
return $cached_embedding;
}
if ( $this->provider === 'ai_service' ) {
// Check if we have a service token first
$service_token = get_option( 'ai_search_service_token', '' );
if ( empty( $service_token ) ) {
$this->set_embedding_error( 'no_token', __( 'The site is not connected to the AI Search Service (no token). Open the General tab and run the connection check.', 'ai-search' ) );
return false; // No token available, service not registered
}
// v1.43.0: route product content (not search queries) through
// /smart-embed when the merchant opted in AND their plan allows.
$smart = ( ! $is_query ) && self::is_smart_embeddings_enabled();
$this->service_client->set_smart_mode( $smart );
// v1.43.3: tight 3s timeout on the search path so a stalled
// service never leaves a shopper watching a spinner — the
// caller handles `false` by falling through to Tier 0 + Tier 1
// (exact + keyword) + the enhanced fallback. Publish / bulk
// paths keep the generous 20s because they run async in cron
// and admin saves have no visitor waiting on them.
$timeout = $is_query
? (int) apply_filters( 'ai_search_query_embedding_timeout', 3 )
: (int) apply_filters( 'ai_search_publish_embedding_timeout', 20 );
$embedding = $this->service_client->get_embedding( $content, $timeout );
$this->service_client->set_smart_mode( false );
if ( $embedding && ! empty( $embedding['embedding'] ) ) {
// Cache the embedding
set_transient( 'ai_search_embedding_' . md5( $content ), $embedding['embedding'], MONTH_IN_SECONDS );
return $embedding['embedding'];
}
$svc_error = method_exists( $this->service_client, 'get_last_error' ) ? $this->service_client->get_last_error() : null;
$this->set_embedding_error(
$svc_error['code'] ?? 'service_error',
$svc_error['message'] ?? __( 'The AI Search Service did not return an embedding.', 'ai-search' ),
$svc_error['status'] ?? null
);
return false; // Service failed, don't fallback to OpenAI without user consent
}
if ( empty( $this->api_key ) ) {
$this->set_embedding_error( 'no_api_key', __( 'No OpenAI API key configured. Add one on the General tab.', 'ai-search' ) );
return false;
}
// v1.44.1 security: match the service-path timeout contract. The
// OpenAI branch had no `timeout` key, so wp_remote_post fell back
// to WP's 5s default — on a slow OpenAI day every synchronous
// search embedding tied up a PHP worker for the whole 5s. Tight 3s
// on the search path mirrors the service branch above.
$openai_timeout = $is_query
? (int) apply_filters( 'ai_search_query_embedding_timeout', 3 )
: (int) apply_filters( 'ai_search_publish_embedding_timeout', 20 );
$response = wp_remote_post( 'https://api.openai.com/v1/embeddings', [
'headers' => [
'Authorization' => 'Bearer ' . $this->api_key,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'input' => $content,
'model' => get_option( 'ai_search_openai_model', 'text-embedding-3-small' ),
]),
'timeout' => $openai_timeout,
]);
if ( is_wp_error( $response ) ) {
$this->set_embedding_error(
'http_error',
sprintf(
/* translators: %s: transport error message (e.g. cURL error 28) */
__( 'Could not reach OpenAI: %s', 'ai-search' ),
$response->get_error_message()
)
);
return false;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! isset( $body['data'][0]['embedding'] ) ) {
$detail = isset( $body['error']['message'] ) && is_string( $body['error']['message'] ) ? $body['error']['message'] : '';
$code = 'provider_error';
if ( $status === 401 ) {
$code = 'invalid_api_key';
} elseif ( $status === 429 ) {
$code = 'quota_exceeded';
}
$this->set_embedding_error(
$code,
$detail !== ''
/* translators: 1: HTTP status, 2: error detail from OpenAI */
? sprintf( __( 'OpenAI returned an error (HTTP %1$d: %2$s)', 'ai-search' ), $status, $detail )
/* translators: %d: HTTP status */
: sprintf( __( 'OpenAI returned an unexpected response (HTTP %d)', 'ai-search' ), $status ),
$status
);
return false;
}
// Cache the embedding for 1 day
set_transient( 'ai_search_embedding_' . md5( $content ), $body['data'][0]['embedding'], MONTH_IN_SECONDS );
return $body['data'][0]['embedding'];
}
/**
* Return true when the current request is a signed bypass from our
* own service. Two conditions must hold:
*
* 1. Query string carries ?ai_search_bypass=1
* 2. Authorization header contains "Bearer ", where
* matches this site's own ai_search_service_token
* option (the token our service issued to this origin).
*
* `hash_equals` is used so timing attacks against the comparison are
* neutralised. If either check fails, the caller runs the normal AI
* pipeline. No behaviour change for any request without both signals.
*
* @since 1.41.1
*/
private static function is_signed_bypass() {
if ( empty( $_GET['ai_search_bypass'] ) ) {
return false;
}
$auth = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? (string) $_SERVER['HTTP_AUTHORIZATION'] : '';
if ( '' === $auth && function_exists( 'getallheaders' ) ) {
// Some FastCGI / CGI setups strip Authorization from $_SERVER;
// getallheaders() usually restores it. Belt and braces.
$all = getallheaders();
$auth = $all['Authorization'] ?? $all['authorization'] ?? '';
}
if ( ! is_string( $auth ) || 0 !== stripos( $auth, 'Bearer ' ) ) {
return false;
}
$presented = trim( substr( $auth, 7 ) );
if ( '' === $presented ) return false;
$stored = (string) get_option( 'ai_search_service_token', '' );
if ( '' === $stored ) return false;
return hash_equals( $stored, $presented );
}
/**
* Customize the search query.
*
* @param string $sql Original SQL query.
* @param WP_Query $query WP_Query object.
*
* @return string Modified SQL query.
*/
public function filter_search_results( $posts, $query ) {
// is_admin() blocks the AI pipeline for admin-side searches (the
// Posts list, media library search etc.) where merchants don't
// want semantic ranking. But the Test Search tab and any other
// caller that wants to preview the frontend behaviour from inside
// wp-admin can set `ai_search_force_run=1` on its WP_Query to
// override that specific short-circuit (v1.41.1).
$force_run = (int) $query->get( 'ai_search_force_run' ) === 1;
if ( ! $query->is_search() || ( is_admin() && ! $force_run ) ) {
return $posts;
}
// v1.41.1: signed bypass. If the current request carries
// ?ai_search_bypass=1 AND the Authorization header holds the site's
// own service token, skip the whole AI pipeline and hand back the
// native WordPress results untouched. Used by the sweep on the
// service side to measure "same site with AI on" vs "same site
// with AI off" back-to-back, so support can quantify the plugin's
// real overhead per host.
//
// Only whoever knows the site's own token can trigger this, and
// the only party besides the site itself that has that token is
// our service (it stores every client's api_token in the clients
// table). Zero risk of an attacker turning AI off silently.
if ( self::is_signed_bypass() ) {
return $posts;
}
$post_type = $query->get( 'post_type' );
// If no post type is specified — or WordPress resolved it to the
// literal 'any', which it does for every site-wide search — expand
// to all public post types not flagged exclude_from_search (same
// rule core applies to `post_type => any`). Before 1.27.0 the
// string 'any' leaked into `post_type IN ('any')` and site-wide
// searches silently skipped the AI ranking.
if ( empty( $post_type ) || $post_type === 'any' || ( is_array( $post_type ) && in_array( 'any', $post_type, true ) ) ) {
$post_type = get_post_types( [ 'public' => true, 'exclude_from_search' => false ], 'names' );
// Remove attachments from the list
unset( $post_type['attachment'] );
$post_type = array_values( $post_type );
}
/**
* Filter the post types that AI Search will search through.
*
* @since 1.9.2
*
* @param string|array $post_type The post type(s) to search. Default is all public post types.
* @param WP_Query $query The current WP_Query object.
*/
$post_type = apply_filters( 'ai_search_searchable_post_types', $post_type, $query );
// Read the searched terms from the query being filtered, not the
// global one: secondary WP_Query instances with their own `s`
// (blocks, widgets, AJAX handlers) carry the right terms here,
// while get_search_query() answers for the main query - empty on
// regular pages, or someone else's terms on a search page (v1.28.1).
$raw_search = (string) $query->get( 's' );
if ( '' === $raw_search ) {
$raw_search = (string) get_search_query( false );
}
$search_query = sanitize_text_field( $raw_search );
$search_query = strtolower( $search_query );
// The raw (unescaped, unfiltered) query is what we compare against
// SKUs and titles - entity escaping would break "Tom & Jerry", and
// synonym expansion via the filter below must never touch a literal
// SKU (v1.27.0).
$exact_query = self::normalize_exact_query( $raw_search );
/**
* Filter the search query before it is sent for embedding.
*
* Useful for synonym expansion ("tshirt" → "tshirt t-shirt tee"),
* spell correction, locale-specific normalization, or stopword
* removal.
*
* @since 1.25.0
*
* @param string $search_query The sanitized, lowercased query.
* @param WP_Query $query The current WP_Query object.
*/
$search_query = apply_filters( 'ai_search_query_before_embedding', $search_query, $query );
// Allow admins to temporarily override threshold for testing
$threshold = $this->similarity_threshold;
if ( current_user_can( 'manage_options' ) && isset( $_GET['ai_threshold'] ) ) {
$threshold = floatval( $_GET['ai_threshold'] );
$threshold = max( 0.1, min( 1.0, $threshold ) );
}
/**
* Filter the similarity threshold for the current search.
*
* Useful for per-query or per-post-type overrides — e.g. a stricter
* threshold on product searches where precision matters, or looser
* on long-tail blog queries.
*
* @since 1.25.0
*
* @param float $threshold The threshold (0.1 – 1.0).
* @param string $search_query The (possibly filtered) search query.
* @param string|array $post_type The post type(s) being searched.
*/
$threshold = apply_filters( 'ai_search_similarity_threshold', $threshold, $search_query, $post_type );
// Check cache first. Term-embedding settings are baked into the
// key so toggling them invalidates the cache without explicit
// cleanup (v1.26.0).
$cache_key = 'ai_search_results_' . md5(
$search_query . serialize( $post_type ) . $threshold
. '|t:' . ( $this->term_embeddings_enabled ? '1' : '0' )
. '|w:' . $this->term_weight
);
$cached_results = get_transient( $cache_key );
if ( $cached_results !== false ) {
self::$similarity_scores = $cached_results['scores'];
self::$exact_match_ids = isset( $cached_results['exact'] ) ? (array) $cached_results['exact'] : [];
self::$lexical_match_ids = isset( $cached_results['lexical'] ) ? (array) $cached_results['lexical'] : [];
return $this->sync_query_counts( $query, $cached_results['posts'] );
}
// Tier 0: exact SKU / title matches. Always on, never below
// threshold, always ahead of the AI ranking (v1.27.0).
$exact_ids = $this->find_exact_matches( $exact_query, $post_type );
self::$exact_match_ids = $exact_ids;
$exact_scores = array_fill_keys( $exact_ids, 1.0 );
// Tier 1 (v1.28.0): WordPress' own lexical results are never
// discarded. A post that literally contains the searched words
// outranks any semantic guess — the AI's job is to ADD results
// below these, not to replace them. Ordered by keyword score
// (title > content > taxonomies > custom fields), not post date.
$lex_candidates = $this->remove_hidden_from_search( (array) $posts );
$lex_post_ids = array_values( array_filter( array_map( function ( $p ) {
return isset( $p->ID ) ? (int) $p->ID : 0;
}, $lex_candidates ) ) );
// v1.43.0: prime WP's object cache for the whole batch BEFORE the
// scoring loop. Without this, calculate_keyword_score() runs one
// SELECT per (post × taxonomy) via wp_get_post_terms + another on
// every get_post_meta — a 20-result Woo search did ~100 queries.
// update_object_term_cache + _prime_post_caches fetch everything
// in two queries and the scoring loop then reads from memory.
if ( ! empty( $lex_post_ids ) ) {
$pt_arg = is_array( $post_type ) ? $post_type : (array) $post_type;
update_object_term_cache( $lex_post_ids, $pt_arg );
_prime_post_caches( $lex_post_ids, true, true );
}
$lexical_scores = [];
foreach ( $lex_candidates as $lex_post ) {
if ( isset( $exact_scores[ $lex_post->ID ] ) ) {
continue; // Already pinned harder in Tier 0.
}
$lexical_scores[ $lex_post->ID ] = $this->calculate_keyword_score( $lex_post, $search_query );
}
arsort( $lexical_scores );
self::$lexical_match_ids = array_keys( $lexical_scores );
// v1.43.0: $is_query=true keeps the search path off /smart-embed.
// The user's query itself should NOT be LLM-enriched — only the
// product/content side benefits from that enrichment, and keeping
// the query path on /embeddings preserves the <100ms budget for
// the search hot path.
$query_embedding = $this->get_embedding( $search_query, true );
// v1.45.0: truncate + L2-renormalize the query to the stored vector
// format. Matches the 1024-dim representation we migrated post vectors
// into, so dot(query, stored) === cosine(query, stored).
if ( $query_embedding && AI_Search_Vector_Migration::is_v2_active() ) {
$query_embedding = AI_Search_Vector_Migration::truncate_and_normalize(
$query_embedding,
AI_Search_Vector_Migration::TARGET_DIM
);
}
if ( ! $query_embedding ) {
// Provider unavailable — still honour exact matches ahead of
// whatever WordPress found on its own.
if ( ! empty( $exact_ids ) ) {
self::$similarity_scores = $exact_scores;
return $this->sync_query_counts( $query, $this->prepend_posts_by_id( $exact_ids, $posts, $post_type ) );
}
return $this->sync_query_counts( $query, $this->remove_hidden_from_search( $posts ) );
}
// Reset per-request term similarity cache for this query.
$this->term_sim_cache = [];
// Fast Search short-circuit (v1.42.0+, Pro tier, opt-in).
// When the merchant has flipped the cloud toggle on, the
// service is our source of truth for semantic ranking. We
// ask it for candidates + scores, feed them into the same
// similarity map the local pipeline would have produced, and
// skip both process_posts_in_batches and find_posts_by_term_match.
//
// If the service returns null (network, 5xx, disabled), we
// fall through to the local pipeline untouched. Shopper never
// notices the outage.
$similarities = null;
if ( class_exists( 'AI_Search_Fast_Search' ) && AI_Search_Fast_Search::is_enabled() ) {
$candidates = AI_Search_Fast_Search::fetch_candidates( $search_query, (array) $post_type );
if ( null !== $candidates ) {
$similarities = [];
foreach ( $candidates as $c ) {
if ( ! is_array( $c ) || ! isset( $c['post_id'] ) ) continue;
$pid = (int) $c['post_id'];
if ( isset( $exact_scores[ $pid ] ) ) continue; // Tier 0 wins.
// Use the larger of the two service scores. Both are
// in [0, 1] where 1 = perfect match.
$lex = isset( $c['lex_score'] ) ? (float) $c['lex_score'] : 0.0;
$ai = isset( $c['ai_score'] ) ? (float) $c['ai_score'] : 0.0;
$score = max( $lex, $ai );
if ( $score >= $threshold ) {
$similarities[ $pid ] = $score;
}
}
arsort( $similarities );
}
}
if ( null === $similarities ) {
// Phase one: score posts that have their own content embedding.
$similarities = $this->process_posts_in_batches( $query_embedding, $search_query, $post_type, $threshold );
}
// Phase two (v1.30.0): posts reachable only through a matching
// taxonomy term. Union with phase one; term-only hits never lower
// a score content matching already earned. Runs even when Fast
// Search generated the candidates - term embeddings live in the
// local termmeta and are AI Search's edge over cloud-only rivals.
$term_candidates = $this->find_posts_by_term_match( $query_embedding, $post_type );
if ( ! empty( $term_candidates ) ) {
$term_candidates = array_filter(
$term_candidates,
function ( $score ) use ( $threshold ) {
return $score >= $threshold;
}
);
if ( ! empty( $term_candidates ) ) {
$similarities = self::merge_term_candidates( $similarities, $term_candidates );
}
}
if ( empty( $similarities ) && empty( $exact_ids ) && empty( $lexical_scores ) ) {
// Nothing lexical, nothing semantic — try the fallback tiers.
return $this->sync_query_counts( $query, $this->enhanced_fallback_search( $search_query, $post_type, $posts ) );
}
arsort( $similarities );
// Keep the pre-merge semantic ids so logging can tell what the AI
// tier actually contributed (v1.31.0).
$semantic_ids = array_keys( $similarities );
// Strict tier order via array union (left-hand keys and order win):
// Tier 0 exact (1.0) → Tier 1 lexical (keyword score) → Tier 2
// semantic extras. A post present in more than one tier appears
// once, in its highest tier.
$similarities = self::merge_tiers( $exact_scores, $lexical_scores, $similarities );
$ordered_ids = array_keys( $similarities );
// Store similarity scores for post_class filter
self::$similarity_scores = $similarities;
$posts = get_posts( $this->apply_search_visibility_args( [
'post_type' => $post_type,
'post_status' => 'publish',
'post__in' => $ordered_ids,
'orderby' => 'post__in',
'posts_per_page' => -1,
] ) );
/**
* Filter the final ordered search results before they are cached
* and returned to WordPress.
*
* Useful for boosting in-stock products, demoting old posts, hiding
* content the current user can't view, or injecting pinned results.
*
* Note: the filtered result is what gets cached for the 5-minute
* transient window. If you need per-user logic, branch inside the
* callback on `get_current_user_id()` so the cached value remains
* neutral.
*
* @since 1.25.0
*
* @param WP_Post[] $posts The ordered result posts.
* @param string $search_query The (possibly filtered) search query.
* @param array $similarities Map of post ID => similarity score, sorted desc.
*/
$posts = apply_filters( 'ai_search_results', $posts, $search_query, $similarities );
// Cache results for 5 minutes
set_transient( $cache_key, [
'posts' => $posts,
'scores' => $similarities,
'exact' => $exact_ids,
'lexical' => self::$lexical_match_ids,
], 5 * MINUTE_IN_SECONDS );
// v1.31.0: remember this search for the Insights tab, but only when
// the AI tier surfaced something the other tiers had missed. The
// write itself happens on shutdown.
$lexical_ids = array_keys( $lexical_scores );
if ( $this->logging_enabled
&& ! empty( $search_query )
&& self::should_log_search( $exact_ids, $lexical_ids, $semantic_ids ) ) {
$result_ids = wp_list_pluck( $posts, 'ID' );
$this->pending_log = [
'query' => mb_substr( $search_query, 0, 191 ),
'results_total' => count( $posts ),
'tier_exact' => count( $exact_ids ),
'tier_lexical' => count( $lexical_ids ),
'tier_ai' => count( array_diff( $semantic_ids, $exact_ids, $lexical_ids ) ),
'top_ids' => self::format_top_ids( $result_ids ),
'post_type' => is_array( $post_type ) ? ( count( $post_type ) === 1 ? reset( $post_type ) : 'any' ) : (string) $post_type,
];
}
return $this->sync_query_counts( $query, $posts );
}
/**
* Normalize the raw search string for exact (lexical) matching.
*
* Strips tags/whitespace noise like the embedding path does, but keeps
* case (comparison happens case-insensitively in SQL) and does NOT
* entity-escape.
*
* @since 1.27.0
*
* @param string $raw Raw search string (e.g. get_search_query( false )).
* @return string Normalized query, possibly empty.
*/
public static function normalize_exact_query( $raw ) {
$q = is_string( $raw ) ? $raw : '';
$q = wp_strip_all_tags( $q );
$q = preg_replace( '/\s+/u', ' ', $q );
return trim( (string) $q );
}
/**
* Whether a query is a plausible SKU prefix.
*
* Prefix matching only makes sense for a single token of reasonable
* length — "abc" or "red dress" must not LIKE-match half the catalog.
*
* @since 1.27.0
*
* @param string $query Normalized exact query.
* @return bool
*/
public static function should_try_sku_prefix( $query ) {
if ( ! is_string( $query ) ) {
return false;
}
if ( strlen( $query ) < 4 ) {
return false;
}
return preg_match( '/\s/u', $query ) !== 1;
}
/**
* Turn raw `_sku` lookup rows into an ordered list of searchable post IDs.
*
* Rows come from find_exact_matches()' SQL and carry, per matching
* post: ID, post_type, post_parent, sku, parent_type, parent_status.
* Variations (`product_variation`) resolve to their published parent
* product when the parent is one of the searched post types.
*
* Ordering: exact SKU equality first, then prefix matches by SKU
* length (shortest = closest to the query). Duplicates removed.
*
* @since 1.27.0
*
* @param array $rows DB rows (objects or arrays).
* @param string[] $post_types Post types being searched.
* @param string $query Normalized exact query.
* @return int[] Ordered post IDs.
*/
public static function resolve_sku_rows( $rows, $post_types, $query ) {
$post_types = array_map( 'strval', (array) $post_types );
$exact = [];
$prefix = [];
foreach ( (array) $rows as $row ) {
$row = (object) $row;
$id = 0;
if ( in_array( (string) $row->post_type, $post_types, true ) ) {
$id = (int) $row->ID;
} elseif (
(int) $row->post_parent > 0
&& isset( $row->parent_type, $row->parent_status )
&& $row->parent_status === 'publish'
&& in_array( (string) $row->parent_type, $post_types, true )
) {
$id = (int) $row->post_parent;
}
if ( $id <= 0 ) {
continue;
}
$sku = (string) ( $row->sku ?? '' );
if ( strcasecmp( $sku, $query ) === 0 ) {
$exact[ $id ] = true;
} else {
// Keep the shortest SKU per post for ordering.
$len = strlen( $sku );
if ( ! isset( $prefix[ $id ] ) || $len < $prefix[ $id ] ) {
$prefix[ $id ] = $len;
}
}
}
asort( $prefix );
$ordered = array_keys( $exact );
foreach ( array_keys( $prefix ) as $id ) {
if ( ! isset( $exact[ $id ] ) ) {
$ordered[] = $id;
}
}
return $ordered;
}
/**
* Merge exact-match IDs ahead of an ordered ID list, deduped, capped.
*
* @since 1.27.0
*
* @param int[] $exact_ids Exact-match IDs in priority order.
* @param int[] $ordered_ids AI-ranked IDs.
* @param int $limit Max exact IDs to pin (0 = no cap).
* @return int[]
*/
public static function merge_exact_matches( $exact_ids, $ordered_ids, $limit = self::EXACT_MATCH_LIMIT ) {
$exact_ids = array_values( array_unique( array_map( 'intval', (array) $exact_ids ) ) );
if ( $limit > 0 ) {
$exact_ids = array_slice( $exact_ids, 0, $limit );
}
$seen = array_fill_keys( $exact_ids, true );
$merged = $exact_ids;
foreach ( (array) $ordered_ids as $id ) {
$id = (int) $id;
if ( ! isset( $seen[ $id ] ) ) {
$seen[ $id ] = true;
$merged[] = $id;
}
}
return $merged;
}
/**
* term_taxonomy_id of WooCommerce's `exclude-from-search` visibility
* term, or 0 when WooCommerce is inactive / the term does not exist.
*
* Products marked "Hidden" or "Search results only: no" in the catalog
* visibility UI carry this term. WooCommerce filters them from its own
* product queries; since we replace the result set we must do the same
* (v1.27.0).
*
* @since 1.27.0
*
* @return int
*/
private function get_hidden_from_search_tt_id() {
static $tt_id = null;
if ( $tt_id !== null ) {
return $tt_id;
}
$tt_id = 0;
if ( function_exists( 'wc_get_product_visibility_term_ids' ) ) {
$ids = wc_get_product_visibility_term_ids();
$tt_id = isset( $ids['exclude-from-search'] ) ? (int) $ids['exclude-from-search'] : 0;
} elseif ( taxonomy_exists( 'product_visibility' ) ) {
$term = get_term_by( 'slug', 'exclude-from-search', 'product_visibility' );
$tt_id = ( $term && ! is_wp_error( $term ) ) ? (int) $term->term_taxonomy_id : 0;
}
return $tt_id;
}
/**
* SQL fragment excluding search-hidden products, for raw queries where
* the posts table is aliased `p`. Empty string when not applicable.
*
* @since 1.27.0
*
* @return string
*/
private function get_hidden_from_search_sql() {
global $wpdb;
$tt_id = $this->get_hidden_from_search_tt_id();
if ( $tt_id <= 0 ) {
return '';
}
return $wpdb->prepare(
" AND p.ID NOT IN ( SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id = %d )",
$tt_id
);
}
/**
* Add the search-visibility tax_query to WP_Query args when WooCommerce
* products are (or may be) part of the query.
*
* @since 1.27.0
*
* @param array $args WP_Query / get_posts args.
* @return array
*/
private function apply_search_visibility_args( array $args ) {
$tt_id = $this->get_hidden_from_search_tt_id();
if ( $tt_id <= 0 ) {
return $args;
}
$types = isset( $args['post_type'] ) ? (array) $args['post_type'] : [ 'post' ];
if ( ! in_array( 'any', $types, true ) && ! in_array( 'product', $types, true ) ) {
return $args;
}
$clause = [
'taxonomy' => 'product_visibility',
'field' => 'term_taxonomy_id',
'terms' => [ $tt_id ],
'operator' => 'NOT IN',
];
if ( ! empty( $args['tax_query'] ) && is_array( $args['tax_query'] ) ) {
$args['tax_query'] = [ 'relation' => 'AND', $args['tax_query'], $clause ];
} else {
$args['tax_query'] = [ $clause ];
}
return $args;
}
/**
* Drop search-hidden products from a list of WP_Post objects.
*
* Used on result sets we did not query ourselves (WordPress' own
* `posts_results` payload) — a plain site search does not go through
* WooCommerce's product query, so hidden products can be in there.
*
* @since 1.27.0
*
* @param WP_Post[] $posts
* @return WP_Post[]
*/
private function remove_hidden_from_search( $posts ) {
$tt_id = $this->get_hidden_from_search_tt_id();
if ( $tt_id <= 0 || empty( $posts ) ) {
return $posts;
}
$out = [];
foreach ( (array) $posts as $post ) {
if ( isset( $post->post_type ) && $post->post_type === 'product'
&& has_term( 'exclude-from-search', 'product_visibility', $post ) ) {
continue;
}
$out[] = $post;
}
return $out;
}
/**
* Keep WP_Query's result counters in sync with a replaced result set.
*
* WordPress computes `found_posts` (and the "Showing all N results"
* count themes print) from its own SQL before `posts_results` runs, so
* once we swap the posts the number lies. The plugin returns the full
* tiered set on one page, so the honest values are count() and one page
* (v1.28.0).
*
* @since 1.28.0
*
* @param WP_Query $query The query whose counters to fix.
* @param WP_Post[] $posts The final result set.
* @return WP_Post[] The same set, for `return` chaining.
*/
private function sync_query_counts( $query, $posts ) {
$posts = is_array( $posts ) ? $posts : [];
if ( $query instanceof WP_Query ) {
$query->found_posts = count( $posts );
$query->max_num_pages = $posts ? 1 : 0;
}
return $posts;
}
/**
* Merge the three result tiers into one ordered id => score map.
*
* Array union semantics: left-hand keys keep their position and score,
* so a post in several tiers lands once, in its highest tier. Tier
* order is positional (exact, lexical, semantic) — scores are per-tier
* and deliberately NOT comparable across tiers.
*
* @since 1.28.0
*
* @param array $exact id => 1.0, in pin order.
* @param array $lexical id => keyword score, sorted desc.
* @param array $semantic id => similarity score, sorted desc.
* @return array Ordered id => score map.
*/
public static function merge_tiers( array $exact, array $lexical, array $semantic ) {
return $exact + $lexical + $semantic;
}
/**
* Whether a post came from WordPress' own lexical search (Tier 1)
* in the current search.
*
* @since 1.28.0
*
* @param int $post_id Post ID.
* @return bool
*/
public static function is_lexical_match( $post_id ) {
return in_array( (int) $post_id, self::$lexical_match_ids, true );
}
/**
* Find posts that match the query lexically and must be pinned first.
*
* Always-on behaviour (no setting): a shopper who types a SKU or the
* exact product name gets that product first regardless of what the
* embedding model thinks of a random alphanumeric string.
*
* Rules:
* 1. `_sku` meta equals the query (variations resolve to parent).
* 2. `_sku` meta starts with the query, when the query is a single
* token of 4+ chars.
* 3. Post title equals the query (case-insensitive).
*
* @since 1.27.0
*
* @param string $query Normalized exact query.
* @param string|array $post_type Post type(s) being searched.
* @return int[] Ordered post IDs (SKU-exact, SKU-prefix, title-exact).
*/
private function find_exact_matches( $query, $post_type ) {
global $wpdb;
$query = (string) $query;
if ( strlen( $query ) < 2 ) {
return [];
}
$post_types = array_values( array_filter( array_map( 'strval', (array) $post_type ) ) );
if ( empty( $post_types ) ) {
return [];
}
$type_placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
// --- SKU (exact + optional prefix) ---
$sku_where = 'pm.meta_value = %s';
$sku_args = [ $query ];
if ( self::should_try_sku_prefix( $query ) ) {
$sku_where .= ' OR pm.meta_value LIKE %s';
$sku_args[] = $wpdb->esc_like( $query ) . '%';
}
$sku_sql = "SELECT p.ID, p.post_type, p.post_parent, pm.meta_value AS sku,
par.post_type AS parent_type, par.post_status AS parent_status
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
LEFT JOIN {$wpdb->posts} par ON par.ID = p.post_parent
WHERE pm.meta_key = '_sku'
AND pm.meta_value <> ''
AND ( {$sku_where} )
AND p.post_status = 'publish'"
. $this->get_hidden_from_search_sql() . "
LIMIT 50";
$sku_rows = $wpdb->get_results( $wpdb->prepare( $sku_sql, $sku_args ) );
$ids = self::resolve_sku_rows( $sku_rows ?: [], $post_types, $query );
// --- Title (exact, case-insensitive) ---
$title_sql = "SELECT p.ID FROM {$wpdb->posts} p
WHERE p.post_status = 'publish'
AND p.post_type IN ({$type_placeholders})
AND LOWER(p.post_title) = %s"
. $this->get_hidden_from_search_sql() . "
LIMIT " . (int) self::EXACT_MATCH_LIMIT;
$lower = function_exists( 'mb_strtolower' ) ? mb_strtolower( $query, 'UTF-8' ) : strtolower( $query );
$title_args = array_merge( $post_types, [ $lower ] );
$title_ids = $wpdb->get_col( $wpdb->prepare( $title_sql, $title_args ) );
foreach ( (array) $title_ids as $tid ) {
$ids[] = (int) $tid;
}
$ids = self::merge_exact_matches( $ids, [], self::EXACT_MATCH_LIMIT );
/**
* Filter the post IDs pinned to the top of search results as exact
* lexical matches, before AI ranking is applied.
*
* Add your own rules (EAN, supplier reference, brand codes) by
* appending IDs, or drop rules by removing them. Order is kept.
*
* @since 1.27.0
*
* @param int[] $ids Ordered post IDs (score 1.0).
* @param string $query Normalized exact query.
* @param string|array $post_type Post type(s) being searched.
*/
$ids = apply_filters( 'ai_search_exact_matches', $ids, $query, $post_type );
return array_values( array_unique( array_map( 'intval', (array) $ids ) ) );
}
/**
* Prepend posts (by ID, in order) to an existing WP_Post list, deduped.
*
* @since 1.27.0
*
* @param int[] $ids IDs to prepend.
* @param WP_Post[] $posts Existing posts.
* @param string|array $post_type Post type(s) to fetch.
* @return WP_Post[]
*/
private function prepend_posts_by_id( $ids, $posts, $post_type ) {
if ( empty( $ids ) ) {
return $posts;
}
$pinned = get_posts( $this->apply_search_visibility_args( [
'post_type' => $post_type,
'post_status' => 'publish',
'post__in' => $ids,
'orderby' => 'post__in',
'posts_per_page' => count( $ids ),
] ) );
$seen = [];
foreach ( $pinned as $p ) {
$seen[ $p->ID ] = true;
}
foreach ( $this->remove_hidden_from_search( (array) $posts ) as $p ) {
if ( empty( $seen[ $p->ID ] ) ) {
$pinned[] = $p;
}
}
return $pinned;
}
/**
* Whether a post is pinned as an exact match in the current search.
*
* @since 1.27.0
*
* @param int $post_id Post ID.
* @return bool
*/
public static function is_exact_match( $post_id ) {
return in_array( (int) $post_id, self::$exact_match_ids, true );
}
/**
* Process posts in batches to avoid memory issues on large sites.
*
* @param array $query_embedding The search query embedding.
* @param string $search_query The search query string.
* @param string|array $post_type Post types to search.
* @param float $threshold Similarity threshold.
* @return array Associative array of post IDs => similarity scores.
*/
private function process_posts_in_batches( $query_embedding, $search_query, $post_type, $threshold ) {
global $wpdb;
$similarities = [];
$offset = 0;
$total_processed = 0;
$max_posts = $this->max_posts_to_process;
$batch_size = $this->batch_size;
// Get total count of posts with embeddings
$post_type_sql = is_array( $post_type )
? "'" . implode( "','", array_map( 'esc_sql', $post_type ) ) . "'"
: "'" . esc_sql( $post_type ) . "'";
// Exclude WooCommerce products hidden from search (v1.27.0).
$hidden_sql = $this->get_hidden_from_search_sql();
// v1.45.0: switch between the legacy JSON postmeta ('_ai_search_embedding')
// and the packed binary postmeta ('_ai_search_embedding_v2') based on
// the migration flag. One hot path, both formats.
$use_v2 = AI_Search_Vector_Migration::is_v2_active();
$meta_key_sql = $use_v2 ? AI_Search_Vector_Migration::META_KEY_V2 : AI_Search_Vector_Migration::META_KEY_V1;
while ( $total_processed < $max_posts ) {
// Fetch batch of post IDs with embeddings
$batch_posts = $wpdb->get_results( $wpdb->prepare(
"SELECT p.ID, pm.meta_value as embedding_json
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type IN ({$post_type_sql})
AND p.post_status = 'publish'
AND pm.meta_key = %s"
. $hidden_sql . "
ORDER BY p.post_date DESC
LIMIT %d OFFSET %d",
$meta_key_sql,
$batch_size,
$offset
) );
if ( empty( $batch_posts ) ) {
break; // No more posts to process
}
foreach ( $batch_posts as $row ) {
if ( $use_v2 ) {
// v2: packed binary, pre-normalized → dot product = cosine.
$embedding = AI_Search_Vector_Migration::unpack_vector( (string) $row->embedding_json );
if ( empty( $embedding ) ) {
continue;
}
$final_score = AI_Search_Vector_Migration::dot( $query_embedding, $embedding );
} else {
$embedding = json_decode( $row->embedding_json, true );
if ( ! empty( $embedding['embedding'] ) ) {
$embedding = $embedding['embedding'];
}
if ( empty( $embedding ) || ! is_array( $embedding ) ) {
continue;
}
$final_score = $this->calculate_similarity( $query_embedding, $embedding );
}
// v1.26.0: additive boost from the post's best-matching taxonomy term.
if ( $this->term_embeddings_enabled && $this->term_weight > 0 ) {
$max_term_sim = $this->compute_max_term_similarity( $row->ID, $query_embedding );
$final_score = self::apply_term_boost( $final_score, $max_term_sim, $this->term_weight );
}
if ( $final_score >= $threshold ) {
$similarities[ $row->ID ] = $final_score;
}
$total_processed++;
}
$offset += $batch_size;
// Free memory
unset( $batch_posts );
// v1.43.0: early exit. The caller only renders ~10-30 posts
// per page and merge_tiers caps the semantic tier anyway, so
// once we've collected 100 above-threshold candidates there
// is no ranking value in continuing to scan the catalogue.
// Saves 50-150ms on sites with 2000+ embedded posts without
// changing visible results.
if ( count( $similarities ) >= 100 ) {
break;
}
}
return $similarities;
}
/**
* Apply the additive term-similarity boost to a post's base score.
*
* Pulled out as a static helper so the math can be unit-tested
* without the search/DB harness around it.
*
* @since 1.26.0
*
* @param float $base_score The post's score before the boost (0–1).
* @param float $max_term_sim Max cosine similarity over the post's terms (0–1, or 0 if none).
* @param int $term_weight Weight as a percent (0–50).
* @return float Final score, clamped to 1.0.
*/
public static function apply_term_boost( $base_score, $max_term_sim, $term_weight ) {
if ( $term_weight <= 0 || $max_term_sim <= 0 ) {
return (float) $base_score;
}
$boosted = $base_score + ( $term_weight / 100 ) * $max_term_sim;
return min( 1.0, $boosted );
}
/**
* Phase two: find posts through their taxonomy terms (v1.30.0).
*
* Phase one only scores posts that already have a content embedding,
* so a product whose only real signal is its category ("Sneakers" for
* a "trainers" search) can never clear the threshold - its content
* score is near zero and the additive boost is capped by term weight.
*
* This method compares the query against every eligible term embedding
* once, keeps the terms that genuinely match, then pulls the posts
* attached to them straight from wp_term_relationships (indexed, cheap).
* Posts found this way get a damped synthetic score.
*
* @since 1.30.0
*
* @param array $query_embedding The search query embedding.
* @param string|array $post_type Post type(s) being searched.
* @return array Map of post ID => synthetic score.
*/
private function find_posts_by_term_match( $query_embedding, $post_type ) {
global $wpdb;
if ( empty( $query_embedding ) || ! $this->term_embeddings_enabled || $this->term_weight <= 0 ) {
return [];
}
// Every term that has an embedding, with its taxonomy so the
// admin's opt-out list can be honoured without extra queries.
//
// v1.43.0: capped at 500 rows. The pipeline only keeps the top
// 25 matched terms downstream (line below), so loading every
// term + JSON-decoding every vector was wasteful on sites with
// thousands of categories/attributes. 500 covers any realistic
// catalogue without the memory + decode cost of unbounded loads.
$rows = $wpdb->get_results(
"SELECT tm.term_id, tt.term_taxonomy_id, tt.taxonomy, tm.meta_value AS embedding_json
FROM {$wpdb->termmeta} tm
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = tm.term_id
WHERE tm.meta_key = '_ai_search_term_embedding'
LIMIT 500"
);
if ( empty( $rows ) ) {
return [];
}
$disabled = (array) $this->disabled_taxonomies;
$matched = [];
foreach ( $rows as $row ) {
if ( in_array( $row->taxonomy, $disabled, true ) ) {
continue;
}
$term_id = (int) $row->term_id;
if ( isset( $this->term_sim_cache[ $term_id ] ) ) {
$sim = $this->term_sim_cache[ $term_id ];
} else {
$decoded = json_decode( $row->embedding_json, true );
if ( ! empty( $decoded['embedding'] ) ) {
$decoded = $decoded['embedding'];
}
if ( ! is_array( $decoded ) || empty( $decoded ) ) {
$this->term_sim_cache[ $term_id ] = 0.0;
continue;
}
// v1.45.0: same v2 dimensionality bridge as compute_max_term_similarity.
if ( AI_Search_Vector_Migration::is_v2_active() ) {
$decoded = AI_Search_Vector_Migration::truncate_and_normalize(
$decoded,
AI_Search_Vector_Migration::TARGET_DIM
);
$sim = AI_Search_Vector_Migration::dot( $query_embedding, $decoded );
} else {
$sim = $this->calculate_similarity( $query_embedding, $decoded );
}
$this->term_sim_cache[ $term_id ] = $sim;
}
/**
* Filter the similarity a taxonomy term needs to pull in the
* posts attached to it during phase-two retrieval.
*
* @since 1.30.0
*
* @param float $threshold Minimum cosine similarity (0-1).
* @param string $taxonomy The term's taxonomy.
*/
$term_threshold = (float) apply_filters( 'ai_search_term_match_threshold', self::TERM_MATCH_THRESHOLD, $row->taxonomy );
if ( $sim >= $term_threshold ) {
$matched[ (int) $row->term_taxonomy_id ] = $sim;
}
}
if ( empty( $matched ) ) {
return [];
}
// Best-matching terms first, so the candidate cap keeps the most
// relevant posts when a query matches many terms.
arsort( $matched );
$matched = array_slice( $matched, 0, 25, true );
$post_types = array_values( array_filter( array_map( 'strval', (array) $post_type ) ) );
if ( empty( $post_types ) ) {
return [];
}
$tt_ids = array_keys( $matched );
$tt_place = implode( ',', array_fill( 0, count( $tt_ids ), '%d' ) );
$type_place = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
$hidden_sql = $this->get_hidden_from_search_sql();
$sql = "SELECT tr.object_id, tr.term_taxonomy_id
FROM {$wpdb->term_relationships} tr
INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id
WHERE tr.term_taxonomy_id IN ({$tt_place})
AND p.post_status = 'publish'
AND p.post_type IN ({$type_place})"
. $hidden_sql . "
LIMIT " . (int) self::TERM_CANDIDATE_LIMIT;
$relations = $wpdb->get_results( $wpdb->prepare( $sql, array_merge( $tt_ids, $post_types ) ) );
if ( empty( $relations ) ) {
return [];
}
$scores = [];
foreach ( $relations as $rel ) {
$post_id = (int) $rel->object_id;
$sim = isset( $matched[ (int) $rel->term_taxonomy_id ] ) ? $matched[ (int) $rel->term_taxonomy_id ] : 0.0;
$score = self::term_only_score( $sim, $this->term_weight );
if ( ! isset( $scores[ $post_id ] ) || $scores[ $post_id ] < $score ) {
$scores[ $post_id ] = $score;
}
}
return $scores;
}
/**
* Default WooCommerce product fields included in embeddings.
*
* The engine and the settings UI both read through this, so a store
* that has never opened the WooCommerce tab still indexes sensible
* fields - including the SKU, which powers exact-match search
* (v1.32.0; previously the UI showed defaults the engine ignored).
*
* @since 1.32.0
*
* @return string[]
*/
public static function default_woocommerce_fields() {
return [
'product_description',
'product_short_description',
'product_sku',
'product_categories',
'product_tags',
];
}
/**
* Name of the search log table.
*
* @since 1.31.0
*
* @return string
*/
public static function log_table() {
global $wpdb;
return $wpdb->prefix . 'aisearch_logs';
}
/**
* Whether a search is worth logging: the AI tier must have surfaced at
* least one post the exact and keyword tiers did not already have.
*
* Pure decision logic, extracted so it can be unit-tested without a DB.
*
* @since 1.31.0
*
* @param int[] $exact_ids Tier 0 post IDs.
* @param int[] $lexical_ids Tier 1 post IDs.
* @param int[] $semantic_ids Tier 2 post IDs (before the tier merge).
* @return bool
*/
public static function should_log_search( array $exact_ids, array $lexical_ids, array $semantic_ids ) {
if ( empty( $semantic_ids ) ) {
return false;
}
$added = array_diff(
array_map( 'intval', $semantic_ids ),
array_map( 'intval', $exact_ids ),
array_map( 'intval', $lexical_ids )
);
return ! empty( $added );
}
/**
* Serialize the first N result IDs for storage.
*
* @since 1.31.0
*
* @param int[] $ordered_ids Result IDs in display order.
* @param int $limit How many to keep.
* @return string Comma-separated IDs.
*/
public static function format_top_ids( array $ordered_ids, $limit = self::LOG_TOP_IDS ) {
$ids = array_slice( array_map( 'intval', $ordered_ids ), 0, max( 0, (int) $limit ) );
$ids = array_filter( $ids, function ( $id ) {
return $id > 0;
} );
return implode( ',', $ids );
}
/**
* Parse a stored top_ids string back into post IDs.
*
* @since 1.31.0
*
* @param string $stored Comma-separated IDs.
* @return int[]
*/
public static function parse_top_ids( $stored ) {
if ( ! is_string( $stored ) || '' === trim( $stored ) ) {
return [];
}
$ids = array_map( 'intval', explode( ',', $stored ) );
return array_values( array_filter( $ids, function ( $id ) {
return $id > 0;
} ) );
}
/**
* Create the search log table when missing, and schedule the daily
* retention cleanup. Runs on plugins_loaded so sites that update the
* plugin (rather than activate it) also get the table.
*
* @since 1.31.0
*/
public function maybe_create_log_table() {
if ( get_option( 'ai_search_log_db_version' ) === self::LOG_DB_VERSION ) {
if ( ! wp_next_scheduled( 'ai_search_purge_logs' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'ai_search_purge_logs' );
}
return;
}
self::create_log_table();
update_option( 'ai_search_log_db_version', self::LOG_DB_VERSION, false );
if ( ! wp_next_scheduled( 'ai_search_purge_logs' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'ai_search_purge_logs' );
}
}
/**
* v1.44.1 one-shot migration: flip `ai_search_api_key` and
* `ai_search_service_token` from autoload=yes to autoload=no so these
* secret-bearing options stop being loaded into the alloptions cache
* on every page request. Any plugin/theme on the install could
* previously read either value via a plain `get_option()` with no
* capability check because the option sat in the shared cache.
*
* The migration runs exactly once per install, tracked via the
* `ai_search_autoload_migrated` option (stored itself with autoload=no).
*
* @since 1.44.1
*/
public function maybe_disable_secret_autoload() {
if ( get_option( 'ai_search_autoload_migrated' ) === '1' ) {
return;
}
foreach ( [ 'ai_search_api_key', 'ai_search_service_token' ] as $opt ) {
$value = get_option( $opt, null );
if ( null === $value ) {
continue;
}
// delete + add is the compat idiom for forcing autoload=no on
// an option that already exists with autoload=yes. `update_option`
// alone will not change the autoload flag of an existing row.
delete_option( $opt );
add_option( $opt, $value, '', false );
}
add_option( 'ai_search_autoload_migrated', '1', '', false );
}
/**
* Create (or update) the search log table via dbDelta.
*
* One row per logged search - never one per result - and deliberately
* no IP, user id or any other personal data, so the log needs no
* consent banner and carries no privacy risk.
*
* @since 1.31.0
*/
public static function create_log_table() {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = self::log_table();
$collate = $wpdb->get_charset_collate();
// 191 chars keeps the index inside utf8mb4's key length limit.
$sql = "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
query varchar(191) NOT NULL DEFAULT '',
results_total smallint(5) unsigned NOT NULL DEFAULT 0,
tier_exact smallint(5) unsigned NOT NULL DEFAULT 0,
tier_lexical smallint(5) unsigned NOT NULL DEFAULT 0,
tier_ai smallint(5) unsigned NOT NULL DEFAULT 0,
top_ids varchar(255) NOT NULL DEFAULT '',
post_type varchar(20) NOT NULL DEFAULT '',
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY query (query),
KEY created_at (created_at)
) {$collate};";
dbDelta( $sql );
}
/**
* Write the pending search log row, if any.
*
* Hooked to `shutdown`: on FastCGI setups the response has already been
* flushed to the visitor by this point, so this single INSERT is free
* as far as the shopper is concerned.
*
* @since 1.31.0
*/
public function flush_search_log() {
if ( empty( $this->pending_log ) ) {
return;
}
$row = $this->pending_log;
$this->pending_log = null;
global $wpdb;
$wpdb->insert(
self::log_table(),
[
'query' => $row['query'],
'results_total' => $row['results_total'],
'tier_exact' => $row['tier_exact'],
'tier_lexical' => $row['tier_lexical'],
'tier_ai' => $row['tier_ai'],
'top_ids' => $row['top_ids'],
'post_type' => $row['post_type'],
'created_at' => current_time( 'mysql' ),
],
[ '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' ]
);
}
/**
* Delete log rows older than the configured retention window.
*
* @since 1.31.0
*/
public function purge_old_logs() {
global $wpdb;
$days = (int) get_option( 'ai_search_log_retention_days', 90 );
$days = max( 1, min( 365, $days ) );
$table = self::log_table();
$wpdb->query( $wpdb->prepare(
"DELETE FROM {$table} WHERE created_at < DATE_SUB( %s, INTERVAL %d DAY )",
current_time( 'mysql' ),
$days
) );
}
/**
* Score a synthetic result for a post found only through a matching
* taxonomy term (phase two).
*
* A term-only match is real evidence but weaker than the post's own
* content matching, so it is damped by the same term-weight the admin
* already tuned: `score = term_sim * (weight / 100 + 0.5)`, clamped
* below 1.0 so a term hit can never outrank an exact match.
*
* @since 1.30.0
*
* @param float $term_sim Cosine similarity of the matching term (0-1).
* @param int $term_weight Term weight as a percent (0-50).
* @return float Synthetic score (0-0.99).
*/
public static function term_only_score( $term_sim, $term_weight ) {
$term_sim = max( 0.0, min( 1.0, (float) $term_sim ) );
$term_weight = max( 0, min( 50, (int) $term_weight ) );
return min( 0.99, $term_sim * ( $term_weight / 100 + 0.5 ) );
}
/**
* Merge phase-two (term-seeded) scores into the phase-one map without
* ever lowering a post that content matching already scored higher.
*
* @since 1.30.0
*
* @param array $content_scores id => score from post embeddings.
* @param array $term_scores id => synthetic score from term matches.
* @return array Merged id => score map, sorted desc.
*/
public static function merge_term_candidates( array $content_scores, array $term_scores ) {
foreach ( $term_scores as $id => $score ) {
if ( ! isset( $content_scores[ $id ] ) || $content_scores[ $id ] < $score ) {
$content_scores[ $id ] = $score;
}
}
arsort( $content_scores );
return $content_scores;
}
/**
* Compute the max cosine similarity between the query embedding and
* any of a post's taxonomy term embeddings.
*
* The per-request `$term_sim_cache` map keys by `term_id` so a popular
* term that appears on many products in the same batch (e.g. "Red") is
* only scored against the query once per search.
*
* @since 1.26.0
*
* @param int $post_id Post ID.
* @param array $query_embedding The search query's embedding.
* @return float Max term similarity, or 0.0 if no eligible terms have embeddings.
*/
private function compute_max_term_similarity( $post_id, $query_embedding ) {
$taxonomies = get_object_taxonomies( get_post_type( $post_id ) );
if ( empty( $taxonomies ) ) {
return 0.0;
}
// Honour the admin's opt-out list.
if ( ! empty( $this->disabled_taxonomies ) ) {
$taxonomies = array_diff( $taxonomies, $this->disabled_taxonomies );
if ( empty( $taxonomies ) ) {
return 0.0;
}
}
$term_ids = wp_get_object_terms( $post_id, $taxonomies, [ 'fields' => 'ids' ] );
if ( is_wp_error( $term_ids ) || empty( $term_ids ) ) {
return 0.0;
}
$max = 0.0;
foreach ( $term_ids as $term_id ) {
if ( isset( $this->term_sim_cache[ $term_id ] ) ) {
$sim = $this->term_sim_cache[ $term_id ];
} else {
$raw = get_term_meta( $term_id, '_ai_search_term_embedding', true );
if ( empty( $raw ) ) {
$this->term_sim_cache[ $term_id ] = 0.0;
continue;
}
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
$this->term_sim_cache[ $term_id ] = 0.0;
continue;
}
// v1.45.0: when post vectors are v2 (1024-dim), truncate the
// term vector to match on the fly. Cached by term_id so this
// only happens once per unique term per request.
if ( AI_Search_Vector_Migration::is_v2_active() ) {
$decoded = AI_Search_Vector_Migration::truncate_and_normalize(
$decoded,
AI_Search_Vector_Migration::TARGET_DIM
);
$sim = AI_Search_Vector_Migration::dot( $query_embedding, $decoded );
} else {
$sim = $this->calculate_similarity( $query_embedding, $decoded );
}
$this->term_sim_cache[ $term_id ] = $sim;
}
if ( $sim > $max ) {
$max = $sim;
}
}
return $max;
}
/**
* Calculate cosine similarity.
*
* @param array $a First vector.
* @param array $b Second vector.
*
* @return float Cosine similarity.
*/
private function calculate_similarity( $a, $b ) {
if ( ! is_array( $a ) || ! is_array( $b ) || count( $a ) !== count( $b ) ) {
return 0;
}
$dot_product = array_sum( array_map( function ( $x, $y ) {
return $x * $y;
}, $a, $b ) );
$magnitude_a = sqrt( array_sum( array_map( function ( $x ) {
return $x ** 2;
}, $a ) ) );
$magnitude_b = sqrt( array_sum( array_map( function ( $x ) {
return $x ** 2;
}, $b ) ) );
// Loose comparison: sqrt() returns float 0.0, which is not === int 0.
// Strict comparison here would let a zero-magnitude vector through to
// the division below, causing a DivisionByZeroError on PHP 8+.
if ( $magnitude_a == 0 || $magnitude_b == 0 ) {
return 0;
}
return $dot_product / ( $magnitude_a * $magnitude_b );
}
/**
* Calculate keyword match score for a post.
*
* @param WP_Post $post The post to score.
* @param string $search_query The search query (already lowercase).
* @return float Keyword match score (0-1).
*/
private function calculate_keyword_score( $post, $search_query ) {
$score = 0.0;
$search_words = array_filter( explode( ' ', $search_query ) );
if ( empty( $search_words ) ) {
return 0.0;
}
$title = strtolower( $post->post_title );
$content = strtolower( wp_strip_all_tags( $post->post_content ) );
// Check each search word
$title_matches = 0;
$content_matches = 0;
$custom_field_matches = 0;
foreach ( $search_words as $word ) {
if ( strlen( $word ) < 2 ) {
continue; // Skip very short words
}
// Exact word match in title (highest priority)
if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/i', $title ) ) {
$title_matches++;
}
// Word match in content
if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/i', $content ) ) {
$content_matches++;
}
}
$total_words = count( array_filter( $search_words, function( $w ) { return strlen( $w ) >= 2; } ) );
if ( $total_words === 0 ) {
return 0.0;
}
// Calculate title score (0-1)
$title_score = $title_matches / $total_words;
// Calculate content score (0-1)
$content_score = $content_matches / $total_words;
// Check categories and tags
$taxonomy_score = 0.0;
$taxonomies = get_object_taxonomies( $post->post_type, 'names' );
foreach ( $taxonomies as $taxonomy ) {
$terms = wp_get_post_terms( $post->ID, $taxonomy, [ 'fields' => 'names' ] );
if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
$terms_string = strtolower( implode( ' ', $terms ) );
foreach ( $search_words as $word ) {
if ( strlen( $word ) >= 2 && strpos( $terms_string, $word ) !== false ) {
$taxonomy_score = max( $taxonomy_score, 0.4 );
break 2;
}
}
}
}
// Check custom fields configured in settings
$custom_fields = get_option( 'ai_search_custom_fields_' . $post->post_type, [] );
$custom_field_content = '';
if ( ! empty( $custom_fields ) ) {
foreach ( $custom_fields as $field_key ) {
$field_value = get_post_meta( $post->ID, $field_key, true );
if ( ! empty( $field_value ) ) {
if ( is_array( $field_value ) ) {
$custom_field_content .= ' ' . implode( ' ', array_map( 'strval', $field_value ) );
} else {
$custom_field_content .= ' ' . strval( $field_value );
}
}
}
}
// Check WooCommerce fields for products
if ( $post->post_type === 'product' && class_exists( 'WooCommerce' ) ) {
$woo_fields = get_option( 'ai_search_woocommerce_fields', self::default_woocommerce_fields() );
$product = wc_get_product( $post->ID );
if ( $product ) {
// SKU
if ( in_array( 'sku', $woo_fields, true ) ) {
$sku = $product->get_sku();
if ( ! empty( $sku ) ) {
$custom_field_content .= ' ' . $sku;
}
}
// Short description
if ( in_array( 'short_description', $woo_fields, true ) ) {
$short_desc = $product->get_short_description();
if ( ! empty( $short_desc ) ) {
$custom_field_content .= ' ' . wp_strip_all_tags( $short_desc );
}
}
// Product attributes
if ( in_array( 'attributes', $woo_fields, true ) ) {
$attributes = $product->get_attributes();
if ( ! empty( $attributes ) ) {
foreach ( $attributes as $attribute ) {
if ( is_object( $attribute ) && method_exists( $attribute, 'get_options' ) ) {
$options = $attribute->get_options();
if ( ! empty( $options ) ) {
$custom_field_content .= ' ' . implode( ' ', $options );
}
}
}
}
}
}
}
// Calculate custom field score
$custom_field_score = 0.0;
if ( ! empty( $custom_field_content ) ) {
$custom_field_content = strtolower( $custom_field_content );
foreach ( $search_words as $word ) {
if ( strlen( $word ) >= 2 && preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/i', $custom_field_content ) ) {
$custom_field_matches++;
}
}
$custom_field_score = $custom_field_matches / $total_words;
}
// Weighted combination: title (1.0), content (0.5), taxonomy (0.4), custom fields (0.6)
$score = ( $title_score * 1.0 ) + ( $content_score * 0.5 ) + $taxonomy_score + ( $custom_field_score * 0.6 );
// Normalize to 0-1 range (max possible is now 2.5: 1.0 + 0.5 + 0.4 + 0.6)
$score = min( 1.0, $score / 2.5 );
return $score;
}
/**
* Enhanced fallback search when AI search finds no results.
*
* @param string $search_query The original search query.
* @param string $post_type The post type being searched.
* @param array $original_posts Original WordPress search results.
* @return array Enhanced search results or informative fallback.
*/
private function enhanced_fallback_search( $search_query, $post_type, $original_posts ) {
// WordPress' own results may contain products hidden from search.
$original_posts = $this->remove_hidden_from_search( $original_posts );
// Get query embedding for similarity calculation
$query_embedding = $this->get_embedding( $search_query, true );
// Get current threshold (with admin override support)
$threshold = $this->similarity_threshold;
if ( current_user_can( 'manage_options' ) && isset( $_GET['ai_threshold'] ) ) {
$threshold = floatval( $_GET['ai_threshold'] );
$threshold = max( 0.1, min( 1.0, $threshold ) );
}
// Strategy 1: If original WordPress search found results, use them
if ( ! empty( $original_posts ) ) {
$filtered_posts = [];
// Calculate similarity scores for fallback results that have embeddings
if ( $query_embedding ) {
foreach ( $original_posts as $post ) {
$embedding_json = get_post_meta( $post->ID, '_ai_search_embedding', true );
if ( ! empty( $embedding_json ) ) {
$embedding = json_decode( $embedding_json, true );
if ( ! empty( $embedding['embedding'] ) ) {
$embedding = $embedding['embedding'];
}
$final_score = $this->calculate_similarity( $query_embedding, $embedding );
self::$similarity_scores[ $post->ID ] = $final_score;
// Only include posts that meet the threshold
if ( $final_score >= $threshold ) {
$filtered_posts[] = $post;
}
} else {
// Post has no embedding, include it anyway (WordPress default behavior)
$filtered_posts[] = $post;
}
}
} else {
$filtered_posts = $original_posts;
}
// If we have filtered results, return them
if ( ! empty( $filtered_posts ) ) {
// Add a transient to indicate fallback was used
set_transient( 'ai_search_used_fallback', [
'query' => $search_query,
'type' => 'wordpress_default',
'count' => count( $filtered_posts )
], 300 ); // 5 minutes
return $filtered_posts;
}
}
// Strategy 2: Try broader WordPress search with partial matching
$broader_results = $this->perform_broader_search( $search_query, $post_type );
if ( ! empty( $broader_results ) ) {
$filtered_broader = [];
// Calculate similarity scores for broader results that have embeddings
if ( $query_embedding ) {
foreach ( $broader_results as $post ) {
$embedding_json = get_post_meta( $post->ID, '_ai_search_embedding', true );
if ( ! empty( $embedding_json ) ) {
$embedding = json_decode( $embedding_json, true );
if ( ! empty( $embedding['embedding'] ) ) {
$embedding = $embedding['embedding'];
}
$final_score = $this->calculate_similarity( $query_embedding, $embedding );
self::$similarity_scores[ $post->ID ] = $final_score;
// Only include posts that meet the threshold
if ( $final_score >= $threshold ) {
$filtered_broader[] = $post;
}
} else {
// Post has no embedding, include it anyway
$filtered_broader[] = $post;
}
}
} else {
$filtered_broader = $broader_results;
}
// If we have filtered results, return them
if ( ! empty( $filtered_broader ) ) {
set_transient( 'ai_search_used_fallback', [
'query' => $search_query,
'type' => 'broader_search',
'count' => count( $filtered_broader )
], 300 );
return $filtered_broader;
}
}
// Strategy 3: Try searching posts without embeddings (not yet processed)
$unprocessed_results = $this->search_unprocessed_posts( $search_query, $post_type );
if ( ! empty( $unprocessed_results ) ) {
$filtered_unprocessed = [];
// Calculate similarity scores for unprocessed results that have embeddings
if ( $query_embedding ) {
foreach ( $unprocessed_results as $post ) {
$embedding_json = get_post_meta( $post->ID, '_ai_search_embedding', true );
if ( ! empty( $embedding_json ) ) {
$embedding = json_decode( $embedding_json, true );
if ( ! empty( $embedding['embedding'] ) ) {
$embedding = $embedding['embedding'];
}
$final_score = $this->calculate_similarity( $query_embedding, $embedding );
self::$similarity_scores[ $post->ID ] = $final_score;
// Only include posts that meet the threshold
if ( $final_score >= $threshold ) {
$filtered_unprocessed[] = $post;
}
} else {
// Post has no embedding, include it anyway
$filtered_unprocessed[] = $post;
}
}
} else {
$filtered_unprocessed = $unprocessed_results;
}
// If we have filtered results, return them
if ( ! empty( $filtered_unprocessed ) ) {
set_transient( 'ai_search_used_fallback', [
'query' => $search_query,
'type' => 'unprocessed_posts',
'count' => count( $filtered_unprocessed )
], 300 );
return $filtered_unprocessed;
}
}
// Strategy 4: No results found anywhere, store info for user feedback
set_transient( 'ai_search_no_results', [
'query' => $search_query,
'suggestions' => $this->generate_search_suggestions( $search_query ),
'timestamp' => time()
], 300 );
// Return empty array - WordPress will show its "no results" page
return [];
}
/**
* Perform broader search with more flexible matching.
*
* @param string $search_query Search query.
* @param string $post_type Post type to search.
* @return array Found posts.
*/
private function perform_broader_search( $search_query, $post_type ) {
// Split search query into individual words
$search_words = explode( ' ', $search_query );
$search_words = array_filter( array_map( 'trim', $search_words ) );
if ( empty( $search_words ) ) {
return [];
}
// Try searching for individual words (OR logic instead of AND)
$args = [
'post_type' => $post_type ?: 'any',
'post_status' => 'publish',
'posts_per_page' => 20,
's' => '', // We'll use meta_query for more flexible searching
'meta_query' => [
'relation' => 'OR',
]
];
// Add word-based searches in title and content
foreach ( $search_words as $word ) {
if ( strlen( $word ) >= 3 ) { // Only search words with 3+ characters
$word_results = get_posts( $this->apply_search_visibility_args( [
'post_type' => $post_type ?: 'any',
'post_status' => 'publish',
'posts_per_page' => 10,
's' => $word,
] ) );
if ( ! empty( $word_results ) ) {
return $word_results;
}
}
}
return [];
}
/**
* Search posts that haven't been processed for AI embeddings yet.
*
* @param string $search_query Search query.
* @param string $post_type Post type to search.
* @return array Found posts.
*/
private function search_unprocessed_posts( $search_query, $post_type ) {
$args = [
'post_type' => $post_type ?: 'any',
'post_status' => 'publish',
'posts_per_page' => 15,
's' => $search_query,
'meta_query' => [
[
'key' => '_ai_search_embedding',
'compare' => 'NOT EXISTS'
]
]
];
return get_posts( $this->apply_search_visibility_args( $args ) );
}
/**
* Generate search suggestions when no results are found.
*
* @param string $search_query Original search query.
* @return array Suggested search terms.
*/
private function generate_search_suggestions( $search_query ) {
$suggestions = [];
// Get common terms from existing posts
$recent_posts = get_posts([
'posts_per_page' => 50,
'post_status' => 'publish'
]);
$common_words = [];
foreach ( $recent_posts as $post ) {
$title_words = explode( ' ', strtolower( $post->post_title ) );
foreach ( $title_words as $word ) {
$word = trim( $word, '.,!?;:"()[]' );
if ( strlen( $word ) >= 4 ) {
$common_words[] = $word;
}
}
}
// Get most frequent words
$word_counts = array_count_values( $common_words );
arsort( $word_counts );
$suggestions = array_slice( array_keys( $word_counts ), 0, 5 );
return $suggestions;
}
/**
* Handle AJAX request to clear search feedback transients.
*/
public function clear_search_feedback() {
// Verify nonce for security
if ( ! wp_verify_nonce( $_POST['nonce'] ?? '', 'ai_search_clear_feedback' ) ) {
wp_die( 'Security check failed' );
}
// Clear the transients
delete_transient( 'ai_search_used_fallback' );
delete_transient( 'ai_search_no_results' );
wp_die( 'success' );
}
/**
* Add similarity score as CSS class to search result posts.
*
* @param array $classes Existing post classes.
* @param array $class Additional classes.
* @param int $post_id Post ID.
* @return array Modified classes.
*/
public function add_similarity_class( $classes, $class, $post_id ) {
if ( ! is_search() || is_admin() ) {
return $classes;
}
if ( isset( self::$similarity_scores[ $post_id ] ) ) {
$score = self::$similarity_scores[ $post_id ];
// Convert to percentage (0-100) for CSS class, clamped to 0-100 range
$score_percent = max( 0, min( 100, round( $score * 100 ) ) );
$classes[] = 'ai-search-result';
$classes[] = 'ai-search-similarity-' . $score_percent;
if ( self::is_exact_match( $post_id ) ) {
$classes[] = 'ai-search-exact-match';
} elseif ( self::is_lexical_match( $post_id ) ) {
$classes[] = 'ai-search-lexical-match';
}
// Add class based on threshold comparison
if ( $score >= $this->similarity_threshold ) {
$classes[] = 'ai-search-match';
} else {
$classes[] = 'ai-search-below-threshold';
}
} else {
// Not an AI search result (fallback or no embedding)
$classes[] = 'ai-search-fallback';
$classes[] = 'ai-search-similarity-0';
}
return $classes;
}
/**
* Add AI Search badge to post titles in search results.
*
* @param string $title The post title.
* @param int $post_id The post ID.
* @return string Modified title with badge.
*/
public function add_ai_search_badge( $title, $post_id = 0 ) {
// Only on search pages, in the loop, on frontend
if ( ! is_search() || is_admin() || ! in_the_loop() ) {
return $title;
}
// v1.37.0: badge now ONLY appears on genuine AI-tier results.
// If the post was exact-matched (SKU/title), lexical-matched (WP
// native keyword hit) or came from the fallback (no embedding),
// no badge is shown — those results look like "normal" search
// results because they *are* normal. The badge earns its place
// only when the AI surfaced something the other tiers would have
// missed.
//
// Visibility gate: `ai_search_badge_public` (default true in 1.37.0)
// lets merchants who want unbranded search results turn it off.
// Editors/admins always see it regardless — they need to be able
// to spot AI results while tuning the plugin. Default flip from
// false to true is intentional: opting in to transparency for the
// 90%+ who never touch the setting.
$badge_public = get_option( 'ai_search_badge_public', true );
if ( ! $badge_public && ! current_user_can( 'edit_posts' ) ) {
return $title;
}
$has_score = isset( self::$similarity_scores[ $post_id ] );
$is_exact = self::is_exact_match( $post_id );
$is_lexical = self::is_lexical_match( $post_id );
if ( ! $has_score || $is_exact || $is_lexical ) {
return $title;
}
$tooltip = __( 'This search result was generated by an AI system.', 'ai-search' );
// No `title` attribute — that triggers the browser's default OS
// tooltip (yellow on macOS, grey on Windows) which we cannot style.
// The visual tooltip is CSS-only via `data-tooltip` + `::after`.
// `aria-label` keeps screen readers covered.
// Empty span — the visual is just the coloured dot, the tooltip on
// hover explains what it means. Removed the literal "AI" text in
// 1.38.0 after it kept overflowing serif titles.
$badge = sprintf(
'',
esc_attr( $tooltip ),
esc_attr( $tooltip )
);
return $title . ' ' . $badge;
}
/**
* Add CSS styles for AI Search badge.
*/
public function add_ai_search_badge_styles() {
if ( ! is_search() ) {
return;
}
// Always emit the CSS on search pages. Badge visibility is decided
// per post inside add_ai_search_badge() (only genuine AI results).
//
// Tooltip bg + text color are configurable in Appearance tab
// (v1.37.0). We inline them into CSS custom properties so a
// merchant change takes effect on the next page load — no
// cache-busting needed.
$tooltip_bg = get_option( 'ai_search_tooltip_bg', '#231a4f' );
$tooltip_color = get_option( 'ai_search_tooltip_color', '#ffffff' );
?>
register_origin();
if ( $service_token ) {
update_option( 'ai_search_service_token', $service_token );
}
// v1.31.0: search log table + daily retention cleanup.
AI_Search::create_log_table();
update_option( 'ai_search_log_db_version', AI_Search::LOG_DB_VERSION, false );
if ( ! wp_next_scheduled( 'ai_search_purge_logs' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'ai_search_purge_logs' );
}
// Set activation redirect flag
set_transient( 'ai_search_activation_redirect', true, 30 );
}
// Clear the retention cron on deactivation; the table and its data stay.
register_deactivation_hook( __FILE__, 'ai_search_deactivate_plugin' );
/**
* Plugin deactivation handler.
*
* @since 1.31.0
*/
function ai_search_deactivate_plugin() {
$timestamp = wp_next_scheduled( 'ai_search_purge_logs' );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, 'ai_search_purge_logs' );
}
}
// Handle activation redirect
add_action( 'admin_init', 'ai_search_activation_redirect' );
/**
* Redirect to setup wizard after plugin activation.
*/
function ai_search_activation_redirect() {
// Check if we should redirect
if ( ! get_transient( 'ai_search_activation_redirect' ) ) {
return;
}
// Delete the transient so we don't redirect again
delete_transient( 'ai_search_activation_redirect' );
// Don't redirect if activating multiple plugins or doing bulk action
if ( isset( $_GET['activate-multi'] ) || ! current_user_can( 'manage_options' ) ) {
return;
}
// Don't redirect if setup was already completed
if ( get_option( 'ai_search_setup_completed', false ) ) {
return;
}
// Redirect to setup wizard
wp_safe_redirect( admin_url( 'admin.php?page=ai-search&setup=wizard' ) );
exit;
}
// Initialize the plugin (skipped during unit tests — the test bootstrap
// wires up its own stubs and instantiates the class without side effects).
if ( ! defined( 'AI_SEARCH_SKIP_BOOTSTRAP' ) ) {
AI_Search::get_instance();
}