Architecture
Current state as of version 0.1.1. The original design and its rationale are in DESIGN.md; where the two differ, this document describes the code.
Purpose
Section titled “Purpose”Loupe Semantic Search adds meaning-based retrieval to Loupe Search:
- Hybrid search: Loupe’s keyword hits are fused with vector-similarity hits for the front-end
search, the REST
/searchendpoint (used by Loupe Search Block) and theloupe-search/searchability. - RAG retrieval: abilities return relevant passages with source URLs, so an AI agent can answer from site content and cite it.
It is glue code. Embedding, chunking, the sync queue and vector storage belong to the WordPress
ai plugin (1.5.0+). Providers are connected under Settings → Connectors (WordPress 7).
Keyword search, indexability rules and result rendering belong to Loupe Search (1.5.0+).
Not included:
- A vector index. The default store scans every stored vector (see Scale).
- Its own embedding queue, chunker or vector table.
- Facet counts for semantic-only hits (Loupe computes facets from keyword hits only).
- Reranking with a cross-encoder, and term embeddings.
System context
Section titled “System context”Calls into the ai plugin and the AI Client go through
Ai_Adapter. Calls into Loupe Search go through its public hooks,
loupe_search_is_post_indexable() and the WP_Loupe_Utils, WP_Loupe_Search_Engine and
WP_Loupe_DB classes.
Inside the add-on, Retriever reaches Ai_Adapter through Query_Embedder, the vector store and
Passage_Builder:
The diagrams are generated from diagrams/graph.json; see diagrams/README.md to change them.
Components
Section titled “Components”| Component | Responsibility | Implementation | Depends on |
|---|---|---|---|
| Bootstrap | Autoloader, boot on plugins_loaded 20 (after Loupe) |
loupe-semantic-search.php | — |
Plugin |
Service wiring, readiness check, consumer registration, model-change handling, setup notices | includes/Plugin.php | everything below |
Ai_Adapter |
The only caller of the ai plugin and AI Client: availability, model list, query embedding, consumer, backfill, vector iteration, text/hash/chunks |
includes/Ai_Adapter.php | ai plugin |
Settings |
Read, default and sanitize the loupe_semantic_settings option |
includes/Settings.php | — |
Indexability |
Veto embedding of posts Loupe would not index | includes/Indexability.php | Ai_Adapter, Loupe |
Query_Embedder |
Normalize the query, cache the vector, apply limits and timeout | includes/Query_Embedder.php | Ai_Adapter, Rate_Limiter |
Rate_Limiter |
Per-IP per-minute limit and site-wide daily cap on uncached query embeddings | includes/Rate_Limiter.php | Settings |
Vector_Store |
Interface: nearest posts for a vector, best chunk per post | includes/Vector_Store.php | — |
Ai_Repository_Vector_Store |
Default store: full cosine scan of the ai plugin’s vectors |
includes/Ai_Repository_Vector_Store.php | Ai_Adapter |
Retriever |
Query → vector → nearest posts (Loupe-indexable only) → passages | includes/Retriever.php | Query_Embedder, Vector_Store, Passage_Builder, Loupe |
Passage_Builder |
Rebuild a matched chunk’s text, or a stale excerpt | includes/Passage_Builder.php | Ai_Adapter |
Fusion |
Reciprocal rank fusion of two ID rankings | includes/Fusion.php | — |
Hybrid_Search |
Loupe filter callbacks; fuse keyword and semantic hits, fall back to keyword hits | includes/Hybrid_Search.php | Retriever, Fusion, Settings |
Abilities |
loupe-semantic/retrieve and loupe-semantic/search |
includes/Abilities.php | Retriever, Hybrid_Search, Loupe |
Admin\Settings_Page |
Semantic search tab in Settings → Loupe Search, admin-post handlers | includes/Admin/Settings_Page.php | Ai_Adapter, Rate_Limiter, Settings |
CLI\Command |
`wp loupe-semantic status | query | backfill` |
| Uninstall | Delete options and transients; prune vectors unless shared | uninstall.php | ai plugin |
Dependency rules:
- Only
Ai_Adapter(anduninstall.php, which runs without the plugin loaded) may referenceWordPress\AI\…orWordPress\AiClient\…. A change in theaiplugin’s API should touch one file. FusionandSettingsare pure helpers and must not depend on other components.- Services are built by
Pluginand passed to constructors; onlyCLI\Commandreaches back throughPlugin::instance().
Boot sequence
Section titled “Boot sequence”plugins_loaded20:Plugin::boot().- In admin,
Admin\Settings_Pageand setup notices are registered, even when the add-on is not ready. Plugin::is_ready()requires Loupe’sloupe_search_is_post_indexable()andAi_Adapter::is_available()(theaiplugin’s embedding functions and classes exist). Neither checks a version number. If not ready, nothing else is hooked.IndexabilityandHybrid_Searchregister their filters.Abilitiesregisters only when theabilitiessetting is on and a model is saved.CLI\Commandregisters under WP-CLI.
- In admin,
init15:Plugin::register_consumer()registers the embedding consumerloupe-semantic-searchfor the saved target and Loupe’s indexed post types. It must run after post types exist and before theaiplugin’s sync atinit20.init30:Plugin::apply_target_change()finishes a model change (see Model change).
Indexing (owned by the ai plugin)
Section titled “Indexing (owned by the ai plugin)”- A post is saved; the
aiplugin’s sync queues it for every consumer covering its post type. - The
aiplugin askswpai_embedding_sync_is_indexable.Indexability::filter()returnsloupe_search_is_post_indexable()only if this add-on’s consumer covers the post type and no other consumer does; otherwise it leaves the value unchanged. - The
aiplugin builds the text (title + raw content), chunks it (750 characters, 125 overlap, at most 50 chunks, filterwpai_embedding_sync_max_chunks), embeds each chunk and stores the vectors with a content hash. Unchanged posts (same hash) are skipped.
A backfill is started from the settings tab, by wp loupe-semantic backfill, or after a model change.
Front-end search (/?s=) and loupe-search/search
Section titled “Front-end search (/?s=) and loupe-search/search”- Loupe’s
WP_Loupe_Search_Engine::search()merges keyword hits and appliesloupe_search_order_results( $hits, $query, $post_types, $sort ). Hybrid_Search::filter_order_results()returns the hits unchanged when thehybrid_frontendsetting is off, a field sort is applied, or the hits are already fused (_hybridflag).Hybrid_Search::hybridize()asksRetriever::search()forloupe_semantic_candidatesmatches (default 50).Retriever::search():Query_Embedder::embed()strips Loupe operators (quotes,-term,OR) and lowercases the query. It returns a cached vector from theloupe_semantic_qv_*transient (30 minutes), or takes aRate_Limiterslot and callsAi_Adapter::embed()with a 1.5 s timeout.- The
Vector_Store(defaultAi_Repository_Vector_Store) scans the target model’s vectors, keeps the best chunk per post abovemin_similarity(default 0.35), and returns3 × limit + 10matches. - Each match is kept only if its post type was searched and
loupe_search_is_post_indexable()is true.
Hybrid_Search::merge()fuses keyword ranks and semantic ranks withFusion::rrf()(w_k / (60 + rank_k) + w_s / (60 + rank_s), weights from settings). If the query has an exclusion (-term), semantic matches only reorder keyword hits; none are added. Every hit gets_score(fused),_hybrid, and_keywordScore/_semanticwhen available.- Loupe caches the returned hits in its own transient, so the fused list is cached with them.
When the semantic side fails, the keyword hits pass through unchanged (see Failure behavior):
REST POST /search (Loupe Search Block)
Section titled “REST POST /search (Loupe Search Block)”WP_Loupe_RESTappliesloupe_search_advanced_hits( $hits, $query, $context )after its sort and before pagination.$contextcarriespost_types,filter,sort,payload,limitand the callablematches_filter.Hybrid_Search::filter_advanced_hits()skips whenhybrid_restis off, the payload hassemantic: false, or the sort is not relevance (Hybrid_Search::is_relevance_sort()).- As in the front-end flow, plus: when the request has a filter, semantic-only IDs are passed to
matches_filterand dropped unless Loupe says they match the filter. - Loupe drops hits of unsearched post types, raises
totalHitsby the hits added, paginates, and drops posts that are not publicly viewable. Fusion runs on every request; Loupe caches only its keyword hits.
Ability loupe-semantic/retrieve (RAG)
Section titled “Ability loupe-semantic/retrieve (RAG)”Abilities::can_use()allows users who canread(filterloupe_semantic_ability_permission).Abilities::execute_retrieve()limits post types to Loupe’s public indexed types andlimitto 1–20.Retriever::passages()runssearch()withpublic_only(drops posts not publicly viewable), thenPassage_Builder::build()for each match:- If the post’s current text hashes to the stored hash, the chunk at
chunk_indexis returned. - Otherwise (post edited, not yet re-embedded) a plain excerpt is returned with
stale: true.
- If the post’s current text hashes to the stored hash, the chunk at
- Returns
passages(post ID, title, URL, type, date, chunk index, text, rounded score, stale) andmodel.
loupe-semantic/search runs Loupe’s WP_Loupe_Search_Engine directly, then
Hybrid_Search::apply() (ignoring the surface toggles), filters to publicly viewable posts and paginates.
wp loupe-semantic query calls Retriever::passages() like the retrieve ability.
Model change
Section titled “Model change”- The settings form saves
targetasprovider::model;Settings::sanitize()splits it. Plugin::on_settings_updated()(oron_settings_added()on first save) compares provider, model and dimensions. On a change it stores the old provider and model in theloupe_semantic_target_changeoption.- On the next request,
init15 registers the consumer with the new target. init30,Plugin::apply_target_change(): deletes the option, starts a backfill and, if the provider or model changed and no other consumer uses the old one, prunes the old model’s vectors (Ai_Adapter::prune()). A dimensions-only change re-embeds on hash mismatch and prunes nothing.
Settings tab model list
Section titled “Settings tab model list”Admin\Settings_Page::render_model_select() calls Ai_Adapter::embedding_models(), which asks each
connector’s provider for models with the embedding_generation capability. Listing is one HTTP
request per provider, so the result is saved in the loupe_semantic_models_<md5> transient:
- The key hashes each active connector and whether it has credentials, so connecting or disconnecting a provider uses a new key.
- 12 hours when every provider answered; 5 minutes when one threw, so it is asked again soon.
- An empty result deletes the saved list instead of saving it.
- The Refresh model list link (
admin-post.php?action=loupe_semantic_refresh_models) fetches again.
Persistence
Section titled “Persistence”| Data | Store | Owner | Lifetime |
|---|---|---|---|
| Settings | option loupe_semantic_settings |
Settings |
until uninstall |
| Pending model change | option loupe_semantic_target_change (not autoloaded) |
Plugin |
until the next init 30 |
| Daily embedding count | option loupe_semantic_daily_usage (not autoloaded) |
Rate_Limiter |
resets on a new day (site time) |
| Query vectors | transient loupe_semantic_qv_<md5(target, query)> |
Query_Embedder |
30 min (loupe_semantic_query_cache_ttl) |
| Per-IP counters | transient loupe_semantic_rl_<md5(ip)>_<minute> |
Rate_Limiter |
60 s |
| Model list | transient loupe_semantic_models_<md5(connectors)> |
Ai_Adapter |
12 h, or 5 min after a provider failure |
| Chunk vectors and content hashes | the ai plugin’s embeddings table |
ai plugin |
until pruned or the post is removed |
| Sync queue and backfill state | the ai plugin |
ai plugin |
— |
The add-on stores no passage text; it is rebuilt from the post at query time. Settings and transients are per site on multisite.
Failure behavior
Section titled “Failure behavior”Search never fails because of the semantic side. Every error in Hybrid_Search::hybridize() returns
the keyword hits unchanged.
| Failure | Behavior |
|---|---|
| No model saved | Keyword hits unchanged; for abilities and CLI, Retriever::search() returns WP_Error loupe_semantic_no_model |
| Empty query after normalization | Keyword hits unchanged; abilities and CLI get WP_Error loupe_semantic_empty_query (from Abilities::execute_retrieve() or Query_Embedder::embed()) |
| No post types (model saved) | Keyword hits unchanged; Retriever::search() returns an empty array, not an error |
| Rate limit or daily cap reached | WP_Error loupe_semantic_rate_limited; keyword hits unchanged |
| Provider error or timeout (1.5 s) | WP_Error loupe_semantic_embedding_failed; not cached; keyword hits unchanged |
matches_filter throws |
Keyword hits unchanged |
| Post edited since embedding | Passage is an excerpt with stale: true |
| Stored vector has other dimensions than the query | Cosine score 0, so it is dropped by min_similarity |
| A provider fails while listing models | Other providers’ models are shown; list saved for 5 minutes |
ai plugin or Loupe missing |
Admin notice; no hooks besides the settings tab |
There are no retries in the add-on. Embedding retries and backoff during indexing belong to the ai plugin.
Invariants
Section titled “Invariants”| Rule | Enforced by | Verified by |
|---|---|---|
| A post Loupe would not index is never returned, even if its vector still exists | Retriever::search() checks loupe_search_is_post_indexable(); Indexability::filter() stops new embeddings |
IndexabilityTest; acceptance AC-14 |
| Abilities and CLI passages return only publicly viewable posts | Retriever::passages() with public_only; Abilities::execute_search() |
RetrieverTest |
The veto never removes content another ai consumer needs |
Indexability::filter() skips post types covered by another consumer |
IndexabilityTest::test_leaves_shared_post_types_to_other_consumers |
A query with -term never gains hits from the semantic side |
Query_Embedder::has_exclusion() → merge( …, $rerank_only ) |
Hybrid_SearchTest::test_merge_rerank_only_adds_nothing, test_rest_exclusion_query_reranks_only |
Explicit field sorts and semantic: false keep Loupe’s order |
filter_order_results() / filter_advanced_hits() guards |
Hybrid_SearchTest::test_front_end_skips_field_sorts_and_disabled_toggle, test_rest_skips_on_opt_out_and_on_non_relevance_sort; AC-09, AC-10 |
| REST semantic-only hits respect the request filter | matches_filter in hybridize() |
Hybrid_SearchTest::test_rest_drops_semantic_only_hits_outside_the_filter |
| Hits are fused at most once per request | _hybrid flag, already_hybrid() |
Hybrid_SearchTest::test_already_fused_hits_are_left_alone |
| Only uncached query embeddings count against limits; administrators and WP-CLI skip the per-IP limit, never the daily cap | Query_Embedder::embed() checks the cache first; Rate_Limiter::is_exempt() |
Query_EmbedderTest, Rate_LimiterTest |
| Old vectors are pruned only when no other consumer uses that provider and model | Plugin::apply_target_change(), uninstall.php via other_consumer_uses() |
PluginTest (uninstall.php is not covered) |
| A failed or empty model listing is never saved for 12 hours | Ai_Adapter::embedding_models() |
Ai_AdapterTest |
Rate_Limiter counts with read-then-write option and transient updates without a lock, so
concurrent requests can exceed a limit slightly.
Ai_Repository_Vector_Store reads every vector of the target model in batches of 200 and computes
cosine similarity in PHP on every semantic search, including REST searches, passage retrieval, and
queries whose vector comes from the query-vector cache. That cache saves only the embedding call. Only
Loupe’s own front-end result cache skips the scan, because a cached result never reaches
Retriever::search(). The store is practical up to about 20,000 chunks. A faster
backend can replace it through the loupe_semantic_vector_store filter by returning a Vector_Store.
Where to make changes
Section titled “Where to make changes”| Change | Primary location | Also update |
|---|---|---|
Adapt to an ai plugin or AI Client API change |
Ai_Adapter |
uninstall.php if it uses the same API; tests/php/Ai_AdapterTest.php |
| New vector backend | New Vector_Store class, returned from loupe_semantic_vector_store |
README.md Filters |
| Change the ranking formula or weights | Fusion::rrf(), Hybrid_Search::merge() |
FusionTest, Hybrid_SearchTest, acceptance AC-03 to AC-07 |
| Hybrid on a new Loupe surface | Loupe hook + Hybrid_Search callback, toggle in Settings::defaults() / sanitize() and the settings tab |
Hybrid_SearchTest, SettingsTest, translations |
| New setting | Settings::defaults(), Settings::sanitize(), Admin\Settings_Page::render() |
SettingsTest, languages/, README.md |
| New ability or ability field | Abilities::register_abilities() and its execute callback |
README.md Abilities, acceptance J4 |
| Query limits or caching | Rate_Limiter, Query_Embedder |
Rate_LimiterTest, Query_EmbedderTest |
| New CLI subcommand | CLI\Command |
README.md WP-CLI |
| New stored option or transient | Owner class | uninstall.php (transients with prefix loupe_semantic_ are deleted automatically) |
User-facing strings use the loupe-semantic-search text domain. Regenerate languages/ with
wp i18n make-pot, update-po, make-mo and make-php after changing them.
- Unit tests (PHPUnit + Brain Monkey, no WordPress):
composer testorvendor/bin/phpunit, intests/php/. - Acceptance tests (Playwright against a running local site with a real provider):
npm run test:acceptance. The criteria are in tests/acceptance/CRITERIA.md.
📦 Source: soderlind/loupe-semantic-search · Edit on GitHub