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, что актуально из-за изменений лицензии. А если у вас есть вопросы или желание поделиться опытом — жду в комментариях!