ブログ

ユーザー行動データに基づく判断リストを使用して Elasticsearch で LTR モデルをトレーニングする

UBI データを使用して判断リストを作成し、Elasticsearch での Learning to Rank (LTR) モデルのトレーニングを自動化する方法を学びます。

ランク付け学習モデルを使用する際の大きな課題は、モデルをトレーニングするための高品質の判断リストを作成することです。従来、このプロセスでは、クエリとドキュメントの関連性を手動で評価し、それぞれにグレードを割り当てていました。これは、拡張性が低く、維持が困難な、時間のかかるプロセスです (数百のエントリを含むリストを手動で更新する必要があることを想像してください)。

さて、検索アプリケーションでの実際のユーザーインタラクションを使用してこのトレーニングデータを作成できたらどうなるでしょうか?UBIデータを使用すると、まさにそれが実現できます。検索、クリック、その他のインタラクションをキャプチャして使用し、判断リストを生成できる自動システムを作成します。このプロセスは、手動による操作よりもはるかに簡単に拡張および繰り返すことができ、より良い結果が得られる傾向があります。このブログでは、Elasticsearch に保存されている UBI データをクエリして意味のある信号を計算し、 LTRモデルのトレーニング データセットを生成する方法について説明します。

完全な実験は ここで ご覧いただけます 。

UBIデータがLTRモデルのトレーニングに役立つ理由

UBI データには、手動による注釈に比べていくつかの利点があります。

  • 量: UBI データは実際のやり取りから得られるため、手動で生成できるよりもはるかに多くのデータを収集できます。もちろん、このデータを生成するのに十分なトラフィックがあることを前提としています。

  • 実際のユーザーの意図:従来、手動の判断リストは、利用可能なデータの専門家による評価から作成されます。一方、UBI データは実際のユーザー行動を反映しています。これは、何が関連しているべきかという理論的な仮定ではなく、ユーザーが実際にコンテンツとやりとりして価値を見出す方法に基づいているため、検索システムの精度を向上させる、より優れたトレーニング データを生成できることを意味します。

  • 継続的な更新:判断リストは時間の経過とともに更新する必要があります。UBI データから作成すれば、最新のデータが得られ、判断リストが更新されます。

  • コスト効率:判断リストを手動で作成するオーバーヘッドがないため、プロセスを何度でも効率的に繰り返すことができます。

  • 自然なクエリ分布: UBI データは実際のユーザー クエリを表し、より深い変化を促すことができます。たとえば、ユーザーはシステム内で検索する際に自然言語を使用しているでしょうか?もしそうなら、セマンティック検索またはハイブリッド検索アプローチを実装する必要があるかもしれません。

ただし、いくつかの警告も伴います。

  • バイアスの増幅:人気のあるコンテンツは、露出が増えるため、クリックされる可能性が高くなります。そのため、人気のあるアイテムが強調され、より良い選択肢が埋もれてしまう可能性があります。

  • カバレッジが不完全:新しいコンテンツにはインタラクションがないため、結果の上位に表示されることは難しい可能性があります。まれなクエリでは、意味のあるトレーニング データを作成するのに十分なデータ ポイントが不足している場合もあります。

  • 季節的な変動:ユーザーの行動が時間の経過とともに劇的に変化することが予想される場合、履歴データからは、どのような結果が適切であるかについてあまり情報が得られない可能性があります。

  • タスクの曖昧さ:クリックしても、ユーザーが探していたものが見つかるとは限らない。

成績計算

LTRトレーニングのグレード

LTR モデルをトレーニングするには、ドキュメントがクエリにどの程度関連しているかを数値で表現する必要があります。私たちの実装では、この数値は 0.0 から 5.0+ までの連続したスコアであり、スコアが高いほど関連性が高くなります。

この評価システムがどのように機能するかを示すために、手動で作成された次の例を考えてみましょう。

クエリ

文書の内容

学年

説明

「最高のピザレシピ」

「本格的なイタリアンピザ生地のレシピ(写真付きステップバイステップ)」

4.0

関連性が高く、まさにユーザーが探しているもの

「最高のピザレシピ」

「イタリアのピザの歴史」

1.0

ピザに関する内容ですが、レシピではありません

「最高のピザレシピ」

「初心者向け15分でできる簡単ピザレシピ」

3.0

関連性があり、良い結果ですが、「最高」のレシピとは言えないかもしれません。

「最高のピザレシピ」

「車のメンテナンスガイド」

0.0

全く関係ありません。クエリとは全く関係ありません。

ここからわかるように、グレードは、ドキュメントが「最高のピザのレシピ」というサンプルクエリにどれだけ関連しているかを数値で表したものです。これらのスコアを使用して、LTR モデルはどのドキュメントを結果の上位に表示する必要があるかを学習できます。

成績の計算方法は、トレーニング データセットの中核です。これを行うには複数のアプローチがあり、それぞれに長所と短所があります。たとえば、関連性がある場合は 1、関連性がない場合は 0 というバイナリ スコアを割り当てたり、クエリごとに結果のドキュメントのクリック数をカウントしたりすることもできます。

このブログ投稿では、ユーザーの行動を入力として考慮し、グレード番号を出力として計算する、異なるアプローチを使用します。また、ドキュメントの関連性に関係なく、検索結果の上位の方がクリックされる傾向があるという事実から生じる可能性のあるバイアスも修正します。

成績の計算 - COECアルゴリズム

COEC ( Clicks over Expected Clicks ) アルゴリズムは、ユーザーのクリックから判断グレードを計算する手法です。前述したように、ユーザーは、ドキュメントがクエリに最も関連していない場合でも、上位に表示された結果をクリックする傾向があります。これは、ポジション バイアスと呼ばれます。COEC アルゴリズムを使用する際の基本的な考え方は、すべてのクリックが同等に重要であるわけではないということです。つまり、位置 10 のドキュメントをクリックすると、位置 1 のドキュメントをクリックするよりも、そのドキュメントがクエリとの関連性がはるかに高いことを示します。COEC アルゴリズムに関する研究論文 (上記リンク) を引用します。

「検索結果や広告のクリック率(CTR)は、検索結果の順位によって大幅に低下することがよく知られています。」

ポジションバイアスの詳細については、こちらをご覧ください。

COEC アルゴリズムを使用してこれに対処するには、次の手順に従います。

1. 位置のベースラインを確立する:検索位置ごとに 1 から 10 までのクリック率 (CTR) を計算します。つまり、通常、ユーザーの何パーセントが位置 1、位置 2 などをクリックするかを決定します。このステップでは、ユーザーの自然な位置の偏りを捉えます。CTR は次のように計算します。

CTRp=CpIpCTRp=CpIp

どこ:

p = 位置。1から10まで Cp = すべてのクエリにおける位置pでの合計クリック数(任意のドキュメント) Ip = 総表示回数: すべてのクエリで、任意のドキュメントが位置 p に表示された回数

ここでは、上位の順位の方がクリック数が多くなると予想されます。

2.予想クリック数(EC)を計算する:

この指標は、ドキュメントが表示された位置とその位置のCTRに基づいて、ドキュメントが「受け取るべき」クリック数を決定します。ECは次の方法で計算します。

EC(foradocument)=qQdCTRpos(d,q)EC (for a document) = qQdCTRpos(d,q)

どこ:

Qd = 文書dが出現したすべてのクエリ pos(d,q) = クエリqの結果における文書dの位置

3.実際のクリック数をカウントする:ドキュメントが表示されたすべてのクエリでドキュメントが受け取った実際の合計クリック数をカウントします。以降、 A(d) と呼びます。

4. COECスコアを計算します。これは、実際のクリック数(A(d))と予想クリック数(EC(d))の比率です。

COEC=A(d)EC(d)COEC = A(d)EC(d)

このメトリックは、次のように位置バイアスを正規化します。

  • スコア 1.0 は、ドキュメントが表示された位置に応じて、期待どおりに実行されたことを意味します。

  • スコアが 1.0 を超える場合、ドキュメントの位置を見ると予想よりもパフォーマンスが優れていることを意味します。したがって、このドキュメントはクエリに対してより関連性があります。

  • スコアが 1.0 未満の場合、ドキュメントの位置から判断すると、予想よりもパフォーマンスが悪かったことを意味します。したがって、このドキュメントはクエリとの関連性が低くなります。

最終結果は、検索システムとの実際のやりとりから抽出された位置ベースの期待を考慮して、ユーザーが探しているものを捉えたグレード番号になります。

技術的な実装

LTR モデルをトレーニングするための判断リストを作成するスクリプトを作成します。

このスクリプトの入力は、Elastic でインデックス化された UBI データ (クエリとイベント) です。

出力は、COEC アルゴリズムを使用してこれらの UBI ドキュメントから生成された CSV ファイル内の判断リストです。この判断リストをElandで使用すると、関連する特徴を抽出し、LTR モデルをトレーニングできます。

クイックスタート

このブログのサンプル データから判断リストを生成するには、次の手順に従います。

1. リポジトリをクローンします。

git clone https://github.com/Alex1795/elastic-ltr-judgement_list-blog.git  
cd elastic-ltr-judgement_list-blog

2. 必要なライブラリをインストールする

このスクリプトには、次のライブラリが必要です。

  • pandas : 判定リストを保存する

  • elasticsearch : ElasticデプロイメントからUBIデータを取得する

Python 3.11も必要です

pip install -r requirements.txt

3. .envファイルでElasticデプロイメントの環境変数を更新します。

  • ES_ホスト

  • API_キー

環境変数を追加するには、次を使用します。

source .env

4. ubi_queries、ubi_events インデックスを作成し、サンプル データをアップロードします。setup.py ファイルを実行します。

python setup.py

5. Python スクリプトを実行します。

python judgement_list-generator.py

これらの手順に従うと、次のような judgement_list.csv という新しいファイルが表示されます。

このスクリプトは、以下に示すcalculate_relevance_grade()関数を使用して、前に説明した COEC アルゴリズムを適用して成績を計算します。

データアーキテクチャ

Ubiクエリ

UBI クエリ インデックスには、検索システムで実行されたクエリに関する情報が含まれています。これはサンプルドキュメントです:

{
          "client_id": "client_002",
          "query": "italian pasta recipes",
          "query_attributes": {
            "search_type": "recipe",
            "category": "food",
            "cuisine": "italian"
          },
          "query_id": "q002",
          "query_response_id": "qr002",
          "query_response_object_ids": [
            "doc_011",
            "doc_012",
            "doc_013",
            "doc_014",
            "doc_015",
            "doc_016",
            "doc_017",
            "doc_018",
            "doc_019",
            "doc_020"
          ],
          "timestamp": "2024-08-14T11:15:00Z",
          "user_query": "italian pasta recipes"
        }

ここでは、ユーザー(client_id)、クエリの結果(query_response_object_ids)、クエリ自体(timestamp、user_query)のデータを見ることができます。

Ubiクリックイベント

ubi_events インデックスには、ユーザーが結果内のドキュメントをクリックするたびに収集されたデータが含まれています。これはサンプルドキュメントです:

{
          "action_name": "click",
          "application": "recipe_search",
          "client_id": "client_001",
          "event_attributes": {
            "object": {
              "description": "Authentic Italian Pizza Dough Recipe with Step-by-Step Photos",
              "device": "desktop",
              "object_id": "doc_001",
              "position": {
                "ordinal": 1,
                "page_depth": 1
              },
              "user": {
                "city": "New York",
                "country": "USA",
                "ip": "192.168.1.100",
                "location": {
                  "lat": 40.7128,
                  "lon": -74.006
                },
                "region": "NY"
              }
            }
          },
          "message": "User clicked on document doc_001",
          "message_type": "click",
          "query_id": "q001",
          "timestamp": "2024-08-14T10:31:00Z",
          "user_query": "best pizza recipe"
        }

判定リスト生成スクリプト

スクリプトの一般的な概要

このスクリプトは、Elasticsearch に保存されているクエリとクリック イベントからの UBI データを使用して、判断リストの生成を自動化します。次のタスクを実行します:

  • Elasticsearch で UBI データを取得して処理します。

  • UBI イベントをそのクエリと関連付けます。

  • 各位置の CTR を計算します。

  • 各ドキュメントの予想クリック数 (EC) を計算します。

  • 各ドキュメントの実際のクリック数をカウントします。

  • 各クエリとドキュメントのペアの COEC スコアを計算します。

  • 判定リストを生成し、CSVファイルに書き込みます。

それぞれの機能を見ていきましょう。

connect_to_elasticsearch()

def connect_to_elasticsearch(host, api_key):
    """Create and return Elasticsearch client"""
    try:
        es = Elasticsearch(
            hosts=[host],
            api_key=api_key,
            request_timeout=60
        )
        # Test the connection
        if es.ping():
            print(f"✓ Successfully connected to Elasticsearch at {host}")
            return es
        else:
            print("✗ Failed to connect to Elasticsearch")
            return None
    except Exception as e:
        print(f"✗ Error connecting to Elasticsearch: {e}")
        return None

この関数は、ホストと API キーを使用して Elasticsearch クライアント オブジェクトを返します。

fetch_ubi_data()

def fetch_ubi_data(es_client: Elasticsearch, queries_index: str, events_index: str,
                   size: int = 10000) -> Tuple[List[Dict], List[Dict]]:
    """
    Fetch UBI queries and events data from Elasticsearch indices.

    Args:
        es_client: Elasticsearch client
        queries_index: Name of the UBI queries index
        events_index: Name of the UBI events index
        size: Maximum number of documents to fetch

    Returns:
        Tuple of (queries_data, events_data)
    """
    logger.info(f"Fetching data from {queries_index} and {events_index}")

    # Fetch queries with error handling
    try:
        queries_response = es_client.search(
            index=queries_index,
            body={
                "query": {"match_all": {}},
                "size": size
            }
        )
        queries_data = [hit['_source'] for hit in queries_response['hits']['hits']]
        logger.info(f"Fetched {len(queries_data)} queries")

    except Exception as e:
        logger.error(f"Error fetching queries from {queries_index}: {e}")
        raise

    # Fetch events (only click events for now) with error handling
    try:
        events_response = es_client.search(
            index=events_index,
            body={
                "query": {
                    "term": {"message_type.keyword": "CLICK_THROUGH"}
                },
                "size": size
            }
        )
        events_data = [hit['_source'] for hit in events_response['hits']['hits']]
        logger.info(f"Fetched {len(events_data)} click events")

    except Exception as e:
        logger.error(f"Error fetching events from {events_index}: {e}")
        raise

    logger.info(f"Data fetch completed successfully - Queries: {len(queries_data)}, Events: {len(events_data)}")

    return queries_data, events_data

この関数はデータ抽出レイヤーであり、Elasticsearch に接続して match_all クエリを使用して UBI クエリを取得し、UBI イベントをフィルタリングして 'CLICK_THROUGH' イベントのみを取得します。

プロセスubi_data()

def process_ubi_data(queries_data: List[Dict], events_data: List[Dict]) -> pd.DataFrame:
    """
    Process UBI data and generate judgment list.

    Args:
        queries_data: List of query documents from UBI queries index
        events_data: List of event documents from UBI events index

    Returns:
        DataFrame with judgment list (qid, docid, grade, keywords)
    """
    logger.info("Processing UBI data to generate judgment list")

    # Group events by query_id
    clicks_by_query = {}
    for event in events_data:
        query_id = event['query_id']
        if query_id not in clicks_by_query:
            clicks_by_query[query_id] = {}

        # Extract clicked document info
        object_id = event['event_attributes']['object']['object_id']
        position = event['event_attributes']['object']['position']['ordinal']

        clicks_by_query[query_id][object_id] = {
            'position': position,
            'timestamp': event['timestamp']
        }

    judgment_list = []

    # Process each query
    for query in queries_data:
        query_id = query['query_id']
        user_query = query['user_query']
        document_ids = query['query_response_object_ids']

        # Get clicks for this query
        query_clicks = clicks_by_query.get(query_id, {})

        # Generate judgment for each document shown
        for doc_id in document_ids:
            grade = calculate_relevance_grade(doc_id, query_clicks, document_ids, queries_data, events_data)

            judgment_list.append({
                'qid': query_id,
                'docid': doc_id,
                'grade': grade,
                'query': user_query
            })

    df = pd.DataFrame(judgment_list)
    logger.info(f"Generated {len(df)} judgment entries for {df['qid'].nunique()} unique queries")

    return df

この関数は判定リストの生成を処理します。UBI イベントとクエリを関連付けることで、UBI データの処理を開始します。次に、ドキュメントとクエリのペアごとに calculate_relevance_grade() 関数を呼び出して、判断リストのエントリを取得します。最後に、結果のリストを pandas データフレームとして返します。

関連性グレードを計算する()

def calculate_relevance_grade(document_id: str, clicks_data: Dict,
                              query_response_ids: List[str], all_queries_data: List[Dict] = None,
                              all_events_data: List[Dict] = None) -> float:
    """
    Calculate COEC (Click Over Expected Clicks) relevance score for a document.

    Args:
        document_id: ID of the document
        clicks_data: Dictionary of clicked documents with their positions for current query
        query_response_ids: List of document IDs shown in search results (ordered by position)
        all_queries_data: All queries data for calculating position CTR averages
        all_events_data: All events data for calculating position CTR averages

    Returns:
        COEC relevance score (continuous value, typically 0.0 to 5.0+)
    """

    # If no global data provided, fall back to simple position-based grading
    if all_queries_data is None or all_events_data is None:
        logger.warning("No global data provided, falling back to position-based grading")
        # Simple fallback logic
        if document_id in clicks_data:
            position = clicks_data[document_id]['position']
            if position > 3:
                return 4.0
            elif position >= 1 and position <= 3:
                return 3.0
        if document_id in query_response_ids:
            position = query_response_ids.index(document_id) + 1
            if position <= 5:
                return 2.0
            elif position >= 6 and position <= 10:
                return 1.0
        return 0.0

    # Calculate rank-aggregated click-through rates
    position_ctr_averages = {}
    position_impression_counts = {}
    position_click_counts = {}

    # Initialize counters
    for pos in range(1, 11):  # Positions 1-10
        position_impression_counts[pos] = 0
        position_click_counts[pos] = 0

    # Count impressions (every document shown contributes)
    for query in all_queries_data:
        for i, doc_id in enumerate(query['query_response_object_ids'][:10]):  # Top 10 positions
            position = i + 1
            position_impression_counts[position] += 1

    # Count clicks by position
    for event in all_events_data:
        if event.get('action_name') == 'click':
            position = event['event_attributes']['object']['position']['ordinal']
            if position <= 10:
                position_click_counts[position] += 1

    # Calculate average CTR per position
    for pos in range(1, 11):
        if position_impression_counts[pos] > 0:
            position_ctr_averages[pos] = position_click_counts[pos] / position_impression_counts[pos]
        else:
            position_ctr_averages[pos] = 0.0

    # Calculate expected clicks for this specific document
    expected_clicks = 0.0

    # Count how many times this document appeared at each position for any query
    for query in all_queries_data:
        if document_id in query['query_response_object_ids']:
            position = query['query_response_object_ids'].index(document_id) + 1
            if position <= 10:
                expected_clicks += position_ctr_averages[position]

    # Count total actual clicks for this document across all queries
    actual_clicks = 0
    for event in all_events_data:
        if (event.get('action_name') == 'click' and
                event['event_attributes']['object']['object_id'] == document_id):
            actual_clicks += 1

    # Calculate COEC score
    if expected_clicks > 0:
        coec_score = actual_clicks / expected_clicks
    else:
        coec_score = 0.0

    logger.debug(
        f"Document {document_id}: {actual_clicks} clicks / {expected_clicks:.3f} expected = {coec_score:.3f} COEC")

    return coec_score

これは COEC アルゴリズムを実装する関数です。各位置の CTR を計算し、次にドキュメントとクエリのペアの実際のクリック数を比較し、最後にそれぞれの実際の COEC スコアを計算します。

判断統計を生成する()

def generate_judgment_statistics(df: pd.DataFrame) -> Dict:
    """Generate statistics about the judgment list."""
    stats = {
        'total_judgments': len(df),
        'unique_queries': df['qid'].nunique(),
        'unique_documents': df['docid'].nunique(),
        'grade_distribution': df['grade'].value_counts().to_dict(),
        'avg_judgments_per_query': len(df) / df['qid'].nunique() if df['qid'].nunique() > 0 else 0,
        'queries_with_clicks': len(df[df['grade'] > 1]['qid'].unique()),
        'click_through_rate': len(df[df['grade'] > 1]) / len(df) if len(df) > 0 else 0
    }
    return stats

判定リストから、合計クエリ数、合計ユニークドキュメント数、グレード分布などの有用な統計情報を生成します。これは純粋に情報提供であり、結果の判断リストは変更されません。

結果と影響

クイック スタート セクションの指示に従うと、320 エントリの判定リストを含む CSV ファイルが生成されます (リポジトリでサンプル出力を確認できます)。これらのフィールド:

  • qid: クエリの一意のID

  • docid: 結果のドキュメントの一意の識別子

  • グレード: クエリとドキュメントのペアの計算されたグレード

  • クエリ: ユーザークエリ

「イタリア料理のレシピ」というクエリの結果を見てみましょう。

クイド

ドシド

学年

クエリ

q1-イタリア料理レシピ

パスタの基本レシピ

0.0

イタリアのレシピ

q1-イタリア料理レシピ

レシピ_ピザ_マルゲリータ

3.333333

イタリアのレシピ

q1-イタリア料理レシピ

レシピ_リゾット_ガイド

10.0

イタリアのレシピ

q1-イタリア料理レシピ

レシピ_フレンチ_クロワッサン

0.0

イタリアのレシピ

q1-イタリア料理レシピ

レシピ_スペイン_パエリア

0.0

イタリアのレシピ

q1-イタリア料理レシピ

ギリシャ風ムサカのレシピ

1.875

イタリアのレシピ

結果から、「イタリアのレシピ」というクエリに対して次のことがわかります。

  • リゾットのレシピは間違いなくクエリに対する最高の結果であり、予想よりも10倍多くのクリックを獲得しています。

  • ピザ マルゲリータも素晴らしい出来栄えです。

  • ギリシャのムサカも(意外にも)良い結果であり、結果上の順位が示唆するよりも良い成績を残しています。これは、イタリア料理のレシピを探していた数人のユーザーが、代わりにこのレシピに興味を持ったことを意味します。おそらくこれらのユーザーは地中海料理全般に興味があるのでしょう。結局のところ、このことからわかるのは、これは上で説明した他の 2 つの「より良い」一致の下に表示される良い結果になる可能性があるということです。

まとめ

UBI データを使用すると、LTR モデルのトレーニングを自動化し、独自のユーザーから高品質の判断リストを作成できます。UBI データは、検索システムがどのように使用されているかを反映する大きなデータセットを提供します。COEC アルゴリズムを使用して成績を生成することで、固有の偏りを考慮しながら、同時にユーザーがより良い結果と考えるものを反映します。ここで概説した方法は、実際のユースケースに適用でき、実際の使用傾向に合わせて進化する、より優れた検索エクスペリエンスを提供できます。

関連記事

ES|QL を使用した Elasticsearch 地理空間検索

Craig Taverner

検索関連性の評価パート1-BEIRベンチマーク

Thanos Papaoikonomou

AI盗作:Elasticsearchによる盗作検出

Priscilla Parodi

Elasticsearch と SigLIP-2 による山頂のマルチモーダル探索

Navneet Kumar

Elasticsearch による語彙検索と意味検索

Priscilla Parodi

最先端の検索体験を構築する準備はできましたか?

十分に高度な検索は 1 人の努力だけでは実現できません。Elasticsearch は、データ サイエンティスト、ML オペレーター、エンジニアなど、あなたと同じように検索に情熱を傾ける多くの人々によって支えられています。ぜひつながり、協力して、希望する結果が得られる魔法の検索エクスペリエンスを構築しましょう。

はじめましょう