perf: sort by date by default, skip scoring on field sorts
- Default sort changed from relevance to updated_at desc. This lets has_child use score_mode:none, which skips child scoring entirely and lets ES short-circuit early on large corpora. - When a field sort is requested, score_mode is also none for the same reason. score_mode:sum is only used when sorting by relevance. - track_total_hits:false avoids a full-index count on every query, allowing ES to stop once it has collected enough results. - Strip all gambit operators (tag:foo, author:bar, is:unread, etc.) from the ES query string, not just is:private. Leaving them in caused operator:and to require the gambit tokens to appear literally in post content, producing zero results when gambits were combined with text.
This commit is contained in:
parent
80357e6a77
commit
08353d2473
|
|
@ -78,27 +78,13 @@ class SearchController extends ListDiscussionsController
|
|||
$offset = $this->extractOffset($request);
|
||||
$include = array_merge($this->extractInclude($request), ['state']);
|
||||
|
||||
$query = BoolQuery::create()
|
||||
// Always restrict to discussion documents; posts are only searched via has_child.
|
||||
->add(TermQuery::create('join_field', 'discussion'), 'filter');
|
||||
|
||||
if (!empty($search)) {
|
||||
$query->add($this->buildTextQuery($search, $actor));
|
||||
}
|
||||
|
||||
$this->addFilters($query, $actor, $filters);
|
||||
|
||||
$builder = (new Builder($this->elastic))
|
||||
->index(resolve('blomstra.search.elastic_index'))
|
||||
->size($limit + 1)
|
||||
->from($offset)
|
||||
->addQuery($query);
|
||||
|
||||
$knownSortFields = array_merge(array_values($this->translateSort), ['rawId']);
|
||||
$logger = resolve(LoggerInterface::class);
|
||||
|
||||
$phpSortField = null;
|
||||
$phpSortDir = 'desc';
|
||||
$needsScoring = true;
|
||||
$sorts = [];
|
||||
|
||||
foreach ($this->extractSort($request) as $field => $direction) {
|
||||
$translated = $this->translateSort[$field] ?? $field;
|
||||
|
|
@ -108,7 +94,8 @@ class SearchController extends ListDiscussionsController
|
|||
continue;
|
||||
}
|
||||
|
||||
$builder->addSort(new Sort($translated, $direction));
|
||||
$sorts[] = new Sort($translated, $direction);
|
||||
$needsScoring = false;
|
||||
|
||||
if ($phpSortField === null && $translated !== 'rawId') {
|
||||
$phpSortField = $translated;
|
||||
|
|
@ -116,7 +103,44 @@ class SearchController extends ListDiscussionsController
|
|||
}
|
||||
}
|
||||
|
||||
$response = $builder->search();
|
||||
// Default to latest when no sort is specified — faster than relevance
|
||||
// because has_child can use score_mode:none and ES can short-circuit early.
|
||||
if ($phpSortField === null) {
|
||||
$needsScoring = false;
|
||||
$phpSortField = 'updated_at';
|
||||
$phpSortDir = 'desc';
|
||||
$sorts[] = new Sort('updated_at', 'desc');
|
||||
}
|
||||
|
||||
$query = BoolQuery::create()
|
||||
// Always restrict to discussion documents; posts are only searched via has_child.
|
||||
->add(TermQuery::create('join_field', 'discussion'), 'filter');
|
||||
|
||||
if (!empty($search)) {
|
||||
$query->add($this->buildTextQuery($search, $actor, $needsScoring));
|
||||
}
|
||||
|
||||
$this->addFilters($query, $actor, $filters);
|
||||
|
||||
$builder = (new Builder($this->elastic))
|
||||
->index(resolve('blomstra.search.elastic_index'))
|
||||
->addQuery($query);
|
||||
|
||||
foreach ($sorts as $sort) {
|
||||
$builder->addSort($sort);
|
||||
}
|
||||
|
||||
// track_total_hits: false lets ES stop counting once it has collected
|
||||
// enough results in sort order, avoiding a full-index count on every query.
|
||||
$payload = $builder->getPayload();
|
||||
$payload['track_total_hits'] = false;
|
||||
|
||||
$response = $this->elastic->search([
|
||||
'index' => resolve('blomstra.search.elastic_index'),
|
||||
'size' => $limit + 1,
|
||||
'from' => $offset,
|
||||
'body' => $payload,
|
||||
]);
|
||||
|
||||
Discussion::setStateUser($actor);
|
||||
|
||||
|
|
@ -196,12 +220,15 @@ class SearchController extends ListDiscussionsController
|
|||
* Build the text-matching portion of the query.
|
||||
*
|
||||
* Discussion titles are matched directly (filtered to join_field=discussion).
|
||||
* Post bodies are matched via has_child with score_mode=sum so discussions
|
||||
* with many matching posts score higher than those with a single strong match.
|
||||
* inner_hits returns the best-scoring post for use as mostRelevantPost.
|
||||
* Post bodies are matched via has_child. When $needsScoring is true (relevance
|
||||
* sort), score_mode=sum accumulates child scores onto the parent. When false
|
||||
* (any field sort, including the default updated_at), score_mode=none skips
|
||||
* scoring entirely — ES only checks whether a matching child exists, which is
|
||||
* significantly cheaper on large corpora.
|
||||
* inner_hits returns the best-matching post for use as mostRelevantPost.
|
||||
* Hidden posts are only included in matching for users with post.hide permission.
|
||||
*/
|
||||
protected function buildTextQuery(string $search, User $actor): BoolQuery
|
||||
protected function buildTextQuery(string $search, User $actor, bool $needsScoring = false): BoolQuery
|
||||
{
|
||||
$textQuery = BoolQuery::create();
|
||||
|
||||
|
|
@ -222,7 +249,7 @@ class SearchController extends ListDiscussionsController
|
|||
$postQuery->minimumShouldMatch(1);
|
||||
|
||||
$textQuery->add(
|
||||
HasChildQuery::create('post', $postQuery)->withInnerHits(),
|
||||
HasChildQuery::create('post', $postQuery, $needsScoring ? 'sum' : 'none')->withInnerHits(),
|
||||
'should'
|
||||
);
|
||||
}
|
||||
|
|
@ -304,8 +331,12 @@ class SearchController extends ListDiscussionsController
|
|||
$search = Arr::get($filters, 'q');
|
||||
|
||||
if ($search) {
|
||||
// Strip Flarum gambit operators (tag:foo, author:bar, is:private, etc.)
|
||||
// before passing to ES. These are structural filters handled separately;
|
||||
// leaving them in causes operator:and to require the gambit tokens to
|
||||
// appear literally in post content, producing zero results.
|
||||
$q = collect(explode(' ', $search))
|
||||
->filter(fn (string $part) => $part !== 'is:private')
|
||||
->filter(fn (string $part) => !preg_match('/^\w+:/', $part))
|
||||
->filter()
|
||||
->join(' ');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue