Как делать full-text поиск в Rails при помощи PostgreSQL

Illustration of a person seated on the floor, leaning against a wall and looking at a tablet, highlighted by a purple and white geometric background. Illustration of a person seated on the floor, leaning against a wall and looking at a tablet, highlighted by a purple and white geometric background.

Поиск – неотъемлимая часть множества веб-приложений. Функцию поиска можно реализовать множеством различных способов. Как правило, когда речь заходит о поиске по приложению, вспоминают несколько зарекомендовавших себя решений: sphinx, elasticsearch (мы писали большую и интересную статью про использование elasticsearch в Rails). Их отличительной особенностью является их независимость и масштабируемость. Термин "независимость" означает, что они могут использоваться как в Rails-приложениях, так и в сочетании с множеством других технологий. Подобная гибкость достигается при помощи их общего свойства: оба эти поисковых движка работают независимо от основного приложения и составляют собственную базу данных, индексируя базу приложения, в котором должен осуществляться поиск.

Навык работы с sphinx и elasticsearch полезен для более или менее опытного разработчика, но для начинающего разработчика имеет смысл ознакомиться с более простыми (но не менее мощными) способами организации поиска. Поэтому я предлагаю рассмотреть реализацию функции поиска по приложению практически штатными средствами наиболее распространенной при работе с Rails базы данных. Мы построим наш поиск на базе PostreSQL с использованием гема pg_search.

О том, как установить и начать разрабатывать приложения на Rails, используя PostgreSQL, вы можете прочитать в нашей статье PostgreSQL: зачем и как.

Поиск по одной таблице

Для начала нам понадобится простейшее приложение, содержащее несколько записей достаточной длины. Предлагаю создать пять записей с биографиями персонажей Star Wars: seeds.rb

Для реализации функции поиска воспользуемся гемом pg_search. Добавим в файл Gemfile строку gem 'pg_search' и установим гем командой bundle install

Модель, содержащая информацию о персонажах, в нашем приложении называется Character. Перейдем в файл app/models/character.rb и добавим строку include PgSearch, чтобы файл модели в результате выглядел так:

# app/models/character.rb
class Character < ActiveRecord::Base
  include PgSearch
end

Подключение PgSearch позволит нам использовать в модели методы этого модуля для организации поиска по объектам Character, содержащимся в базе данных.

Гем pg_search предоставляет два способа организации поиска. Первый называется multi-search, он позволяет организовать поиск по множеству моделей ActiveRecord. Это удобно, когда необходимо сделать глобальный поиск по всему приложению. Для туристического сайта, например, это будет поиск по отелям, хостелам, курортам, аэропортам и так далее.

Второй способ называется pg_search_scope и отличается от первого тем, что с его помощью создается поиск по одной модели ActiveRecord, в нашем случае это модель Character. Поскольку в нашем приложении пока всего одна модель, воспользуемся вторым способом.

Добавим в файл app/models/character.rb новую строку, в которой укажем название нашего поиска и атрибуты модели Character, в которых будет осуществляться поиск:

# app/models/character.rb
class Character < ActiveRecord::Base
  include PgSearch
  pg_search_scope :search_everywhere, against: [:title, :content]
end

Этих двух строк кода уже достаточно, чтобы проверить работоспособность поиска. Зайдем в консоль Rails (команда rails console в директории приложения) и попробуем задать поиск по имени "Шми", которое встречается лишь в одной из содержащихся в базе данных записей:

2.1.5 :001 > Character.search_everywhere("Шми")
  Character Load (64.1ms) SELECT "characters".*, ((ts_rank((to_tsvector('simple', coalesce("characters"."title"::text, '')) || to_tsvector('simple', coalesce("characters"."content"::text, ''))), (to_tsquery('simple', ''' ' || 'Шми' || ' ''')), 0))) AS pg_search_rank FROM "characters" WHERE (((to_tsvector('simple', coalesce("characters"."title"::text, '')) || to_tsvector('simple', coalesce("characters"."content"::text, ''))) @@ (to_tsquery('simple', ''' ' || 'Шми' || ' ''')))) ORDER BY pg_search_rank DESC, "characters"."id" ASC
 => #<ActiveRecord::Relation [#<Character id: 1, title: "Энакин Скайуокер", content: "Сын Шми Скайуокер. Энакин родился в 41 до я. б. В ...", created_at: "2015-01-15 15:05:47", updated_at: "2015-01-15 15:05:47">]>

Ответом на запрос стала запись об Энакине Скайуокере, которая содежит слово "Шми" в атрибуте content.

Теперь попробуем поискать по имени, которое упоминается в трех записях из пяти:

2.1.5 :007 > Character.search_everywhere("Квай-Гон")
  Character Load (2.2ms) SELECT "characters".*, ((ts_rank((to_tsvector('simple', coalesce("characters"."title"::text, '')) || to_tsvector('simple', coalesce("characters"."content"::text, ''))), (to_tsquery('simple', ''' ' || 'Квай-Гон' || ' ''')), 0))) AS pg_search_rank FROM "characters" WHERE (((to_tsvector('simple', coalesce("characters"."title"::text, '')) || to_tsvector('simple', coalesce("characters"."content"::text, ''))) @@ (to_tsquery('simple', ''' ' || 'Квай-Гон' || ' ''')))) ORDER BY pg_search_rank DESC, "characters"."id" ASC
 => #<ActiveRecord::Relation [#<Character id: 4, title: "Квай-Гон Джинн", content: "Квай-Гон Джинн очень восприимчив к голосу Силы и в...", created_at: "2015-01-15 15:05:47", updated_at: "2015-01-15 15:05:47">, #<Character id: 1, title: "Энакин Скайуокер", content: "Сын Шми Скайуокер. Энакин родился в 41 до я. б. В ...", created_at: "2015-01-15 15:05:47", updated_at: "2015-01-15 15:05:47">, #<Character id: 5, title: "Оби-Ван Кеноби", content: "В юности (примерно во время Скрытой угрозы) верный...", created_at: "2015-01-15 15:05:47", updated_at: "2015-01-15 15:05:47">]>

Результат выдал три записи, в которых упоминается Квай-Гон Джинн: статью о самом Квай-Гоне первой, поскольку поисковый запрос упоминается в заголовке, а так же статьи об Энакине Скайуокере и об Оби-Ван Кеноби, в которых запрос "Квай-Гон" находится в тексте.

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

Начнем с формы. Наличие формы поиска с любой страницы сайта или приложения давно стало хорошей традицией, от которой не станем отступать и мы. Отредактируем файл app/views/application.html.erb, чтобы он выглядел так:

# app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
<head>
  <title>MkdevPgSeach</title>
  <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
  <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
  <%= csrf_meta_tags %>
</head>
<body>

<%= form_tag results_path, method: :get, role: 'search' do %>
  <%= text_field_tag :query, params[:query] %>
  <%= submit_tag "Искать", name: nil %>
<% end %>

<%= yield %>

</body>
</html>

Если после обновления файла application.html.erb вы вдруг захотите посмотреть на приложение в браузере, вас ожидает разочарование в виде ошибки, изещающей о неизвестной переменной или методе results_path. Исправим её, отредактировав файл config/routes.rb:

# config/routes.rb
Rails.application.routes.draw do
  resources :characters
  root 'characters#index'
  get 'results', to: 'results#index', as: 'results'
end

Мы создаем именованный роут results_path, который будет доступен в браузере по адресу http://localhost:3000/results и будет показывать данные, отдающиеся методом index контроллера ResultsController. Самое время создать этот контроллер, экшен и вьюху для экшена:

# app/controllers/results_controller.rb
class ResultsController < ApplicationController
  def index
    @search_results = Character.search_everywhere(params[:query])
  end
end
# app/views/results/index.html.erb
<h3>Результаты поиска по запросу <em><%= params[:query]%></em></h3>
<% @search_results.each do |result| %>
  <h4><%= link_to result.title, result %></h4>
  <div><%= result.content.html_safe %></div>
  <hr>
<% end %>

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

Поиск по нескольким таблицам

Теперь, когда у нас есть полнфункциональный поиск по персонажам, можно усложнить задачу, реализовав возможность запускать поиск по разным моделям. Для воплощения multi-search требуется еще одна таблица в базе данных, которая будет содержать проиндексированные и доступные для поиска записи. Создадим её командой rails g pg_search:migration:multisearch и применим миграцию: rake db:migrate. Не забудьте добавить эту таблицу, без неё мультипоиск работать не будет.

Добавим родные планеты уже находящихся в базе персонажей и установим между ними ассоциативную связь.

rails g scaffold planet title

Через консоль rails добавим в базу несколько планет:

planets = %w(Стьюджон Татуин Набу)
planets.each do |p|
  Planet.create(title: p)
end

Редактируем модель персонажа:

# app/models/character.rb
class Character < ActiveRecord::Base
  include PgSearch
  pg_search_scope :search_everywhere, against: [:title, :content]
  # Добавляем принадлежность к планете
  belongs_to :planet
end

Теперь создадим миграцию, в которой добавим к модели Character атрибут planet_id:

rails g migration add_planet_id_to_character planet_id:integer

Применяем изменения к базе командой rake db:migrate. Поскольку для создания поиска, который будет осуществляться по нескольким моделям вместо одной, используется несколько другой способ реализации, нам необходимо еще раз обновить файл модели Character и добавить код в файл модели Planet.

# app/models/character.rb
class Character < ActiveRecord::Base
  include PgSearch
  # Заменяем строку pg_search_scope :search_everywhere, against: [:title, :content] на следующую:
  multisearchable against: [:title, :content]
  belongs_to :planet
end
# app/models/planet.rb
class Planet < ActiveRecord::Base
  # Включим возможности pg_search
  include PgSearch
  # Добавим ассоциацию с персонажами
  has_many :characters
  # Добавим возможность искать планеты по названиям
  multisearchable against: :title
end

Не забываем о том, что в контроллере у нас все еще старый код для поиска по модели Character. Обновим контроллер:

# app/controllers/results_controller.rb
class ResultsController < ApplicationController
  def index
    # Обновим строку, сделав возможным мультипоиск
    @search_results = PgSearch.multisearch(params[:query])
  end
end

И, наконец, обновим вьюху свежеотредактированного экшена так, чтобы в результатах поиска итерировались проиндексированные записи, соответствующие поисковому запросу:

# app/views/results/index.html.erb
<h3>Результаты поиска по запросу <em><%= params[:query]%></em></h3>
<% @search_results.each do |pg_search_document| %>
  <h4><%= link_to pg_search_document.searchable.title, pg_search_document.searchable %></h4>
  <div><%= pg_search_document.content.html_safe %></div>
  <hr>
<% end %>

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

Для переиндексации можно использовать коллбеки, которые будут переиндексировать базу данных после обновления существующих записей или добавления новых. Создадим такие коллбеки для моделей Character и Planet:

# app/models/character.rb
class Character < ActiveRecord::Base
  include PgSearch
  multisearchable against: [:title, :content]

  # Добавим вызов метода, осуществляющего переиндексацию, после сохранения записи
  after_save :reindex

  # Сделаем метод реиндексации приватным
  private

  # Опишем метод, который будет вызываться после каждого создания новой записи или обновления существующей
  def reindex
    PgSearch::Multisearch.rebuild(Hero)
  end
end

По аналогии, модель Planet:

# app/models/planet.rb
class Planet < ActiveRecord::Base
  include PgSearch
  has_many :characters
  multisearchable against: :title

  after_save :reindex

  private

  def reindex
    PgSearch::Multisearch.rebuild(Hero)
  end
end

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


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

Если перед вами стоит задача написать поиск для крупного приложения с большой (или огромной) базой данных, по которым необходимо будет осуществлять поиск, могут потребоваться решения посложнее, чем pg_search. Примеры таких решений были приведены в начале статьи.

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

И, конечно же, рекомендуется к изучению документация полнотекстового поиска PostgreSQL. У этой СУБД тонна крутых фич в том числе и для этой задачи.