Pull to refresh
0
Directum
Цифровизация процессов и документов

Делаем действительно умный поиск: пошаговый гайд

Reading time 16 min
Views 27K

Поиск в корпоративной информационной системе — уже от самой этой фразы вязнет во рту. Хорошо если он вообще есть, о положительном user experience можно даже не задумываться. Как перевернуть отношение пользователей, избалованных поисковыми системами, и создать быстрый, точный, понимающий с полуслова продукт? Надо взять хороший кусок Elasticsearch, горсть интеллектуальных сервисов и замешать их по этому гайду.


Статей о том, как к существующей базе прикрутили полнотекстовый поиск на основе Elasticsearch, в интернете уже предостаточно. А вот статей, как сделать действительно умный поиск, явно не хватает.


При этом сама фраза «Умный поиск» уже превратилась в баззворд и используется к месту и нет. Что же такого должна делать поисковая система, чтобы её можно было считать умной? Ультимативно это можно описать как выдачу результата, который на самом деле нужен пользователю, даже если этот результат не совсем соответствует тексту запроса. Популярные поисковые системы вроде Google и Яндекс идут дальше и не просто находят нужную информацию, а напрямую отвечают на вопросы пользователя.

Окей, сразу на ультимативное решение замахиваться не будем, но что можно сделать чтобы приблизить обычный полнотекстовый поиск к умному?


Элементы интеллектуальности


Умный поиск — это как раз тот случай, когда количество может перейти в качество и множество небольших и достаточно простых фич может сформировать ощущение магии.


  • Исправление ошибок пользователя — опечатка ли это, неправильная раскладка или, может быть, запрос с подозрительно маленьким количеством результатов, но похожий на запрос, по которому информации гораздо больше.
  • Зайчатки NLP (natural language processing, а не то, что вы подумали) — если пользователь ввёл коммерческие предложения за прошлый год, действительно ли он хотел поискать эти слова в тексте всех документов или ему на самом деле нужны только коммерческие предложения и только за прошлый год?
  • Предсказание ввода на основе предыдущих запросов или популярных документов.
  • Представление результата — привычная подсветка найденного фрагмента, дополнительная информация в зависимости о того, что искали. Раз уж в предыдущем абзаце были нужны коммерческие предложения, то, может, имеет смысл сразу показать предмет предложения и от какой организации оно поступило?
  • Легкий drilldown — возможность уточнять поисковый запрос с помощью дополнительных фильтров, фасет.

Вводная


Есть ECM DIRECTUM со множеством документов в ней. Документ состоит из карточки с метаинформацией и тела, которое может иметь несколько версий.


Цель — быстро и удобно искать информацию в этих документах в привычной для пользователя поисковых систем манере.


Индексирование


Чтобы что-то хорошо поискать, нужно это что-то вначале хорошо проиндексировать.

Документы в ECM не статичны, пользователи модифицируют текст, создают новые версии, изменяют данные в карточках; постоянно создаются новые документы и иногда удаляются старые.
Для поддержания актуальной информации в Elasticsearch документы нужно постоянно переиндексировать. К счастью, в ECM уже есть своя очередь асинхронных событий, поэтому при изменении документа достаточно добавить его в очередь для индексирования.


Отображение документов ECM на документы Elasticsearch


Тело документа в ECM может иметь несколько версий. В Elasticsearch это можно было бы представить как массив nested-объектов, но тогда с ними становится неудобно работать — усложняется написание запросов, при изменении одной из версий надо переиндексировать всё, разные версии одного документа не могут храниться в разных индексах (зачем это может понадобиться — в следующем разделе). Поэтому мы денормализуем один документ из ECM в несколько документов Elasticsearch с одинаковой карточкой, но разными телами.


Кроме карточки и тела в документ Elasticsearch добавляется разная служебная информация, в которой отдельно стоит отметить:


  • список ИД групп и пользователей, имеющих права на документ, — для поиска с учётом прав;
  • количество обращений к документу — для тюнинга релевантности;
  • время последней индексации.

Состав индексов


Да, индексов во множественном числе. Обычно несколько индексов для хранения схожей по смыслу информации в Elasticsearch используются только если эта информация неизменяемая и привязана к какому-то временному отрезку, например логи. Тогда индексы создаются каждый месяц/день или чаще в зависимости от интенсивности нагрузки. В нашем случае может быть изменён любой документ, и можно было бы хранить всё в одном индексе.


Но — документы в системе могут быть на разных языках, а хранение мультиязычных данных в Elasticsearch несёт 2 проблемы:


  • Неправильный стемминг. Для некоторых слов основа будет найдена корректно, для некоторых — некорректно (в индексе будет другое слово), для некоторых — вообще не будет найдена (индекс будет засоряться словоформами). Для некоторых слов из разных языков и имеющих разное значение основа будет совпадать, и тогда будет утеряно значение слова. Применение нескольких стеммеров подряд может приводить к довычислению основы у уже вычисленной.

Стемминг — нахождение основы слова. Основа не обязательно должна являться корнем слова или его нормальной формой. Обычно хватает того, чтобы связанные слова проецировались в одну основу.
Лемматизация — разновидность стемминга, в которой основой считается нормальная (словарная) форма слова.

  • Некорректная частота слов. Часть механизмов определения релевантности в ES учитывает частоту искомых слов в документе (чем чаще, тем выше релевантность) и частоту искомых слов в индексе (чем чаще, тем ниже релевантность). Так, небольшое вкрапление русской речи в английском документе, когда в индексе преимущественно английские документы, будет иметь высокий вес, но стоит смешать в индексе английские и русские документы, и вес понизится.

Первую проблему можно решить для случая, когда разные языки используют разные наборы символов, (русско-английские документы используют кириллицу и латиницу) — языковые стеммеры будут обрабатывать только «свои» символы.


Как раз для решения второй проблемы мы использовали подход с отдельным индексом для каждого языка.


Комбинируя оба подхода, получим языковые индексы, которые тем не менее содержат анализаторы сразу для нескольких не пересекающихся по наборам символов языков: русско-английский (и отдельно англо-русский), польско-русский, немецко-русский, украино-английский и т.д.


Чтобы не создавать все возможные индексы заранее, использовали шаблоны индексов — Elasticsearch позволяет задать шаблон, который содержит настройки и маппинги, и указывать паттерн имени индекса. При попытке проиндексировать документ в несуществующий индекс, имя которого подходит под один из паттернов шаблона, будет не только создан новый индекс, но и к нему будут применены настройки и маппинги из соответствующего шаблона.


Структура индексов


Для индексирования используем сразу два анализатора (через multi-fields): default для поиска по точной фразе и custom для всего остального:


"ru_en_analyzer": {
    "filter": [
        "lowercase",
        "russian_morphology",
        "english_morphology",
        "word_delimiter",
        "ru_en_stopwords"
    ],
    "char_filter": [
        "yo_filter"
    ],
    "type": "custom",
    "tokenizer": "standard"}

С фильтром lowercase всё понятно, расскажу про остальные.


Фильтры russian_morphology и english_morphology предназначены для морфологического анализа русского и английского текста соответственно. Они не входят в состав Elasticsearch и ставятся в составе отдельного плагина analysis-morphology. Это лемматизаторы, использующие словарный подход в сочетании с некоторыми эвристиками и работающие значительно, ЗНАЧИТЕЛЬНО, лучше встроенных фильтров для соответствующих языков.


POST _analyze
{
    "analyzer": "russian", 
    "text": "Мыли руки с мылом"
}
>> мыл рук мыл

И:


POST _analyze
{
    "analyzer": "ru_en_analyzer", 
    "text": "Мыли руки с мылом"
}
>> мыть рука мыло

Очень любопытный фильтр word_delimiter. Он, например, помогает устранять опечатки, когда после точки нет пробела. Используем следующую конфигурацию:


"word_delimiter": {
    "catenate_all": "true",
    "type": "word_delimiter",
    "preserve_original": "true"
}

yo_filter позволяет игнорировать разницу между E и Ё:


"yo_filter": {
    "type": "mapping",
    "mappings": [
        "ё => е",
        "Ё => Е"
    ]
}

ru_en_stopwords фильтр с типом stop — наш словарь стоп-слов.


Процесс индексирования


Тела документов в ECM — это, как правило, файлы офисных форматов: .docx, .pdf и т.д. Для извлечения текста используется плагин ingest-attachment со следующим pipeline:


{
    "document_version": {
        "processors": [
            {
                "attachment": {
                    "field": "content",
                    "target_field": "attachment",
                    "properties": [ "content", "content_length", "content_type", "language" ],
                    "indexed_chars": -1,
                    "ignore_failure": true
                }
            },
            {
                "remove": {
                    "field": "content",
                    "ignore_failure": true
                }
            },
            {
                "script": {
                    "lang": "painless",
                    "params": {
                        "languages": ["ru", "en" ],
                        "language_delimeter": "_"
                    },
                    "source": "..."
                }
            },
            {
                "remove": {
                    "field": "attachment",
                    "ignore_failure": true
                }
            }
        ]
    }
}

Из необычного в pipeline — игнорирование ошибок отсутствия тела (такое случается для зашифрованных документов) и определение целевого индекса, исходя из языка текста. Последнее делается в painless-скрипте, тело которого я приведу отдельно, т.к. из-за ограничений JSON его приходится записывать в одну строку. Вкупе со сложностями отладки (рекомендуемый способ — генерация исключений там и сям) он и вовсе превращается в painful.


if (ctx.attachment != null)
{
    if (params.languages.contains(ctx.attachment.language))
        ctx._index = ctx._index + params.language_delimeter + ctx.attachment.language;
    if (ctx.attachment.content != null)
        ctx.content = ctx.attachment.content;
    if (ctx.attachment.content_length != null)
        ctx.content_length = ctx.attachment.content_length;
    if (ctx.attachment.content_type != null)
        ctx.content_type = ctx.attachment.content_type;
    if (ctx.attachment.language != null)
        ctx.language = ctx.attachment.language; 
}

Таким образом, мы всегда посылаем документ в index_name. Если язык не определился или не поддерживается, то в этом индексе документ и оседает, иначе попадает в index_name_language.


Само исходное тело файла мы не храним, но поле _source включено, т.к. оно требуется для частичного обновления документа и подсветки найденного.


Если с момента последней индексации изменялась только карточка, то для её обновления используем Update By Query API без pipeline. Это позволяет, во-первых, не тянуть потенциально тяжелые тела документов из ECM, а во-вторых, значительно ускоряет обновление на стороне Elasticsearch — не приходится извлекать текст документов из офисных форматов, что весьма ресурсоёмко.


Как такового обновления документа в Elasticsearch вообще нет, технически при обновлении из индекса достается старый документ, изменяется и снова полностью индексируется.

А вот если менялось тело, то старый документ вообще удаляется и индексируется с нуля. Это позволяет документам переезжать из одного языкового индекса в другой.


Поиск


Для облегчения описания приведу скриншот итогового результата



Полнотекст


Основным типом запроса у нас служит Simple Query String Query:


"simple_query_string": {
    "fields": [
        "card.d*.*_text",
        "card.d*.*_text.exact",
        "card.name^2",
        "card.name.exact^2",
        "content",
        "content.exact"
    ],
    "query": "искомый текст",
    "default_operator": "or",
    "analyze_wildcard": true,
    "minimum_should_match": "-35%",
    "quote_field_suffix": ".exact"
}

где .exact — это поля, проиндексированные default анализатором. Важность имени документа считаем в два раза выше остальных полей. Сочетание "default_operator": "or" и "minimum_should_match": "-35%" позволяет находить документы в которых нет до 35% искомых слов.


Синонимы


Вообще для индексирования и поиска используются разные анализаторы, но единственное отличие в них — это добавление фильтра для подмешивания синонимов в поисковый запрос:


"search_analyzer": {
    "filter": [
        "lowercase",
        "russian_morphology",
        "english_morphology",
        "synonym_filter",
        "word_delimiter",
        "ru_en_stopwords"
    ],
    "char_filter": [
        "yo_filter"
    ],
    "tokenizer": "standard"
}

"synonym_filter": {
    "type": "synonym_graph",
    "synonyms_path": "synonyms.txt"
}

Учёт прав


Для поиска с учетом прав основной запрос вложен в Bool Query, с добавлением фильтра:


"bool": {
    "must": [
        {
            "simple_query_string": {...}
        }
    ],
    "filter": [
        {
            "terms": {
                "rights": [ ИД текущего пользователя и всех групп в которых он состоит ]
            }
        }
    ]
}

Как помним из раздела про индексацию, в индексе есть поле с ИД пользователей и групп, имеющих права на документ. Если есть пересечение этого поля с переданным массивом — значит есть и права.


Тюнинг релевантности


По умолчанию Elasticsearch оценивает релевантность результатов по алгоритму BM25, используя запрос и текст документа. Мы решили, что на оценку соответствия желаемого и фактического результата должны влиять ещё три фактора:


  • время последнего редактирования документа — чем дальше в прошлом оно было, тем менее вероятно, что нужен именно этот документ;
  • количество обращений к документу — чем больше, тем вероятнее, что нужен этот документ;
  • у версий тела в ECM есть несколько возможных состояний: разрабатываемая, действующая и устаревшая. Логично, что действующая важнее остальных.


    Добиться такого влияния можно с помощью Function Score Query:


    "function_score": {
    "functions": [
        {
            "gauss": {
                "modified_date": {
                "origin": "now",
                "scale": "1095d",
                "offset": "31d",
                "decay": 0.5
                }
            }
        },
        {
            "field_value_factor": {
                "field": "access_count",
                "missing": 1,
                "modifier": "log2p"
            }
        },
        {
            "filter": {
                "term": {
                    "life_stage_value_id": {
                        "value": "Д"
                    }
                }
            },
            "weight": 1.1
        }
    ],
    "query": {
        "bool": {...}
    }
    }

    В результате, при прочих равных, получается примерно такая зависимость модификатора рейтинга результата от даты его последнего изменения X и количества обращений Y:




Внешний интеллект


Для части функциональности умного поиска нам необходимо извлечь из поискового запроса различные факты: даты с указанием их применения (создания, изменения, утверждения и т.п.), названия организаций, виды искомых документов и т.п.


Также желательно классифицировать запрос в определённую категорию, например, документы по организациям, по сотрудникам, нормативные и т.п.


Эти две операции выполняются интеллектуальным модулем ECM — DIRECTUM Ario.


Процесс умного поиска


Настало время подробнее рассмотреть, какими механизмами реализуются элементы интеллектуальности.


Исправление ошибок пользователя


Определение правильности раскладки происходит на основе триграмной модели языка — для строки вычисляется, насколько вероятно встретить её трехсимвольные последовательности в текстах на английском и русском языках. Если текущая раскладка считается менее вероятной, то, во-первых, показывается подсказка с исправленной раскладкой:



а во-вторых, дальнейшие этапы поиска выполняются с исправленной раскладкой:



И уж если с исправленной раскладкой ничего не найдётся, то поиск запускается с оригинальной строкой.


Исправление опечаток реализовано с помощью Phrase Suggester. С ним есть проблема — если выполнить запрос на нескольких индексах одновременно, то suggest может ничего не вернуть, в то время как при выполнении только на одном индексе результаты есть. Это лечится установкой confidence = 0, но тогда suggest предлагает заменять слова на их нормальную форму. Согласитесь, странно будет при поиске "письма" получить ответ в духе: Возможно, вы искали письмо?


Это можно обойти, используя сразу два suggester'а в запросе:


"suggest": {
    "content_suggest": {
        "text": "искомый текст",
        "phrase": {
            "collate": {
                "query": { тут схожий с основным поисковый запрос по тексту {{suggestion}} }
            },
        }
    },
    "check_suggest": {
        "text": "письма",
        "phrase": {
            "collate": {
                "query": { тут схожий с основным поисковый запрос по тексту {{suggestion}} - ({{source_query}}) },
                "params": {
                    "source_query": "искомый текст"
                }
            },
        }
    }
}

Из общих параметров используются


"confidence": 0.0,
"max_errors": 3.0,
"size": 1

Если первый suggester вернул результат, а второй нет, значит этот результат — сама исходная строка, возможно со словами в других формах, и подсказку показывать не надо. В случае, если подсказка всё-таки требуется, исходная поисковая фраза сливается с подсказкой. Это происходит путем замены только исправленных слов и только тех, которые проверка орфографии (используем Hunspell) сочтёт некорректными.


Если поиск по исходной строке вернул 0 результатов, то он заменяется полученной слиянием строкой и снова выполняется поиск:



Иначе полученная строка с подсказками возвращается только в качестве подсказки для поиска:



Классификация запросов и извлечение фактов


Как я уже упоминал, мы используем DIRECTUM Ario, а именно сервис классификации текстов и сервис извлечения фактов. Для этого мы отдали аналитикам обезличенные поисковые запросы и список фактов, которые нам интересны. На основе запросов и знаний о том, какие документы есть в системе, аналитики выделили несколько категорий и обучили сервис классификации определять категорию по тексту запроса. Исходя из получившихся категорий и списка фактов, сформулировали правила использования этих фактов. Например, фраза за прошлый год в категории Все считается датой создания документа, а в категории По организации — датой регистрации. При этом созданные в прошлом году должно в любой категории попадать в дату создания.


Со стороны поиска — сделали конфиг, в котором прописали категории, какие факты в какие фасетные фильтры применяются.


Автодополнение ввода


Кроме уже упоминавшегося исправления раскладки, в автодополнение попадают прошлые поисковые запросы пользователя и общедоступные документы.



Они реализованы с помощью другого вида Suggester'ов — Completion Suggester, но у каждого есть свои нюансы.


Автодополнение: История поисков

Пользователей в ECM гораздо меньше, чем у поисковых систем, и выделить для них достаточное количество общих запросов почему ленин гриб не представляется возможным. Показывать всё подряд тоже не стоит из-за соображений приватности. Обычный Completion Suggester умеет искать только по всему набору документов в индексе, но к нему на помощь приходит Context Suggester — способ задать для каждой подсказки некий контекст и при поиске фильтровать по этим контекстам. Если в качестве контекстов использовать имена пользователей, то каждому можно показывать только его историю.


Также нужно дать пользователю возможность удалить подсказку, за которую ему стыдно. В качестве ключа для удаления мы использовали имя пользователя и текст подсказки. В результате для индекса с подсказками получился такой, немного дублирующийся, маппинг:


"mappings": {
    "document": {
        "properties": {
            "input": {
                "type": "keyword"
            },
            "suggest": {
                "type": "completion",
                "analyzer": "simple",
                "preserve_separators": true,
                "preserve_position_increments": true,
                "max_input_length": 50,
                "contexts": [
                    {
                        "name": "user",
                        "type": "CATEGORY"
                    }
                ]
            },
            "user": {
                "type": "keyword"
            }
        }
    }
}

Вес для каждой новой подсказки устанавливается в единичку и увеличивается при каждом повторном вводе с помощью Update By Query API с очень простым скриптом ctx._source.suggest.weight++.


Автодополнение: Документы

А вот документов и возможных комбинаций прав может быть ну очень много. Поэтому тут мы, наоборот, решили при автодополнении фильтрацию по правам не делать, но индексировать только общедоступные документы. Да и удалять отдельные подсказки из этого индекса не надо. Казалось бы, реализация во всём легче предыдущей, если бы не два момента:


Первый — Completion Suggester поддерживает только префиксный поиск, а клиенты так любят присваивать всему номенклатурные номера, и какой-нибудь ЖПА.01.01 Правила сокращения слов по мере ввода запроса Правила сокращения не найти. Тут вместе с полным именем можно индексировать и производные от него n-граммы:


{
    "extension": "pdf",
    "name": "ЖПА.01.01 Правила сокращения слов",
    "suggest": [
        {
            "input": "слов",
            "weight": 70
        },
        {
            "input": "сокращения слов",
            "weight": 80
        },
        {
            "input": "Правила сокращения слов",
            "weight": 90
        },
        {
            "input": "ЖПА.01.01 Правила сокращения слов",
            "weight": 100
        }
    ]
}

С историей это было не так критично, всё же один и тот же пользователь вводит примерно одну и ту же строку, если ищет что-то повторно. Наверное.



Второй — по умолчанию все подсказки равны, но нам бы хотелось некоторые из них сделать равнее и желательно так, чтобы это было согласовано с ранжированием результатов поиска. Для этого надо примерно повторить функции gauss и field_value_factor, используемые в Function Score Query.


Получается вот такой pipeline:


{
  "dir_public_documents_pipeline": {
    "processors": [
      ...
      {
        "set": {
            "field": "terms_array",
            "value": "{{name}}"
        }
      },
      {
        "split": {
            "field": "terms_array",
            "separator": "\\s+|$"
        }
      },
      {
        "script": {
            "source": "..."
        }
      }
    ]
  }
} 

со следующим скриптом:


Date modified = new Date(0);
if (ctx.modified_date != null)
  modified = new SimpleDateFormat('dd.MM.yyyy').parse(ctx.modified_date);
long dayCount = (System.currentTimeMillis() - modified.getTime())/(1000*60*60*24);
double score = Math.exp((-0.7*Math.max(0, dayCount - 31))/1095) * Math.log10(ctx.access_count + 2);
int count = ctx.terms_array.length;
ctx.suggest = new ArrayList();
ctx.suggest.add([
    'input': ctx.terms_array[count - 1], 
    'weight': Math.round(score * (255 - count + 1))
    ]);
for (int i = count - 2; i >= 0 ; --i)
{
  if (ctx.terms_array[i].trim() != "")
  {
    ctx.suggest.add([
      "input": ctx.terms_array[i] + " " + ctx.suggest[ctx.suggest.length - 1].input,
      "weight": Math.round(score * (255 - i))]);
  }
}
ctx.remove('terms_array');
ctx.remove('access_count');
ctx.remove('modified_date');

Зачем вообще городить pipeline с painless вместо того, чтобы написать это на более удобном языке? Потому что теперь с помощью Reindex API в индекс для подсказок можно перегнать содержимое поисковых индексов (указав только нужные поля, разумеется) буквально в одну команду.


Состав действительно нужных общедоступных документов обновляется не часто, поэтому эту команду можно оставить на ручном запуске.


Отображение результатов


Категории


Категория определяет, какие фасеты будут доступны и как будет выглядеть сниппет. Может быть определена автоматически внешним интеллектом или выбрана вручную над строкой поиска.


Фасеты


Фасеты — это такая интуитивно понятная всем штука, поведение которой, тем не менее, описывается весьма нетривиальными правилами. Вот несколько из них:


  1. Значения фасетов зависят от результатов поиска, НО и результаты поиска зависят от выбранных фасетов. Как избежать рекурсии?


  2. Выбор значений внутри одного фасета не влияет на другие значения этого фасета, но влияет на значения в других фасетах:




  1. Выбранные пользователем значения фасета не должны исчезать, даже если выбор в другом фасете аннигилирует их в 0 или они больше не входят в топ:


В эластике фасеты реализуются через механизм агрегаций, но для соблюдения описанных правил эти агрегации приходится вкладывать друг в друга и друг другом же фильтровать.



Рассмотрим фрагменты запроса, ответственные за это:


Слишком большой кусок кода
{
    ...
    "post_filter": {
        "bool": {
            "must": [
                {
                    "terms": {
                        "card.author_value_id": [
                            "1951063"
                        ]
                    }
                },
                {
                    "terms": {
                        "editor_value_id": [
                            "2337706",
                            "300643"
                        ]
                    }
                }
            ]
        }
    },
    "query": {...}
    "aggs": {
        "card.author_value_id": {
            "filter": {
                "terms": {
                    "editor_value_id": [
                        "2337706",
                        "300643"
                    ]
                }
            },
            "aggs": {
                "card.author_value_id": {
                    "terms": {
                        "field": "card.author_value_id",
                        "size": 11,
                        "exclude": [
                            "1951063"
                        ],
                        "missing": ""
                    }
                },
                "card.author_value_id_selected": {
                    "terms": {
                        "field": "card.author_value_id",
                        "size": 1,
                        "include": [
                        "1951063"
                        ],
                        "missing": ""
                    }
                }
            }
        },
...
        "editor_value_id": {
            "filter": {
                "terms": {
                    "card.author_value_id": [
                        "1951063"
                    ]
                }
            },
            "aggs": {
                "editor_value_id": {
                    "terms": {
                        "field": "editor_value_id",
                        "size": 11,
                        "exclude": [
                            "2337706",
                            "300643"
                        ],
                        "missing": ""
                    }
                },
                "editor_value_id_selected": {
                    "terms": {
                        "field": "editor_value_id",
                        "size": 2,
                        "include": [
                            "2337706",
                            "300643"
                        ],
                        "missing": ""
                    }
                }
            }
        },
...
  }
}

Что тут что:


  • post_filter позволяет наложить дополнительное условие на результаты уже выполненного запроса и не влияет на результаты агрегаций. Тот самый разрыв рекурсии. Включает в себя все выбранные значения всех фасетов.
  • агрегации верхнего уровня, в примере — card.author_value_id и editor_value_id. В каждой есть:
    • фильтр по значениям всех других фасетов, кроме своего;
    • вложенная агрегация для выбранных значений фасета — защита от аннигиляции;
    • вложенная агрегация для остальных значений фасета. Показываем топ-10, а запрашиваем топ-11 — для определения, нужно ли выводить кнопку Показать всё.

Сниппеты


В зависимости от выбранной категории сниппет может выглядеть по-разному, например, один и тот же документ при поиске в категории


Все:



и Сотрудники:



Или помните, мы хотели видеть предмет коммерческого предложения и от кого оно поступило?



Чтобы не тащить с эластика всю карточку целиком (это замедляет поиск), используется Source filtering:


{
    ...
    "_source": {
        "includes": [
            "id",
            "card.name",
            "card.card_type_value_id",
            "card.life_stage_value_id",
            "extension",
            ...
        ]
    },
    "query": {...}
    ...
}

Для подсветки найденных слов в тексте документа используется Fast Vector highlighter — как генерирующий наиболее адекватные сниппеты для больших текстов, а для наименования — Unified highlighter — как наименее требовательный к ресурсам и структуре индекса:


"highlight": {
    "pre_tags": [
        "<strong>"
    ],
    "post_tags": [
        "</strong>"
    ],
    "encoder": "html",
    "fields": {
        "card.name": {
            "number_of_fragments": 0
        },
        "content": {
            "fragment_size": 300,
            "number_of_fragments": 3,
            "type": "fvh"
        }
    }
},

При этом наименование подсвечивается целиком, а из текста достаем до 3 фрагментов длиной 300 символов. Текст, возвращаемый Fast Vector highlighter'ом, дополнительно сжимается самодельным алгоритмом для получения минимизированного состояния сниппета.


Коллапс


Исторически пользователи этой ECM привыкли, что поиск возвращает им документы, но на самом деле Elasticsearch ищет среди версий документов. Может получиться, что по одному и тому же запросу будут найдены несколько почти одинаковых версий. Это будет захламлять результаты и вводить пользователя в недоумение. К счастью, избежать такого поведения можно при помощи механизма Field Collapsing — некоторого облегченного варианта агрегаций, который срабатывает уже на готовых результатах (в этом он напоминает post_filter, два костыля — пара). Результатом коллапса станет самый релевантный из сворачивающихся объектов.


{
  ...
  "query": {...}  
  ...
  "collapse": {
    "field": "id"
  }
}

К сожалению, коллапс обладает рядом неприятных эффектов, например, различные численные характеристики результата поиска продолжают возвращаться такими, как будто никакого коллапса не было. То есть, число результатов, количество по значениям фасет — все будут слегка неправильные, но пользователь этого обычно не замечает, как и уставший читатель, который вряд ли дочитал до этого предложения.


Конец.

Tags:
Hubs:
+21
Comments 3
Comments Comments 3

Articles

Information

Website
www.directum.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия