AI-powered search for Drupal — semantic relevance scoring, AI summaries, and natural language query expansion on a Pagefind-compatible index built in PHP.
Built and maintained by Tag1 Consulting — technology leadership since 2007. Tag1 offers AI strategy, architecture, and implementation consulting for organizations evaluating or deploying AI-powered products.
Scolta 1.0 — the API documented here is stable. Breaking changes follow semantic versioning: no removal or signature change without a major version bump and a deprecation cycle. File bugs at the issue tracker.
Scolta is a scoring, ranking, and AI layer built on Pagefind. Pagefind is the search engine: it builds a static inverted index at publish time, runs a browser-side WASM search engine, produces word-position data, and generates highlighted excerpts. Scolta takes Pagefind's result set and re-ranks it with configurable boosts — title match weight, content match weight, recency decay curves, and phrase-proximity multipliers. No search server required. Queries resolve in the visitor's browser against the pre-built static index.
This Drupal module is one of three CMS adapters (alongside scolta-wp and scolta-laravel). It provides Drush commands, an admin settings form, a search block, and API endpoints for AI query expansion and summarization.
The LLM tier — query expansion, result summarization, follow-up questions — is optional. When enabled, it sends the query text and selected result excerpts to a configured LLM provider. The base search tier shares nothing with any third party; it runs entirely in the visitor's browser.
- Drupal 10.5+ or Drupal 11
- PHP 8.3+
composer require tag1/scolta-drupal
drush en scoltaThe browser assets (search JS/CSS and the WASM scorer) are not part of the
module's codebase: at install time — and again on every cache rebuild — they
are copied from the installed tag1/scolta-php package into
public://scolta-assets. Updating scolta-php therefore needs nothing beyond
the usual composer update followed by drush cr. That directory is runtime
state, not code: if it is ever missing or stale — say, after restoring a
database backup without the files directory — drush cr recreates it.
scolta.settings: entity_types names the entity types and bundles the index
holds. It is keyed by entity type ID and lists the bundles to index, an empty
list meaning every bundle. The default indexes every node bundle. Set it
under Entity types to index on the settings form, or with Drush:
# Articles and pages only
drush config:set --input-format=yaml scolta.settings entity_types '{node: [article, page]}'Then build the index and place the Scolta Search block via Structure > Block Layout:
drush scolta:buildContent saves, updates and deletes keep the index current through the
queue:run scolta_rebuild cron line (see Running rebuilds: the cron line
and Auto-rebuild debounce below), and the settings form's Index now
button rebuilds it from the browser.
| Command | Description |
|---|---|
drush scolta:build (sb) |
Build the search index |
drush scolta:build --force |
Force rebuild even if content has not changed |
drush scolta:build --resume |
Resume a previously interrupted build |
drush scolta:build --restart |
Discard interrupted state and start fresh. Also discards the page-table ledger, renumbering every page from zero |
drush scolta:build --reset-ledger |
Discard the page-table ledger under a plain build, renumbering every page from zero. Escape hatch for a corrupt page table (a duplicate page ordinal at the merge) without a full --restart. Cannot be combined with --resume or a scoped build |
drush scolta:build --memory-budget=256M |
Set memory budget (profile name or byte value) |
drush scolta:build --chunk-size=N |
Process N pages per chunk (overrides config) |
drush scolta:build --entity-type=node,group |
Index these entity types instead of the configured entity_types (default node). See Indexing more than one entity type below |
drush scolta:build --bundle=article |
Scope the build to one bundle. See Scoped builds below — this is not a way to reindex part of a larger index |
drush scolta:request-build (srb) |
Queue one full rebuild for the next drush queue:run scolta_rebuild tick; adds nothing if a request is already waiting. For operators and deploy scripts |
drush scolta:build --entity-ids=12,34 |
Scope the build to these entities; IDs that cannot be loaded are logged and skipped. --bundle is ignored. See Scoped builds below |
drush scolta:finalize (sf) |
Merge chunks into the final search index |
drush scolta:clear-cache (scc) |
Clear expansion and summary caches |
drush scolta:check-setup (scs) |
Verify dependencies and configuration |
drush scolta:status (sst) |
Show current index, in-progress build, indexer, and AI provider status; YAML by default, --format=json for machines |
drush scolta:reindex <entity-type> [<bundle>] (sri) |
Queue entities for reindexing without re-saving them, for when an alter hook or field mapping changed but the content did not. --ids=12,34 for specific entities. Refused above incremental.max_changed_items; use drush scolta:build --force for a whole corpus, or scolta_queue_full_rebuild(__FUNCTION__, TRUE) from an update hook |
drush scolta:inspect /node/123 (sin) |
Show what the index holds for an entity: the URL, indexed text, filters and metadata of its fragment, and of its translations. Takes a path or alias, or --entity-type=node --entity-id=123 |
Reindexing is also available to code: \Drupal::service('scolta.reindexer')->queue('node', $nids)
queues the same forced incremental requests scolta:reindex does, and returns
['entities' => int, 'pages' => int, 'skipped' => int]. Use it instead of
re-saving entities to make Scolta notice derived text that changed outside the
entity — a re-extracted file attachment, a refreshed import — where a save
would bump changed and, on a bundle with new revisions, create a revision.
--bundle and --entity-ids narrow what a build gathers. They do not narrow
what it publishes: a build merges the pages it gathered into a whole new
index, and it cannot carry over a page it never looked at — the posting lists
come from that run alone, and so does every fragment.
So a scoped build is a way to build an index that holds only that scope. It is not a way to refresh part of a larger index. If the index already holds pages outside the scope, the build stops before publishing and says how many pages were out of scope; the index that was already serving stays in place. (Before this check existed, such a build published and the out-of-scope pages were dropped from the index.)
What to use instead:
| Goal | Command |
|---|---|
| Refresh the whole index | drush scolta:build |
| Reflect an edit to a few nodes | Nothing — saving a node queues it, and the next queue:run scolta_rebuild tick applies the change to the published index in place |
| Build an index of one bundle only | drush scolta:build --bundle=article, every time, so the scope and the index agree |
On sites with thousands of pages or on shared-hosting environments, builds can be interrupted by PHP timeouts, SSH disconnects, or memory limits.
Use drush scolta:build for initial and full index builds. The settings form's Index now runs the same build through the Batch API, which is fine for small sites but slower and less resilient than Drush.
Run the build inside a persistent terminal session so it survives disconnects:
# nohup — simplest, output goes to nohup.out
nohup drush scolta:build &
# screen
screen -S scolta
drush scolta:build
# Detach: Ctrl+A, D — reconnect: screen -r scolta
# tmux
tmux new-session -s scolta
drush scolta:build
# Detach: Ctrl+B, D — reconnect: tmux attach -t scoltaIf the build is interrupted (timeout, disconnect, memory limit), resume from where it stopped:
drush scolta:build --resumeUse --restart to discard the interrupted state and start the build fresh.
It also discards the page-table ledger, so every page is renumbered from zero
and every fragment URL changes:
drush scolta:build --restartIf a build fails at the merge with a duplicate page ordinal, the ledger itself
is corrupt. --reset-ledger discards just the ledger under an otherwise plain
build, without a --restart. It cannot be combined with --resume or with
--bundle/--entity-ids:
drush scolta:build --reset-ledgerOn very large sites, drush scolta:build may defer the final merge step to stay within memory limits. Run finalization separately:
drush scolta:finalizeWhen a build publishes a new index, the previous one is renamed to a .scolta-trash-* directory next to pagefind/, then deleted after publishing (scolta-php ≥ 1.5.0). On NFS-backed file storage the deletion is parallelized (16 concurrent rm workers), which turns the hours-long serial deletion that used to make a finished drush scolta:build look hung into minutes — and it now happens after the new index is live, announced with a notice so it is never mistaken for a hang. Environments without process spawning fall back to serial deletion automatically.
Two backstops catch trash from builds that died before their own sweep, and from the batch-UI indexing path (which never sweeps):
- Cron. Each cron run spends up to
cleanup.cron_seconds(default 180) deleting leftover trash, then stops; an interrupted sweep resumes on the next run, and the common no-trash case costs one directory listing and logs nothing. On web-triggered cron the budget is further capped by the request's remaining execution time. Disable or tune withdrush config:set scolta.settings cleanup.cron_seconds 0. - On demand:
drush scolta:cleanup--dry-run lists what would be deleted. Cleanup is always safe: the live index is never touched. A stale .scolta-old from an interrupted swap is cleaned up by the command too, and .scolta-trash-* directories are safe to remove by hand at any time.
Scolta supports three AI provider paths. The right path depends on where you are in your deployment:
Scolta ships with no AI provider selected. The AI Provider field opens on - Select a provider -, and while nothing is selected AI features are off: search works exactly as it does now, no provider is assumed, and Anthropic in particular is not silently assumed. There is no default anywhere.
This is going-forward only. A site that already saved a provider keeps it and keeps working; nothing rewrites, clears or re-defaults an existing value, and there is no update hook that turns AI off on a working install. Only new installs start with nothing selected.
Amazee.ai is a managed AI gateway you can enable without holding an API key of your own, and it comes with a no-cost evaluation. Enabling it takes two deliberate steps and never happens on its own:
- Select Amazee.ai (managed gateway) as the AI Provider at Administration > Configuration > Search and Metadata > Scolta AI Search > AI Configuration, and save.
- Follow the Set up Amazee.ai link from that screen and choose one of two actions. Nothing is connected until you do:
- Try the demo — one click. No email, no account, no card. AI is on immediately and runs until the demo's included credit is used up. The demo is one-time per site; it stays available whether you are on a fresh install or coming from another provider, and once it has been used the page points you at the account path instead.
- Enter your Amazee credentials — sign in with the email address on your amazee.ai account. Amazee emails a verification code, you pick a region, and your account's credentials are stored for you. If you do not have an account yet, this creates one. You never generate or paste an API key: this mirrors amazee.ai's own
ai_provider_amazeeiomodule, which manages the keys for you, so there is deliberately no bring-your-own-key form.
When a connection stops being accepted — a demo whose credit ran out, or revoked credentials — AI degrades cleanly, /health reports it, and the Amazee.ai settings page shows a prompt pointing straight at Enter your Amazee credentials. Completing that flow restores AI without disconnecting first. Nothing is provisioned automatically at any point in that recovery.
The settings page states which of the two actions established the current connection, because that is recorded when it happens rather than inferred afterwards. A connection made before Scolta recorded it says only "Connected to Amazee.ai".
Installing the module configures no AI provider and stores no credentials, and no page request, cron run or activation will establish a connection for you. Selecting any other provider afterwards removes the stored connection, so the gateway can never serve traffic for a site that has moved to its own key. Selecting Amazee.ai again and repeating the connect flow re-establishes it.
Model configuration on this path is separate. Amazee.ai serves models through a LiteLLM gateway under its own names (claude-4-5-sonnet), which no provider's own API accepts. Scolta therefore keeps them apart: the gateway's names are resolved automatically into amazee_model and amazee_expansion_model and are read only while Amazee.ai credentials are in use, while the AI Model and Expansion Model fields on the settings form hold provider-native IDs (claude-sonnet-5-5, gpt-4o) and are what a direct provider key uses. The two gateway settings have no form field, because there is nothing to choose — they are whatever the gateway offers. Switching away from Amazee.ai therefore leaves your own model choice intact.
Sites installed before this separation existed may have an Amazee.ai gateway name sitting in AI Model. drush updatedb moves it across and restores the shipped default, reporting what it moved; if you use a direct Anthropic or OpenAI key, check the AI Model field afterwards.
For sites that want full control over their AI provider, Scolta integrates with the Drupal AI module — the same provider abstraction used by CKEditor AI, AI Automators, and other AI Initiative modules.
When "Drupal AI module" is selected in Scolta's settings, Scolta routes all AI requests through the Drupal AI module's configured default provider. This gives you:
- 48+ supported providers — Anthropic, OpenAI, Google Gemini, AWS Bedrock, Mistral, Ollama, Groq, and more
- Key module integration — API keys stored securely using Drupal's Key module, out of code and config
- Rate limiting and token tracking — managed by the Drupal AI module site-wide
- Hooks —
hook_alter_ai_message,hook_alter_ai_response, and others fire for Scolta requests - Centralized provider management — change your AI provider site-wide without touching Scolta config
Setup:
composer require drupal/ai
drush en aiThen install a provider module for your preferred AI service, for example:
composer require drupal/ai_provider_anthropic
drush en ai_provider_anthropicConfigure the provider at Administration > Configuration > AI > AI Providers, using a Key entity for secure API key storage.
Finally, select Drupal AI module in Scolta settings at Administration > Configuration > Search and Metadata > Scolta AI Search > AI Configuration > AI Provider.
Scolta will use the Drupal AI module's configured default provider and model. The model, API key, expansion model, and base URL fields in Scolta's settings are hidden when this provider is selected — the Drupal AI module manages all of these.
Moving from Amazee.ai: If your site is on the Amazee.ai gateway and you want to switch to the Drupal AI module, install drupal/ai, configure a provider there, then change the dropdown in Scolta settings. Saving the change removes the stored Amazee.ai connection, so nothing of it is left to shadow the provider you selected; to go back, select Amazee.ai again and complete the connect flow.
For simple setups or sites without the Drupal AI module, Scolta can make direct HTTP calls to Anthropic or OpenAI with an API key configured via environment variable or settings.php:
# Environment variable (preferred)
export SCOLTA_API_KEY="sk-ant-..."
# Or in settings.php
$settings['scolta.api_key'] = 'sk-ant-...';Select Anthropic (Claude) or OpenAI in Scolta's AI provider settings to use this path.
Getting fewer results than you expect on a recipe, product, or catalog site? Go to Administration > Configuration > Search and Metadata > Scolta AI Search, open the Site Type section, choose the Recipe & Content Catalog preset, save, and rebuild the index (drush scolta:build).
Scolta defaults to a conservative search breadth so generic words ("easy", "quick", "best") don't flood your results. On a recipe or catalog site, the useful domain words you actually want to match — ingredients, techniques, product attributes — are common enough that the default can hide them. The Recipe & Content Catalog preset widens the breadth (and tunes a handful of other ranking settings) so those searches return the fuller set of matches you'd expect.
Pick the Site Type that matches your site and Scolta sets sensible defaults for you:
| Your site | Preset |
|---|---|
| Recipes, product or content catalogs | Recipe & Content Catalog |
| Docs, knowledge bases, encyclopedias, references | Documentation & Reference |
| Online stores | E-commerce & Product Store |
| Blogs and editorial sites | Blog & Editorial |
| News sites | Start from Scratch, then tune recency |
You rarely need to touch individual numbers — the preset is the recommended path, and any value you change by hand in the Scoring section still overrides the preset. The one advanced knob worth knowing is Search breadth (expand_subword_max_frequency): higher returns more results but can pull in loosely-related matches; lower keeps results tight. The Recipe & Content Catalog preset already raises it from 0.05 to 0.10.
One further advanced knob controls how a multi-term query expansion feeds the AI summary. Expansion combine mode (expansion_combine_mode) is either relevance_union (historical behavior) or round_robin, which deals the top candidates from each expansion sub-query so the summarizer sees breadth across distinct sub-topics. It is preset-defaulted — the Recipe & Content Catalog, Blog & Editorial, and E-commerce presets default it to round_robin; the others use relevance_union — and any value you set by hand overrides the preset. The visible results list stays relevance-sorted in both modes.
For the evidence behind each preset — the scoring sweeps and the per-parameter data — see scolta-php's docs/TUNING.md.
On by default. Typing in the search box opens a suggestions dropdown underneath it. Typing alone never runs a search: the full pipeline (AI query expansion, the merge, facet counts, the AI overview, follow-ups) still waits for Enter, the search button, or a selected suggestion. No index rebuild is needed — suggestions read the same fragments the result list does, so the feature works on an index built by any earlier release.
Everything is configurable at Administration > Configuration > Search and Metadata > Scolta AI Search, in the Search as you type section:
| Setting | Config key | Default | What it does |
|---|---|---|---|
| Enable search as you type | sayt_enabled |
true |
The off switch (see below) |
| Minimum characters | sayt_min_chars |
2 |
How much must be typed before suggestions are fetched |
| Debounce | sayt_debounce_ms |
150 |
Idle milliseconds after the last keystroke before a suggestion pass runs |
| Maximum suggestions | sayt_max_suggestions |
6 |
Rows shown, and the cap on fragment loads per pass |
| Offer recent searches | sayt_recent_searches |
true |
Suggest the visitor their own recent searches |
| Maximum recent searches | sayt_max_recent |
3 |
How many recent searches are shown |
| Enrich with AI query expansion | sayt_expand |
true |
Merge AI expansion matches into the dropdown |
| AI enrichment calls per minute | sayt_expand_per_minute |
6 |
Per-visitor cap on those AI calls |
| AI enrichment delay | sayt_expansion_delay_ms |
500 |
Idle milliseconds before the AI call fires |
| Selecting a suggestion | sayt_suggestion_action |
navigate |
navigate opens the result, search runs the full search for it |
The off switch. Set Enable search as you type to off (drush config:set scolta.settings sayt_enabled 0) and the search widget behaves exactly as it did before this feature existed: no dropdown, no combobox roles on the input, no browser storage touched on any path, and no suggestion searches. The results, ranking and AI overview are unaffected either way.
Sites in Chinese, Japanese or Korean generally want sayt_min_chars: 1. The count is in characters as a person sees them, and a single han character is already a meaningful query; a floor of two means a CJK visitor gets no suggestions until their query is twice as specific as an English speaker's needs to be.
Why the enrichment cap exists. Suggestion-driven AI expansions share the AI flood budget with committed searches — expansion, summarize and follow-up all count against the same per-IP limit described under AI endpoint rate limiting (60 requests/minute by default). Without a cap, a visitor's whole allowance could be spent on prefixes they never submitted, and the search they actually ran would come back with no expansion and no AI overview. Over the cap, suggestions silently fall back to keyword matches until the window rolls.
On an existing site, drush updatedb (or /update.php) adds these defaults to scolta.settings. Any of the ten you had already set by hand is left alone, including sayt_enabled: false. Sites that manage permissions and settings through exported configuration should drush cex afterwards and commit the changed scolta.settings.yml.
If searches return no results, the search index may not exist yet. Build it with:
drush scolta:buildScolta defines a Use Scolta AI features permission (use scolta ai) that gates the AI API endpoints. This permission is granted to the authenticated role automatically at module install, so logged-in visitors receive AI overviews with no admin action required. The anonymous role is deliberately not granted it: the AI endpoints make cost-bearing LLM calls, and opening them to unauthenticated traffic is a decision for the site rather than a default.
To serve AI overviews to anonymous visitors, grant Use Scolta AI features to the anonymous role at Administration > People > Permissions. Until you do, anonymous requests to the AI endpoints return 403.
A site installed before this change may already hold the anonymous grant, which earlier releases applied automatically. A database update revokes it: run drush updatedb (or visit /update.php) after updating the module. It runs once, so a later decision to grant it again stands.
If the site manages permissions through exported configuration, the update alone is not enough. Run the update in the environment you export from, then drush cex, and commit the changed user.role.anonymous.yml and user.role.authenticated.yml. drush deploy runs config:import after updatedb, so on a site whose exported config predates the update, the import restores the old grant seconds after the hook removed it — the update having run and the permission still being present at the same time is exactly what this looks like. Re-exporting also makes the change survive every later config:import, which running the update on each environment separately does not.
The health endpoint (GET /api/scolta/v1/health) is reachable without any permission so uptime monitors always work, but callers without Administer Scolta (administer scolta) receive only {"status": "ok"|"degraded"}. The full diagnostic payload (AI provider, index integrity, fragment counts) requires administer scolta.
The detail payload comes from scolta-php's HealthChecker::check(), with Drupal-specific ai_provider/ai_configured overrides and the index field merged on top. This module adds one fault of its own: an index that exists but fails the integrity spot check (index.integrity.valid: false, with the specifics in index.integrity.issues) reports status: degraded. Against a scolta-php that reports status_reasons — the list of machine-readable fault keys, empty exactly when the status is ok, added in scolta-php 1.5.0 — that failure also appends index_integrity_invalid to the list, so the reasons never contradict the status. An older scolta-php produces no status_reasons key and the module adds none.
A role-level permission cannot express every rule a site needs — a per-user preference, a quota, an entitlement that arrives with a subscription. The scolta.ai_access service is the one decision point for all three AI features, and both gates ask it: ScoltaSearchBlock before it tells the browser a feature exists, and the endpoint routes before they serve the request the browser then makes. Decorate it to narrow the rule, and the search UI stops offering what the endpoint would refuse.
The service answers who may use a feature, not what the site offers. The ai_expand_query / ai_summarize switches and the max_follow_ups quota stay in configuration and stay enforced where they are, so a switched-off feature keeps answering 404 and an exhausted follow-up quota keeps answering 429 with the remaining limit. Don't restate them in a decorator: that turns those documented responses into a 403 from routing, and the quota can't be expressed as access at all — the real rule counts the messages in the request body, which no access check is given.
# mymodule.services.yml
services:
mymodule.ai_access:
class: Drupal\mymodule\Access\MyAiAccess
decorates: scolta.ai_access
arguments: ['@mymodule.ai_access.inner']
cache_context.user.my_ai_optout:
class: Drupal\mymodule\Cache\AiOptOutCacheContext
arguments: ['@current_user']
tags:
- { name: cache.context }final class MyAiAccess implements AiAccessInterface {
public function __construct(private readonly AiAccessInterface $inner) {}
public function access(AccountInterface $account, string $feature): AccessResultInterface {
$result = $this->inner->access($account, $feature);
// Only ever narrow: hand back a refusal as it arrived.
if (!$result->isAllowed()) {
return $result;
}
$preference = $this->userWantsAi($account)
? AccessResult::allowed()
: AccessResult::forbidden('The user has opted out of AI search.');
$preference->addCacheContexts(['user.my_ai_optout']);
return $result->andIf($preference);
}
}The cacheability on the returned result matters as much as the answer. The block's render array takes it on, so an implementation that varies by account must say so, or the first visitor's answer is served to the next. Declare that variation with a cache context keyed on the value your rule reads, not with cachePerUser(): a boolean preference gives everything downstream two shared cache variants, where per-user gives one per account — on a large membership that is millions of copies of the search page's cacheable pieces, each hit only when the same person returns. This is the same distinction core draws between user.roles and user.
final class AiOptOutCacheContext implements CacheContextInterface {
public function __construct(private readonly AccountInterface $currentUser) {}
public static function getLabel(): string {
return (string) t('AI search opt-out');
}
public function getContext(): string {
return $this->userWantsAi($this->currentUser) ? '1' : '0';
}
public function getCacheableMetadata(): CacheableMetadata {
// Merged only where this context is optimized away under a coarser one
// such as `user`. That entry is keyed per account, so a preference
// change no longer moves its owner to a different variant — the entry
// itself must be invalidated when the account is saved. The shared
// two-variant entries never get this tag, which is the point: an entry
// serving millions must not be purged because one of them saved.
return (new CacheableMetadata())->addCacheTags(['user:' . $this->currentUser->id()]);
}
}A flip then takes effect on the member's next request with no invalidation at all — the context value is recomputed per request, so they simply land in the other variant.
The three features are AiAccessInterface::FEATURE_EXPAND, FEATURE_SUMMARIZE and FEATURE_FOLLOW_UP. Follow-ups are only offered inside an overview, so refusing FEATURE_SUMMARIZE withdraws both.
Visit Administration > Configuration > Search and Metadata > Scolta AI Search to configure the AI provider, API key, model, and indexing options.
The AI API endpoints (/api/scolta/v1/expand-query, /api/scolta/v1/summarize, /api/scolta/v1/followup) make cost-bearing LLM calls. They require the Use Scolta AI features permission, which is granted to authenticated users at install; flood limits apply to every caller regardless. The Rate Limiting section of the settings form configures per-IP and site-wide flood thresholds (defaults: 60 requests/minute per IP, 1000 requests/minute site-wide); requests beyond a threshold are rejected with HTTP 429 before any AI work happens. Set a limit to 0 to disable that layer.
The index covers every node bundle by default. scolta.settings: entity_types is keyed by entity type ID and lists the bundles to index, an empty list meaning all of them. The build, the settings form's Index now, the rebuild queue worker and the auto-rebuild entity hooks all read it. Set it under Entity types to index in the Content section of the settings form, or with Drush:
drush config:set --input-format=yaml scolta.settings entity_types '{node: [], group: [community]}'Any fieldable entity type with a changed field works; a type with a published flag is filtered to published entities. Add the field its prose lives in to body_fields (for example field_description for groups). Every page ID is prefixed with its entity type ID (node:42, node:42-es, group:42), which is why node 42 and group 42 do not collide. An index built by an earlier release used bare node IDs; the update hook discards the build state and queues a rebuild, so the next build numbers every page from zero.
Rebuilds — the install-time build, auto-rebuild requests from content edits, drush scolta:request-build, and the rebuild an update hook queues when the index format changes — sit in the scolta_rebuild queue until something runs it. Drupal cron does not. Run the queue from an external cron line, every minute, in every environment that should index:
* * * * * drush queue:run scolta_rebuild
A tick that finds a build running exits at the build lock in about a second, so the one-minute cadence costs nothing while a build is in progress and picks up a finished or killed build within a minute. --lease-time does not matter: the worker enqueues one resume marker before the first segment of a full build runs and deletes it only when the build completes or is given up on, so a process killed mid-segment (an evicted pod, the OOM killer) leaves a claimable request whatever the lease was, and the next tick continues from the last committed page (the on-disk build state is the truth; the queue item is only the request). A build too large for one process yields on memory pressure and is chained to completion within that same tick by spawning drush scolta:build --resume segments, the way drush scolta:build does; on a host where drush cannot be spawned, each tick runs one segment. A build that stops making progress, or fails for any other reason, is logged once with the remedy and given up on; the published index is untouched. A request that arrives mid-build is applied to the finished index afterwards.
A deploy does not need to run drush scolta:build. Content edits are applied incrementally, and a release that changes the index format queues its own full rebuild from its update hook and says so in its release notes. Pass --force to a hand-run drush scolta:build when a deploy changed body_fields or field_mappings: the timestamp manifest skips every entity whose changed time is unchanged, so a plain build re-gathers nothing for a config-only change. drush scolta:build --entity-type=node,group overrides the configured list for one run; --bundle and --entity-ids need a single --entity-type.
Sites without a drush cron of any kind keep the settings form's Index now, which builds in a batch of web requests.
What sits in the queue, and what the worker does with it:
| Payload | Enqueued by | Meaning to the worker |
|---|---|---|
{type: install} |
module install | Untargeted; forces a full build |
{type: <update hook>} |
scolta_reset_index_state() from an update hook |
Untargeted; full build |
{type: <update hook>, force} |
scolta_queue_full_rebuild() from an update hook |
Untargeted; full build, reloading every entity when force is set, as scolta:build --force does |
{type: request-build} |
drush scolta:request-build |
Untargeted; full build |
{type: auto, op, entity_type, entity_id, item_ids} |
entity insert, update and delete hooks | Targeted; applied incrementally to the published index |
{type: reindex, op: update, entity_type, entity_id, item_ids, force: TRUE} |
drush scolta:reindex |
Targeted; applied incrementally, and rewrites the timestamp manifest so the next full build does not serve the cached page it replaced |
{op: resume} |
the worker itself, before a full build's first segment | The standing request for a build in progress; with nothing to resume on disk, a full build |
Duplicates are harmless. When a build starts, the worker claims every item in the queue and folds them into one change set, so a burst of saves becomes one incremental update and several full-build requests become one build. The worker keeps exactly one {op: resume} marker itself, draining extras before each segment and deleting them all when the build completes or is given up on.
On container platforms, run the tick in the CLI container from a platform cron entry that is not given a timeout: a chained build can run for hours.
When auto-rebuild is enabled, content saves enqueue an index rebuild that the queue:run scolta_rebuild tick processes. The rebuild is debounced by scolta.settings: pagefind.auto_rebuild_delay (default 300 seconds, clamped to 60–3600): the queue waits until that many seconds have passed since the last content change, so a burst of edits produces one build instead of many.
Inserts, updates and deletes all enqueue a request, and each one names the node and the content item IDs it touched.
A queued request that names what changed can be applied to the existing index instead of rebuilding it: the worker gathers only the changed nodes and updates, adds or tombstones just their pages. On a large site this is the difference between an edit costing seconds and an edit costing a full rebuild, because the content gather — not the merge — is where a build spends its time.
Two scolta.settings keys control it. They have no form field yet, so set them with Drush:
# Turn the incremental path off entirely (every request becomes a full rebuild).
drush config:set scolta.settings incremental.enabled false
# Largest change set applied incrementally before falling back to a full
# rebuild. Default 1000. Set to 0 to remove the ceiling.
drush config:set scolta.settings incremental.max_changed_items 250The worker falls back to a full rebuild, and logs why at warning, whenever it cannot update exactly: a queued request that does not name what changed (the install hook enqueues a plain full-rebuild marker), a change set over the threshold, or an index with no page-table ledger yet. Incremental updates apply to an index; they do not create one, so the first build after installing is always a full build.
This path is dormant until a tag1/scolta-php release carrying IncrementalIndexUpdater is installed. Until then the class is absent, the worker takes the full build path, and nothing changes.
Scolta's config stores scoring and display values in nested namespaces (scoring.*, display.*). When using drush config:set, use the full nested path:
# Correct — nested path used by the admin UI
drush config:set scolta.settings display.max_pagefind_results 10
drush config:set scolta.settings scoring.title_match_boost 2.0
# Also accepted — top-level keys take precedence over nested values
drush config:set scolta.settings max_pagefind_results 10Top-level keys (without a namespace prefix) override nested values of the same name, so both forms work. The nested path is canonical and matches the admin UI; the top-level form is convenient for one-off overrides.
Scolta connects to external services under specific conditions. No data is sent automatically — all connections are triggered by admin/developer action or explicit configuration.
When: Only after an administrator selects Amazee.ai as the AI provider and completes the connect flow in Scolta's settings. Once connected, every search query made by site visitors is sent to the Amazee.ai API endpoint while AI features are active. What is sent: The user's search query text, and selected page content excerpts (for result summarization). Service: Amazee.ai, operated by Amazee Group AG. Amazee.ai: https://amazee.ai/ Terms of Service: https://amazee.ai/terms/ Privacy Policy: https://amazee.ai/privacy/
When: A visitor performs a search and AI features are enabled. Which provider receives the data depends on the Scolta AI provider setting. What is sent: The user's search query text and selected page content excerpts (for result summarization) are sent to the configured provider's API endpoint. Providers:
- Drupal AI module — Scolta routes requests through the Drupal AI module, which supports 48+ providers. Review the terms and privacy policy of the provider configured in the Drupal AI module.
- Anthropic (Claude) — processes search queries and page excerpts directly. Terms of Service: https://www.anthropic.com/legal/consumer-terms Privacy Policy: https://www.anthropic.com/legal/privacy
- OpenAI — processes search queries and page excerpts directly. Terms of Use: https://openai.com/policies/terms-of-use Privacy Policy: https://openai.com/policies/privacy-policy
- OpenAI-compatible endpoints (including self-hosted Ollama and other providers) — any endpoint configured by the site administrator that speaks the OpenAI API protocol. Review the terms and privacy policy of your chosen provider.
No AI API calls are made unless a provider is configured and AI features are enabled in Scolta settings.
This project is maintained on Github. The code is git.drupalcode.org/prject/scolta is configured with a pull mirror of the Github repo so that Drupal sites may get the package via the usual drupal.org Composer facade.
See CHANGELOG.md for a full list of changes.
Scolta is designed, built, and maintained by Tag1 Consulting. Tag1 has been delivering technology leadership since 2007 and is one of the leading open-source consulting firms in the world.
Tag1 offers AI strategy, architecture, and implementation consulting — from evaluating whether AI search is right for your organization, to production deployment and ongoing tuning. If you need help integrating Scolta, customizing scoring for your content model, or connecting it to your AI provider of choice, get in touch.
GPL-2.0-or-later. See LICENSE.