Semantic Search (Dashboard → Settings → Search, member feature, on by default for members) makes the AI chat assistant understand what a visitor means, not just the words they typed — and puts the same engine into site search. By default site search runs Both: keyword matches appear instantly and the pages that match by meaning are appended under them a moment later, with a Both / Keywords / Meaning toggle on the search overlay, the header search bars and the dashboard's ⌘K palette.
A visitor asking "my lights keep flickering" has described a problem in their own words. An electrician's site says "electrical repair", "panel replacement", "wiring". The two share no word at all, so keyword search returns nothing and the visitor concludes the site can't help. The chat assistant finds the page anyway, because retrieval compares meaning rather than spelling. So does site search, under its keyword results.
Visitor search: Both by default — keyword at once, meaning appended
Site search — the /search page, the header search bars, the ⌘K overlay — has three methods (VisitorSearchMode):
| Mode | Value | What runs |
|---|---|---|
| Both (the default) | hybrid |
The keyword list, instantly, plus whatever meaning matched that keyword search did not, appended underneath as one group a moment later. |
| Keywords | keyword |
Keyword matching only (Standard LIKE, MySQL FULLTEXT, or Meilisearch, whichever the Search settings page has picked). Never reaches the provider. |
| Meaning | semantic |
The meaning matches lead and the keyword groups follow. One slower step: the visitor waits for the query to be embedded before anything shows. |
Keyword matching is instant; the semantic layer is the slow engine, because it has to embed the query through a provider before it can score anything. Both is the default because it spends that wait where nobody is waiting on it: the visitor reads the keyword results while the meaning half is still on its way, and the result keyword search missed — "my lights keep flickering" on a site that says "electrical repair" — turns up under them without anyone flipping a switch.
The settings
Two controls on Settings → Search → Semantic Search → Site search, both saved on change (each save clears the response cache, because every public page bakes the toggle flag and the opening mode into the overlay and the header bars):
| Control | Setting | Default | What it does |
|---|---|---|---|
| Search opens in — Both / Keywords / Meaning | search.visitor_mode |
Both | The mode every visitor surface opens in. |
| Let visitors switch search method | search.semantic_visitor |
on | Renders the Both / Keywords / Meaning toggle on the overlay and the header bars, and lets /_search and /search honour a mode other than the default. |
| (legacy, read only) | search.semantic_default |
— | The pre-hybrid "open the overlay in meaning mode" switch. Nothing writes it any more; it is read only while search.visitor_mode has never been saved (below). |
Both controls are inert unless the layer is actually usable (feature on and a provider that can embed): an install that cannot serve meaning runs keyword whatever is stored. A choice saved while the layer can't serve it still saves, and comes back with the feature.
Resolution is server-side. VisitorSearchMode::resolve() decides what every request actually runs, and PublicSearch re-resolves rather than trusting the caller:
- No
modeparameter — or junk, or an array — means the owner's default, so on most installs/search?q=…runs Both. Every interactive surface therefore sendsmodeexplicitly; none relies on "absent means keyword". - Keyword is always honoured (it never reaches the provider, and it is the instant half every Both surface asks for first). The default is honoured as itself. Any other mode needs the toggle to be offered — so an owner who picked Keywords and turned the toggle off has an install where no visitor path reaches the provider, crafted
?mode=hybridincluded. - Internal callers keep keyword.
PublicSearch::search()'s own default iskeyword, so the chat assistant's tools (search($q, 5)) never start embedding through it; they run their own retrieval.
Installs that predate the mode setting keep the choice their owner made. Until search.visitor_mode is first saved, the default is read from the old two switches: semantic_default on ⇒ Meaning; a stored semantic_visitor off (an owner who withdrew meaning) ⇒ Keywords; otherwise Both. That is a read-time fallback, not a migration, so there is no update-order window — and the new default can never start sending a withdrawn install's searches to a provider. Turning the toggle switch off on an install that never chose a mode pins the current default first, so the fallback can't then read the switch and drop the site to Keywords.
The privacy policy follows the setting, not the toggle. Search by meaning is disclosed whenever the owner's settings let any visitor search run it — by default or by switching (VisitorSearchMode::meaningAllowedByOwner(), read by TrackingLegalClauses) — because Both with no toggle sends every search to the provider.
What each mode returns
Both is the keyword result exactly as Keywords returns it — spelling correction included — then ONE group appended at the end: "Also matched by meaning", or "Matched by meaning" when keyword search found nothing and it is the whole answer. It lists only what the keyword groups (pinned results included) do not already show, compared by URL path so /about, /about/ and an absolute URL to the same page are one result. Retrieval is over-fetched by what will be skipped, so the group still fills when the best meaning match is a page keyword search already listed. It is appended, never inserted: the interactive surfaces have already painted the keyword rows, and a group arriving above them would move the row under the visitor's cursor. Nothing new by meaning — the keyword list already covers it, the site doesn't answer it, or the provider is mid-outage — means no group and a keyword result left byte-for-byte as it was. The meaning half is scored against the spelling keyword search actually ran: an auto-corrected typo is embedded corrected.
In the header typeahead the keyword rows are capped exactly as in keyword mode (PublicSearch::SUGGEST_MAX_ROWS), so that half is identical to a Keywords answer; the meaning group is deduped against the rows the panel shows — a page keyword search ranked past the cut still surfaces by meaning — and carries a cap of its own after the cut (SUGGEST_MEANING_ROWS, 4). On a shop it is exempt from the product-group hoist and stays last.
Meaning leads with the "Matched by meaning" group — one row per page or record, ranked by its best passage, the passage as the snippet (SemanticSearch::retrieveDocuments()) — and drops those URLs from the keyword groups that follow, so nothing lists twice and switching never loses a result. Pinned results still lead everything: an editor's exact-term curation outranks an inference. On a shop, the meaning group also keeps its lead over the product groups the typeahead normally hoists, because the row cap would otherwise cut the one group the visitor switched for. When meaning finds nothing the keyword results are left exactly as they were and the overlay says so ("Nothing matched by meaning — showing keyword matches."), so the toggle never makes a search worse than the mode it left and never looks like it did nothing.
Both on an interactive surface: two requests, one swap
The overlay and the header-bar typeahead share one implementation of the mode rules and the request flow, search-modes.js, so the two can never disagree (locked by SiteSearchModeFlowTest, which drives it in node with a fake fetch and clock). Both is two requests per settled query:
mode=keywordon the usual 200 ms debounce — painted the moment it lands.mode=hybridon a 450 ms debounce — the full Both answer, which the client swaps in wholesale. The server keeps the keyword groups identical and puts the extras last, so the swap moves nothing already on screen and the highlighted row stays where it was.
While the second half is out, a "Searching by meaning…" row sits under the keyword list — and an empty keyword list reads "Searching by meaning…", not "No results", until it answers. The rules that make it robust: a late answer for an earlier query lands nowhere (each new query bumps a generation counter); the keyword half arriving after the full answer never overwrites it; a failed or throttled full answer leaves the keyword list standing, and a failed keyword half waits on the full one rather than blanking the panel. /_search shares one throttle:120,1 for both halves — deliberately not raised, because the second half is the one that reaches the provider — and echoes the mode it actually ran in, so a downgraded request is never mistaken for a miss.
Where the mode travels
A visitor's pick on a toggle sticks for the tab (sessionStorage siteSearchMethod, shared by the overlay and the header bars). Only an explicit pick is ever written, and a stored value the owner no longer offers falls back to the default. Not the two-way toggle's siteSearchMode: a keyword stored there was a pick against meaning-only, made before Both existed, and must not pin a tab to Keywords — the same reason the palette renamed its session key. Enter, "See all results" and the header form's hidden mode input carry the current mode onto /search only when the toggle is offered — without it the panel runs the owner default, which is exactly what /search with no parameter runs, so a free install's URLs never grow a &mode=keyword.
The /search results page renders Both server-side
site_search() resolves the request's mode with VisitorSearchMode::fromRequest(), so a link with no parameter gets the owner default and the page renders Both in one pass — keyword groups, then the meaning group through the results row's existing group loop. There is no JS injection into the results row, because that row is materialised per install and can't be changed without a row edit and a fleet sweep. The extra wait is normally nil: a visitor usually arrives from the overlay or the typeahead, which has already embedded that exact query (query embeddings are cached a week); a cold direct link pays one embedding round-trip.
The page's own search bar is a /search form, so it gets the injected panel and toggle too, with two rules that make it feel native: it opens in the mode the page rendered — the URL's, or the owner default when the URL has none — never the tab's remembered pick (a toggle that disagreed with the results under it would lie); and flipping it with a query on screen re-submits, so the whole page re-renders in the other method.
Known gap: the baked results row's "Did you mean" link is route('search', ['q' => …]), so following it drops the mode and lands on the owner default. Fixing it needs a row change and a fleet sweep; it was left as is.
Headers with a built-in search bar
They get the toggle without a single header row changing. The 36 design-library headers with a bar render a plain <form action="/search"> with a q input; everything interactive under it is injected at runtime — public.js clones the panel from the git-tracked search-suggest template and binds search-suggest.js to the form's own input, which is how the typeahead and typo correction reached every materialised header. The toggle is the panel's last row, so it appears the moment a visitor has results to judge, and the form still submits natively, carrying the mode as above. Shop headers stay keyword-only, with no toggle: a bar that opts in with data-search-suggest-form posts to the product grid, which is a keyword grid — a meaning preview would promise results Enter can't show.
The dashboard ⌘K palette
GlobalSearch / CommandPalette has the same three modes and the same default, Both, gated on the layer being usable rather than on the visitor switch (that Setting is about the public site). Meaning hits point at each record's dashboard editor, with the matched passage as the subtitle — for the page an editor remembers by what it says rather than by its title.
It is Livewire rather than search-modes.js, with the same semantics:
- Keyword paints first. Each query renders its keyword groups at once — never waiting on the provider — with a "Searching by meaning…" row under them (or in place of "No results" when nothing matched by keyword).
- The meaning half is asked for by that row. It is keyed per query and calls
loadMeaning(query)once the editor has paused on it (250 ms after the input's 200 ms debounce — the public surfaces' 450 ms). Waiting matters more here than on the public side: Livewire holds the next keystroke's update until a call already in flight returns, so asking while the editor is still typing would hold the next keyword list up behind the provider. - The meaning group is appended.
loadMeaning's render returns the same keyword groups with "Also matched by meaning" after them, deduped by editor URL; the result list is keyed by mode + query, so it morphs in place and nothing moves. A provider outage ends the wait with the keyword list unchanged. AloadMeaningrequest that fails outright (a 5xx, an expired session, the network) never renders, so the row removes itself rather than spin until the editor types again, and the keyword list stays. Livewire's error overlay is held back for that one action ($wire.intercept('loadMeaning', …)in the palette's Alpine component): a background ask the editor never made must not cover the dashboard with an error page. Every other palette request keeps the default overlay. - A stale answer lands nowhere. The answer counts only while it names the query on screen, and every new query starts over at its keyword step — even one whose meaning ran before.
- Only an explicit pick persists, for the session, under
dashboard_search_method. Not the two-way toggle'sdashboard_search_mode: akeywordstored there was a pick against meaning-only, made before Both existed, and must not pin an editor to keyword.
Meaning mode in the palette is one slower step that leads with the meaning group straight after the "Go to" shortcuts. The palette does no typo correction (it searches admin models the public vocabulary doesn't cover).
History
Until 2026-08 a meaning-based rerank finished every /search request unconditionally: keyword search decided what matched and the semantic layer reordered it. It was removed because its cost-benefit never closed — the visitor waited on an embedding round-trip per distinct query, on the surface where they want speed, and it could never ADD a result, only reshuffle ones keyword search had already found. A Keywords / Meaning toggle replaced it (keyword by default, meaning only when asked for), and Both (2026-10) is the default that toggle made possible. Both does spend an embedding per distinct settled query by default — the cost the rerank was removed over — but buys what the rerank never could on both counts: nobody waits on it, because the keyword half paints first, and what it adds is the result keyword search missed. Don't reintroduce an unconditional rerank on a visitor-facing path, or anything that makes the keyword half wait, without confronting that history.
Two properties of retrieval do the safety work the old rerank's rules used to do:
- Retrieval is additive and separate. The chat tools run keyword search untouched and append a clearly-labelled "matched by meaning" group beside it; site search's Both appends the same kind of group after the keyword groups and Meaning leads with it. Both dedupe rather than reorder: a keyword hit is never reordered, demoted, or dropped by a similarity score.
- Retrieval applies a relevance floor (below), because its output feeds a model — and a "matched by meaning" group that listed the nearest unrelated page would teach visitors the toggle is noise.
What gets embedded
| Source | Notes |
|---|---|
| Pages | From page_search_entries, which is already extracted, already per-language, and already carries required_role. |
| Content items | Blog, services, events, locations, and every custom content type. Published and listed only. |
| Documentation articles | Default version only, matching public search. |
| Products | Published only. |
Everything else is deliberately left out. Those are short, structured records where an exact filter already beats a similarity score — "3-bed under $400k" is a query for the listings filter, not for an embedding — and retrieval's one consumer, the chat assistant, wants prose passages that answer a question, which those records don't hold.
Role gating is not optional
Page copy can sit behind auth or a role gate. page_search_entries records that as required_role, and it is copied onto every embedded passage and enforced on every read.
Without it the public chat bot could quote an admin-only page to an anonymous visitor. That is a disclosure, not a ranking bug, so the default role is 0 — a caller that forgets to pass one gets the public slice, never everything.
A page that later moves behind auth keeps its text, so its content hash is unchanged and nothing would re-embed it. The indexer catches that separately and updates the stored role in place, at no API cost.
How indexing works
Indexing runs on the lazy cron (semantic:embed, every 5 minutes), so new and changed content is picked up within a few minutes of a save with no queue worker and no cron entry required.
Each pass is bounded — 40 passages by default — because the lazy cron executes inside a visitor's page request. Enumeration is lazy and abandoned the moment the budget is full, so the cost the limit exists to bound is not paid up front. Whatever is left is picked up on the next pass. Same reasoning as the Blog Agent's one-task-per-tick rule.
Staleness is a content hash and nothing else. There is no observer and no invalidation event: the sources are already kept correct by their own, so following them means an out-of-band change — a raw SQL edit, a snapshot restore, a content import — self-heals on the next pass instead of leaving a vector that confidently describes deleted copy.
Passages are chunked at roughly 900 characters with overlap, and every chunk carries its document's title. A paragraph three screens down often names none of the subject nouns ("we replace the panel and test the circuit") and is unfindable on its own; the title is the cheapest possible context.
A document scores as its best passage, never its average. Averaging would penalise a long page that answers the question in one paragraph, which is exactly the case chunking exists to handle.
Where the vectors live
In the embeddings table, as BLOBs of packed little-endian float32.
WebProCMS runs on MySQL, which has no vector column type — and laravel/ai's vector columns, whereVectorSimilarTo() and orderByVectorDistance() are all pgvector-only. So both the storage format and the distance metric are ours, and comparison happens in PHP.
That is only viable because the vectors are deliberately narrow: 256 dimensions, truncated from the model's full-width output, about a kilobyte each. A whole site is a couple of thousand passages, which PHP scores in single-digit milliseconds. Vectors are unit-normalized on write, so every comparison at read time is a bare dot product.
Vectors from different models or different widths are not comparable to each other. Changing either means a rebuild, which is what the Rebuild button on the Search settings page is for.
Where the embeddings come from
Almost always the managed AI proxy, on the broker's own OpenAI key, with no provider key on the install at all. Embeddings are a separate managed upstream from text and images — Claude has no embeddings endpoint, so a fleet brokering text through Anthropic needs a different house account for vectors, exactly as it does for pictures.
An install running its own keys falls back to its OpenAI key, then its Google key. Anthropic and DeepSeek can't embed at all, so an install whose text provider is Claude and which holds no other key will show "no provider can generate embeddings yet" and stay in keyword order — working, just not smarter.
Query embeddings are cached for a week. Scoring a search means embedding the query, which is the one place in this system a visitor waits on a provider round-trip, and real search traffic repeats itself heavily.
Retrieval for AI surfaces
SiteKnowledge::retrieve($query, $k) returns the top-k passages — not documents — with their titles and URLs, language-scoped and role-gated. It is the content counterpart to SiteKnowledge::forPrompt(): the Brief tells an AI surface who it is speaking for, and this tells it what the site actually says.
SemanticSearch::retrieveDocuments($query, $k) is the search-surface twin: the same scan, collapsed to one row per document (ranked and snippeted by its best passage, with the candidate pool scanned deeper to make up for the collapse). A long page answers a question in several adjacent chunks, and a results list that showed it three times would read as broken.
SiteKnowledge::retrievedForPrompt($query) returns the same thing formatted for a system prompt, with source URLs so the model can cite them.
Retrieval applies a relevance floor. Cosine similarity has no zero point — the nearest passage on a site that says nothing about the question still scores well above zero. On a real install, passages that genuinely answered a question scored 0.44–0.61 while the best match for a question the site never addresses scored 0.33. Handing an AI three unrelated passages is worse than handing it none, because it will answer from them and sound certain. So retrieval drops anything under 0.35 (tunable via the semantic_search.min_relevance Setting, since the useful value depends on the model).
The chat bot
The visitor-facing half of this is written up in ai-chat.md under "Search by meaning, not just keywords"; what follows is the mechanism.
search_site_content runs the plain keyword federation, then appends a "matched by meaning" group from retrieval on every call — deduped by path against the keyword results, role-gated to the current visitor, and degrading to nothing (never an error) when no embedding provider is available.
Both search tools take two inputs, because one string can't serve both engines. query stays short and keyword-shaped — the LIKE/FULLTEXT index narrows on every added word. question carries the visitor's need as one full sentence in their own words, and it is what retrieval scores: meaning-matching is at its best on exactly the phrasing keyword search is worst at ("someone tricked my employee into wiring money" finds the fraud-prevention content; the keyword "wiring" finds audio-visual pages). Before the split, the model's dutifully-compressed keyword query was also what fed retrieval, so the semantic layer never saw the sentence-shaped input it exists for. With no question passed, retrieval falls back to scoring the keyword query.
search_knowledge_base goes semantic-first, and it is the single biggest beneficiary — it was a bare LIKE '%term%' that didn't even use the search engine setting. Support questions almost never repeat the wording of the article that answers them: "it keeps logging me out" against a page titled "Session timeout" shares no term at all. Keyword remains the fallback, because it is still better for an exact lookup like an error code or a setting name.
What it does not do
Worth stating plainly, because all of these get assumed:
- It never holds up keyword results. Both — the default — paints the keyword list first and appends meaning when it lands; Keywords never reaches the provider at all; only a Meaning pick makes anyone wait. An owner who wants no visitor search sent to the provider picks Keywords and turns the toggle off — see "Visitor search" above.
- It does not fix typos. Embeddings tolerate mild misspellings only incidentally, and fail on short queries. Typos are handled by a different layer: the database engines' spelling correction (see search.md "Typo correction"), which auto-corrects dead-end queries and offers "did you mean" links — or Meilisearch's native per-keystroke tolerance on the Smart Search add-on.
- It does not help with structured queries. "3-bed under $400k", "open after 6pm", "in stock under $50" — the existing filters are better, and those sources are deliberately not embedded.
- It cannot answer what the site never says. If the content isn't there, retrieval correctly returns nothing rather than the nearest unrelated page.
The whole feature is gated, data layer included
AI Knowledge gates its editing UI but deliberately leaves its data layer ungated, because free installs have migrated chat-bot instructions in there that must keep working.
Semantic Search does the opposite: the gate covers everything. Retrieval returns nothing and indexing does not run when the feature is off. The reason the two differ is that there is no pre-existing data here to protect — a free install has never had an embedding, so an ungated data layer would only mean buying vectors nobody can use. This was a decision, not an oversight; the ai_assistant split is the exception, not the pattern.
Operations
| Action | How |
|---|---|
| Build the index now | Search settings → Semantic Search → Build index (unlimited; the admin who clicked it is the one waiting) |
| Rebuild from scratch | Rebuild — required after an embeddings model or width change |
| From the CLI | php artisan semantic:embed --limit=0 (--flush to rebuild, --force to run with the feature off) |
| See whether it has caught up | The same card: "Still building the index…" while a pass is still working through the backlog, the provider's error when one is the reason, or "Caught up as of …". Deliberately point-in-time — content added since that pass is un-embedded until the next tick |
| Check volume | The passage / record / language counts on the same card. A count is not coverage: an install 28% through a backfill still reads a healthy-looking "1,350 passages indexed", which is why the catch-up line above exists |
| Choose the mode visitor search opens in / let visitors switch | Search opens in and Let visitors switch search method under Site search on the same card. Saved on change; each save clears the response cache, because every public page bakes the toggle flag and the opening mode into the overlay and the header bars |
Turning the feature off leaves the stored vectors alone; the chat assistant falls back to keyword-only matching immediately, the Both / Keywords / Meaning toggle disappears from the overlay, the header bars and the palette, and every surface runs keyword whatever Search opens in says.