Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.00% covered (warning)
75.00%
171 / 228
34.78% covered (danger)
34.78%
8 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaSearchASTQueryBuilder
75.00% covered (warning)
75.00%
171 / 228
34.78% covered (danger)
34.78%
8 / 23
84.00
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
1
 getQuery
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
3.02
 applyLogisticFunction
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
4.31
 normalizeMultiClauseScores
21.43% covered (danger)
21.43%
3 / 14
0.00% covered (danger)
0.00%
0 / 1
11.76
 visitParsedBooleanNode
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
11.19
 visitBooleanClause
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitWordsQueryNode
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
1
 visitPhraseQueryNode
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 visitPhrasePrefixNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitNegatedNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitFuzzyNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitPrefixNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitWildcardNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitEmptyQueryNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitKeywordFeatureNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitNamespaceHeader
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWikibaseEntitiesHandler
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getSynonyms
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
13.26
 canonicalizeTerm
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 filterTermsTooShort
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 filterTermsTooDissimilarCanonicalized
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 filterTermsTooSimilar
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 filterTermsSupersets
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3namespace Wikibase\MediaInfo\Search;
4
5use CirrusSearch\Parser\AST\BooleanClause;
6use CirrusSearch\Parser\AST\EmptyQueryNode;
7use CirrusSearch\Parser\AST\FuzzyNode;
8use CirrusSearch\Parser\AST\KeywordFeatureNode;
9use CirrusSearch\Parser\AST\NamespaceHeaderNode;
10use CirrusSearch\Parser\AST\NegatedNode;
11use CirrusSearch\Parser\AST\ParsedBooleanNode;
12use CirrusSearch\Parser\AST\ParsedNode;
13use CirrusSearch\Parser\AST\ParsedQuery;
14use CirrusSearch\Parser\AST\PhrasePrefixNode;
15use CirrusSearch\Parser\AST\PhraseQueryNode;
16use CirrusSearch\Parser\AST\PrefixNode;
17use CirrusSearch\Parser\AST\Visitor\Visitor;
18use CirrusSearch\Parser\AST\WildcardNode;
19use CirrusSearch\Parser\AST\WordsQueryNode;
20use CirrusSearch\Query\Builder\NearMatchFieldQueryBuilder;
21use Elastica\Query\AbstractQuery;
22use Elastica\Query\BoolQuery;
23use Elastica\Query\FunctionScore;
24use Elastica\Query\MatchNone;
25use Elastica\Script\Script;
26use SplObjectStorage;
27use Wikibase\MediaInfo\Search\ASTQueryBuilder\PhraseQueryNodeHandler;
28use Wikibase\MediaInfo\Search\ASTQueryBuilder\WikibaseEntitiesHandler;
29use Wikibase\MediaInfo\Search\ASTQueryBuilder\WordsQueryNodeHandler;
30use Wikimedia\Assert\Assert;
31
32class MediaSearchASTQueryBuilder implements Visitor {
33    /** @var SplObjectStorage */
34    private $map;
35
36    /** @var ParsedQuery */
37    private $parsedQuery;
38
39    /** @var float[] */
40    private readonly array $boosts;
41
42    /** @var float[] */
43    private readonly array $decays;
44
45    /** @var array */
46    private readonly array $options;
47
48    /**
49     * @param MediaSearchASTEntitiesExtractor $entitiesExtractor
50     * @param array[] $stemmingSettings Stemming settings (see $wgWBCSUseStemming)
51     * @param string[] $languages Languages to search text in
52     * @param string $contentLanguage Content language code
53     * @param array[] $settings Optional weight/decay overrides, plus some options
54     */
55    public function __construct(
56        private readonly MediaSearchASTEntitiesExtractor $entitiesExtractor,
57        private readonly array $stemmingSettings,
58        private readonly array $languages,
59        private readonly string $contentLanguage,
60        array $settings = [],
61    ) {
62        $this->boosts = ( $settings['boost'] ?? [] ) + [
63            'statement' => 1.0,
64            'descriptions.$language' => 1.0,
65            'descriptions.$language.plain' => 1.0,
66            'title' => 1.0,
67            'title.plain' => 1.0,
68            'category' => 1.0,
69            'category.plain' => 1.0,
70            'heading' => 1.0,
71            'heading.plain' => 1.0,
72            'auxiliary_text' => 1.0,
73            'auxiliary_text.plain' => 1.0,
74            'file_text' => 1.0,
75            'file_text.plain' => 1.0,
76            'redirect.title' => 1.0,
77            'redirect.title.plain' => 1.0,
78            'text' => 1.0,
79            'text.plain' => 1.0,
80            'suggest' => 1.0,
81        ];
82        $this->decays = ( $settings['decay'] ?? [] ) + [
83            'descriptions.$language' => 1.0,
84            'descriptions.$language.plain' => 1.0,
85            'synonyms' => 1.0,
86        ];
87        $this->options = [
88            'normalizeMultiClauseScores' => (bool)( $settings['normalizeMultiClauseScores'] ?? false ),
89            'entitiesVariableBoost' => (bool)( $settings['entitiesVariableBoost'] ?? true ),
90            'applyLogisticFunction' => (bool)( $settings['applyLogisticFunction'] ?? false ),
91            'useSynonyms' => (bool)( $settings['useSynonyms'] ?? false ),
92            'logisticRegressionIntercept' => (float)( $settings['logisticRegressionIntercept'] ?? 0 ),
93            'synonymsMaxAmount' => (float)( $settings['synonymsMaxAmount'] ?? 0 ),
94            'synonymsMinScoreThreshold' => (float)( $settings['synonymsMinScoreThreshold'] ?? 0 ),
95            'synonymsMinByteLength' => (float)( $settings['synonymsMinByteLength'] ?? 0 ),
96            'synonymsMinSimilarityToCanonicalForm' => (float)( $settings['synonymsMinSimilarityToCanonicalForm'] ?? 0 ),
97            'synonymsMinDifferenceFromOthers' => (float)( $settings['synonymsMinDifferenceFromOthers'] ?? 0 ),
98            'nearMatchBoost' => (float)( $settings['nearMatchBoost'] ?? 5.0 ),
99        ];
100    }
101
102    public function getQuery( ParsedQuery $parsedQuery ): AbstractQuery {
103        $this->map = new SplObjectStorage();
104        $this->parsedQuery = $parsedQuery;
105        $root = $parsedQuery->getRoot();
106        $root->accept( $this );
107        $nearMatchQuery = NearMatchFieldQueryBuilder::defaultFromWeight( $this->options["nearMatchBoost"] )
108            ->buildFromParsedQuery( $parsedQuery );
109        $mainQuery = $this->map[$root] ?? new MatchNone();
110        if ( $mainQuery instanceof MatchNone ) {
111            $actualQuery = $nearMatchQuery;
112        } elseif ( $nearMatchQuery instanceof MatchNone ) {
113            $actualQuery = $mainQuery;
114        } else {
115            $actualQuery = new BoolQuery();
116            $actualQuery->addShould( $nearMatchQuery );
117            $actualQuery->addShould( $mainQuery );
118            $actualQuery->setMinimumShouldMatch( 1 );
119        }
120
121        return $actualQuery;
122    }
123
124    /**
125     * Applies a logistic function to the sum of the scores minus a constant
126     *
127     * @see https://phabricator.wikimedia.org/T271799
128     * @param AbstractQuery $query
129     * @return AbstractQuery
130     */
131    private function applyLogisticFunction( AbstractQuery $query ): AbstractQuery {
132        if ( !$this->options[ 'applyLogisticFunction' ] ) {
133            return $query;
134        }
135
136        return ( new FunctionScore() )
137            ->setQuery( $query )
138            ->addScriptScoreFunction(
139                new Script(
140                    // this will produce scores in the 0-100 range
141                    '100 / ( 1 + exp( -1 * ( _score + intercept ) ) )',
142                    [ 'intercept' => $this->options['logisticRegressionIntercept'] ],
143                    'expression'
144                )
145            )
146            ->setBoostMode( FunctionScore::BOOST_MODE_REPLACE );
147    }
148
149    /**
150     * If we've applied a logistic function to the scores, then we expect the score to be
151     * between 0 and 100, HOWEVER if we have >1 text nodes we get a score of 0-1 for each,
152     * and therefore end up with a final score between 0 and 100*(number of nodes)
153     * Wrap the root node inside a function that divides the score by the number of nodes
154     *
155     * @param BoolQuery $query
156     * @return AbstractQuery
157     */
158    private function normalizeMultiClauseScores( BoolQuery $query ): AbstractQuery {
159        if (
160            !$this->options[ 'applyLogisticFunction' ]
161             || !$this->options[ 'normalizeMultiClauseScores' ]
162        ) {
163            return $query;
164        }
165
166        if ( $query->count() <= 1 ) {
167            return $query;
168        }
169
170        return ( new FunctionScore() )
171            ->setQuery( $query )
172            ->addScriptScoreFunction(
173                new Script(
174                    '_score / count',
175                    [ 'count' => $query->count() ],
176                    'expression'
177                )
178            );
179    }
180
181    public function visitParsedBooleanNode( ParsedBooleanNode $node ) {
182        $query = new BoolQuery();
183
184        $should = $must = 0;
185        foreach ( $node->getClauses() as $clause ) {
186            $clauseNode = $clause->getNode();
187            $clauseNode->accept( $this );
188            if ( isset( $this->map[$clauseNode] ) ) {
189                switch ( $clause->getOccur() ) {
190                    case BooleanClause::SHOULD:
191                        $query->addShould( $this->map[$clauseNode] );
192                        $should++;
193                        break;
194                    case BooleanClause::MUST:
195                        $query->addMust( $this->map[$clauseNode] );
196                        $must++;
197                        break;
198                    case BooleanClause::MUST_NOT:
199                        $query->addMustNot( $this->map[$clauseNode] );
200                        break;
201                }
202            }
203        }
204        if ( $should && !$must ) {
205            // If we have must and should clauses allow 0 should clauses to match. If we
206            // only have should clauses require at least 1 to match.
207            $query->setMinimumShouldMatch( 1 );
208        }
209
210        if ( $query->count() > 0 ) {
211            $query = $this->normalizeMultiClauseScores( $query );
212            $this->map[$node] = $query;
213        }
214    }
215
216    public function visitBooleanClause( BooleanClause $clause ) {
217        // BooleanClause is being handled in visitParsedBooleanNode already,
218        // this will not be visited
219    }
220
221    public function visitWordsQueryNode( WordsQueryNode $node ) {
222        $synonyms = array_merge(
223            // the original term (below) will be removed again later, but we should
224            // also consider it when clearing out synonyms that are too similar
225            [ $node->getWords() => 10 ],
226            $this->getSynonyms( $node, $this->options['synonymsMinScoreThreshold'] )
227        );
228
229        $synonyms = $this->filterTermsTooDissimilarCanonicalized(
230            $synonyms,
231            $this->options['synonymsMinSimilarityToCanonicalForm']
232        );
233        $synonyms = array_reduce(
234            array_keys( $synonyms ),
235            function ( $result, $term ) use ( $synonyms ) {
236                $canonical = $this->canonicalizeTerm( $term );
237                $result[$canonical] = max( $synonyms[$term], $result[$canonical] ?? 0 );
238                return $result;
239            },
240            []
241        );
242        $synonyms = $this->filterTermsTooShort( $synonyms, $this->options['synonymsMinByteLength'] );
243        $synonyms = $this->filterTermsTooSimilar( $synonyms, $this->options['synonymsMinDifferenceFromOthers'] );
244        $synonyms = $this->filterTermsSupersets( $synonyms );
245
246        // remove original term (and duplicates thereof)
247        unset( $synonyms[$this->canonicalizeTerm( $node->getWords() )] );
248
249        $synonyms = array_slice( $synonyms, 0, $this->options['synonymsMaxAmount'] );
250
251        $nodeHandler = new WordsQueryNodeHandler(
252            $node,
253            $this->getWikibaseEntitiesHandler( $node ),
254            $this->languages,
255            $synonyms,
256            array_fill_keys( $synonyms, [ $this->contentLanguage ] ),
257            $this->stemmingSettings,
258            $this->boosts,
259            $this->decays
260        );
261        $this->map[$node] = $this->applyLogisticFunction( $nodeHandler->transform() );
262    }
263
264    public function visitPhraseQueryNode( PhraseQueryNode $node ) {
265        $nodeHandler = new PhraseQueryNodeHandler(
266            $node,
267            $this->getWikibaseEntitiesHandler( $node ),
268            $this->languages,
269            $this->stemmingSettings,
270            $this->boosts,
271            $this->decays
272        );
273        $this->map[$node] = $nodeHandler->transform();
274    }
275
276    public function visitPhrasePrefixNode( PhrasePrefixNode $node ) {
277        // @phan-suppress-next-line PhanImpossibleCondition
278        Assert::invariant( false, 'PhrasePrefixNode not (yet) supported.' );
279    }
280
281    public function visitNegatedNode( NegatedNode $node ) {
282        // @phan-suppress-next-line PhanImpossibleCondition
283        Assert::invariant( false, 'NegatedNode not (yet) supported.' );
284    }
285
286    public function visitFuzzyNode( FuzzyNode $node ) {
287        // @phan-suppress-next-line PhanImpossibleCondition
288        Assert::invariant( false, 'FuzzyNode not (yet) supported.' );
289    }
290
291    public function visitPrefixNode( PrefixNode $node ) {
292        // @phan-suppress-next-line PhanImpossibleCondition
293        Assert::invariant( false, 'PrefixNode not (yet) supported.' );
294    }
295
296    public function visitWildcardNode( WildcardNode $node ) {
297        // @phan-suppress-next-line PhanImpossibleCondition
298        Assert::invariant( false, 'WildcardNode not (yet) supported.' );
299    }
300
301    public function visitEmptyQueryNode( EmptyQueryNode $node ) {
302        // nothing...
303    }
304
305    public function visitKeywordFeatureNode( KeywordFeatureNode $node ) {
306        // this is already dealt with elsewhere in the query building process
307    }
308
309    public function visitNamespaceHeader( NamespaceHeaderNode $node ) {
310        // this is already dealt with elsewhere in the query building process
311    }
312
313    private function getWikibaseEntitiesHandler( ParsedNode $node ): WikibaseEntitiesHandler {
314        return new WikibaseEntitiesHandler(
315            $node,
316            $this->parsedQuery,
317            $this->entitiesExtractor,
318            $this->boosts,
319            $this->options
320        );
321    }
322
323    /**
324     * @param WordsQueryNode $node
325     * @param float $threshold relevance percentage below which not to include synonyms
326     * @return array [synonym => score]
327     */
328    private function getSynonyms( WordsQueryNode $node, float $threshold = 0.5 ): array {
329        if ( !$this->options[ 'useSynonyms' ] ) {
330            return [];
331        }
332
333        $entities = $this->entitiesExtractor->getEntities( $this->parsedQuery, $node );
334
335        $synonyms = [];
336        foreach ( $entities as $entity ) {
337            if ( $entity['score'] < $threshold ) {
338                // skip entities that don't pass relevance threshold
339                continue;
340            }
341
342            $synonyms = array_merge(
343                $synonyms,
344                array_fill_keys( $entity['synonyms'] ?? [], $entity['score'] )
345            );
346        }
347
348        return $synonyms;
349    }
350
351    private function canonicalizeTerm( string $term ): string {
352        $canonical = strtolower( $term );
353        // replace punctuation (\p{P}) and separators (\p{Z}) by a single space
354        $canonical = preg_replace( '/[\p{P}\p{Z}]+/u', ' ', $canonical );
355        return trim( $canonical );
356    }
357
358    private function filterTermsTooShort( array $synonyms, int $threshold ): array {
359        // remove variations, preserving the highest value in case of duplicates
360        return array_filter(
361            $synonyms,
362            static function ( $term ) use ( $threshold ) {
363                // discard 1-letter latin characters - they're too generic & expensive
364                return strlen( $term ) >= $threshold;
365            },
366            ARRAY_FILTER_USE_KEY
367        );
368    }
369
370    private function filterTermsTooDissimilarCanonicalized( array $synonyms, float $threshold ): array {
371        // remove variations, preserving the highest value in case of duplicates
372        return array_filter(
373            $synonyms,
374            function ( $term ) use ( $threshold ) {
375                $canonical = $this->canonicalizeTerm( $term );
376                // discard terms where a significant portion was punctuation or separators,
377                // the canonical form likely is no longer representative enough (e.g `c#` != `c`)
378                similar_text( strtolower( $canonical ), strtolower( $term ), $similarity );
379                return $similarity / 100 >= $threshold;
380            },
381            ARRAY_FILTER_USE_KEY
382        );
383    }
384
385    private function filterTermsTooSimilar( array $synonyms, float $threshold ): array {
386        // now calculate the similarity to other terms (with same or higher weight)
387        // and get rid of terms that are simply too similar (e.g. 'cat' and 'cats',
388        // or 'house cat' and 'housecat' are too similar; we'd rather spend our
389        // resources looking for more significantly different terms)
390        $terms = array_keys( $synonyms );
391        $differences = [];
392        foreach ( $synonyms as $term => $score ) {
393            $index = array_search( $term, $terms );
394            $previousTerms = array_slice( $terms, 0, $index );
395            $differences[$term] = array_reduce(
396                $previousTerms,
397                static function ( $min, $otherTerm ) use ( $term ) {
398                    similar_text( strtolower( $term ), strtolower( $otherTerm ), $similarity );
399                    $difference = 1 - $similarity / 100;
400                    return $min === null ? $difference : min( $min, $difference );
401                },
402                null
403            );
404            if ( $differences[$term] !== null && $differences[$term] < $threshold ) {
405                unset( $synonyms[$term] );
406            }
407        }
408
409        // now re-sort them by difference compared to other terms (by weight),
410        // so that we get the "more different" terms first; then sort by weight
411        // again so that we end up with an array sorted by weight first, and
412        // "different-ness" second
413        uksort( $synonyms, static function ( $a, $b ) use ( $differences ) {
414            return $differences[ $b ] <=> $differences[ $a ];
415        } );
416        arsort( $synonyms );
417
418        return $synonyms;
419    }
420
421    private function filterTermsSupersets( array $synonyms ): array {
422        // sort synonyms by descending weight & descending term length
423        uksort( $synonyms, static function ( $a, $b ) {
424            return strlen( $a ) <=> strlen( $b );
425        } );
426        arsort( $synonyms );
427
428        // remove synonyms that are a superset of something we're already searching
429        // (unless said superset has a higher weight)
430        // e.g. if we're already matching "commons", then trying to find documents
431        // with "wikimedia commons" would yield no additional results - they'd
432        // already be found with "commons"...
433        // (yes, they would get a higher score for "wikimedia commons", but that's
434        // no more or less correct than "commons" in this case - it's just as good
435        // a description as the longer form as far as we know, both referring to the
436        // exact same concept
437        return array_reduce(
438            array_keys( $synonyms ),
439            static function ( $result, $term ) use ( $synonyms ) {
440                foreach ( $result as $existing => $weight ) {
441                    if ( preg_match_all( '/\b[^\p{P}\p{Z}]+?\b/u', $existing, $matches ) ) {
442                        foreach ( $matches[0] as $word ) {
443                            if ( !preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/', $term ) ) {
444                                // at least one of the words of another synonym do not
445                                // occur in this term, so it's at least more exclusive
446                                // in some way = this term is no superset of that other
447                                continue 2;
448                            }
449                        }
450                        // another term of equal or higher weight already matches this
451                        return $result;
452                    }
453                }
454                // this synonym turned out to be different enough from all others;
455                // include it
456                $result[$term] = $synonyms[$term];
457                return $result;
458            },
459            []
460        );
461    }
462
463}