Loupe Semantic Search — Design
Status: agreed design for v0.1.0. Implementation follows this document.
Purpose
Section titled “Purpose”Add semantic (vector) retrieval to Loupe Search:
- Hybrid site search. Merge Loupe’s keyword hits with vector-similarity hits for the
front-end WordPress search and the REST
/searchendpoint (used by Loupe Search Block). - RAG retrieval. Expose abilities that return relevant passages with source URLs, so AI agents (for example through the MCP Adapter) can answer from site content and cite it.
Identity
Section titled “Identity”| Item | Value |
|---|---|
| Plugin name | Loupe Semantic Search |
| Slug / folder / text domain | loupe-semantic-search |
| Main file | loupe-semantic-search.php |
| Namespace | Soderlind\Plugin\LoupeSemanticSearch |
| Abilities | loupe-semantic/retrieve, loupe-semantic/search |
| Hook / option prefix | loupe_semantic_ |
| Version | 0.1.0 |
| Requires | WordPress 7.0.3, PHP 8.3 |
| Requires Plugins | loupe-search, ai |
| License | GPL-2.0-or-later |
Architecture
Section titled “Architecture” ┌──────────── ai plugin (WordPress/ai) ─────────────┐ post saved ──────▶ │ Change_Listener → Sync_Queue → Sync_Worker │ │ Post_Source::get_text → Text_Chunker → embed │ │ via Connector provider → Embedding_Repository │ └────────────────────────────────────────────────────┘ ▲ register_embedding_consumer() │ search query ──▶ Loupe keyword hits ──┐ │ ├─┴─ Loupe Semantic Search: embed query, rank search query ──▶ query vector ────────┘ vectors, fuse (RRF), filter, rebuild passagesDependency on the ai plugin
Section titled “Dependency on the ai plugin”WordPress 7 core ships Connectors and the AI Client, but no embedding API. The ai plugin
(1.5.0+) provides generate_embeddings() on top of the connectors and a complete sync pipeline.
Loupe Semantic Search does not implement its own queue, chunker, or vector storage.
All calls into the ai plugin live in one adapter class (Ai_Adapter). It checks the ai
plugin version and the presence of the classes and functions it uses; if anything is missing the
add-on deactivates its features and shows an admin notice. Used surface:
WordPress\AI\supports_embedding_generation(),WordPress\AI\generate_embeddings()WordPress\AI\register_embedding_consumer()(beforeinitpriority 20)Embedding_Sync::start_backfill(),cancel_backfill(),reset_backfill(),get_status(),prune_target(),instance()->get_repository(),instance()->get_registry()Embedding_Repository::iterate(),get_content_hash()Text_Chunker,Object_Processor::hash_text(),Vector_Math/Vector_Ranker- Filters
wpai_embedding_sync_is_indexable,wpai_embedding_sync_max_chunks
Provider and model
Section titled “Provider and model”- The admin connects a provider under Settings → Connectors (WordPress 7). Without a connected provider that offers embedding models, the add-on is inactive and shows a notice linking to Connectors.
- The settings page lists only connected providers’ models with the
embedding_generationcapability. The admin must choose one; nothing is selected automatically. - Optional dimensions setting, passed through when the model supports it.
- Changing the model: register the consumer with the new target, start a backfill, and prune the old target’s vectors once no other consumer uses it.
Indexing
Section titled “Indexing”- Consumer ID
loupe-semantic-search, objects[ 'post' => <Loupe post types> ], where the post types come fromloupe_search_post_types(via Loupe’s utilities). - Indexability follows Loupe. Loupe Search gets a public helper
loupe_search_is_post_indexable( WP_Post $post ): boolwrapping its existing checks (post type, indexable statuses, password protection,loupe_search_is_indexable). We hookwpai_embedding_sync_is_indexableand veto a post that Loupe would not index.- The filter is shared by every
aiconsumer, so the veto only applies when no other consumer covers that post type. Otherwise the other consumer would lose content it needs.
- The filter is shared by every
- Text, chunking and hashing are the
aiplugin’s: title plus raw content as plain text (the_contentis not applied),Text_Chunkerdefaults (750 characters, 125 overlap), and one content hash per post. Unchanged posts are not re-embedded. - Backfill: started from the settings page, or with
wp ai embeddings sync backfill loupe-semantic-search. Our WP-CLI command addsstatusandquery(see below).
Retrieval
Section titled “Retrieval”Vector search
Section titled “Vector search”Vector_Storeinterface with a defaultAi_Repository_Vector_Store. Selectable through theloupe_semantic_vector_storefilter so a native vector backend can replace it later.- Default store: iterate the model’s vectors in batches of 200 with
Embedding_Repository::iterate(), computing cosine similarity and keeping a running top-K. Theaitable has no vector index, so this is a full scan; about 20,000 chunks is the documented practical limit. - Keep the best chunk per post. Drop chunks below the minimum similarity (default 0.35).
- Query vectors are cached in a transient for 30 minutes, keyed by provider, model, dimensions and a hash of the normalised query.
Passage text
Section titled “Passage text”Stored records contain vectors, not text. For the top hits only:
- Rebuild the text with the
aiplugin’s post text source (same filter chain). - Compare
Object_Processor::hash_text()with the stored hash. - If they match, chunk with
Text_Chunker, apply the same max-chunks limit, and take the chunk at the storedchunk_index. - If they differ (content changed and not yet re-embedded), fall back to the post excerpt and
mark the passage
stale: true.
Hybrid ranking
Section titled “Hybrid ranking”- Reciprocal rank fusion:
score = w_k / (60 + rank_keyword) + w_s / (60 + rank_semantic). Default weights 1.0 and 1.0, set in settings. - Fallback to keyword-only when embedding the query fails, takes longer than 1.5 s, the rate limit or daily cap is hit, or no model is configured. Fallbacks never throw to the visitor.
Where hybrid applies
Section titled “Where hybrid applies”| Surface | Hook | Default |
|---|---|---|
| Front-end WordPress search | loupe_search_order_results (existing) | on |
REST /search and Loupe Search Block | loupe_search_advanced_hits (new in Loupe Search) | on |
| Abilities | own registration | on once a model is chosen |
- A REST request can opt out with
semantic: false. - Hybrid is skipped when the request sorts explicitly by a field other than
_score, or by geo distance: the caller asked for that order, not relevance. The front-end filter gets the applied field sort as its fourth argument for the same check. - REST filters: semantic-only hits are kept only if the context’s
matches_filtercallback returns them. Loupe runs the request’s own filter string as an empty-query search, so every filter Loupe supports (taxonomies, custom fields, dates, geo) applies. Loupe caps that lookup at 1000 matches; with a broader filter some semantic-only hits may be dropped, never wrongly added. - Facet counts stay as Loupe computes them from keyword hits (known v1 limitation).
- Every semantic hit is checked with
loupe_search_is_post_indexable()at query time, because bulk exclusion may change only post meta and the vector may still exist. - Query operators: quotes,
-termandORare stripped before embedding. If the query contains an exclusion (-term), semantic ranking only reorders keyword hits and adds none, so excluded terms never come back through the semantic side. loupe_search_order_resultsruns before Loupe caches front-end results, so hybrid results are cached together with the keyword results. When the semantic side falls back, keyword-only results may be cached for the cache lifetime (accepted v1 limitation). REST keeps caching only Loupe’s keyword results; fusion runs on every request.
Abilities
Section titled “Abilities”Category loupe-semantic. Both default to current_user_can( 'read' ); the
loupe_semantic_ability_permission filter can open them up.
loupe-semantic/retrieve
Section titled “loupe-semantic/retrieve”Input: query (required), limit (default 5, max 20), post_type (optional string or list).
Output:
{ "passages": [ { "post_id": 12, "title": "…", "url": "…", "post_type": "post", "post_date": "…", "chunk_index": 2, "text": "…", "score": 0.71, "stale": false } ], "model": { "provider": "openai", "model": "text-embedding-3-small" }}loupe-semantic/search
Section titled “loupe-semantic/search”Same input and output shape as loupe-search/search (hits, total_hits, page,
total_pages), but hybrid ranked and limited to public post types.
Limits and cost
Section titled “Limits and cost”- Per-IP rate limit for anonymous query embeddings: 30 per minute (
loupe_semantic_rate_limit). - Site-wide daily cap on embedding calls: 5,000 (setting,
loupe_semantic_daily_cap). - Cached query vectors do not count against either limit.
- Logged-in users with
manage_optionsare exempt from the per-IP limit.
Settings page
Section titled “Settings page”Settings → Loupe Search → Semantic search tab (manage_options), added through Loupe Search’s
loupe_search_settings_tabs filter. The old ?page=loupe-semantic-search URL redirects to the tab.
- Provider and model picker, dimensions.
- Hybrid toggles per surface, keyword and semantic weights, minimum similarity.
- Rate limit and daily cap.
- Index status from
Embedding_Sync::get_status(): coverage per post type, queue, backoff, provider errors, last run. Buttons: Start backfill, Cancel, Reset.
Option name: loupe_semantic_settings (single array, autoloaded).
WP-CLI
Section titled “WP-CLI”wp loupe-semantic:
status: settings, target model and index coverage.query <text> [--limit=<n>] [--format=<format>]: run a retrieval and print passages.backfill [--reset]: wrapper around theaiplugin’s backfill for our consumer.
Changes to Loupe Search
Section titled “Changes to Loupe Search”Released as Loupe Search 1.5.0.
- Filter
loupe_search_advanced_hits( array $hits, string $query, array $context )in the RESTPOST /searchhandler, after the merge-level sort and before pagination. Context keys:post_types,filter,sort,payload,limitandmatches_filter(callable: post IDs in, IDs matching the request’s filter out). Loupe drops added hits of unsearched post types, still runs the public-visibility check, and raisestotalHitsby the number of hits added. loupe_search_order_resultsgains two arguments: the searched post types and the applied field sort (backwards compatible).- Public function
loupe_search_is_post_indexable( WP_Post|int $post ): bool, backed by the staticWP_Loupe_Indexer::is_post_indexable()(no Loupe instances are created).
WordPress 7 requirement
Section titled “WordPress 7 requirement”Requires at least: 7.0 in Loupe Search, Loupe Search Block, Loupe Cross-Site Search and
Loupe Admin Search, each with a minor version bump and changelog entry.
Uninstall
Section titled “Uninstall”- Delete
loupe_semantic_settingsand our transients. - Prune our model’s vectors with
Embedding_Sync::prune_target()only if no other registered consumer uses the same provider and model.
Project layout
Section titled “Project layout”loupe-semantic-search.phpincludes/ PSR-4 classes (Composer autoload)languages/ .pot and nb_NO translationstests/php/ PHPUnit + Brain Monkeydocs/DESIGN.mdREADME.md, readme.txt, CHANGELOG.md, uninstall.phpLocal git init only; the GitHub repository is created later.
Out of scope for v0.1.0
Section titled “Out of scope for v0.1.0”- Native vector index backends (sqlite-vec, MySQL 9
VECTOR, external stores). - Recomputing facet counts for semantic-only hits.
- Cross-encoder reranking.
- Term embeddings.
📦 Source: soderlind/loupe-semantic-search · Edit on GitHub