Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 155
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CompletionSuggester
0.00% covered (danger)
0.00%
0 / 155
0.00% covered (danger)
0.00%
0 / 10
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
90
 suggest
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
56
 processMSearchResponse
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 collectCompSuggestResults
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 collectPrefixSearchResults
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 getSuggestSearchRequest
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getPrefixSearchRequest
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 newLog
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getCompletionIndex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getResultsTransformer
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace CirrusSearch;
4
5use CirrusSearch\Profile\SearchProfileService;
6use CirrusSearch\Query\CompSuggestQueryBuilder;
7use CirrusSearch\Query\PrefixSearchQueryBuilder;
8use CirrusSearch\Search\CompletionResultsCollector;
9use CirrusSearch\Search\FancyTitleResultsType;
10use CirrusSearch\Search\MSearchRequests;
11use CirrusSearch\Search\SearchContext;
12use CirrusSearch\Search\SearchRequestBuilder;
13use CirrusSearch\SecondTry\SecondTryRunner;
14use CirrusSearch\SecondTry\SecondTryRunnerFactory;
15use CirrusSearch\SecondTry\SecondTrySearchFactory;
16use Closure;
17use Elastica\Index;
18use Elastica\Multi\Search as MultiSearch;
19use Elastica\Query;
20use Elastica\ResultSet;
21use Elastica\Search;
22use MediaWiki\MediaWikiServices;
23use MediaWiki\Search\SearchSuggestion;
24use MediaWiki\Search\SearchSuggestionSet;
25use MediaWiki\Status\Status;
26use MediaWiki\User\User;
27use Wikimedia\Assert\Assert;
28
29/**
30 * Performs search as you type queries using Completion Suggester.
31 *
32 * @license GPL-2.0-or-later
33 */
34
35/**
36 * Completion Suggester Searcher
37 *
38 * NOTES:
39 * The CompletionSuggester is built on top of the ElasticSearch Completion
40 * Suggester.
41 * (https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters-completion.html).
42 *
43 * This class is used at query time, see
44 * CirrusSearch\BuildDocument\SuggestBuilder for index time logic.
45 *
46 * Document model: Cirrus documents are indexed with 2 suggestions:
47 *
48 * 1. The title suggestion (and close redirects).
49 * This helps to avoid displaying redirects with typos (e.g. Albert Enstein,
50 * Unietd States) where we make the assumption that if the redirect is close
51 * enough it's likely a typo and it's preferable to display the canonical title.
52 * This decision is made at index-time in SuggestBuilder::extractTitleAndSimilarRedirects.
53 *
54 * 2. The redirect suggestions
55 * Because the same canonical title can be returned twice we support fetch_limit_factor
56 * in suggest profiles to fetch more than what the use asked.
57 *
58 * Additionally if the namespaces request include non NS_MAIN a prefix search query
59 * is sent to the main index. Results are appended to the suggest results. Appending
60 * is far from ideal but in the current state scores between the suggest index and prefix
61 * search are not comparable.
62 * TODO: investigate computing the comp suggest score on main indices to properly merge
63 * results.
64 */
65class CompletionSuggester extends ElasticsearchIntermediary {
66    /**
67     * @const string multisearch key to identify the comp suggest request
68     */
69    private const MSEARCH_KEY_SUGGEST = "suggest";
70
71    /**
72     * @const string multisearch key to identify the prefix search request
73     */
74    private const MSEARCH_KEY_PREFIX = "prefix";
75
76    /**
77     * Search type (used for logs & timeout configs)
78     */
79    private const SEARCH_TYPE = 'comp_suggest';
80
81    /**
82     * @var int maximum number of result (final)
83     */
84    private $limit;
85
86    /**
87     * @var int offset (final)
88     */
89    private $offset;
90
91    /**
92     * @var string index base name to use (final)
93     */
94    private $indexBaseName;
95
96    /**
97     * @var Index (final)
98     */
99    private $completionIndex;
100
101    /**
102     * Search environment configuration (final)
103     * @var SearchConfig
104     */
105    private $config;
106
107    /**
108     * @var SearchContext (final)
109     */
110    private $searchContext;
111
112    /**
113     * @var CompSuggestQueryBuilder (final)
114     */
115    private $compSuggestBuilder;
116
117    /**
118     * @var PrefixSearchQueryBuilder (final)
119     */
120    private $prefixSearchQueryBuilder;
121
122    /**
123     * @var SearchRequestBuilder the builder to build the search for prefix search queries
124     */
125    private $prefixSearchRequestBuilder;
126
127    private SecondTryRunner $secondTryRunner;
128
129    /**
130     * @param Connection $conn
131     * @param int $limit Limit the results to this many
132     * @param int $offset
133     * @param SearchConfig|null $config Configuration settings
134     * @param int[]|null $namespaces Array of namespace numbers to search or null to search all namespaces.
135     * @param User|null $user user for which this search is being performed.  Attached to slow request logs.
136     * @param string|bool $index Base name for index to search from, defaults to $wgCirrusSearchIndexBaseName
137     * @param null $profileName force the profile to use otherwise SearchProfileService defaults will be used
138     * @param CirrusDebugOptions|null $debugOptions
139     * @param SecondTryRunnerFactory|null $secondTryRunnerFactory the SecondTryRunner factory
140     */
141    public function __construct(
142        Connection $conn,
143        $limit,
144        $offset = 0,
145        ?SearchConfig $config = null,
146        ?array $namespaces = null,
147        ?User $user = null,
148        $index = false,
149        $profileName = null,
150        ?CirrusDebugOptions $debugOptions = null,
151        ?SecondTryRunnerFactory $secondTryRunnerFactory = null
152    ) {
153        if ( $config === null ) {
154            // @todo connection has an embedded config ... reuse that? somehow should
155            // at least ensure they are the same.
156            $config = MediaWikiServices::getInstance()
157                ->getConfigFactory()
158                ->makeConfig( CirrusSearch::NAME );
159        }
160        parent::__construct( $conn, $user, $config->get( CirrusConfigNames::SlowSearch ) );
161        if ( $secondTryRunnerFactory === null ) {
162            $secondTryRunnerFactory = new SecondTryRunnerFactory(
163                new SecondTrySearchFactory(
164                    MediaWikiServices::getInstance()
165                        ->getLanguageConverterFactory(),
166                ),
167                $config
168            );
169        }
170        $this->secondTryRunner = $secondTryRunnerFactory->create( SearchProfileService::CONTEXT_COMPLETION );
171
172        $this->limit = $limit;
173        $this->offset = $offset;
174        $this->indexBaseName = $index ?: $config->get( SearchConfig::INDEX_BASE_NAME );
175        $altIndexId = $config->get( CirrusConfigNames::CompletionSuggesterUseAltIndexId );
176        // Check if the alternate index id is actually setup
177        $altIndex = null;
178        if ( $altIndexId !== null && AlternativeIndices::isValidAltIndexId( $altIndexId ) ) {
179            $altIndex = AlternativeIndices::build( $config )->getAlternativeIndexById( AlternativeIndices::COMPLETION, (int)$altIndexId );
180            if ( $altIndex !== null && !$altIndex->isUse() ) {
181                $altIndex = null;
182            }
183        }
184        if ( $altIndex !== null ) {
185            $this->completionIndex = $altIndex->getIndex( $this->connection );
186            $this->config = $altIndex->getConfig();
187        } else {
188            $this->completionIndex = $this->connection->getIndex( $this->indexBaseName, Connection::TITLE_SUGGEST_INDEX_SUFFIX );
189            $this->config = $config;
190        }
191        $this->searchContext = new SearchContext( $this->config, $namespaces, $debugOptions );
192
193        $profileDefinition = $this->config->getProfileService()
194            ->loadProfile( SearchProfileService::COMPLETION, SearchProfileService::CONTEXT_COMPLETION, $profileName );
195        $this->compSuggestBuilder = new CompSuggestQueryBuilder(
196            $this->searchContext,
197            $profileDefinition,
198            $this->secondTryRunner,
199            $limit,
200            $offset
201        );
202
203        $this->prefixSearchQueryBuilder = new PrefixSearchQueryBuilder( $this->secondTryRunner );
204    }
205
206    /**
207     * Produce a set of completion suggestions for text using _suggest
208     * See https://www.elastic.co/guide/en/elasticsearch/reference/1.6/search-suggesters-completion.html
209     *
210     * WARNING: experimental API
211     *
212     * @param string $text Search term
213     * @return Status
214     */
215    public function suggest( $text ) {
216        $secondTryCandidates = $this->secondTryRunner->candidates( $text );
217        $suggestSearch = $this->getSuggestSearchRequest( $text, $secondTryCandidates );
218        $mSearchRequests = new MSearchRequests();
219
220        if ( $suggestSearch !== null ) {
221            $mSearchRequests->addRequest( self::MSEARCH_KEY_SUGGEST, $suggestSearch );
222        }
223
224        $prefixSearch = $this->getPrefixSearchRequest( $text, $secondTryCandidates );
225        if ( $prefixSearch !== null ) {
226            $mSearchRequests->addRequest( self::MSEARCH_KEY_PREFIX, $prefixSearch );
227        }
228
229        if ( !$mSearchRequests->getRequests() ) {
230            return Status::newGood( SearchSuggestionSet::emptySuggestionSet() );
231        }
232        $description = "{queryType} search for '{query}'";
233
234        if ( $this->searchContext->getDebugOptions()->isCirrusDumpQuery() ) {
235            return $mSearchRequests->dumpQuery( $description );
236        }
237
238        $multiSearch = new MultiSearch( $this->connection->getClient() );
239        $multiSearch->addSearches( $mSearchRequests->getRequests() );
240
241        $this->connection->setTimeout( $this->getClientTimeout( self::SEARCH_TYPE ) );
242
243        $status = Util::doPoolCounterWork( PoolCounterKey::COMPLETION, $this->user,
244                function () use ( $multiSearch, $text, $description ) {
245                    $log = $this->newLog( $description, self::SEARCH_TYPE, [
246                        'query' => $text,
247                        'offset' => $this->offset,
248                    ] );
249
250                    $resultsTransformer = $this->getResultsTransformer( $log );
251
252                    return $this->runMSearch( $multiSearch, $log, $this->connection,
253                        $resultsTransformer );
254                } );
255
256        if ( $status->isOk() && $this->searchContext->getDebugOptions()->isCirrusDumpResult() ) {
257            $resultSets = $status->getValue()->getResultSets();
258            $responses = $mSearchRequests->toMSearchResponses( $resultSets );
259
260            return $responses->dumpResults( $description );
261        }
262
263        return $status;
264    }
265
266    /**
267     * @param ResultSet[] $results
268     * @param CompletionRequestLog $log
269     * @return SearchSuggestionSet
270     */
271    private function processMSearchResponse( array $results, CompletionRequestLog $log ) {
272        $collector = new CompletionResultsCollector(
273            $this->limit, $this->offset, $this->config->get( CirrusConfigNames::CompletionBannedPageIds ) );
274        $totalHits = $this->collectCompSuggestResults( $collector, $results, $log );
275        $totalHits += $this->collectPrefixSearchResults( $collector, $results, $log );
276        $log->setTotalHits( $totalHits );
277        return $collector->logAndGetSet( $log );
278    }
279
280    /**
281     * @param CompletionResultsCollector $collector
282     * @param ResultSet[] $results
283     * @param CompletionRequestLog $log
284     * @return int
285     */
286    private function collectCompSuggestResults( CompletionResultsCollector $collector, array $results, CompletionRequestLog $log ) {
287        if ( !isset( $results[self::MSEARCH_KEY_SUGGEST] ) ) {
288            return 0;
289        }
290        $log->addIndex( $this->completionIndex->getName() );
291        $suggestResults = $results[self::MSEARCH_KEY_SUGGEST];
292        $log->setSuggestTookMs( intval( $suggestResults->getResponse()->getQueryTime() * 1000 ) );
293        return $this->compSuggestBuilder->postProcess(
294            $collector,
295            $suggestResults,
296            $this->completionIndex->getName()
297        );
298    }
299
300    /**
301     * @param CompletionResultsCollector $collector
302     * @param ResultSet[] $results
303     * @param CompletionRequestLog $log
304     * @return int
305     * @throws \Exception
306     */
307    private function collectPrefixSearchResults( CompletionResultsCollector $collector, array $results, CompletionRequestLog $log ) {
308        if ( !isset( $results[self::MSEARCH_KEY_PREFIX] ) ) {
309            return 0;
310        }
311        $indexName = $this->prefixSearchRequestBuilder->getIndex()->getName();
312        $prefixResults = $results[self::MSEARCH_KEY_PREFIX];
313        $totalHits = $prefixResults->getTotalHits();
314        $log->addIndex( $indexName );
315        $log->setPrefixTookMs( intval( $prefixResults->getResponse()->getQueryTime() * 1000 ) );
316        // We only append as we can't really compare scores without more complex code/evaluation
317        if ( $collector->isFull() ) {
318            return $totalHits;
319        }
320        /** @var FancyTitleResultsType $rType */
321        $rType = $this->prefixSearchRequestBuilder->getSearchContext()->getResultsType();
322        // the code below highly depends on the array format built by
323        // FancyTitleResultsType::transformOneElasticResult assert that this type
324        // is properly set so that we fail during unit tests if someone changes it
325        // inadvertently.
326        Assert::precondition( $rType instanceof FancyTitleResultsType, '$rType must be a FancyTitleResultsType' );
327        // scores can go negative, it's not a problem we only use scores for sorting
328        // they'll be forgotten in client response
329        $score = $collector->getMinScore() !== null ? $collector->getMinScore() - 1 : count( $prefixResults->getResults() );
330
331        $namespaces = $this->prefixSearchRequestBuilder->getSearchContext()->getNamespaces();
332        foreach ( $prefixResults->getResults() as $res ) {
333            $pageId = $this->config->makePageId( $res->getId() );
334            $title = FancyTitleResultsType::chooseBestTitleOrRedirect( $rType->transformOneElasticResult( $res, $namespaces ) );
335            if ( $title === false ) {
336                continue;
337            }
338            $suggestion = new SearchSuggestion( $score--, $title->getPrefixedText(), $title, $pageId );
339            if ( !$collector->collect( $suggestion, 'prefix', $indexName ) && $collector->isFull() ) {
340                break;
341            }
342        }
343        return $totalHits;
344    }
345
346    /**
347     * @param string $text Search term
348     * @param array<string, string[]> $secondTryCandidates second try search candidates
349     * @return Search|null
350     */
351    private function getSuggestSearchRequest( string $text, array $secondTryCandidates ): ?Search {
352        if ( !$this->compSuggestBuilder->areResultsPossible() ) {
353            return null;
354        }
355
356        $suggest = $this->compSuggestBuilder->build( $text, $secondTryCandidates );
357        $query = new Query( new Query\MatchNone() );
358        $query->setSize( 0 );
359        $query->setSuggest( $suggest );
360        $query->setSource( [ 'target_title' ] );
361        $search = new Search( $this->connection->getClient() );
362        $search->addIndex( $this->completionIndex );
363        $search->setQuery( $query );
364        return $search;
365    }
366
367    /**
368     * @param string $term Search term
369     * @param array<string, string[]> $secondTryCandidates second try candidates
370     * @return Search|null
371     */
372    private function getPrefixSearchRequest( $term, array $secondTryCandidates ): ?Search {
373        $namespaces = $this->searchContext->getNamespaces();
374        if ( $namespaces === null ) {
375            return null;
376        }
377
378        foreach ( $namespaces as $k => $v ) {
379            // non-strict comparison, it can be strings
380            if ( $v === NS_MAIN ) {
381                unset( $namespaces[$k] );
382            }
383        }
384
385        if ( $namespaces === [] ) {
386            return null;
387        }
388        $limit = CompSuggestQueryBuilder::computeHardLimit( $this->limit, $this->offset, $this->config );
389        if ( $this->offset > $limit ) {
390            return null;
391        }
392        $prefixSearchContext = new SearchContext( $this->config, $namespaces );
393        $prefixSearchContext->setResultsType( new FancyTitleResultsType( 'prefix' ) );
394        $this->prefixSearchQueryBuilder->build( $prefixSearchContext, $term, $secondTryCandidates );
395        if ( !$prefixSearchContext->areResultsPossible() ) {
396            // $prefixSearchContext might contain warnings, but these are lost.
397            return null;
398        }
399        $this->prefixSearchRequestBuilder = new SearchRequestBuilder( $prefixSearchContext, $this->connection, $this->indexBaseName );
400        $this->prefixSearchRequestBuilder->setTimeout( $this->getTimeout( self::SEARCH_TYPE ) );
401        return $this->prefixSearchRequestBuilder->setLimit( $limit )
402            // collect all results up to $limit, $this->offset is the offset the client wants
403            // not the offset in prefix search results.
404            ->setOffset( 0 )
405            ->build();
406    }
407
408    /**
409     * @param string $description
410     * @param string $queryType
411     * @param array $extra
412     * @return CompletionRequestLog
413     */
414    protected function newLog( $description, $queryType, array $extra = [] ) {
415        return new CompletionRequestLog(
416            $description,
417            $queryType,
418            $extra,
419            $this->searchContext->getNamespaces()
420        );
421    }
422
423    /**
424     * @return Index
425     */
426    public function getCompletionIndex() {
427        return $this->completionIndex;
428    }
429
430    /**
431     * @param CompletionRequestLog $log
432     * @return Closure|null
433     */
434    private function getResultsTransformer( CompletionRequestLog $log ): ?Closure {
435        $resultsTransformer = null;
436        if ( !$this->searchContext->getDebugOptions()->isCirrusDumpResult() ) {
437            $resultsTransformer = function ( \Elastica\Multi\ResultSet $results ) use ( $log ) {
438                return $this->processMSearchResponse( $results->getResultSets(), $log );
439            };
440        }
441
442        return $resultsTransformer;
443    }
444
445}