Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
75.00% |
171 / 228 |
|
34.78% |
8 / 23 |
CRAP | |
0.00% |
0 / 1 |
| MediaSearchASTQueryBuilder | |
75.00% |
171 / 228 |
|
34.78% |
8 / 23 |
84.00 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
38 / 38 |
|
100.00% |
1 / 1 |
1 | |||
| getQuery | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
3.02 | |||
| applyLogisticFunction | |
16.67% |
2 / 12 |
|
0.00% |
0 / 1 |
4.31 | |||
| normalizeMultiClauseScores | |
21.43% |
3 / 14 |
|
0.00% |
0 / 1 |
11.76 | |||
| visitParsedBooleanNode | |
70.00% |
14 / 20 |
|
0.00% |
0 / 1 |
11.19 | |||
| visitBooleanClause | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| visitWordsQueryNode | |
100.00% |
33 / 33 |
|
100.00% |
1 / 1 |
1 | |||
| visitPhraseQueryNode | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| visitPhrasePrefixNode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| visitNegatedNode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| visitFuzzyNode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| visitPrefixNode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| visitWildcardNode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| visitEmptyQueryNode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| visitKeywordFeatureNode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| visitNamespaceHeader | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getWikibaseEntitiesHandler | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| getSynonyms | |
16.67% |
2 / 12 |
|
0.00% |
0 / 1 |
13.26 | |||
| canonicalizeTerm | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| filterTermsTooShort | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| filterTermsTooDissimilarCanonicalized | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| filterTermsTooSimilar | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
5 | |||
| filterTermsSupersets | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Wikibase\MediaInfo\Search; |
| 4 | |
| 5 | use CirrusSearch\Parser\AST\BooleanClause; |
| 6 | use CirrusSearch\Parser\AST\EmptyQueryNode; |
| 7 | use CirrusSearch\Parser\AST\FuzzyNode; |
| 8 | use CirrusSearch\Parser\AST\KeywordFeatureNode; |
| 9 | use CirrusSearch\Parser\AST\NamespaceHeaderNode; |
| 10 | use CirrusSearch\Parser\AST\NegatedNode; |
| 11 | use CirrusSearch\Parser\AST\ParsedBooleanNode; |
| 12 | use CirrusSearch\Parser\AST\ParsedNode; |
| 13 | use CirrusSearch\Parser\AST\ParsedQuery; |
| 14 | use CirrusSearch\Parser\AST\PhrasePrefixNode; |
| 15 | use CirrusSearch\Parser\AST\PhraseQueryNode; |
| 16 | use CirrusSearch\Parser\AST\PrefixNode; |
| 17 | use CirrusSearch\Parser\AST\Visitor\Visitor; |
| 18 | use CirrusSearch\Parser\AST\WildcardNode; |
| 19 | use CirrusSearch\Parser\AST\WordsQueryNode; |
| 20 | use CirrusSearch\Query\Builder\NearMatchFieldQueryBuilder; |
| 21 | use Elastica\Query\AbstractQuery; |
| 22 | use Elastica\Query\BoolQuery; |
| 23 | use Elastica\Query\FunctionScore; |
| 24 | use Elastica\Query\MatchNone; |
| 25 | use Elastica\Script\Script; |
| 26 | use SplObjectStorage; |
| 27 | use Wikibase\MediaInfo\Search\ASTQueryBuilder\PhraseQueryNodeHandler; |
| 28 | use Wikibase\MediaInfo\Search\ASTQueryBuilder\WikibaseEntitiesHandler; |
| 29 | use Wikibase\MediaInfo\Search\ASTQueryBuilder\WordsQueryNodeHandler; |
| 30 | use Wikimedia\Assert\Assert; |
| 31 | |
| 32 | class 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 | } |