Розумний вибір для додатків з простими потребами в пошуку
Elasticsearch — це всебічний і висококонфігурований пошуковий двигун і система зберігання для багатьох завдань в додатках. У цій статті ми порівняємо лише його можливості пошуку в контексті Dockerized додатку на Ruby on Rails. Якщо ваш додаток має потребу в специфічному підвищенні ваги атрибутів, результатах, які поліпшуються за допомогою машинного навчання, розвинених можливостях шардінгу з високою доступністю або пошуку по кількох індексах, Elasticsearch — це саме те, що вам потрібно.
Якщо ваші потреби в пошуку знаходяться між pg_search/ransack і Elasticsearch, Meilisearch — новий претендент, який працює дуже швидко (<50ms), значно ефективніший у використанні ресурсів, має розумну конфігурацію за замовчуванням, власну бібліотеку для Ruby і гем для Rails, а також панель адміністратора, де можна спробувати пошук перед повною інтеграцією в додаток. З повнотекстовим пошуком, синонімами, толерантністю до помилок, стоп-словами і налаштовуваними правилами релевантності, Meilisearch має достатньо функцій, щоб задовольнити потреби більшості додатків — і це ще до їх релізу v1.0 👏. Пошук по кількох індексах також знаходиться на дорожній карті.
Частина нуль: Але чому?
Чому варто пройти через труднощі зміни? Продуктивність та ефективність використання ресурсів!
Спочатку давайте порівняємо Elasticsearch і Meilisearch за показником, за яким ви, ймовірно, прийшли — використанням ресурсів. Пам'ять в хмарі коштує дорого, і Elasticsearch відомий тим, що споживає багато пам'яті. На моєму додатку на Rails, який має досить низьке навантаження, він використовує 3,5 ГБ. Це на 2,7 ГБ більше, ніж наступний за величиною контейнер, в якому працюють веб-робітники Rails, що використовують malloc замість jemalloc (це тема для іншої статті!).
Отже, наскільки ефективніший Meilisearch? Спочатку давайте встановимо базовий рівень з Elasticsearch. Ми будемо використовувати цю базу даних фільмів з ~32k рядків.
Тут треба зауважити, що налаштування Elasticsearch зайняло набагато більше часу. Він спочатку відмовився запускатися, бо йому потрібно було більше пам'яті, ніж ОС дозволяла виділити лише для запуску. Цю межу потрібно було збільшити за допомогою sysctl -w vm.max_map_count=262144
. Потім JSON файл потребував значних маніпуляцій, оскільки bulk JSON API вимагає вказувати індекс для кожного рядка. Це не було очевидно з документації, і стара відповідь на StackOverflow прийшла мені на допомогу.
docker network create elastic
docker run --name es01 --net elastic -p 9200:9200 -p 9300:9300 -it docker.elastic.co/elasticsearch/elasticsearch:8.2.3curl --location --request POST '[https://localhost:9200/movies/_bulk/'](https://localhost:9200/movies/_bulk/') \
--header 'Content-Type: application/x-ndjson' \
--header 'Authorization: Basic ---' \
--data-binary '@movies.json'
docker stats
показує, що Elasticsearch використовує 5,2 ГБ пам'яті. Додавання фільмів до індексу не збільшило це — він використовує 5,2 ГБ за замовчуванням без даних. Ви, звісно, можете налаштувати ES_JAVA_OPTS
і зменшити це. Однак навіть для маленьких додатків є ризик, що контейнери будуть евіковані через високий тиск на пам'ять. Це була основна причина, чому я вирішив ознайомитися з Meilisearch.
Тепер давайте зробимо те ж саме з Meilisearch. Налаштування було значно простіше, і bulk імпорт був безтурботним.
docker run --rm -p 7700:7700 -v "$(pwd)/meili_data:/meili_data" getmeili/meilisearchcurl -i -X POST 'http://127.0.0.1:7700/indexes/movies/documents' \
--header 'content-type: application/json' \
--data-binary @movies.json
Через кілька хвилин роботи Meilisearch, використання пам'яті фактично зменшилося до 96,7 МБ.
Тепер давайте проведемо просте порівняння. Ми запустимо 100 ітерацій q=batman&limit=10
для Meilisearch та ?q=batman&size=10
для Elasticsearch.
Elasticsearch: середнє 9,68мс, пікове 15мс.
Meilisearch: 5.17ms середнє значення. 11ms пікове значення.
Meilisearch використовує в 54.8 рази менше пам'яті і був на 46.6% швидший за Elasticsearch при однакових даних і запитах.
Це значно швидше і набагато легше для хостингу.
Зображення також займає 36MB замість 1.2GB — круто. Зверніть увагу, що це порівняння за умовчанням конфігурацій. Що ще важливо, Meilisearch має інтерфейс на localhost:7700, тому нам навіть не потрібно відкривати Postman для тестування (вибачте, зараз немає фільтрації чи сортування в адміністративному інтерфейсі).
Переконані? Тоді читайте далі, і я покажу, як виглядала міграція з Elasticsearch на Meilisearch для реального виробничого додатка — ScribeHub. Ми також перейшли від чудової бібліотеки Searchkick від Ankane до офіційної бібліотеки meilisearch-rails, і я покажу зміни, які ми зробили.
Частина перша: DevOps
Почнімо з того, що замінимо контейнер Elasticsearch на контейнер Meilisearch у вашому файлі docker-compose.yml:
meilisearch:
image: getmeili/meilisearch:v0.27.0
user: root
ports:
- "7700:7700"
volumes:
- "meili:/meili_data/"
env_file:
- .msenv...volumes:
meili:
Перша велика відмінність — це автентифікація. Meilisearch підтримує пряме інтегрування з фронтендом, яке навіть не торкається Rails (класно!). Це означає, що якщо встановлено головний ключ, він автоматично генерує стандартні ключі з конкретними правами доступу під час запуску. Якщо ви просто пробуєте Meilisearch локально, я рекомендую не встановлювати головний ключ, щоб дозволити неавтентифіковані запити. Якщо ви плануєте запускати на продакшн, я рекомендую встановити головний ключ, щоб зрозуміти, як це працює, перед запуском. Ми не будемо розглядати лише фронтенд реалізації в цій статті — зосередимося лише на міграції з ES на MS.
Що змусило мене майже здатися на самому початку, так це те, що сервіс MS буде генерувати нові ключі, якщо буде будь-яка зміна у його файлі середовища. Я постійно додавав стандартний адміністративний ключ у спільний файл .env, через що ключі генерувалися знову, і я отримував помилки автентифікації під час повторної індексації. Це повинно відбуватися тільки при зміні головного ключа, але генерування ключів при будь-яких змінах у файлі середовища означає, що для сервісу MS потрібно мати окремий файл середовища. Я назвав його '.msenv', як ви бачите вище. Я бачив, як ключі генерувалися навіть тоді, коли файл середовища не змінювався, але це було наслідком того, що я не монтував каталог до /meili_data.
Якщо ви встановлюєте головний ключ, виконайте команду SecureRandom.hex 32
у консолі Rails і вставте його в MEILI_MASTER_KEY
у файлі .msenv. Ви також можете налаштувати хост і вимкнути анонімну аналітику, що, на мою думку, має бути вимкнено за замовчуванням. Ось мій приклад файлу .msenv:
# УВАГА
# Кожного разу, коли в цей файл вносяться зміни, Meilisearch генерує нові ключі.
# Це призведе до анулювання поточних ключів і зробить вас сумними.
MEILISEARCH_HOST=http://meilisearch:7700
MEILI_MASTER_KEY=
MEILI_NO_ANALYTICS=true
Виконайте команду docker-compose up
, і ви повинні побачити це виведення при запуску MS:
Головний ключ встановлено. Запити до Meilisearch не будуть авторизовані, якщо ви не надасте ключ автентифікації.
Тепер нам потрібно отримати стандартний адміністративний API-ключ. Ось запит curl для отримання ключів. Я рекомендую зберегти запит у Postman або Insomnia, щоб не доводилося постійно його шукати.
curl --location --request GET 'http://localhost:7700/keys' \
--header 'Authorization: Bearer '
Вставте стандартний адміністративний API-ключ у MEILISEARCH_API_KEY
у файлі .env вашого Rails додатка і встановіть MEILISEARCH_HOST
на те саме значення, яке ви встановили в .msenv, щоб воно було доступне і в Rails.
Час написати файл ініціалізації для Meilisearch! Ви можете налаштувати таймоути та кількість спроб під час налаштування.
MeiliSearch::Rails.configuration = {
meilisearch_host: ENV['MEILISEARCH_HOST'],
meilisearch_api_key: ENV['MEILISEARCH_API_KEY'],
timeout: 1,
max_retries: 2
}
Перезапустіть все, щоб зміни в середовищі набули чинності, і тепер ви повинні мати можливість повторно індексувати модель з урахуванням дозволів. Але спочатку нам потрібна модель для повторного індексування.
Частина друга: Інтеграція з Rails
Ось де мій шлях і ваш можуть відрізнятися, але я надам приклад інтеграції моделі. Оскільки ScribeHub має багато ресурсів для пошуку, я написав concern. schema_searchable.rb:
module SchemaSearchable
extend ActiveSupport::Concern
included do
include MeiliSearch::Rails
extend Pagy::Meilisearch
end module ClassMethods
def trigger_sidekiq_job(record, remove)
MeilisearchEnqueueWorker.perform_async(record.class.name, record.id, remove)
end
end
end
Це дозволило уникнути дублювання коду для Elasticsearch, але я завжди радий зменшенню обсягу коду. Тепер ви можете додавати include SchemaSearchable
до будь-якої моделі, яку потрібно індексувати. Ось приклад додатків до нашої моделі GlossaryTerm:
after_touch :index!meilisearch enqueue: :trigger_sidekiq_job, per_environment: true, primary_id: :ms_id do
attributes [:account_id, :id, :term, :definition, :updated]
attribute :updated do
updated_at.to_i
end
filterable_attributes [:account_id]
enddef ms_id
"gt_#{account_id}_#{id}"
end
Зверніть увагу, що Meilisearch не має типу даних для об'єктів дати та часу Ruby чи Rails, тому ми перетворюємо це на Unix epoch за допомогою to_i
. after_touch :index!
дозволяє підтримувати індекс актуальним, коли модель змінюється. per_environment: true
гарантує, що ви не забрудните свої індекси для розробки тестовими даними. enqueue
запускає оновлення індексу у фоновому режимі відповідно до методу, визначеного в schemasearchable.rb — але нам ще потрібен цей worker. Ось meilisearchenqueue_worker.rb:
class MeilisearchEnqueueWorker
include Sidekiq::Worker
def perform(klass, record_id, remove)
if remove
klass.constantize.index.delete_document(record_id)
else
klass.constantize.find(record_id).index!
end
end
end
Якщо ви можете запустити нову консолі Rails і виконати Model.reindex!
без помилок, то ви готові редагувати дію індексації у контролері. Зараз, використовуючи метод пошуку active pagy без створення запиту N+1, нам потрібно використовувати обидва pagy_meilisearch
і pagy_search
ось так:
def index
@pagy, @glossary_terms = pagy_meilisearch(
GlossaryTerm.includes(GlossaryTerm.search_includes).pagy_search(
params[:q],
**{
filter: "account_id = #{current_account.id}"
}
)
)
end
Метод search_includes
в GlossaryTerm
просто повертає список асоціацій, які потрібні для уникнення запитів N+1. Мені подобається тримати це в моделі:
def self.search_includes
%i(
user
)
end
Збір рядка filter
може бути складнішим порівняно з Elasticsearch, оскільки це рядок, а не хеш, але це дає можливість складати логіку з будь-якою кількістю AND
і OR
, які тільки хочеться. Для таких випадків, як фільтрація за тегами з логікою AND, вам слід зробити ось так:
filter = "discarded=false"
if @conditions.key?(:tags)
@conditions[:tags].each do |tag|
filter += " AND tags='#{tag}'"
end
end
У цьому випадку @conditions
— це хеш, який заповнюється обробкою запиту для витягнення таких елементів, як теги та сортування для упорядкування. Документація має корисні примітки щодо поєднання логіки.
Залишилося тільки виправити тести, і в основному це зводиться до заміни index
на index!
і search_index.delete
на clear_index!
.
Було дуже круто бачити, як тести знову проходять після такої невеликої кількості виправлень у тестах.
Сподіваюсь, вам сподобалося! Ми точно отримали задоволення тут, в ScribeHub, і з нетерпінням чекаємо на пошук по кількох індексах 😉.
Перекладено з: Swapping Elasticsearch for Meilisearch in Rails feat. Docker