[WIP] OpenSearchとSearchkickを利用した全文検索の実装
Rack web server 起動までの詳細
FROM ruby:3.4
RUN mkdir -p /app
WORKDIR /app
RUN gem install rails -v 8
RUN apt-get update -qq
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
を設定しておく
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 を利用するのでプラグインが必要
FROM opensearchproject/opensearch:2.19.0
RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-kuromoji
@@ -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 で用いるメソッド名を変更できる
Searchkick.search_method_name = :searchkick_search
一般的な設定は公式ドキュメントなどに記載があると思うので割愛するが、個人的にハマったところや注意が必要と思ったものだけ記載する
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 のリレーションのラッパーが返ってくる)
必要なデータをすべてドキュメントに含むようにインデックスを設定しているなら、データベースへのアクセスをしない方が高速になる
load
を false
に設定することで検索サーバー(この場合は 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
でドキュメントの追加や更新が実行されるので注意
無効にしたり、ジョブで非同期で実行できたり簡単に設定は変えられる
Searchkick::ImportError
処理の効率化のため、 load
を false
にして検索サーバー側に必要なデータをすべて保持しておくという方法を取ると、検索対象となる項目以外のデータも置くことになる
例えば、ステータスや画像のパスなどをドキュメントに含むようにするが、画像のパスなどは検索処理の際に値を考慮するようなデータではない
このような設定にした場合、必要な設定を省くと以下のようなエラーとなる
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