# The Story of Building an Auto-Sync System for Google Drive KB Articles to Vertex AI RAG Corpus — From Abandoning Event-Driven to Arriving at Smart Polling
This page has been translated by machine translation. View original
Introduction
We operate a system where RAG (Retrieval-Augmented Generation) is integrated into an internal Google Chat bot, generating responses based on KB (Knowledge Base) articles. The setup involves importing documents into Vertex AI RAG Engine, with the bot searching the corpus to answer user questions.
Initially, KB articles were managed as Markdown files in a Git repository and uploaded to the RAG corpus via GCS using CLI scripts. However, adding or editing articles required git operations and CLI execution, which posed a high barrier for non-technical staff.
"Edit an article in Google Drive, and have it automatically reflected in the RAG corpus" — this is a record of the research and implementation done to achieve that goal. The initial design was event-driven, but we hit a wall with OAuth constraints, and ultimately settled on smart polling as a practical solution.
Overall Architecture
The final configuration we adopted is as follows.

The key point is that we adopted Google Docs as the format for KB articles. Rather than placing Markdown files in a Drive folder, using Google Docs allows staff to edit articles in a WYSIWYG editor. Even without knowing Markdown syntax, they can intuitively work with formatting such as headings, lists, and bold text.
Vertex AI RAG Engine natively supports Google Workspace files including Google Docs, and they can be imported simply by passing a Drive folder URL to rag.import_files(). However, passing a folder URL to import_files() reprocesses all files every time, which is inefficient for polling at 15-minute intervals. Therefore, we implemented smart polling that uses GCS as an intermediate layer to detect changes and deletions.
Initial Design: Event-Driven with Workspace Events API
The first architecture we designed was for real-time synchronization.

Using the Google Workspace Events API, Drive file creation, update, and deletion events can be delivered to Pub/Sub. The supported event types are as follows.
google.workspace.drive.file.v3.created— File createdgoogle.workspace.drive.file.v3.updated— File updatedgoogle.workspace.drive.file.v3.trashed— File deleted (moved to trash)
With this design, the RAG corpus would be updated the moment an edit occurs, allowing the bot to always respond with the latest information.
Creating a Subscription
A Workspace Events API subscription is created as follows.
EVENT_TYPES = [
"google.workspace.drive.file.v3.created",
"google.workspace.drive.file.v3.updated",
"google.workspace.drive.file.v3.trashed",
]
credentials, _ = google.auth.default(
scopes=["https://www.googleapis.com/auth/drive"]
)
service = build("workspaceevents", "v1", credentials=credentials)
body = {
"targetResource": f"//drive.googleapis.com/files/{folder_id}",
"eventTypes": EVENT_TYPES,
"notificationEndpoint": {
"pubsubTopic": "projects/YOUR_PROJECT_ID/topics/drive-kb-events",
},
"payloadOptions": {
"includeResource": True,
"fieldMask": "mimeType,name,parents",
},
}
result = service.subscriptions().create(body=body).execute()
Important note: The OAuth scope required for Drive events in the Workspace Events API is https://www.googleapis.com/auth/drive. A scope called apps.events.subscriptions does not exist. The official documentation also states to use existing scopes corresponding to the target resource (the drive scope for Drive).
Subscription Expiration
Workspace Events API subscriptions have a maximum TTL of 7 days and require periodic renewal. This can be handled by renewing every 6 days with Cloud Scheduler, or by handling the expirationReminder event that the subscription itself delivers.
The Wall of OAuth and Organizational Security
While advancing the event-driven design, we encountered several obstacles.
Wall 1: Subscriptions Cannot Be Created with a Service Account
Creating a Workspace Events API subscription requires user OAuth authentication. It cannot be created with a service account. Additionally, Domain-Wide Delegation (DWD) is not supported by the Workspace Events API.
This means a human must always go through the OAuth flow for the initial subscription creation.
Wall 2: gcloud CLI OAuth Client Gets Blocked
When attempting OAuth authentication using gcloud auth application-default login, an error appeared in the Google Workspace admin console saying "This app is blocked."
This is caused by App access controls configured by Google Workspace administrators (Admin Console → Security → API controls → App access control), which has two independent settings:
| Setting | Description |
|---|---|
| OAuth scope allowlist | Whether to allow specific scopes (e.g., drive) |
| App trust level | Classifies individual OAuth clients as "Trusted," "Limited," or "Blocked" |
Even if the scope is allowed, authentication fails if the app itself is blocked. The gcloud CLI OAuth client ID is 764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com, and an administrator needs to set this to "Trusted."
Wall 3: Organizational Policy Changes Required
In the end, using the Workspace Events API required all of the following conditions to be met:
- The
drivescope must be allowed in the organization - The gcloud CLI OAuth client must be set to trusted
- The administrator must approve the app in App access control
Depending on the organization's security policies, a process to request and approve these changes may be required.
Decision: Switching to Polling
Weighing the coordination cost of having organizational security settings changed for the event-driven approach against the sufficient update frequency achievable with polling, we adopted smart polling.
| Aspect | Event-Driven | Smart Polling |
|---|---|---|
| Reflection speed | Seconds to tens of seconds | Up to 15 minutes |
| Org setting changes | Required (OAuth permission, app trust) | Not required |
| Infrastructure | Pub/Sub + Workspace Events subscription | Cloud Scheduler only |
| Maintainability | Subscription renewal (7-day TTL) | Not required |
| Reliability | Risk of subscription expiration | Scheduler runs stably |
Given the frequency of KB article updates, 15-minute interval sync poses no practical issues. The Pub/Sub topic and event handler code have been kept in place so that event-driven can be added in the future once OAuth settings are in order.
Implementation of Smart Differential Sync via GCS
Even though we say "polling," reprocessing all files every time is inefficient. We implemented smart polling that only syncs files that have changed.
Change Detection: Saving modifiedTime in GCS Metadata
When uploading files exported from Drive to GCS, we record Drive's modifiedTime in the GCS blob metadata.
def _export_doc_to_gcs(file_id: str, file_name: str, modified_time: str) -> str:
drive = _get_drive_service()
content = drive.files().export(
fileId=file_id,
mimeType="text/plain",
).execute()
client = _get_gcs_client()
bucket = client.bucket(GCS_BUCKET)
blob = bucket.blob(_blob_name(file_name))
blob.metadata = {"drive_modified_time": modified_time, "drive_file_id": file_id}
blob.upload_from_string(content, content_type="text/plain")
return f"gs://{GCS_BUCKET}/{blob.name}"
Sync Logic: Detecting Both Changes and Deletions
full_sync() works as follows:
- Retrieve the list of Google Docs in the target folder using the Drive API
- Retrieve the metadata (
drive_modified_time) of existing blobs on GCS - Compare the two and re-export only files that have changed
- Detect files that exist in GCS but not in Drive (= deleted files) and remove them from both GCS and the RAG corpus
- Call
rag.import_files()only when there are changes
def full_sync() -> dict:
drive_docs = _list_drive_docs()
gcs_blobs = _get_gcs_blobs()
# Get modifiedTime from GCS metadata
gcs_modified = {}
for blob in gcs_blobs.values():
blob.reload()
meta = blob.metadata or {}
gcs_modified[blob.name] = meta.get("drive_modified_time", "")
exported = 0
unchanged = 0
drive_blob_names = set()
for doc in drive_docs:
bn = _blob_name(doc["name"])
drive_blob_names.add(bn)
gcs_mtime = gcs_modified.get(bn, "")
if gcs_mtime == doc["modifiedTime"]:
unchanged += 1
continue
_export_doc_to_gcs(doc["id"], doc["name"], doc["modifiedTime"])
exported += 1
# Deletion detection: files in GCS but not in Drive
deleted = 0
for bn, blob in gcs_blobs.items():
if bn not in drive_blob_names:
file_name = bn.removeprefix(GCS_PREFIX).removesuffix(".txt")
blob.delete()
_delete_rag_file(file_name)
deleted += 1
# Import only when there are changes
imported = 0
if exported > 0:
response = rag.import_files(
corpus_name=_corpus_name(),
paths=[f"gs://{GCS_BUCKET}/{GCS_PREFIX}"],
)
imported = response.imported_rag_files_count
return {"exported": exported, "unchanged": unchanged, "deleted": deleted, "imported": imported}
rag.import_files() itself has a mechanism to detect and skip unchanged files internally, but the differential check on the GCS side allows us to skip unnecessary export processing, making the overall sync faster.
Why Use GCS as an Intermediate Layer
You can import Google Docs by passing a Drive folder URL directly to rag.import_files(), but this method reprocesses all files in the folder every time. Reimporting 170+ files every 15 minutes of polling is inefficient.
By using GCS as an intermediate layer, the application can perform differential detection and process only files that have changed:
- Change detection: By saving Drive's
modifiedTimein GCS blob metadata, we can accurately determine whether a file has changed. If there are no changes, both export and import are skipped - Deletion detection: By diffing the GCS blob list against the Drive file list, deleted files can be identified. Since
rag.import_files()does not detect deletions, this mechanism is essential - Ease of debugging: Files on GCS can be checked directly, making it easier to troubleshoot sync state issues
Cloud Function Entry Point
The Cloud Function (2nd gen / Cloud Run-based) handles both periodic calls from Cloud Scheduler and Pub/Sub events (for future event-driven use).
@functions_framework.http
def handle_sync(request):
body = request.get_json(silent=True) or {}
try:
# Pub/Sub push message (for future event-driven use)
if "message" in body:
return _handle_pubsub_event(body["message"])
action = body.get("action", "")
if action == "full_sync":
summary = full_sync()
return (summary, 200)
return ("Unknown request", 400)
except Exception:
log("ERROR", "sync_error", error=traceback.format_exc())
# Return 200 to prevent infinite Pub/Sub retries
return ("error logged", 200)
Deployment and Scheduler Configuration
# Deploy Cloud Function (sync)
gcloud functions deploy kb-drive-sync \
--gen2 --runtime=python314 --region=asia-northeast1 \
--source=sync/ --entry-point=handle_sync \
--trigger-http --no-allow-unauthenticated \
--memory=512Mi --cpu=0.5 \
--set-env-vars="GCP_PROJECT_ID=YOUR_PROJECT_ID,GCP_LOCATION=asia-northeast1,RAG_CORPUS_ID=YOUR_CORPUS_ID,DRIVE_FOLDER_ID=YOUR_FOLDER_ID,GCS_BUCKET=YOUR_BUCKET"
# Periodic execution every 15 minutes with Cloud Scheduler
gcloud scheduler jobs create http kb-drive-sync-scheduler \
--location=asia-northeast1 \
--schedule="*/15 * * * *" \
--uri="https://kb-drive-sync-HASH-an.a.run.app" \
--http-method=POST \
--headers="Content-Type=application/json" \
--message-body='{"action":"full_sync"}' \
--oidc-service-account-email="YOUR_SA@developer.gserviceaccount.com"
KB Article Migration
Hundreds of Markdown files needed to be migrated to Google Docs.
Challenge: Unable to Call Drive API from Local Environment
Due to the OAuth constraints mentioned above, it was not possible to upload files using the Drive API from a local environment. Therefore, we took an approach of performing the migration via Cloud Function.
- Upload Markdown files from local to a temporary prefix in GCS (
kb_migrate/) - Call the Cloud Function's
migrateaction - Inside the Cloud Function, read files from GCS and create them as Google Docs using the Drive API
def migrate_from_gcs() -> dict:
client = _get_gcs_client()
bucket = client.bucket(GCS_BUCKET)
drive = _get_drive_service()
blobs = list(bucket.list_blobs(prefix=GCS_MIGRATE_PREFIX))
md_blobs = [b for b in blobs if b.name.endswith(".md")]
# Skip duplicates (safety for re-runs)
existing_docs = {doc["name"] for doc in _list_drive_docs()}
for blob in md_blobs:
file_name = blob.name.removeprefix(GCS_MIGRATE_PREFIX).removesuffix(".md")
if file_name in existing_docs:
continue
content = blob.download_as_bytes()
media = MediaInMemoryUpload(content, mimetype="text/markdown")
metadata = {
"name": file_name,
"parents": [DRIVE_FOLDER_ID],
"mimeType": "application/vnd.google-apps.document",
}
drive.files().create(
body=metadata,
media_body=media,
fields="id",
supportsAllDrives=True,
).execute()
When specifying application/vnd.google-apps.document as the mimeType in the Drive API's files().create(), the uploaded file is automatically converted to Google Docs. Markdown files sent as text/markdown have their headings, lists, code blocks, and other elements appropriately converted to Google Docs format.
Handling Timeouts
The default timeout for Cloud Functions (2nd gen) is 540 seconds (9 minutes). There is not enough time to upload hundreds of files in one run, and in the initial execution, approximately half the files timed out.
However, since the existing_docs check enables duplicate skipping, simply re-running processes the remaining files. By designing with idempotency in mind, timeouts do not become a fatal issue.
Summary
Lessons learned:
- Workspace Events API requires OAuth: Cannot be used with service accounts or DWD. Coordination with organizational security policies is necessary
- The OAuth scope trap: There is no dedicated scope for the Workspace Events API (like
apps.events.subscriptions). Use existing scopes corresponding to the target resource (such asdrive) - The role of the GCS intermediate layer: RAG Engine can import Google Docs directly, but reprocesses all files every time. Using GCS blob metadata for change detection and deletion detection significantly improves polling efficiency
- Idempotency matters: Designing migration scripts to be re-runnable allows you to handle timeouts and temporary failures
Future prospects:
The event-driven code (Pub/Sub handler) has been kept in place, so in the future, once OAuth settings are in order, smart polling and event-driven can be used together. Maintaining polling as a baseline for reliability while adding real-time capability with event-driven — this "polling + event-driven" hybrid configuration is what we consider the most robust choice.