🔍

[WIP] OpenSearchとSearchkickを利用した全文検索の実装

に公開
Rack web server 起動までの詳細
containers/app/Dockerfile.dev
FROM ruby:3.4

RUN mkdir -p /app
WORKDIR /app

RUN gem install rails -v 8
RUN apt-get update -qq
docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: ./containers/app/Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - gem-cache:/usr/local/bundle
    working_dir: /app
    stdin_open: true
    tty: true
    command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"
    networks:
      - searchkick-playground-network
  db:
    image: mysql:8.3
    platform: linux/x86_64
    environment:
      MYSQL_ROOT_PASSWORD: "password"
    ports:
      - "3306:3306"
    command:
      - --default-authentication-plugin=mysql_native_password
    volumes:
      - ./tmp/mysql/data:/var/lib/mysql
    networks:
      - searchkick-playground-network
volumes:
  gem-cache: {}

networks:
  searchkick-playground-network:
    driver: bridge
docker compose run --rm app rails new . \
  --api \
  --database=mysql \
  --skip-javascript \
  --skip-hotwire \
  --skip-test
$ docker compose up
$ docker compose exec app rake db:create 

これで http://localhost:3000/ にアクセスしたらデモ画面が表示されるはず!

DBスキーマ(モデル)の追加
$ docker compose exec app rails g model Company name:string description:text
      invoke  active_record
      create    db/migrate/20250420005327_create_companies.rb
      create    app/models/company.rb

$ docker compose exec app rails g model CompanySynonym name:string company:references
      invoke  active_record
      create    db/migrate/20250420005336_create_company_synonyms.rb
      create    app/models/company_synonym.rb

どちらも生成された migration ファイルを編集して name には null: false を設定しておく

db/seeds.rb
Company.create!(
  name: "帝国金融",
  description: "大阪府に本社を置く、貸金業を営む企業。主に中小企業向けの融資を行っている。",
  company_synonyms_attributes: [
    { name: "バンク・オブ・エンペラー" },
  ]
)

Company.create!(
  name: "雑巾自動車",
  description: "中古車販売を行う企業。特にスポーツカーに強みを持つ。",
  company_synonyms_attributes: [
    { name: "Zokin Motors" },
  ]
)

Company.create!(
  name: "ニシキヘビファイナンス",
  description: "支店を大量に出しているため勢いがありそうに見える貸金業者。",
  company_synonyms_attributes: [
    { name: "Python Finance" },
  ]
)

シードは適当に作った。

これで、

$ docker compose exec app rake db:migrate db:seed

Searchkick への Ping を成功させるまで

日本語の処理に kuromoji を利用するのでプラグインが必要

containers/opensearch/Dockerfile.dev
FROM opensearchproject/opensearch:2.19.0

RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-kuromoji
docker-compose.yml
@@ -12,6 +12,8 @@ services:
     stdin_open: true
     tty: true
     command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"
+    environment:
+      OPENSEARCH_URL: "http://opensearch:9200"
     networks:
       - searchkick-playground-network
   db:
@@ -27,8 +29,29 @@ services:
       - ./tmp/mysql/data:/var/lib/mysql
     networks:
       - searchkick-playground-network
+  opensearch:
+    build:
+      context: .
+      dockerfile: ./containers/opensearch/Dockerfile.dev
+    environment:
+      - discovery.type=single-node
+      - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m
+      - DISABLE_SECURITY_PLUGIN=true
+    ulimits:
+      memlock:
+        soft: -1
+        hard: -1
+    volumes:
+      - opensearch_data:/usr/share/opensearch/data
+    ports:
+      - "9200:9200"
+    networks:
+      - searchkick-playground-network
+
 volumes:
   gem-cache: {}
+  opensearch_data:
+    driver: local
 
 networks:
   searchkick-playground-network:

上記の保存後に以下のようにOpenSearchのコンテナと疎通できるようになるはず

$ docker compose exec app bundle add searchkick
docker compose exec app bundle add opensearch-ruby
$ docker compose down
$ docker compose up
$ docker compose exec app rails console
Loading development environment (Rails 8.0.2)
app(dev)> Searchkick.client.ping
=> true

インデックスの作成と検索実行

全文検索のインデックスを作成したいモデルで Searchkick の設定を宣言的に書くことで検索機能が使えるようになる

ミニマルに設定するなら以下

 class Company < ApplicationRecord
+  searchkick
+
   has_many :company_synonyms, dependent: :destroy
 
   accepts_nested_attributes_for :company_synonyms
+
+  def search_data
+    {
+      name: name,
+      description: description,
+      synonyms: company_synonyms.pluck(:name)
+    }
+  end
 end

これで、 Company.search で全文検索が行えるようになる

ただし、 Company.reindex でインデックスの初期化をしておかないと Searchkick::MissingIndexError となる

$ docker compose exec app rails console
CoLoading development environment (Rails 8.0.2)
app(dev)> Company.search
  Company Search (51.7ms)  companies_development/_search {"query":{"match_all":{}},"timeout":"11000ms","_source":false,"size":10000}
An error occurred when inspecting the object: #<Searchkick::MissingIndexError: Index missing - run Company.reindex>
Result of Kernel#inspect: #<Searchkick::Relation:0x0000ffff91a6b670 @model=Company (call 'Company.load_schema' to load schema informations), @term="*", @options={}, @query=#<Searchkick::Query:0x0000ffff9a6f15e0 @klass=Company (call 'Company.load_schema' to load schema informations), @term="*", @options={}, @match_suffix="analyzed", @type=nil, @routing=nil, @misspellings=false, @misspellings_below=nil, @highlighted_fields=nil, @index_mapping=nil, @json=nil, @body={query: {match_all: {}}, timeout: "11000ms", _source: false, size: 10000}, @page=1, @per_page=10000, @padding=0, @load=true, @scroll=nil>>
=> 
app(dev)> Company.reindex
  Company Load (7.9ms)  SELECT `companies`.* FROM `companies` ORDER BY `companies`.`id` ASC LIMIT 1000 /*application='App'*/
  CompanySynonym Pluck (4.1ms)  SELECT `company_synonyms`.`name` FROM `company_synonyms` WHERE `company_synonyms`.`company_id` = 1 /*application='App'*/
  CompanySynonym Pluck (0.2ms)  SELECT `company_synonyms`.`name` FROM `company_synonyms` WHERE `company_synonyms`.`company_id` = 2 /*application='App'*/
  CompanySynonym Pluck (2.3ms)  SELECT `company_synonyms`.`name` FROM `company_synonyms` WHERE `company_synonyms`.`company_id` = 3 /*application='App'*/
  Company Import (25.3ms)  {"count":3}
=> true
app(dev)> Company.search("バンク")
  Company Search (26.2ms)  companies_development/_search {"query":{"bool":{"should":[{"dis_max":{"queries":[{"multi_match":{"query":"バンク","boost":10,"operator":"and","analyzer":"searchkick_search","fields":["*.analyzed"],"type":"best_fields"}},{"multi_match":{"query":"バンク","boost":10,"operator":"and","analyzer":"searchkick_search2","fields":["*.analyzed"],"type":"best_fields"}},{"multi_match":{"query":"バンク","boost":1,"operator":"and","analyzer":"searchkick_search","fuzziness":1,"prefix_length":0,"max_expansions":3,"fuzzy_transpositions":true,"fields":["*.analyzed"],"type":"best_fields"}},{"multi_match":{"query":"バンク","boost":1,"operator":"and","analyzer":"searchkick_search2","fuzziness":1,"prefix_length":0,"max_expansions":3,"fuzzy_transpositions":true,"fields":["*.analyzed"],"type":"best_fields"}}]}}]}},"timeout":"11000ms","_source":false,"size":10000}
  Company Load (1.8ms)  SELECT `companies`.* FROM `companies` WHERE `companies`.`id` = 1 /*application='App'*/
=> #<Searchkick::Relation [#<Company id: 1, name: "帝国金融", description: "大阪府に本社を置く、貸金業を営む企業。主に中小企業向けの融資を行っている。", created_at: "2025-04-20 01:07:05.138596000 +0000", updated_at: "2025-04-20 01:07:05.138596000 +0000">]>

すでにモデルに search というクラスメソッドが定義されている場合などは以下のように Searchkick で用いるメソッド名を変更できる

config/initializers/searchkick.rb
Searchkick.search_method_name = :searchkick_search

一般的な設定は公式ドキュメントなどに記載があると思うので割愛するが、個人的にハマったところや注意が必要と思ったものだけ記載する

曖昧検索 ( misspellings )

https://github.com/ankane/searchkick?tab=readme-ov-file#misspellings

misspellings で曖昧検索の有効・無効を指定できるらしいがデフォルトで有効で、有効の場合は日本語のキーワードで意図しない結果になることが多かった

例えば、これを明確に false にせずに曖昧検索が有効になっている状態で「帝国」と検索したらこのキーワードを含んでいないものもヒットしてしまう。
逆に false にすると「帝国金融」のみヒットする

app(dev)> Company.search("帝国")
  Company Search (26.6ms)  companies_development/_search {"query":{"bool":{"should":[{"dis_max":{"queries":[{"multi_match":{"query":"帝国","boost":10,"operator":"and","analyzer":"searchkick_search","fields":["*.analyzed"],"type":"best_fields"}},{"multi_match":{"query":"帝国","boost":10,"operator":"and","analyzer":"searchkick_search2","fields":["*.analyzed"],"type":"best_fields"}},{"multi_match":{"query":"帝国","boost":1,"operator":"and","analyzer":"searchkick_search","fuzziness":1,"prefix_length":0,"max_expansions":3,"fuzzy_transpositions":true,"fields":["*.analyzed"],"type":"best_fields"}},{"multi_match":{"query":"帝国","boost":1,"operator":"and","analyzer":"searchkick_search2","fuzziness":1,"prefix_length":0,"max_expansions":3,"fuzzy_transpositions":true,"fields":["*.analyzed"],"type":"best_fields"}}]}}]}},"timeout":"11000ms","_source":false,"size":10000}
  Company Load (0.8ms)  SELECT `companies`.* FROM `companies` WHERE `companies`.`id` IN (1, 2, 3) /*application='App'*/
=> #<Searchkick::Relation [#<Company id: 1, name: "帝国金融", description: "大阪府に本社を置く、貸金業を営む企業。主に中小企業向けの融資を行っている。", created_at: "2025-04-20 01:07:05.138596000 +0000", updated_at: "2025-04-20 01:07:05.138596000 +0000">, #<Company id: 2, name: "雑巾自動車", description: "中古車販売を行う企業。特にスポーツカーに強みを持つ。", created_at: "2025-04-20 01:07:05.162221000 +0000", updated_at: "2025-04-20 01:07:05.162221000 +0000">, #<Company id: 3, name: "ニシキヘビファイナンス", description: "支店を大量に出しているため勢いがありそうに見える貸金業者。", created_at: "2025-04-20 01:07:05.184211000 +0000", updated_at: "2025-04-20 01:07:05.184211000 +0000">]>
app(dev)> Company.search("帝国", misspellings: false)
  Company Search (18.8ms)  companies_development/_search {"query":{"bool":{"should":[{"dis_max":{"queries":[{"multi_match":{"query":"帝国","boost":10,"operator":"and","analyzer":"searchkick_search","fields":["*.analyzed"],"type":"best_fields"}},{"multi_match":{"query":"帝国","boost":10,"operator":"and","analyzer":"searchkick_search2","fields":["*.analyzed"],"type":"best_fields"}}]}}]}},"timeout":"11000ms","_source":false,"size":10000}
  Company Load (1.0ms)  SELECT `companies`.* FROM `companies` WHERE `companies`.`id` = 1 /*application='App'*/
=> #<Searchkick::Relation [#<Company id: 1, name: "帝国金融", description: "大阪府に本社を置く、貸金業を営む企業。主に中小企業向けの融資を行っている。", created_at: "2025-04-20 01:07:05.138596000 +0000", updated_at: "2025-04-20 01:07:05.138596000 +0000">]>

OpenSearchからデータを取得する

デフォルトだとデータベースからレコードを取るようになっている
(search を実行すると ActiveRecord のリレーションのラッパーが返ってくる)

必要なデータをすべてドキュメントに含むようにインデックスを設定しているなら、データベースへのアクセスをしない方が高速になる

loadfalse に設定することで検索サーバー(この場合は OpenSearch)からデータを取得するようになる

app(dev)> Company.search("帝国", misspellings: false, load: false)
  Company Search (23.1ms)  companies_development/_search {"query":{"bool":{"should":[{"dis_max":{"queries":[{"multi_match":{"query":"帝国","boost":10,"operator":"and","analyzer":"searchkick_search","fields":["*.analyzed"],"type":"best_fields"}},{"multi_match":{"query":"帝国","boost":10,"operator":"and","analyzer":"searchkick_search2","fields":["*.analyzed"],"type":"best_fields"}}]}}]}},"timeout":"11000ms","size":10000}
=> #<Searchkick::Relation [#<Searchkick::HashWrapper _id="1" _index="companies_development_20250420102110650" _score=22.299704 description="大阪府に本社を置く、貸金業を営む企業。主に中小企業向けの融資を行っている。" id="1" name="帝国金融" synonyms=#<Hashie::Array ["バンク・オブ・エンペラー"]>>]>

上記のように、エンドポイントからのレスポンスがハッシュ化されたものを得ることができるようになる

コールバック

コールバックがデフォルトで有効で、 after_commit でドキュメントの追加や更新が実行されるので注意

https://github.com/ankane/searchkick?tab=readme-ov-file#strategies

無効にしたり、ジョブで非同期で実行できたり簡単に設定は変えられる

Searchkick::ImportError

処理の効率化のため、 loadfalse にして検索サーバー側に必要なデータをすべて保持しておくという方法を取ると、検索対象となる項目以外のデータも置くことになる
例えば、ステータスや画像のパスなどをドキュメントに含むようにするが、画像のパスなどは検索処理の際に値を考慮するようなデータではない

このような設定にした場合、必要な設定を省くと以下のようなエラーとなる

Corporation Import (34.3ms)  {"count":15,"exception":["Searchkick::ImportError","{\"type\"=\u003e\"mapper_parsing_exception\", \"reason\"=\u003e\"failed to parse\", \"caused_by\"=\u003e{\"type\"=\u003e\"illegal_argument_exception\", \"reason\"=\u003e\"analyzer [searchkick_[:exact, :word_start, :text_middle]index] has not been configured in mappings\"}} on item with id '1'"],"exception_object":"{\"type\"=\u003e\"mapper_parsing_exception\", \"reason\"=\u003e\"failed to parse\", \"caused_by\"=\u003e{\"type\"=\u003e\"illegal_argument_exception\", \"reason\"=\u003e\"analyzer [searchkick[:exact, :word_start, :text_middle]_index] has not been configured in mappings\"}} on item with id '1'"}

これを防ぐには、 searchable: %i[name synonyms description] のように検索に用いる属性をホワイトリスティングする

where による選択

SQL での選択処理と同様、 search メソッドで where 節の指定ができる
(この記事のデモ用スキーマではステータスというカラムはないが)ステータスというカラムがテーブルにあってそれをドキュメントにも持つようにした場合、以下のようにステータスの値で選択処理を行うことができる

Company.search("帝国", misspellings: false, load: false, where: { status: "pub" })

Discussion