Vertex AI Search + Flutter Webで簡単にナレッジ検索RAGアプリを作成してみた

## はじめに

Vertex AI Search と Flutterを使って、独自データを使ったナレッジ検索などができる簡易的なRAGアプリを作成してみました。

本記事では、Vertex AI Searchで簡易的なRAGを作成しアプリに組み込む手順を記載します。

## 対象読者

- Flutterでアプリ開発をしている方
- Vertex AI Searchに興味がある方

## アプリの概要

このアプリは、独自のドキュメントから学習したデータを基に、簡易的なQ&Aやナレッジの検索をするアプリを簡易的に試したものになります。

- ナレッジ検索：Vertex AI Agent BuilderのAPIを呼び出し、検索結果を取得
- 結果表示：APIから取得した結果を画面に表示

## 実装

### 使用技術

- Vertex AI Agent Builder
- Flutter Web

### Agent BuilderでRAGアプリ作成

GCP上の [Vertex AI Agent Builder](https://cloud.google.com/generative-ai-app-builder/docs/introduction?hl=ja) のコンソールからアプリを作成します。

「＋アプリを作成する」を選択します。

![スクリーンショット 2025-03-28 12.07.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314263/803fec8a-a763-4eeb-8f6e-9d710737035b.png)

今回はGoogle Cloud StorageにアップしたドキュメントをRAGとして扱えるようにするので「ウェブサイト検索」を選択します。

![スクリーンショット 2025-03-28 12.08.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314263/77555303-ce9c-480a-86b1-1f28b05dfc03.png)

アプリの構成はそのまま「アプリ名」と「会社名または組織名」を入力します。

![スクリーンショット 2025-03-28 12.09.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314263/1155faf4-dfd2-45e6-adb2-92e11fd275e6.png)

データストアを作成します。
今回はこれがGoogle Cloud Storageに当たります。


![スクリーンショット 2025-03-28 12.09.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314263/73bb1e14-6893-4012-b6c0-2a72aaa46674.png)


![スクリーンショット 2025-03-28 12.10.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314263/e6b56e25-dd10-470b-a4db-ba3870fa8cf7.png)

今回は「非構造化ドキュメント（PDF、 HTML、TXTなど）」で試します。

![スクリーンショット 2025-03-28 12.10.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314263/cc01b10d-dff2-4a5d-bf88-0c6b1cef5472.png)

データストア名を入力したらアプリとデータストアの完成です。

![スクリーンショット 2025-03-28 12.11.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314263/62398568-5ad5-465a-8b75-89ce6dd31fe4.png)

![スクリーンショット 2025-03-28 12.12.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314263/da5c3cf9-f633-4e39-b798-78a4f0fdff44.png)

### API設定の確認

作成したアプリを選択し、「統合」の中のAPIタブで確認できます。
ウィジェットとして検索窓を追加する方法や、Python、Java、Node.js などの言語での接続方法も記載されていますが、今回はAPIでの接続をベースにFlutter Webからの接続を試していきます。

![スクリーンショット 2025-04-02 9.24.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314263/e7ba7881-419a-4bfc-98cf-257b13550293.png)

### Flutter Webの設定

今回はRAGの動作検証のため、Flutter側の実装はmain.dartのみで行ったサンプルを記載します。
Agent Builderで作成したAPIを実行して画面に検索結果を表示します。
GCPにて事前にサービスアカウントの作成が必要になります。

```dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:googleapis_auth/auth_io.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'RAG Search',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  // 検索結果を格納する変数
  String _response = '';
  // 検索テキスト入力欄のコントローラー
  final TextEditingController _queryController = TextEditingController();
  // 検索中かどうかのローディング用フラグ
  bool isLoading = false;

  @override
  void initState() => super.initState();

  /// データ取得処理
  Future<void> _fetchData() async {
    setState(() => isLoading = true);
    try {
      String accessToken = await _getAccessToken();

      final dio = Dio();
      dio.options.headers['Content-Type'] = 'application/json';
      dio.options.headers['Authorization'] = 'Bearer $accessToken';

      // Agent Builderで作成したアプリの「統合 > APIタブ」を参照してエンドポイントを設定
      final response = await dio.post(
        'https://discoveryengine.googleapis.com/v1alpha/projects/xxxxx/default_search:search',
        // ※dataは一例です
        data: {
          'query': _queryController.text,
          'pageSize': 10,
          'queryExpansionSpec': {'condition': 'AUTO'},
          'spellCorrectionSpec': {'mode': 'AUTO'},
          'contentSearchSpec': {
            'summarySpec': {
              'summaryResultCount': 5,
              'modelSpec': {'version': 'preview'},
              'ignoreAdversarialQuery': true,
              'includeCitations': true,
            },
            'snippetSpec': {'returnSnippet': true},
            'extractiveContentSpec': {'maxExtractiveAnswerCount': 1},
          },
        },
      );

      // 結果を表示用の変数に上書きする
      setState(() {
        final responseData = response.data;
        final extractiveAnswersContent = responseData['summary']['summaryText'];
        _response = extractiveAnswersContent;
      });
    } catch (e) {
      setState(() => _response = 'Error: $e');
    } finally {
      setState(() => isLoading = false);
    }
  }

  /// アクセストークン取得処理
  Future<String> _getAccessToken() async {
    // assetsフォルダなどに配置したサービスアカウントの認証情報を取得
    String jsonString = await rootBundle.loadString('assets/xxxxx.json');
    Map<String, dynamic> jsonData = json.decode(jsonString);
    // サービスアカウントの認証情報を設定
    final credentials = ServiceAccountCredentials(
      jsonData['client_email'],
      ClientId(jsonData['client_id']),
      jsonData['private_key']
    );

    // アクセストークンの取得
    final client = await clientViaServiceAccount(credentials, [
      'https://www.googleapis.com/auth/cloud-platform',
    ]);
    final accessToken = (client.credentials).accessToken.data;
    return accessToken;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: PreferredSize(
        preferredSize: const Size.fromHeight(80.0),
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Container(height: 20.0),
              AppBar(
                title: const Text('RAG Search'),
              ),
            ],
          ),
        ),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              ConstrainedBox(
                constraints: const BoxConstraints(maxWidth: 800),
                // 検索入力欄
                child: Row(
                  children: [
                    Expanded(
                      child: TextField(
                        controller: _queryController,
                        decoration: const InputDecoration(
                          labelText: '検索入力欄',
                          border: UnderlineInputBorder(),
                        ),
                      ),
                    ),
                    const SizedBox(width: 16),
                    SizedBox(
                      height: 56,
                      child: ElevatedButton(
                        onPressed: isLoading ? null : _fetchData,
                        style: ElevatedButton.styleFrom(
                          padding: const EdgeInsets.symmetric(horizontal: 32.0),
                          textStyle: const TextStyle(fontSize: 18),
                        ),
                        child: isLoading ? const CircularProgressIndicator(strokeWidth: 5) : const Text('検索'),
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 16),
              // 検索結果表示
              if (_response.isNotEmpty)
                ConstrainedBox(
                  constraints: const BoxConstraints(maxWidth: 800),
                  child: Card(
                    elevation: 4,
                    margin: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0),
                    child: Padding(
                      padding: const EdgeInsets.all(16.0),
                      child: Text(
                        _response,
                        style: const TextStyle(fontSize: 16.0),
                      ),
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}
```

検索欄と検索結果表示飲みのシンプルな構成ですがAgent Builderからの検索結果を表示することの確認ができました。

![スクリーンショット 2025-04-03 10.04.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314263/7eb99bfe-4165-450c-9a49-c0f7f0dcd057.png)

※黒塗り部分は検索結果部分になります。

## 参考資料
[Vertex AI Agent Builder](https://cloud.google.com/generative-ai-app-builder/docs/introduction?hl=ja&_gl=1*1evwa7k*_ga*MjExNDY5NzQ4My4xNjQ5MjkzNTky*_ga_WH2QY8WWF5*MTc0MzU1MzMwNS4zMTkuMS4xNzQzNTUzMzMwLjM1LjAuMA..) ドキュメント

## おわりに

Vertex AI Agent Builderを使用することで、簡単に独自のRAGを作成し、例えば社内向けデータを使ったナレッジ検索用のRAGなどを簡単に構築しアプリに統合する方法の検証をすることができました。

今回の検証ではAgent Builderの簡単な扱い方で試しましたが、もっと複雑なケースなどに合わせて柔軟に実装することができそうでした。

Cloud Storageに追加するだけでRAG精度の調整など検証できていない部分があるので試していこうと思います。