Gemini Enterpriseで社内Notionを検索可能に―OAuthとACLで権限を維持(気合い)
本記事は、Luup Advent Calendar 2025の5日目の記事になります。
こんにちは、株式会社Luupの栗村です。
本記事の内容は、12/05に開催されたTECH PLAY 生成AI Conferenceのスピンオフ記事で、Gemini EnterpriseとNotionとの連携について詳説しています。
1. Gemini Enterpriseと情報検索の課題
1.1 Gemini Enterpriseとは
Gemini Enterpriseは、ドキュメントツールやコミュニケーションシステムなどのさまざまなデータソースに接続し、分散した情報を一元的に検索・活用できる生成AIプラットフォームです。
GeminiやChatGPTなどは、インターネット上に公開されたデータや「自分」が連携したデータに基づいて回答しますが、「会社」が連携したデータも含めて回答してくれるようになるということです。
特徴としては以下が挙げられます。
- 統合検索:複数のデータソースを横断検索(ドライブ、Slack、GitHub、データベースなど)
- AIアシスタント:Geminiによる要約・質問応答
- アクセス制御:Google Workspace Identityと連携した細かい権限管理
- エンタープライズ対応:セキュリティ、コンプライアンス、監査ログ
1.2 Notionを統合する理由
社内で先にSlackなどに接続していましたが、Google Driveと並んで社内のドキュメントを管理しているNotionが連携されることによる情報検索の利便性向上は大きそうだということがわかってきました。
- 情報の分散(各メンバーが個別のNotionワークスペースで情報管理)
- エンジニアリングチーム:技術仕様、アーキテクチャ設計
- ビジネスチーム:営業資料、提案書
- オペレーション(修理や充電など)チーム:マニュアルの浸透、イレギュラー対応
- 検索の困難さ
- 「あの人が書いたNotionページ、どこだっけ?」
- 「プロジェクトの議事録、複数のワークスペースに散らばっている」
- Notion内検索では自分のワークスペースしか検索できない
1.3 要件の整理
"NotionデータをカスタムコネクタとしてGemini Enterpriseに統合し、権限を保ったまま全社横断検索を実現する"
- メンバーごとに異なるNotionワークスペースからデータ取得
- 各メンバーが自分のデータのみ閲覧可能(ACL制御)
- 日々更新されるコンテンツを定期的に反映
- Gemini Enterpriseのスキーマモデルに準拠
2. Gemini Enterpriseの構造化データとスキーマ設計
2.1 構造化データとは
Gemini Enterpriseでは、データソースからのデータを構造化データとして取り込むことで、検索精度とAI要約の品質を向上させることができます。
非構造化データもうまくはまると楽ですが、どういう形であれば上手く拾われ、上手く検索結果に表示されるのかが見えてこなかったため、構造化データでの整備に切り替えました。
構造化データを利用するとメリットは以下のように整理できます。
- 検索精度向上:フィールドごとに検索可能(例:タイトルのみ検索、作成者で絞り込み)
- ファセット検索:カテゴリー、日付範囲などでフィルタリング
- AI要約の品質向上:Geminiが各フィールドの意味を理解して要約
- 並べ替え:作成日時、更新日時などでソート可能
2.2 スキーマの2つの指定方法
スキーマの指定方法には2つの方法があります。
高度に正規化されたNULLの有無や型のブレのないデータであれば自動検出でも問題ありません。ただ、BigQueryの自動スキーマ判定と同様にデータの一部をサンプリングしているだけなので、想定外の型推論が発生することもあります。例えば、先頭に日付が来るようなタイトル「2025/12/01 部署定例」のような文字列は、datetime型でパースされてしまうことがあり、やや使いづらい印象です。
そのため、今回は方法Bの事前指定方式で整備しました。
方法A:自動検出 + 編集
1. BigQueryにデータをインポート
↓
2. Gemini Enterpriseが最初の数ドキュメントをサンプリング
↓
3. スキーマを自動生成して提案
↓
4. コンソールでスキーマを編集
- keyPropertyMappingを設定
- retrievable、indexableなどの属性を追加
方法B:JSON スキーマを事前指定(推奨)
# 1. データストア作成
curl -X POST \
"https://discoveryengine.googleapis.com/v1/projects/PROJECT_ID/.../dataStores" \
-d '{ "displayName": "Notion Documents", "industryVertical": "GENERIC" }'
# 2. スキーマを明示的に指定
curl -X PATCH \
"https://discoveryengine.googleapis.com/v1beta/.../schemas/default_schema" \
-d '{
"structSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"dynamic": "true",
"datetime_detection": true,
"properties": {
"title": {
"type": "string",
"keyPropertyMapping": "title",
"retrievable": true,
"completable": true
},
"uri": {
"type": "string",
"keyPropertyMapping": "uri"
},
"description": {
"type": "string",
"keyPropertyMapping": "description",
"searchable": true
}
}
}
}'
2.3 キープロパティマッピング
地味に重要な作業としてキープロパティマッピングというものがあります。データのフィールドを、Gemini Enterpriseが認識する標準的な意味にマッピングする仕組みで、検索品質と予測入力の精度を向上するとのことなのでやらない手はありません。
4つのキープロパティ:
{
"properties": {
"my_page_title": {
"type": "string",
"keyPropertyMapping": "title" // ← フィールド名は任意、意味を "title" に紐付け
},
"page_url": {
"type": "string",
"keyPropertyMapping": "uri" // ← URLとして扱われる
},
"content": {
"type": "string",
"keyPropertyMapping": "description" // ← 本文コンテンツ
},
"tags": {
"type": "array",
"items": {
"type": "string",
"keyPropertyMapping": "category" // ← カテゴリ・タグ
}
}
}
}
keyPropertyMappingをすると以下のような効果があります。
- 検索精度の向上:Gemini Enterpriseが各フィールドの意味を正しく理解し、タイトル・URL・説明文が適切に表示される
- 予測入力(オートコンプリート)の精度向上と機能実装
- AI要約の品質向上:タイトルと本文が正しく認識される
- ファセット検索(カテゴリー、日付範囲など)が可能になる
- 自動的に
indexable: true、searchable: trueが設定される
実際にこれを行ったデータはGemini Enterprise上で以下のように見えます。

2.4 スキーマフィールド属性
仕様書の通りですが、属性は細かく設定が可能です。検索の性能や特殊な情報を扱う場合は確認してみるといいでしょう。
Key propertiesに設定している場合は変更ができない点にも注意が必要です。重要なプロパティだが、カスタムなファセット設定などしたい場合は同じプロパティを複製して別の名前で保存するなどの必要があります。
| 属性 | 説明 | 対象型 | デフォルト | 上限 |
|---|---|---|---|---|
keyPropertyMapping |
標準的な意味に紐付け(title、uri、description、category) |
string、array
|
なし | - |
retrievable |
検索レスポンスで返す |
string、number、boolean、integer、datetime、geolocation
|
false | 50個 |
indexable |
フィルタ、ファセット、ブースト、並べ替え可能 | 同上 | false(keyPropertyMapping付きは true) | 50個 |
searchable |
非構造化テキストクエリで検索可能 |
string のみ |
false(keyPropertyMapping付きは true) | 50個 |
completable |
予測入力候補として返す |
string のみ |
false | - |
dynamicFacetable |
動的ファセットとして使用可能(indexable: true も必要) |
string、number、boolean、integer
|
false | - |
スキーマレベルの設定:
| 属性 | 説明 | デフォルト |
|---|---|---|
dynamic |
新しいフィールドを自動的にスキーマに追加するか | "true" |
datetime_detection |
日時形式(RFC 3339、ISO 8601)を自動検出 | true |
geolocation_detection |
緯度経度、住所を自動検出 | true |
2.5 スキーマ設計の勘所
Gemini Enterpriseのカスタムコネクターのスキーマ定義はやや情報が乏しく苦戦しました。
今回のケースはBigQueryに情報を格納し、そこからGemini Enterpriseのデータコネクターとして(おそらく内部的にはMatching Engine?に)インジェストされます。
そのときの形式を上述の構造化データに当てはめる方法が良いのですが、BigQueryへの連携はJSONをStringifyしたデータである必要があります。
// BigQueryに保存
const document = {
id: "notion_page_123",
jsonData: JSON.stringify(data), // ← 文字列化する
acl_info: { ... }
};
なお、BigQueryに保存する際には、Cloud KMS(Cloud Key Management Service)を利用してデータを暗号化できます。Gemini Enterpriseは暗号化されたデータを自動的に復号して検索できるため、セキュリティーを保ちながら全文検索が可能です。
このときの data の中身を構造化データの形式にする必要があります。
// トップレベルに配置
const data = {
id: `notion_page_${page.id}`,
// キープロパティ
title: pageTitle, // keyPropertyMapping: "title"
uri: pageUrl, // keyPropertyMapping: "uri"
description: aggregatedContent, // keyPropertyMapping: "description"
category: "page", // keyPropertyMapping: "category"
// その他のフィールド
workspace_id: workspaceId,
created_at: page.created_time,
updated_at: page.last_edited_time,
author: page.created_by?.id || "unknown",
indexed_at: new Date().toISOString(),
acl_info: this.generateAclInfo(),
};
3. データ収集フローの全体像
3.1 Notion API呼び出しフロー
Notionからデータを収集する際のフローを整備します。

ユーザーには事前にOAuthの認証をしてもらうことで、順繰りにNotionのデータを取得できます。
3.2 Cloud SQL → BigQuery データフロー
OAuthを行ったのち、BigQueryに構造化データを保存する流れを示します。

- OAuth認証済みトークン取得:
notion_tokensテーブルからアクセストークンとメールアドレスを取得 - インクリメンタル同期判定:
fetched_notion_pagesテーブルで前回収集時のpage_last_edited_timeを確認 - Notion API呼び出し:ページメタデータ + ブロック(コンテンツ)を取得
- ドキュメント変換:
- ブロックをマークダウンに変換
- ACL情報生成(OAuth所有者のメールアドレス)
- トップレベルフィールド配置(
title,uri,descriptionなど)
- 収集履歴記録:
fetched_notion_pagesテーブルに収集日時とpage_last_edited_timeを記録 - BigQueryWriter呼び出し:
mergeNotionDocument()メソッドでBigQueryに保存 - 既存ドキュメント取得 + ACL重複チェック:同じページIDのドキュメントが既にある場合、ACLをマージ
- MERGE操作:BigQueryに
MERGE INTOで保存(INSERT or UPDATE + ACL追加) - 同期ステータス更新:
fetch_sync_statusテーブルをcompletedに更新 - インジェスト:BigQueryから自動的にGemini Enterpriseに連携され、検索・AI要約が可能に
4. 技術的チャレンジ① メンバーごとに異なるアクセス権限
4.1 Gemini EnterpriseのACLモデル
NotionのOAuth時のメールアドレスとGemini Enterpriseのメールアドレスが一致しているという前提でACLを有効化できます。
1. ユーザーがGemini Enterpriseにログイン
└─ Google Workspace Identity で認証
└─ user_id = yamada@luup.co.jp
2. 検索クエリ実行:"プロジェクトAのドキュメント"
3. Discovery EngineがBigQueryを検索
└─ WHERE acl_info IS NULL -- 全員アクセス可(GitHub)
OR EXISTS (
SELECT 1 FROM UNNEST(acl_info.readers) AS reader
CROSS JOIN UNNEST(reader.principals) AS principal
WHERE principal.user_id = 'yamada@luup.co.jp'
)
4. 検索結果を返す
└─ yamada@luup.co.jpがアクセス可能なドキュメントのみ
ACL構造(BigQueryスキーマ):
{
"acl_info": {
"readers": [
{
"principals": [
{ "user_id": "yamada@luup.co.jp" },
{ "group_id": "engineering@luup.co.jp" }
]
}
]
}
}
4.2 課題の深堀り
複数ユーザーが同じNotionページを収集した場合、そのまま処理すると二度手間、二重管理が発生します。
- ユーザーA(yamada)が
notion_page_123を収集 → ACL:["yamada@luup.co.jp"] - ユーザーB(tanaka)が
notion_page_123を収集 → ACL:["tanaka@luup.co.jp"]
// 問題: 同じページIDで複数レコードが作成される
await bigquery.insert({
id: "notion_page_123",
title: "プロジェクトA設計書",
acl_info: { readers: [{ principals: [{ user_id: "yamada@luup.co.jp" }] }] },
});
await bigquery.insert({
id: "notion_page_123",
title: "プロジェクトA設計書",
acl_info: { readers: [{ principals: [{ user_id: "tanaka@luup.co.jp" }] }] },
});
2レコードBigQueryに格納されても問題はありません。ただ理論上 社員の人数分 x Notionのページ数 の数の処理とレコードが発生することなります。
そうなるとBigQueryからGemini Enterpriseへのインジェストにかかる時間も増えますし、BigQuery上の読み取りコストも増えるはずです。
4.3 解決策:Smart MERGEによるACL管理
MERGE方式にすることによって、処理時間やレコード数の不用意な増加を回避します。
// 解決: MERGEでACLを追加(重複チェック自動化)
await writer.mergeNotionDocument(doc, {
updateContent: true, // コンテンツ更新
appendUserId: "tanaka@luup.co.jp", // ACLにユーザー追加
});
BigQuery MERGE SQL:
MERGE `project.dataset.notion_documents` AS target
USING (
SELECT
@id AS id,
@title AS title,
@uri AS uri,
@description AS description,
@updateContent AS updateContent,
-- 新規ユーザーのACL
STRUCT(
ARRAY(
SELECT AS STRUCT
ARRAY(
SELECT AS STRUCT
p.user_id AS user_id,
p.group_id AS group_id
FROM UNNEST(r.principals) AS p
) AS principals
FROM UNNEST(@acl_info.readers) AS r
) AS readers
) AS acl_info
) AS source
ON target.id = source.id
WHEN MATCHED THEN
UPDATE SET
-- コンテンツ更新(条件付き)
title = CASE WHEN source.updateContent = true THEN source.title ELSE target.title END,
description = CASE WHEN source.updateContent = true THEN source.description ELSE target.description END,
-- ACLマージ(アプリケーション側で重複排除済み)
acl_info = source.acl_info
WHEN NOT MATCHED THEN
INSERT (id, title, uri, description, acl_info)
VALUES (source.id, source.title, source.uri, source.description, source.acl_info)
// 1. 既存ドキュメントを取得
const existingDoc = await bigquery.getNotionDocument(doc.id);
// 2. ACLをマージ(重複チェック)
let finalAclInfo = doc.acl_info;
if (appendUserId && existingDoc?.acl_info) {
const existingPrincipals = existingDoc.acl_info.readers.flatMap(
(r) => r.principals
);
const userExists = existingPrincipals.some((p) => p.user_id === appendUserId);
if (!userExists) {
// 新規ユーザーをACLに追加
finalAclInfo = {
readers: [
{
principals: [
...existingPrincipals,
{ user_id: appendUserId, group_id: null },
],
},
],
};
} else {
// 既にACLに存在 → 既存ACLをそのまま使用
finalAclInfo = existingDoc.acl_info;
}
}
// 3. MERGE実行
await bigquery.query(mergeQuery, {
id,
title,
uri,
description,
acl_info: finalAclInfo,
});
BigQuery自体の基本ではありますが、重複する列が多い場合はArray型を使うなどで保存されるデータ量を減らしたほうが良いでしょう。
4.4 最適化:他ユーザーの収集履歴を活用
同じNotionページを複数のユーザーが収集すると、BigQueryへの書き込みが重複してしまうという課題があります。
そこで、他人がすでに保存したページではないことをCloudSQL上で確認します。
// 他のユーザーが既に収集済みか確認
const otherUserRecord = await this.getOtherUserFetchRecord(pageId);
if (otherUserRecord) {
// コンテンツは既存、ACLのみ追加
await this.writer.mergeNotionDocument(doc, {
updateContent: false, // コンテンツ更新スキップ
appendUserId: currentUser, // 自分のACLだけ追加
});
} else {
// 初回収集 → コンテンツ + ACL両方書き込み
await this.fetchPageFully(page);
}
この実装により、以下のような効果が得られます。
- BigQuery書き込み量を削減(コンテンツの重複書き込み回避)
- DML操作制限(1日1500件)の節約
- API呼び出し削減(ブロック取得スキップ可能)
CloudSQL上では、ページ内容は保存していません。idをキーとして管理しているのみなので、参照ができないページを謝って参照するリスクは一定避けられています。
5. 技術的チャレンジ② Notion側の権限APIが限定的
5.1 Notionの権限関連のAPI仕様
Notionページの共有ユーザーリストをAPIで取得し、それをそのままACL情報として使用できればシンプルです。
// このようなAPIは存在しない
const sharedUsers = await notion.pages.getSharedUsers(pageId);
// → ["yamada@luup.co.jp", "tanaka@luup.co.jp", "engineering@luup.co.jp"]
const acl_info = {
readers: [
{
principals: sharedUsers.map((email) => ({ user_id: email })),
},
],
};
しかし、現実のNotion APIには以下のような制約があります。
- 取得可能:ワークスペース情報、ページメタデータ、ブロックコンテンツ
- 取得不可:ページ単位の共有ユーザーリスト
- 取得不可:ページの個別権限設定
実際のNotion API仕様では、以下のような情報しか取得できません。
// ワークスペース情報取得(所有者のみ)
const workspace = await notion.users.me();
// → { type: "person", person: { email: "yamada@luup.co.jp" } }
// ページ情報取得(共有ユーザー情報なし)
const page = await notion.pages.retrieve({ page_id: "abc123" });
// → { id, title, created_time, last_edited_time, ... }
// → 共有設定は取得できない!
5.2 解決策:OAuth Token所有者ベースのACL
このAPI制約に対して、以下のような設計方針を採用しました。
OAuth認証を行ったユーザー = そのワークスペースのページにアクセス権を持つ
具体的な実装は以下のとおりです。
// 1. OAuth時にワークスペース所有者のメールアドレスを取得
async function handleOAuthCallback(code: string) {
const { access_token } = await exchangeCodeForToken(code);
const notion = new Client({ auth: access_token });
const user = await notion.users.me();
const email = user.person.email; // yamada@luup.co.jp
// PostgreSQLに保存
await db.query(
"INSERT INTO notion_tokens (user_id, email, access_token, workspace_id) VALUES ($1, $2, $3, $4)",
[uuid(), email, access_token, workspace.id]
);
}
// 2. ページデータ取得時にOAuth所有者のメールをACLに設定
class NotionFetcher {
private ownerEmail: string;
constructor(token: string, userId: string, ownerEmail: string) {
this.ownerEmail = ownerEmail; // yamada@luup.co.jp
}
private generateAclInfo() {
return {
readers: [
{
principals: [{ user_id: this.ownerEmail, group_id: null }],
},
],
};
}
}
メリット:
- シンプルな実装(identity_mappingsテーブル不要)
- プライバシー保護(個人のNotionデータは本人のみアクセス)
- 運用負荷削減(グループ管理やマッピング設定が不要)
制約:
- 同じワークスペース内の他のメンバーは検索できない
- チーム共有ページも個人単位でのみ表示
5.3 解決策②:ページごとの収集履歴管理
ページごとの収集履歴を管理することで、以下のような活用が可能になります。
重要なのは、収集履歴の確認をCloud SQL(PostgreSQL)で行うことで、BigQueryへの高コストなクエリを回避できる点です。Cloud SQLは低レイテンシー・低コストでインデックスベースの検索が可能なため、事前チェック層として最適です。
const fetchRecord = await getFetchedPageRecord(pageId); // Cloud SQLへデータ有無確認
if (fetchRecord) {
if (lastEditedTime <= fetchRecord.page_last_edited_time) {
console.log("No updates - skip");
continue;
} else {
console.log("Updated - re-fetch");
}
} else {
console.log("New page - first fetch");
}
ここでは、BigQueryへ問い合わせる前にCloud SQLで前回収集時刻を確認することで、不要なNotion API呼び出しとBigQuery書き込みを回避しています。
また、他ユーザーの収集を活用した最適化も可能です。
// 他のユーザーが既に収集済みか確認
const otherUserRecord = await this.getOtherUserFetchRecord(pageId); // Cloud SQLへデータ有無確認
if (otherUserRecord) {
// 他ユーザーが既に収集済み
// → コンテンツは既存、ACLのみ追加
console.log(`Already fetched by other user: ${otherUserRecord.user_id}`);
await this.writer.mergeNotionDocument(doc, {
updateContent: false, // コンテンツ更新スキップ
appendUserId: this.ownerEmail, // ACLのみ追加
});
} else {
// 初回収集 → 全て処理
await this.fetchPageFully(page);
}
BigQueryのコストはもちろんですが、大量のユーザーが更新を実施するとき、高速に処理できるかは重要です。このアプローチにより、以下のようなメリットが得られます。
- レイテンシー削減:Cloud SQLのインデックス検索(数ms)vs BigQueryのフルスキャン(数百ms〜数秒)
- スループット向上:数十〜数百ユーザー分の履歴確認を並行処理しても、レスポンス時間を一定に保てる
- コスト削減:BigQueryはスキャン量に応じて課金されるため、事前フィルタリングでクエリコストを大幅に削減
- API呼び出し削減:既に収集済みのページは重複した大量のブロック取得APIをスキップ可能
5.4 解決策③:BigQuery DML制限対策
BigQueryには以下のようなDML操作の制限があります。
- 同時DML文:1テーブルあたり最大20件
- DML操作数:1テーブルあたり1日1500件まで
これらの制限を超えると、
Error: Resources exceeded during query execution:
Too many DML statements outstanding against table, limit is 20.
Error: Exceeded rate limits:
too many table dml insert operations for this table.
のようなエラーが発生します。
この制限に対して、1つ目の対策としてMERGE操作間に3秒の遅延を入れています。
await writer.mergeNotionDocument(doc);
await delay(3000); // 3秒待機
// 計算:
// - 1操作/3秒 = 20操作/分 = 1,200操作/時間 = 28,800操作/日
// - 制限(1,500操作/日)を大きく下回る
2つ目の対策として、並行実行するユーザー数を制限しています。
// notion-fetch-coordinator.ts
const MAX_CONCURRENT_USERS = 2; // 3ユーザー → 2ユーザーに削減
async fetchAllUsers(userIds: string[]) {
const chunks = chunkArray(userIds, MAX_CONCURRENT_USERS);
for (const chunk of chunks) {
// 最大2ユーザー並行で実行
await Promise.all(chunk.map(userId => this.fetchUser(userId)));
}
}
// 計算:
// - 2ユーザー並行 × 1 MERGE/3秒 = 最大2 MERGE/3秒
// - BigQuery制限: 20 DML同時実行
まとめ
こちらで紹介した以外にも、例えばNotion APIで取得した情報のMarkdown化には苦戦しました。ライブラリーもありますし、AIコーディングの世の中であればある程度カバーできますが、少し厄介な部分です。
その他にも、学び・気づきとしては以下のようなものがあります。
- トップレベルに
title、uri、descriptionを配置 +keyPropertyMapping設定で検索精度向上する - BigQuery MERGEでACL追加を同時することで省コスト化
- API呼び出し削減でCloud Run実行時間削減、Notion API制限回避する必要がある
- OAuth + ACLで正確な権限管理が可能
システム設計としては「Cloud SQLをキャッシュとして利用しながら、更新時にはBigQueryのレコードを更新する」形にしたことで、比較的費用も抑えながらGemini Enterpriseでの検索でも使えるようにできました。
こうしたハードルを乗り越えたこともあり、現在はGemini Enterpriseの全従業員への導入をしました。
まだまだ、Gemini Enterprise側のブースト設定などで検索結果を改善するなどの課題があるので、今後も全社のパフォーマンス向上をサポートできればと考えています。
参考リンク
- Gemini Enterprise Documentation
- Gemini Enterprise スキーマ指定ガイド
- アクセス制御
- Notion API Documentation
- BigQuery DML Limits
最後に
ミッションである「街じゅうを『駅前化』するインフラをつくる」を実現するためには、ソフトウェア・ハードウェア両面でまだまだ解決すべき課題が山積みです。
また、それを解決するための従業員の生産性向上も急務です。
「街全体を変えるプロダクト開発」というスケール感、そして法規制やハードウェア制約も含めた複合的な課題解決に興味がある方は、ぜひカジュアルにお話ししましょう〜。
ほとんどの職種のソフトウェアエンジニア、大募集中です!
Discussion