Claudeで検索ログの0件クエリを分析 — 3分類で施策に落とす手順
サイト内検索のヒット0件クエリを、表記ゆれ・辞書不足・コンテンツ欠落に分けて施策へつなぐ手順です。claude -pとJSON Schemaで分類し、再検索で効果を測ります。
サイト内検索のログを開くと、ヒット0件のクエリが上位に並んでいます。「ログイン できない」「クラウド」「請求書 再発行」。どれも利用者が探していたのに、何も返せなかった語です。
0件の理由は一つではありません。表記が揺れているだけのもの、同じ意味の別の語を知らないもの、そもそも答えるページが無いものが混ざっています。理由ごとに直し方が違うので、先に分類しないと施策が決まりません。
この記事は、0件クエリを集計し、claude -p で3つに分類し、それぞれの施策に落とすまでの手順です。最後は分類の見立てを再検索で確かめます。
0件の原因は3つに分かれ、直し方が別になる
0件クエリを眺めて「全部シノニムを足せばよい」と考えると、半分は空振りします。原因ごとに、直す場所が違うからです。
0件クエリの3分類と直す場所
表記ゆれ
全角と半角、大文字と小文字、長音の有無、空白の入れ方などの違いです。文書側に同じ語があるのに、文字の形が合わず当たりません。直す場所は検索エンジンの正規化設定です。
辞書不足
利用者の語と文書の語が別の言葉です。略称、俗称、英語と日本語の言い換え、社内用語が典型です。直す場所は同義語辞書と、形態素解析のユーザー辞書です。
コンテンツ欠落
探している情報がそもそも無い状態です。検索設定をいくら直しても0件のままなので、直す場所は記事やFAQの追加になります。
この3つに「対象外」を足します。誤入力、意味の取れない文字列、ボットの投入語、個人情報の混入などで、施策の対象にしない行です。
分類の順序にも意味があります。表記ゆれは機械的に直せて副作用が小さいので最初に拾い、次に辞書、最後にコンテンツと進むと、人手のかかる施策が後ろに残ります。
集計は先にスクリプトで済ませ、Claudeには一意クエリだけ渡す
件数を数える作業を言語モデルに任せる理由はありません。合計や順位は、スクリプトで決定的に出せます。Claudeに渡すのは、重複を除いた一意クエリと、その検索回数です。
次のスクリプトは、検索ログのCSVから0件のクエリだけを取り出し、正規化のうえで集計して、頻度順に並べます。列名はログの形式に合わせて読み替えてください。
import csv, re, unicodedata
from collections import Counter
MASKS = [
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "<EMAIL>"),
(re.compile(r"\d{2,4}-\d{2,4}-\d{3,4}"), "<TEL>"),
(re.compile(r"\d{7,}"), "<NUM>"),
]
def mask(q: str) -> str:
for pattern, label in MASKS:
q = pattern.sub(label, q)
return q
counts = Counter()
with open("search_log.csv", encoding="utf-8", newline="") as f:
for row in csv.DictReader(f):
if row["hits"] != "0":
continue
q = mask(row["query"].strip())
# 集計用の鍵だけ NFKC と小文字化で寄せる。元の表記は別に残す
key = unicodedata.normalize("NFKC", q).lower()
counts[key] += 1
with open("zero_hit_queries.tsv", "w", encoding="utf-8") as out:
for key, n in counts.most_common(300):
out.write(f"{n}\t{key}\n")ここで注意したいのは、集計用の鍵を正規化すると、表記ゆれの証拠が消えることです。全角の「API」と半角の「API」が同じ鍵に潰れるため、元の表記は別の列に残しておきます。表記ゆれ分類の根拠は、元の表記にあります。
また、メールアドレスや電話番号は、Claudeに渡す前に置換します。検索窓には、問い合わせ文をそのまま貼る利用者がいるからです。正規表現で拾えない氏名は残りうるため、後述のとおり、渡す前の目視確認も1回挟みます。
上位300件に絞るのは、長い尾を追っても施策の効きが小さいためです。件数は、ログの規模に合わせて変えてかまいません。
分類基準をJSON Schemaに固定する
Claudeへの指示は、自由文より、出力の形を縛るほうが安定します。claude -p は --json-schema を受け取り、スキーマに沿った結果を structured_output フィールドに返します。
分類のラベルは英語のスラッグにします。構造化出力では、文字列の enum の大文字小文字が保証されないという注意書きがあるためです。日本語ラベルでも動きますが、後段のスクリプトで比較するなら、小文字の英語に固定しておくと扱いやすくなります。
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"query": { "type": "string" },
"category": {
"type": "string",
"enum": ["variant", "synonym", "missing", "out_of_scope"]
},
"matched_title": { "type": ["string", "null"] },
"action": { "type": "string" }
},
"required": ["query", "category", "matched_title", "action"],
"additionalProperties": false
}
}
},
"required": ["items"],
"additionalProperties": false
}variant が表記ゆれ、synonym が辞書不足、missing がコンテンツ欠落、out_of_scope が対象外です。matched_title には、判断の根拠になった既存の文書タイトルを入れさせます。根拠が空なら、missing と判断した理由も空になるので、人が見直すときの目印になります。
数値の範囲や文字数の上限は、スキーマではなく説明文で伝えます。構造化出力は、JSON Schemaの一部しか使えないためです。minLength のような文字列の制約は、サポート外として挙げられています。
claude -pで分類を実行する
文書タイトルの一覧を titles.txt に1行1件で用意し、クエリはパイプで渡します。タイトルの一覧は Read だけ許可して読ませます。
claude --bare -p "$(cat prompt.txt)" \
--allowedTools "Read" \
--output-format json \
--json-schema "$(cat schema.json)" \
< zero_hit_queries.tsv > result.jsonprompt.txt には、分類基準と判断の手順を書きます。例えば次のような内容です。
標準入力は「検索回数<TAB>クエリ」の行です。titles.txt を読み、
各クエリを次の基準で1つに分類してください。
- variant: 全半角・大小文字・長音・空白を揃えれば titles.txt の語に当たる
- synonym: 別の言い方で、titles.txt に同じ意味の文書がある
- missing: titles.txt に答える文書が見当たらない
- out_of_scope: 誤入力・意味不明・個人情報
matched_title には根拠にしたタイトルを入れ、無ければ null にします。
action には、直す内容を30字以内で書いてください。-pは非対話で実行するフラグで、成功すると終了コード0、失敗すると0以外で終わります。スクリプトの分岐に使えます--bareは、フックやMCPサーバー、CLAUDE.mdの自動読み込みを省きます。どのマシンでも同じ結果になるので、スクリプト用途の推奨です。ただし--bareではサブスクリプションのログインを使わないため、ANTHROPIC_API_KEYを環境変数に置く必要があります--output-format jsonを付けると、結果にセッション情報とともにtotal_cost_usdが入ります。件数を増やす前に、1回分の費用を見積もれます- パイプで渡せる標準入力は10MBまでです。上位300件のTSVなら、まず問題になりません
結果は jq で取り出します。スキーマに合わない終わり方をした場合は structured_output が付かないので、先に存在を確かめます。
jq -e '.structured_output.items | length > 0' result.json > /dev/null \
|| echo "structured_output がないか、items が空です。再実行します"
jq '.structured_output.items' result.json > classified.json
jq -r '.structured_output.items[] | [.category, .query] | @tsv' \
result.json | sort | uniq -c | sort -rn | headjq -e は、判定結果が false か null のとき0以外の終了コードを返します。length > 0 を付けているので、structured_output がないときや items が空配列のときもここで止まります。止まったら、同じ入力で再実行します。
分類結果は、件数の多い順に人が抜き取って確かめる
出力は、次のような形で返ってきます。以下は形を示すための架空のデータです。
{
"items": [
{"query": "アカウント 削除", "category": "variant",
"matched_title": "アカウント削除の手順", "action": "半角カナを全角へ正規化"},
{"query": "請求書 再発行", "category": "synonym",
"matched_title": "領収書の再発行", "action": "請求書と領収書を同義語に"},
{"query": "api 料金 上限", "category": "missing",
"matched_title": null, "action": "上限の説明ページを新設"}
]
}分類は、見立てであって判定ではありません。次の手順で人が抜き取ります。
- 3分類のそれぞれから、検索回数の上位20件を取り出します
matched_titleに書かれた文書を実際に開き、本当にそのクエリの答えになっているかを確認しますmissingは特に念入りに見ます。タイトルの一覧に無くても、本文には書いてあることがあるからです
missing の取り違えは、後で高くつきます。実在する記事を「無い」と判断して新規に作ると、同じ内容が二つできてしまいます。missing に分けた語は、サイト内を全文検索して、本当に無いことを確かめてから起票します。
3分類を、検索エンジンの設定とネタ帳に落とす
分類ごとの施策を、Elasticsearchの設定例で示します。OpenSearchや他の検索基盤でも考え方は同じですが、設定名は製品ごとに違います。
表記ゆれは文字の正規化で直す
variant が多いときは、インデックスの文字正規化を見直します。ElasticsearchのICU正規化文字フィルター icu_normalizer は、追加の設定なしで全インデックスから使えます。name の既定値は nfkc_cf で、NFKCの正規化に大文字小文字の折りたたみを加えたものです。
{
"settings": {
"index": {
"analysis": {
"analyzer": {
"ja_normalized": {
"tokenizer": "kuromoji_tokenizer",
"char_filter": ["icu_normalizer"]
}
}
}
}
}
}これで、半角カナの「アカウント」と全角の「アカウント」が、同じ語として照合されます。icu_normalizer を使うには、ICU解析プラグインが必要です。
辞書不足は同義語とユーザー辞書で直す
synonym は、二つに割れます。語の言い換えは同義語、語の区切り方がおかしいときはユーザー辞書です。
同義語のフィルターには updateable という設定があり、既定は false です。true にすると、同義語ファイルを書き換えたあと検索アナライザーをリロードするだけで反映でき、再インデックスは要りません。リロードは _reload_search_analyzers APIで行います。ただし、検索時のアナライザー専用で、インデックス時には使えません。
{
"filter": {
"ja_synonyms": {
"type": "synonym_graph",
"synonyms_path": "analysis/synonyms_ja.txt",
"updateable": true
}
}
}同義語ファイルには、分類結果の action をそのまま写せます。
請求書, 領収書
ログイン できない, ログインできない, サインイン 失敗語の区切りが原因のときは、kuromojiのユーザー辞書を使います。標準ではMeCab-IPADIC辞書を使い、user_dictionary で語を追加できます。書式は、表層形、分割後の語、読み、品詞をカンマで区切ります。
社内ポータル,社内 ポータル,シャナイ ポータル,カスタム名詞インラインで書くなら、トークナイザーの user_dictionary_rules にも同じ形式の文字列を渡せます。
コンテンツ欠落は件数順の起票リストにする
missing は、検索設定では直りません。検索回数の多い順に並べ、記事やFAQの起票リストにします。jq で missing だけを取り出し、action を見出し案として使えます。
jq -r '.structured_output.items[]
| select(.category=="missing") | [.query, .action] | @tsv' \
result.json > backlog.tsv回数が多く、似た語が連なっている missing は、一つのページにまとめて答えられる場合があります。「api料金 上限」「api利用制限」「apiレート」が並ぶなら、起票は3本ではなく1本です。
設定を変えたら、0件クエリを再検索して効果を測る
分類が妥当だったかどうかは、ラベルの正誤ではなく、直したあとにヒットするかで判断します。設定を反映したステージングのインデックスに、元の0件クエリを投げ直し、ヒットが出た割合を数えます。
def search_hits(query: str) -> int:
"""ステージング環境の検索APIを呼び、ヒット件数を返す。"""
...
import json
items = json.load(open("classified.json"))
resolved = {"variant": 0, "synonym": 0}
total = {"variant": 0, "synonym": 0}
for item in items:
cat = item["category"]
if cat in total:
total[cat] += 1
if search_hits(item["query"]) > 0:
resolved[cat] += 1
for cat in total:
print(cat, resolved[cat], "/", total[cat])variant の解消率が低いなら、分類が外れているか、正規化の設定が効いていません。synonym で0件が残る語は、同義語の登録漏れか、同義語の相手が文書側に無い可能性があります。後者は実質的に missing なので、起票リストへ移します。
ヒットが出ただけでは、良い結果とは限りません。再検索で出た上位3件に目を通し、意図に合うかを抜き取りで見てください。同義語を広く登録しすぎると、無関係な文書が上位に出るようになります。
つまずきやすい点
- 0件の定義が揺れる。ヒット数0のほかに、検索エラー、絞り込み条件による0件、クリックされなかった検索が混ざっていないかを、集計の前に確かめます。絞り込みで0件になった行は、語の問題ではないので外します
- 同じ人の言い直しを数えすぎる。1人が「アカウント 削除」を数回打ち直しているだけで、件数が膨らみます。セッション単位で重複を除くと、順位が安定します
- 個人情報の残り。正規表現は氏名を拾えません。Claudeに渡す前に、上位300件を一度目で通すのが確実です
- 分類を一度で信じる。同じ入力を2回流して分類が割れた行は、人が見る対象に回します。割れた行そのものが、判断の難しい語の目印です
まとめ
0件クエリの分析は、分類そのものより、分類のあとに何を直すかを決める作業です。集計と再検索をスクリプトで固定し、判断が要る真ん中の分類だけをClaudeに任せると、人の確認は抜き取りで足ります。
最初に回すなら、variant の正規化です。副作用が小さく、効果が再検索ですぐ見えます。synonym は登録しすぎると精度が落ちるので、検索回数の多い語から順に入れます。missing は、記事の起票リストとして別に回します。
同じ「自由記述を決めた基準で分類して件数表にする」型の応用は、退職面談の離職理由を分類するClaude手順や、検品ログCSVから誤出荷の原因を分類する手順にもあります。ログ側のクエリを書く部分は、CloudWatch Logsのクエリを書かせる手順と組み合わせられます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。