Привет! На связи Аркадий из Т-Банка, мы по прежнему делаем 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/
Добавить комментарий