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

Комментарии (1)


  1. Re1ter
    28.07.2024 00:42

    Ого какая красота. Побольше бы таких статей!