Статьи

GeoSpatial Search в Rails с использованием Elasticsearch

В этом уроке я собираюсь создать небольшое приложение Rails. Я покажу вам, как создать грабли, чтобы импортировать некоторые объекты из Foursquare в нашу базу данных. Тогда мы будем индексировать их на Elasticsearch. Кроме того, местоположение каждого места будет проиндексировано, чтобы мы могли осуществлять поиск по расстоянию.

Rake-задача — это всего лишь скрипт ruby, который мы можем запустить вручную, или мы можем запускать его периодически, если нам нужно выполнить некоторые фоновые задачи, например, для обслуживания. В нашем случае мы собираемся запустить его вручную. Нам понадобится новое приложение для рельсов и несколько моделей для сохранения в нашей базе данных, местах, которые мы собираемся импортировать из Foursquare. Давайте начнем с создания нового приложения rails, поэтому введите в своей консоли:

$ rails new elasticsearch-rails-geolocation

Я собираюсь создать две модели: место и категорию, используя рельсовые генераторы. Чтобы создать модель Venue, введите в своем терминале:

$ rails g model venue name:string address:string country:string latitude:float longitude:float

Введите следующую команду для создания модели категории:

$ rails g model category name:string venue:references

Отношение от места проведения до категории много ко многим. Например, если мы импортируем итальянский ресторан, он может иметь категории «Итальянский» и «Ресторан», но другие заведения также могут иметь такие же категории. Чтобы определить отношение «многие ко многим» из «Места проведения» и «Категории», мы используем has_and_belongs_to_many активной записи has_and_belongs_to_many , поскольку у нас нет других свойств, которые относятся к этому отношению. Наши модели теперь выглядят так:

1
2
3
4
5
6
7
class Venue < ActiveRecord::Base
  has_and_belongs_to_many :categories
end
 
class Category < ActiveRecord::Base
  has_and_belongs_to_many :venues
end

Теперь нам все еще нужно создать таблицу соединений для отношений. Он будет хранить список ‘venue_id, category_id’ для отношений. Чтобы сгенерировать эту таблицу, выполните следующую команду в своем терминале:

$ rails generate migration CreateJoinTableVenueCategory venue category

Если мы посмотрим на сгенерированную миграцию, мы можем убедиться, что создана правильная таблица для отношений:

1
2
3
4
5
6
7
8
class CreateJoinTableVenueCategory < ActiveRecord::Migration
  def change
    create_join_table :venues, :categories do |t|
      # t.index [:venue_id, :category_id]
      # t.index [:category_id, :venue_id]
    end
  end
end

Чтобы действительно создать таблицу в базе данных, не забудьте запустить миграцию, выполнив команду bin/rake db:migrate в своем терминале.

Чтобы импортировать объекты из foursquare, нам нужно создать новое задание Rake. В Rails также есть генератор для задач, поэтому просто введите свой терминал:

$ rails g task import venues

Если вы откроете новый файл, созданный в lib / tasks / import.rake , вы увидите, что он содержит задачу без реализации.

1
2
3
4
5
namespace :import do
  desc «TODO»
  task venues: :environment do
  end
end

Для реализации задачи я собираюсь использовать два драгоценных камня. Драгоценный камень ‘foursquare2’ используется для подключения к foursquare. Второй драгоценный камень — «геокодер», чтобы преобразовать название города, который мы передаем задаче в качестве аргумента для гео-координат. Добавьте эти два камня в ваш Gemfile:

gem 'foursquare2'
gem 'geocoder'

Запустите bundle install в своем терминале, внутри папки проекта rails, чтобы установить гемы.

Чтобы выполнить задачу, я проверил документацию для foursquare2 , а также официальную документацию Foursquare . Foursquare не принимает анонимные вызовы своего API, поэтому нам нужно создать учетную запись разработчика и зарегистрировать это приложение, чтобы получить ключи client_id и client_secret, которые нам нужно подключить. Для этого примера меня интересует конечная точка API поиска места , поэтому мы можем получить некоторые реальные данные для нашего образца. После получения данных от API мы просто сохраняем их в базе данных. Окончательная реализация выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace :import do
  desc «Import venues from foursquare»
  task :venues, [:near] => :environment do |t, args|
 
    client = Foursquare2::Client.new(
      client_id: ‘your_foursquare_client_id’,
      client_secret: ‘your_foursquare_client_secret’,
      api_version: ‘20160325’)
 
    result = client.search_venues(near: args[:near].to_s, query: ‘restaurants’, intent: ‘browse’)
    result.venues.each do |v|
        venue_object = Venue.new(name: v.name, address: v.location.address, country: v.location.country, latitude: v.location.lat, longitude: v.location.lng)
 
        v.categories.each do |c|
          venue_object.categories << Category.find_or_create_by(name: c.shortName)
        end
 
        venue_object.save
 
        puts «‘#{venue_object.name}’ — imported»
    end
  end
end

После того, как вы добавили свои ключи API Foursquare, чтобы импортировать некоторые объекты из «Лондона», выполните эту команду в своем терминале: bin/rake import:venues[london]

Рейк результат импорта

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

На данный момент у нас есть наш импортер и модель данных, но нам все еще нужно проиндексировать наши места на Elasticsearch. Затем нам нужно создать вид с формой поиска, которая позволит вам ввести адрес, рядом с которым вы заинтересованы в поиске мест.

Давайте начнем с добавления gem ‘chewy’ в Gemfile и запуска bundle install .

В соответствии с документацией создайте файл app / chewy / venues_index.rb, чтобы определить, как каждое место будет индексироваться Elasticsearch. Используя chewy, нам не нужно аннотировать наши модели, поэтому индексы Elasticsearch полностью изолированы от моделей.

1
2
3
4
5
6
7
8
9
class VenuesIndex < Chewy::Index
  define_type Venue do
    field :country
    field :name
    field :address
    field :location, type: ‘geo_point’, value: ->{ {lat: latitude, lon: longitude} }
    field :categories, value: ->(venue) { venue.categories.map(&:name) } # passing array values to index
  end
end

Как вы можете видеть, в классе VenuesIndex я указываю, что хочу индексировать поля страна, имя и адрес в виде строки. Затем, чтобы иметь возможность поиска по географическому местоположению, мне нужно указать, что широта и долгота составляют гео- точку , которая является географическим местоположением в Elasticsearch. Последнее, что мы хотим проиндексировать с каждым местом, это список категорий.

Запустите задачу rake, введя в свой терминал bin/rake chewy:reset чтобы проиндексировать все места, которые мы имеем в базе данных. Вы можете использовать ту же команду для переиндексации вашей базы данных в Elasticsearch, если вам нужно.

Теперь у нас есть данные в базе данных SQLite и они проиндексированы в Elasticsearch, но мы еще не создали никаких представлений. Давайте сгенерируем наш контроллер Venues только с действием show.

Давайте начнем с изменения нашего файла rout.rb :

1
2
3
4
Rails.application.routes.draw do
  root ‘venues#show’
  get ‘search’, to: ‘venues#show’
end

Теперь создайте представление app / views / venues / show.html.erb , где я просто добавляю форму, чтобы указать место, где вы хотите найти места. Я также отображаю список мест, если результат поиска доступен:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<h1>Search venues</h1>
 
<% if @total_count %>
    <h3><%= @total_count %> venues found near <%= params[:term] %></h3>
<% end %>
 
<%= form_for :term, url: search_path, method: :get do |form|
    <p>
        Venues near
    <%= text_field_tag :term, params[:term] %>
    <%= submit_tag «Search», name: nil %>
    </p>
<% end %>
 
<hr/>
 
<div id=’search-results’>
    <% @venues.each do |venue|
      <div>
        <h3><%= venue.name %></h3>
        <% if venue.address %>
          <p>Address: <%= venue.address %></p>
        <% end %>
            <p>Distance: <%= number_to_human(venue.distance(@location), precision: 2, units: {unit: ‘km’}) %></p>
        </div>
    <% end %>
</div>

Как вы видите, я показываю расстояние от местоположения, указанного в форме поиска, до каждого места. Чтобы рассчитать и отобразить расстояние, добавьте метод «расстояние» в свой класс объекта:

1
2
3
4
5
6
7
class Venue < ActiveRecord::Base
  has_and_belongs_to_many :categories
 
  def distance location
    Geocoder::Calculations.distance_between([latitude, longitude], [location[‘lat’], location[‘lng’]])
  end
end

Теперь нам нужно сгенерировать VenuesController, поэтому введите в вашем терминале $ rails g controller venues show . Это полная реализация:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class VenuesController < ApplicationController
  def show
    if params[:term].nil?
      @venues = []
    else
      @location = address_to_geolocation params[:term]
 
      scope = search_by_location
      @total_count = scope.total_count
      @venues = scope.load
    end
  end
 
  private
    def address_to_geolocation term
      res = Geocoder.search(term)
      res.first.geometry[‘location’] # lat / lng
    end
 
    def search_by_location
      VenuesIndex
        .filter {match_all}
        .filter(geo_distance: {
          distance: «2km»,
          location: {lat: @location[‘lat’], lon: @location[‘lng’]}
        })
        .order(_geo_distance: {
            location: {lat: @location[‘lat’], lon: @location[‘lng’]}
          })
    end
end

Как видите, у нас есть только действие «показать». Местоположение поиска сохраняется в params[:term] , и если это значение доступно, мы конвертируем адрес в географическое местоположение. В методе ‘search_by_location’ я просто запрашиваю Elasticsearch, чтобы он соответствовал любому месту в пределах 2 км от расстояния поиска и порядка ближайшего.

Вы можете подумать: «Почему результат не упорядочен по расстоянию по умолчанию, если мы выполняем гео-поиск?» Elasticsearch рассматривает геолокационный фильтр как один фильтр, вот и все. Вы также можете выполнить поиск по другим полям, чтобы мы могли искать «пиццерия» рядом с локацией. Может быть, есть итальянский ресторан, в меню которого есть четыре пиццы, но есть большая пиццерия чуть дальше. Elasticsearch учитывает актуальность поиска по умолчанию.

Если я выполню поиск, я могу увидеть список мест:

результаты поиска

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

1
2
3
4
5
<p>Category:
  <% venue.categories.each do |c|
    <%= link_to c.name, search_path(term: params[:term], category: c.name) %>
  <% end %>
</p>

Если мы обновим страницу поиска, то увидим, что категории отображаются сейчас:

Результаты поиска с категорией

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class VenuesController < ApplicationController
  def show
    if params[:term].nil?
      @venues = []
    else
      @location = address_to_geolocation params[:term]
      @category = params[:category]
 
      scope = search_by_location
      @total_count = scope.total_count
      @venues = scope.load
    end
  end
 
  private
    def address_to_geolocation term
      res = Geocoder.search(term)
      res.first.geometry[‘location’] # lat / lng
    end
 
    def search_by_location
      scope = VenuesIndex
        .filter {match_all}
        .filter(geo_distance: {
          distance: «2km»,
          location: {lat: @location[‘lat’], lon: @location[‘lng’]}
        })
        .order(_geo_distance: {
            location: {lat: @location[‘lat’], lon: @location[‘lng’]}
          })
 
      if @category
        scope = scope.merge(VenuesIndex.filter(match: {categories: @category}))
      end
 
      return scope
    end
end

Кроме того, в шапке я добавляю ссылку, чтобы вернуться к «нефильтрованным результатам».

01
02
03
04
05
06
07
08
09
10
<h1>Search venues</h1>
 
<% if @total_count %>
    <% if @category %>
        <h3><%= «#{@total_count} #{@category} found near #{params[:term]}» %></h3>
        <%= link_to ‘All venues’, search_path(term: params[:term]) %>
    <% else %>
        <h3><%= @total_count %> venues found near <%= params[:term] %></h3>
    <% end %>
<% end%>

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

Результаты поиска отфильтрованы по категориям

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