Praktyczna architektura RAG w Firebase i Vertex AI z wykorzystaniem Firestore Vectors
RAG w Firebase z embeddingami Vertex AI i wyszukiwaniem wektorowym Firestore: grupowanie zadań, filtry metadanych, izolacja użytkowników, ustrukturyzowane prompty, buforowanie i metryki.
Źródła: How to Implement RAG (Retrieval-Augmented Generation) in Your Web Application
Retrieval-Augmented Generation stało się standardowym rozwiązaniem dla aplikacji AI, które pracują z informacjami prywatnymi, zmieniającymi się lub specjalistycznymi. Zamiast oczekiwać, że model językowy zapamięta wszystko z okresu szkolenia, RAG umożliwia mu korzystanie z zewnętrznego źródła wiedzy w momencie wysyłania zapytania. Dzięki temu można tworzyć asystentów do obsługi dokumentacji firmy, danych klientów, wiedzy o produktach, danych historycznych lub procesów wewnętrznych.
RAG to coś więcej niż zwykła baza danych wektorowych połączona z modelem językowym. Wersja gotowa do użycia łączy w sobie przetwarzanie dokumentów, tworzenie wektorów reprezentujących treść, wyszukiwanie wektorowe, filtrowanie metadanych, budowanie kontekstu, projektowanie zapytań, generowanie treści, buforowanie danych, obsługę błędów oraz monitorowanie. Poniżej przedstawiono praktyczną architekturę opartą na Firebase i Vertex AI, wraz z wzorcami implementacji istotnymi, gdy prototyp przechodzi w fazę produkcji.
Trzy warstwy
System RAG składa się z trzech warstw: magazynu wiedzy, warstwy wyszukiwania oraz warstwy generowania. Dokumenty źródłowe lub dane aplikacji są przekształcane w embeddingi i przechowywane obok ich oryginalnego tekstu oraz metadanych. Gdy użytkownik zadaje pytanie, ono również jest przekształcane w embedding, a wyszukiwanie podobieństwa semantycznego znajduje najbliższe informacje. Te informacje stają się kontekstem przekazywanym modelowi językowemu, który tworzy odpowiedź na podstawie znalezionych danych.
Kluczową ideą jest to, że model nie musi zapamiętywać całej bazy wiedzy. Proces wyszukiwania jest wykonywany na nowo dla każdego zapytania, dzięki czemu dane mogą ulegać zmianom bez konieczności ponownego szkolenia modelu.
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
Rozdzielenie to ułatwia również konserwację. Strategia wyszukiwania może ulegać zmianie, podczas gdy model generujący pozostaje niezmieniony, lub można zastąpić LLM bez konieczności odbudowy bazy wiedzy.
Embeddingi
Embeddingi stanowią podstawę semantycznego wyszukiwania. Zamiast traktować tekst jako słowa kluczowe, model embeddingowy mapuje go na wektor liczbowy, który odzwierciedla relacje na poziomie znaczeń. Implementacja referencyjna wykorzystuje gemini-embedding-001 z Vertex AI, z różnymi typami zadań dla dokumentów i zapytań, ponieważ przechowywane dokumenty oraz przychodzące pytania pełnią różne role.
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;
}
Indeksowanie bazy wiedzy wymaga użycia RETRIEVAL_DOCUMENT, natomiast przetwarzanie pytania użytkownika wymaga RETRIEVAL_QUERY. Konsekwencja w tym względzie jest ważna, ponieważ jakość wyszukiwania w dużej mierze zależy od sposobu reprezentacji obu elementów.
Przetwarzanie partiami i równoczesność
Wczesne wersje kodu RAG często włączają każdy dokument pojedynczo. To nadaje się do małego prototypu, ale jest marnotrawstwem zasobów, gdy trzeba przetworzyć tysiące lub miliony fragmentów. Lepsze efekty daje grupowanie tekstów w partie w ramach ograniczeń liczby żądań i tokenów.
Kod referencyjny prostym sposobem szacuje liczbę tokenów oraz ustala maksymalną liczbę segmentów i szacowaną liczbę tokenów na żądanie. Wykorzystuje również ograniczoną równoczesność, dzięki czemu kilka partii może być przetwarzanych równolegle bez przeciążenia 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;
}
Granice powinny być określone w konfiguracji, a nie zakładane jako stałe wartości. W środowisku produkcyjnym powinny się różnić w zależności od środowiska, a przed dostosowaniem poziomu równoczesności należy monitorować opóźnienia API, limity przepustowości, wskaźniki awarii oraz ogólną wydajność.
Zachowywanie wektorów obok danych aplikacji
Praktycznym rozwiązaniem jest przechowywanie wektorów razem z danymi aplikacji zamiast od razu tworzenia dodatkowej bazy danych. Kod przykładowy przechowuje tekst dokumentu, embeddingi oraz metadane w Firestore i wyszukuje podobne rekordy za pomocą wyszukiwania wektorowego według najbliższych sąsiadów.
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()
}));
}
Strategia wyszukiwania wymaga szczególnej uwagi. Podobieństwo wektorowe wskazuje, które dokumenty są semantycznie bliskie zapytaniu, ale bliskość znaczeniowa nie zawsze oznacza istotność dla biznesu. W miarę rozwoju aplikacji metadane stają się więc niezwykle ważne.
Filtrowanie i izolacja użytkowników
Czyste wyszukiwanie wektorowe rzadko wystarcza w środowisku produkcyjnym. Wyobraźmy sobie bazę wiedzy obejmującą kilku klientów, działów, produktów lub regionów. Zapytanie może odpowiadać dokumentom należącym do kilku użytkowników, ale aplikacja powinna zwracać tylko to, co może zobaczyć aktualny użytkownik.
Skuteczniejszym podejściem jest zastosowanie ustrukturyzowanych filtrów przed lub równocześnie z semantycznym wyszukiwaniem. Kod referencyjny filtrowa dokumenty według tematów przed wysłaniem zapytania wektorowego, co zmniejsza obszar wyszukiwania i poprawia trafność wyników.
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 || {}
};
});
}
To ma największe znaczenie w aplikacjach wielu najemców. Proces wyświetlania danych nie może przypadkowo stać się barierą bezpieczeństwa, która ujawni dane innego klienta. Autorizacja i izolacja najemców powinny być realizowane oddzielnie od kryteriów podobieństwa semantycznego.
Budowanie promptu
Proces wyświetlania danych rozwiązuje tylko połowę problemu. Wyświetlony tekst musi również dotrzeć do modelu LLM w formie, która odróżnia materiał źródłowy od rzeczywistego pytania użytkownika. Kod referencyjny tworzy dedykowaną sekcję kontekstową, zawiera szczegóły źródłowe i wyraźnie instruuje model, aby odpowiadał na podstawie dostarczonego kontekstu oraz informował o tym, gdy kontekst nie zawiera potrzebnych informacji.
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();
}
Jakość pozyskiwania informacji jest powiązana z jakością zapytania. Nawet solidne dokumenty mogą dostarczać słabych odpowiedzi, gdy kontekst jest źle zorganizowany, powtarzalny, zbyt obszerny lub przedstawiony bez jasnych instrukcji.
Opóźnienia, koszty i błędy
Od samego początku należy zwracać uwagę na opóźnienia i koszty. Generowanie embeddingów odbywa się poprzez zewnętrzne wywołanie API, a to samo zapytanie może się często powtarzać, dlatego przechowywanie w pamięci popularnych embeddingów redukuje niepotrzebne żądania. Tymczasowe błędy oraz ograniczenia szybkości wymagają ponownych prób, a nie natychmiastowego porzucenia całego żądania. Kod przykładowy pokazuje pamięć cache embeddingów w pamięci operacyjnej oraz eksponencjalne opóźnienia przy błędach związanych z ograniczeniami szybkości.
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');
}
Gdy uruchomionych zostanie kilka instancji aplikacji, lokalny Map już nie wystarcza, a rozproszona pamięć cache zapewnia lepszą spójność pomiędzy replikami. Zasada ma większe znaczenie niż technologia: nie należy ponownie obliczać kosztownych, deterministycznych operacji, gdy wynik można bezpiecznie ponownie wykorzystać.
Kilka źródeł wiedzy
Część pytań wymaga więcej niż jednego rodzaju informacji. Aby dobrze odpowiedzieć, asystent może potrzebować definicji produktu, praktycznego przykładu oraz informacji historycznych. Umieszczanie wszystkich dokumentów w jednej ogólnej kolekcji sprawia, że wyszukiwanie staje się mniej precyzyjne.
Podejście oparte na referencjach dzieli definicje, przykłady i dane historyczne, pobiera każdy z nich oddzielnie i łączy wyniki w ustrukturyzowany obiekt kontekstowy.
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;
}
W ten sposób aplikacja uzyskuje kontrolę. Zamiast prosić jeden mechanizm wyszukiwania wektorowego o wykonanie wszystkiego, wybiera, które źródła są istotne dla każdego zapytania, i łączy je przed generowaniem odpowiedzi.
Obserwowalność
Widoczność to kluczowy aspekt, w którym różnią się dema oraz aplikacje RAG w środowisku produkcyjnym. Odpowiedź od chatbota nie jest dowodem na to, że proces wyświetlania informacji przebiega prawidłowo. Zespoły potrzebują danych dotyczących czasu wyświetlania informacji, liczby zwróconych dokumentów na zapytanie, ich trafności, wykorzystania pamięci cache oraz częstotliwości błędów.
Kod referencyjny zawiera klasę RAGMetrics, która śledzi objętość zapytań, opóźnienia, dokładność wyświetlania informacji, zachowanie pamięci cache oraz błędy, a także oblicza precyzję i召回率, gdy istnieją dokumenty istotne z punktu widzenia rzeczywistości.
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
});
}
}
Gdy takie liczby już istnieją, udoskonalanie RAG staje się zadaniem inżynieryjnym. Można testować pojedynczo różne elementy, takie jak rozmiar fragmentów, top-K, filtry metadanych, model embeddingów, układ promptów oraz ponowna sortowanie, a pomiary pokazują, czy proces wyszukiwania i generowanie odpowiedzi rzeczywiście się poprawił.
Cały proces
System RAG w produkcji to cały proces, a nie pojedyncze wezwanie modelu. Dokumenty są oczyszczane i dzielone na fragmenty, efektywnie generowane są embeddingi, metadane są przechowywane w spójny sposób, proces wyszukiwania uwzględnia zasady autoryzacji, a warstwa generowania otrzymuje jasne instrukcje. Praktyczny układ wygląda następująco:
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
Nic tutaj nie fiksuje ostatecznego kształtu projektu. Późniejsze dodatki, takie jak hybrydowe wyszukiwanie słów kluczowych i wektorów, narzędzia do ponownego rankowania, przepisywanie zapytań, pamięć rozmów, zestawy oceny, wyjście w formie strumienia, wspólna pamięć cache oraz proces pobierania danych w tle, pasują bez konieczności zmiany podstawowej koncepcji RAG.
Lekcje
LLM to tylko jeden z komponentów. Nawet bardzo zaawansowany model nie może nadrobić słabego dzielenia danych na fragmenty, niskiej jakości wyszukiwania, błędnych metadanych, braku sprawdzeń uprawnień czy nieistotnego kontekstu. W praktyce to właśnie jakość wyszukiwania często decyduje o tym, czy końcowy system będzie wydawał się inteligentny, czy niestabilny.
Najlepiej budować stopniowo: zacznij od pobierania danych, tworzenia embeddingów, wyszukiwania wektorowego oraz prostego, dobrze sformułowanego promptu, a następnie mierz efekty przed dodawaniem dodatkowej złożoności. Następnie dodawaj warstwy takie jak filtrowanie metadanych, cache, próby ponowne, monitorowanie, ocena oraz zaawansowane metody wyszukiwania, dostosowując je do rzeczywistych wąskich gardeł.
RAG zmienia sposób tworzenia aplikacji opartych na sztucznej inteligencji, ponieważ model może korzystać z wiedzy, która nie musi znajdować się w jego danych treningowych. Embeddingi, wyszukiwanie semantyczne, strukturyzowane metadane oraz generowanie uwzględniające kontekst mogą razem przekształcić zwykłą aplikację w asystenta, który rozumie własną bazę wiedzy.
Najlepsze systemy RAG nie zawsze są najbardziej skomplikowane. Są to te, w których możliwość wyszukiwania można zmierzyć, kontekst jest istotny, dostęp jest bezpieczny, błędy są radzone, a odpowiedzi odnoszą się do użytecznych źródeł. Lepiej zacząć od prostoty i ulepszać rozwiązanie na podstawie rzeczywistych wskaźników, niż dodawać złożoność, zanim zrozumiemy rzeczywisty problem.
Literatura pokrewna
- Szukanie i osądzanie w RAG: Dlaczego wyświetlanie danych wymaga oddzielnego etapu ponownego rankowania — Wkładanie danych do modeli w celu określenia podobieństwa znajduje kandydatów, ale nie może ocenić ich istotności. Zapoznaj się z dwuetapowym wzorcem szukania i osądzania wykorzystującym RRF, kodery krzyżowe, ColBERT oraz narzędzia do ponownego rankowania LLM.
- Praktyczne notatki: Co to jest generowanie wzmacniane wyświetlaniem danych (RAG)? Praktyczne wskazówki — Szczegółowy przewodnik po temacie: Co to jest generowanie wzmacniane wyświetlaniem danych (RAG)? Praktyczne wskazówki: umowy, sprawdzenia oraz gotowe fragmenty kodu przeznaczone dla zespołów wdrażających ten wzorzec.