# Полнотекстовый поиск заказов ### Как это работает сейчас > > Все поля заказа конкатенируются в одно и по нему происходит поиск: * назвавние предмета * адрес * цель (для кого?) (aim) * программа (начальная школа, ) (program) * дни занятий (пт 16-00-19-00) (formatz) * пожелания заказчика (wishes) OrdersIndexFields.php ```php= private function getFulltext(Order $order) { $text = $order->getSubjects()."\n"; $text .= $order->getRegion()."\n"; $text .= $order->getAim()."\n"; $text .= $order->getProgram()."\n"; $text .= $order->getFormatz()."\n"; $text .= $order->getWishes()."\n"; return trim($text); } ``` FulltextMatching.php ```php= private function getQuery(Prep $prep, ?Filter $filter) { $index_query = $this->query_factory->defaultQuery($prep, $filter)->getIndexQuery(); $index_query['bool']['must'] = [ 'match' => [ 'fulltext' => [ 'query' => $this->text_helper->W2U($filter->getText()), 'operator' => 'and', 'analyzer' => 'russian' ] ] ]; return [ "script_score" => [ "query" => $index_query, "script" => [ "source"=> "Math.round(_score*1000)", // Умножаем на 1000 т.к. полнотекстовый скоринг может глючить при пагинации ] ] ]; } ``` Текущая реализация маппинга - для fulltext используется анализатор russian, который на этапе индексирования удаляет стоп слова (предлоги, частицы), приводит к начальной форме остальные. "+": простая и вполне рабочая реализация "-": одинаковый приоритет (отсутствие бустинга) для полнотекстового поиска по отдельным полям (так как поле одно - fulltext), нельзя, к примеру, сделать чтобы совпадение строки поиска с названием услуги повышало релевантность сильнее, чем совпадение строки поиска с гео или наоборот, нельзя сделать полнотекстовый поиск по гео в "ИЛИ-формате" (наличие одного из слов из поисковой строки в гео, т.к. вряд ли по запросу "репетитор математики метро аэропорт цска" ищут заказы, где обе станции есть в гео) **Кейс:** Запрос: "репетитор математики аэропорт цска" Сейчас: Все запросы в которых есть "репетитор", "математик", "цска", "аэропорт" Можем: Если искать не только по fulltext, но и по строке region полнотекстом сможем получать заказы, где есть хотя бы одна станция метро, а не обе. ```json= { "orders_index_v1": { "mappings": { "properties": { "alloc": { "type": "long" }, "alloc_real": { "type": "long" }, "bo_sort": { "type": "long" }, "city_id": { "type": "keyword" }, "events_pids": { "properties": { "a_antiz": { "type": "long" }, "r_order_refused": { "type": "long" }, "r_zayavka": { "type": "long" }, "r_zayavkawait": { "type": "long" }, "zayavka": { "type": "long" } } }, "flag2": { "type": "long" }, "fulltext": { "type": "text", "analyzer": "russian" }, "geoplaces": { "properties": { "region": { "type": "long" }, "region_plus": { "type": "long" } } }, "gity_id": { "type": "keyword" }, "id": { "type": "long" }, "oaddr": { "properties": { "point": { "type": "geo_point" }, "prcn": { "type": "long" } } }, "oinfs": { "properties": { "addr_type": { "type": "long" }, "aimis-avto": { "type": "long" }, "aimis-sprt": { "type": "long" }, "aimis-treda": { "type": "long" }, "aimis-vr": { "type": "long" }, "avto": { "type": "long" }, "beznal": { "type": "long" }, "cont2prep": { "type": "long" }, "distance": { "type": "long" }, "dogovor-mstr": { "type": "long" }, "first_summary": { "type": "long" }, "galochki-vr": { "type": "long" }, "garant": { "type": "long" }, "grazhdan": { "type": "long" }, "grp-sprt": { "type": "long" }, "is_pupil_made-vr": { "type": "long" }, "lang": { "type": "long" }, "liveatwork": { "type": "long" }, "oborud-avto": { "type": "long" }, "parent_order": { "type": "long" }, "passport": { "type": "long" }, "pets": { "type": "long" }, "pvozrast": { "type": "long" }, "samost": { "type": "long" }, "sbr": { "type": "long" }, "sft": { "type": "long" }, "smok": { "type": "long" }, "sostav-mstr": { "type": "long" }, "time_to_exam-vr": { "type": "long" }, "uk_dop_usl": { "type": "long" }, "uk_usl": { "type": "long" }, "ulevels-avto": { "type": "long" }, "ulevels-sprt": { "type": "long" }, "ulevels-treda": { "type": "long" }, "ulevels-vr": { "type": "long" }, "uvozrs-avto": { "type": "long" }, "uvozrs-krst": { "type": "long" }, "uvozrs-sprt": { "type": "long" }, "uvozrs-treda": { "type": "long" }, "uvozrs-vr": { "type": "long" }, "wizard_id": { "type": "long" }, "wizard_plat_ver": { "type": "long" }, "wizard_version": { "type": "long" } } }, "orders_index": { "properties": { "region": { "type": "long" }, "region_plus": { "type": "long" } } }, "pservices": { "properties": { "spec_count": { "type": "long" }, "spec_ids": { "type": "long" }, "vc_count": { "type": "long" }, "vc_ids": { "type": "long" } } }, "receivd": { "type": "date" }, "status": { "type": "long" }, "stoim": { "type": "long" }, "timeslots": { "properties": { "start_ts": { "type": "long" }, "urgent_before_ts": { "type": "long" } } }, "tobo": { "type": "long" }, "utry_id": { "type": "keyword" }, "wgender": { "type": "long" }, "wprice": { "properties": { "max": { "type": "long" }, "min": { "type": "long" }, "value": { "type": "long" } } }, "wprice_edizm": { "properties": { "id": { "type": "long" }, "nquanta": { "type": "float" }, "quantum_id": { "type": "long" } } }, "wprice_p4q": { "properties": { "max": { "type": "float" }, "min": { "type": "float" }, "value": { "type": "float" } } }, "z_id": { "type": "keyword" }, "zprice": { "type": "long" } } } } } ``` ### Предложения что добавить #### Устройчивость к е и Ё ```json= { "analysis": { "filter": { "char_filter": { "e_mapping": { "type": "mapping", "mapping": ["Ё=>Е", "ё=>е"] } } } } } ``` #### Синонимы Свойство lenient отвечает за игнорирование ошибок при обработке синонимов. Чтобы не заниматься своим словарем есть плагин russian_morphology, можно попробовать прикрутить. Существует два фильтра связанных с синонимами - synonym_filter и synonim_graph_filter. synonim_graph_filter умеет заменят последовательность термов на один или несколько термов. synonym_filter - просто заменяет слова на синонимы и не способен работать со словосочетаниями. ```json= { "analysis": { "filter": { "synonym_filter": { "type": "synonym", "synonyms_path": "analysis/synonym.txt", "lenient": true, } } } } ``` **Кейс** С использованием synonim_graph_filter сможем заменять копиравальные аппараты на ксероксы в поле fulltext во время индексации, тем самым сократив размер индекса. #### Переключение раскладки за пользователя Если в поисковой строке нет кириллических символов и не один документ не подошел, сделать поиск по строке с заменой символов на кириллические по qwerty. #### Бустинг Как-то так можно повлиять на релевантность (то есть на score). Мы ищем все так же по всей строке, но если слова из запроса есть в region или subjects, то такие заказы будут релевантнее. > should - || > must - && ```json= { "query": { "bool": { "minimum_should_match": 0, "should": [ { "match": { "region": { "query": "text", "operator": "or", "analyzer": "russian", "boost": 100 } } }, { "match": { "subjects": { "query": "text", "operator": "or", "analyzer": "russian", "boost": 200 } } }, { "match": { "fulltext": { "query": "text", "operator": "and", "analyzer": "russian", } } } ] } } } ``` #### Подсказки в запросах на основе нечетных запросов Можно использовать fuzzy запросы для формирования подсказок. ```json= { "query": { "fuzzy": { "subjects": { "value": "маник", "fuzziness": 3, "transpositions": true } } } } ``` ### Опечатки Добавление параметра fuzziness и указание в нем максимального отличия в символах поможет искать в том числе с опечатками. ```json= { "query": { "bool": { "must": [ { "match": { "fulltext": { "query": "маникр", "operator": "and", "fuzziness": 2, "analyzer": "russian" } } } ] } } } ``` ### Пагинация Мы можем использовать search_after параметр для получения следующей страницы заказов основываясь на предыдущей выборке заказов. Необходимо создать point in time (PIT) он хранит текущее состояние индекса и позволяет не нарушать консистентность при пагинации. ``` POST /orders_index/_pit?keep_alive=1m ``` keep_alive отвечает за время жизни поисковой сессии. В результате мы получим PIT ID, который будем использовать при запросах. ```json= GET /_search { "size": 10000, "query": { "match" : { "fulltext" : { "query": "водопроводчик" } } }, "pit": { "id": "46ToAwMDaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQNpZHkFdXVpZDIrBm5vZGVfMwAAAAAAAAAAKgFjA2lkeQV1dWlkMioGbm9kZV8yAAAAAAAAAAAMAWICBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA==", "keep_alive": "1m" } } ``` *Подводные камни:* время жизни сессии, нужно точно понять сколько нам нужно и нужно ли хранить индекс в состоянии снимка. Нужно выяснить когда заканчивается поисковая сессия. # Про ранжирование Используется функция BM25, на ее основе вычисляется оценка (важно): Как часто термин появляется в документе — временная частота ( tf ) Насколько распространен термин для всех документов — частота обратного документа ( idf ) Документы, содержащие все или большинство условий запроса, оцениваются выше, чем документы, который содержат меньше условий Нормализация основана на длине документа, более короткие документы оцениваются лучше, чем более длинные Балл релевантности, рассчитанный для запроса, применяется только к текущему контексту запроса и не может быть повторно использован. Оценка основана на токенах и их частоте в документе, из-за чего запросы не кэшируются. С другой стороны, фильтры могут автоматически кэшироваться. ## BM25 BM25 — поисковая функция на неупорядоченном множестве термов («мешке слов») и множестве документов, которые она оценивает на основе встречаемости слов запроса в каждом документе, без учёта взаимоотношений между ними (например, близости). Это не одна функция, а семейство функций с различными компонентами и параметрами. Одна из распространенных форм этой функции описана ниже. ![](https://i.imgur.com/lfjACHH.png) Q - запроc, где q1, q2, q3 слова; D - документ ![](https://i.imgur.com/Uu4bNro.png) ## TF-IDF TF - term frequency - отношение числа вхождений слова к общему количеству слов в документе IDF - inverse document frequency - инверсия частоты, с которой слово встречается в документах коллекции. Учёт IDF уменьшает вес широкоупотребительных слов. Для каждого уникального слова в пределах конкретной коллекции документов существует только одно значение IDF. tf - idf = tf*idf Большой вес в TF-IDF получат слова с высокой частотой в пределах конкретного документа и с низкой частотой употреблений в других документах. # Переиндексация Чтобы прикрутить все это и попробовать с новым индексом, с другими анализаторами, бустингом и т. д, необходимо текущие записи скопировать в новый индекс и переиндексировать тем самым. Делается вроде бы просто, но я пока не тестил. ```json= POST _reindex { "source": { "index": "my-index-000001" }, "dest": { "index": "my-new-index-000001" } } ``` # FAQ > С какого символа осуществляется поиск? (С первого / с третьего / с 30 ... ) Сейчас поиск осуществляется путем разбиения на токены, со стоп словами, поэтому предлоги выпиливаются и при запросе "на" вернет пустой результат. > Есть ли сейчас бустинг? если есть то как настроен? Исходя из конфигурации запроса, бустинга сейчас нет. > Что произойдет если клиент меняет детали заказа и теперь они отличаются от исходного запроса в полнотексте? В рамках поисковой сессии эластик все равно отдаст этот заказ, а так как мы данные заказа берем из mysql пользователь увидит не совсем релевантный заказ, который в начале поисковой сессии все-таки ему подходил > Что произойдет если я напишу запрос стрижка ЦСКА пятница? Какие заказы возвращаем? Сейчас возвращаем заказы где есть все 3 слова есть в их начальной форме (стрижк, цска, пятниц) > Можно ли исключать слова? Например есть ли возможность указать какого слова не должно быть в заказе? Используя query_string можно различные условия задавать, в том числе и реализовать минус-слова. ```json= GET /_search { "query": { "query_string": { "query": "((quick AND fox) OR (brown AND fox) OR fox) AND NOT news", "default_field": "fulltext" } } } ``` --- **Могу искать английские слова? Например, "python", "shellac", "volvo"?** > Да, но ни не проходят через stemming. И соотвественно только запросы с точным совпадения терм будут выданы. **Кейсы** Запрос: "разработка python": Результат: вернем все заказы где есть встречается терм "разработк" и "python" Запрос: "pyton": Результат: вернем заказы где есть "pyton". (такие реально есть 😂) Запрос: "pythoning": Результат: вернем заказы где есть "pythoning". (таких нет) --- > Возможен ли поиск по всем ВИДИМЫМ полям заказа? > И есть ли он сейчас? Могу искать по id заказа? Если нет, то добавить легко Сейчас добавить легко - в список конкатенируемых просто внести доп поля. Такой подход плох только в отношении бустинга (его так не реализовать). ### Кейс 2.0 Заказ_1: Стрижка рыжего котика Заказ_2 Стрижка котика Заказ_3: Стрижка рыжего Заказ_4: Стрижка рыжего пушистого котика Я ввожу запрос: Стрижка рыжего котика (Сейчас) - 1 (0.78935426), 4 (0.69034123) (Можем - если оператор будет OR, а не AND) 1 (0.78935426), 4 (0.69034123), 2 (0.5200585), 3 (0.5200585) Если добавим еще документов с повторяющимися текстами, то получим такую выдачу: ```json= { "hits": [ { "_index": "orders", "_type": "_doc", "_id": "BRpqjHgB-TjH-_u8n6yp", "_score": 0.712669, "_source": { "fulltext": "Стрижка Стрижка Стрижка Стрижка рыжего рыжего пушистого пушистого пушистого котика котика котика котика котика котика котика" } }, { "_index": "orders", "_type": "_doc", "_id": "lZ5gjHgBh8reDZxFKLK3", "_score": 0.70441025, "_source": { "fulltext": "Стрижка рыжего котика" } }, { "_index": "orders", "_type": "_doc", "_id": "BxppjHgB-TjH-_u816sc", "_score": 0.6753383, "_source": { "fulltext": "Стрижка рыжего пушистого котика котика котика котика котика котика котика" } }, { "_index": "orders", "_type": "_doc", "_id": "-p5gjHgBh8reDZxFd7Lm", "_score": 0.64983606, "_source": { "fulltext": "Стрижка рыжего пушистого котика" } }, { "_index": "orders", "_type": "_doc", "_id": "x55gjHgBh8reDZxFS7KS", "_score": 0.43570426, "_source": { "fulltext": "Стрижка котика" } }, { "_index": "orders", "_type": "_doc", "_id": "yp5gjHgBh8reDZxFYbLj", "_score": 0.43570426, "_source": { "fulltext": "Стрижка рыжего" } } } ``` ## Кейс 3.0 для @YakovlevaAA Ранжирование результатов должно осуществляться следующим образом: сначала показываем результат со 100% совпадением поискового запроса, затем сортируем по дате выкладки заказа (сначала свежие заказы), далее частичное совпадение запросу, отсортированное по дате выкладки заказа. Сценарий: Слово_1 слово_2 словов_3 (дата сейчас) Слово_1 слово_2 словов_3 (дата сейчас-1) Слово_1 слово_2 словов_3 (дата сейчас-2) Слово_1 словов_3 (дата сейчас) словов_3 (дата сейчас) Слово_1 слово_2 словов_3 слово_4 (дата сейчас-1) словов_3 (дата сейчас-10) Запрос: Слово 3 Выдача: [Слово_3 - часто встречается в заказах] словов_3 (дата сейчас) словов_3 (дата сейчас-10) Слово_1 словов_3 (дата сейчас) или Слово_1 слово_2 словов_3 (дата сейчас) (?) Есть следующие записи: ```json= { "_source": { "fulltext": "стрижка рыжего кота", "bo_sort": "2021-04-05T12:00:00Z" } }, { "_source": { "fulltext": "стрижка рыжего кота", "bo_sort": "2021-04-04T12:00:00Z" } }, { "_source": { "fulltext": "стрижка рыжего кота", "bo_sort": "2021-04-03T12:00:00Z" } }, { "_source": { "fulltext": "стрижка кота", "bo_sort": "2021-04-05T12:00:00Z" } }, { "_source": { "fulltext": "стрижка", "bo_sort": "2021-04-05T12:00:00Z" } }, { "_source": { "fulltext": "стрижка толстого рыжего кота ", "bo_sort": "2021-04-04T12:00:00Z" } }, { "fulltext": "кот", "bo_sort": "2021-03-04T12:00:00Z" } }, { "_source": { "fulltext": "стрижка", "bo_sort": "2021-03-04T12:00:00Z" } } ``` Можем сделать вот такой запрос "стрижка": ```json= { "query":{ "bool":{ "should": [ { "match": { "fulltext": { "query": "стрижка" } } } ] } }, "sort": [ { "_score": { "order": "desc" } }, { "bo_sort": { "order": "desc" } } ] } ``` И получить вот такой ответ: ```json= [ { "_score": 0.23594555, "_source": { "fulltext": "стрижка", "bo_sort": "2021-04-05T12:00:00Z" }, "sort": [ 0.23594555, 1617624000000 ] }, { "_score": 0.23594555, "_source": { "fulltext": "стрижка", "bo_sort": "2021-03-04T12:00:00Z" }, "sort": [ 0.23594555, 1614859200000 ] }, { "_score": 0.19100355, "_source": { "fulltext": "стрижка кота", "bo_sort": "2021-04-05T12:00:00Z" }, "sort": [ 0.19100355, 1617624000000 ] }, { "_score": 0.160443, "_source": { "fulltext": "стрижка рыжего кота", "bo_sort": "2021-04-05T12:00:00Z" }, "sort": [ 0.160443, 1617624000000 ] }, { "_score": 0.160443, "_source": { "fulltext": "стрижка рыжего кота", "bo_sort": "2021-04-04T12:00:00Z" }, "sort": [ 0.160443, 1617537600000 ] }, { "_score": 0.160443, "_source": { "fulltext": "стрижка рыжего кота", "bo_sort": "2021-04-03T12:00:00Z" }, "sort": [ 0.160443, 1617451200000 ] }, { "_score": 0.1383129, "_source": { "fulltext": "стрижка толстого рыжего кота ", "bo_sort": "2021-04-04T12:00:00Z" }, "sort": [ 0.1383129, 1617537600000 ] } ] ```