Skip to content
← Wszystkie wpisy
5 min czytania Dawid Skłodowski

Wyszukiwanie pełnotekstowe z Elasticsearch w Rails

SQL LIKE to nie wyszukiwanie. Deep-dive o dodaniu prawdziwego wyszukiwania pełnotekstowego do aplikacji Rails z Elasticsearch — analizatory, mapowania, trafność, trzymanie indeksu w synchronizacji — i uczciwe spojrzenie na to, kiedy zamiast tego wystarczy Postgres.

W pewnym momencie przychodzi prośba o funkcję, która brzmi prosto — „pozwól użytkownikom przeszukiwać produkty” — a ty sięgasz po WHERE name LIKE '%query%'. Działa w demie i rozpada się w rzeczywistości: nie potrafi szeregować wyników, ignoruje literówki i rdzenie słów, nie potrafi szukać w kilku polach z różnymi wagami, a z wiodącym wildcardem nie może użyć indeksu, więc robi się wolne. LIKE to dopasowanie podłańcucha. To, czego prośba naprawdę chce, to wyszukiwanie — a to inny rodzaj narzędzia. Elasticsearch to ten, po który sięga większość zespołów. Oto jak dodać go do aplikacji Rails i uczciwa dyskusja o tym, kiedy nie powinieneś.

Co Elasticsearch właściwie robi

Elasticsearch to rozproszona wyszukiwarka zbudowana na Lucene. Tym, co czyni go wyszukiwaniem, a nie dopasowywaniem, jest indeks odwrócony w połączeniu z analizą. Gdy indeksujesz dokument, Elasticsearch przepuszcza jego tekst przez analizator, który zmniejsza wielkość liter, dzieli go na tokeny, usuwa stop-słowa i redukuje słowa do rdzeni („running” → „run”). Przechowuje mapę z każdego tokenu na dokumenty go zawierające. Zapytanie jest analizowane tak samo i wyszukiwane w tym indeksie, więc „running shoes” dopasowuje dokument zawierający „run shoe” i wraca uszeregowane według trafności — jak dobrze każdy dokument pasuje — a nie w przypadkowej kolejności.

Ten krok analizy to cała różnica wobec LIKE. To dlatego wyszukiwanie toleruje liczby mnogie i formy słów, dlaczego może ważyć dopasowanie tytułu nad dopasowaniem opisu i dlaczego zwraca najlepsze wyniki najpierw.

Wpinanie w Rails

Gemy elasticsearch-rails i elasticsearch-model integrują go z ActiveRecord. Włączasz moduł i deklarujesz, co indeksować:

class Product < ActiveRecord::Base
  include Elasticsearch::Model
  include Elasticsearch::Model::Callbacks   # auto-aktualizacja indeksu przy save/destroy

  settings index: { number_of_shards: 1 } do
    mappings dynamic: false do
      indexes :name,        analyzer: "english", boost: 3
      indexes :description, analyzer: "english"
      indexes :brand,       type: "keyword"      # dopasowanie dokładne, nieanalizowane
    end
  end
end

Dwie idee w tym mapowaniu zwracają studiowanie. Analizator per pole kontroluje, jak tekst jest tokenizowany — english stosuje angielskie rdzeniowanie i stop-słowa do name i description. boost sprawia, że dopasowanie w name liczy się trzy razy bardziej niż w description, więc produkt, którego tytuł zawiera zapytanie, szereguje się nad tym, który jedynie o nim wspomina. A brand to keyword (nieanalizowany), bo chcesz dokładnego, fasetowego dopasowania na nim, nie rdzeniowania. Wybór analizowane vs keyword per pole to większość tego, do czego sprowadza się dobra trafność wyszukiwania.

Wyszukiwanie to potem wywołanie metody zwracające uszeregowane rekordy:

Product.search("running shoes").records.to_a

Trafność to prawdziwa praca

Otrzymanie wyników jest łatwe; otrzymanie właściwych wyników we właściwej kolejności to zadanie. Kilka dźwigni, których faktycznie użyjesz:

  • Zapytania wielopolowe z wagami. Zapytanie multi_match przeszukuje kilka pól naraz i łączy ich wyniki, z boostami per pole — chleb powszedni wyszukiwania produktów czy artykułów.
  • Rozmycie na literówki. Elasticsearch potrafi dopasować w obrębie odległości edycyjnej, więc „Manchestr” znajduje „Manchester” — idea trigramów, wbudowana.
  • Filtry vs zapytania. Zapytanie przyczynia się do wyniku trafności; filtr (w magazynie? cena poniżej 50 zł?) tylko włącza lub wyklucza i jest cache’owany oraz szybki. Używaj filtrów do ograniczeń tak/nie, a zapytań do „jak dobrze to pasuje” i nigdy nie pozwól binarnemu ograniczeniu wpływać na wynik.
Product.search(query: {
  bool: {
    must:   { multi_match: { query: "running shoes", fields: ["name^3", "description"], fuzziness: "AUTO" } },
    filter: { term: { in_stock: true } }
  }
})

Strojenie trafności jest iteracyjne — patrzysz na realne zapytania, widzisz, co szereguje się źle, i dostrajasz boosty, analizatory oraz wybór pól. Zaplanuj na to czas; domyślna konfiguracja to punkt startu, nie odpowiedź.

Trzymanie indeksu w synchronizacji: trudna część

Oto operacyjna rzeczywistość, o której nikt nie wspomina w przewodniku dla początkujących: masz teraz dwa składy danych, swoją bazę i indeks wyszukiwania, i będą dryfować. Elasticsearch::Model::Callbacks aktualizuje indeks przy save i destroy, co jest w porządku dla prostych przypadków, ale po cichu psuje się w kilku częstych:

  • Operacje masowe omijają callbacki. update_all, delete_all, surowy SQL i importy nie odpalają callbacków ActiveRecord, więc indeks po cichu się starzeje.
  • Indeksowanie w callbacku wiąże twoje żądanie z Elasticsearch. Jeśli ES jest wolny lub leży, twój save jest wolny lub zawodzi. Indeksowanie należy do zadania w tle, nie inline w żądaniu webowym.
  • Zmiany powiązanych rekordów. Jeśli przeszukiwalny tekst produktu zawiera nazwę jego kategorii, zmiana nazwy kategorii musi przeindeksować produkty — czego callback na produkcie nie złapie.

Wzorce, które przeżywają produkcję: przeindeksuj przez zadanie w tle (zakolejkuj przy zmianie, niech worker wypchnie do ES, by awaria wyszukiwania nigdy nie psuła save’a), zaplanuj okresowe pełne przeindeksowanie, by naprawić dryf, i używaj aliasów Elasticsearch, byś mógł odbudować indeks od zera i podmienić go atomowo bez przestoju. Traktuj indeks jako skład pochodny, który musi być odbudowywalny z bazy w dowolnym momencie, bo ostatecznie będziesz musiał go odbudować.

Kiedy zamiast tego wystarczy Postgres

Zanim weźmiesz na siebie drugi skład danych, zapytaj, czy go potrzebujesz. Uruchamianie, monitorowanie i synchronizowanie Elasticsearch to realny ciężar operacyjny, a dla bardzo wielu aplikacji Postgres potrafi wykonać robotę:

  • pg_trgm daje ci trigramowe rozmyte dopasowanie i przyspieszenie ILIKE — świetne do autouzupełniania i dopasowania tolerancyjnego na literówki na kolumnie.
  • Wyszukiwanie pełnotekstowe Postgres (tsvector/tsquery, z indeksem GIN) robi rdzeniowanie, szeregowanie i wyszukiwanie wielopolowe w całości wewnątrz bazy, którą już uruchamiasz — bez drugiego składu, bez problemu synchronizacji, z transakcyjną spójnością za darmo.

Nasza zasada: jeśli wyszukiwanie jest funkcją twojej aplikacji — katalog produktów, magazyn dokumentów, cokolwiek, gdzie trafność, fasety i skala są centralne — Elasticsearch zarabia na swój koszt operacyjny. Jeśli wyszukiwanie jest wygodą — pozwoleniem użytkownikom znaleźć rekord po nazwie — zacznij od FTS Postgresa i awansuj do Elasticsearch dopiero, gdy uderzysz w jego granice. Dodanie wyszukiwarki, której nie potrzebowałeś, to klasyczny sposób podwojenia ciężaru operacyjnego dla funkcji, którą obsłużyłaby kolumna tsvector.

Werdykt

Elasticsearch zamienia „przeszukaj produkty” z kruchej klauzuli LIKE w prawdziwe, uszeregowane, tolerancyjne na literówki, wielopolowe wyszukiwanie, a integracja z Rails czyni indeksowanie i odpytywanie przyjemnymi. Haczyk nie jest w odpytywaniu — jest w synchronizacji: wdrożyłeś drugi, pochodny skład danych, a praca to trzymanie go poprawnym przez zadania w tle, okresowe przeindeksowania i odbudowywalne aliasy. Potraktuj tę pracę poważnie, a Elasticsearch jest znakomity w tym, co robi. Ale sprawdź najpierw, czy FTS Postgresa pokrywa twoją potrzebę — dla wielu aplikacji pokrywa, a najlepszy drugi skład danych to ten, którego nie musiałeś dodawać.