Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.00% covered (warning)
75.00%
90 / 120
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
RescoreBuilder
75.00% covered (warning)
75.00%
90 / 120
44.44% covered (danger)
44.44%
4 / 9
81.52
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 build
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
4.04
 buildRescoreQuery
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 buildLtrQuery
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 windowSize
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 prepareQueryParams
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
9.06
 getSupportedProfile
72.73% covered (warning)
72.73%
24 / 33
0.00% covered (danger)
0.00%
0 / 1
10.64
 isProfileNamespaceSupported
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 isProfileSyntaxSupported
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3namespace CirrusSearch\Search\Rescore;
4
5use CirrusSearch\CirrusSearchHookRunner;
6use CirrusSearch\Elastica\LtrQuery;
7use CirrusSearch\Profile\SearchProfileService;
8use CirrusSearch\Search\SearchContext;
9use Elastica\Query\AbstractQuery;
10use MediaWiki\MainConfigNames;
11
12/**
13 * Set of rescore builders
14 *
15 * @license GPL-2.0-or-later
16 */
17
18/**
19 * Builds a rescore queries by reading a rescore profile.
20 */
21class RescoreBuilder {
22
23    /** Maximum number of rescore profile fallbacks */
24    private const FALLBACK_LIMIT = 4;
25
26    /**
27     * List of allowed rescore params
28     * @todo refactor to const with php 5.6
29     *
30     * @var string[]
31     */
32    private static $rescoreMainParams = [
33        'query_weight',
34        'rescore_query_weight',
35        'score_mode'
36    ];
37
38    private const FUNCTION_SCORE_TYPE = "function_score";
39    private const LTR_TYPE = "ltr";
40    private const PHRASE = "phrase";
41
42    /**
43     * @var SearchContext
44     */
45    private $context;
46
47    /**
48     * @var array|string a rescore profile
49     */
50    private $profile;
51    /**
52     * @var CirrusSearchHookRunner
53     */
54    private $cirrusSearchHookRunner;
55
56    /**
57     * @param SearchContext $context
58     * @param CirrusSearchHookRunner $cirrusSearchHookRunner
59     * @param string|null $profile
60     * @throws InvalidRescoreProfileException
61     */
62    public function __construct( SearchContext $context, CirrusSearchHookRunner $cirrusSearchHookRunner, $profile = null ) {
63        $this->context = $context;
64        $this->profile = $this->getSupportedProfile( $profile ?? $context->getRescoreProfile() );
65        $this->cirrusSearchHookRunner = $cirrusSearchHookRunner;
66    }
67
68    /**
69     * @return array[] List of rescore queries
70     */
71    public function build() {
72        $rescores = [];
73        foreach ( $this->profile['rescore'] as $rescoreDef ) {
74            $windowSize = $this->windowSize( $rescoreDef );
75            if ( $windowSize <= 0 ) {
76                continue;
77            }
78
79            $rescoreQuery = $this->buildRescoreQuery( $rescoreDef );
80            if ( $rescoreQuery === null ) {
81                continue;
82            }
83
84            $rescore = [
85                'window_size' => $windowSize,
86                'query' => $this->prepareQueryParams( $rescoreDef ),
87            ];
88            $rescore['query']['rescore_query'] = $rescoreQuery;
89            $rescores[] = $rescore;
90        }
91        return $rescores;
92    }
93
94    /**
95     * builds the 'query' attribute by reading type
96     *
97     * @param array $rescoreDef
98     * @return AbstractQuery|null the rescore query
99     * @throws InvalidRescoreProfileException
100     */
101    private function buildRescoreQuery( array $rescoreDef ) {
102        switch ( $rescoreDef['type'] ) {
103            case self::FUNCTION_SCORE_TYPE:
104                $funcChain = new FunctionScoreChain( $this->context, $rescoreDef['function_chain'],
105                    $rescoreDef['function_chain_overrides'] ?? [], $this->cirrusSearchHookRunner );
106                return $funcChain->buildRescoreQuery();
107            case self::LTR_TYPE:
108                return $this->buildLtrQuery( $rescoreDef['model'] );
109            case self::PHRASE:
110                return $this->context->getPhraseRescoreQuery();
111            default:
112                throw new InvalidRescoreProfileException(
113                    "Unsupported rescore query type: " . $rescoreDef['type'] );
114        }
115    }
116
117    /**
118     * @param string $model Name of the sltr model to use
119     * @return AbstractQuery
120     */
121    private function buildLtrQuery( $model ) {
122        // This is a bit fragile, and makes the bold assumption
123        // only a single level of rescore will be used. This is
124        // strictly for debugging/testing before shipping a model
125        // live so shouldn't be a big deal.
126        $override = $this->context->getDebugOptions()->getCirrusMLRModel();
127        if ( $override ) {
128            $model = $override;
129        }
130
131        $bool = new \Elastica\Query\BoolQuery();
132        // the ltr query can return negative scores, which mucks with elasticsearch
133        // sorting as that will put these results below documents set to 0. Fix
134        // that up by adding a large constant boost.
135        $constant = new \Elastica\Query\ConstantScore( new \Elastica\Query\MatchAll );
136        $constant->setBoost( 100000 );
137        $bool->addShould( $constant );
138        $bool->addShould( new LtrQuery( $model, [
139                // TODO: These params probably shouldn't be hard coded
140                'query_string' => $this->context->getCleanedSearchTerm(),
141            ] ) );
142
143        return $bool;
144    }
145
146    /**
147     * @param array $rescore
148     * @return int the window size defined in the profile
149     * or the value from config if window_size_override is set.
150     */
151    private function windowSize( array $rescore ) {
152        if ( isset( $rescore['window_size_override'] ) ) {
153            $windowSize = $this->context->getConfig()->get( $rescore['window_size_override'] );
154            if ( $windowSize !== null ) {
155                return $windowSize;
156            }
157        }
158        return $rescore['window'];
159    }
160
161    /**
162     * Assemble query params in the rescore block
163     * Only self::$rescoreMainParams are allowed.
164     * @param array $settings
165     * @return array
166     */
167    private function prepareQueryParams( array $settings ) {
168        $def = [];
169        foreach ( self::$rescoreMainParams as $param ) {
170            if ( !isset( $settings[$param] ) ) {
171                continue;
172            }
173            $value = $settings[$param];
174            if ( isset( $settings[$param . '_override'] ) ) {
175                $oValue = $this->context->getConfig()->get( $settings[$param . '_override'] );
176                if ( $oValue !== null ) {
177                    $value = $oValue;
178                }
179            }
180            $def[$param] = $value;
181        }
182        return $def;
183    }
184
185    /**
186     * Inspect requested namespaces and return the supported profile
187     *
188     * @param string $profileName
189     * @return array the supported rescore profile.
190     * @throws InvalidRescoreProfileException
191     */
192    private function getSupportedProfile( $profileName ) {
193        $profile = $this->context->getConfig()
194            ->getProfileService()
195            ->loadProfileByName( SearchProfileService::RESCORE, $profileName );
196        if ( !is_array( $profile ) ) {
197            throw new InvalidRescoreProfileException(
198                "Invalid fallback profile, must be array: $profileName" );
199        }
200
201        $seen = [];
202        while ( true ) {
203            $seen[$profileName] = true;
204            if ( count( $seen ) > self::FALLBACK_LIMIT ) {
205                throw new InvalidRescoreProfileException(
206                    "Fell back more than " . self::FALLBACK_LIMIT . " times"
207                );
208            }
209
210            if ( !$this->isProfileNamespaceSupported( $profile )
211                || !$this->isProfileSyntaxSupported( $profile )
212            ) {
213                if ( !isset( $profile['fallback_profile'] ) ) {
214                    throw new InvalidRescoreProfileException(
215                        "Invalid rescore profile: fallback_profile is mandatory "
216                        . "if supported_namespaces is not 'all' or "
217                        . "unsupported_syntax is not null."
218                    );
219                }
220                $profileName = $profile['fallback_profile'];
221                if ( isset( $seen[$profileName] ) ) {
222                    $chain = implode( '->', array_keys( $seen ) ) . "->$profileName";
223                    throw new InvalidRescoreProfileException( "Cycle in rescore fallbacks: $chain" );
224                }
225
226                $profile = $this->context->getConfig()
227                    ->getProfileService()
228                    ->loadProfileByName( SearchProfileService::RESCORE, $profileName );
229                if ( !is_array( $profile ) ) {
230                    throw new InvalidRescoreProfileException(
231                        "Invalid fallback profile, must be array: $profileName" );
232                }
233                continue;
234            }
235            return $profile;
236        }
237    }
238
239    /**
240     * Check if a given profile supports the namespaces used by the current
241     * search request.
242     *
243     * @param array $profile Profile to check
244     * @return bool True is the profile supports current namespaces
245     * @throws InvalidRescoreProfileException
246     */
247    private function isProfileNamespaceSupported( array $profile ) {
248        if ( !is_array( $profile['supported_namespaces'] ) ) {
249            switch ( $profile['supported_namespaces'] ) {
250                case 'all':
251                    return true;
252                case 'content':
253                    $profileNs = $this->context->getConfig()->get( MainConfigNames::ContentNamespaces );
254                    // Default search namespaces are also considered content
255                    $defaultSearch = $this->context->getConfig()->get( MainConfigNames::NamespacesToBeSearchedDefault );
256                    foreach ( $defaultSearch as $ns => $isDefault ) {
257                        if ( $isDefault ) {
258                            $profileNs[] = $ns;
259                        }
260                    }
261                    break;
262                default:
263                    throw new InvalidRescoreProfileException( "Invalid rescore profile: supported_namespaces " .
264                        "should be 'all', 'content' or an array of namespaces" );
265            }
266        } else {
267            $profileNs = $profile['supported_namespaces'];
268        }
269
270        $queryNs = $this->context->getNamespaces();
271
272        if ( !$queryNs ) {
273            // According to comments in Searcher if namespaces is
274            // not set we run the query on all namespaces
275            // @todo: verify comments.
276            return false;
277        }
278
279        foreach ( $queryNs as $ns ) {
280            if ( !in_array( $ns, $profileNs ) ) {
281                return false;
282            }
283        }
284
285        return true;
286    }
287
288    /**
289     * Check if the given profile supports the syntax used by the
290     * current search request.
291     *
292     * @param array $profile
293     * @return bool
294     */
295    private function isProfileSyntaxSupported( array $profile ) {
296        if ( ( $profile['supported_syntax'] ?? [] ) !== [] ) {
297            $supportedSyntax = false;
298            foreach ( $profile['supported_syntax'] as $supported ) {
299                if ( $this->context->isSyntaxUsed( $supported ) ) {
300                    $supportedSyntax = true;
301                    break;
302                }
303            }
304            if ( !$supportedSyntax ) {
305                return false;
306            }
307        }
308        if ( ( $profile['unsupported_syntax'] ?? [] ) !== [] ) {
309            foreach ( $profile['unsupported_syntax'] as $reject ) {
310                if ( $this->context->isSyntaxUsed( $reject ) ) {
311                    return false;
312                }
313            }
314        }
315
316        return true;
317    }
318}