为技术文档站点集成一个基于生成式AI的问答系统,常规思路是搭建一个独立的RAG(Retrieval-Augmented Generation)服务。这个服务需要连接一个向量数据库,并设置一个数据同步管道,持续将文档内容摄取、分块、向量化后存入数据库。对于一个内容更新频率并不高的静态站点而言,这套架构显得过于笨重且成本高昂。服务器、数据库以及数据同步的维护开销,与静态站点的低运维理念背道而驰。
痛点很明确:我们需要一个RAG方案,但又不想要常驻的后台服务和数据库。Gatsby的核心机制是“在构建时做一切繁重工作”,这启发了一个新的方向。既然Gatsby在gatsby-node.js中通过其内部的GraphQL层已经访问了所有源内容(比如Markdown文件),我们能否就在这个阶段完成向量化的所有预处理工作?
这个构想的核心是将RAG管道拆分为两个阶段:
- 构建时 (Build-Time): 在Gatsby的构建流程中,通过GraphQL查询所有文档内容,进行文本分块(chunking)、调用Embedding模型生成向量,最终将文本块与对应的向量持久化为一个或多个静态JSON文件,随站点一同部署。
- 运行时 (Run-Time): 创建一个轻量的Serverless函数作为GraphQL服务端点。当用户发起查询时,这个函数加载构建时生成的静态向量文件,将用户问题向量化,在内存中执行相似度搜索,构建Prompt上下文,最后调用大语言模型(LLM)生成答案。
这种架构将最消耗计算资源的Embedding过程移到了CI/CD的构建步骤中,使得运行时环境极度轻量,只需处理查询向量化和LLM调用,完美契合Serverless的按需计算模型。
第一阶段: 在 gatsby-node.js 中构建向量索引
我们的战场是gatsby-node.js文件。Gatsby提供的sourceNodes或createPages等API允许我们在构建过程中执行任意Node.js代码。我们将在这里实现数据的获取、处理和向量化。
首先,确保安装了必要的依赖:
npm install dotenv openai
我们需要一个文本分块器。一个简单的策略是基于段落或固定字符数进行分割。在真实项目中,使用更智能的分块器如langchain/text_splitter会更佳,但为了聚焦核心逻辑,我们手动实现一个简单的分块函数。
// src/utils/text-splitter.js
// 一个基础的文本分块器,确保不从单词中间切断
const chunkText = (text, chunkSize = 1000, overlap = 100) => {
const chunks = [];
let i = 0;
while (i < text.length) {
const end = Math.min(i + chunkSize, text.length);
let chunk = text.slice(i, end);
// 如果不是文本末尾,向后找到一个完整的句子或段落边界
if (end < text.length) {
const lastPeriod = chunk.lastIndexOf('.');
const lastNewline = chunk.lastIndexOf('\n');
const splitPoint = Math.max(lastPeriod, lastNewline);
if (splitPoint > 0) {
chunk = chunk.slice(0, splitPoint + 1);
}
}
chunks.push(chunk);
i += chunk.length - overlap; // 应用重叠
if (i <= chunks.join('').length - text.length) break; // 防止死循环
}
return chunks;
};
module.exports = { chunkText };
接下来是gatsby-node.js的核心实现。我们将使用sourceNodes API,因为它在数据层构建的早期运行。
// gatsby-node.js
require('dotenv').config({
path: `.env.${process.env.NODE_ENV}`,
});
const { OpenAI } = require('openai');
const fs = require('fs');
const path = require('path');
const { chunkText } = require('./src/utils/text-splitter');
// 初始化OpenAI客户端
// 在真实项目中,API密钥管理需要更安全的方式
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// 向量化函数,包含重试和日志
async function getEmbedding(text, retries = 3) {
const sanitizedText = text.replace(/\n/g, ' ');
try {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small', // 使用性价比高的模型
input: sanitizedText,
});
return response.data[0].embedding;
} catch (error) {
if (retries > 0) {
console.warn(`Embedding failed for chunk, retrying... (${retries} retries left)`);
await new Promise(res => setTimeout(res, 2000)); // 等待2秒后重试
return getEmbedding(text, retries - 1);
}
console.error('Error getting embedding after multiple retries:', error);
// 在CI环境中,这里应该抛出错误导致构建失败
throw new Error('Failed to generate embedding from OpenAI API.');
}
}
exports.sourceNodes = async ({ graphql, reporter }) => {
const timer = reporter.activityTimer(`Building AI Vector Index`);
timer.start();
// 1. 使用Gatsby的内部GraphQL查询所有Markdown文档
const result = await graphql(`
query {
allMarkdownRemark {
nodes {
rawMarkdownBody
frontmatter {
title
slug
}
}
}
}
`);
if (result.errors) {
reporter.panicOnBuild('Error fetching markdown files.', result.errors);
return;
}
const vectorIndex = [];
const nodes = result.data.allMarkdownRemark.nodes;
for (const node of nodes) {
const { rawMarkdownBody, frontmatter } = node;
if (!rawMarkdownBody || rawMarkdownBody.trim() === '') continue;
reporter.info(`Processing: ${frontmatter.title}`);
// 2. 对每个文档内容进行分块
const chunks = chunkText(rawMarkdownBody, 1500, 150);
// 3. 并行处理所有块的向量化
// 注意:这里的并行度需要根据API的速率限制进行调整
const embeddingPromises = chunks.map(async (chunk, index) => {
try {
const embedding = await getEmbedding(chunk);
return {
content: chunk,
title: frontmatter.title,
slug: frontmatter.slug,
embedding: embedding,
};
} catch (e) {
reporter.error(`Failed to process chunk ${index} for ${frontmatter.title}`, e);
return null; // 返回null以便后续过滤
}
});
const chunkEmbeddings = (await Promise.all(embeddingPromises)).filter(Boolean);
vectorIndex.push(...chunkEmbeddings);
}
// 4. 将向量索引写入静态文件
// 在真实项目中,对于非常大的索引,可能需要分片存储
const outputDir = path.join(__dirname, 'public', 'ai');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputPath = path.join(outputDir, 'vector-index.json');
fs.writeFileSync(outputPath, JSON.stringify(vectorIndex));
timer.end();
reporter.success(`AI Vector Index created with ${vectorIndex.length} vectors.`);
};
这里的关键点在于:
- 错误处理与重试: 调用外部API(如OpenAI)是不可靠的。
getEmbedding函数内置了简单的指数退避重试逻辑,这在生产构建中至关重要。 - 并行处理:
Promise.all用于并行生成embeddings,能显著缩短构建时间。但必须注意API的速率限制,可能需要引入一个队列或信号量来控制并发数。 - 日志与可观测性: 使用Gatsby的
reporterAPI,可以在构建日志中清晰地看到处理进度和潜在问题。 - 持久化: 最终的向量数据被序列化为
vector-index.json并放置在public目录下,这样它就会像网站的其他静态资源一样被部署。
第二阶段: 实现 Serverless GraphQL 端点
现在我们有了一个静态的向量索引,需要一个运行时环境来使用它。一个部署在Vercel或Netlify上的Serverless函数是理想选择。我们将使用Apollo Server来快速搭建一个GraphQL API。
首先,安装相关依赖:
# 在你的serverless function目录下
npm install @apollo/server graphql graphql-request cross-fetch openai
# `cross-fetch` 用于在Node.js环境中提供Fetch API
我们的Serverless函数需要实现以下功能:
- 启动时加载
vector-index.json。 - 提供一个GraphQL
Query来接收用户问题。 - 对用户问题进行向量化。
- 在内存中计算与索引向量的余弦相似度。
- 选取top-k最相关的文本块作为上下文。
- 调用LLM生成回答。
下面是一个Vercel Serverless Function的示例 (api/ask.js):
// api/ask.js
const { ApolloServer } = require('@apollo/server');
const { startStandaloneServer } = require('@apollo/server/standalone');
const { OpenAI } = require('openai');
const path = require('path');
const fs = require('fs/promises');
require('cross-fetch/polyfill'); // Polyfill for fetch API
// --- 向量计算工具 ---
function cosineSimilarity(vecA, vecB) {
let dotProduct = 0.0;
let normA = 0.0;
let normB = 0.0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
normA += vecA[i] * vecA[i];
normB += vecB[i] * vecB[i];
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
// --- 核心服务逻辑 ---
let vectorIndex = null;
async function loadIndex() {
// 在Serverless环境中,这只会在冷启动时执行一次
if (vectorIndex) return;
console.log('Loading vector index...');
try {
// Vercel会将public目录下的文件部署到根目录
const indexPth = path.resolve(process.cwd(), 'public/ai/vector-index.json');
const data = await fs.readFile(indexPth, 'utf-8');
vectorIndex = JSON.parse(data);
console.log(`Vector index loaded with ${vectorIndex.length} entries.`);
} catch (error) {
console.error('Failed to load vector index:', error);
// 如果索引加载失败,服务无法正常工作
throw new Error('Critical error: Could not load vector index.');
}
}
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const typeDefs = `#graphql
type SearchResult {
title: String!
slug: String!
content: String!
similarity: Float!
}
type Query {
ask(question: String!): String
debugSearch(question: String!): [SearchResult]
}
`;
const resolvers = {
Query: {
// 用于调试的查询,只返回搜索结果,不调用LLM
debugSearch: async (_, { question }) => {
if (!vectorIndex) await loadIndex();
if (!question || question.trim().length < 5) {
throw new Error("Question is too short.");
}
const questionEmbedding = (await openai.embeddings.create({
model: 'text-embedding-3-small',
input: question,
})).data[0].embedding;
const scoredChunks = vectorIndex.map(chunk => ({
...chunk,
similarity: cosineSimilarity(questionEmbedding, chunk.embedding),
}));
scoredChunks.sort((a, b) => b.similarity - a.similarity);
return scoredChunks.slice(0, 5).map(c => ({
title: c.title,
slug: c.slug,
content: c.content,
similarity: c.similarity
}));
},
// 主要的问答查询
ask: async (_, { question }) => {
if (!vectorIndex) await loadIndex();
if (!question || question.trim().length < 5) {
throw new Error("Question is too short.");
}
// 1. 向量化用户问题
const questionEmbedding = (await openai.embeddings.create({
model: 'text-embedding-3-small',
input: question,
})).data[0].embedding;
// 2. 在内存中进行相似度搜索
const scoredChunks = vectorIndex.map(chunk => ({
...chunk,
similarity: cosineSimilarity(questionEmbedding, chunk.embedding),
}));
scoredChunks.sort((a, b) => b.similarity - a.similarity);
const topKChunks = scoredChunks.slice(0, 5);
// 3. 构建Prompt
const context = topKChunks
.map(chunk => `Source: ${chunk.title} (${chunk.slug})\nContent:\n${chunk.content}`)
.join('\n\n---\n\n');
const systemPrompt = `You are a helpful assistant for a technical documentation website.
Answer the user's question based *only* on the provided context.
If the context does not contain the answer, state that you cannot answer based on the available information.
Do not make up information. Be concise and clear.
For each piece of information you use, cite the source title. Example: (Source: My Doc Title).`;
const userPrompt = `Context:\n${context}\n\nQuestion: ${question}`;
// 4. 调用LLM
try {
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.2, // 低温以获得更确定的回答
});
return completion.choices[0].message.content;
} catch (error) {
console.error("Error calling OpenAI completion API:", error);
throw new Error("Failed to generate answer from the language model.");
}
},
},
};
// --- GraphQL 服务启动 ---
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production', // 生产环境关闭内省
});
// 在Vercel/Netlify环境中,通常需要导出一个handler
// 这里为了本地测试,使用startStandaloneServer
// 对于Vercel,你需要使用 @as-integrations/vercel-next
// export default startServerAndCreateNextHandler(server);
// 本地测试启动
async function startLocalServer() {
await loadIndex();
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`🚀 Server ready at ${url}`);
}
if (process.env.RUN_LOCAL) {
startLocalServer();
}
// 导出 Vercel Handler (示例)
// import { startServerAndCreateVercelHandler } from '@as-integrations/vercel-next';
// export default startServerAndCreateVercelHandler(server);
这个Serverless函数的设计考量:
- 冷启动优化:
loadIndex函数利用了Serverless函数的执行上下文复用特性。索引文件只在容器第一次被“冷启动”时加载一次,后续的“热”调用会直接复用内存中的vectorIndex,响应速度极快。 - 内存计算: 所有的相似度搜索都在内存中完成,避免了任何数据库I/O,这是性能的关键。
- 健壮性: 对用户输入进行了基本校验,并且对LLM API调用进行了try-catch封装。
- 可调试性: 提供了一个
debugSearch查询,可以让我们独立测试检索阶段的效果,而无需每次都消耗LLM的token。这对于调试Prompt和检索质量至关重要。
架构权衡与分析
这个架构的优势显而易见:极低的运营成本和维护负担。没有数据库,没有常驻服务,只有构建时的一次性计算和运行时的按需函数调用。
sequenceDiagram
participant CI/CD as CI/CD Pipeline (Build Time)
participant Gatsby as Gatsby Build Process
participant OpenAI_Emb as OpenAI Embedding API
participant StaticHost as Static Hosting (Vercel/Netlify)
CI/CD->>Gatsby: Trigger `gatsby build`
Gatsby->>Gatsby: Query Markdown via internal GraphQL
Gatsby->>OpenAI_Emb: Request embeddings for text chunks
OpenAI_Emb-->>Gatsby: Return vectors
Gatsby->>StaticHost: Deploy site files + vector-index.json
participant User as User's Browser
participant GraphQL_Client as GraphQL Client (in React)
participant Serverless as Serverless GraphQL API
participant OpenAI_LLM as OpenAI LLM API
User->>GraphQL_Client: Submits question
GraphQL_Client->>Serverless: GraphQL Query: ask(question: "...")
Note over Serverless: On cold start, loads vector-index.json
Serverless->>OpenAI_Emb: Request embedding for user question
OpenAI_Emb-->>Serverless: Return question vector
Serverless->>Serverless: In-memory cosine similarity search
Serverless->>OpenAI_LLM: Send prompt with context
OpenAI_LLM-->>Serverless: Return generated answer
Serverless-->>GraphQL_Client: Return answer
GraphQL_Client->>User: Display answer
当然,它也存在明确的局限性:
- 内容更新延迟: 任何内容的变更都需要重新运行Gatsby的构建流程才能反映到问答系统中。对于每天更新数次的站点,这可能无法接受。
- 索引大小限制: Serverless函数的内存通常是有限的(例如1GB-3GB)。如果文档站点极其庞大,生成的
vector-index.json可能会超出内存限制,导致函数无法启动或频繁崩溃。一个可能的优化是将索引分片,或者将其存储在像Vercel KV或Cloudflare KV这样的边缘键值存储中,但这会增加架构的复杂性。 - 构建时间: 向量化过程,尤其是当文档数量巨大时,会显著增加Gatsby的构建时间。需要考虑CI/CD的超时限制和成本。
此方案最适合那些内容相对稳定、规模中等的技术文档、知识库或个人博客。它是在追求极致的低成本、低维护与实现高级AI功能之间的一个精妙平衡。它不是一个可以替代专业向量数据库的万能方案,而是一个在特定场景下极具吸引力的工程选择。未来的迭代方向可能包括使用更高效的二进制格式(如FlatBuffers)替代JSON来存储向量,或者探索在Edge Function中运行本地化的、更小的Embedding模型,以进一步降低对外部API的依赖和延迟。