🔎

BigQueryのベクトル検索でJICFSカテゴリ推定精度を+35%改善した話

に公開

はじめに

WED株式会社でMLエンジニアをしています、ishi2kiです。

当社では、コンビニなどの特定小売チェーンの最新商品情報を定期的に取得しています。
取得した商品データにはJICFSカテゴリを付加しており、今回はそのカテゴリ推定の精度改善について紹介します。

従来のLUKEによるクラス分類モデルでの精度が 43% だったところを、BigQueryのベクトル検索を活用することで 78% まで改善することができました。

背景

JICFSカテゴリとは

JICFS (JAN Item Code File Service) は、日本で広く使われている商品分類体系です。
6桁のカテゴリコードが使われており、全部で2,679カテゴリあります。

例:

  • チョコレート: 130123
  • 歯ブラシ: 212103

商品データへのカテゴリ付与

当社では、コンビニ等のチェーンから商品情報を定期的に取得しています。
しかし、そこで得られる情報は商品名やJANコードなどの基本情報のみで、JICFSカテゴリは含まれていません。
そのため、取得した商品データに対してJICFSカテゴリを自動で付加する仕組みが必要です。

従来の手法:LUKEによるクラス分類

従来はLUKEを使ったクラス分類モデルでカテゴリを推定していました。

手法概要

日本語の事前学習済みモデル LUKE をベースに、ファインチューニングを行ったモデルです。
商品名をテキスト入力として受け取り、JICFSカテゴリのクラスラベルを出力します。

問題点

このアプローチにはいくつかの課題がありました。

1. 対応カテゴリ数の制限

JICFSのカテゴリ数は2,679カテゴリあります。全カテゴリをクラスとして扱うと、学習データが少ないカテゴリでは十分な精度が出ません。
そのため、頻出する数百カテゴリに絞って学習・推論を行っており、対応外のカテゴリに属する商品は正しく分類できないという問題がありました。

2. 表記の多様性への対応が困難

スクレイピングで取得する商品名は、チェーンによって表記が異なります。
例えば、同じ商品でも「チョコレート」「チョコ」「ちょこ」など様々な表記が存在します。
さらに、特にお菓子の商品名では、「柿の種」のようにお菓子以外のカテゴリ (この場合は農産カテゴリ) に誤分類されそうなものも多く存在します。
学習データに含まれていない表記の商品は精度が低くなる傾向がありました。

3. 学習データ不足

頻出カテゴリに絞っていても、一部のカテゴリは学習データが少なく、十分な精度を出すことができませんでした。

新手法:BigQueryのベクトル検索

これらの問題を解決するために、BigQueryのベクトル検索を使った新しいアプローチを採用しました。

アーキテクチャ概要

【事前準備】
商品マスタ (約600万件)

  エンベディング生成
  (ML.GENERATE_EMBEDDING)

ベクトルインデックス作成
     (IVF)

  エンベディングテーブル

【推論時】
  推定したい商品名

  エンベディング生成
  (ML.GENERATE_EMBEDDING)

   ベクトル検索
   (VECTOR_SEARCH)

 商品マスタから上位10件取得

   最頻カテゴリを選択

  JICFSカテゴリ

商品マスタをデータベースとして活用

当社では、JANコードと商品情報、JICFSカテゴリのひもづきを持つ商品マスタを保有しています。
このマスタは 約600万レコード あり、様々な表記の商品名とそのカテゴリが含まれています。

このマスタの商品名をGCPが提供するエンベディングモデルでベクトル化し、BigQueryに格納しています。

実装

1. 商品マスタのベクトル化

推論の事前準備として、商品マスタ全件の商品名をベクトル化しておきます。
これはマスタ更新時に実行する1回限りのバッチ処理です。

なお、ベクトル化に使用するモデルは事前に作成しておく必要があります。(参考)
今回はGoogleが提供しているgemini-embedding-001を使用しました。

CREATE OR REPLACE TABLE `your_project.dataset.product_master_embeddings` AS
SELECT
  jan_code,
  content,
  jicfs_item_category,
  ml_generate_embedding_result
FROM ML.GENERATE_EMBEDDING(
  MODEL `your_project.dataset.model_name`,
  (
    SELECT
      jan_code,
      product_name AS content,
      jicfs_item_category
    FROM `your_project.dataset.product_master`
  ),
  STRUCT('SEMANTIC_SIMILARITY' AS task_type)
)

2. クエリのエンベディング生成

推定したい商品名を一時テーブルに格納し、ML.GENERATE_EMBEDDING でベクトルに変換します。

CREATE OR REPLACE TABLE `{{ embedding_table }}` AS
SELECT
  query_id,
  content,
  ml_generate_embedding_result
FROM ML.GENERATE_EMBEDDING(
  MODEL `your_project.dataset.model_name`,
  TABLE `{{ query_table }}`,
  STRUCT('SEMANTIC_SIMILARITY' AS task_type)
)

3. ベクトル検索の実行

商品マスタのエンベディングテーブルに対して VECTOR_SEARCH を実行し、類似商品を上位10件取得します。

SELECT
  query.query_id,
  base.jicfs_item_category,
  results.distance
FROM VECTOR_SEARCH(
  TABLE `your_project.dataset.product_master_embeddings`,
  'ml_generate_embedding_result',
  (SELECT ml_generate_embedding_result, query_id FROM `{{ embedding_table }}`),
  top_k => {{ top_k }},
  distance_type => 'COSINE',
  options => '{"fraction_lists_to_search": 0.005}'
) AS results
ORDER BY query.query_id, distance

COSINE 距離を使用し、コサイン類似度が高い(距離が小さい)順に10件取得しています。
また、fraction_lists_to_search は探索範囲を調整するオプションです(値を大きくすると精度が上がる代わりに速度が下がります)。

4. 最頻カテゴリの選択

上位10件の検索結果から、最も多く出現するカテゴリをその商品のカテゴリとして採用します。

from collections import Counter

def _get_most_frequent_category(
    self, categories: list[str], distances: list[float]
) -> tuple[str, float | None]:
    """
    最頻出カテゴリを取得する。頻度が同じ場合はより上位(ランクが高い)のカテゴリを選択。
    """
    if not categories:
        return "", None

    category_counts = Counter(categories)
    max_count = max(category_counts.values())
    most_frequent_categories = {
        cat for cat, count in category_counts.items() if count == max_count
    }

    # 頻度が同じ場合は、距離が最小のカテゴリを選択
    for category, distance in zip(categories, distances, strict=True):
        if category in most_frequent_categories:
            return category, distance

    return "", None

全体フロー

Pythonで上記の処理をまとめると次のようになります。
ステップ1の商品マスタのベクトル化は事前に実施しておくバッチ処理なので、推論時のフローには含まれていません。

class VectorSearchPredictor:
    def predict(self, items: Series, category_candidates: Series):
        # 1. クエリテーブルを作成
        self._create_query_table(items.tolist())
        # 2. エンベディングを生成
        self._create_embedding_table()
        # 3. ベクトル検索を実行
        results_df = self._execute_vector_search()

        cat_predictions = []
        for i in range(len(items)):
            query_results = results_df[results_df["query_id"] == i + 1]
            categories = query_results["jicfs_item_category"].tolist()
            distances = query_results["distance"].tolist()
            # 4. 最頻カテゴリを選択
            cat_pred, distance = self._get_most_frequent_category(categories, distances)
            cat_predictions.append(cat_pred)

        return Series(cat_predictions)

メリット

1. 学習不要

GCPが提供するエンベディングモデルを使用しているため、モデルの学習が不要です。
商品マスタのエンベディングを事前に計算しておけば、新規商品名に対してすぐに推論できます。

2. 全2,679カテゴリに対応

商品マスタには様々なカテゴリの商品が含まれているため、従来のアプローチで頻出カテゴリに絞っていた制限がなくなりました。
2,679カテゴリ全てに対応可能です。

3. 表記の多様性に強い

エンベディングによるセマンティック検索のため、表記の揺れにも対応できます。
「チョコレート」「チョコ」「チョコレート菓子」など、異なる表記でも類似した商品として検索できます。

4. 少量レコードのカテゴリでも推定可能

学習データの少ないカテゴリでも、商品マスタに類似商品があれば推定できます。
ベクトル検索はデータ量ではなく類似度に基づいているため、マイナーカテゴリでも機能します。

結果

新旧の各手法の推定精度を評価した結果、以下の改善が確認されました。

手法 Accuracy macro-F1
LUKE (旧手法) 0.43 0.13
ベクトル検索 (新手法) 0.78 0.65

Accuracyは単純な正解率を表しています。
macro-F1は少し複雑な指標ですが、最終的に各クラスの平均を取って求めます。そのため、出現頻度は少なくとも精度の低いクラスがあれば評価が大きく下がる指標です。

つまりこの結果は、全体的に大きく精度が向上しただけでなく、出現頻度が少ないカテゴリでも推定できるようになったことを示しています。

さらなる精度改善案

モデルのドメイン適用

現在、エンベディングモデルはGoogleが提供しているものをそのまま使用しています。
しかし、一般的な単語と商品名は異なる特徴を持っていると考えられます。
そのため、今回使ったモデルをドメイン適用させるように追加学習することで、より高精度な推定が可能になると考えられます。

商品マスタの継続的なメンテナンス

コンビニの新商品の中には、新フレーバーなど商品名がそこまで変わらないものも多くあります。
そのため、カテゴリ推定を行った結果をチェックし、その結果を商品マスタに追加する仕組みを整えることで、類似商品が発売された場合に、更に高い精度で推定できるようになると考えられます。

まとめ

BigQueryのベクトル検索を活用することで、JICFSカテゴリ推定の精度を大幅に改善することができました。

  • 学習不要でメンテナンスコストが低い
  • 全カテゴリに対応可能
  • 表記の揺れにも対応可能

GCPのエンベディングモデルとBigQueryのVECTOR_SEARCH機能を組み合わせることで、比較的シンプルな実装で高精度な推定を実現できました。
大規模な商品マスタをそのまま検索DBとして活用するという発想は、同様の課題を持つシステムでも参考になるかと思います。

参考

弊社のベクトル検索活用事例

GCPの公式ドキュメント

WED Engineering Blog

Discussion