Search retrieval pipeline — developer and operator guide
How an orchestrated search request becomes a ranked list. Adaptive text matching (fuzziness, required terms, engine translation) is a separate concern documented in SEARCH_MATCHING_DEVELOPER.md and SEARCH_MATCHING_USER.md.
Entry point and mode
CrudService::search() picks the path from SearchRequestData->mode (mode query parameter,
default auto):
mode |
Path |
|---|---|
orchestrated |
always AdvancedSearchService |
auto (default) |
AdvancedSearchService when available() (engine implements ISearchEngine and supportsOrchestratedSearch()), otherwise plain Scout |
basic |
always plain Scout, no orchestration |
An engine that does not support orchestration returns AdvancedSearchResult::empty() with
meta['unsupported_driver'] = true when orchestrated was forced.
Search modes: how much a request spends
AdvancedSearchService::search() takes a SearchMode ($mode, default fast) and asks one contract,
ISearchStrategyResolver, for the components of this request. The resolver returns a SearchStrategy: planner,
reranker, intent parser, an optional embedder and a retry cap. The service holds no planner, reranker or
embedder of its own, so the cost of a search is decided per call, never at boot.
| Mode | What it runs | Cost |
|---|---|---|
fast (default) |
Core's FallbackSearchPlanner, HeuristicReranker, SimpleQueryIntentParser; no query vector |
milliseconds, no external call |
balanced |
the same, plus the query embedding (the AI module's SearchEmbedder), so lexical and vector retrieval are fused |
one embedding call, about 80 ms once the model is warm |
deep |
LLM intent parsing and planning, cross-encoder reranking when the plan asks for it, the embedding, and up to two retries | seconds per LLM call, real money on a paid provider |
Core binds CoreSearchStrategyResolver, which ignores the mode and always returns the fast set: Core has
no embedder and no LLM. A module that can do more rebinds ISearchStrategyResolver (the AI module binds
AiSearchStrategyResolver); Core never names it. Balanced and Deep are therefore served as fast when no
module offers them.
Degradation is part of the contract. A search never fails because a model is unavailable. Whatever prevents
the requested mode produces the cheaper result and says why, in meta['search']['degraded_reason']:
degraded_reason |
Cause |
|---|---|
mode_unavailable |
no installed module offers the requested mode (Core's resolver) |
search_orchestration_disabled |
the AI overlay is switched off by the Setting features.search_orchestration.enabled of the AI module (Filament, Settings) |
embedder_unavailable |
the embedding service could not be built |
deep_unavailable |
the LLM components could not be built |
rate_limited |
the person started more deep searches than search.deep_per_minute allows |
A failure during the search is handled where it happens, and the component that falls back notes it in
SearchDegradations (a scoped object, emptied at the start of every search); the first note becomes
degraded_reason when the strategy itself did not degrade:
degraded_reason |
Fallback inside the search |
|---|---|
llm_unavailable |
the LLM intent parser or planner failed or timed out and the search used its rules; also set when a repeated query is served a cached rule-based plan |
embedder_failed |
the query embedding threw (logged) and the search ran on keywords |
reranker_failed |
the reranker threw (logged) and the fused order was kept, with reranked=false |
mode_applied still says what the strategy was (deep), so a client sees both: it got deep, and part of it
fell back. A client should read degraded_reason as well as mode_applied rather than assume it got the mode it
asked for.
Retries. $retries is how many times the caller lets a poor result be searched again, capped by the strategy
(max_retries: 0 for fast and balanced, 2 for deep). SearchQualityEvaluator judges the result (fewer than five
hits, fewer than three distinct ones, or an average score under the plan's retry_policy threshold). A retry
is not the same query again: the candidate window doubles up to 200, the user's own words replace the intent
expansion, and vector retrieval is switched on when the strategy has an embedder. The retry replaces the first
result only if it has at least as many hits. meta['search']['retries_used'] reports how many ran.
On the wire. The search endpoint takes two parameters for this, next to the existing mode
(auto|basic|orchestrated, which picks the path and is unrelated to cost):
| Parameter | Values | Default | Effect |
|---|---|---|---|
cost |
fast, balanced, deep |
fast |
the search mode above; an unknown value reads as fast, never an error |
retry |
integer 0 or more | 0 |
retries the caller allows after a poor result; clamped to the setting search.max_retries (default 2), then to the strategy's cap |
cost matters only on the orchestrated path; a plain Scout search ignores it. (It is not the depth of the graph search.) The response carries
meta.search (mode_requested, mode_applied, degraded_reason, retries_used); it is the only part of the engine meta the HTTP
response serializes. deep has no permission of its own. It is limited per person by the setting
search.deep_per_minute (default 6, counted per signed-in user, else per IP address); over the limit the
search is served as fast with degraded_reason: rate_limited and a 200, not refused. Both settings are in the
search group and change without a deploy.
The five steps
AdvancedSearchService::search()
1. IQueryIntentParser::parse() -> optional query expansion
2. ISearchPlanner::safePlan() -> the plan (which strategies, weights, rerank)
applyEngineCapabilities() -> plan downgraded to keyword if engine lacks kNN
TextMatchOptionsResolver::resolve() -> ResolvedTextMatch (lexical only, carries the query analysis)
RetrievalTuningProfile::apply() -> measured profile per QueryClass, only when search.adaptive_tuning is on
3. resolveVector() -> query embedding, or null
4. EnsembleSearchService::search()
executeScoutSearch() x1 or x3 -> keyword | vector | hybrid, SEQUENTIAL
RankFusion::fuse() -> weighted normalized score + RRF + agreement
5. rerankTopK() -> IReranker over the fused top-K
Step 2 — the plan decides how many strategies run
FallbackSearchPlanner::fallbackPlan() (Core, no LLM) applies two rules:
$is_short = mb_strlen($query) < 20;
$has_numbers = (bool) preg_match('/\d/', $query);
$use_vector = config('core.search.vector.enabled', false) && ! $has_numbers;
| Condition | Strategies executed |
|---|---|
core.search.vector.enabled = false |
keyword only |
| query contains any digit | keyword only |
engine without supportsOrchestratedVectorSearch() |
keyword only (plan downgraded by applyEngineCapabilities()) |
the strategy has no embedder (fast, or the AI module absent or its overlay disabled) |
keyword only ($vector === null) |
| otherwise | keyword + vector + hybrid |
One or three, never two: hybrid runs only when both use_fulltext and use_vector are true.
The three Scout calls are sequential, not parallel. When a strategy does not run, its weight is
removed and the remaining weights are renormalized to sum to 1.0
(RankFusion::renormalizeWeights()), so a keyword-only run gives keyword weight 1.0 and
fusion becomes a pass-through.
SearchOrchestratorAgent (AI module) can replace the planner with an LLM-produced plan; it is
clamped and validated before use, and falls back to the same rule-based plan on any failure.
Step 2b — the retrieval tuning profile (L1)
When the search.adaptive_tuning runtime setting (core.search.adaptive_tuning, group search,
seeded off) is on, RetrievalTuningProfile::apply() merges a committed parameter set into the
plan right after the planner and the capability downgrade, so it covers both planners. It is not
learning: nothing reads user behaviour and two identical requests get identical plans.
The query class comes from the analysis TextMatchOptionsResolver already made (no second
tokenizer), via QueryClass::fromAnalysis(), in this precedence:
| Class | Condition |
|---|---|
identifier |
a significant numeric, UUID, email or structured-identifier token (INV-1042) |
short_keyword |
at most two significant tokens |
multi_term |
three to five significant tokens and fewer than two stopwords |
natural_language |
anything longer, or two stopwords or more |
Short words and acronyms are protected from fuzziness by text matching but are not codes, so they
do not make a query an identifier.
The profile is Modules/Core/config/search_tuning.php (config('search_tuning')): a version, a
default set and one optional set per class. The class set overrides default; a parameter left
out keeps the planner value. Allowed parameters: keyword_weight, vector_weight,
hybrid_weight, rrf_weight, agreement_boost, rerank_blend (numbers in [0, 1]), rrf_k and
rerank_top_k (integers >= 1); a set defining all three weights must keep one positive. The
profile only writes plan.ensemble and plan.ranking; it never touches plan.retrieval, so
whether vector search runs stays a capability question.
With the switch off, or a missing or invalid profile, the plan is returned untouched (an invalid
profile logs one warning per process). The shipped profile restates the L0 constants
(rrf_k 60, rrf_weight 0.25, agreement_boost 0.15, empty class sets), so switching it on
changes only meta['tuning'] until a measured profile is committed. A profile with measured values
must cite its source in a report key, a path relative to the Core module under
docs/evaluations/retrieval-tuning/: a report of ai:tune-retrieval that passed the held-out
validation and the noise check and holds the same default and class sets (checked by
CommittedRetrievalTuningProfileTest; the runtime ignores the key). Profile values come from
ai:tune-retrieval (AI module), never from intuition: see
Modules/AI/docs/rag/MODULE.md. The tuner holds out a share of its cases to validate the winner and
withholds class overrides backed by too few cases, and it declares no winner when the gain over the committed profile is no bigger than what one flipped case of its sample could produce (the noise margin), so a profile is not just the best fit to its dataset.
Step 3 — the query vector is an AI-module capability
if (($retrieval['use_vector'] ?? false) !== true || ! $embedder instanceof ITextEmbedder) {
return null;
}
The embedder comes from the SearchStrategy, not from the container. Core's strategy has none, so a fast
search is keyword-only; the AI module's resolver supplies SearchEmbedder for balanced and deep. Without the AI
module the query vector is null and the pipeline stays keyword-only. This is independent of
core.search.vector.enabled: both must hold. An embedder that throws is logged (Query embedding failed) and
the search continues on keywords.
What is lost without the AI module, per contract:
| Contract | Core fallback | Effect when AI is absent |
|---|---|---|
ITextEmbedder |
none | vector and hybrid retrieval unavailable (fast only) |
IReranker |
HeuristicReranker (pure PHP lexical scoring) |
reranking still runs, coarser |
ISearchPlanner |
FallbackSearchPlanner (rules above) |
plans by rules instead of LLM |
IQueryIntentParser |
SimpleQueryIntentParser |
basic intent, no LLM expansion |
Only the embedder has no Core fallback. Lexical tokenization is not affected: it belongs to the
engine analyzers and to TextMatchOptionsResolver, both in Core.
Vector availability guard
Before the query is embedded, AdvancedSearchService asks IVectorSearchAvailability::check($model)
whether vectors may be used. It asks only when the plan wants vectors and ITextEmbedder is bound,
so a keyword-only search never reports a reason. When the answer is no, the query vector is null,
the search runs keyword-only (no engine error), and the result carries meta['vector_disabled']
with the reason. Reasons, checked in this order:
| Reason | Cause | Checked by |
|---|---|---|
disabled |
core.search.vector.enabled is false |
Core VectorSearchAvailability |
suspended |
core.search.vector.suspended_reason is a non-empty string: an embedding model switch is running, or failed after its start |
Core |
dimension_mismatch |
the engine's index maps vectors of another length than core.search.vector.dimensions |
Core, for engines implementing IReportsVectorDimensions (Elasticsearch: embeddings.properties.vector.dims of the model's index), cached 60 s per model class |
no_vectors |
no core_model_embeddings row carries the active profile's model_key, or no active profile resolves |
AI EmbeddingVectorSearchAvailability, which decorates Core's guard and runs after it |
Without the AI module only the first three exist. With it, no_vectors also answers when the
embeddings feature is off or nothing has been embedded yet. The AI model switch calls
VectorSearchAvailability::forget() at activation, so the new dimensions are read at once instead
of after the cache expires. Engines that do not report dimensions (database, Typesense) skip the
dimension_mismatch check.
Index documents carry the vectors of one model only: Searchable::toSearchableArray() keeps the
embedding rows whose model_key equals VectorModelContext::get(), which is
core.search.vector.model unless code runs inside VectorModelContext::using($key, ...) (the switch
does, to write documents with the target's vectors). With no model configured every row is kept.
PostgreSQL with pgvector
On PostgreSQL with the vector extension, core_model_embeddings.embedding is created as a plain
vector column, with no dimension, in its create migration
(2024_11_05_233754_create_model_embeddings_table.php; an existing installation needs
migrate:fresh, there is no alter migration). Rows of several models, of different lengths, can
coexist. The migration creates no vector index.
Each embedding profile gets its own partial HNSW expression index, built by
Modules\Core\Search\Support\PgvectorProfileIndex (bound as IProfileVectorIndex):
CREATE INDEX IF NOT EXISTS "me_embedding_<12 hex of sha1(model_key)>" ON "<prefix>core_model_embeddings"
USING hnsw (("embedding"::vector(N)) <ops>) WHERE ("model_key" = '<model_key>')
similarity |
Operator class | Distance operator |
|---|---|---|
cosine |
vector_cosine_ops |
<=> |
l2 |
vector_l2_ops |
<-> |
ip |
vector_ip_ops |
<#> |
Any other similarity throws InvalidArgumentException. The name is a fixed prefix and a hash, so
any key gives a valid identifier. ensure() reads pg_indexes first and issues the CREATE INDEX
only when the name is missing; ensureOnce() remembers a key per process. The index is created by
the switch's indexes phase and, once per process, by the AI synchronizer before it writes rows of
the active profile; it is dropped with its rows at activation and by ai:embeddings:prune.
DatabaseEngine searches with the same cast, operator and predicate, so the planner can use the
index: "embedding"::vector(N) <op> ?::vector, with N from core.search.vector.dimensions, the
operator from core.search.vector.similarity, and "model_key" = '<core.search.vector.model>' as a
literal (not a binding, so a generic plan still matches the partial predicate). Without a configured
model it filters vector_dims("embedding") = N instead, because a row of another length would make
the cast fail. A non-positive or non-numeric dimension throws.
Caveats, none verified on a real PostgreSQL yet (open point of the design spec): the build is not
CONCURRENTLY, so it blocks writes to core_model_embeddings while it runs; the pg_indexes check
ignores indisvalid, so an invalid index left by a failed build counts as present; that the planner
uses the partial index for this query is by construction, not measured.
Step 4 — fusion is weighted score plus RRF, not RRF alone
The math lives in RankFusion (pure, no engine or container), which EnsembleSearchService
delegates to and the offline tuner reuses to re-fuse recorded rankings.
Per strategy, hit scores are min-max normalized to [0,1] (RankFusion::minMaxNormalize()), because BM25
and cosine similarity are not comparable. Then, for every id seen by at least one strategy:
score = Σ_s (normalized_score_s × weight_s) // weighted component
+ (Σ_s 1 / (rrf_k + rank_s)) × rrf_weight // rank-based component
+ agreement_boost × (appearances / strategies) // only when appearances > 1
Defaults from the plan: keyword_weight 0.35, vector_weight 0.35, hybrid_weight 0.30,
rrf_k 60, rrf_weight 0.25, agreement_boost 0.15. For short queries the planner shifts weights
to 0.30 / 0.40 / 0.30.
The rank component makes the fusion robust to scale differences; the weighted component keeps the within-strategy score distance meaningful; the agreement term rewards ids that more than one strategy found.
Step 5 — reranking
Reranking runs on the fused list, not on the individual strategy lists, and only on the first
rerank_top_k entries (default 30). The reranker receives {query, text} pairs, never the raw engine
scores. The text is the one the model provides through IProvidesRerankerText::rerankerTexts(), called once
per search with every key to rerank and the language of the search; a model that provides none is read from
the hit source (buildRerankerText(): title/name plus body area). This matters when the text is not an
attribute of the model: a search hit carries the model's own columns, and translated text lives elsewhere, so
without the contract a model like the CMS Content sent the reranker an empty text for every hit and the
rerank returned the fused order whatever model scored it. When no pair has any text at all the rerank fails
(meta['reranked'] = false, a warning in the log) instead of scoring empty pairs.
The blend comes from the plan (ranking.rerank_blend, set by the tuning profile when it defines
one), falling back to the search.reranker.weight runtime setting (core.search.reranker.weight,
seeded 0.6):
$item['score'] = ($item['score'] * (1 - $blend)) + ($rerank_score * $original_max * $blend);
The seeded 0.6 reproduces the historical 0.4 / 0.6 split exactly. 0 keeps the fused order,
1 ranks the top-K by the reranker alone. $original_max rescales the reranker's [0,1] output onto the fused score range. A reranker
failure (for example the cross-encoder service being down) is caught, logged as a warning, and the
fused order is returned with meta['reranked'] = false. Search never fails because of reranking.
An IReranker that cannot score must therefore throw, not answer: CrossEncoderService throws on an
error status, an unreachable service and an answer that is not one numeric score per pair, because a
list of invented zeros would pass for a rerank that changed nothing and meta['reranked'] would say
true.
A caller can disable it for one search through the plan (ranking.use_reranker); otherwise
config('core.search.reranker.enabled') decides, and it is off by default.
The language of the results
The query can be in any language and the vectors carry none, so every vector of every document is searched.
What is language-bound is the result: it comes back only in the language requested, whether by a
preference or explicitly, which is the language of the LocaleContext. A document that has no translation in
that language is not returned, however strongly it matches: an English query that matches an English
translation strongly still brings out the document in Italian when Italian is requested, as long as the
document exists in Italian. This is strict; the translations.locale_fallback.* settings govern how a
translated model is shown elsewhere and do not apply to search.
The restriction is part of the engine request, not a clean-up after it. EnsembleSearchService adds a
locales where clause to every strategy when the model's engine implements ILocaleFilterableEngine and its
index records locales (filtersByLocale(); Elasticsearch does, and a mono-language model such as a ticket
does not). The Elasticsearch engine turns it into a document-level terms filter on the nearest-vector search,
on the keyword search and on the text half of a hybrid search, whose hits are added to the vector ones. Applied
only after the top results are fetched, the documents of another language took the first places and were then
dropped, and a request for five results returned one to four. An engine without the capability is left alone:
it would read locales as a model attribute and return nothing.
The text half of a hybrid search is added to the nearest-vector hits, so it carries the same filters as the
vectors: the language and every filter the caller passed, the ones the keyword and vector strategies already
applied. It used to carry none, so a document that matched the text but not those filters could still come back
through the hybrid strategy. The generic CRUD search passes the user's ACL row filters this way
(injectAclFilters()) and used to reload the hits without them, since only Media re-authorized on rehydration,
so for those hits the row-level restriction did not hold, on every model but Media and whenever vector search
was on (it is off by default). When there is no filter at all the query is unchanged.
The filters in the engine query are no longer the only barrier. CrudService reloads the records behind the hits
of both search paths (orchestrated and Scout) through one query that applies the user's ACL row filters to the
database (AuthorizationService::applyAclFiltersToQuery(), the same semantics as everywhere else in the CRUD
service) and then the model's own IAuthorizesSearchRehydration guard, so a hit the engine should not have
returned (a strategy that dropped the filters, an index out of date) is dropped there. The cost is that such a
hit takes a place in the page. The CMS and SAO application-content providers have always reloaded through an
authorized query.
The text is matched on the fields of the requested language only (title.it, subtitle.it, content.it, with
the title boosted), analysed with that language's analyser; fields with no language (entity, preset, ...)
stay, and nested fields, which a plain multi_match cannot reach, dates, numbers and locales are left out.
The field list comes from the live mapping of the index (cached for five minutes), because the component fields
are mapped dynamically, as an object with a sub-field per language, and from the model's declared mapping when
the cluster cannot be asked. A caller that names its own fields in the text-match options keeps them. The
consequence for ranking is deliberate: a word that only matches the text of another language no longer counts,
so a request in Italian ranks on Italian text and on the vectors.
Response metadata
AdvancedSearchResult->meta carries:
| Key | Meaning |
|---|---|
driver |
active Scout driver |
strategies_executed / strategies |
how many and which strategies ran |
reranked |
whether reranking actually ran (false after a reranker failure) |
reranker_model |
the model that scored the hits, present only when the reranker names it (IRerankerWithModel, which CrossEncoderService implements from the model its service answers with) |
matching |
resolved text-match decision plus engine degradations |
per_strategy |
per-strategy ordered hits (id, score, rank), consumed by ai:evaluate-retrieval-strategies and ai:tune-retrieval |
tuning |
present only when the tuning profile was applied: {applied, profile_version, query_class} |
search |
always present: {mode_requested, mode_applied, degraded_reason, retries_used}, see Search modes |
unsupported_driver |
present when the engine cannot orchestrate |
vector_disabled |
present when the plan wanted vectors and the guard refused them: disabled, suspended, dimension_mismatch or no_vectors |
timings |
present only when the search.debug_timings setting is on: {intent_ms, plan_ms, vector_ms, ensemble_ms, total_ms}, see Measuring the stages |
AdvancedSearchResult->meta is what CrudService hands to CrudMeta->search. The CRUD HTTP response
(CrudController::buildResponse()) serializes only its search block, as meta.search; the other keys are read
in PHP (tests, tinker, the AI evaluation commands), not in the JSON a client receives.
Measuring the stages
The search.debug_timings runtime setting (config core.search.debug_timings, seeded off, group
search) makes AdvancedSearchService::search() time its four costly stages with hrtime() and add
meta['timings'], in milliseconds rounded to 3 decimals:
| Key | Stage |
|---|---|
intent_ms |
IQueryIntentParser::parse() (an LLM call with the AI overlay, a stopword split without it) |
plan_ms |
ISearchPlanner::safePlan() only (the AI planner caches a plan per query for 10 minutes) |
vector_ms |
resolving ITextEmbedder and embedding the query; null when no embedding ran (no embedder bound, plan without vectors, or guard refusal) |
ensemble_ms |
EnsembleSearchService::search(): the 1 or 3 engine calls, fusion and reranking |
total_ms |
the whole search() call; the gap to the sum of the four is plan shaping, text-match resolution, tuning and the vector guard |
A stage that did not run is null, never 0. With the setting off nothing is built and the meta is
unchanged. A stage that throws is rethrown unchanged; with the setting on, an info log line
Search stage failed; timings measured so far carries failed_stage and the partial timings.
How to take the numbers (a machine that runs the app against its real engine, never the test suite;
bootstrap/cache/config.php must not exist where tests run, so do not config:cache to switch the
overlay):
-
Pick a searchable model and a representative query. The snippet sets the flag for its own process, so the setting row does not need to change; to see timings from a worker instead, set
search.debug_timingsto true in Filament > Settings (rundb:seed --class=CoreDatabaseSeederfirst if the row is missing). -
Run, from the laraplate root:
php artisan tinker --execute ' config(["core.search.debug_timings" => true]); $service = app(Modules\Core\Search\Services\AdvancedSearchService::class); $model = new Modules\CMS\Models\Content(); $query = "replace with a representative query"; $metas = []; foreach (range(1, 11) as $run) { $metas[] = $service->search($model, $query, 1, 25)->meta; } $warm = collect(array_slice($metas, 1))->pluck("timings"); foreach (["intent_ms", "plan_ms", "vector_ms", "ensemble_ms", "total_ms"] as $stage) { $values = $warm->pluck($stage)->reject(fn ($value) => $value === null); printf("%-12s cold %10s warm median %10s\n", $stage, $metas[0]["timings"][$stage] ?? "null", $values->isEmpty() ? "null" : $values->median()); } printf("reranked: %s, vector_disabled: %s\n", var_export($metas[0]["reranked"] ?? null, true), $metas[0]["vector_disabled"] ?? "-"); 'The first run is the cold one (planner cache empty for that query, if it was not searched in the last 10 minutes); the median of the other ten is the warm figure. Use a query not searched recently to get a true cold run.
-
The loop above measures the path of the
fastmode, which no longer calls any model. To time the LLM and embedding stages, call the AI classes directly with a reachable provider (OLLAMA_API_URLfor Ollama), for exampleapp(Modules\AI\Services\LlmQueryIntentParser::class)->parse($query),app(Modules\AI\Services\SearchOrchestratorAgent::class)->safePlan($query)andapp(Modules\Core\Search\Contracts\ITextEmbedder::class)->embed($query), each wrapped inhrtime(). Measured on 2026-10-09 on a 1 vCPU test server: 23 to 60 s per LLM call, about 80 ms per warm embedding; see the Measurements section of2026-09-16-search-modes-and-strategy-resolution-design.md. The timeout of an LLM call in a search is the Settingfeatures.search_orchestration.timeoutof the AI module (10 s by default, 0 = no limit). -
Reranking runs only when the plan or the
search.reranker.enabledsetting asks for it: thererankedline says whether theensemble_msfigure includes it.
perf:crud does not exercise this path: it benchmarks /api/v1/select/{module}/{entity}, the list
endpoint, without a search query. perf:bench and perf:profile can target
GET:/api/v1/search/{module}/{entity}?qs=...&mode=orchestrated for end-to-end latency or a flame
profile (the public API must be exposed, as perf:crud does with the expose_api setting), but none
of the perf commands prints the response body, so the per-stage split comes from step 2.
What the matching preference does and does not affect
strict / balanced / tolerant / auto resolve to granular TextMatchOptions and are passed
only to the keyword and hybrid Scout calls. The vector call receives no text-match
options: an embedding has no notion of typo tolerance or token coverage.
The preference therefore never changes ensemble weights, rrf_k, rrf_weight, agreement_boost,
or reranking. Those come from the plan. Preference and plan are two independent axes: the plan
decides which strategies and how to fuse, the preference decides how forgiving the lexical
strategy is.
Configuration reality check
Consumed at runtime:
| Key | Env | Where it is read |
|---|---|---|
core.search.vector.enabled |
runtime setting (Filament > Settings) | FallbackSearchPlanner, engines |
core.search.vector.dimensions |
managed setting (written by the AI model switch) | ES dense_vector mapping, guard, pgvector query cast |
core.search.vector.similarity |
managed setting (written by the AI model switch) | ES mapping, pgvector operator |
core.search.vector.model |
managed setting (written by the AI model switch) | VectorModelContext (index documents), pgvector query filter |
core.search.vector.suspended_reason |
managed setting (switching during a switch, else null) |
vector guard |
search.analyzers |
SEARCH_ANALYZER_IT, SEARCH_ANALYZER_EN |
per-locale text mappings |
core.search.reranker.enabled |
runtime setting | EnsembleSearchService (plan fallback) |
core.search.reranker.top_k |
runtime setting | EnsembleSearchService |
core.search.reranker.weight |
runtime setting | EnsembleSearchService (rerank blend when the plan sets none) |
core.search.adaptive_tuning |
runtime setting | RetrievalTuningProfile (switch) |
core.search.debug_timings |
runtime setting | AdvancedSearchService (adds meta['timings']) |
search_tuning.* |
— (committed file) | RetrievalTuningProfile, ai:tune-retrieval |
search.text_matching.* |
— | TextMatchOptionsResolver |
Declared but not read by any code today (do not document them as working knobs):
| Key | Env | Status |
|---|---|---|
plan.retry_policy.* |
— | produced by both planners, validated by the AI planner, consumed by nobody |
Evaluation is an offline loop
ai:evaluate-application-content (end-to-end ordering) and ai:evaluate-retrieval-strategies
(per-strategy ordering, reranker off and on) score the real pipeline against committed relevance
datasets. No runtime code reads the produced reports. The loop is: run the command, read the
metrics, change code or configuration in a reviewed commit, re-run. Committed baselines are
regression gates in CI, not inputs to the algorithm.
See Modules/AI/docs/rag/MODULE.md for datasets, metrics and baseline locations.
Common failure modes
| Symptom | Check |
|---|---|
strategies_executed = 1 when vectors were expected |
digits in the query, core.search.vector.enabled, AI module present, engine kNN support |
reranked = false |
reranker service reachable; look for the Reranker failed warning (its context carries the reranker's own report context, such as a request_id, when the failure has one) |
unsupported_driver = true |
Scout driver is not an orchestration-capable ISearchEngine |
meta.vector_disabled = suspended |
an embedding model switch runs or failed: ai:embeddings:status, then ai:embeddings:switch --resume or --abandon |
meta.vector_disabled = dimension_mismatch |
the index maps another length than core.search.vector.dimensions: finish the switch, or recreate the index |
meta.vector_disabled = no_vectors |
nothing embedded with the active model: embeddings feature off, or run ai:embeddings:repair --all |
| Vector results are noise | records with embeddings but none of the active model_key; run ai:embeddings:repair --stale |
| Hybrid ranks worse than keyword alone | measure with ai:evaluate-retrieval-strategies before changing weights |
Ranking changed after flipping search.adaptive_tuning |
read meta['tuning'] (profile version, query class); switch it off to return to L0 without a deploy |
FAQ prompts for RAG
- How many retrieval strategies run for a query with a number in it?
- Why is vector search inactive even though
core.search.vector.enabledis true? - What does
meta.vector_disabledmean, and what are its reasons? - How does PostgreSQL store vectors of two embedding models, and which index serves a query?
- Does
matching=tolerantchange how results are fused? - Where is the reranker blend defined?
- What does the
search.adaptive_tuningsetting change, and how do I tell from a response? - Which query class does a query with an invoice code fall into?
- Which search configuration keys are declared but unused?
- Do the evaluation reports influence search at runtime?