Как делать full-text поиск в Rails при помощи PostgreSQL
Поиск – неотъемлимая часть множества веб-приложений. Функцию поиска можно реализовать множеством различных способов. Как правило, когда речь заходит о поиске по приложению, вспоминают несколько зарекомендовавших себя решений: 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. У этой СУБД тонна крутых фич в том числе и для этой задачи.