Головна / Статті

Практична архітектура RAG на Firebase та Vertex AI з використанням векторів Firestore.

RAG у Firebase з вбудованими моделями Vertex AI та векторним пошуком у Firestore: групування даних, фільтри метаданих, ізоляція користувачів, чіткі запити, кешування та метрики.

Видавець: REACTAPP.TOP10 хв читання2276 слів

Джерела: How to Implement RAG (Retrieval-Augmented Generation) in Your Web Application

Технологія Retrieval-Augmented Generation стала основним підходом для ШІ-додатків, які працюють із приватною, змінною чи спеціалізованою інформацією. Замість того, щоб очікувати від ШІ-моделі згадування всього з часів навчання, RAG дозволяє їй звертатися до зовнішнього джерела знань під час обробки запиту. Це уможливлює створення асистентів для корпоративної документації, записів клієнтів, інформації про продукти, історичних даних чи внутрішніх процесів.

RAG — це не просто векторна база даних, підключена до ШІ-моделі. Продуктивна реалізація поєднує обробку документів, створення векторних представлень, пошук у векторному просторі, фільтрацію метаданих, формування контексту, проектування запитів, генерацію тексту, кешування, обробку помилок та моніторинг. Нижче наведена практична архітектура на Firebase та Vertex AI із шаблонами реалізації, які є важливими під час переходу прототипу у продакшн.

Три шари

Система RAG складається з трьох шарів: сховища знань, шару пошуку та шару генерації. Джерельні документи чи дані додатку перетворюються на ембеддинги та зберігаються разом із їхнім оригінальним текстом та метаданими. Коли користувач ставить запитання, воно також перетворюється на ембеддинг, і пошук за семантичною схожістю знаходить найбільш відповідну інформацію. Ця інформація стає контекстом, який передається мовній моделі, яка формує відповідь на основі знайдених даних.

Ключова ідея полягає у тому, що моделі ніколи не потрібно запам’ятовувати всю базу знань. Пошук виконується заново для кожного запиту, тому основні дані можуть змінюватися без необхідності повторного навчання.

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

Розділення також сприяє технічному обслуговуванню. Стратегія пошуку може змінюватися, залишаючись незмінною моделлю генерації, або можна замінити LLM без необхідності перебудови бази знань.

Ембеддинги

Ембеддинги є основою семантичного пошуку. Замість того, щоб розглядати текст як ключові слова, модель ембеддингів перетворює його на числовий вектор, який відображає взаємозв’язки на рівні значень. У базовій реалізації використовується gemini-embedding-001 від Vertex AI, з різними типами завдань для документів та запитів, оскільки збережені документи та надходжені запити виконують різні функції.

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;
}

Індексування бази знань вимагає використання RETRIEVAL_DOCUMENT, а обробка запиту користувача — RETRIEVAL_QUERY. Важливо дотримуватися послідовності тут, оскільки якість пошуку сильно залежить від того, як представлені обидві сторони.

Групування та конкурентність

У ранніх версіях коду RAG кожен документ часто інтегрується поодинці. Це підходить для невеликого прототипу, але є марнотратним, коли потрібно обробити тисячі чи мільйони фрагментів. Краще групувати тексти у пакети в межах лімітів запиту та токенів.

У базовому коді кількість токенів оцінюється просто, і встановлюються ліміти як кількості фрагментів, так і оціненої кількості токенів за запит. Також використовується обмежена конкурентність, тож кілька пакетів можуть виконуватися паралельно, не перевантажуючи 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;
}

Ліміти мають бути визначені в налаштуваннях, а не закладені як передумови. У умовах продакшену вони повинні змінюватися залежно від середовища, а перед налаштуванням паралельної обробки необхідно стежити за затримками API, лімітами швидкості, часткою збоїв та пропускною здатністю.

Зберігання векторів поруч із даними додатку

Зручним рішенням є зберігання векторів разом із даними додатку замість негайного створення ще однієї бази даних. У прикладному коді текст документа, ембеддинги та метадані зберігаються у Firestore, а схожі записи знаходяться за допомогою пошуку векторів за принципом найближчого сусіда.

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()
  }));
}

Стратегія пошуку потребує уважного підходу. Схожість векторів вказує на те, які документи є семантично близькими до запиту, але близькість у значенні не завжди означає їхню релевантність для бізнесу. Тож у міру розвитку додатку метадані стають дуже важливими.

Фільтрація та ізоляція користувачів

Чистий векторний пошук рідко є достатнім у продакшені. Уявіть базу знань, яка охоплює кількох клієнтів, відділів, продуктів чи регіонів. Запит може відповідати документам кількох користувачів, проте додаток має повертати лише те, що може бачити поточний користувач.

Кращим підходом є використання структурованих фільтрів перед або разом із семантичним пошуком. У коді-зразку фільтрація за темами відбувається перед векторним запитом, що звужує простір пошуку та підвищує релевантність.

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 || {}
    };
  });
}

Це має найвище значення у додатках з кількома орендарями. Процес отримання інформації не повинен випадково стати бар’єром безпеки, який розкриває дані іншого клієнта. Авторизація та ізоляція орендарів мають забезпечуватися окремо від оцінки семантичної схожості.

Створення запиту

Отримання інформації вирішує лише половину проблеми. Отриманий текст також має потрапити до ШІ у формі, яка відрізняє джерельний матеріал від реального запиту користувача. У прикладному коді створюється окремий розділ контексту, додаються деталі джерела, і моделі чітко вказується відповідати на основі наданого контексту, а також повідомляти про це, якщо в контексті бракує необхідної інформації.

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();
}

Якість отримання інформації та якість запиту пов’язані між собою. Навіть якісні документи можуть давати погані відповіді, якщо контекст погано організований, дубльований, занадто об’ємний або представлений без чітких інструкцій.

Затримка, витрати та збої

Затримка та витрати вимагають уваги з самого початку. Генерація за допомогою ембеддингів — це зовнішній виклик API, і один і той самий запит може виконуватися багато разів, тому кешування популярних ембеддингів допомагає уникнути зайвих запитів. Тимчасові помилки та обмеження швидкості вимагають повторних спроб, а не негайної невдачі всього запиту. У прикладі коду показано кеш ембеддингів у пам’яті та експоненційне збільшення інтервалів спроб у разі помилок через обмеження швидкості.

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');
}

Як тільки починають працювати кілька інстанцій додатку, локальний Map вже недостатній, тому дистрибуйована кеш-система забезпечує кращу послідовність між репліками. Принцип має перевагу над технологією: не варто перераховувати дорогі детерміністичні обчислення, якщо результат можна безпечно використати знову.

Кілька джерел інформації

Деякі запитання вимагають більше ніж одного типу інформації. Щоб дати правильну відповідь, асистенту можуть знадобитися визначення продукту, практичний приклад та певна історія. Зберігання всіх документів у одній загальній колекції ускладнює точне пошукове знаходження.

У цьому підході визначення, приклади та історичні дані розділяються, кожен з них знаходиться окремо, а потім результати об’єднуються у структурований об’єкт контексту.

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;
}

Таким чином додаток отримує контроль. Замість того, щоб просити один механізм векторного пошуку робити все, він вибирає, які джерела є важливими для кожного запиту, та поєднує їх перед генерацією відповіді.

Спостережуваність

Видимість є ключовим аспектом, у якому демонстраційні версії та продакшн-додатки типу RAG відрізняються. Відповідь від чат-бота не є доказом того, що процес пошуку працює належним чином. Командам потрібні показники часу пошуку, кількості повернених документів за запитом, їхньої релевантності, використання кешу та частоти збоїв.

У базовому коді додано клас RAGMetrics, який відстежує обсяг запитів, затримку, точність пошуку, поведінку кешу та помилки, а також обчислює точність та відтворюваність, коли існують релевантні документи з достовірними даними.

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
    });
  }
}

Як тільки такі числа існують, покращення RAG перетворюється на інженерне завдання. Різні параметри, такі як розмір частинок, top-K, фільтри метаданих, модель ембеддингів, формат запиту та переранжування, можна пробувати по одному, а показники дозволяють визначити, чи справді покращилися процес пошуку та якість відповідей.

Весь процес

Продуктивна система RAG — це цілий процес, а не просто один виклик моделі. Документи очищуються та діляться на частини, ембеддинги створюються ефективно, метадані зберігаються послідовно, процес пошуку дотримується правил авторизації, а шар генерації отримує чіткі інструкції. Практичний підхід виглядає так:

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

Ніщо тут не фіксує дизайн наперед. Пізніші додатки, такі як гібридний пошук за ключовими словами та векторами, інструменти переранжування, переписування запитів, пам’ять розмов, набори для оцінки, потоковий вивід, спільна кеш-пам’ять та фонове оброблення даних, можуть бути додані без змін основної ідеї RAG.

Уроки

LLM — це лише один з компонентів. Навіть дуже потужна модель не зможе компенсувати погану структурузацію даних, слабкий механізм пошуку, неправильні метадані, відсутність перевірок авторизації чи неактуальний контекст. На практиці саме якість пошуку часто визначає, чи здається кінцева система розумною чи ненадійною.

Найкраще будувати систему поступово: почніть з обробки даних, створення ембеддингів, векторного пошуку та простого запиту, вимірюйте результати перед додаванням складності. Потім додавайте фільтрацію метаданих, кешування, повторні спроби, моніторинг, оцінку та розширені методи пошуку відповідно до реальних обмежень.

RAG змінює спосіб створення додатків на основі ШІ, оскільки модель може працювати зі знаннями, які раніше не були частиною її навчальних даних. Вбудовування ембеддингів, семантичне пошукове функціонування, структуровані метадані та генерація з урахуванням контексту разом можуть перетворити звичайний додаток на асистента, який розуміє власну базу знань.

Найкращі системи RAG не завжди є найскладнішими. Це ті системи, у яких можливість пошуку можна виміряти, контекст є релевантним, доступ є безпечним, несправності усуваються, а відповіді походять з корисних джерел. Краще почати з простого та вдосконалюватися за допомогою реальних показників, ніж додавати складність до того, як буде зрозуміла справжня проблема.

Пов’язана література

  • Сім архітектур RAG, які виходять за межі простого підходу, та ситуації, коли кожна з них підходить — Простий підхід RAG зазнає проблем у передбачуваних ситуаціях. Сім архітектур, від контекстуальних нотаток до багатоагентних систем та механізмів отримання інформації з пам’яті, з їхніми перевагами та недоліками та ситуаціями, коли їх варто використовувати.
  • Чому RAG зазнає невдач у практичному використанні: чанкінг, ембеддинги, отримання та оцінка інформації — Практичний аналіз причин невдач RAG поза межами демонстрацій: межі чанків, сліпі зони ембеддингів, метод top-K, застарілі дані та відсутність оцінки.
  • Поза межами косинусної схожості: порівняння п’яти технік отримання даних RAG — пояснення косинусної схожості, BM25, гібридної техніки RRF, методу переранжування за допомогою крос-енкодера та MMR із прикладами коду, перевагами та підходом, що спочатку враховує запит, для вибору техніки отримання даних, необхідної для вашого RAG.
  • Набір інструментів для отримання даних RAG: BM25, Dense, HyDE, RRF, MMR та переранжування — огляд технік отримання даних, які впливають на якість RAG, інформація про ситуації, коли варто використовувати кожну з них, приблизний час виконання кожного етапу та спосіб їхнього поєднання в гібридному потоці обробки даних у продакшені.