Una arquitectura práctica de RAG en Firebase y Vertex AI con Firestore Vectors
RAG en Firebase con incrustaciones de Vertex AI y búsqueda vectorial de Firestore: agrupamiento por lotes, filtros de metadatos, aislamiento de usuarios, prompts basados en realidad, caché y métricas.
Fuentes: How to Implement RAG (Retrieval-Augmented Generation) in Your Web Application
La generación reforzada por recuperación de información se ha convertido en el enfoque preferido para las aplicaciones de IA que manejan datos privados, cambiantes o especializados. En lugar de esperar que un LLM recuerde todo lo aprendido durante su entrenamiento, RAG permite al modelo consultar una fuente de conocimiento externa en el momento de realizar una consulta. Esto hace posible crear asistentes para documentación corporativa, registros de clientes, conocimiento sobre productos, datos históricos o procesos internos.
RAG es más que una base de datos vectorial conectada a un LLM. Una implementación de nivel profesional combina procesamiento de documentos, embeddings, búsqueda vectorial, filtrado de metadatos, montaje de contexto, diseño de prompts, generación de texto, caché, manejo de errores y monitoreo. A continuación se muestra una arquitectura práctica basada en Firebase y Vertex AI, con los patrones de implementación que son importantes cuando un prototipo pasa a la fase de producción.
Tres capas
Un sistema RAG tiene tres capas: un almacén de conocimiento, una capa de recuperación y una capa de generación. Los documentos de origen o los datos de la aplicación se convierten en embeddings y se guardan junto con su texto original y metadatos. Cuando un usuario hace una pregunta, esta también se convierte en un embedding, y una búsqueda de similitud semántica encuentra la información más cercana. Esos fragmentos de información se convierten en el contexto que se entrega al modelo de lenguaje, el cual escribe la respuesta a partir de lo recuperado.
La idea clave es que el modelo nunca necesita memorizar toda la base de conocimientos. La recuperación se realiza de forma fresca para cada consulta, por lo que los datos subyacentes pueden cambiar sin necesidad de reentrenamiento.
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
La separación también facilita el mantenimiento. La estrategia de recuperación puede cambiar mientras el modelo de generación permanece igual, o se puede sustituir el LLM sin tener que reconstruir la base de conocimientos.
Embeddings
Los embeddings son la base de la recuperación semántica. En lugar de tratar el texto como palabras clave, un modelo de embeddings lo mapea a un vector numérico que captura las relaciones a nivel de significado. La implementación de referencia utiliza gemini-embedding-001 de Vertex AI, con tipos de tarea diferentes para los documentos y para las consultas, ya que los documentos almacenados y las preguntas recibidas desempeñan roles distintos.
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;
}
El indexado de la base de conocimientos requiere RETRIEVAL_DOCUMENT, mientras que el procesamiento de la pregunta de un usuario necesita RETRIEVAL_QUERY. Es importante mantener la consistencia en esto, ya que la calidad de la recuperación depende en gran medida de cómo se representen ambos elementos.
Procesamiento por lotes y concurrencia
El código inicial de RAG suele incrustar cada documento uno por uno. Eso está bien para un prototipo pequeño, pero resulta ineficiente cuando se deben procesar miles o millones de fragmentos. Agrupar los textos en lotes, dentro de los límites de solicitudes y tokens, funciona mejor.
El código de referencia estima los tokens de manera sencilla y establece un límite tanto para la cantidad de segmentos como para los tokens estimados por solicitud. También utiliza una concurrencia limitada, de modo que varios lotes se ejecutan en paralelo sin sobrecargar la 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;
}
Los límites deben definirse en la configuración y no como suposiciones inherentes al sistema. En entornos de producción, estos deberían variar según el entorno, y se debe monitorear la latencia de la API, los límites de tasa, las tasas de fallo y el ancho de banda antes de ajustar la concurrencia.
Mantener los vectores junto a los datos de la aplicación
Una opción práctica es almacenar los vectores junto con los datos de la aplicación en lugar de crear otra base de datos de inmediato. El código de referencia almacena el texto del documento, las representaciones vectoriales y los metadatos en Firestore, y recupera registros similares mediante búsquedas vectoriales de vecino más cercano.
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 estrategia de recuperación merece atención especial. La similitud vectorial indica qué documentos están semánticamente cercanos a la consulta, pero la cercanía en el significado no siempre implica relevancia para el negocio. A medida que una aplicación crece, los metadatos se vuelven sumamente importantes.
Filtrado e isolamiento de usuarios
La búsqueda vectorial pura rara vez es suficiente en entornos de producción. Imagine una base de conocimientos que abarca varios clientes, departamentos, productos o regiones. Una consulta podría corresponderse con documentos de varios usuarios, pero la aplicación debe devolver únicamente lo que el usuario actual puede ver.
Un enfoque más robusto consiste en aplicar filtros estructurados antes o junto con la recuperación semántica. El código de referencia filtra por temas antes de realizar la consulta vectorial, lo que reduce el espacio de búsqueda y mejora la relevancia.
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 || {}
};
});
}
Esto es de suma importancia en las aplicaciones multiinquilino. La recuperación de información no debe convertirse en un límite de seguridad accidental que exponga los datos de otro cliente. La autorización y el aislamiento de los inquilinos deben aplicarse por separado de la similitud semántica.
Creación de la instrucción
La recuperación de información solo resuelve la mitad del problema. El texto recuperado también debe llegar al modelo LLM de una forma que distinga el material de origen de la pregunta real del usuario. El código de referencia crea una sección de contexto dedicada, incluye detalles del material de origen y le indica explícitamente al modelo que responda a partir del contexto proporcionado y que lo indique cuando el contexto carezca de lo necesario.
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 calidad de la recuperación de información y la calidad de la consulta están relacionadas. Los documentos sólidos aún pueden generar respuestas deficientes cuando el contexto está mal organizado, duplicado, de tamaño excesivo o presentado sin instrucciones claras.
Latencia, costo y fallos
La latencia y el costo merecen atención desde el principio. La generación mediante embeddings es una llamada a una API externa, y la misma consulta puede repetirse con frecuencia; por lo tanto, almacenar en caché los embeddings más utilizados reduce las solicitudes innecesarias. Los errores temporales y los límites de tasa requieren intentos repetidos, no un fallo inmediato de toda la solicitud. El código de referencia muestra un caché de embeddings en memoria además de un mecanismo de retroceso exponencial para los errores por límites de tasa.
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');
}
Una vez que se ejecutan varias instancias de la aplicación, un Map local al proceso ya no es suficiente, y una caché distribuida ofrece una mejor consistencia entre las réplicas. El principio prevalece sobre la tecnología: no vuelva a calcular tareas deterministas costosas cuando el resultado puede reutilizarse sin riesgo.
Varias fuentes de conocimiento
Algunas preguntas requieren más de un tipo de información. Para responder adecuadamente, un asistente podría necesitar una definición del producto, un ejemplo práctico y algo de historia. Incorporar todos los documentos en una colección genérica reduce la precisión de la recuperación.
El enfoque basado en referencias separa las definiciones, los ejemplos y los datos históricos, recupera cada uno de forma independiente y combina los resultados en un objeto de contexto estructurado.
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;
}
De esta manera, la aplicación obtiene el control. En lugar de pedirle a una sola búsqueda vectorial que haga todo, elige qué fuentes son relevantes para cada consulta y las combina antes de generar la respuesta.
Observabilidad
La visibilidad es un aspecto clave en el que difieren las demostraciones de las aplicaciones RAG en producción. Una respuesta del chatbot no demuestra necesariamente que el proceso de recuperación de información esté funcionando correctamente. Los equipos necesitan datos sobre el tiempo de recuperación, los documentos devueltos por consulta, su relevancia, el uso de la caché y la frecuencia de fallos.
El código de referencia incluye una clase RAGMetrics que registra el volumen de consultas, la latencia, la precisión en la recuperación, el comportamiento de la caché y los errores, y calcula la precisión y el recuerdo cuando existen documentos relevantes de referencia.
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
});
}
}
Una vez que existen tales números, mejorar RAG se convierte en una tarea de ingeniería. Se pueden probar variaciones como el tamaño de los fragmentos, top-K, filtros de metadatos, el modelo de incrustación, el formato de la instrucción y el reclasificado una por una, con mediciones que indican si la recuperación de información y las respuestas realmente han mejorado.
Todo el proceso
Un sistema RAG en producción es un proceso completo y no simplemente una llamada a un modelo. Los documentos se limpian y se dividen en fragmentos, se generan incrustaciones de manera eficiente, el metadato se almacena de forma consistente, la recuperación respeta las autorizaciones y la capa de generación recibe instrucciones claras. Un formato práctico se ve así:
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
Nada aquí fija el diseño de forma definitiva. Las adiciones posteriores, como la búsqueda híbrida de palabras clave y vectores, los reordenadores de resultados, la reescritura de consultas, la memoria de conversación, los conjuntos de evaluación, la salida en tiempo real, un caché compartido y la ingestión en segundo plano, se integran sin alterar la idea básica de RAG.
Lecciones aprendidas
El LLM es solo un componente. Un modelo muy capaz no puede compensar una mala división en fragmentos, una recuperación deficiente, metadatos incorrectos, la falta de verificaciones de autorización o un contexto irrelevante. En la práctica, la calidad de la recuperación suele determinar si el sistema final parece inteligente o inestable.
Es mejor desarrollarlo de forma incremental: comience con la ingestión de datos, las representaciones vectoriales, la búsqueda vectorial y un prompt sencillo, y mida los resultados antes de añadir complejidad. Luego, incorpore gradualmente filtros de metadatos, caché, intentos repetidos, monitoreo, evaluación y técnicas avanzadas de recuperación según los cuellos de botella reales.
RAG cambia la forma en que se desarrollan las aplicaciones de IA, ya que un modelo puede trabajar con conocimientos que nunca estuvieron incluidos en sus datos de entrenamiento. Los embeddings, la recuperación semántica, los metadatos estructurados y la generación consciente del contexto juntos pueden transformar una aplicación ordinaria en un asistente que comprende su propia base de conocimientos.
Los mejores sistemas RAG no son siempre los más complejos. Son aquellos en los que la recuperación de información puede medirse, el contexto es relevante, el acceso es seguro, se gestionan los fallos y las respuestas se remontan a fuentes útiles. Empezar de forma sencilla y mejorar con métricas reales es mejor que añadir complejidad antes de comprender realmente el problema.
Leer más
- Búsqueda y juicio para RAG: ¿Por qué la recuperación necesita una etapa separada de reclasificación? — Los embeddings de similitud encuentran candidatos, pero no pueden determinar la relevancia. Conozca el patrón de búsqueda y juicio en dos etapas con RRF, codificadores cruzados, ColBERT y reclasificadores de LLM.
- Notas prácticas: ¿Qué es la generación aumentada con recuperación (RAG)? Un enfoque práctico — Guía paso a paso de las Notas prácticas: ¿Qué es la generación aumentada con recuperación (RAG)? Un enfoque práctico: contratos, verificaciones y espacios para código listo para usar para los equipos que implementan este patrón.