Практическая архитектура RAG на Firebase и Vertex AI с векторами Firestore.
RAG в Firebase с встраиванием данных из Vertex AI и векторным поиском в Firestore: группировка запросов, фильтры метаданных, изоляция тенантов, основанные на фактах промпты, кэширование и метрики.
Источники: 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.
Уроки
Большие языковые модели — это лишь один из компонентов. Даже очень мощная модель не сможет компенсировать плохое разбиение данных на части, слабую систему поиска, некорректные метаданные, отсутствие проверок авторизации или нерелевантный контекст. На практике качество поиска часто определяет, будет ли конечная система казаться умной или ненадежной.
Наилучший подход — постепенное развитие: начните с загрузки данных, генерации векторных представлений, векторного поиска и простого запроса, затем оцените результаты перед добавлением дополнительной сложности. После этого по мере появления реальных проблем добавляйте фильтрацию метаданных, кэширование, повторные попытки, мониторинг, оценку и более продвинутые методы поиска.
RAG меняет подход к созданию приложений на основе ИИ, поскольку модель может использовать знания, которые ранее не входили в её обучающие данные. Использование эмбеддингов, семантического поиска, структурированных метаданных и генерации с учётом контекста позволяет превратить обычное приложение в помощника, способного понимать собственную базу знаний.
Лучшие системы RAG не всегда являются самыми сложными. Это те системы, в которых возможность поиска можно измерить, контекст имеет значение, доступ защищён, сбои устраняются, а ответы ведут к полезным источникам. Начать с простого и совершенствоваться на основе реальных показателей лучше, чем добавлять сложность до того, как будет полностью понята суть проблемы.
Связанные материалы
- Поиск и оценка для RAG: почему процесс извлечения данных требует отдельного этапа переоценки — Методы определения сходства помогают находить кандидатов, но не могут оценить их релевантность. Рассматривается двухэтапная схема поиска и оценки с использованием RRF, кросс-кодеров, ColBERT и моделей LLM для переоценки.
- Практические рекомендации: что такое генерация с усилением на основе извлечения данных (RAG)? Практическое руководство — Подробное пошаговое руководство с примерами контрактов, проверок и готовых фрагментов кода для команд, внедряющих эту схему.