A client sends a screenshot of a search box that understands "late cancellation" when the help article is titled "refund windows". The app underneath is Laravel. Search already runs through Laravel Scout, usually Meilisearch or Typesense, and it is fine when the visitor types the same words the editor used. The request sounds like "add AI search". The useful version of that request is narrower: keep the index you have, match by meaning on the content types where wording drifts, and do not mix one tenant's records or API bill with another's.
Laravel 13's September framework work, written up on 2 October 2026, is the part we would actually turn on. Scout can run semantic and hybrid queries on Meilisearch and Typesense. Ai::build() can construct a model provider from a key loaded at runtime, including a key stored on the tenant, and that provider can sit in a failover list. The notes are in Laravel's September product update. The behavior we build against is in the Scout docs and the AI SDK docs.
Where hybrid search earns a place
Keyword search stays on order numbers, SKUs, email addresses, and admin tables. People looking those up know the string. Hybrid search is for the help center, the course catalog, and the service finder, where someone describes a problem in their own words.
Hybrid still weighs the words that were typed. It also weighs nearby meaning. A semantic-only query is a poor fit for names and codes, and it is a poor fit for a two-word search. We enable hybrid on the content models that fail in real query logs, and we leave the rest on the text index.
Embeddings are produced when a record is published or updated. The visitor's request should return hits, not wait on a model. Meilisearch can generate those vectors with its own embedder, which keeps a simple catalog off our model bill. When the same embedding model has to serve a later summary feature, Scout can generate the vector through Laravel AI instead. One path per index. Two paths in one index makes a missed result impossible to explain on a call.
Meilisearch and similar engines also finish indexing after Laravel has returned. A publish can be a few seconds ahead of search. The editor screen should say the article is queued for search, rather than implying the public box is already current.
Each tenant brings its own key
On a portal used by clinics, associations, or franchisees, one shared API key hides who spent the money and puts every tenant on the same rate limit. Ai::build() accepts a provider config in the same shape as an entry in config/ai.php, so the key can come from the tenant row for that request only.
We store it encrypted, editable by an owner role, absent from logs, and absent from the React app. The browser talks to our API. The API resolves the tenant and builds the provider there.
Two production details decide whether this survives the first week. A tenant can list their own backup provider, because a built provider is valid inside a failover list. And embedding a batch of articles belongs on a queue. The worker does not still hold the web request, so the agent has to rebuild the provider from its provider() method when the job runs. The AI SDK docs call this out for queued agents. It is the first test we write: enqueue a job for tenant A, assert the embedding call used tenant A's key.
A tenant with no key still gets search. An open-ended summary stays off, or it runs on a platform key with a monthly cap the client can see on an admin screen. We do not quietly bill our key for every tenant's chatter.
The filter is the authorization
A vector match does not know what a tenant is. If two clinics share an index, a similar note from the other clinic will surface unless the query filters on tenant first. Every searchable model carries that id. Text, semantic, and hybrid queries all apply it. Staff limited to one location get a second filter. We test with two tenants and a question written so the other tenant's private article is the closer match. If that title appears, the feature is not finished.
On Meilisearch this is configuration, not just a where. Filterable attributes are declared in the index settings and pushed with scout:sync-index-settings. A tenant id that was never marked filterable is not a safety check. We put that sync in the deploy steps so a new environment cannot boot with a wide-open index.
The same boundary holds when the public site is WordPress or Joomla and the product is Laravel. Editors stay in the CMS. A publish hook sends the public article into Scout. Drafts, private member posts, and trashed pages stay out. The model never reads the CMS database directly.
What the screen should do
The React page, and an Android or iOS app on the same endpoint, shows the record title, the section, and a link. A short answer, if we generate one, is allowed to use only those hits, and the hits stay on screen under the paragraph. A weak match is an empty state with the filters still available: nothing close, try a name or a category. That state is the same component on web and mobile, so a small phone does not get a chatty paragraph the desktop refused to show.
Search itself does not call a generative model. "Ask about these results" is a separate action. The list can paint immediately, which matters on a slow mobile connection, and the expensive call happens only when someone wants a paragraph.
What belongs in a later project
We index one content type first, usually published help articles or the catalog. We compare hybrid results with the current keyword results on twenty queries the client writes from their own support inbox. Then we decide whether a summary button is worth the key management.
A chat over every PDF the company has ever emailed is a different build. It needs chunking, citations, and a retrieval store sized for documents, which we scope on its own. A customer-facing search box also does not get a tool that runs code at the model provider. Search retrieves records the signed-in user is already allowed to open.
If the app is still on database LIKE queries and has no Scout engine, we say that before talking about vectors. Typo tolerance and real filters are often the first win. Semantic search comes after the text index is something the client trusts.
The rollout we would put in a proposal is short. Confirm the Scout engine and which models are public. Add the tenant filter tests and the Meilisearch filterable attributes. Index one content type and review it against those twenty real queries. Only then connect Ai::build() for an optional summary, with the queue worker reconstructing the tenant provider. That is a feature we can ship inside the Laravel app the client already runs, from our studio in Lalitpur, without renaming it as a new product.