给我们的博客接入专属的 AI 助手”的功能:用户在网页右下角聊天,问题会先去我的博客知识库里检索相关内容,再把检索到的片段喂给大模型生成答案。
RAG 的经典四步:
- 构建向量(Embedding):把“文档/问题”映射到同一向量空间。 这里我用是的 Gemini 的
text-embedding-004。 - 入库(向量数据库):把向量 + 原文写入库里,并建立向量索引。我用的是 vercel 免费的 neon。
- 检索(TopK 召回):用向量相似度找最相关片段,控制阈值与片段长度。
- 生成(LLM + 上下文):把检索到的片段塞进 Prompt,让模型回答;用 SSE 流式返回提升体验。我用的是 Groq 的
llama-3.3-70b-versatile。
前言
RAG 的主链路
离线/构建阶段(Index)
md/mdx/config → 文档化 → embedding(768维) → pgvector 入库 → 建向量索引
在线请求阶段(Query)
用户问题 → embedding(同模型) → pgvector TopK 召回 → 拼上下文 Prompt → Groq 流式回答 → SSE 推给前端
这里有个很关键的点:索引和查询必须用同一个 embedding 模型,否则向量空间不一致,召回会非常飘。
一、构建向量(Embedding)
这一步只有一个硬约束:索引阶段和查询阶段必须用同一个 embedding 模型,否则向量空间不一致,召回会很飘。
另外,为了提高命中率,我把 title + description + text 拼在一起做向量化(比只 embed 正文稳定很多)。
核心代码:同一模型 + 文本拼接 + 增量跳过
// 1) Gemini Embedding(text-embedding-004,768 维)
class GeminiEmbedding {
constructor(apiKey: string, model = "text-embedding-004") {
this.genAI = new GoogleGenerativeAI(apiKey);
this.model = model;
}
async getEmbedding(text: string): Promise<number[]> {
const model = this.genAI.getGenerativeModel({ model: this.model });
const result = await model.embedContent(text);
return result.embedding.values; // 768 维
}
}
// 2) 增量:title/description/text 都没变就跳过 embedding 重算
const skipIndices = new Set<number>();
for (let i = 0; i < documents.length; i++) {
const doc = documents[i];
const existing = await sql`
SELECT title, description, text, embedding FROM blog_embeddings WHERE id = ${doc.id}
`;
if (
existing.rowCount &&
existing.rows[0].text === doc.text &&
existing.rows[0].title === doc.title &&
existing.rows[0].description === doc.description
) {
skipIndices.add(i);
existingEmbeddings.set(i, JSON.parse(existing.rows[0].embedding));
}
}
// 3) 把 title/description/text 拼在一起 embed(提升召回稳定性)
const texts = documents.map((doc) => [doc.title, doc.description, doc.text].filter(Boolean).join("\n\n"));
const embeddings = await embedder.getEmbeddings(texts, skipIndices);
二、入库:向量数据库
我用的是vercel 的 neno数据库:
- 一张表:存原文(标题/简介/正文/来源)+ 向量(embedding)
- 一个向量索引:让“按向量距离排序的 TopK 检索”足够快
- upsert:同一篇文档重复索引时,直接更新(配合上面的增量跳过更省)
核心代码:vector(768) + ivfflat 索引 + upsert
await sql`CREATE EXTENSION IF NOT EXISTS vector`;
await sql`
CREATE TABLE IF NOT EXISTS blog_embeddings (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
source TEXT NOT NULL,
text TEXT NOT NULL,
embedding vector(768) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`;
await sql`
CREATE INDEX IF NOT EXISTS blog_embeddings_vector_idx
ON blog_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100)
`;
await sql`
INSERT INTO blog_embeddings (id, title, description, source, text, embedding)
VALUES (
${doc.id},
${doc.title},
${doc.description},
${doc.source},
${doc.text},
${JSON.stringify(embedding)}::vector
)
ON CONFLICT (id)
DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
text = EXCLUDED.text,
embedding = EXCLUDED.embedding,
created_at = CURRENT_TIMESTAMP
`;
三、检索:向量召回
检索阶段做的事情很固定:
- 问题向量化(同一模型)
- 按 cosine distance 排序取 TopK
- 用相似度阈值过滤(避免把不相关内容塞进 Prompt)
- 片段截断(避免 Prompt 爆炸)
核心代码:问题向量化 + pgvector 召回
async function getQueryEmbedding(geminiKey: string, query: string): Promise<number[]> {
const genAI = new GoogleGenerativeAI(geminiKey);
const model = genAI.getGenerativeModel({ model: "text-embedding-004" });
const result = await model.embedContent(query);
return result.embedding.values; // 必须和索引侧一致
}
async function searchSimilarDocs(
embedding: number[],
topK = 5,
minSimilarity = 0.25
) {
const embeddingString = JSON.stringify(embedding);
const allResults = await sql`
SELECT
title,
source,
text,
description,
1 - (embedding <=> ${embeddingString}::vector) as similarity
FROM blog_embeddings
ORDER BY embedding <=> ${embeddingString}::vector
LIMIT 10
`;
return allResults.rows
.filter((row) => (row.similarity as number) >= minSimilarity)
.slice(0, topK)
.map((row) => ({
title: row.title as string,
source: row.source as string,
description: (row.description as string) || "",
text: (row.text as string).substring(0, 2000),
similarity: row.similarity as number,
}));
}
这里阈值 minSimilarity 设为 0.25;调参时建议把前 10 个结果的相似度打印出来先看分布(不要拍脑袋)。
四、生成:LLM + 上下文 + SSE 流式返回
生成阶段关注两件事:
- 把检索到的片段变成可控的上下文(标题/简介/正文片段,做分隔;必要时截断)
- 流式返回:先把
sources推给前端用于展示引用,再持续推content
生成模型用的是 Groq 的 llama-3.3-70b-versatile,并且启用了 stream: true。
服务端用 Server-Sent Events 把结果一点点推给前端,事件格式很简单:
type: "sources":先把召回到的来源(标题/文件/相似度)发给前端,用于 UI 展示“参考来源”type: "content":持续推 tokentype: "done":结束type: "error":异常
这条链路的关键收益是:用户几乎立刻看到回复开始出现,体验远好于等完整 JSON。
核心代码:SSE 先发 sources,再流式发 content
const stream = await groq.chat.completions.create({
messages: [
{ role: "system", content: promptData.system },
...promptData.messages,
],
model: "llama-3.3-70b-versatile",
temperature: 0.3,
max_tokens: 1024,
top_p: 0.9,
stream: true,
});
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
// 先把引用来源发给前端(用于 UI 展示“参考来源”)
if (similarDocs.length > 0) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({
type: "sources",
sources: similarDocs.map((doc) => ({
title: doc.title,
source: doc.source,
similarity: Math.round(doc.similarity * 100),
})),
})}\n\n`)
);
}
// 再流式输出模型回复
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: "content", content })}\n\n`)
);
}
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "done" })}\n\n`));
controller.close();
},
});
五、如何使用
- 先构建知识库:
bun run index-blog(需要GEMINI_API_KEY与POSTGRES_URL;可用--force全量重建) - 运行站点后打开聊天窗口直接问即可;在文章页可以“一键总结”(走
summary模式,按postId取全文总结,不走 RAG)。
六、一些 RAG 相关的“经验点
- 索引与查询必须同 embedding 模型:这里统一用 Gemini
text-embedding-004(768 维)。 - 文本拼接策略很重要:标题/简介/正文一起 embed,能显著提升召回稳定性。
- 增量索引是必做项:对比
title/description/text,没变就复用向量,避免重复计费/耗时。 - 阈值不要拍脑袋:先把前 10 个相似度打日志,观察分布再定
minSimilarity。 - 上下文要“可控”:片段截断 + 历史只取最近几轮,避免 token 膨胀导致成本和延迟飙升。
到这里,一个最小但完整的 RAG 就闭环了:内容进库 → 用户提问 → 向量召回 → 带来源生成 → 流式展示。后续如果要继续打磨,优先从“切分策略(chunking)/ 重排(rerank)/ 更严格的引用约束”这几块下手,性价比最高。