Skip to content

课 1 · RAG 全链路搭建

本课目标

理解 RAG 完整流水线,跑通文档解析 → 切块 → Embedding → 向量存储 → 检索 → 生成的闭环。课后你会拿到一个可运行的 RAG 检索 + 回答程序。

前两个模块我们做了聊天和 Prompt。但模型的知识是训练时冻结的——它不知道你公司的内部文档、最新的产品说明、也不了解你的私有数据。

RAG(Retrieval-Augmented Generation,检索增强生成) 的思路很直接:先从你的文档库里找到相关内容,再把这些内容塞进 Prompt,让模型基于它来回答。

RAG 的本质

一句话:给模型一个"开卷考试"的机会。

没有 RAG 时,模型回答问题全靠训练时记住的知识(闭卷考试)。问它你公司的内部 API 文档,它只能瞎编。

有了 RAG,模型回答之前先检索相关文档,把文档片段作为上下文传入(开卷考试)。模型只需要理解和组织这些内容,不需要"记住"所有知识。

为什么不直接把所有文档塞进 Prompt?

因为 Context Window 有限。虽然现在主流模型(如 Qwen3.6-Plus)都已经有很大的上下文窗口,听起来很大,但一个中型企业的文档量轻松超过几百万 Token。

就算全塞得下,也不该这么做:

  • 成本:每次调用都要为所有文档付费
  • 质量:模型在超长上下文中注意力分散(Lost in the Middle 问题)
  • 延迟:输入越长,首 Token 等待越久

RAG 的价值就在于只取需要的那几段

RAG 完整流水线

这是面试必考的核心知识,六个步骤:

文档解析 → 切块 → Embedding → 向量存储 → 检索 → 生成

第一步:文档解析

把原始文档(PDF、Markdown、HTML、Word 等)转换为纯文本。

typescript
import { readFileSync } from 'node:fs'

// 最简单的情况:Markdown 文件直接读取
function loadMarkdown(filePath: string): string {
  return readFileSync(filePath, 'utf-8')
}

// 实际项目中,PDF 需要用 pdf-parse,Word 需要用 mammoth 等库
// 核心目标:拿到干净的纯文本

文档解析看起来简单,但实际项目中经常是最头疼的环节——PDF 里的表格、图片中的文字、格式混乱的 HTML 都需要处理。这课先用 Markdown 文件演示核心流程。

第二步:切块(Chunking)

一篇文档可能几万字,不能一整篇存起来。需要拆成小块(Chunk),每块内容独立、语义完整。

为什么不能太长?

  • 向量检索返回的 Chunk 会塞进 Prompt,太长浪费 Context Window
  • 长文本的 Embedding 向量会把太多语义压缩在一起,检索不精确

为什么不能太短?

  • 太短缺乏上下文,模型拿到一句话很难给出好回答
  • 比如只有"返回值是 Promise",没有上下文不知道在说哪个函数

三种切块策略

1. 固定长度切块

按字符数或 Token 数切,设置重叠区域防止语义被截断:

typescript
function fixedSizeChunk(text: string, chunkSize: number = 500, overlap: number = 50): string[] {
  const chunks: string[] = []
  let start = 0
  while (start < text.length) {
    chunks.push(text.slice(start, start + chunkSize))
    start += chunkSize - overlap
  }
  return chunks
}

优点:实现简单。缺点:不管语义,可能从一句话中间切断。

2. 递归字符切块(推荐)

按分隔符层级递归切分:先按 \n\n(段落)切,段落过长再按 \n(行)切,行过长再按句号切。

typescript
function recursiveChunk(
  text: string,
  maxSize: number = 500,
  separators: string[] = ['\n\n', '\n', '。', '. ', ' ']
): string[] {
  if (text.length <= maxSize) return [text]

  for (const sep of separators) {
    const parts = text.split(sep)
    if (parts.length > 1) {
      const chunks: string[] = []
      let current = ''

      for (const part of parts) {
        const candidate = current ? current + sep + part : part
        if (candidate.length > maxSize && current) {
          chunks.push(current)
          current = part
        } else {
          current = candidate
        }
      }
      if (current) chunks.push(current)

      // 递归处理仍然过长的块
      return chunks.flatMap(chunk =>
        chunk.length > maxSize ? recursiveChunk(chunk, maxSize, separators) : [chunk]
      )
    }
  }

  // 所有分隔符都用完了,强制按长度切
  return fixedSizeChunk(text, maxSize)
}

这是最常用的策略,LangChain 的 RecursiveCharacterTextSplitter 就是这个思路。

3. 语义切块

用 Embedding 计算相邻段落的语义相似度,相似度低于阈值时切分。效果最好但实现复杂、成本高,通常只在高要求场景使用。

切块的经验值

参数推荐值说明
Chunk 大小300-800 字符太小缺上下文,太大检索不精确
重叠50-100 字符保证跨块语义连续
策略递归字符切块兼顾效率和质量

第三步:Embedding(向量化)

把每个文本块转换成一个向量(一组浮点数),使得语义相近的文本在向量空间中距离更近。

typescript
import { embed, embedMany } from 'ai'
import { createOpenAI } from '@ai-sdk/openai'

const provider = createOpenAI({
  apiKey: process.env.CHAT_API_KEY,
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
})
const model = provider.chat('qwen3.6-plus')
const embeddingModel = provider.embedding('text-embedding-v4')

// 单个文本的 Embedding
const { embedding } = await embed({
  model: embeddingModel,
  value: '什么是 React Server Components?',
})
// embedding 是一组浮点数向量,维度取决于所选 Embedding 模型

// 批量 Embedding(处理所有 Chunk)
const { embeddings } = await embedMany({
  model: embeddingModel,
  values: chunks, // 传入所有文本块
})

Embedding 模型选型

模型维度价格推荐场景
text-embedding-v4以控制台为准按百炼计费课程默认选择,与 Qwen 演示链路保持一致

课程中统一用 text-embedding-v4,这样 .env.example、Demo 代码和百炼演示链路保持一致。

为什么 Embedding 能做语义搜索?

传统关键词搜索只能匹配字面一致的词。Embedding 把语义编码成向量后:

  • "React 的虚拟 DOM" 和 "Virtual DOM in React" 的向量距离很近
  • "如何优化首屏加载" 和 "提升页面首次渲染速度" 的向量距离也很近

搜索时,把用户的问题也做 Embedding,然后找向量空间中最近的文档块,就能实现语义级别的匹配。

第四步:向量存储

Embedding 后的向量需要存到数据库中,方便后续检索。这里先用 PostgreSQL + pgvector 讲清工程上最常见的向量检索方案:对全栈工程师来说,不需要学一个全新的数据库,用熟悉的 Postgres 加个扩展就行。

typescript
// 建表(伪代码,实际用 SQL 或 ORM 执行)
// CREATE EXTENSION IF NOT EXISTS vector;
// CREATE TABLE documents (
//   id SERIAL PRIMARY KEY,
//   content TEXT NOT NULL,
//   embedding VECTOR(<embedding-dim>) NOT NULL,
//   metadata JSONB DEFAULT '{}'
// );

// 插入向量
async function storeChunks(
  chunks: string[],
  embeddings: number[][],
  metadata: Record<string, string>
) {
  for (let i = 0; i < chunks.length; i++) {
    await db.query(
      'INSERT INTO documents (content, embedding, metadata) VALUES ($1, $2, $3)',
      [chunks[i], JSON.stringify(embeddings[i]), metadata]
    )
  }
}

本地开发

本课的代码示例使用内存中的向量存储,不依赖真实数据库。课程工程实践里可以继续用 pgvector 理解通用方案;而主线最终交付的 final 会切到 SQLite + sqlite-vec,服务于本地优先工具形态。

第五步:检索

用户提问时,把问题做 Embedding,然后在向量数据库中找最相似的 K 个文档块:

typescript
async function search(query: string, topK: number = 3) {
  // 1. 问题 → 向量
  const { embedding } = await embed({
    model: embeddingModel,
    value: query,
  })

  // 2. 在数据库中找最近的向量(余弦相似度)
  const results = await db.query(
    'SELECT content, 1 - (embedding <=> $1) AS similarity FROM documents ORDER BY embedding <=> $1 LIMIT $2',
    [JSON.stringify(embedding), topK]
  )

  return results.rows
}

<=> 是 pgvector 的余弦距离运算符。距离越小 = 相似度越高。后续如果切到 sqlite-vec,核心思想不变,变化的是底层存储形态和查询接口。

第六步:生成

把检索到的文档块拼进 Prompt,让模型基于它们回答:

typescript
async function ragAnswer(question: string) {
  // 检索相关文档
  const docs = await search(question)
  const context = docs.map(d => d.content).join('\n\n---\n\n')

  // 带上下文生成回答
  const { text } = await generateText({
    model,
    system: `你是知识库助手。只基于以下文档内容回答问题,如果文档中没有相关信息就说"未找到"。

<documents>
${context}
</documents>`,
    prompt: question,
  })

  return text
}

整个流程到这里就闭环了:用户问一个问题 → Embedding → 检索最相关的文档块 → 拼进 Prompt → 模型基于文档回答。

跑通全链路

代码示例用内存模拟向量存储,不依赖外部数据库。这一节的目标是把 RAG 从文档解析到回答生成的整条链路跑通,还没有并入主线聊天项目。完整代码在 basic/examples/03-rag-basics/01-rag-pipeline/index.ts

示例做了以下事情:

  1. 加载一组示例文档
  2. 递归切块
  3. 用 Embedding API 生成向量
  4. 存入内存
  5. 接受用户提问,检索最相关的 3 个块
  6. 用 Qwen3.6-Plus 基于检索结果生成回答
bash
pnpm exec tsx basic/examples/03-rag-basics/01-rag-pipeline/index.ts

本课产物

  • ✅ 理解 RAG 全链路六个步骤
  • ✅ 三种切块策略的对比和选择
  • ✅ Embedding 原理和模型选型
  • ✅ 向量存储与检索的基本实现
  • ✅ 检索 + 生成的闭环 Demo

试试看

bash
cd daqi-ai-agent
pnpm exec tsx basic/examples/03-rag-basics/01-rag-pipeline/index.ts
  1. 问一个文档里有明确答案的问题——验证检索是否成功
  2. 问一个文档里没有的内容——观察模型如何处理「检索不到」的情况
  3. 修改代码中的 topK(默认 3),分别试 1 和 5,对比答案质量的差异

面试追问

Q:RAG 流水线的每一步分别做什么?

六步:文档解析(提取纯文本)→ 切块(拆成语义完整的小块)→ Embedding(转成向量)→ 向量存储(存到数据库)→ 检索(用户问题也做 Embedding,找最近的向量)→ 生成(把检索结果塞进 Prompt 让模型回答)。本质是把"闭卷考试"变成"开卷考试"。

Q:Embedding 和传统搜索有什么区别?

传统搜索(如 BM25、Elasticsearch)基于关键词匹配,需要字面一致才能匹配上。Embedding 搜索把文本编码成语义向量,语义相近的内容即使用词不同也能匹配。比如"提高页面加载速度"和"优化首屏渲染性能"关键词不同,但 Embedding 向量很接近。两者各有优劣,后续模块会讲混合检索。

Q:切块的大小怎么选?

通常 300-800 字符。太小缺乏上下文(模型不知道这句话在说什么),太大把不相关的内容一起检索出来浪费 Token 且降低精度。需要根据文档类型实测:API 文档可能 300 字符就够,技术博客可能需要 600-800。设置 50-100 字符的重叠,防止语义被截断。

Q:为什么不直接把所有文档塞进 Context Window?

三个原因:成本(每次调用都对所有文档付费)、质量(Lost in the Middle——模型对超长上下文中间部分的注意力下降)、延迟(输入越长首 Token 等待越久)。RAG 的价值在于只取需要的那几段。

面向前端工程师和独立开发者的 AI 应用工程课程