Naar inhoud
lightbulb Welkom op de nieuwe kennisbank | We hebben de docs volledig vernieuwd met meer dan 160 features. Bekijk wat nieuw isarrow_forward

RAG-kennisbank op Workspace-content

Zo bouw je een RAG-systeem dat Google Drive, Docs en Sites als kennisbron gebruikt, met een service-account en automatische synchronisatie.

Waarom Workspace als kennisbron?

De meeste organisaties bewaren hun kennis al in Google Workspace: beleid in Docs, handleidingen in Sites, procedures in gedeelde drives. In plaats van een aparte kennisbank te bouwen en die handmatig te onderhouden, gebruik je de bestaande Workspace-content direct als bron voor je RAG-systeem.

De belangrijkste voordelen:

  • Altijd actueel: documenten worden bijgehouden door de inhoudsexperts zelf.
  • Geen duplicatie: de bron van waarheid blijft in Drive.
  • Rechten respecteren: je indexeert alleen documenten die het service-account mag lezen.
  • Schaalt automatisch: nieuwe documenten worden vanzelf opgepikt bij de volgende sync.
lightbulb

Begin klein met een afgebakende map

Richt je eerste versie op een of twee gedeelde drives met goed onderhouden documenten. Een kleine, schone bron levert betere antwoorden dan een grote map vol verouderde of dubbele bestanden. Breid daarna stapsgewijs uit.

Service-account setup

Voor een gedeelde kennisbank gebruik je een service-account in plaats van OAuth per gebruiker. Zo draait de synchronisatie zonder dat er telkens een mens hoeft in te loggen.

Service-account aanmaken

  1. Maak een service-account aan in de Google Cloud Console.
  2. Schakel de Drive API en de Docs API in voor je project.
  3. Geef het service-account lees-toegang tot de gedeelde drives die je wilt indexeren.
  4. Download het JSON-sleutelbestand en bewaar het veilig.
warning

Bewaar de sleutel buiten je repository

Het JSON-sleutelbestand geeft toegang tot alle documenten die met het service-account zijn gedeeld. Zet het nooit in versiebeheer. Gebruik een secret manager of omgevingsvariabelen, en geef het service-account alleen leesrechten (geen schrijf- of beheerrechten).

import { google } from "googleapis";
import { JWT } from "google-auth-library";

function getServiceAccountAuth(): JWT {
  return new google.auth.JWT({
    email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
    key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\n/g, "
"),
    scopes: [
      "https://www.googleapis.com/auth/drive.readonly",
      "https://www.googleapis.com/auth/documents.readonly",
    ],
  });
}

Drive-inventarisatie

Eerst maak je een lijst van alle bestanden die je wilt indexeren. De Drive API ondersteunt paginatie, dus loop door totdat er geen nextPageToken meer is. Zet supportsAllDrives en includeItemsFromAllDrives aan om ook gedeelde drives mee te nemen.

async function inventorizeDrive(
  auth: JWT,
  folderId: string,
  includeTypes: string[] = [
    "application/vnd.google-apps.document",
    "application/vnd.google-apps.spreadsheet",
    "application/pdf",
  ]
): Promise<DriveFile[]> {
  const drive = google.drive({ version: "v3", auth });
  const files: DriveFile[] = [];
  let pageToken: string | undefined;

  const mimeQuery = includeTypes.map(t => `mimeType='${t}'`).join(" or ");
  const query = `(${mimeQuery}) and '${folderId}' in parents and trashed=false`;

  do {
    const response = await drive.files.list({
      q: query,
      pageSize: 100,
      fields: "nextPageToken,files(id,name,mimeType,modifiedTime,webViewLink)",
      pageToken,
      supportsAllDrives: true,
      includeItemsFromAllDrives: true,
    });

    files.push(...(response.data.files ?? []).map(f => ({
      id: f.id!,
      name: f.name!,
      mimeType: f.mimeType!,
      modifiedTime: f.modifiedTime!,
      webViewLink: f.webViewLink!,
    })));
    pageToken = response.data.nextPageToken ?? undefined;
  } while (pageToken);

  return files;
}

Content-extractie per bestandstype

Google-documenten exporteer je naar platte tekst, sheets naar CSV. Voor PDF's haal je de ruwe bytes op en parse je die lokaal. Houd er rekening mee dat de Drive API-export per bestand begrensd is (ongeveer 10 MB tekst); voor grotere documenten werk je beter met de revisie- of download-route en splits je vooraf.

const EXPORT_FORMATS: Record<string, string> = {
  "application/vnd.google-apps.document": "text/plain",
  "application/vnd.google-apps.spreadsheet": "text/csv",
  "application/vnd.google-apps.presentation": "text/plain",
};

async function extractDriveFileContent(auth: JWT, file: DriveFile): Promise<string> {
  const drive = google.drive({ version: "v3", auth });
  const exportMime = EXPORT_FORMATS[file.mimeType];

  if (exportMime) {
    const response = await drive.files.export(
      { fileId: file.id, mimeType: exportMime },
      { responseType: "text" }
    );
    return response.data as string;
  }

  if (file.mimeType === "application/pdf") {
    const response = await drive.files.get(
      { fileId: file.id, alt: "media" },
      { responseType: "arraybuffer" }
    );
    const pdfParse = await import("pdf-parse");
    const data = await pdfParse.default(Buffer.from(response.data as ArrayBuffer));
    return data.text;
  }

  return "";
}

Synchronisatiepipeline

De indexer houdt per bestand bij wanneer het laatst is gewijzigd en welke chunk-id's eruit zijn voortgekomen. Bij een update verwijder je eerst de oude chunks en schrijf je daarna de nieuwe weg, zodat je geen verouderde fragmenten in je vectordatabase houdt.

interface SyncState {
  fileId: string;
  lastModified: string;
  chunkIds: string[];
}

class WorkspaceIndexer {
  private syncState = new Map<string, SyncState>();

  async fullSync(auth: JWT, folderId: string): Promise<void> {
    const files = await inventorizeDrive(auth, folderId);
    console.error(`Gevonden: ${files.length} bestanden`);

    for (const file of files) {
      await this.indexFile(auth, file);
      await new Promise(resolve => setTimeout(resolve, 100));
    }
  }

  async incrementalSync(auth: JWT, folderId: string): Promise<void> {
    const files = await inventorizeDrive(auth, folderId);
    const toUpdate = files.filter(f => {
      const state = this.syncState.get(f.id);
      return !state || state.lastModified !== f.modifiedTime;
    });
    console.error(`Te updaten: ${toUpdate.length} bestanden`);
    for (const file of toUpdate) {
      await this.indexFile(auth, file);
    }
  }

  private async indexFile(auth: JWT, file: DriveFile): Promise<void> {
    try {
      const content = await extractDriveFileContent(auth, file);
      if (!content.trim()) return;

      const existingState = this.syncState.get(file.id);
      if (existingState) {
        await vectorDB.deleteByIds(existingState.chunkIds);
      }

      const chunks = recursiveChunk(content, 400, 50);
      const chunkIds: string[] = [];

      for (let i = 0; i < chunks.length; i++) {
        const chunkId = `drive-${file.id}-chunk-${i}`;
        const embedding = await embedText(chunks[i]);
        await vectorDB.upsert(chunkId, embedding, {
          content: chunks[i],
          source_name: file.name,
          source_url: file.webViewLink,
          source_id: file.id,
          chunk_index: i,
        });
        chunkIds.push(chunkId);
      }

      this.syncState.set(file.id, {
        fileId: file.id,
        lastModified: file.modifiedTime,
        chunkIds,
      });
    } catch (err) {
      console.error(`Fout bij indexeren ${file.name}:`, err);
    }
  }
}
info

Bewaar de sync-state buiten het geheugen

In dit voorbeeld staat de sync-state in een Map in het geheugen. Bij een herstart ben je die kwijt en wordt alles opnieuw geïndexeerd. In productie zet je deze state in een database of een eigen tabel, zodat incrementele sync ook na een deploy blijft werken.

Cron-gebaseerde synchronisatie

Een dagelijkse incrementele sync houdt de index actueel zonder onnodige API-calls. Voor snellere updates kun je later overstappen op de Drive changes-feed met webhooks (push-notificaties), zodat je alleen synchroniseert wat echt is veranderd.

import cron from "node-cron";

const indexer = new WorkspaceIndexer();
const auth = getServiceAccountAuth();

cron.schedule("0 2 * * *", async () => {
  console.error("Nachtelijke synchronisatie gestart");
  await indexer.incrementalSync(auth, process.env.DRIVE_FOLDER_ID!);
  console.error("Synchronisatie voltooid");
});

RAG-query met bronvermelding

Bij een vraag haal je de meest relevante chunks op en geef je die als context mee aan het model. Vraag het model expliciet om naar de bronnummers te verwijzen, en lever de onderliggende bronlinks apart terug zodat gebruikers de informatie kunnen controleren.

async function queryWorkspaceRAG(question: string): Promise<{ answer: string; sources: string[] }> {
  const embedding = await embedText(question);
  const results = await vectorDB.search(embedding, 5);

  const context = results
    .map((r, i) => `[Bron ${i + 1}: ${r.metadata.source_name}]
${r.metadata.content}`)
    .join("

");

  const response = await anthropic.messages.create({
    model: "claude-opus-4-8",
    max_tokens: 1024,
    system: `Je bent een interne kennisassistent. Beantwoord vragen op basis van de verstrekte Workspace-documenten. Verwijs altijd naar de bronnummers [1], [2] enzovoort. Zeg het eerlijk als het antwoord niet in de context staat.`,
    messages: [{ role: "user", content: `Context:
${context}

Vraag: ${question}` }],
  });

  const answer = response.content[0].type === "text" ? response.content[0].text : "";
  const sources = [...new Set(results.map(r => `${r.metadata.source_name}: ${r.metadata.source_url}`))];
  return { answer, sources };
}

Modelnamen veranderen regelmatig. Op het moment van schrijven is claude-opus-4-8 het meest capabele model; controleer de actuele modellijst in de Claude API-documentatie en pas de waarde aan op je eigen behoefte aan snelheid, kosten en kwaliteit.

Hoe ga ik om met toegangsrechten?

Het service-account ziet alleen de documenten die expliciet met dat account zijn gedeeld. Bestanden waar het geen toegang toe heeft, worden simpelweg overgeslagen. Wil je de individuele rechten van elke eindgebruiker respecteren, dan moet je per gebruiker OAuth implementeren of een aparte autorisatielaag voor je zoekresultaten bouwen.

Hoe verwerk ik Google Sites?

Google Sites heeft geen handige export via de Drive API. De praktische route is om de gepubliceerde site-URL's te crawlen met een HTML-parser zoals cheerio en de tekst per pagina te indexeren. Behandel elke pagina als een aparte bron met de publieke URL als bronlink.

Hoe groot kan de kennisbank worden?

De omvang schaalt mee met je vectordatabase. Een map met 10.000 documenten levert grofweg 50.000 tot 200.000 chunks op, afhankelijk van je chunkgrootte. Dat is goed behapbaar voor oplossingen als pgvector of een beheerde vectordatabase. Let vooral op de embedding-kosten en de doorlooptijd van de eerste volledige sync.

Wat als de synchronisatie te lang duurt?

Voer de volledige sync eenmalig uit en draai daarna alleen incrementele updates op basis van de wijzigingsdatum. Verwerk de updates asynchroon in een job-queue, meet de doorlooptijd en pas de frequentie aan. Voor grote of snel veranderende bronnen is de Drive changes-feed met webhooks efficiënter dan periodiek alles opnieuw scannen.

Hoe voorkom ik verouderde fragmenten in de index?

Verwijder bij elke update eerst de oude chunks van een bestand voordat je de nieuwe wegschrijft, en sla per bestand de wijzigingsdatum op. Verwijderde of in de prullenbak geplaatste bestanden moet je ook uit de index halen; controleer daarop tijdens de sync.