ElasticSearch — поиск последовательности в тексте

от автора

Привет! На связи Аркадий из Т-Банка, мы по прежнему делаем TQM, и в этой статье покажу, как мы решили задачу с поиском последовательностей в тексте коммуникаций. Это работает как на простых цепочках из словосочетаний по порядку, так и на сложных кейсах — со временем фразы, каналом «клиент — оператор». Мы по прежнему работаем с ElasticSearch, оставляя возможность “накрутить” на поиск по тексту такие вещи как RAG, LLM и другие модные технологии. 

Несколько ограничений для сегодняшней задачи:

  • Нелинейное возрастание сложности запроса при увеличении количества фраз. Поэтому предел у нас 4.

  • Шаг тайминга мы выбрали 5 секунд. После каждой фразы ставим метку времени или несколько меток, если фраза заняла больше 5 секунд. Если сделать шаг слишком мелким это позволит искать более точно, но замусорит наше поле метками времени. Кажется, это тот момент когда лучше заранее договориться о требованиях.

А теперь к самому интересному. Добро пожаловать под кат!

Поиск решения

В прошлой статье мы создали индекс, научились по нему искать, словили и поправили несколько проблем. На этот раз менеджеры принесли задачку посложнее. Типовой сценарий поиска выглядит так: у нас есть диалоги, где оператор говорит «Здравствуйте», клиент отвечает «Здравствуйте», «Привет» или любое другое приветствие. Найди мне все тексты, где оператор забыл представиться. Нужно найти имя оператора, компанию, отдел и другую подобную информацию. Речь идет не о простом поиске фразы “здравствуйте”, в данном случае мы ищем несколько фраз в начале диалога, сказанных в определенной последовательности. Между ними может вклиниться фраза клиента, они сами могут быть разбиты на несколько реплик, но эту последовательность мы должны найти или сказать, что в данном звонке оператор забыл полностью представиться.

Задача раскладывается в запрос типа: Фраза 1 + Канал + Время, не более чем → Фраза 2 + Канал + Время, не более чем → ! Фраза 3 (оператор представляется).

Есть несколько вариантов решения задачи. 

Решение в лоб — написать скрипт. Команда будет выглядеть так:

{ "script: "(doc['phrases'][0] == 'message1' && doc['phrases'][1] == 'message2') || ... " }

или 

{ "script: "for (item in doc['phrases']) { if (item == 'message1') { ... } }" }

Скриптом можно перебрать все возможные варианты. 

Плюсы решения:

  • никаких ограничений, в скрипт можно записать все что угодно;

  • не требуется переработка индекса;

  • просто реализовать.

Минусы решения:

  • Медленно, потому что запрос выполняется последовательно для каждого документа, имеет вложенный цикл, квадратичную сложность. С нашими серверами это значит, что если документов больше 100 000, решение не будет работать.

  • В скрипте не будет работать поиск по словоформам. Можно это решить тем, что мы храним поле с начальными формами слов и приводим запрос к такому виду, но это убивает почти все плюсы

Решение не в лоб — поиск по сплошному массиву текста с помощью spans. Span позволяют строить запросы на более низком уровне, полностью контролируя количество, последовательность и прочие параметры вхождения фрагментов. 

Используем intervals и span_containing. Запрос intervals поможет вернуть документы с учетом порядка совпавших поисковых подзапросов. В этом случае массив запросов выглядит примерно так:

"all_of" : { // ищем все совпадения ( any_of ) "ordered" : true, // значит, что порядок нам важен “intervals”: [ // список интервалов {  “match” : {} } …. ] }

Попробуем преобразовать наш документ или коммуникацию в нужный вид.  Изначально документ выглядит так:

            {               "message_source_type" : 1,               "message" : "Здравствуйте"             },             {               "message_source_type" : 2,               "message" : "Здравствуйте"             },             ...             {               "message_source_type" : 1,               "message" : "До свидания"             },

Мы храним весь документ в виде размеченного текста, теггируя текст каналом, например клиент или оператор, любыми другими тегами типа «негативная фраза», смайлик и тому подобное. Получается что-то вроде:

"sequential_data" : " _s _1s _dd64f641bf052479288baecd291ec329c _d066d2f79cc114eb9b0f954221d18c558 _db132abccc3774e169c5aad6de4c372d2 _d20df59fe639547e5af5e339286a5dc73 алло _5 _1e "

Это решение сложнее, но работает на большом объеме данных. Есть и минусы: нет прямой возможности искать с перестановками (intervals не понимает slop). Другая проблема в том, что для интервалов есть возможность задавать max_gaps, который работает немного по-другому. И очень сложно объяснить заказчику, почему в одном случае мы находим фразу, а в другом — нет. Эта проблема возникает очень редко, поэтому пока вопросов не возникало.

Так как у нас только один дата-стрим может занимать в сумме 20 ТБ, для нас возможность быстрой работы на большом объеме данных — главное преимущество.

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

 "sequential_data": {     "type": "text",     "fields": {       "exact": {         "type": "text",       }     },     "analyzer": //тут наши кастомные аналайзеры, работу с которыми описали в другой статье   }

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

_s _e — start/end документа;

_1s _1e — канал номер один, например канал клиента; 

_2s 2e — канал номер два, например канал оператора;

t5 5, 10, 15 и до окончания разговора — метки времени, пишем их в индекс.

Получилось поле, в котором хранится текст вида:

"_source" : { ... "sequential_data" : " _s //старт документа _1s // старт фразы канала 1 (клиент) алло  _1e // конец фразы канала 1 _2s здравствуйте аркадий аркадьевич _2e  _1s алло вы куда звоните там девушка _1e  _2s меня зовут достоевский федор михайлович отдел премий Т-банка вам  знаком сидоров михаил михайлович? _2s  _1s знаком _1e  _2s спасибо что уделили время всего доброго до свидания _2e  _e " .... }

Самое сложное позади, теперь можно заняться самим поиском.

Реализация поиска

Самый простой запрос в нашей задаче будет выглядеть так:

{   "must": [     {       "intervals": {         "sequential_data": {           "all_of": {             "intervals": [               {                 "any_of": {                   "intervals": [                     {                       "match": {                         "max_gaps": 2,                         "query": "меня зовут" — фраза два (порядок обратный)                       }                     }                   ],                   "filter": {                     "contained_by": {                       "match": {                         "ordered": true,                         "query": "_1s _1e" — фраза обернута в теги начала и окончания для канала 1                       }                     }                   }                 }               }             ],             "filter": {               "after": {                 "all_of": {                   "intervals": [                     {                       "any_of": {                         "intervals": [                           {                             "match": {                               "max_gaps": 2,                               "query": "здравствуйте" — первая фраза, которую мы хотим найти                             }                           }                         ],                         "filter": {                           "contained_by": {                              "match": {                               "ordered": true,                               "query": "_1s _1e" — фраза обернута в теги начала и окончания для канала 1                             }                           }                         }                       }                     }                   ]                 }               }             }           }         }       }     }   ] }

Более сложный кейс, когда мы ищем оператора, который не представился:

  "must": [     {       "bool": {         "must": [           {             "intervals": {               "sequential_data": {                 "all_of": {                   "intervals": [                     {                       "any_of": {                         "intervals": [                           {                             "match": {                              "max_gaps": 2,                               "query": "здравствуйте"                             }                           }                         ],                         "filter": {                           "contained_by": {                             "match": {                               "ordered": true,                               "query": "_2s _2e"                             }                           }                         }                       }                     }                   ]                 }               }             }           }         ],         "must_not": [           {             "intervals": {               "sequential_data": {                 "all_of": {                   "intervals": [                     {                       "any_of": {                         "intervals": [                           {                             "match": {                               "max_gaps": 2,                               "query": "меня зовут"                             }                           }                         ],                         "filter": {                           "contained_by": {                             "match": {                               "ordered": true,                               "query": "_2s _2e"                             }                           }                         }                       }                     }                   ],                   "filter": {                     "after": {                       "all_of": {                         "intervals": [                           {                             "any_of": {                               "intervals": [                                 {                                   "match": {                                     "max_gaps": 2,                                     "query": "здравствуйте"                                   }                                 }                               ],                               "filter": {                                 "contained_by": {                                   "match": {                                     "ordered": true,                                     "query": "_2s _2e"                                   }                                 }                               }                             }                           }                         ]                       }                     }                   }                 }               }             }           }         ]       }     }

В более сложном случае нужно подключить два условия:

  • Ищем все звонки, в которых участвовал оператор: здравствуйте.

  • Ищем все звонки, где не было цепочки «оператор: здравствуйте» → «оператор: меня зовут». Это на самом деле мозговыносящая идея, что мы должны составить условие, по которому ищем последовательность, а потом завернуть это условие в must_not оператор. Надо привыкнуть.

Добавим крутости нашему поиску — ищем последовательность в течение временного интервала. В этом случае используем временные метки, которые ранее мы добавили в текст. Для нас достаточно точности 5 секунд, но можно делать их произвольными.

Например: найди мне все тексты, где оператор забыл представиться (имя оператора, компания, отдел и так далее), в течение 10 секунд.

Если словами, мы ищем Фраза (оператор представляется) → метку времени. В запросе мы хотим найти «меня зовут» перед _5 _5 метками:

{   "query": {     "bool": {       "must": [         {           "bool": {             "must_not": [               {                 "intervals": {                   "sequential_data": {                     "all_of": {                       "intervals": [                         {                           "any_of": {                             "intervals": [                               {                                 "match": {                                   "max_gaps": 2,                                   "query": "меня зовут"                                 }                               }                             ],                             "filter": {                               "contained_by": {                                 "match": {                                   "ordered": true,                                   "query": "_2s _2e"                                 }                               }                             }                           }                         }                       ],                       "filter": {                         "contained_by": {                           "any_of": {                             // ищем любое совпадение, или метку времени (10 секунд), или завершение диалога без меток времени перед ним.                              "intervals": [                               {                                 "match": {                                   "ordered": true,                                   "query": "_s _5 _5"                                 }                               },                               {                                 //это условие на случай, если разговор слишком быстро закончится                                 "match": {                                   "ordered": true,                                   "query": "_s _e",                                   "filter": {                                     "not_containing": {                                       "match": {                                         "ordered": true,                                         "query": "_5 _5"                                       }                                     }                                   }                                 }                               }                             ]                           }                         }                       }                     }                   }                 }               }             ]           }           ]         }       }       }

С помощью подобных конструкций можно искать пропущенные фразы, правильное или неправильное прощание и многое другое. Мы активно используем разметку по моделям и можем использовать их в последовательном поиске. Сделали для них еще один формат метки и размечаем тело коммуникации с их помощью.

На сладкое рассмотрим, как можно решить кейс с повторением фразы. Например, человек пишет «кредит» три и более раз. Как найти все чаты с этим отчаянным призывом? Судя по stackoverflow, проблема актуальна.

Из нового в этом случае используем after — указание порядка запросов. Запрос будет выглядеть так:

{   "must": [     {       "bool": {         "must": [           {             "intervals": {               "sequential_data": {                 "all_of": {                   "intervals": [                     {                       "any_of": {                         "intervals": [                           {                             "match": {                               "max_gaps": 2,                               "query": "кредит"                             }                           }                         ],                         "filter": {                           "contained_by": {                             "match": {                               "ordered": true,                               "query": "_2s _2e"                             }                           }                         }                       }                     }                   ],                   "filter": {                     "after": {                       "all_of": {                         "intervals": [                           {                             "any_of": {                               "intervals": [                                 {                                   "match": {                                     "max_gaps": 2,                                     "query": "кредит"                                   }                                 }                               ],                               "filter": {                                 "contained_by": {                                   "match": {                                     "ordered": true,                                     "query": "_2s _2e"                                   }                                 }                               }                             }                           }                         ],                         "filter": {                           "after": {                             "all_of": {                               "intervals": [                                 {                                   "any_of": {                                     "intervals": [                                       {                                         "match": {                                           "max_gaps": 2,                                           "query": "кредит"                                         }                                       }                                     ],                                     "filter": {                                       "contained_by": {                                         "match": {                                           "ordered": true,                                           "query": "_2s _2e"                                         }                                       }                                     }                                   }                                 }                               ],                               "filter": {                                 "after": {                                   "all_of": {                                     "intervals": [                                       {                                         "any_of": {                                           "intervals": [                                             {                                               "match": {                                                 "max_gaps": 2,                                                 "query": "кредит"                                               }                                             }                                           ],                                           "filter": {                                             "contained_by": {                                               "match": {                                                 "ordered": true,                                                 "query": "_2s _2e"                                               }                                             }                                           }                                         }                                       }                                     ]                                   }                                 }                               }                             }                           }                         }                       }                     }                   }                 }               }             }           }         ]       }     }   ] }

Заключение

Elasticsearch вполне подходит для реализации задач последовательного поиска. С помощью разметки можно искать кейсы в диалогах, последовательность в договоре, наличие или отсутствие пунктов и других документов, где важна структура.

Все описанное работает и на Opensearch, что актуально из-за изменений лицензии. А если у вас есть вопросы или желание поделиться опытом — жду в комментариях!


ссылка на оригинал статьи https://habr.com/ru/articles/831498/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *