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

3df306076edab22e4f4ed277bbb2d533.png

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

© Habrahabr.ru