セマンティック検索：検索精度向上のテクニック ～カレーレシピデータを使った備忘録～

Qiitaで「セマンティック検索：検索精度向上のテクニック」という記事を公開しました。カレーレシピデータを例に、クエリ拡張、埋め込み次元削減、ハイブリッド検索の3つの手法を解説しています。実装コード付きなので、ぜひ試してみてください。

## はじめに

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/9d7a56d0-4d1a-4077-a5f5-89500e01ab28.png)

セマンティック検索は、単純なキーワードマッチングを超え、テキストの「意味」に基づいて検索を行う技術です。前回の記事では基本概念について解説しました。今回は、セマンティック検索の精度を向上させるための具体的なテクニックに焦点を当て、Google Colabを使った実践例をカレーレシピデータを題材に紹介します。

## 環境準備

まずは必要なライブラリと日本語処理のための環境を整えましょう。以下のコードをGoogle Colab上で実行してください。

```python
# 必要なライブラリのインストール
!pip install transformers fugashi unidic-lite torch numpy matplotlib scikit-learn pandas

# 日本語フォントと日本語処理ツールのインストール
!apt-get install -y fonts-noto-cjk fonts-noto-cjk-extra
!apt-get install -y mecab mecab-ipadic-utf8 libmecab-dev
!pip install japanize-matplotlib

# MeCabの設定ファイルの存在確認とインストール
!ln -s /etc/mecabrc /usr/local/etc/mecabrc 2>/dev/null || true
import japanize_matplotlib  # 日本語フォントの設定
```

## 目次
1. クエリ拡張と言い換え処理
2. 埋め込み次元の削減と影響
3. ハイブリッド検索（キーワード＋意味検索）
4. まとめ

## 1. クエリ拡張と言い換え処理

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/88a7de77-9a7b-4979-8bc8-abcdc839d0d7.png)

### クエリ拡張とは？

クエリ拡張（Query Expansion）は、ユーザーの検索クエリを自動的に拡張・補完することで、検索精度を向上させる技術です。例えば、ユーザーが「辛いカレー」と検索した場合、システムは自動的に「スパイシーカレー」「激辛カレー」「唐辛子を使ったカレー」などの関連語句を追加して検索範囲を広げます。

### 言い換え処理とは？

言い換え処理（Paraphrasing）は、同じ意味を持つ別の表現形式に変換することで、表現の多様性に対応する技術です。例えば「子供向けのカレー」というクエリは「マイルドなカレー」「優しい味のカレー」などに言い換えることができます。

### Google Colabでの実装例

```python
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import pandas as pd

# モデルのロード
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

# サンプルカレーレシピデータ
curry_recipes = [
    "バターチキンカレー：鶏肉、トマト、バター、生クリームを使った濃厚な北インドカレー",
    "ポークカレー：豚肉と玉ねぎのシンプルな和風カレー",
    "グリーンカレー：ココナッツミルクとパクチーの爽やかなタイカレー",
    "キーマカレー：挽き肉とスパイスがたっぷりの刺激的なカレー",
    "野菜カレー：季節の野菜をたっぷり使ったヘルシーカレー",
    "ビーフカレー：牛肉の旨味が溶け込んだ本格的なカレー",
    "スープカレー：野菜と肉の旨味たっぷりの札幌発祥のカレー",
    "ドライカレー：水分が少なくスパイシーな挽き肉カレー",
    "チキンカレー：鶏肉の柔らかさが特徴的なスタンダードカレー",
    "シーフードカレー：海の幸の旨味が凝縮した贅沢なカレー"
]

# レシピの埋め込みを作成
recipe_embeddings = model.encode(curry_recipes)

# オリジナルクエリ
original_query = "辛いカレーが食べたい"
original_query_embedding = model.encode([original_query])[0]

# クエリ拡張のためのバリエーション
expanded_queries = [
    "辛いカレーが食べたい",
    "スパイシーなカレーを探している",
    "刺激的な味のカレーレシピ",
    "激辛カレーのレシピが知りたい",
    "辛口で香辛料の効いたカレー"
]

# 拡張クエリの埋め込みを作成
expanded_query_embeddings = model.encode(expanded_queries)

# 拡張クエリの平均埋め込みを計算
avg_expanded_query_embedding = np.mean(expanded_query_embeddings, axis=0)

# オリジナルクエリと拡張クエリの検索結果を比較
original_similarities = cosine_similarity([original_query_embedding], recipe_embeddings)[0]
expanded_similarities = cosine_similarity([avg_expanded_query_embedding], recipe_embeddings)[0]

# 結果の表示
results_df = pd.DataFrame({
    'レシピ': curry_recipes,
    'オリジナルクエリの類似度': original_similarities,
    '拡張クエリの類似度': expanded_similarities
})

print("=== クエリ拡張前後の検索精度比較 ===")
results_df.sort_values('拡張クエリの類似度', ascending=False, inplace=True)
print(results_df)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/14c79094-b3c9-4d4d-9c43-87df5b344911.png)


### 実行結果と解説

上記のコードを実行すると、オリジナルのクエリ「辛いカレーが食べたい」と、それを拡張したクエリ群による検索結果の違いが分かります。拡張クエリを使用することで、「キーマカレー」や「ドライカレー」などの辛さに関連するレシピの類似度スコアが向上し、より多様なレシピが上位に表示されるようになります。

### 実践的なクエリ拡張テクニック

クエリ拡張の主な方法は以下の通りです：
- 同義語辞書であらかじめ定義した言い換えを利用
- Word2Vec/GloVeで類似語をベクトルから取得
- LLM（例：GPT-4）で関連クエリを自動生成
- 検索ログから実際の検索傾向を学習

以下はLLMを利用したクエリ拡張の実装例です：

```python
# OpenAI APIを利用したクエリ拡張
from google.colab import userdata

import openai

# 新しいクライアント初期化方式
client = openai.OpenAI(api_key=userdata.get('OPENAI_API_KEY'))

def expand_query_with_llm(query, n=5):
    prompt = f"""
    次の検索クエリに対して、意味的に類似した別の表現を{n}個生成してください：
    「{query}」
    
    出力は改行区切りのテキストのみとし、余計な説明等は含めないでください。
    """

    response = client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "system", "content": "あなたは検索クエリの拡張を支援するAIです。"},
            {"role": "user", "content": prompt}
        ],
        temperature=0.7
    )

    expanded_queries = [query]  # オリジナルクエリも含める
    expanded_queries.extend(response.choices[0].message.content.strip().split('\n'))
    return expanded_queries

# 使用例
query = "子供が喜ぶカレー"
expanded_queries = expand_query_with_llm(query)
print(expanded_queries)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/683e3fd5-50f9-4c0c-b4ea-7398e25c65c0.png)

## 2. 埋め込み次元の削減と影響

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/4b21490a-6b34-4fec-b2e1-aa56334fb94a.png)

### 埋め込み次元削減の目的

セマンティック検索では、テキストを高次元のベクトルに変換して類似度を計算します。低次元に圧縮することで次の利点があります：

- 類似度計算が高速になる
- 保存するデータ量が減る
- ノイズが減り、意味の精度が向上する可能性がある

### 次元削減手法

代表的な次元削減手法には以下があります：

- PCA：最も一般的な線形手法
- t-SNE：可視化に適した非線形手法
- UMAP：局所性と大域構造を保つ手法
- オートエンコーダ：ニューラルネットによる非線形手法

### Google Colabでの実装例

```python
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.decomposition import PCA
from sklearn.metrics.pairwise import cosine_similarity
import matplotlib.pyplot as plt
import pandas as pd

# モデルのロード
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

# カレーレシピデータ（前述のものを再利用）
# recipe_embeddings にはすでに埋め込みベクトルが格納されていると仮定

# 次元数を変えてPCAで削減
# 注意: PCAの次元数はデータの最小次元（サンプル数または特徴数）を超えられない
# レシピが10個しかないので、次元数は最大10まで
dimensions = [10, 8, 6, 4, 2]
similarity_scores = []

query = "本格的なインドカレー"
query_embedding = model.encode([query])[0]

# 元の埋め込み次元を確認
original_dim = recipe_embeddings.shape[1]
print(f"元の埋め込み次元数: {original_dim}, レシピ数: {len(curry_recipes)}")

for dim in dimensions:
    # PCAで次元削減
    pca = PCA(n_components=dim)
    reduced_recipe_embeddings = pca.fit_transform(recipe_embeddings)
    reduced_query_embedding = pca.transform([query_embedding])[0]
    
    # 類似度計算
    similarities = cosine_similarity([reduced_query_embedding], reduced_recipe_embeddings)[0]
    
    # 最も類似度の高いレシピとそのスコアを保存
    top_idx = np.argmax(similarities)
    similarity_scores.append((dim, similarities[top_idx], pca.explained_variance_ratio_.sum()))

# 結果のプロット
df = pd.DataFrame(similarity_scores, columns=['次元数', '最大類似度', '累積寄与率'])

plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)
plt.plot(df['次元数'], df['最大類似度'], marker='o')
plt.xlabel('次元数')
plt.ylabel('最大類似度')
plt.title('次元数と検索精度の関係')
plt.grid(True)
plt.xticks(dimensions)  # 実際の次元数を表示

plt.subplot(1, 2, 2)
plt.plot(df['次元数'], df['累積寄与率'], marker='o')
plt.xlabel('次元数')
plt.ylabel('累積寄与率')
plt.title('次元数と情報保持率の関係')
plt.grid(True)
plt.xticks(dimensions)  # 実際の次元数を表示

plt.tight_layout()
plt.show()

# 2次元に削減して可視化
pca = PCA(n_components=2)
reduced_embeddings_2d = pca.fit_transform(recipe_embeddings)
reduced_query_2d = pca.transform([query_embedding])[0]

plt.figure(figsize=(10, 8))
plt.scatter(reduced_embeddings_2d[:, 0], reduced_embeddings_2d[:, 1], alpha=0.7)
plt.scatter(reduced_query_2d[0], reduced_query_2d[1], color='red', marker='X', s=100)

for i, recipe in enumerate(curry_recipes):
    recipe_name = recipe.split('：')[0]
    plt.annotate(recipe_name, (reduced_embeddings_2d[i, 0], reduced_embeddings_2d[i, 1]))

plt.title('カレーレシピの2次元マッピング')
plt.grid(True)
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/1983a1d7-52a1-401e-bdb4-64d74411c20b.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/bd91b672-fc92-498b-a40f-d4858a2836ea.png)

### 実行結果と解説

このコードでは、PCAを用いて埋め込みを様々な次元に削減し、検索精度や情報保持率への影響を検証しています。

要点：

- PCAの次元数はサンプル数（例では10）を超えられない
- サンプル数が多ければ高次元も検討可能
- 次元を減らすと計算効率は上がるが、情報損失のリスクがある
- 累積寄与率が90%以上なら精度は大きく落ちにくい
- 2次元など極端な削減は可視化に有効だが、精度が大幅に低下する

### 実践的な次元削減の適用方法

- 累積寄与率95%以上の次元数を選ぶと効果的
- 新データごとに再計算せず、既存PCAに投影するのが効率的
- 分野に特化した埋め込みモデル（例：料理レシピ用）を使うと精度向上が期待できる

## 3. ハイブリッド検索（キーワード＋意味検索）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/3bb9143c-c8fb-41b9-90af-177e759f1048.png)

### ハイブリッド検索の必要性

セマンティック検索は意味を捉えるのに優れていますが、以下のような場面ではキーワード検索が有効です：

- 専門用語や固有名詞の完全一致
- 珍しい表現など学習データに少ないケース
- 計算コストを抑えたい場合

セマンティックとキーワードを併用するハイブリッド検索で、それぞれの長所を活かせます。

### ハイブリッド検索の実装方法

主なハイブリッド検索の方法は以下の3つです：

- スコア結合：両検索のスコアを重み付けして合算
- 段階的フィルタ：キーワードで絞り、セマンティックで順位付け
- ブースト：セマンティック結果に特定キーワードの有無でスコア補正

### Google Colabでの実装例

```python
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.feature_extraction.text import TfidfVectorizer
import pandas as pd

# モデルのロード
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

# カレーレシピデータ（前述のものを再利用）

# セマンティック検索用の埋め込み
semantic_embeddings = model.encode(curry_recipes)

# キーワード検索用のTF-IDF行列
tfidf = TfidfVectorizer()
keyword_matrix = tfidf.fit_transform(curry_recipes)

# 検索クエリ
query = "ココナッツミルクを使った本格的なカレー"
query_semantic_embedding = model.encode([query])[0]
query_keyword_vector = tfidf.transform([query])

# セマンティック検索のスコア計算
semantic_scores = cosine_similarity([query_semantic_embedding], semantic_embeddings)[0]

# キーワード検索のスコア計算
keyword_scores = cosine_similarity(query_keyword_vector, keyword_matrix)[0]

# ハイブリッド検索（重み付け結合）
alpha = 0.7  # セマンティック検索の重み
beta = 0.3   # キーワード検索の重み
hybrid_scores = alpha * semantic_scores + beta * keyword_scores

# 結果の表示
results_df = pd.DataFrame({
    'レシピ': curry_recipes,
    'セマンティックスコア': semantic_scores,
    'キーワードスコア': keyword_scores,
    'ハイブリッドスコア': hybrid_scores
})

print("=== ハイブリッド検索の結果比較 ===")
print("\n【セマンティック検索の上位3件】")
print(results_df.sort_values('セマンティックスコア', ascending=False).head(3)[['レシピ', 'セマンティックスコア']])

print("\n【キーワード検索の上位3件】")
print(results_df.sort_values('キーワードスコア', ascending=False).head(3)[['レシピ', 'キーワードスコア']])

print("\n【ハイブリッド検索の上位3件】")
print(results_df.sort_values('ハイブリッドスコア', ascending=False).head(3)[['レシピ', 'ハイブリッドスコア']])

# 重みによる結果の変化を調査
alphas = [0, 0.2, 0.4, 0.6, 0.8, 1.0]
top_results = []

for alpha in alphas:
    beta = 1 - alpha
    hybrid_scores = alpha * semantic_scores + beta * keyword_scores
    top_idx = np.argmax(hybrid_scores)
    top_results.append((alpha, curry_recipes[top_idx].split('：')[0], hybrid_scores[top_idx]))

# 結果表示
weight_df = pd.DataFrame(top_results, columns=['セマンティック重み', 'トップレシピ', 'スコア'])
print("\n【重みを変えた場合のトップ結果の変化】")
print(weight_df)
```

query = "ココナッツミルクを使った本格的なカレー"
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/085d2b43-1f68-48af-84ea-c16392a98b2d.png)


### ハイブリッド検索の最適化テクニック

1. 重み付けの自動調整: トレーニングデータを使って最適な重みを学習
2. コンテキスト依存型重み付け: クエリのタイプに応じて重みを動的に変更
3. ユーザーフィードバックの活用: 検索結果へのユーザー反応から重みを微調整

```python
# コンテキスト依存型重み付けの簡易実装
def get_adaptive_weights(query):
    # クエリ長が短い場合はキーワード検索を重視
    if len(query.split()) <= 3:
        return 0.3, 0.7  # セマンティック重み, キーワード重み
    
    # 専門用語や固有名詞が含まれる場合はキーワード検索を重視
    special_terms = ["バターチキン", "グリーンカレー", "キーマ", "ココナッツミルク"]
    if any(term in query for term in special_terms):
        return 0.4, 0.6
    
    # それ以外はセマンティック検索を重視
    return 0.8, 0.2

# 実装例
test_queries = [
    "辛いカレー",
    "バターチキンカレーのレシピ",
    "子供も大人も楽しめるスパイシーだけど優しい味わいのカレー"
]

for query in test_queries:
    alpha, beta = get_adaptive_weights(query)
    print(f"クエリ: {query}")
    print(f"セマンティック重み: {alpha}, キーワード重み: {beta}")
    print("---")
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/1fd829a2-ee60-4284-8cae-0e4b0f3467db.png)
例があまりよくなかった。時間ぎれ・・。

## 4. まとめ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/9bcc65a2-4e6c-4e41-965d-886565581e0c.png)

セマンティック検索の精度向上に有効なポイントは以下の3つです：

- クエリ拡張：言い換えや類義語で検索範囲を広げる

- 次元削減：効率を保ちつつ精度を維持

- ハイブリッド検索：キーワード検索と組み合わせて精度を向上

これらをデータ特性やユーザー行動に応じて適切に組み合わせることで、様々な分野で高精度な検索が可能になります。

## 参考資料

- [Sentence-Transformers公式ドキュメント](https://www.sbert.net/)
- [Pinecone: Hybrid Search Tutorial](https://www.pinecone.io/learn/hybrid-search/)
- [Hugging Face: Query Expansion with LLMs](https://huggingface.co/blog/)