Accueil / Articles

Une architecture RAG pratique sur Firebase et Vertex AI avec Firestore Vectors

RAG sur Firebase avec des embeddings de Vertex AI et la recherche vectorielle de Firestore : lotification, filtres de métadonnées, isolation des locataires, prompts ciblés, mise en cache et métriques.

Éditeur : REACTAPP.TOP10 min de lecture2276 mots

Sources: How to Implement RAG (Retrieval-Augmented Generation) in Your Web Application

La génération augmentée par récupération d’informations est devenue la méthode privilégiée pour les applications d’IA qui traitent des données privées, changeantes ou spécialisées. Plutôt que d’attendre qu’un LLM se souvienne de tout ce qu’il a appris pendant son entraînement, RAG permet au modèle de consulter une source de connaissances externe au moment de la requête. Cela rend possible la création d’assistants pour la documentation d’entreprise, les dossiers clients, les connaissances produits, les données historiques ou les processus internes.

RAG n’est pas simplement une base de données vectorielle connectée à un LLM. Une implémentation de niveau production combine le traitement de documents, les embeddings, la recherche vectorielle, le filtrage des métadonnées, l’assemblage du contexte, la conception des prompts, la génération de texte, le cache, la gestion des erreurs et la surveillance. Ci-dessous est présentée une architecture pratique basée sur Firebase et Vertex AI, ainsi que les modèles d’implémentation essentiels lorsque un prototype passe en environnement de production.

Trois couches

Un système RAG comporte trois couches : un stock de connaissances, une couche de récupération et une couche de génération. Les documents sources ou les données de l’application sont transformés en embeddings et sauvegardés aux côtés de leur texte original et de leurs métadonnées. Lorsqu’un utilisateur pose une question, celle-ci est également transformée en embedding, et une recherche de similarité sémantique permet de trouver les informations les plus proches. Ces informations forment le contexte transmis au modèle de langage, qui rédige la réponse à partir de ce qui a été récupéré.

L’idée clé est que le modèle n’a jamais besoin de mémoriser l’ensemble de la base de connaissances. La récupération s’effectue à chaque requête, ce qui permet aux données de changer sans nécessiter de reformation du modèle.

User Question
      |
      v
Generate Query Embedding
      |
      v
Metadata Filtering
      |
      v
Vector Similarity Search
      |
      v
Top-K Relevant Documents
      |
      v
Context Construction
      |
      v
LLM / Gemini
      |
      v
Grounded Response + Sources

Cette séparation facilite également la maintenance. La stratégie de récupération peut changer tout en conservant le modèle de génération, ou le LLM peut être remplacé sans devoir reconstruire la base de connaissances.

Embeddings

Les embeddings sont au cœur de la récupération sémantique. Plutôt que de traiter le texte comme des mots-clés, un modèle d’embeddings le convertit en un vecteur numérique qui capture les relations au niveau du sens. La mise en œuvre de référence utilise gemini-embedding-001 de Vertex AI, avec des types de tâches différents pour les documents et pour les requêtes, étant donné que les documents stockés et les questions reçues jouent des rôles distincts.

const { PredictionServiceClient, helpers } =
  require('@google-cloud/aiplatform');
const PROJECT_ID = process.env.PROJECT_ID;
const client = new PredictionServiceClient({
  apiEndpoint: 'aiplatform.googleapis.com'
});
async function generateEmbedding(
  text,
  taskType = 'RETRIEVAL_DOCUMENT'
) {
  const endpoint =
    `projects/${PROJECT_ID}/locations/global/` +
    `publishers/google/models/gemini-embedding-001`;
  const instance = {
    content: text,
    task_type: taskType
  };
  const request = {
    endpoint,
    instances: [helpers.toValue(instance)]
  };
  const [response] = await client.predict(request);
  return response.predictions[0].embeddings.values;
}

L’indexation de la base de connaissances nécessite RETRIEVAL_DOCUMENT, tandis que le traitement d’une question de l’utilisateur requiert RETRIEVAL_QUERY. Il est essentiel de maintenir la cohérence à cet égard, car la qualité de la récupération dépend fortement de la manière dont ces deux éléments sont représentés.

Groupement par lots et concurrence

Les premiers codes RAG insèrent souvent chaque document un par un. Cela convient bien pour un petit prototype, mais c’est gaspilleur lorsque des milliers ou des millions de fragments doivent être traités. Il est préférable de regrouper les textes en lots, dans le respect des limites de requête et de tokens.

Le code de référence estime les tokens de manière simple et fixe à la fois le nombre de segments et le nombre maximal de tokens par requête. Il utilise également une concurrence limitée, de sorte que plusieurs lots peuvent être traités en parallèle sans surcharger l’API.

const EMBEDDING_CONFIG = {
  maxSegmentsPerRequest: 100,
  maxTokensPerRequest: 18000,
  concurrency: 3,
  tokenEstimateDivisor: 3
};
function estimateTokens(text) {
  return Math.ceil(
    text.length / EMBEDDING_CONFIG.tokenEstimateDivisor
  );
}
function packIntoBatches(texts) {
  const batches = [];
  let currentBatch = [];
  let currentTokens = 0;
  for (const text of texts) {
    const tokens = estimateTokens(text);
    const exceedsCount =
      currentBatch.length >=
      EMBEDDING_CONFIG.maxSegmentsPerRequest;
    const exceedsTokens =
      currentTokens + tokens >
      EMBEDDING_CONFIG.maxTokensPerRequest;
    if (exceedsCount || exceedsTokens) {
      if (currentBatch.length > 0) {
        batches.push(currentBatch);
      }
      currentBatch = [text];
      currentTokens = tokens;
    } else {
      currentBatch.push(text);
      currentTokens += tokens;
    }
  }
  if (currentBatch.length > 0) {
    batches.push(currentBatch);
  }
  return batches;
}

Les limites doivent être définies dans la configuration et ne pas être intégrées par défaut en tant que prémisses. En environnement de production, elles doivent varier en fonction de l’environnement, et il convient de surveiller la latence API, les limites de débit, les taux d’échec ainsi que la capacité de traitement avant d’ajuster la concurrence.

Conserver les vecteurs à côté des données de l’application

Une solution pratique consiste à stocker les vecteurs avec les données de l’application plutôt que d’ajouter immédiatement une autre base de données. Le code de référence conserve le texte du document, les embeddings et les métadonnées dans Firestore et récupère des enregistrements similaires grâce à une recherche vectorielle par voisin le plus proche.

const { Firestore } = require('@google-cloud/firestore');
const firestore = new Firestore();
async function storeDocumentWithEmbedding(
  collectionPath,
  docId,
  text,
  embedding,
  metadata
) {
  const docRef =
    firestore.doc(`${collectionPath}/${docId}`);
  await docRef.set({
    text,
    embedding,
    ...metadata,
    createdAt: Firestore.FieldValue.serverTimestamp()
  });
}
async function findSimilarDocuments(
  collectionPath,
  queryEmbedding,
  limit = 5
) {
  const collectionRef =
    firestore.collection(collectionPath);
  const vectorQuery = collectionRef.findNearest({
    vectorField: 'embedding',
    queryVector: queryEmbedding,
    limit,
    distanceMeasure: 'DOT_PRODUCT'
  });
  const snapshot = await vectorQuery.get();
  return snapshot.docs.map(doc => ({
    id: doc.id,
    data: doc.data()
  }));
}

La stratégie de récupération mérite une attention particulière. La similarité vectorielle indique quels documents sont sémantiquement proches de la requête, mais cette proximité sémantique ne signifie pas toujours une pertinence pour les besoins métier. À mesure que l’application se développe, les métadonnées deviennent donc très importantes.

Filtrage et isolation des utilisateurs

La recherche vectorielle pure suffit rarement en environnement de production. Imaginez une base de connaissances couvrant plusieurs clients, départements, produits ou régions. Une requête peut correspondre à des documents appartenant à plusieurs utilisateurs, mais l’application ne doit afficher que ce que l’utilisateur actuel est autorisé à voir.

Une approche plus robuste consiste à appliquer des filtres structurés avant ou en parallèle de la récupération sémantique. Le code de référence filtre par thèmes avant la requête vectorielle, ce qui réduit l’espace de recherche et améliore la pertinence des résultats.

async function retrieveContext(
  queryText,
  selectedTopics = [],
  limit = 5
) {
  const queryEmbedding =
    await generateEmbedding(
      queryText,
      'RETRIEVAL_QUERY'
    );
let collectionRef =
    firestore.collection('knowledge_base');
  if (selectedTopics.length > 0) {
    collectionRef = collectionRef.where(
      'topics',
      'array-contains-any',
      selectedTopics
    );
  }
  const vectorQuery =
    collectionRef.findNearest({
      vectorField: 'embedding',
      queryVector: queryEmbedding,
      limit,
      distanceMeasure: 'DOT_PRODUCT'
    });
  const snapshot = await vectorQuery.get();
  return snapshot.docs.map(doc => {
    const data = doc.data();
    return {
      text: data.text,
      source: data.source,
      metadata: data.metadata || {}
    };
  });
}

Cela est particulièrement important dans les applications multi-locataires. La récupération des données ne doit pas devenir par erreur une barrière de sécurité qui exposerait les données d’un autre client. L’autorisation et l’isolation des locataires doivent être appliquées séparément de la similarité sémantique.

Construction de l’instruction

La récupération ne résout que la moitié du problème. Le texte récupéré doit également parvenir au LLM sous une forme qui permette de distinguer le matériel source de la vraie question de l’utilisateur. Le code de référence crée une section de contexte dédiée, y inclut les détails du source et indique explicitement au modèle de répondre à partir du contexte fourni, ainsi que de le signaler lorsque ce dernier ne contient pas les informations nécessaires.

async function generateRAGResponse(
  userQuery,
  contextDocuments
) {
  const contextSection =
    contextDocuments
      .map((doc, index) => {
        return `
### Reference ${index + 1}
${doc.text}
Source: ${doc.metadata?.source || 'Unknown'}
`;
      })
      .join('\n');
const prompt = `
You are an AI assistant with access
to a knowledge base.
Use the provided context to answer
the user's question accurately.
CONTEXT:
${contextSection}
USER QUESTION:
${userQuery}
INSTRUCTIONS:
1. Answer using the provided context.
2. If the context is insufficient, say so clearly.
3. Cite the references used.
4. Do not invent information.
ANSWER:
`;
  const result =
    await genAI.models.generateContent({
      model: 'gemini-2.5-flash-lite',
      contents: prompt
    });
  return result.text.trim();
}

La qualité des résultats obtenus et la qualité des instructions fournies sont liées. Des documents solides peuvent encore produire des réponses de mauvaise qualité lorsque le contexte est mal organisé, dupliqué, trop volumineux ou présenté sans instructions claires.

Latence, coût et pannes

La latence et le coût méritent une attention dès le début. La génération par embedding correspond à une appel API externe, et la même requête peut se reproduire fréquemment ; donc, le mémorisation des embeddings les plus utilisés réduit les demandes inutiles. Les erreurs temporaires et les limites de fréquence exigent des tentatives de réinitialisation, et non l’échec immédiat de toute la requête. Le code de référence montre un cache d’embeddings en mémoire ainsi qu’un mécanisme de backoff exponentiel pour les erreurs liées aux limites de fréquence.

const embeddingCache = new Map();
async function getCachedEmbedding(text, taskType) {
  const cacheKey = `${taskType}:${text}`;
  if (embeddingCache.has(cacheKey)) {
    return embeddingCache.get(cacheKey);
  }
  const embedding =
    await generateEmbedding(text, taskType);
  embeddingCache.set(cacheKey, embedding);
  return embedding;
}
async function retryWithBackoff(
  fn,
  maxRetries = 3
) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (
        error.code === 429 ||
        error.message.includes('rate limit')
      ) {
        const delay =
          Math.pow(2, attempt) * 1000;
        await new Promise(resolve =>
          setTimeout(resolve, delay)
        );
        continue;
      }
      throw error;
    }
  }
  throw new Error('Max retries exceeded');
}

Lorsque plusieurs instances d’une application tournent en même temps, un Map local au processus n’est plus suffisant, et un cache distribué assure une meilleure cohérence entre les répliques. Le principe l’emporte sur la technologie : il ne faut pas recalculer des tâches coûteuses et déterministes lorsque le résultat peut être réutilisé en toute sécurité.

Plusieurs sources de connaissances

Certaines questions nécessitent plus d’un type d’information. Un assistant peut avoir besoin d’une définition du produit, d’un exemple concret ainsi que d’informations historiques pour répondre correctement. Rassembler tous les documents dans une seule collection générique rend la recherche moins précise.

La méthode de référence sépare les définitions, les exemples et les données historiques, récupère chacun d’eux de manière indépendante puis fusionne les résultats en un objet de contexte structuré.

async function retrieveMultiContext(
  queryText,
  options = {}
) {
  const {
    includeDefinitions = true,
    includeExamples = true,
    includeHistorical = false,
    topics = []
  } = options;
const queryEmbedding =
    await generateEmbedding(
      queryText,
      'RETRIEVAL_QUERY'
    );
  const contextPromises = [];
  if (includeDefinitions) {
    contextPromises.push(
      findSimilarDocuments(
        'definitions',
        queryEmbedding,
        3
      ).then(documents => ({
        type: 'definitions',
        documents
      }))
    );
  }
  if (includeExamples) {
    contextPromises.push(
      findSimilarDocuments(
        'examples',
        queryEmbedding,
        5
      ).then(documents => ({
        type: 'examples',
        documents
      }))
    );
  }
  const contexts =
    await Promise.all(contextPromises);
  return contexts;
}

L’application prend le contrôle de cette manière. Au lieu de demander à une seule recherche vectorielle de faire tout le travail, elle choisit quels sources sont pertinentes pour chaque requête et les combine avant de générer la réponse.

Observabilité

La visibilité est un aspect majeur où les démos et les applications RAG en production diffèrent. Une réponse du chatbot ne prouve pas que le processus de récupération fonctionne correctement. Les équipes ont besoin de données sur le temps de récupération, le nombre de documents retournés par requête, leur pertinence, l’utilisation de la mémoire cache et la fréquence des échecs.

Le code de référence ajoute une classe RAGMetrics qui suit le volume des requêtes, la latence, la précision de la récupération, le comportement de la mémoire cache et les erreurs, et calcule la précision et le rappel lorsqu’il existe des documents pertinents connus.

class RAGMetrics {
  constructor() {
    this.metrics = {
      totalQueries: 0,
      averageLatency: 0,
      retrievalAccuracy: [],
      errors: []
    };
  }
logQuery(
    query,
    contextCount,
    latency,
    sources
  ) {
    this.metrics.totalQueries++;
    const previousLatency =
      this.metrics.averageLatency *
      (this.metrics.totalQueries - 1);
    this.metrics.averageLatency =
      (previousLatency + latency) /
      this.metrics.totalQueries;
    console.log({
      query: query.substring(0, 100),
      contextCount,
      latency,
      sourceCount: sources.length,
      timestamp: new Date().toISOString()
    });
  }
  logRetrievalAccuracy(
    retrievedDocs,
    relevantDocs
  ) {
    const retrievedIds =
      new Set(retrievedDocs.map(d => d.id));
    const relevantIds =
      new Set(relevantDocs.map(d => d.id));
    const intersection =
      new Set(
        [...retrievedIds]
          .filter(id => relevantIds.has(id))
      );
    const precision =
      intersection.size / retrievedIds.size;
    const recall =
      intersection.size / relevantIds.size;
    this.metrics.retrievalAccuracy.push({
      precision,
      recall
    });
  }
}

Lorsque de tels paramètres existent, l’amélioration de RAG devient une tâche d’ingénierie. Des variations telles que la taille des blocs, le top-K, les filtres de métadonnées, le modèle d’incorporation, la structure des prompts et le réclassement peuvent être testées une par une, avec des mesures permettant de déterminer si la récupération des informations et les réponses s’améliorent réellement.

Tout le processus

Un système RAG en production est un processus complet et non une simple appel à un modèle. Les documents sont nettoyés et divisés en blocs, les incorporations sont générées de manière efficace, les métadonnées sont stockées de façon cohérente, la récupération respecte les autorisations en vigueur, et la couche de génération reçoit des instructions claires. Une structure pratique ressemble à ceci :

DOCUMENT INGESTION
                    |
                    v
          Clean + Split Documents
                    |
                    v
            Generate Embeddings
                    |
                    v
       Firestore + Metadata Storage
                    |
                    |
USER QUERY --------+
                    |
                    v
           Query Embedding
                    |
                    v
       Authorization + Filters
                    |
                    v
          Vector Similarity Search
                    |
                    v
          Relevant Context
                    |
                    v
          Prompt Construction
                    |
                    v
              Gemini / LLM
                    |
                    v
       Answer + Sources + Metrics

Rien ici ne fixe définitivement le design. Les ajouts ultérieurs tels que la recherche hybride mots-clés + vecteurs, les outils de réclassification, la réécriture des requêtes, la mémoire de conversation, les ensembles d’évaluation, la sortie en flux continu, un cache partagé et l’ingestion en arrière-plan s’intègrent tous sans modifier l’idée de base de RAG.

Leçons apprises

Le LLM n’est qu’un composant parmi d’autres. Un modèle très performant ne peut pas compenser un découpage insuffisant des données, une récupération inefficace, des métadonnées erronées, l’absence de vérifications d’autorisation ou un contexte irrelevant. En pratique, la qualité de la récupération détermine souvent si le système final semble intelligent ou instable.

Il est préférable de construire progressivement : commencez par l’ingestion des données, les embeddings, la recherche vectorielle et des prompts simples, puis évaluez avant d’ajouter de la complexité. Ensuite, ajoutez progressivement le filtrage des métadonnées, le caching, les tentatives de récupération répétées, la surveillance, l’évaluation et des méthodes de recherche avancées en fonction des véritables goulots d’étranglement.

RAG change la manière dont les applications d’IA sont développées, car un modèle peut utiliser des connaissances qui n’étaient pas nécessairement présentes dans ses données d’entraînement. Les embeddings, la récupération sémantique, les métadonnées structurées et la génération consciente du contexte permettent ensemble de transformer une application ordinaire en un assistant capable de comprendre sa propre base de connaissances.

Les meilleurs systèmes RAG ne sont pas toujours les plus complexes. Ce sont ceux où la récupération des informations peut être mesurée, où le contexte est pertinent, où l’accès est sécurisé, où les pannes sont gérées et où les réponses renvoient à des sources utiles. Commencer simplement et s’améliorer grâce à des indicateurs réels vaut mieux que d’ajouter de la complexité avant même de bien comprendre le problème.

Lectures complémentaires

  • Sept architectures RAG au-delà du pipeline naïf et quand en utiliser chacune — Le RAG naïf présente des défaillances prévisibles. Sept architectures, allant des notes contextuelles aux systèmes multi-agents et au récupération de mémoire, avec leurs avantages et inconvénients ainsi que les situations où chacune convient.
  • Pourquoi RAG échoue en production : le chunking, les embeddings, la récupération et l’évaluation — Une analyse pratique des points de défaillance de la génération augmentée par récupération au-delà des démonstrations : les limites des chunks, les lacunes des embeddings, le top-K, les données obsolètes et l’absence d’évaluation.
  • Au-delà de la similarité cosinus : comparaison de cinq techniques de récupération RAG — Explication de la similarité cosinus, de BM25, du RRF hybride, du reranking par encodeur croisé et du MMR, accompagnée de codes, d’analyses des compromis et d’une méthode axée sur la requête pour déterminer quelle technique de récupération votre RAG nécessite.
  • Le kit d’outils de récupération RAG : BM25, Dense, HyDE, RRF, MMR et reranking — Présentation des techniques de récupération qui influencent la qualité du RAG, avec des indications sur leur utilisation, une estimation de la latence par étape, et la manière dont elles s’intègrent dans un pipeline hybride en production.