Фильтр nGram на синонимы

Добрый день!
Ситуация следующая.
Был следующий индекс:

{
   "settings":{
      "analysis":{
         "analyzer":{
            "str_search_analyzer":{
               "tokenizer":"keyword",
               "filter":["lowercase"]
            },
            "str_index_analyzer":{
               "tokenizer":"keyword",
               "filter":["lowercase","substring"]
            }
         },
         "filter":{
            "substring": {"type":"nGram", "min_gram":2, "max_gram":25}
         }
      }
   },
   "mappings":{
      "item":{
         "properties":{
            "org":{"type":"string", "search_analyzer":"str_search_analyzer", "analyzer":"str_index_analyzer"},
            "ind":{"type":"string", "search_analyzer":"str_search_analyzer", "analyzer":"str_index_analyzer"},
            "name":{"type":"string", "search_analyzer":"str_search_analyzer", "analyzer":"str_index_analyzer"
            }
         }
      }
   }
}

Понадобилось введение синонимов.
Изменил индекс следующим образом:

{
   "settings":{
      "analysis":{
         "analyzer":{
            "synonym":{
               "tokenizer":"whitespace",
               "filter":["synonym"]
            },
            "str_search_analyzer":{
               "tokenizer":"keyword",
               "filter":["lowercase"]
            },
            "str_index_analyzer":{
               "tokenizer":"keyword",
               "filter":["lowercase", "substring", "synonym"]
            }
         },
         "filter":{
            "substring":{"type":"nGram", "min_gram":2, "max_gram":25},
            "synonym":{
               "type":"synonym",
               "synonyms_path":"synonyms.txt"
            }
         }
      }
   },
   "mappings":{
      "item":{
         "properties":{
            "org":{"type":"string", "search_analyzer":"str_search_analyzer", "analyzer":"str_index_analyzer"},
            "ind":{"type":"string", "search_analyzer":"str_search_analyzer", "analyzer":"str_index_analyzer"},
            "name":{"type":"string", "search_analyzer":"str_search_analyzer", "analyzer":"str_index_analyzer"}
         }
      }
   }
}

Поиск осуществляю следующим запросом:

{
  "size" : 20,
  "query" : {
    "multi_match" : {
      "query" : "string",
      "fields" : [ "org^3", "ind", "name" ],
      "analyzer" : "synonym"
    }
  }
}

Синонимы в файле synonyms.txt заданы в виде:
synonym data1,synonym data2 => data1
synonym data3,synonym data4 => data2

С такими условиями поиск по синонимам срабатывает, если в query задано точное значение синонима.
Возможно ли, чтобы поиск по синонимам так же работал по фильтру nGram?

Пробовал в analyzer synonym добавить фильтр nGram и перезалить данные в индекс. В таком случае я вообще не понимаю что он ищет - результаты поиска совершенно не соответствуют запросу.

Мешать синонимы с nGram в одном поле- это очень плохая идея. Фильтр синонимов ожидает, что он получит полное слово, и если давать ему фрагменты - то он будет генерировать странные результаты. Если поиск с nGram и синонимы нужны, то лучше проиндексировать поле дважды - один раз с nGram и другой раз со стандартным токенизатором и фильтром синонимов и искать оба поля с multi_match.

То есть, надо сделать как-то так:

"filter":{
  "substring":{"type":"nGram", "min_gram":2, "max_gram":25},
  "synonym":{"type":"synonym", "synonyms_path":"synonyms.txt"}
}
"analyzer":{
  "synonym":{"tokenizer":"whitespace", "filter":["synonym"]},
  "str_search_analyzer":{"tokenizer":"keyword", "filter":["lowercase"]},
  "str_index_analyzer":{"tokenizer":"keyword", "filter":["lowercase", "substring"]}
}
"mappings":{
  "item":{
  "properties":{
    "org":{
       "type":"multi_field",
       "fields" : {
              "org": {"type":"string", "search_analyzer":"str_search_analyzer", "analyzer":"str_index_analyzer"},
              "syn": {"type":"string", "search_analyzer":"str_search_analyzer", "analyzer":"synonym"}
       }
.....

а поиск осуществлять так:

  "query" : {
    "multi_match" : {
      "query" : "some",
      "fields" : [ "org^3", "org.syn^3", "name"..... ]

.....

Но ведь фильтр по синонимам по прежнему будет ожидать полного слова?
А я хочу получать результат уже по части синонима.
Этого можно как-то добиться?

"analyzer":{
   "synonym":{"tokenizer":"standard", "filter":["lowercase", "synonym"]},
"mappings":{
  "item":{
  "properties":{
    "org":{
       "type":"multi_field",
       "fields" : {
          "org": {"type":"string", "search_analyzer":"str_search_analyzer", "analyzer":"str_index_analyzer"},
          "syn": {"type":"string", "analyzer":"synonym"}
       }
   }
}

1 Like

Большое спасибо. Попробую так.

Все равно не получилось (
Если в запросе поиска указывать

...
"query" : "точное значение синонима",
"analyzer" : "synonym"
...

то находит записи, в которых индекс содержит то, во что "точное значение синонима" преобразуется в synonym.txt

Если же в запросе задать

...
"query" : "часть синонима",
"analyzer" : "synonym"
...

то ничего не находит.

Если "analyzer" : "synonym" не указывать, то по синонимам вообще не ищет.

Теперь понял - надо искать по подстроке синонима. Как я уже сказал, это сложная проблема и мешанина синонимов и n-грам к добру обычно не приводит. Есть несколько решений - но все они не идеальны. Вы не могли бы подробнее рассказать о том, что хранится в этих полях, какой они длины, есть ли в этих значениях пробелы, какого типа синонимы вы используете и сколько таких записей в индексе?

Записей в индексе не много - около 3000. Добавляться будут, но достаточно медленно.
Значения - названия от 3 символов и больше (где-то до 60). Пробелы есть (могут быть и другие знаки - цифры, тире, кавычки и т. д.).
В синонимах аналогичные строки.
Например, в индексе в поле org может находиться строка "Horns and hooves",
а в файле с синонимами ей соответствовать следующее:
рога и копыта,h&h ltd.,РиК консалтинг => horns and hooves

Файл с синонимами сейчас небольшой - около 500 строк.

Из полученных результатов действенный пока только разделение синонимов по nGram-ам в самом файле.
Но такое решение мне очень не нравится.

То есть синоним всегда применяется ко всему значению поля, а не к подстроке?

Да, синоним применяется ко всему значению поля.

Тогда синонимы надо применять при индексировании и в другом формате, чтобы каждое название замещалось всеми другими названиями. То есть вместо

рога и копыта,h&h ltd.,РиК консалтинг => horns and hooves

я бы попробовал

рога и копыта,h&h ltd.,РиК консалтинг,horns and hooves

с такими анализаторами

"synonym_index":{"tokenizer":"keyword", "filter":["lowercase", "synonym", "substring"]},
"synonym_search":{"tokenizer":"keyword", "filter":["lowercase", "synonym"]}

и такой схемой

"syn": {"type":"string", "search_analyzer":"synonym_search", "analyzer":"synonym_index"}
1 Like

Огромное спасибо!

Все получилось. Только в synonym_index и synonym_search заменил tokenizer на whitespace, т.к. с keyword поиск не работал.

С таким вариантом возник вопрос: Если будет меняться файл с синонимами, то в этом случае данные в индекс надо будет перезалить?

Если синонимы по всему полю - то должно было работать с keyword. Токенизатор whitespace не разделяет по знакам препинания, поэтому я бы переключился на standard если keyword для вас не работает.

К сожалению, да. Но с 3000 записей это не должно быть проблемой. Я думаю, что это будет даже быстрее чем перезагрузка сервера, которая обычно требуется при изменении списка синонимов используемых при поиске. Так что в вашем случае, я бы создал индекс и обращался к нему через алиас. Когда надо будет изменить синонимы я бы сделал новый файл синонимов, создал новый индекс с этим файлом, переиндексировал туда все данные и затем переключил бы алиас на этот новый файл.

Спасибо!

Игорь, добрый день!
Рано я радовался. С индексом возникли непонятные мне проблемы с релевантностью поиска.

Если я ищу по подстроке, то все отлично - поиск выдает ожидаемые мной результаты.
Если же поиск осуществлять по полной строке, то я ожидаю увидеть в результатах не больше того, что нашлось при поиске по подстроке. Однако, в результат попадает даже то, что я не ожидал.

Можете в очередной раз помочь понять, в чем проблема?

Тестовый пример с воспроизведением проблемы:

curl -XDELETE "http://127.0.0.1:9200/test"

curl -XPUT "http://127.0.0.1:9200/test" -d '{
    "settings" : {
        "analysis" : {
            "analyzer" : {
                "synonym" : {
                    "tokenizer" : "standard",
                    "filter" : ["lowercase", "synonym"]
                },
                "str_search_analyzer" : {
                    "tokenizer" : "standard",
                    "filter" : ["lowercase"]
                },
                "str_index_analyzer" : {
                    "tokenizer" : "standard",
                    "filter" : ["lowercase", "substring"]
                },
                "synonym_index":{"tokenizer":"standard", "filter":["lowercase", "synonym", "substring"]},
                "synonym_search":{"tokenizer":"standard", "filter":["lowercase", "synonym"]}
            },
            "filter" : {
                "substring" : {"type" : "nGram", "min_gram" : 1, "max_gram" : 15},
                "synonym" : {"type" : "synonym", "synonyms" : ["roga i kopyta,h&h ltd,horns and hooves", "dragon co,some-else,narnia", "duglas co,usa question,narnia zero"]}
            }
        }
    },
    "mappings" : {
        "doc" : {
            "properties" : {
                "org" : {
                    "type": "multi_field",
                    "fields": {
                        "org" : {"type" : "string"},
                        "syn": {"type":"string", "search_analyzer":"synonym_search", "analyzer":"synonym_index"}
                    }
                },
                "ind" : {
                    "type": "multi_field",
                    "fields": {
                        "ind" : {"type" : "string"},
                        "ngram" : {"type" : "string", "search_analyzer" : "str_search_analyzer", "analyzer" : "str_index_analyzer"}
                    }
                },
                "name" : {
                    "type": "multi_field",
                    "fields": {
                        "name" : {"type" : "string"},
                        "ngram" : {"type" : "string", "search_analyzer" : "str_search_analyzer", "analyzer" : "str_index_analyzer"}
                    }
                }
            }
        }
    }
}'


curl -XPUT "http://127.0.0.1:9200/test/doc/XC83KD1231" -d '{
  "org":"horns and hooves",
  "ind":"XC83KD1231",
  "name":"DATA DUMMY CONSULTING"
}'

curl -XPUT "http://127.0.0.1:9200/test/doc/HF73286KJD" -d '{
  "org":"narnia",
  "ind":"HF73286KJD",
  "name":"UBER ZERO SPACE"
}'


#; Поиск по подстроке "narni" - 1 Результат в ответе
curl -XPOST "http://127.0.0.1:9200/test/doc/_search?pretty&explain" -d '{
  "query" : {
    "multi_match" : {
      "query" : "narni",
      "fields" : [ "org^30", "ind^10", "name^10", "org.syn^3", "ind.ngram", "name.ngram" ],
      "analyzer" : "synonym_search"
    }   
  }
}'


#; Поиск по строке "narnia" - 2 Результата в ответе
curl -XPOST "http://127.0.0.1:9200/test/doc/_search?pretty&explain" -d '{
  "query" : {
    "multi_match" : {
      "query" : "narnia",
      "fields" : [ "org^30", "ind^10", "name^10", "org.syn^3", "ind.ngram", "name.ngram" ],
      "analyzer" : "synonym_search"
    }   
  }
}'

Я немного отредактировал Ваше сообщение что бы сделать пример читаемым. Я надеюсь, Вы не будете против.

Для того, что бы понять, почему что-то попало в результат, смотрим на то, что нам говорит explain про запись которую мы не ожидали:

.....
{
      "_shard" : 1,
      "_node" : "tCoU5mE3QOqN6_WILBo2ug",
      "_index" : "test",
      "_type" : "doc",
      "_id" : "XC83KD1231",
      "_score" : 3.9232775E-4,
      "_source":{
  "org":"horns and hooves",
  "ind":"XC83KD1231",
  "name":"DATA DUMMY CONSULTING"
},
      "_explanation" : {
        "value" : 3.9232775E-4,
        "description" : "sum of:",
        "details" : [ {
          "value" : 3.9232775E-4,
          "description" : "max of:",
          "details" : [ {
            "value" : 3.9232775E-4,
            "description" : "product of:",
            "details" : [ {
              "value" : 7.846555E-4,
              "description" : "sum of:",
              "details" : [ {
                "value" : 7.846555E-4,
                "description" : "weight(name.ngram:co in 0) [PerFieldSimilarity], result of:",
                                        ^^^^^^^^^^^^^ - вот оно - совпадение
.....

Выходит, попала она в результат потому, что токен co нашелся в поле name.ngram. Но откуда он взялся в этом поле? Смотрим, как это поле было проанализировано:

$ curl -s -XPOST "http://127.0.0.1:9200/test/_analyze?pretty" -d '
{
  "field" : "name.ngram",
  "text" : "DATA DUMMY CONSULTING"
}' | grep \"token\"

    "token" : "d",
    "token" : "da",
    "token" : "dat",
    "token" : "data",
    "token" : "a",
    "token" : "at",
    "token" : "ata",
    "token" : "t",
.... это все не интересно - пропускаем ....
    "token" : "mmy",
    "token" : "m",
    "token" : "my",
    "token" : "y",
    "token" : "c",
    "token" : "co",   <---------- А вот и он - больной зуб!
    "token" : "con",
    "token" : "cons",
    "token" : "consu",
    "token" : "consul",
    "token" : "consult",
    "token" : "consulti",
    "token" : "consultin",
    "token" : "consulting",
    "token" : "o",
    "token" : "on",
.....

Выходит, что co попал из начала COnsulting.

А почему первый запрос не находит эту запись? Смотрим, как наши запросы обрабатываются:


curl -XPOST "http://127.0.0.1:9200/test/doc/_validate/query?pretty&explain" -d '{
  "query" : {
    "multi_match" : {
      "query" : "narni",
      "fields" : [ "org^30", "ind^10", "name^10", "org.syn^3", "ind.ngram", "name.ngram" ],
      "analyzer" : "synonym_search"
    }
  }
}'
{
  "valid" : true,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "failed" : 0
  },
  "explanations" : [ {
    "index" : "test",
    "valid" : true,
    "explanation" : "+(org.syn:narni^3.0 | org:narni^30.0 | name:narni^10.0 | ind.ngram:narni | name.ngram:narni | ind:narni^10.0) #ConstantScore(+ConstantScore(_type:doc))"
  } ]
}

$ curl -XPOST "http://127.0.0.1:9200/test/doc/_validate/query?pretty&explain" -d '{
  "query" : {
    "multi_match" : {
      "query" : "narnia",
      "fields" : [ "org^30", "ind^10", "name^10", "org.syn^3", "ind.ngram", "name.ngram" ],
      "analyzer" : "synonym_search"
    }
  }
}'

{
  "valid" : true,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "failed" : 0
  },
  "explanations" : [ {
    "index" : "test",
    "valid" : true,
    "explanation" : "+((((org.syn:narnia org.syn:dragon org.syn:some-else) org.syn:co)^3.0) | (((org:narnia org:dragon org:some-else) org:co)^30.0) | (((name:narnia name:dragon name:some-else) name:co)^10.0) | ((ind.ngram:narnia ind.ngram:dragon ind.ngram:some-else) ind.ngram:co) | ((name.ngram:narnia name.ngram:dragon name.ngram:some-else) name.ngram:co) | (((ind:narnia ind:dragon ind:some-else) ind:co)^10.0)) #ConstantScore(+ConstantScore(_type:doc))"
  } ]
}


Похоже, что все дело в синонимах - dragon co,some-else,narnia первый запрос не совпал ни с каким синонимом, и поэтому, co в него не попал.

Классический пример precision vs recall. :slight_smile:

1 Like

Игорь, огромное спасибо!
explain я смотрел, но не мог понять, откуда же elasticsearch берет это "CO".
Посмотрев _validate я понял свою ошибку.

Значения синонимов уже проиндексированы при добавлении элемента.
Указывая в поисковом запросе

"analyzer" : "synonym_search"

Я применял фильтр synonym и к самому запросу, что было лишним.
Применение в запросе analyzer

"analyzer" : "str_search_analyzer"

решило проблему.