🌐

Next.jsのEdge RuntimeでGoogle Vertex AI Search and Conversationを使う

2024/01/19に公開

はじめに

GoogleのVertex AI Search and Conversationを使うと、Webページのベクトル化、APIによる関連チャンクの検索が簡単に実現できます。これをLLMに連携させれば、Webページを外部ナレッジにしたRAGの完成です。

https://cloud.google.com/vertex-ai-search-and-conversation?hl=ja

...ところが、チャットをStreamingをしたい、具体的にはNext.jsのEdge RuntimeでVercel AI SDKを使った実装をしたい場合、詰まります。Googleの認証の強い味方であったはずのgoogle-auth-libraryが、Edge Runtimeで動かないためです。[1]

というわけで、これはEdge RuntimeでGoogle Vertex AI Search and Conversation APIを使ってみる記事です。

本記事の結論

  • google-auth-libraryはEdge Runtime非対応
  • サービスアカウントによる認証をfetchで行うことで実現できる。
  • web-auth-libraryというライブラリを使うことでも実現できる。

本記事のスコープ

扱うこと

  1. Node.js環境でGoogle Auth Libraryを使い、認証してみる
  2. Edge RuntimeでGoogle Auth Libraryを使い、認証してみる
  3. Edge RuntimeでGoogle Auth Libraryを使わずに、認証してみる
  4. Edge RuntimeでVertex AI Search and Conversation APIをcallする

扱わないこと

  • gcloud CLIのインストール
  • Vertex AI Search and Conversationの説明
  • Vertex AI Search and Conversationの「ウェブサイトの高度なインデックス登録」のセットアップ
  • Next.jsについて
  • 実運用レベルでのセキュリティ検証

環境

  • Node v18.19.0
  • Next.js 14.0.4
  • google-auth-library 9.4.1
  • 「ウェブサイトの高度なインデックス登録」を済ませたVertex AI Search and Conversationのデータストア

セットアップ

さっそく実験用のNext.jsプロジェクトを作成します。

$npx create-next-app@latest

各種設定はデフォルトのままNext.jsプロジェクトを作成します。

package.jsonに下記を追加し、依存ライブラリをインストールしておきます。本記事ではpnpmを使います。

  "dependencies": {
    "dotenv": "^16.3.1",
    "google-auth-library": "^9.4.1",
    "jose": "^5.1.3",
    "next": "14.0.4",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "eslint": "^8",
    "eslint-config-next": "14.0.4",
    "postcss": "^8",
    "tailwindcss": "^3.3.0",
    "typescript": "^5"
  }

検証

0. Google Cloud CLIにログイン

$gcloud auth init

Vertex AI Search and Conversationをセットアップしたプロジェクトで設定。

$gcloud auth application-default login

Application Default Credentials (ADC)をセットアップします。
https://cloud.google.com/generative-ai-app-builder/docs/authentication#local-development

1. Node.js環境でGoogle Auth Libraryを使い、認証してみる

まずはNode.js環境でtokenを取得してみます。

/src/app/api/auth/authlib/route.tsに下記を追加します。

/src/app/api/auth/authlib/route.ts
import { GoogleAuth } from "google-auth-library"

export async function GET() {
  const auth = new GoogleAuth();
  const client = await auth.getClient();
  return Response.json({ token: await client.getAccessToken() })
}
$pnpm dev

ブラウザでhttp://localhost:3000/api/auth/authlibにアクセスすると、認証情報が返ってきます。

{
  "token": {
    "token": "REDACTED",
    "res": {
      "config": {
        "method": "POST",
        "url": "https://oauth2.googleapis.com/token",
        "data": "REDACTED",
        "headers": {
          "Content-Type": "application/x-www-form-urlencoded",
          "User-Agent": "REDACTED",
          "x-goog-api-client": "REDACTED"
        },
        "body": "REDACTED",
        "responseType": "unknown"
      },
      "data": {
        "access_token": "REDACTED",
        "scope": "REDACTED",
        "token_type": "Bearer",
        "id_token": "REDACTED",
        "expiry_date": REDACTED,
        "refresh_token": "REDACTED"
      },
      "headers": {
        "alt-svc": "REDACTED",
        "cache-control": "REDACTED",
        "content-encoding": "REDACTED",
        "content-type": "REDACTED",
        "date": "REDACTED",
        "expires": "REDACTED",
        "pragma": "REDACTED",
        "server": "REDACTED",
        "transfer-encoding": "REDACTED",
        "vary": "REDACTED",
        "x-content-type-options": "REDACTED",
        "x-frame-options": "REDACTED",
        "x-xss-protection": "REDACTED"
      },
      "status": 200,
      "statusText": "OK",
      "request": {
        "responseURL": "https://oauth2.googleapis.com/token"
      }
    }
  }
}

2. Edge RuntimeでGoogle Auth Libraryを使い、認証してみる

次に、export const runtime = "edge"を加えて検証します。

api/auth/edge/route.ts
import { GoogleAuth } from "google-auth-library"

export const runtime = "edge"

export async function GET() {
  const auth = new GoogleAuth();
  const client = await auth.getClient();
  return Response.json({ token: await client.getAccessToken() })
}

ブラウザでhttp://localhost:3000/api/auth/edgeにアクセスすると下記のエラーが出ます。

Error: Module not found: Can't resolve 'http'

これはNode.jsのhttpはEdge Runtimeでは使えないためです。

3. Edge RuntimeでGoogle Auth Libraryを使わずに、認証してみる

解決策は、ライブラリを使わずfetchを使って実装することです。

まずGCPでサービスアカウントを作成し、認証キーをsrc/secret/service_account_key.jsonとして保存します。[2]


このキーを用いてJWT認証を実装します。(コードはこちらの記事にほぼ従っています。)

/src/app/api/auth/edge/getToken.ts
import { SignJWT, importPKCS8 } from "jose";
import GoogleServiceAccountCredentials from "../../../secret/service_account_key.json"

type Token = {
  access_token: string;
  expires_in: number;
  token_type: string;
};

type TokenWithExpiration = Token & {
  expires_at: number;
};

const payload = {
  iss: GoogleServiceAccountCredentials.client_email,
  scope: "https://www.googleapis.com/auth/cloud-platform",
  aud: "https://www.googleapis.com/oauth2/v4/token",
  exp: Math.floor(Date.now() / 1000) + 60 * 60,
  iat: Math.floor(Date.now() / 1000),
};

let token: TokenWithExpiration | null = null;

async function createToken() {
  const rawPrivateKey = GoogleServiceAccountCredentials.private_key.replace(/\\n/g, "\n");
  const privateKey = await importPKCS8(rawPrivateKey, "RS256");

  const token = await new SignJWT(payload)
    .setProtectedHeader({ alg: "RS256" })
    .setIssuedAt()
    .setIssuer(GoogleServiceAccountCredentials.client_email)
    .setAudience("https://www.googleapis.com/oauth2/v4/token")
    .setExpirationTime("1h")
    .sign(privateKey);

  const form = {
    grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
    assertion: token,
  };

  const tokenResponse = await fetch("https://www.googleapis.com/oauth2/v4/token", {
    method: "POST",
    body: JSON.stringify(form),
    headers: { "Content-Type": "application/json" },
  });

  const json = (await tokenResponse.json()) as Token;

  return {
    ...json,
    expires_at: Math.floor(Date.now() / 1000) + json.expires_in,
  };
}

export async function authenticate(): Promise<Token> {
  if (token === null) {
    token = await createToken();
  } else if (token.expires_at < Math.floor(Date.now() / 1000)) {
    token = await createToken();
  }
  return token;
}

route.tsを修正します。

/src/app/api/auth/edge/route
import { authenticate } from "./getToken"

export const runtime = "edge"

export async function GET() {
  const res = await authenticate()
  return Response.json({ res })
}

http://localhost:3000/api/auth/edgeにアクセス、access_tokenを取得できました。

4. Edge RuntimeでVertex AI Search and Conversation APIをcallする

認証に成功したので、Vertex AIのAPIをcallします。
コードはクライアントライブラリのサンプルを見ながら、Edge Runtimeで使えるfetchを使った実装にしています。
https://cloud.google.com/generative-ai-app-builder/docs/libraries#client-libraries-usage-nodejs

extractiveContentSpecを指定して関連するチャンクであるextractive answerを取得し、返り値はContextというinterfaceになるようにparseしています。

.env
NEXT_PUBLIC_PROJECT_ID=<Vertex AIをセットアップしたプロジェクトID>
NEXT_PUBLIC_COLLECTION_ID=default_collection
NEXT_PUBLIC_DATASTORE_ID=<データストアのID。Vertex AIの管理画面で取得できます。>
/src/app/api/auth/edge/search.ts
import { authenticate } from "./getToken"

interface Context {
  id?: string
  title: string
  description: string
  score?: number
  metadata?: {
    source_id?: string
    source_name?: string
    category?: string
    text?: string
    url?: string
  }
}

export async function search({ query }: { query: string }): Promise<Context[]> {
  const projectId = process.env.NEXT_PUBLIC_PROJECT_ID;
  //const region = 'global';              // Options: 'global', 'us', 'eu'
  const collectionId = process.env.NEXT_PUBLIC_COLLECTION_ID;     // Options: 'default_collection'
  const dataStoreId = process.env.NEXT_PUBLIC_DATASTORE_ID     // Create in Cloud Console
  //const servingConfigId = 'default_config';      // Options: 'default_config'

  // For more information, refer to:
  // https://cloud.google.com/generative-ai-app-builder/docs/locations#specify_a_multi-region_for_your_data_store
  const url = `https://discoveryengine.googleapis.com/v1alpha/projects/${projectId}/locations/global/collections/${collectionId}/dataStores/${dataStoreId}/servingConfigs/default_search:search`
  // https://cloud.google.com/generative-ai-app-builder/docs/reference/rest/v1alpha/projects.locations.collections.dataStores.servingConfigs#extractivecontentspec
  const reqBody = {
    query,
    pageSize: 3,
    queryExpansionSpec: { condition: "AUTO" },
    spellCorrectionSpec: { mode: "AUTO" },
    contentSearchSpec: {
      snippetSpec: { returnSnippet: true },
      extractiveContentSpec: {
        maxExtractiveAnswerCount: 3
      }
    }
  }
  const { access_token } = await authenticate()

  const res = await fetch(
    url,
    {
      method: "POST",
      headers: {
        'Authorization': `Bearer ${access_token}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify(reqBody)
    });

  const data = await res.json() as any

  if (!data) {
    return []
  }

  const contexts: Context[] = []
  if ("results" in data) {
    const results = data.results
    if (!results) {
      return []
    }
    for (const result of results) {
      const id = result.id
      const fields = result.document?.derivedStructData
      if (!fields) continue
      const text = fields.extractive_answers[0].content
      if (!text) continue
      const snippet = fields.snippets[0].snippet

      const title = fields.title || ""
      const url = fields.link || ""

      contexts.push({
        id,
        title,
        description: snippet,
        metadata: {
          url,
          text
        }
      })
    }
  }
  return contexts
}

これをimportするようにroute.tsを修正します。

/src/app/api/auth/edge/route
import { search } from "./search"

export const runtime = "edge"

export async function GET() {
  const res = await search({ query: "2023特級グランプリ" })
  return Response.json({ res })
}

筆者のデータストアは https://compe.piano.or.jp/ をindexしているので、2023年度のピティナ・ピアノコンペティションの特級グランプリ(優勝者)をqueryに入れています。

http://localhost:3000/api/auth/edge にアクセスすると、下記の結果が返ってきました。

{
  "res": [
    {
      "id": "eb5fda64533cb00854ed148ba622e627",
      "title": "特級 TOP|コンペティション",
      "description": "ピティナ・ピアノコンペティション &middot; <b>2023</b>/08/18 <b>2023特級ファイナリスト</b>決定! &middot; <b>2023</b>/08/17 <b>特級</b>セミファイナル 当日券情報 &middot; <b>2023</b>/08/10 <b>2023特級</b>セミファイナル新曲は、&nbsp;...",
      "metadata": {
        "url": "https://compe.piano.or.jp/event/tokkyu/",
        "text": "<b>特級</b> TOP|コンペティション ピティナ・ピアノコンペティション ピティナとは マイページ ピティナ トップ twitter instagram youtube コンペとは 趣旨・特長 開催概要 申込方法 今年の変更点 参加要項 訂正とお詫び 開催日程 地区選択上の注意 地区予選 地区本選 準本選 全国大会 課題曲 演奏上の注意 ソロ部門(A2-F級) デュオ部門 グランミューズ部門 <b>特級</b>・Pre<b>特級</b>・G級 版の違いについて 審査結果 <b>2023</b>年度(第47回) 過去の審査結果 お問合せ・変更申請 資料請求 各種手続き・変更申請 よくあるご質問 お問合せフォーム 関連イベント 課題曲チャレンジ 準本選 <b>特級 特級</b>お手紙企画 Jr.G級マスタークラス 福田靖子賞選考会 課題曲説明会 新曲課題曲募集 提携コンクール フォトコンテスト 課題曲ミュージアム トップ 関連イベント <b>特級 特級</b> TOP ピティナ・ピアノコンペティション<b>特級</b> Home 日程・チケット <b>特級</b>とは 結果・演奏順 MOVIE <b>特級</b>応援 <b>特級</b>サポーター賞 投票ありがとうございました 最終結果発表 <b>特級</b>最終結果 更新情報 <b>2023</b>/08/18 <b>2023特級ファイナリスト</b>決定! <b>2023</b>/08/17 <b>特級</b>セミ<b>ファイナル</b> 当日券情報 <b>2023</b>/08/10 <b>2023特級</b>セミ<b>ファイナル</b>新曲は、片山柊さんに作曲を委嘱しました <b>2023</b>/08/01 <b>2023特級</b>セミ<b>ファイナル</b>、演奏順・スケジュール <b>2023</b>/07/30 <b>2023特級</b>三次予選、演奏順・スケジュールと演奏曲目 <b>2023</b>/07/27 <b>2023特級</b> 2~3次予選 まもなく開催 <b>2023</b>/07/15 <b>2023特級</b>二次予選演奏順発表! <b>2023</b>/07/06 <b>2023特級</b>、いよいよ開幕 <b>2023</b>/07/06 <b>特級</b>二次予選進出者 一次予選演奏動画 連続プレミア公開! <b>2023</b>/07/06 ピティナ<b>特級2023</b> 公式レポーター募集! <b>2023</b>/07/06 全国大会出場者&指導者賞受賞者 <b>特級ファイナル</b>ご招待のご案内 <b>2023</b>/07/04 結果発表:<b>特級</b>一次(動画審査) ◆ ..."
      }
    },
    {
      "id": "7a4747b2f3e11e07a9709939d7939e9d",
      "title": "2023最終審査結果一覧|コンペティション",
      "description": "<b>特級</b>グランプリ:鈴木 愛美 東京音楽大学 4年生. ピティナ ピアノチャンネル PTNA. 150K subscribers. <b>PTNA2023特級ファイナル</b> ... Pre<b>特級</b> &middot; <b>特級</b>. デュオ部門. 連弾初級A&nbsp;...",
      "metadata": {
        "url": "https://compe.piano.or.jp/result/2023/index.html",
        "text": "<b>2023</b>最終審査結果一覧|コンペティション ピティナ・ピアノコンペティション ピティナとは マイページ ピティナ トップ twitter instagram youtube コンペとは 趣旨・特長 開催概要 申込方法 今年の変更点 参加要項 訂正とお詫び 開催日程 地区選択上の注意 地区予選 地区本選 準本選 全国大会 課題曲 演奏上の注意 ソロ部門(A2-F級) デュオ部門 グランミューズ部門 <b>特級</b>・Pre<b>特級</b>・G級 版の違いについて 審査結果 <b>2023</b>年度(第47回) 過去の審査結果 お問合せ・変更申請 資料請求 各種手続き・変更申請 よくあるご質問 お問合せフォーム 関連イベント 課題曲チャレンジ 準本選 <b>特級 特級</b>お手紙企画 Jr.G級マスタークラス 福田靖子賞選考会 課題曲説明会 新曲課題曲募集 提携コンクール フォトコンテスト 課題曲ミュージアム トップ 審査結果 <b>2023</b>年度審査結果 <b>2023</b>最終審査結果一覧 <b>2023</b> 最終審査結果一覧 <b>特級</b>グランプリ:鈴木 愛美 東京音楽大学 4年生 ピティナ ピアノチャンネル PTNA 150K subscribers PTNA2023<b>特級ファイナル</b>グランプリ ベートーヴェン:ピアノ協奏曲 第4番,Op.58 鈴木 愛美:Suzuki, Manami ピティナ ピアノチャンネル PTNA Search Info Shopping Tap to unmute If playback doesn&#39;t begin shortly, try restarting your device. Your browser can&#39;t play this video. Learn more More videos on YouTube Share Include playlist An error occurred while retrieving sharing information. Please try again later. Watch later Share Copy link Watch on 0:00 / • Live ..."
      }
    },
    {
      "id": "0ba449a6e449a0e90a53694df8cb3f65",
      "title": "2023最終審査結果|コンペティション",
      "description": "ピティナ・ピアノコンペティション ; <b>特級</b>グランプリ &middot; 鈴木 愛美 Ms Manami SUZUKI ; 銀賞 &middot; 三井 柚乃 Ms Yuno MITSUI ; 銅賞 &middot; 神原 雅治 Mr. Masaharu KAMBARA ; 入賞 &middot; 嘉屋&nbsp;...",
      "metadata": {
        "url": "https://compe.piano.or.jp/event/tokkyu/result.html",
        "text": "<b>2023</b>最終審査結果|コンペティション ピティナ・ピアノコンペティション ピティナとは マイページ ピティナ トップ twitter instagram youtube コンペとは 趣旨・特長 開催概要 申込方法 今年の変更点 参加要項 訂正とお詫び 開催日程 地区選択上の注意 地区予選 地区本選 準本選 全国大会 課題曲 演奏上の注意 ソロ部門(A2-F級) デュオ部門 グランミューズ部門 <b>特級</b>・Pre<b>特級</b>・G級 版の違いについて 審査結果 <b>2023</b>年度(第47回) 過去の審査結果 お問合せ・変更申請 資料請求 各種手続き・変更申請 よくあるご質問 お問合せフォーム 関連イベント 課題曲チャレンジ 準本選 <b>特級 特級</b>お手紙企画 Jr.G級マスタークラス 福田靖子賞選考会 課題曲説明会 新曲課題曲募集 提携コンクール フォトコンテスト 課題曲ミュージアム トップ 関連イベント <b>特級 2023</b>最終審査結果 ピティナ・ピアノコンペティション<b>特級</b> Home 日程・チケット <b>特級</b>とは 結果・演奏順 MOVIE <b>特級</b>応援 <b>特級</b>サポーター賞 投票ありがとうございました 最終結果 <b>特級</b>グランプリ 顔写真 鈴木 愛美 Ms Manami SUZUKI <b>ファイナル</b>演奏曲目 ベートーヴェン/ピアノ協奏曲 第4番 ト長調 Op.58 詳細・プロフィール 銀賞 顔写真 三井 柚乃 Ms Yuno MITSUI <b>ファイナル</b>演奏曲目 ラフマニノフ/ピアノ協奏曲 第2番 ハ短調 Op.18 詳細・プロフィール 銅賞 顔写真 神原 雅治 Mr. Masaharu KAMBARA <b>ファイナル</b>演奏曲目 ラフマニノフ/ピアノ協奏曲 第2番 ハ短調 Op.18 詳細・プロフィール 入賞 顔写真 嘉屋 翔太 Mr. Syota KAYA <b>ファイナル</b>演奏曲目 サン=サーンス/ピアノ協奏曲 第2番 ト短調 Op.22 詳細・プロフィール 聴衆賞 1位 鈴木 愛美 2位 三井 柚乃 3位 嘉屋 翔太 4位 神原 雅治 サポーター賞 1位 塩﨑 基央 2位 小野寺 拓真 3位 鈴木 愛美 4位 嘉屋 翔太 5位 小野田 ..."
      }
    }
  ]
}

"ピティナ・ピアノコンペティション ; <b>特級</b>グランプリ · 鈴木 愛美 Ms Manami SUZUKI ;

とあるので、これでピティナ・ピアノコンペティションを知らないLLMでも、2023年度の特級グランプリが鈴木 愛美さんとわかるでしょう。

https://www.youtube.com/watch?v=dSQhgOTf10A

おまけ:web-auth-library

google-auth-libraryをNode.js以外で使いたいと考える人はもちろん他にもいて、web-auth-libraryというライブラリがあります。
https://github.com/kriasoft/web-auth-library

ライブラリを使うと簡単に書けます。

$pnpm install web-auth-library 
import { getAccessToken } from "web-auth-library/google";
import GoogleServiceAccountCredentials from "../../../secret/service_account_key.json"

export async function authenticate() {
  const access_token = await getAccessToken({
    credentials: GoogleServiceAccountCredentials,
    scope: "https://www.googleapis.com/auth/cloud-platform",
  });
  return { access_token }
}

これでEdge Runtimeでも認証が可能です。langchainもこのライブラリを使ってEdge Runtime対応をしているようです。[3]

おわりに

このようにgoogle-auth-libraryを使わないことで、Edge RuntimeでもGoogle Vertex AI Search and Conversationを使うことができました。

実際の運用にあたってはサービスアカウント認証であること(Workload Identity連携でない)、実際にたとえばVercelで使う場合の実験など、いくつか課題があります。現実的にはApplication Default Credentialsが使えるCloud Runなど中間APIを挟んで別の認証を用いるのも選択肢だと思います。

前提知識が多く、ニッチな記事かもしれませんが、もしどなたかの参考になれば幸いです。
最後までお読みいただきありがとうございました。

参考・関連記事

https://sdorra.dev/posts/2023-08-03-google-auth-on-the-edge
https://nextjs.org/docs/pages/api-reference/edge
https://cloud.google.com/discovery-engine/media/docs/authentication
https://cloud.google.com/generative-ai-app-builder/docs/preview-search-results

脚注
  1. Vertex AIと連携しているLangchainでもIssueが上がっており、一部対応されています。
    https://github.com/langchain-ai/langchainjs/pull/2579
    https://github.com/langchain-ai/langchainjs/issues/3021 ↩︎

  2. サービスアカウントのキー認証はセキュリティ的にベストな認証ではありません。推奨はWorkload Identity連携ですが、Vercelでどう認証するのか、浅学にしてわかりませんでした。 ↩︎

  3. https://github.com/langchain-ai/langchainjs/pull/2579 ↩︎

Discussion