Эта статья является логическим продолжением и развитием статьи Как построить полнотекстовый поиск с помощью нейронных сетей.
Наш проект посвящен поиску данных и аналитике в сегменте b2b. Поэтому мы давно занимаемся проблемами полнотекстового поиска. У нас есть обширная (на десятки и сотни миллионов) база данных товарных позиций, и нам всегда было интересно сопоставлять на основании этих данных поставщиков и покупателей.
Изначально мы использовали для этих целей полнотекстовый поиск на движке ElasticSearch. Но достаточно быстро мы пришли к тому, что нам нужно нечто большее. Основные проблемы, с которыми мы столкнулись это:
Контекстная значимость слов. Словосочетания «синяя ручка» и «красное дерево» отличаются от фразы «красная ручка» одинаково одним словом, имеют примерно одну полнотекстовую релевантность, однако семантически «красная ручка» намного ближе к «синей ручке», чем к «красному дереву». Наверное, с этим сложнее всего бороться.
Разное написание одинаковых по значению слов. Сокращения и синонимы в наименованиях товарных позиций делают полнотекстовый поиск ограниченным. Например, «лист бумаги А4» может быть написан «А4», «бумага для принтера», «лист А4» и это один и тот же товар. Решением этой проблемы часто выступает создание словарей синонимов. Однако сборка обширных специализированных библиотек синонимов, мягко говоря, очень трудоемка.
Разное значение одинаковых по написанию слов. Обратной проблемой является то, что, в зависимости от контекста, слова могут иметь разное значение. Например, во фразах «ключ дверной» и «ключ активации Windows» слово «ключ» имеет совершенно разное значение.
Поэтому мы обратились в сторону NLP, а именно – к DeepPavlov. Мы взяли и обучили из него BiLSTM и построили на нем VP (Vantage Point)-дерево. Это был, безусловно, большой шаг вперед. Качество сопоставления данных заметно улучшилось.
Однако у такого решения были проблемы:
Построение поискового дерева занимает
Дерево нужно хранить в памяти, а оно также требует памяти.
Это решение – кастомный сервис, что создает издержки при его поддержке, а его доработки, в частности, добавление дополнительных критериев поиска, требуют значительных ресурсов.
Под добавлением дополнительных критериев поиска мы понимаем возможность искать данные не только на основании семантической близости, но и на основании жестких критериев вида: товары “только из Москвы” или “дешевле 1000 рублей”.
Основная идея описанного выше решения заключалась в том, что мы преобразуем семантическую близость текстов в “расстояние” между ними. После этого мы используем kNN-поиск (поиск ближайших соседей) для нахождения наиболее близких и похожих текстов. Отметим, что наш kNN-поиск с помощью VP-дерева не был точным в силу того, что “расстояние”, получаемое с помощью нейронной сети, не всегда удовлетворяло аксиомам метрики. Тем самым, мы на самом деле использовали ANN (Approximate Nearest Neighbor).
Если мы сможем преобразовать текстовые фразы в вектора так, чтобы косинусное расстояние между ними было равно их семантической близости, то наша задача будет решена: отправив вектора в систему, поддерживающую ANN, поиск похожих текстов будет эквивалентен поиску ближайших векторов. К нашему счастью, ElasticSearch, начиная с 8ой версии, уже поддерживает ANN поиск. То есть, мы с одной стороны сможем решить проблемы 1 и 2, используя существующие у нас на проекте решения, а заодно и решить проблему 3.
Как уже отмечалось, наша основная задача: преобразовать текстовые фразы в вектора так, чтобы расстояние между ними соответствовало их семантической близости. Под расстоянием мы будем понимать косинусное расстояние, так как оно является наиболее естественным в задачах текстового анализа.
Одним из инструментов, позволяющих получить такие векторные представления, может быть нейронная сеть, которая на выходных слоях формирует embedding. Мы выбрали BERT, так как он оптимально сочетает точность предсказаний и вычислительную производительность. Также он неоднократно реализован в разных библиотеках.
Мы взяли пакет SentenceTransformers, а из него – модель stsb-xlm-r-multilingual, и занялись ее дообучением (fine-tuning). В качестве целевой функции был взят ContrastiveLoss. Как и ранее, для формирования тренировочного dataset были взяты данные, распаршенные из агрегаторов интернет-магазинов. На страницах с товарами есть ссылки на один и тот же товар в разных интернет магазинах. Тут можно взять положительные примеры.
Для формирования отрицательных примеров мы использовали подход Hard Negative Mining. Он заключается в следующем. Берется набор случайных фраз и из них выбираются такие пары, которые текущая модель считает наиболее близкими. Но, так как фразы случайные, с вероятностью 99% они не соответствуют друг другу. Эти пары мы берем в качестве отрицательных. Тем самым, мы говорим модели, что ее лучшие соответствия не являются похожими, и переобучаем ее. Затем снова формируем отрицательные примеры на основании нового набора фраз. Повторяя этот процесс итеративно, модель будет становиться все лучше и лучше. Для улучшения результатов на каждой следующей итерации можно брать немного больше данных.
Отметим, что стандартный BERT embedding имеет размерность 768. Хранить такие большие вектора и организовывать по ним поиск достаточно накладно. Поэтому поверх BERT embedding мы разместили Pooling слой меньшей размерности. Также это немного ускоряет процесс дообучения модели. Результаты сравнения точности финальной модели от разных размерностей Pooling слоя представлены на графике ниже.
На мой взгляд, оптимальная размерность Pooling слоя равна 128.
Теперь нам требуется организовать поиск ближайших соседей (kNN-поиск) по получившимся N-мерным векторам, обученных на косинусных расстояниях. Вариант использования K-d дерева не подходит, так как для его эффективной работы требуется, чтобы количество векторов было много больше (в нашем случае ).
Обратимся в сторону приближенных алгоритмов поиска (Approximate Nearest Neighbor, ANN-поиск), например, Locality-sensitive hashing (LSH). Существует множество реализаций таких алгоритмов (сравнение и анализ таких алгоритмов можно найти в ann-benchmarks. Нам был интересен вариант его реализации в Elasticsearch, так как, с одной стороны, мы храним там большую часть данных, а с другой – Elasticsearch позволяет комбинировать kNN-поиск с другими видами поиска, в частности, полнотекстовым. Основным минусом Elasticsearch является относительно невысокая производительность. Однако рост времени поиска относительно роста поискового индекса остается приемлемым.
На графике ниже изображен рост (цветные линии) времени поиска в зависимости от размера поискового индекса (тренировочного набора данных). Для сравнения приведены графики функции и .
Предположим, нам нужно сделать классификационную модель, распределяющую товары по товарным категориям, которых очень много (миллионы товаров и десятки тысяч категорий). Классические классификационные модели, обычно, показывают не очень хорошие результаты (f1 менее 50%). Понятно, что похожие товары будут с высокой вероятностью находиться в одной категории. Это наталкивает на использование полученных ранее векторных представлений. Превратим тренировочные данные в множество поиска, а поисковые результаты проаггрегируем с весом из косинусного расстояния. Такой подход позволяет существенно повысить точность классификации (довести f1 до 90% на обучающем датасете из нескольких миллионов).
На Github доступен пример реализации такого подхода, который уже на небольшом датасете (465 тысяч данных в обучающем наборе и более 7 тысяч классов) дает неплохие результаты (f1 равен 27% против 22% обычной классификацией).
На данный момент при решении задачи определения семантической близости можно обойтись без обучения классификационной модели и использовать только хорошо настроенные embedding. Также embedding’у можно найти и другие применения – например, в задачах классификации. На мой взгляд, следующим этапом этого процесса является внедрение этих технологий в современные хранилища данных (например, ElasticSearch и Postgres) для еще более массового внедрения NLP в разработку обычных проектов.