feat: make ngram min_gram configurable via admin setting

Adds a 'Minimum search query length' setting (1–4, default 3). The admin
shows a warning when the analyzer or min-search-length have changed since
the last index build.
This commit is contained in:
Bart van Bragt 2026-04-10 13:19:22 +02:00
parent 4412583cd9
commit f486292f15
9 changed files with 91 additions and 13 deletions

View File

@ -33,5 +33,6 @@ return [
(new Flarum\Settings()) (new Flarum\Settings())
->default('blomstra-search.search-discussion-subjects', true) ->default('blomstra-search.search-discussion-subjects', true)
->default('blomstra-search.search-post-bodies', true), ->default('blomstra-search.search-post-bodies', true)
->default('blomstra-search.min-search-length', Commands\BuildCommand::DEFAULT_MIN_SEARCH_LENGTH),
]; ];

2
js/dist/admin.js vendored
View File

@ -1,2 +1,2 @@
(()=>{var t={n:a=>{var e=a&&a.__esModule?()=>a.default:()=>a;return t.d(e,{a:e}),e},d:(a,e)=>{for(var r in e)t.o(e,r)&&!t.o(a,r)&&Object.defineProperty(a,r,{enumerable:!0,get:e[r]})},o:(t,a)=>Object.prototype.hasOwnProperty.call(t,a),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},a={};(()=>{"use strict";function e(t,a){return e=Object.setPrototypeOf||function(t,a){return t.__proto__=a,t},e(t,a)}t.r(a);const r=flarum.core.compat["admin/app"];var n=t.n(r);const s=flarum.core.compat["common/extend"],i=flarum.core.compat["admin/components/DashboardPage"];var o=t.n(i);const l=flarum.core.compat["admin/components/DashboardWidget"];var c=t.n(l);const d=flarum.core.compat["common/components/Alert"];var p=t.n(d),u=function(t){var a,r;function s(){return t.apply(this,arguments)||this}r=t,(a=s).prototype=Object.create(r.prototype),a.prototype.constructor=a,e(a,r);var i=s.prototype;return i.className=function(){return"ReindexWarningWidget"},i.content=function(){return m(p(),{type:"warning",dismissible:!1,icon:"fas fa-exclamation-triangle",title:n().translator.trans("blomstra-search.admin.reindex-required.title")},n().translator.trans("blomstra-search.admin.reindex-required.detail"))},s}(c());n().initializers.add("blomstra-search",(function(){var t=n().data.settings["blomstra-search.active-index"],a=n().data.settings["blomstra-search.index-compatible"];t&&"v2"!==a&&(0,s.extend)(o().prototype,"availableWidgets",(function(t){t.add("blomstra-search-reindex",m(u),110)}));var e=new Map;["arabic","armenian","basque","bengali","brazilian","bulgarian","catalan","cjk","czech","danish","dutch","english","estonian","finnish","french","galician","german","greek","hindi","hungarian","indonesian","irish","italian","latvian","lithuanian","norwegian","persian","portuguese","romanian","russian","sorani","spanish","swedish","turkish","thai"].forEach((function(t){e.set(t,t)})),n().extensionData.for("blomstra-search").registerSetting({setting:"blomstra-search.elastic-endpoint",label:n().translator.trans("blomstra-search.admin.elastic-endpoint"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-username",label:n().translator.trans("blomstra-search.admin.elastic-username"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-password",label:n().translator.trans("blomstra-search.admin.elastic-password"),type:"password"}).registerSetting({setting:"blomstra-search.elastic-index",label:n().translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.analyzer-language",label:n().translator.trans("blomstra-search.admin.analyzer.label"),help:n().translator.trans("blomstra-search.admin.analyzer.help"),type:"select",options:Object.fromEntries(e.entries()),default:"english"}).registerSetting({setting:"blomstra-search.search-discussion-subjects",label:n().translator.trans("blomstra-search.admin.search-discussion-subjects"),type:"switch"}).registerSetting({setting:"blomstra-search.search-post-bodies",label:n().translator.trans("blomstra-search.admin.search-post-bodies"),type:"switch"})}))})(),module.exports=a})(); (()=>{var t={n:a=>{var e=a&&a.__esModule?()=>a.default:()=>a;return t.d(e,{a:e}),e},d:(a,e)=>{for(var r in e)t.o(e,r)&&!t.o(a,r)&&Object.defineProperty(a,r,{enumerable:!0,get:e[r]})},o:(t,a)=>Object.prototype.hasOwnProperty.call(t,a)};(()=>{"use strict";function a(t,e){return a=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,a){return t.__proto__=a,t},a(t,e)}const e=flarum.core.compat["admin/app"];var r=t.n(e);const n=flarum.core.compat["common/extend"],s=flarum.core.compat["admin/components/DashboardPage"];var i=t.n(s);const o=flarum.core.compat["admin/components/DashboardWidget"];var l=t.n(o);const c=flarum.core.compat["common/components/Alert"];var h=t.n(c),d=function(t){var e,n;function s(){return t.apply(this,arguments)||this}n=t,(e=s).prototype=Object.create(n.prototype),e.prototype.constructor=e,a(e,n);var i=s.prototype;return i.className=function(){return"ReindexWarningWidget"},i.content=function(){return m(h(),{type:"warning",dismissible:!1,icon:"fas fa-exclamation-triangle",title:r().translator.trans("blomstra-search.admin.reindex-required.title")},r().translator.trans("blomstra-search.admin.reindex-required.detail"))},s}(l());r().initializers.add("blomstra-search",function(){var t=r().data.settings["blomstra-search.active-index"],a=r().data.settings["blomstra-search.index-compatible"];t&&"v2"!==a&&(0,n.extend)(i().prototype,"availableWidgets",function(t){t.add("blomstra-search-reindex",m(d),110)});var e=new Map;["arabic","armenian","basque","bengali","brazilian","bulgarian","catalan","cjk","czech","danish","dutch","english","estonian","finnish","french","galician","german","greek","hindi","hungarian","indonesian","irish","italian","latvian","lithuanian","norwegian","persian","portuguese","romanian","russian","sorani","spanish","swedish","turkish","thai"].forEach(function(t){e.set(t,t)}),r().extensionData.for("blomstra-search").registerSetting(function(){var a=r().data.settings["blomstra-search.indexed-analyzer"];if(!t||!a)return null;var e=this.setting("blomstra-search.analyzer-language")()||"english",n=this.setting("blomstra-search.min-search-length")()||String(3),s=String(r().data.settings["blomstra-search.indexed-min-search-length"]||3);return e===a&&n===s?null:m(h(),{type:"warning",dismissible:!1,icon:"fas fa-exclamation-triangle"},r().translator.trans("blomstra-search.admin.index-settings-changed"))}).registerSetting({setting:"blomstra-search.elastic-endpoint",label:r().translator.trans("blomstra-search.admin.elastic-endpoint"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-username",label:r().translator.trans("blomstra-search.admin.elastic-username"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-password",label:r().translator.trans("blomstra-search.admin.elastic-password"),type:"password"}).registerSetting({setting:"blomstra-search.elastic-index",label:r().translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.analyzer-language",label:r().translator.trans("blomstra-search.admin.analyzer.label"),help:r().translator.trans("blomstra-search.admin.analyzer.help"),type:"select",options:Object.fromEntries(e.entries()),default:"english"}).registerSetting({setting:"blomstra-search.search-discussion-subjects",label:r().translator.trans("blomstra-search.admin.search-discussion-subjects"),type:"switch"}).registerSetting({setting:"blomstra-search.search-post-bodies",label:r().translator.trans("blomstra-search.admin.search-post-bodies"),type:"switch"}).registerSetting({setting:"blomstra-search.min-search-length",label:r().translator.trans("blomstra-search.admin.min-search-length.label"),help:r().translator.trans("blomstra-search.admin.min-search-length.help"),type:"select",options:{1:"1",2:"2",3:"3",4:"4"},default:String(3)})})})(),module.exports={}})();
//# sourceMappingURL=admin.js.map //# sourceMappingURL=admin.js.map

File diff suppressed because one or more lines are too long

3
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -78,6 +78,25 @@ app.initializers.add('blomstra-search', () => {
app.extensionData app.extensionData
.for('blomstra-search') .for('blomstra-search')
.registerSetting(function (this: any) {
// No index yet, or first build predates this tracking — stay silent.
const indexedAnalyzer = app.data.settings['blomstra-search.indexed-analyzer'];
if (!activeIndex || !indexedAnalyzer) return null;
const currentAnalyzer = this.setting('blomstra-search.analyzer-language')() || 'english';
const currentMinLength = this.setting('blomstra-search.min-search-length')();
const indexedMinLength = String(
app.data.settings['blomstra-search.indexed-min-search-length'] || app.data.settings['blomstra-search.min-search-length']
);
if (currentAnalyzer === indexedAnalyzer && currentMinLength === indexedMinLength) return null;
return m(
Alert,
{ type: 'warning', dismissible: false, icon: 'fas fa-exclamation-triangle' },
app.translator.trans('blomstra-search.admin.index-settings-changed')
);
})
.registerSetting({ .registerSetting({
setting: 'blomstra-search.elastic-endpoint', setting: 'blomstra-search.elastic-endpoint',
label: app.translator.trans('blomstra-search.admin.elastic-endpoint'), label: app.translator.trans('blomstra-search.admin.elastic-endpoint'),
@ -116,5 +135,13 @@ app.initializers.add('blomstra-search', () => {
setting: 'blomstra-search.search-post-bodies', setting: 'blomstra-search.search-post-bodies',
label: app.translator.trans('blomstra-search.admin.search-post-bodies'), label: app.translator.trans('blomstra-search.admin.search-post-bodies'),
type: 'switch', type: 'switch',
})
.registerSetting({
setting: 'blomstra-search.min-search-length',
label: app.translator.trans('blomstra-search.admin.min-search-length.label'),
help: app.translator.trans('blomstra-search.admin.min-search-length.help'),
type: 'select',
options: { '1': '1', '2': '2', '3': '3', '4': '4' },
default: app.data.settings['blomstra-search.min-search-length'],
}); });
}); });

View File

@ -9,6 +9,15 @@ import DiscussionsSearchSource from './SearchSources/DiscussionsSearchSource';
import extendDiscussionState from './PaginatedListStates/extendDiscussionState'; import extendDiscussionState from './PaginatedListStates/extendDiscussionState';
app.initializers.add('blomstra-search', () => { app.initializers.add('blomstra-search', () => {
const minLength = parseInt(app.data.settings['blomstra-search.min-search-length'] || String(Search.MIN_SEARCH_LEN), 10);
if (minLength !== Search.MIN_SEARCH_LEN) {
// Flarum provides no extension point for MIN_SEARCH_LEN, so we overwrite the
// static property directly. TypeScript `readonly` is compile-time only — at
// runtime this is a plain property assignment and is safe as long as no code
// reads MIN_SEARCH_LEN before this initializer runs.
(Search as any).MIN_SEARCH_LEN = minLength;
}
extend(Search.prototype, 'sourceItems', function (this: Search<SearchAttrs>, items: ItemList<SearchSource>) { extend(Search.prototype, 'sourceItems', function (this: Search<SearchAttrs>, items: ItemList<SearchSource>) {
items.replace('discussions', new DiscussionsSearchSource()); items.replace('discussions', new DiscussionsSearchSource());
}); });

View File

@ -8,7 +8,8 @@ blomstra-search:
label: Analyzer language label: Analyzer language
help: | help: |
The analyzer makes search understand stop words and undertakes language The analyzer makes search understand stop words and undertakes language
specific improvements for indexing. specific improvements for indexing. Changing this requires a full index rebuild to take effect.
index-settings-changed: "Search index settings have changed. Run: php flarum blomstra:search:index build"
reindex-required: reindex-required:
title: Search index rebuild required title: Search index rebuild required
detail: "The search index is not compatible with this version of the extension. Run: php flarum blomstra:search:index build" detail: "The search index is not compatible with this version of the extension. Run: php flarum blomstra:search:index build"
@ -16,3 +17,9 @@ blomstra-search:
search-post-bodies: Search inside comments search-post-bodies: Search inside comments
match-sentences: Match search term against full sentence match-sentences: Match search term against full sentence
match-words: Match search term against full words match-words: Match search term against full words
min-search-length:
label: Minimum search query length
help: |
Minimum number of characters required before a search is triggered. Lower this to 1 or 2
for CJK (Chinese, Japanese, Korean) communities where single characters carry full meaning.
Changing this requires a full index rebuild to take effect.

View File

@ -35,6 +35,9 @@ class BuildCommand extends Command
*/ */
public const INDEX_COMPAT_VERSION = 'v2'; public const INDEX_COMPAT_VERSION = 'v2';
/** Matches Flarum's Search::MIN_SEARCH_LEN — the default minimum query length. */
public const DEFAULT_MIN_SEARCH_LENGTH = 3;
protected $signature = 'blomstra:search:index protected $signature = 'blomstra:search:index
{action? : build | promote | rollback | discard | mapping | fill} {action? : build | promote | rollback | discard | mapping | fill}
{--fresh} {--fresh}
@ -248,6 +251,7 @@ HELP;
$settings->set('blomstra-search.active-index', $staging); $settings->set('blomstra-search.active-index', $staging);
$settings->set('blomstra-search.staging-index', null); $settings->set('blomstra-search.staging-index', null);
$settings->set('blomstra-search.index-compatible', self::INDEX_COMPAT_VERSION); $settings->set('blomstra-search.index-compatible', self::INDEX_COMPAT_VERSION);
$this->saveIndexedConfig($client, $settings, $staging);
} }
protected function runRollback(Client $client, string $alias, SettingsRepositoryInterface $settings): void protected function runRollback(Client $client, string $alias, SettingsRepositoryInterface $settings): void
@ -274,6 +278,7 @@ HELP;
$settings->set('blomstra-search.active-index', $backup); $settings->set('blomstra-search.active-index', $backup);
$settings->set('blomstra-search.backup-index', null); $settings->set('blomstra-search.backup-index', null);
$this->saveIndexedConfig($client, $settings, $backup);
} }
protected function runDiscard(Client $client, SettingsRepositoryInterface $settings): void protected function runDiscard(Client $client, SettingsRepositoryInterface $settings): void
@ -416,7 +421,7 @@ HELP;
$client->indices()->create([ $client->indices()->create([
'index' => $concrete, 'index' => $concrete,
'body' => ['settings' => $this->indexSettings($settings)], 'body' => ['settings' => $this->buildIndexSettings($settings)],
]); ]);
$client->indices()->putAlias(['index' => $concrete, 'name' => $alias]); $client->indices()->putAlias(['index' => $concrete, 'name' => $alias]);
@ -424,6 +429,8 @@ HELP;
$settings->set('blomstra-search.active-index', $concrete); $settings->set('blomstra-search.active-index', $concrete);
$settings->set('blomstra-search.index-compatible', self::INDEX_COMPAT_VERSION); $settings->set('blomstra-search.index-compatible', self::INDEX_COMPAT_VERSION);
$this->saveIndexedConfig($client, $settings, $concrete);
foreach ($seeders as $seeder) { foreach ($seeders as $seeder) {
$this->setContinueAt($settings, $seeder->type(), null); $this->setContinueAt($settings, $seeder->type(), null);
} }
@ -466,7 +473,7 @@ HELP;
$client->indices()->create([ $client->indices()->create([
'index' => $staging, 'index' => $staging,
'body' => ['settings' => $this->indexSettings($settings)], 'body' => ['settings' => $this->buildIndexSettings($settings)],
]); ]);
$settings->set('blomstra-search.staging-index', $staging); $settings->set('blomstra-search.staging-index', $staging);
@ -480,14 +487,40 @@ HELP;
return $staging; return $staging;
} }
protected function indexSettings(SettingsRepositoryInterface $settings): array /**
* Persist the analysis config that is actually live in ES for the given index.
* Reading from ES (rather than from Flarum settings) means rollbacks are also
* covered: the stored values always reflect the index that is currently aliased,
* not the settings at the time the command ran.
*/
protected function saveIndexedConfig(Client $client, SettingsRepositoryInterface $settings, string $indexName): void
{ {
$response = $client->indices()->getSettings(['index' => $indexName]);
$analysis = Arr::get($response, "$indexName.settings.index.analysis", []);
$analyzer = Arr::get($analysis, 'analyzer.flarum_analyzer.type', 'english');
$minGram = (int) Arr::get($analysis, 'filter.partial_search_filter.min_gram', self::DEFAULT_MIN_SEARCH_LENGTH);
$settings->set('blomstra-search.indexed-analyzer', $analyzer);
$settings->set('blomstra-search.indexed-min-search-length', $minGram);
}
protected function buildIndexSettings(SettingsRepositoryInterface $settings): array
{
$language = $settings->get('blomstra-search.analyzer-language') ?: 'english';
$minGram = max(1, (int) ($settings->get('blomstra-search.min-search-length') ?: self::DEFAULT_MIN_SEARCH_LENGTH));
$maxGram = 10;
if ($minGram >= $maxGram) {
throw new \InvalidArgumentException("min_gram ($minGram) must be less than max_gram ($maxGram).");
}
return [ return [
'index.max_ngram_diff' => 7, 'index.max_ngram_diff' => $maxGram - $minGram,
'analysis' => [ 'analysis' => [
'analyzer' => [ 'analyzer' => [
'flarum_analyzer' => [ 'flarum_analyzer' => [
'type' => $settings->get('blomstra-search.analyzer-language') ?: 'english', 'type' => $language,
], ],
'flarum_analyzer_partial' => [ 'flarum_analyzer_partial' => [
'type' => 'custom', 'type' => 'custom',
@ -498,8 +531,8 @@ HELP;
'filter' => [ 'filter' => [
'partial_search_filter' => [ 'partial_search_filter' => [
'type' => 'ngram', 'type' => 'ngram',
'min_gram' => 3, 'min_gram' => $minGram,
'max_gram' => 10, 'max_gram' => $maxGram,
'token_chars' => ['letter', 'digit', 'symbol'], 'token_chars' => ['letter', 'digit', 'symbol'],
], ],
], ],