首页 / 文章

基于Firebase、Vertex AI及Firestore Vectors的实用RAG架构

结合 Vertex AI 嵌入模型与 Firestore 向量搜索的 Firebase RAG:批量处理、元数据过滤、租户隔离、基于事实的提示词、缓存机制及性能指标。

发布者:REACTAPP.TOP阅读约 10 分钟2276 词

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

检索增强生成已成为处理私有、动态或专业信息的 AI 应用首选模式。与期望大型语言模型回忆训练数据中的所有内容不同,检索增强生成允许模型在查询时查阅外部知识源。这使得为公司文档、客户记录、产品知识、历史数据或内部流程构建助手成为可能。

检索增强生成并不仅仅是与大型语言模型相连的向量数据库。一个成熟的解决方案需要整合文档处理、嵌入生成、向量搜索、元数据过滤、上下文整合、提示词设计、内容生成、缓存机制、错误处理以及监控功能。下文展示了基于 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

这种分离也有助于维护工作。在生成模型保持不变的情况下,检索策略可以进行调整;或者无需重建知识库即可更换大语言模型。

嵌入向量

嵌入向量是语义检索的基础。它并非将文本视为关键词,而是将其映射为能够体现语义层面关系的数值向量。参考实现使用了 Vertex AI 的 gemini-embedding-001,针对文档和查询分别设计了不同的模型,因为存储的文档与输入的查询所扮演的角色不同。

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架构:超越简单流程的模式及适用场景 —— 简单的RAG架构会以可预测的方式出问题。本文介绍了从上下文笔记到多智能体架构及内存检索等七种架构,分析了各自的优缺点及适用时机。
  • 为何RAG在实战中会失败:分块、嵌入、检索与评估问题 —— 从实际角度探讨检索增强生成技术在脱离演示环境后会出现的问题:分块边界问题、嵌入模型的缺陷、Top-K算法的局限性、数据过时以及缺乏有效评估机制等。
  • 超越余弦相似度:五种RAG检索技术对比 — 通过代码详解余弦相似度、BM25、混合RRF、交叉编码器重排及MMR算法,分析各技术的优缺点,并提供一种以查询为优先的思路来决定您的RAG系统应采用哪种检索技术。
  • RAG检索工具箱:BM25、Dense、HyDE、RRF、MMR与重排算法 — 梳理决定RAG系统质量的各类检索技术,说明每种技术的适用场景、各阶段的大致延迟,以及它们如何在实际生产环境中的混合流程中协同工作。