ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

ZendeskのヘルプページをBigQueryとdbtでお手軽RAG構築

ミツモアでAI関連の開発を担当している増田(@xmasudahiroto)です。

最近、社内でZendeskのヘルプページの記事をRAG化し、MCP経由でAIエージェントから利用可能にする開発を行いました。今回は、既存のデータ基盤であるdbtとBigQueryを活用し、短期間でRAGを構築した際の手順を共有します。

背景と課題

ミツモアでは顧客対応にZendeskを導入しており、プロダクトの仕様をまとめたヘルプページもZendeskのArticle機能で運用しています。

開発中のAIエージェントにおいて、「プロダクトの仕様に基づいた正確な回答」や「FAQを参照した回答」をさせたいという要望がありました。そのため、Zendeskのヘルプページの記事をRAG化し、それを参照するMCPツールを作成することになりました。

当初はZendesk自体が提供する「Zendesk AI」や他の外部RAGサービスの利用も検討しましたが、以下の理由から自前での構築を選択しました。

  1. MCPとして統合: MCPとして実装することで、開発中のAIエージェントや社内のDifyなど、複数のエージェントから統一して利用できる環境を目指しました。
  2. 拡張性: 将来的に独自の検索ロジックやプロンプト調整が必要になった際、自社基盤の方が圧倒的に小回りが利きます。
  3. ベンダーロックインの回避: RAG専用サービスに依存しすぎると、将来的なサービスの乗り換えが困難になるリスクを考慮しました。
  4. コストの最適化: 構築済みのデータ基盤(BigQuery / dbt)に載せることで、追加のインフラ費用を最小限に抑えられます。

エージェントに提供する文章をZendeskのヘルプページとは別に作成することも議論しましたが、やはりZendeskを SSoT (Single Source of Truth) として活用することにメリットがあると考え、Zendeskの記事をRAGすることにしました。

RAGとは?

RAG(Retrieval-Augmented Generation)は、ユーザーの質問に関連するドキュメントを外部知識から検索(Retrieval)し、その内容をLLMに渡して回答を生成(Generation)させる手法です。

検索にあたっては、事前にテキストを「埋め込み(Embedding)」し、多次元のベクトル空間上の点として表現します。質問文も同様に埋め込みを行いベクトル化し、質問文のベクトルとドキュメントのベクトルの「距離」を計算することで、意味内容が近い情報を探し出すことができます。

アーキテクチャ

「まずは最小限の工数でクイックに実装する」という方針のもと、以下の構成を採用しました。データの抽出からベクトル化、検索までをすべてBigQueryとdbtの範囲内で完結させています。

技術的なポイント

  • dbt Pythonモデル: 従来、APIからのデータ取得はワークフローエンジン等の別ジョブとして切り出す必要がありましたが、Pythonモデルを使うことでdbtの実行フロー内で完結できます。
  • BigQuery ML: SQLから直接Vertex AIのEmbeddingモデルを呼び出せるため、追加の推論サーバーを立てる必要がありません。
  • スケーラビリティ: サーバーレス構成のため、記事数が増加してもパフォーマンスやインフラコストを過度に気にする必要がありません。

これにより、ミツモアの既存データ基盤に載せる形で、追加インフラなしでの開発を実現しました。

※ミツモアのデータ基盤の詳細については、こちらの記事もご参照ください。

実際の実装手順

基本的には、BigQuery公式のRAGチュートリアルに沿って進めていきます。

セマンティック検索と検索拡張生成を行う  |  BigQuery  |  Google Cloud Documentation

BigQuery側の準備

BigQuery上でAI機能を利用するために、リモートモデルを作成します。これはVertex AIへのコネクションを定義する作業です。

  1. モデルを保存するデータセットを作成します(例:models)。
  2. テキスト埋め込み用のリモートモデルを作成します(例:embedding_model)。
CREATE OR REPLACE MODEL `models.embedding_model`
  REMOTE WITH CONNECTION DEFAULT
  OPTIONS (ENDPOINT = 'gemini-embedding-001');

EmbeddingモデルはVertex AIのModel Gardenから選択可能です。今回は gemini-embedding-001 を使用していますが、text-embedding-005 なども選択肢に入ります。

dbt Pythonモデルで記事を抽出

Zendesk APIを叩いて記事を取得し、テーブルに格納する dbt モデル(zendesk_articles)を作成します。APIキーはSecret Managerに安全に保存してある前提です。

# zendesk_articles.py
def model(dbt, session):
    """
    Zendesk Help Center API (Articles) から記事データを取得する Python モデル。

    - 認証: Secret Manager から取得
      - JSON key: {"email": "...", "token": "..."}
      - リソース: projects/<project_id>/secrets/<secret_id>/versions/latest
    - 出力スキーマ: Zendesk API Article Object の全フィールド
    """
    dbt.config(
        submission_method="bigframes",
        materialized="table",
        tags=["proone"],
        packages=[
            "requests",
            "google-cloud-secret-manager",
            "bigframes",
        ],
    )

    import json
    import base64
    import requests
    import bigframes.pandas as bpd
    from google.cloud import secretmanager

    # 定数
    SECRET_RESOURCE_NAME = "projects/<project_id>/secrets/<secret_name>/versions/latest"
    SUBDOMAIN = "subdomain"

    # 出力カラム定義 (APIドキュメントに基づく)
    columns = [
        "id",
        "url",
        "html_url",
        "author_id",
        "comments_disabled",
        "draft",
        "promoted",
        "position",
        "vote_sum",
        "vote_count",
        "section_id",
        "created_at",
        "updated_at",
        "name",
        "title",
        "source_locale",
        "locale",
        "outdated",
        "outdated_locales",
        "edited_at",
        "user_segment_id",
        "permission_group_id",
        "content_tag_ids",
        "label_names",
        "body",
    ]

    def _get_api_creds():
        """
        Secret Manager から認証情報を取得する。
        
        【Secretの作成方法】
        以下のワンライナーでJSONを作成・エンコードし、Google Cloud Secret Managerに `zendesk_api_creds` という名前で保存してください。
        
        # Mac/Linux
        $ echo -n '{"email": "USER_EMAIL", "token": "API_TOKEN"}' | base64 | pbcopy
        
        (作成されたBase64文字列をSecretのpayloadとして保存)
        """
        client = secretmanager.SecretManagerServiceClient()
        resp = client.access_secret_version(name=SECRET_RESOURCE_NAME)
        payload = resp.payload.data.decode("utf-8")
        try:
            return json.loads(payload)
        except json.JSONDecodeError:
            # Base64 encoded JSON case
            return json.loads(base64.b64decode(payload).decode("utf-8"))

    def _fetch_from_zendesk(creds):
        email = creds.get("email")
        token = creds.get("token")
        
        if not email or not token:
            raise ValueError("Zendesk credentials missing 'email' or 'token'")

        auth = (f"{email}/token", token)
        base_url = f"https://{SUBDOMAIN}.zendesk.com/api/v2/help_center/articles.json"
        
        params = {"page[size]": 100}
        url = base_url
        all_articles = []
        
        while url:
            resp = requests.get(url, auth=auth, params=params)
            resp.raise_for_status()
            data = resp.json()
            
            articles = data.get("articles", [])
            all_articles.extend(articles)
            
            url = data.get("links", {}).get("next")
            params = None 
            
        return all_articles

    # Main Logic
    data = []

    try:
        creds = _get_api_creds()
        data = _fetch_from_zendesk(creds)
    except Exception as e:
        raise RuntimeError(f"Failed to fetch Zendesk data: {e}")

    if not data:
        return bpd.DataFrame({c: [] for c in columns})

    # Normalize data to ensure all columns exist
    normalized_data = []
    for item in data:
        row = {}
        for col in columns:
            # APIのレスポンスにないキーは None (NULL) にする
            row[col] = item.get(col)
        normalized_data.append(row)

    df = bpd.DataFrame(normalized_data)
    
    # 型変換が必要な場合ここで行うが、BigFrames/Pandasがある程度推論する
    # idなどは str にしておくと安全かもしれないが、一旦推論に任せる
    
    return df

加工とEmbeddingの生成(dbt SQLモデル)

取得した記事情報を後続のdbtモデルでEmbedding処理します。実行コストを抑えるため、incrementalモデルを利用し、前回の更新以降に変更・追加された記事のみを対象にします。

-- mart_zendesk_articles.sql
{{
  config(
    materialized='incremental',
    unique_key='id',
    incremental_strategy='merge',
  )
}}

WITH source_articles AS (
  SELECT *
  FROM {{ ref('zendesk_articles') }}
),

-- 現在のターゲットテーブルの状態を取得 (Incremental run時のみ)
{% if is_incremental() %}
current_state AS (
  SELECT id, updated_at
  FROM {{ this }}
),
{% endif %}

-- 1. 新規・更新分の特定 (Upserts)
articles_to_embed AS (
  SELECT
    s.id,
    s.url,
    s.html_url,
    s.title,
    s.body,
    s.updated_at,
    -- Embedding用コンテンツ作成
    CONCAT('Title: ', IFNULL(s.title, ''), '\nBody: ', IFNULL(s.body, '')) AS content
  FROM source_articles s
  {% if is_incremental() %}
  LEFT JOIN current_state c ON s.id = c.id
  -- 新規レコード OR 更新日時が新しいレコード OR 以前削除済みだったが復活したレコード
  WHERE c.id IS NULL 
     OR s.updated_at > c.updated_at
  {% endif %}
),

-- Embeddingの生成 (Upsert分のみ)
embedded_articles AS (
  SELECT
    id,
    url,
    html_url,
    title,
    body,
    updated_at,
    content,
    embedding,
    statistics,
    status,
    FALSE AS is_deleted
  FROM AI.GENERATE_EMBEDDING(
    MODEL `models.embedding_model`,
    (
      SELECT
        id,
        url,
        html_url,
        title,
        body,
        updated_at,
        content
      FROM articles_to_embed
    )
  )
)

{% if is_incremental() %}
,
-- 2. 削除分の特定 (Logically Deleted)
-- ソーステーブルに存在しないIDは、ターゲットテーブル(this)から既存レコードを取得し、is_deleted=TRUEにする
deletions AS (
  SELECT
    c.id,
    c.url,
    c.html_url,
    c.title,
    c.body,
    c.updated_at,
    c.content,
    c.embedding,
    c.statistics,
    c.status,
    TRUE AS is_deleted
  FROM {{ this }} c
  WHERE c.id NOT IN (SELECT id FROM source_articles)
  AND c.is_deleted = FALSE -- 既に削除済みのものは更新不要
)

SELECT * FROM embedded_articles
UNION ALL
SELECT * FROM deletions

{% else %}

-- 初回実行時 (Full Refresh)
SELECT * FROM embedded_articles

{% endif %}

ベクトル検索(RAG)の実行

RAGを実行する際は、ユーザーのクエリも同じモデルでEmbedding化し、VECTOR_SEARCH 関数を利用して類似度検索を行います。この処理をMCPで定義してサーバーに載せることで、外部からエージェントがRAGを利用できるようになります。

SELECT
    query.query,
    base.id,
    base.html_url,
    base.title,
    base.body,
    base.updated_at
FROM VECTOR_SEARCH(
    TABLE `dbt.proone_mid_zendesk_articles_embedded`,
    'embedding',
    (
        SELECT embedding, content AS query
        FROM AI.GENERATE_EMBEDDING(
            MODEL `ml.embedding_model`,
            (SELECT @searchQuery AS content)
        )
    ),
    top_k => @topK
)
WHERE NOT base.is_deleted

Indexによる高速化

記事数が増えてパフォーマンスが課題になる場合は、ベクトルインデックスを貼ることで高速化が可能です。

CREATE OR REPLACE VECTOR INDEX my_index
ON `<dbt_dataset>.mart_zendesk_articles`(embedding)
OPTIONS(index_type = 'IVF',
  distance_type = 'COSINE',
  ivf_options = '{"num_lists":500}')

AIエージェント(MCPツール)での活用イメージ

構築したRAGは、MCPサーバーを通じてAIエージェントに提供されます。エージェントはユーザーの質問に応じて、以下のようなツール定義に従ってBigQueryへアクセスします。

{
  "name": "search_help_center",
  "description": "ミツモアのプロダクト仕様やFAQについて検索します。",
  "input_schema": {
    "type": "object",
    "properties": {
      "query": { "type": "string", "description": "検索クエリ" }
    }
  }
}

このツールが呼ばれると、裏側で前述の VECTOR_SEARCH クエリが実行され、上位数件の記事がエージェントのコンテキストに注入されます。これにより、最新の仕様に基づいた正確な回答が可能になります。

運用上の留意点と制約

今回は工数をかけずに実装することを目指したため、以下のようなトレードオフが存在します。

  • モデルの選択肢: BigQuery MLの制約上、Vertex AIでサポートされているモデルに限定されます。
  • リアルタイム性: dbtのバッチ実行間隔に依存するため、Zendesk側の変更が即座に反映されるわけではありません。
  • チャンク分割: 今回は記事単位でEmbeddingしていますが、極端に長いドキュメントを扱う場合は、dbt内で適切なChunking処理を検討する必要があります。
  • ハイブリッド検索: 今回はベクトル類似度のみの検索です。単語の完全一致を重視したい場合は、全文検索エンジンを組み合わせるなど、より高度なRAG構成が必要です。

まとめ

BigQueryとdbtをそのままRAGのエンジンとして活用することで、追加のインフラ管理コストをほぼゼロに抑えつつ、最小限の工数でRAGを実現できます。今回はZendeskのヘルプページの記事での例でしたが、他のサービスでもAPI連携部分を変えれば同様の方針でできます。BigQueryとdbtをデータ基盤としている会社は多いと思うので、参考になれば幸いです。

ミツモアで一緒に働きませんか?

ミツモアでは、 生成AIを活用して圧倒的な生産性を生み出し、日本のGDPを向上させる という目標に向けて、一緒に働く仲間を募集しています。

今回ご紹介したように、プロダクトへのAI組み込みや、AIを使った業務効率化など、ありとあらゆるところで生成AIの利用が進んでいます。

一緒にスタートアップで生成AIのシステム開発をしてみませんか?少しでも興味をお持ちの方は、カジュアル面談からでも大歓迎です。ぜひお気軽にご応募ください!

ミツモア採用ページをチェックする!