Appearance
课 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 完整流水线
这是面试必考的核心知识,六个步骤:
RAG 六步流水线
文档解析
→
切块
→
Embedding
→
向量存储
→
检索
→
生成
文档解析 → 切块 → 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。
示例做了以下事情:
- 加载一组示例文档
- 递归切块
- 用 Embedding API 生成向量
- 存入内存
- 接受用户提问,检索最相关的 3 个块
- 用 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- 问一个文档里有明确答案的问题——验证检索是否成功
- 问一个文档里没有的内容——观察模型如何处理「检索不到」的情况
- 修改代码中的
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 的价值在于只取需要的那几段。