Claude Media
AlgoliaからElasticsearchへ移行する — Claude Codeで設定対応と結果差分を検証

AlgoliaからElasticsearchへ移行する — Claude Codeで設定対応と結果差分を検証

Algoliaのranking・facet・synonymsの設定をElasticsearchのQuery DSLへ対応付け、同じクエリ集の結果差分で検証する手順を、Claude Codeのサブエージェント構成と一緒に示します。

AlgoliaからElasticsearchへ検索を移すとき、一番壊れやすいのは「検索結果の並び」です。APIの呼び出しは書き換えれば動きますが、Algoliaは順位を決着方式で決め、Elasticsearchはスコアを合成して決めます。同じ設定名を訳しても、同じ順位にはなりません。

この記事は、Algoliaのindex設定を棚卸しし、Query DSLへ対応付け、同じクエリ集の結果差分で確かめる流れを、Claude Codeに任せる部分と人が決める部分に分けて書きます。差分の取り方とサブエージェントの設定例まで置いています。

先に押さえる違い — 決着方式とスコア合成方式

Algoliaの順位は、複数の基準を順に当てて同点を割っていく方式です。基準にはTypo、Geo、Words、Filters、Proximity、Attribute、Exact、そして最後のCustomがあります。custom rankingは、前の基準で同点のときにだけ働く最後の基準です。popularityやratingのような数値や真偽値の属性を、降順か昇順で指定します。

Elasticsearchは、クエリが出したスコアに関数のスコアを合成します。function_scoreのscore_modeで関数同士の合成方法を、boost_modeでクエリスコアとの合成方法を決めます。boost_modeの既定はmultiplyで、replaceならクエリスコアを無視します。

人気度を「同点の決着」に使っていたAlgoliaの設定を、そのままfield_value_factorで掛け合わせると、人気の高い記事が関連度の低いまま上位に来ることがあります。ここは翻訳作業ではなく設計判断です。Claude Codeに「訳して」と頼むだけで済ませず、どの方式で再現するかを人が先に決めます。

設定の対応表 — 1対1で移せるものと移せないもの

Algoliaの設定Elasticsearch側の候補注意点
searchableAttributesElasticsearch側の候補multi_matchのfieldsと^による重み注意点順序の優先度が数値の重みに変わる
customRankingElasticsearch側の候補function_scoreのfield_value_factor注意点同点時のみ→常時加点に意味が変わる
attributesForFacetingElasticsearch側の候補keywordフィールドとterms集計注意点取得件数の既定値が違う
facetFilters(OR条件)Elasticsearch側の候補post_filterとfilter集計注意点絞り込み後も他の値の件数を出す設計
synonymsElasticsearch側の候補synonym_graphトークンフィルタ注意点同義語セットの更新方法を決める
typo toleranceElasticsearch側の候補fuzziness注意点数が揃っても順位は揃わない

searchableAttributesは、Algoliaでは並び順が優先度で、同じ重要度にしたい属性はカンマで同じ階層に並べます。Elasticsearchのmulti_matchでは"subject^3"のように属性ごとに数値の重みを付け、typeの既定はbest_fieldsで、最もよく当たったフィールドのスコアを採用します。tie_breakerを指定すると、他のフィールドのスコアも一定割合で足されます。

順位の再現に近づけたいときは、まず重みを粗く置き、結果差分を見てから調整するほうが安全です。属性の優先度を直接スコアに変換する式はありません。

facetは「件数の出し方」まで揃える

Algoliaのfacetは、attributesForFacetingに宣言した属性ごとに値と件数が検索結果と一緒に返ります。取得できる値の既定は属性ごとに100件で、maxValuesPerFacetで最大1,000件まで増やせます。巨大なindexでは件数が近似になることがあり、exhaustiveFacetsCountで厳密かどうかが分かります。

Elasticsearchのterms集計は、既定で件数の多い上位10個を返します。返す数はsizeで増やします。ここを変えずに移すと、画面のfacet候補が10個で切れます。件数も各シャードの上位候補から合算するため近似になり、show_term_doc_count_errorをtrueにすると誤差の上限が分かります。

OR条件のfacetは特に違います。Algolia側では、OR条件のときの件数の見せ方はInstantSearchライブラリが担当します。Elasticsearchには、同様の件数を出すための構造がQuery DSLの中にあります。post_filterは検索結果(ヒット)だけを絞り、集計は絞り込む前の集合で計算します。

{
  "query": { "bool": { "filter": { "term": { "brand": "gucci" } } } },
  "aggs": { "colors": { "terms": { "field": "color" } } },
  "post_filter": { "term": { "color": "red" } }
}

この形は、Elasticsearchのドキュメントにある絞り込みの例に沿っています。boolのfilterは検索結果と集計の両方に効くので、facetの候補を残したい属性の絞り込みだけをpost_filterに移します。

Claude Codeに任せる範囲を決める

移行は、棚卸し、対応付け、クエリ集の用意、差分の集計、調整の五つに分かれます。このうち「訳案を作る」「差分を集計して読む」はClaude Codeに向きます。「どの順位方式で再現するか」「許容する差分の大きさ」は、検索の利用実態を知っている人が決めます。

Claude Codeのサブエージェントは、.claude/agents/にMarkdownファイルとして置きます。プロジェクト直下に置けばリポジトリに含めてチームで共有でき、~/.claude/agents/に置けば自分の全プロジェクトで使えます。必須のフロントマターはnameとdescriptionだけです。toolsで使えるツールを絞り、modelでモデルも選べます。

差分を読むだけの役なら、書き込み系のツールを外します。ドキュメントのcode-reviewerの例も、tools: Read, Grep, Glob, BashでEditとWriteを持たせない構成です。

---
name: search-diff-reviewer
description: Algoliaと新しい検索基盤の結果差分を読み、順位差の原因を設定の項目単位で説明する。移行の差分検証で使う。
tools: Read, Grep, Glob, Bash
model: inherit
---
 
diff-report.jsonを読み、次の順に報告する。
 
1. 上位10件の一致率が低いクエリを、低い順に10件挙げる
2. 各クエリについて、順位が入れ替わった文書の差を、
   searchableAttributesの重みかcustomRankingの扱いのどちらで
   説明できるか推定する。断定できない場合はそう書く
3. facet件数が食い違うクエリは、termsのsizeとpost_filterの有無を点検する
 
設定ファイルは変更しない。変更案は文章で示す。

descriptionは、Claudeがいつこのサブエージェントへ委任するかの判断材料になります。「差分検証で使う」のように使う場面まで書いておくと選ばれやすくなります。

CLAUDE.mdには、移行中に守る決まりを短く置きます。

# 検索移行の決まり
 
- 旧Algoliaの設定は migration/algolia-settings.json にあり、書き換えない
- 対応表は migration/mapping.md に1設定1行で書く。未確定の行は「未確定」と明記する
- 新しい重みを変えたら、必ず scripts/diff_results.py を再実行してから報告する
- 本番indexに書き込むコマンドは実行しない

サブエージェントの設計を観点別に分ける考え方は、design docを観点別サブエージェントでレビューする手順にもあります。読み取り専用で意見を返す役を作る点は共通です。

手順 — 棚卸しから結果差分まで

手順

移行を進める五つの段階

  1. 1

    Algoliaの設定を書き出して固定する

    index設定をJSONで保存し、リポジトリに入れます。以降の対応付けはこのファイルだけを根拠にします。

  2. 2

    対応表を一行ずつ作る

    searchableAttributes、customRanking、attributesForFaceting、synonyms、rulesの順に、Elasticsearch側の候補を並べます。意味が変わる行には「意味が変わる」と書きます。

  3. 3

    mappingとクエリを組み立てる

    facetにする属性はkeywordで持たせます。検索対象の属性はtextで持たせ、multi_matchのfieldsに重みを付けます。

  4. 4

    同じクエリ集を両方に投げる

    検索ログから代表的なクエリを抜き出します。件数の多いクエリだけでなく、ヒット0件のクエリや1語の短いクエリも混ぜます。

  5. 5

    差分を集計して重みを調整する

    一致率の低いクエリから原因を絞り、重みを一つずつ変えて再実行します。複数を同時に変えると、効いた変更が分からなくなります。

mappingでは、同義語の扱いも先に決めます。synonym_graphトークンフィルタは複数語の同義語を正しく扱えますが、同じフィルタの中に複数の同義語セットを入れる使い方が勧められています。synonym_graphを連鎖させると、検索時の挙動が不定になることがあるためです。同義語ファイルを更新するたびに検索用アナライザーを再読込したい場合は、updateableをtrueにします。ただし更新対象は検索時アナライザーに限られます。

rules(クエリごとの順位上書きや季節の販促)は、対応先が一対一では決まりません。Algolia側のrulesで何をしているかを列挙し、別に設計する項目として表に残してください。

差分を取る小さなスクリプト

Algoliaと新しい基盤の両方から、クエリごとの上位の文書IDを一行一件のJSONで書き出しておきます。比較は、検索クライアントの種類に依存しない形にします。

import json, sys
 
def load(path):
    with open(path, encoding="utf-8") as f:
        return {r["query"]: r["ids"] for r in map(json.loads, f)}
 
old, new = load(sys.argv[1]), load(sys.argv[2])
report = []
for q, old_ids in old.items():
    new_ids = new.get(q, [])
    k = min(10, len(old_ids))
    overlap = len(set(old_ids[:k]) & set(new_ids[:k])) / k if k else 1.0
    report.append({"query": q, "overlap_at_10": overlap,
                   "top1_same": bool(old_ids and new_ids and old_ids[0] == new_ids[0])})
report.sort(key=lambda r: r["overlap_at_10"])
json.dump(report, open("diff-report.json", "w"), ensure_ascii=False, indent=2)
python scripts/diff_results.py \
  results/algolia.jsonl results/elasticsearch.jsonl

上位10件の一致率と、先頭の1件が同じかどうかの二つが基本の指標です。一致率が低くても、入れ替わった文書の質が同等なら問題ない場合があります。数字を合格基準にせず、サブエージェントに原因の推定を書かせ、人が上から読んで判断します。

見落としやすい落とし穴

落とし穴

facetの属性名と値の大文字小文字が揃わない

Algoliaでは、attributesForFacetingに宣言する属性名は大文字小文字を区別します。一方でfacetFiltersとfiltersは、値の大文字小文字を区別しません。「Blue」と「blue」が同じfacet値として動いていたindexをそのままkeywordへ移すと、値が別々に数えられる可能性があります。keywordにはnormalizerパラメータがあるので、indexする前に正規化の方針を決めます。

落とし穴

facet候補が10個で切れる

terms集計のsizeの既定は10です。Algolia側で100件取れていた画面は、sizeを明示しないと候補が大きく減ります。移行直後に画面を見比べると最初に気づく差です。

もう一つ、クエリ集の偏りがあります。検索ログの上位だけで検証すると、頻出語では一致するのに、ロングテールで大きくずれることがあります。短いクエリ、複数語、ヒット0件になるクエリを意識的に足してください。

差分に出る理由の多くは、設定の写し間違いではなく、方式の違いに由来します。表の「意味が変わる」行を先に読み、そこに原因があるか見るのが近道です。

並列に回すときの上限

indexが複数あり、サブエージェントを並列に走らせる場合、Claude Codeは同時に動くサブエージェントが20に達していると、新しい起動を失敗させ、リトライしないよう伝えます。環境変数で上限を変えられますが、並列数を増やす前に、各サブエージェントが同じdiff-report.jsonを書き換えないよう、出力先をindexごとに分けておきます。

データ移行の対応表を作る方法そのものは、移行時の項目マッピング表の作り方に詳しくあります。ElasticsearchをClaude Codeから直接操作したい場合は、Elasticsearch MCPサーバーの使い方を先に見てください。非推奨化の経緯とAgent Builderへの移行が書かれています。

まとめ

Algoliaの設定名をQuery DSLへ並べ替えるだけでは、順位は再現できません。custom rankingは同点の決着、function_scoreは常時の加点という意味の違いが、差分の主な原因になります。

進め方は、設定をJSONで固定し、対応表の「意味が変わる行」を先に決め、同じクエリ集の一致率で重みを一つずつ動かすことです。Claude Codeには集計と原因の推定を任せ、再現方式と許容差は人が持ちます。読み取り専用のサブエージェントなら、本番indexを触らずに回せます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/sub-agents
  2. algolia.com/doc/guides/managing-results/must-do/custom-ranking
  3. algolia.com/doc/guides/managing-results/refine-results/faceting
  4. elastic.co/docs/reference/elasticsearch/rest-apis/filter-search-resul…
  5. elastic.co/docs/reference/query-languages/query-dsl/query-dsl-functio…
この記事を共有:XはてブLinkedIn