Spanner Omni を使用したオンデバイスのデータ エージェントと GraphRAG

1. 概要

この Codelab では、Spanner Omni の基本、Spanner Omni が自律型 AI エージェントの究極のデータ基盤として機能する理由、Spanner Omni(リレーショナル + グラフ GQL)、Python Flask、Gemini 3.8 Flash を活用した、エッジにデプロイされたエージェント型ピックルボール戦術コーチと試合シミュレーターのエンドツーエンドの実装の概要について説明します。

問題

最新のインタラクティブなスポーツ プラットフォームやリアルタイムのゲーム環境では、次の 3 つの異なるワークロードを同時に処理する必要があります。

同時実行性の高い運用トランザクション:

コートの予約、プレーヤーのメンバーシップ、請求、トーナメントのスケジュール。ダブルブッキングの競合を排除するために、厳格な ACID 直列化可能性が必要です。

接続グラフのテレメトリー:

連続したラリーショット シーケンス、遷移マルコフ連鎖、ゾーン カバレッジ グラフ。これらは、大規模な ETL パイプラインを必要とせず、低レイテンシのトラバーサルを必要とします。

ミリ秒未満のエッジ応答性:

広域クラウド ネットワークのラウンドトリップ(100 ~ 200 ミリ秒)で許容できないジッターが発生する、ペースの速い 60 FPS の物理シミュレーション。

これまで、エンジニアリング チームは、トランザクション データ用の PostgreSQL、ショットグラフ用の Neo4j、エッジ キャッシング用の Redis、バックグラウンド同期パイプラインなど、断片化されたアーキテクチャを組み合わせてこの問題を解決していました。これにより、アーキテクチャのドリフト、運用の複雑化、結果整合性の遅延が発生します。

仕組み

Google Cloud Spanner Omni をエッジにデプロイし、Google Gemini 3.8 Flash と組み合わせることで、Spanner Omni Pickleball Game を構築しました。これは、リレーショナル ビジネス ロジック、ネイティブ ISO GQL ショット テレメトリー、リアルタイムの戦術的 AI コーチングを統合した、シングルエンジンのスポーツ ゲーム プラットフォームです。

Spanner Omni

Spanner Omni は、Google の地球規模の分散型データベース エンジンを、セルフマネージド、オンプレミス、マルチクラウド、エアギャップ環境に導入します。このエンジンは、水平方向のスケーラビリティ、ACID トランザクション、マルチモデル機能(リレーショナル、グラフ、ベクトル、分析)を備えています。Spanner Omni は、Google Cloud Spanner のダウンロード可能なセルフマネージド エディションです。Spanner のコア分散データベース機能(自動シャーディング、Paxos ベースのレプリケーション、強力な外部整合性)を、実行場所を問わず提供します。

acc871a470f63b29.png

学習内容

この Codelab では、従来のサイロ化されたデータベース アーキテクチャから、最新の統合エッジ データベース ソリューションに移行します。学習内容は次のとおりです。

  1. Docker を使用して Spanner Omni をローカルにデプロイしてオーケストレートし、高パフォーマンスの単一インスタンスのリレーショナル ワークロードを管理します。
  2. 外部データベースの同期を必要とせずに、トランザクション ビジネスモデルと高次元のゲーム テレメトリーをマッピングする、統合されたリレーショナルおよび ISO GQL グラフスキーマを設計します。
  3. Python の読み取り / 書き込みトランザクション内に厳格な ACID 直列化可能性を実装して、競合状態をプログラムで防止し、ダブルブッキングの競合を排除します。
  4. SQL 統合 ISO GQL グラフクエリを活用するネイティブ GraphRAG パイプラインを構築し、リアルタイムでコンテキストに沿ったグラウンディングされた試合のテレメトリーを Google Gemini に直接フィードして、即座に戦術的なコーチングを行います。

要件

  • Linux(Ubuntu 22.04 以降)、macOS、または Windows を実行している標準のローカルマシンまたは開発用 VM。
  • Docker Desktop または Docker Engine がインストールされ、実行されている。
  • Python 3.10 以降がインストールされている。
  • テキスト エディタまたは IDE(VS Code や PyCharm など)。
  • 有効な Google Gemini API キー。(注: Spanner Omni は無料枠のセルフマネージド デベロッパー評価ライセンスで実行されるため、ローカルで実行するのに Google Cloud 請求先アカウントは必要ありません)。

2. Spanner Omni のデプロイと構成

このステップでは、ローカル エッジ データベース インスタンスを起動します。Spanner Omni は高度に最適化されたコンテナ内で実行されるため、ローカルの開発用ノートパソコンでグローバル規模のリレーショナル機能を利用できます。

  1. ターミナルを開き、データベース スキーマとゲーム テレメトリーを保存するための永続的な Docker ボリュームを作成します。
docker volume create spanner_omni_data
  1. 単一サーバーのデベロッパー モードで構成された公式の Spanner Omni コンテナ イメージを pull して起動します。
docker run -d --name spanner-omni \
  -p 9010:9010 -p 9020:9020 \
  -v spanner_omni_data:/spanner \
  us-docker.pkg.dev/spanner-omni/images/spanner-omni:2026.r2.1-beta \
  start-single-server
  1. コンテナが正常に起動したことを確認します。
docker ps

🛠️ 簡単なワンライナー トラブルシューティング: バックグラウンド プロセスまたは以前のエミュレータの実行によってポート 9010 または 9020 がすでに使用されているために起動に失敗した場合は、ターミナルで次のクリーンアップ スクリプトを実行します。

pkill -f "spanner" || true
fuser -k 9010/tcp 9020/tcp 2>/dev/null || true
docker rm -f spanner-local spanner-omni spanner-omni-local 2>/dev/null || true

3. リレーショナルと ISO GQL グラフの DDL スキーマ設計

Spanner Omni が有効になったら、データベース スキーマを確立できます。このスキーマは、信頼できる唯一の情報源として機能し、リレーショナル 事業体(コート、プレーヤー、予約など)を、高速で接続されたグラフ テレメトリー スキーマ(シュート、ゾーン、トランジション)と統合します。

運用テーブルとテレメトリー テーブルを作成するには:

  1. Spanner CLI またはアプリケーション クライアントを使用してデータベースに接続する
  2. 次の DDL ステートメントを実行します。

この問題はアプリケーション コードで対処しているため、現時点ではお客様側でのご対応は必要ありません。

-- ============================================================================
-- 1. OPERATIONAL & FACILITY MANAGEMENT (RELATIONAL ACID)
-- ============================================================================

CREATE TABLE Courts (
    court_id INT64 NOT NULL,
    name STRING(100) NOT NULL,
    location STRING(100) NOT NULL,
    surface_type STRING(50) NOT NULL,
    is_indoor BOOL NOT NULL,
    hourly_rate FLOAT64 NOT NULL,
    has_lighting BOOL NOT NULL,
    created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (court_id);

CREATE TABLE Players (
    player_id INT64 NOT NULL,
    name STRING(100) NOT NULL,
    email STRING(100) NOT NULL,
    skill_level STRING(20) NOT NULL,
    phone STRING(50),
    created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (player_id);

CREATE TABLE Bookings (
    booking_id INT64 NOT NULL,
    court_id INT64 NOT NULL,
    player_id INT64 NOT NULL,
    booking_date DATE NOT NULL,
    start_hour INT64 NOT NULL,
    end_hour INT64 NOT NULL,
    player_name STRING(100) NOT NULL,
    court_name STRING(100) NOT NULL,
    status STRING(20) NOT NULL,
    notes STRING(MAX),
    created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (booking_id);

CREATE TABLE MatchRequests (
    match_id INT64 NOT NULL,
    player_id INT64 NOT NULL,
    player_name STRING(100) NOT NULL,
    court_id INT64,
    court_name STRING(100),
    play_date DATE NOT NULL,
    start_hour INT64 NOT NULL,
    skill_required STRING(20) NOT NULL,
    match_type STRING(20) NOT NULL,
    spots_open INT64 NOT NULL,
    status STRING(20) NOT NULL,
    notes STRING(MAX),
    created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (match_id);

-- ============================================================================
-- 2. TACTICAL GAME TELEMETRY & PROPERTY GRAPH (ISO GQL)
-- ============================================================================

CREATE TABLE CourtZones (
    zone_id STRING(50) NOT NULL,
    name STRING(100) NOT NULL,
    is_kitchen BOOL NOT NULL,
    side STRING(50) NOT NULL
) PRIMARY KEY (zone_id);

CREATE TABLE Shots (
    shot_id STRING(50) NOT NULL,
    player_id STRING(50) NOT NULL,
    shot_type STRING(100) NOT NULL,
    speed_mph FLOAT64,
    spin_type STRING(50),
    outcome STRING(50),
    from_zone_id STRING(50),
    to_zone_id STRING(50)
) PRIMARY KEY (shot_id);

CREATE TABLE ShotTransitions (
    transition_id STRING(50) NOT NULL,
    from_shot_id STRING(50) NOT NULL,
    to_shot_id STRING(50) NOT NULL,
    rally_depth INT64,
    pressure_level STRING(20)
) PRIMARY KEY (transition_id);

-- ============================================================================
-- 3. MATCH SESSIONS & AI COACHING HISTORY
-- ============================================================================

CREATE TABLE GameSessions (
    session_id STRING(50) NOT NULL,
    player_id INT64 NOT NULL,
    player_score INT64 NOT NULL,
    bot_score INT64 NOT NULL,
    bot_difficulty STRING(20) NOT NULL,
    winner STRING(20) NOT NULL,
    rally_length INT64 NOT NULL,
    fault_type STRING(100),
    coaching_summary STRING(MAX),
    created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (session_id);
cat << 'EOF' > Dockerfile
FROM ollama/ollama:latest
ENV OLLAMA_HOST=0.0.0.0:8080
ENV OLLAMA_MODELS=/root/.ollama
ENV MODEL=gemma4:e2b
RUN ollama serve & sleep 5 && ollama pull $MODEL
ENTRYPOINT ["ollama", "serve"]
EOF

💡 commit タイムスタンプが重要な理由: OPTIONS (allow_commit_timestamp=true) を指定すると、すべてのローカルノード レプリカでマイクロ秒単位の正確な単調な順序付けが可能になります。これは、高速なゲームイベント(ラインコール ビデオ映像など)を、対応するデータベースのショット テレメトリー ログと同期するために不可欠です。

4. ACID 同時実行制御とダブルブッキングの防止

アクティブなスポーツ予約ポータルでは、複数のユーザーが同じコートのタイムスロットを予約しようと競合することがよくあります。競合状態を防ぐため、Spanner Omni で Python の読み取り / 書き込みトランザクションを記述し、ダブルブッキングを完全に防止するシリアル化可能性を確保します。

現時点では、特に操作は必要ありません。コードを確認するだけです。

from google.cloud import spanner

def create_booking_transaction(transaction, booking_data):
    # 1. Query for existing overlapping confirmed bookings on the same court
    query = """
        SELECT booking_id, player_name, start_hour, end_hour
        FROM Bookings
        WHERE court_id = @court_id
          AND booking_date = @booking_date
          AND status = 'CONFIRMED'
          AND NOT (end_hour <= @start_hour OR start_hour >= @end_hour)
    """
    params = {
        "court_id": booking_data["court_id"],
        "booking_date": booking_data["booking_date"],
        "start_hour": booking_data["start_hour"],
        "end_hour": booking_data["end_hour"],
    }
    param_types = {
        "court_id": spanner.param_types.INT64,
        "booking_date": spanner.param_types.DATE,
        "start_hour": spanner.param_types.INT64,
        "end_hour": spanner.param_types.INT64,
    }
    
    results = list(transaction.execute_sql(query, params=params, param_types=param_types))
    
    if results:
        conflict = results[0]
        raise ValueError(
            f"Court conflict: Already booked by {conflict[1]} ({conflict[2]}:00-{conflict[3]}:00)"
        )
    
    # 2. Insert new confirmed booking with Spanner commit timestamp
    insert_stmt = """
        INSERT INTO Bookings (
            booking_id, court_id, player_id, booking_date, start_hour, end_hour,
            player_name, court_name, status, notes, created_at
        ) VALUES (
            @booking_id, @court_id, @player_id, @booking_date, @start_hour, @end_hour,
            @player_name, @court_name, 'CONFIRMED', @notes, PENDING_COMMIT_TIMESTAMP()
        )
    """
    # execute_update statement writing to Spanner Omni...
    transaction.execute_update(insert_stmt, params=booking_data, param_types=booking_types)

5. リアルタイム 60 FPS の物理エンジンと調整可能な DUPR ボットエンジン

アプリケーションのフロントエンドは、60 フレーム/秒(FPS)でレンダリングされる 2.5D HTML5 Canvas シミュレーションを利用しています。このレンダリングは、データベース レイヤから完全に切り離されて実行され、非常にスムーズなパフォーマンスを維持します。テレメトリーは、ラリーが終了したときにのみフラッシュされます。

このシミュレーターには、実際の DUPR(Dynamic Universal Pickleball Rating)レベルに基づく難易度マトリックスを活用した、調整可能な OmniBot AI エンジンが搭載されています。

1640056a824b47e6.png

現時点では、お客様側での対応は必要ありません。詳細を確認するだけです。

6. ISO GQL ショット テレメトリー グラフ処理

各ラリーの終了時に、フロントエンドは接続された軌跡グラフをバックエンド エンドポイント /api/rally-end に送信します。すべてのショットはリレーショナル ノードとして記録され、シーケンスは遷移エッジを介して動的にリンクされます。

現時点では、お客様側で必要なご対応はありません。コードを確認するだけです。

@app.route("/api/execute-shot", methods=["POST"])
def execute_shot():
    data = request.json or {}
    shot_id = f"shot_{int(time.time()*1000)}"
    
    # 1. Insert the Shot Node
    insert_shot_sql = """
        INSERT INTO Shots (shot_id, player_id, shot_type, speed_mph, spin_type, outcome, from_zone_id, to_zone_id)
        VALUES (@shot_id, @player_id, @shot_type, @speed, @spin, @outcome, @from_zone, @to_zone)
    """
    
    # 2. If part of a continuous rally, link with a ShotTransition Edge
    if data.get("prev_shot_id"):
        transition_id = f"trans_{int(time.time()*1000)}"
        insert_trans_sql = """
            INSERT INTO ShotTransitions (transition_id, from_shot_id, to_shot_id, rally_depth, pressure_level)
            VALUES (@trans_id, @from_shot, @to_shot, @depth, @pressure)
        """
        # Executed within a single, unified Spanner Omni transaction...

Spanner のネイティブ ISO GQL グラフクエリ エンジンを活用することで、コーチや分析プログラムは、大規模なデータベース結合を実行することなく、複雑なディープホップ構造パターンを即座にクエリできます。

-- Find highest-frequency transition sequences leading to player unforced errors
GRAPH PickleballTelemetry
MATCH (s1:Shots)-[t:ShotTransitions]->(s2:Shots)
WHERE s1.shot_type = 'Speedup' AND s2.outcome = 'FAULT_NET'
RETURN s1.speed_mph, s2.from_zone_id, COUNT(*) AS occurrences
ORDER BY occurrences DESC
LIMIT 5;

7. Gemini AI の戦術的コーチングとグラウンディングされた RAG チャット

次に、アプリケーションの頭脳である Google Gemini 3.8 Flash を有効にします。Python バックエンドは、このモデルを使用して、Spanner Omni によって直接グラウンディングされた 3 つのリアルタイム AI 機能を提供します。

このシステムは、いくつかの異なるリアルタイム AI ワークフローに Google Gemini 3.8 Flash を使用します。

リアルタイムのラリー分析と戦術的な審判:

ポイントごとに、Gemini がショットチェーン、ボットの難易度、コートのポジショニングを分析します。

Spanner のマルチテーブル グラウンディング AI コンシェルジュとマッチ アナリスト:

ユーザーは、コートのスケジュールや施設の料金をクエリしたり、「OmniBot との対戦成績はどうだった?」と自然言語で質問したりできます。バックエンドは、4 つのドメイン(Courts、Bookings、GameSessions、Shots)にわたって Spanner Omni に動的にクエリを実行し、ハルシネーションをゼロにして Gemini をグラウンディングします。

リアルタイム パフォーマンス アナリティクス API(/api/performance):

Spanner Omni の GameSessions テーブルから、プレーヤー ダッシュボード用のライブの勝敗比率、平均ラリー深度、最近のゲーム セッションを直接集計します。

現時点では、お客様側での対応は必要ありません。詳細を確認するだけです。

8. 構成、実行、テスト

ローカルマシンで完全なアプリケーションを起動して、システムをエンドツーエンドで検証しましょう。

  1. ホスト ターミナルを開き、プロジェクト リポジトリのクローンを作成します。
git clone https://github.com/AbiramiSukumaran/pickleball

cd pickleball
  1. 次のドキュメントの手順に沿って、Gemini API キーを取得します。

https://medium.com/google-cloud/get-api-key-from-google-ai-studio-b2d2059a171d

  1. エディタで .env ファイルを開き、環境の詳細を構成します。
SPANNER_EMULATOR_HOST="localhost:9010"
SPANNER_PROJECT="PROJECT_ID"
SPANNER_INSTANCE="omni-instance"
SPANNER_DATABASE="pickleball-db"
GEMINI_API_KEY="REPLACE_WITH_YOUR_ACTUAL_GEMINI_KEY"
  1. Python 依存関係をインストールしてアプリケーションを実行します。
pip install -r requirements.txt

python app.py
  1. ウェブブラウザを開き、http://localhost:5000 にアクセスしてプレイを開始しましょう。[予約] タブでコートの時間を予約し、調整可能な DUPR OmniBot と対戦したり、コートサイドでリアルタイムの Gemini Match Analyst に相談したりできます。

ウェブブラウザを開くか、localhost で実行されているアプリへのリンクをクリックします。

a2d18f8288607c66.png

9. 完了

この Codelab を完了したことで、データベース エンジニアリングのパラダイム シフトを目の当たりにしました。長年にわたり、エッジ(工場、軍事区域、コートサイドのスポーツ ネットワークなど)でレスポンシブなリアルタイム エージェントを構築するには、分離されたトランザクション システム、個別のグラフノード、ベクトル検索インデックス間の脆弱な ETL パイプラインを結合するなど、設計上の妥協を強いられてきました。

Spanner Omni は、このトレードオフを解消します。地球規模の分散整合性、ISO GQL グラフクエリ、リレーショナル パフォーマンスを、ダウンロード可能な単一のローカル コンテナに統合します。ドメインに関係なく、Spanner Omni はエッジ エージェントに揺るぎないグラウンド トゥルースを保証します。