astro bun Deno logo
Skip to content
Go back

实现一个免费的 RAG 聊天助手

Edit page

给我们的博客接入专属的 AI 助手”的功能:用户在网页右下角聊天,问题会先去我的博客知识库里检索相关内容,再把检索到的片段喂给大模型生成答案。

RAG 的经典四步:

前言

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数据库:

核心代码: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
`;

三、检索:向量召回

检索阶段做的事情很固定:

核心代码:问题向量化 + 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 流式返回

生成阶段关注两件事:

生成模型用的是 Groq 的 llama-3.3-70b-versatile,并且启用了 stream: true

服务端用 Server-Sent Events 把结果一点点推给前端,事件格式很简单:

这条链路的关键收益是:用户几乎立刻看到回复开始出现,体验远好于等完整 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();
  },
});

五、如何使用

六、一些 RAG 相关的“经验点


到这里,一个最小但完整的 RAG 就闭环了:内容进库 → 用户提问 → 向量召回 → 带来源生成 → 流式展示。后续如果要继续打磨,优先从“切分策略(chunking)/ 重排(rerank)/ 更严格的引用约束”这几块下手,性价比最高。


Edit page
Share this post on:

Next Post
使用Service Worker实现资源缓存加速
Code_You