Как мы делали поиск в elasticsearch на vulners.com
Как мы писали ранее, в качестве основной базы для поиска на сайте используется elasticsearch. Поиск в elastic работает очень быстро и из коробки доступно много полезных функций для работы с данными — полнотекстовый поиск, неточный поиск, всевозможные методы агрегации и тд.
И в отличии от классических SQL баз данных или noSQL типа MongoDB здесь очень удобно делать неточный поиск по всему документу. Для этого используется синтаксис Query DSL. Для полнотекстового поиска по всему документу есть несколько поисковых запросов. У себя на сайте мы используем тип query_string. Этот запрос поддерживает Lucene синтаксис, который позволяет и нам и пользователю создавать сложные запросы в google-style. Вот примеры таких запросов:
title: apache AND title: vulnerability
type: centos cvss.score:[8 TO 10]
Можно сделать вот такой простой запрос и все:
{
"query": {
"query_string": {
"query": "exploit wordpress"
}
}
}
Но начав впервые использовать query_string, вы столкнетесь с тем, что поиск выдает не то, что вы хотите видеть. Как же добиться от elasticsearch внятного результата поиска?
И тут мы впервые сталкиваемся с таким понятием в elasticsearch, как релевантность, она же score. Очень подробное описание есть на официальном сайтете, я же просто скажу, что score показывает, насколько документ соответствует вашему запросу. Каждый найденный документ содержит поле _score и результаты поиска автоматически сортируются, но нему. В большинстве случаев это уместно, но если пользователь хочет отсортировать по дате? Тогда нужно в указанном json-запросе передать дополнительное поле sort, вот так:
{
"query": {
"query_string": {
"query": "hackapp"
}
},
"sort": "published"
}
Означает, что в post-запросе надо послать это поле отдельно, заставив пользователя его куда-то ввести или выбрать, например, из выпадающего списка. Но почему бы не сделать это прям в поисковом запросе? И мы приходим к костылю №1. Мы ищем регуляркой в поиской строке фразу вида (order|sort):\w+, ее выцепляем и указанное поле передаем в дополнительном параметре в json.
Также мы подсмотрели у замечательных товарищей из Wallarm вот такой dork — last N days. Нам сразу понравилось, так как можно очень быстро смотреть уязвимости за последний месяц например. Как вы можете сами догадаться, это тоже можно писать прям в поисковой строке. Регуляркой это выцепляется и подставляется в запрос. При этом не нужно делать хитрых расчетов вида для вычисления дат. Можно задать условие в таком виде — {«gte»: «now-3d/d»}. Также открываем для себя новый тип запроса в elasticsearch — bool и filter. В итоге после двух хаков имеем уже такой запрос:
{
"query": {
"bool": {
"filter": {
"range": {
"published": {
"gte": "now-3d/d"
}
}
},
"should": {
"query_string": {
"query": "wordpress"
}
}
}
}
}
Поиск вроде работает, но эта пресловутая релевантность оставляет желать лучшего. Хочется ее подтюнить. Открываем для себя понятие boost. В зависимости от того или иного критерия можем влиять на итоговый score. Самый простой способ в случае с поиском через query_string — это задать поля с заданием коэффициента. Задается это так с помощью указания дополнительного параметра fields:
{
"query": {
"bool": {
"filter": {
"range": {
"published": {
"gte": "now-3d/d"
}
}
},
"should": {
"query_string": {
"query": "wordpress",
"fields": [
"title^2",
"type^3",
"affectedPackage.packageName^3",
"affectedSoftware.name^3",
"_all"
],
"default_operator": "AND"
}
}
}
}
}
При этом если задание коэффициента 2 не увеличит score в 2 раза, оно сделает документ релевантнее в 2 раза).
Также попутно задаем параметр default_operator, чтобы слова перечисленные в запросе по умолчанию искались с условие AND.
Поиск стал лучше, но мы сталкиваемся со случаями, когда статьи с большим количеством упоминаний какой-то темы вылезают в топ, полностью убирая от пользователя более важные новые уязвимости или эксплоиты. Решено исправить это в двух направлениях. Добавляем boost на основе типа документа. При этом мы хотим перечислить только те типы, которые должны понижать или повышать итоговый рейтинг, то есть условие может и не выполняться. Для этого необходимо использовать поиск с типом bool, задавая условие must которое точно должно выполняться (пользовательский запрос) и необязательное условие should, в котором мы перечислим необходимые типы.
{
"query": {
"bool": {
"must": [
{
"bool": {
"minimum_should_match": 1,
"should": [
{
"query_string": {
"default_operator": "AND",
"fields": [
"id^4",
"title^3",
"_all"
],
"query": "wordpress"
}
}
]
}
}
],
"should": [
{
"bool": {
"minimum_should_match": 0,
"should": [
{
"term": {
"boost": 2.5,
"type": "exploit"
}
},
{
"term": {
"boost": 2,
"type": "software"
}
},
{
"term": {
"boost": 0.3,
"type": "info"
}
}
]
}
}
]
}
}
}
И помимо этого добавляется желание более новые документы вытащить в начало найденного. Здесь уже линейным множителем не обойтись, поэтому вместо обычной query используем function_score. Изначально созданную query необходимо подставить в query внутри function_score и также задать саму функцию. Мы используем поле modified и распределение Гаусса. При этом в качестве начальной отметкой считается текущая дата. Такой множитель в elasticsearch можно использовать для числовых типов, дат и геопозиций, при этом можно задать любую начальную точку и в этом огромный плюс от использование elasticsearch. Итого наш запрос приобретает следующий вид:
{
"from": 0,
"query": {
"function_score": {
"functions": [
{
"weight": 1
},
{
"gauss": {
"modified": {
"scale": "12w"
}
}
}
],
"query": {
"bool": {
"must": [
{
"bool": {
"minimum_should_match": 1,
"should": [
{
"query_string": {
"default_operator": "AND",
"fields": [
"id^4",
"title^3",
"_all"
],
"query": "wordpress"
}
}
]
}
}
],
"should": [
{
"bool": {
"minimum_should_match": 0,
"should": [
{
"term": {
"boost": 2,
"type": "unix"
}
},
{
"term": {
"boost": 2.5,
"type": "exploit"
}
},
{
"term": {
"boost": 2,
"type": "software"
}
},
{
"term": {
"boost": 2,
"type": "nvd"
}
},
{
"term": {
"boost": 0.3,
"type": "info"
}
}
]
}
}
]
}
}
}
},
"size": 20,
"sort": [
{
"_score": {
"order": "desc"
}
},
{
"published": {
"order": "desc"
}
}
]
}
Остается заключительный штрих — добавляем highlight, чтобы было проще определить, почему был выбран именно указанный документ, и на этом наш небольшой запрос готов:
{
"from": 0,
"query": {
"function_score": {
"functions": [
{
"weight": 1
},
{
"gauss": {
"modified": {
"scale": "12w"
}
}
}
],
"query": {
"bool": {
"must": [
{
"bool": {
"minimum_should_match": 1,
"should": [
{
"query_string": {
"default_operator": "AND",
"fields": [
"id^4",
"title^3",
"_all"
],
"query": "wordpress"
}
}
]
}
}
],
"should": [
{
"bool": {
"minimum_should_match": 0,
"should": [
{
"term": {
"boost": 2,
"type": "unix"
}
},
{
"term": {
"boost": 2.5,
"type": "exploit"
}
},
{
"term": {
"boost": 2,
"type": "software"
}
},
{
"term": {
"boost": 2,
"type": "nvd"
}
},
{
"term": {
"boost": 0.3,
"type": "info"
}
}
]
}
}
]
}
}
}
},
"size": 20,
"sort": [
{
"_score": {
"order": "desc"
}
},
{
"published": {
"order": "desc"
}
}
],
"highlight": {
"fields": {
"*": {
"fragment_size": 100,
"number_of_fragments": 4,
"post_tags": [
""
],
"pre_tags": [
""
],
"require_field_match": false
}
}
}
}
Если подвести сухой остаток — elasticsearch прост в освоении для базовых запросов, но если необходимо сделать релевантный и гибкий поиск по документам с полностью разным содержанием, то стоит запастись терпением.