RAG 检索增强生成
大模型的知识有截止日期,也不知道你公司的内部文档。RAG(Retrieval-Augmented Generation)解决这个问题——先从知识库检索相关内容,再把内容注入 Prompt 让 AI 基于事实回答。本章讲完从文档导入到生成回答的完整 RAG 管线。
1. RAG 原理
用户提问 → 向量化 → 在知识库中检索相似内容 → 注入 Prompt → AI 生成回答
↓
"根据以下参考资料回答用户问题:
【检索到的文档片段 1】
【检索到的文档片段 2】
问题:用户的原始问题"
四个关键步骤:
- 文档解析:PDF/Markdown/HTML → 纯文本
- 文本分块:长文档切成 500-1000 Token 的小块
- 向量化存储:每个块转成向量(Embedding),存入向量数据库
- 检索生成:用户提问 → 向量化 → 找最相似的块 → 注入 Prompt → 生成回答
为什么不直接把整个文档塞进 Prompt?三个原因:
- 窗口限制:文档可能有几十万 Token,远超模型的 Context Window
- 成本问题:即使窗口够大,把 10 万 Token 的文档塞进去,每次调用光输入就要花 $0.25
- 注意力稀释:LLM 在处理超长上下文时,对中间部分的关注度会下降("Lost in the Middle" 问题),检索出最相关的少量内容反而效果更好
RAG 的核心价值:用最少的 Token 提供最有用的上下文,在准确性、成本和速度之间取得最佳平衡。
2. 文档解析与分块
2.1 文本提取
不同格式的文件需要不同的解析器:
// lib/rag/parser.ts
import pdf from 'pdf-parse'
export async function extractText(file: File): Promise<string> {
const buffer = Buffer.from(await file.arrayBuffer())
switch (file.type) {
case 'application/pdf':
const pdfData = await pdf(buffer)
return pdfData.text
case 'text/markdown':
case 'text/plain':
return buffer.toString('utf-8')
case 'text/html':
// 去除 HTML 标签,保留文本
return buffer.toString('utf-8').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ')
default:
throw new Error(`不支持的文件类型:${file.type}`)
}
}2.2 文本分块
分块策略直接影响检索质量。核心原则:每个块应该是语义完整的段落,不要在句子中间切断。
为什么要分块而不是用整篇文档做 Embedding?因为 Embedding 模型会把整段文本压缩成一个固定维度的向量。文本越长,信息越被稀释,向量就越“模糊”——一篇 10000 字的文档压缩成 1536 维向量,很难精确表达其中某个特定段落的语义。分成 500-1000 字的小块,每个块的向量能更精确地代表其内容。
// lib/rag/chunker.ts
interface Chunk {
content: string
index: number
metadata: {
source: string
page?: number
}
}
export function chunkText(
text: string,
options: {
chunkSize?: number // 目标块大小(字符数)
chunkOverlap?: number // 重叠字符数
source: string
},
): Chunk[] {
const { chunkSize = 1000, chunkOverlap = 200, source } = options
const chunks: Chunk[] = []
// 按段落分割
const paragraphs = text.split(/\n\n+/)
let currentChunk = ''
let chunkIndex = 0
for (const paragraph of paragraphs) {
if (currentChunk.length + paragraph.length > chunkSize && currentChunk.length > 0) {
chunks.push({
content: currentChunk.trim(),
index: chunkIndex++,
metadata: { source },
})
// 保留重叠部分
currentChunk = currentChunk.slice(-chunkOverlap) + '\n\n' + paragraph
} else {
currentChunk += (currentChunk ? '\n\n' : '') + paragraph
}
}
// 最后一块
if (currentChunk.trim()) {
chunks.push({
content: currentChunk.trim(),
index: chunkIndex,
metadata: { source },
})
}
return chunks
}分块参数的选择:
- chunkSize 500-1000:太小会丢失上下文(一句话没有前因后果),太大会稀释相关性(混入无关内容)
- chunkOverlap 100-200:重叠是为了防止信息在块边界被截断。比如一句话刚好在分块点被切断,重叠能保证完整句子至少出现在某一个块中
- 按段落/标题分割:比固定字符数切割效果好,因为段落是作者已经按语义单元分好的
3. 向量化(Embeddings)
3.1 什么是 Embedding
Embedding(嵌入向量)是把文本转成一个高维向量(如 1536 维的浮点数数组)。原理是:模型在大规模文本上训练时“学会”了语义关系——经常在相似上下文中出现的词,它们的向量会很接近。这不是简单的关键词匹配,而是语义级别的理解。
举例:“Next.js 路由” 和 “App Router 页面导航”这两段文本没有任何相同的字词,但它们的向量距离很近,因为语义相似。而 “Python 爬虫” 虽然也是编程话题,但向量距离就远得多。这正是 Embedding 相比传统关键词搜索的优势——它能找到“意思相近但用词不同”的内容。
向量的每一维代表一个抽象的语义特征(不是人类可解释的)。两段文本的向量越接近,语义越相似。衡量“接近”的方法就是余弦相似度(Cosine Similarity)。
3.2 用 AI SDK 生成 Embedding
// lib/rag/embeddings.ts
import { embed, embedMany } from 'ai'
import { openai } from '@ai-sdk/openai'
// 单个文本
export async function getEmbedding(text: string): Promise<number[]> {
const { embedding } = await embed({
model: openai.embedding('text-embedding-3-small'),
value: text,
})
return embedding
}
// 批量生成(效率更高)
export async function getEmbeddings(texts: string[]): Promise<number[][]> {
const { embeddings } = await embedMany({
model: openai.embedding('text-embedding-3-small'),
values: texts,
})
return embeddings
}模型选择:
- text-embedding-3-small:1536 维,便宜($0.02/1M Token),大多数场景够用
- text-embedding-3-large:3072 维,更精确,价格翻倍
- 选
small就好——性价比最高
4. 向量数据库
4.1 方案选择
| 方案 | 特点 | 适合场景 |
|---|---|---|
| pgvector | PostgreSQL 扩展,和业务数据同库 | 中小规模(<100 万向量) |
| Pinecone | 托管服务,开箱即用 | 不想管基础设施 |
| Qdrant | 开源,高性能 | 大规模,需要自托管 |
| Turbopuffer | Serverless 向量数据库 | Vercel 部署 |
推荐 pgvector——和业务数据在同一个 PostgreSQL 中,不需要额外服务,Neon/Supabase 都支持。
4.2 pgvector + Drizzle
// src/db/schema.ts — 向量表
import { pgTable, uuid, text, timestamp, integer, vector } from 'drizzle-orm/pg-core'
export const documentChunks = pgTable('document_chunks', {
id: uuid('id').defaultRandom().primaryKey(),
documentId: uuid('document_id').references(() => documents.id, { onDelete: 'cascade' }).notNull(),
content: text('content').notNull(),
chunkIndex: integer('chunk_index').notNull(),
embedding: vector('embedding', { dimensions: 1536 }), // pgvector 类型
metadata: text('metadata'), // JSON 字符串
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const documents = pgTable('documents', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
type: text('type').notNull(), // 'pdf' | 'markdown' | 'url'
workspaceId: uuid('workspace_id').references(() => workspaces.id).notNull(),
chunkCount: integer('chunk_count').default(0),
status: text('status', { enum: ['processing', 'ready', 'error'] }).default('processing'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})需要启用 pgvector 扩展和创建索引:
-- migration
CREATE EXTENSION IF NOT EXISTS vector;
CREATE INDEX ON document_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);4.3 文档导入流程
把解析、分块、向量化串起来:
// lib/rag/ingest.ts
export async function ingestDocument(file: File, workspaceId: string) {
// 1. 创建文档记录
const [doc] = await db.insert(documents).values({
name: file.name,
type: file.type,
workspaceId,
}).returning()
try {
// 2. 提取文本
const text = await extractText(file)
// 3. 分块
const chunks = chunkText(text, { source: file.name })
// 4. 批量生成 Embedding
const embeddings = await getEmbeddings(chunks.map((c) => c.content))
// 5. 批量写入数据库
await db.insert(documentChunks).values(
chunks.map((chunk, i) => ({
documentId: doc.id,
content: chunk.content,
chunkIndex: chunk.index,
embedding: embeddings[i],
metadata: JSON.stringify(chunk.metadata),
})),
)
// 6. 更新状态
await db.update(documents)
.set({ status: 'ready', chunkCount: chunks.length })
.where(eq(documents.id, doc.id))
return doc
} catch (error) {
await db.update(documents)
.set({ status: 'error' })
.where(eq(documents.id, doc.id))
throw error
}
}5. 检索与回答生成
5.1 相似性搜索
用户提问 → 向量化 → 在数据库中找最相似的块。
余弦相似度(Cosine Similarity)衡量两个向量的方向相似度,不受向量长度(文本长度)影响。值范围 0-1:1 表示完全相同,0 表示完全无关。它比欧氏距离更适合文本相似性计算,因为短文本和长文本的向量模长不同,但如果语义相似,方向应该接近。
在 pgvector 中,cosineDistance 计算的是余弦距离(= 1 - 余弦相似度),所以我们用 1 - cosineDistance 得到相似度:
// lib/rag/retrieve.ts
import { sql, cosineDistance, desc } from 'drizzle-orm'
export async function searchSimilarChunks(
query: string,
workspaceId: string,
options: { topK?: number; threshold?: number } = {},
) {
const { topK = 5, threshold = 0.3 } = options
// 1. 把用户问题向量化
const queryEmbedding = await getEmbedding(query)
// 2. 余弦相似度搜索
const similarity = sql<number>`1 - (${cosineDistance(documentChunks.embedding, queryEmbedding)})`
const results = await db
.select({
content: documentChunks.content,
similarity,
documentName: documents.name,
chunkIndex: documentChunks.chunkIndex,
})
.from(documentChunks)
.innerJoin(documents, eq(documentChunks.documentId, documents.id))
.where(
and(
eq(documents.workspaceId, workspaceId),
eq(documents.status, 'ready'),
sql`${similarity} > ${threshold}`, // 过滤低相关性结果
),
)
.orderBy(desc(similarity))
.limit(topK)
return results
}5.2 构建 RAG Prompt
把检索到的内容注入 System Prompt:
// lib/rag/prompt.ts
export function buildRAGPrompt(
chunks: { content: string; documentName: string }[],
): string {
if (chunks.length === 0) {
return '知识库中没有找到相关内容。请基于你的通用知识回答,并提醒用户这不是基于知识库的回答。'
}
const context = chunks
.map((c, i) => `[来源:${c.documentName}]\n${c.content}`)
.join('\n\n---\n\n')
return `你是一个知识库问答助手。请严格基于以下参考资料回答用户的问题。
规则:
1. 只使用参考资料中的信息,不要编造内容
2. 如果参考资料不足以回答问题,明确说明"知识库中未找到相关信息"
3. 引用信息时标注来源文档名称
4. 回答要简洁、准确
参考资料:
${context}`
}5.3 RAG 对话 API
// app/api/chat/rag/route.ts
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { searchSimilarChunks } from '@/lib/rag/retrieve'
import { buildRAGPrompt } from '@/lib/rag/prompt'
export async function POST(request: Request) {
const { messages, chatId } = await request.json()
const { workspace } = await requireWorkspaceAccess()
// 用最后一条用户消息做检索
const lastUserMessage = messages.findLast((m: any) => m.role === 'user')
// 检索相关文档
const chunks = await searchSimilarChunks(lastUserMessage.content, workspace.id, {
topK: 5,
threshold: 0.3,
})
const result = streamText({
model: openai('gpt-4o'),
system: buildRAGPrompt(chunks),
messages,
onFinish: async ({ text, usage }) => {
// 保存消息和引用来源
await db.insert(dbMessages).values({
chatId,
role: 'assistant',
content: text,
metadata: JSON.stringify({ sources: chunks.map((c) => c.documentName) }),
})
},
})
return result.toDataStreamResponse()
}6. 优化策略
6.1 查询改写
用户的提问往往是口语化的(“那个退款怎么搞的来着”),而知识库里的文档是书面语(“退款政策:用户可在 7 天内申请...”)。直接用口语做 Embedding 检索,相似度可能不高。查询改写就是用一个小模型先把口语转成更适合检索的关键词查询:
async function rewriteQuery(query: string): Promise<string> {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
prompt: `将以下用户问题改写为适合知识库检索的关键词查询(保持简短,提取核心概念):\n\n${query}`,
maxTokens: 100,
})
return text
}6.2 混合搜索
向量搜索擅长语义匹配(“退货流程”能匹配“退款政策”),但对精确关键词不够好(搜“错误码 E1042”时,向量搜索可能找到“常见错误”但漏掉包含 E1042 的那段)。全文搜索则相反——精确匹配关键词很强,但不懂语义。
混合搜索把两者结合:同时做向量搜索和全文搜索,合并结果。这样既能找到语义相关的内容,也能找到精确包含关键词的内容:
// 同时做向量搜索和全文搜索,合并结果
async function hybridSearch(query: string, workspaceId: string) {
const [vectorResults, textResults] = await Promise.all([
searchSimilarChunks(query, workspaceId, { topK: 5 }),
db.select()
.from(documentChunks)
.innerJoin(documents, eq(documentChunks.documentId, documents.id))
.where(
and(
eq(documents.workspaceId, workspaceId),
sql`to_tsvector('chinese', ${documentChunks.content}) @@ plainto_tsquery('chinese', ${query})`,
),
)
.limit(3),
])
// 去重合并
const seen = new Set<string>()
const merged = []
for (const result of [...vectorResults, ...textResults]) {
if (!seen.has(result.content)) {
seen.add(result.content)
merged.push(result)
}
}
return merged.slice(0, 8)
}6.3 Reranking
检索是“粗筛”,Reranking 是“精排”。Embedding 模型是轻量级的,能快速从几万个块中筛出 Top-20。但 Reranker 模型更重(比如 Cohere rerank-v3.5),它会同时看问题和文档的全文,更精确地判断相关性。但它慢,不能直接对几万个块排序。所以实践中用“二阶段检索”:先用 Embedding 粗筛出 Top-20,再用 Reranker 从中精排出 Top-5:
import { CohereClient } from 'cohere-ai'
const cohere = new CohereClient({ token: process.env.COHERE_API_KEY })
async function rerankResults(query: string, chunks: { content: string }[]) {
const response = await cohere.rerank({
query,
documents: chunks.map((c) => ({ text: c.content })),
topN: 5,
model: 'rerank-v3.5',
})
return response.results.map((r) => chunks[r.index])
}7. 文档管理界面
// app/(dashboard)/knowledge/page.tsx
export default async function KnowledgePage() {
const { workspace } = await requireWorkspaceAccess()
const docs = await db.query.documents.findMany({
where: eq(documents.workspaceId, workspace.id),
orderBy: desc(documents.createdAt),
})
return (
<div className="p-6">
<h1 className="text-xl font-bold mb-4">知识库</h1>
<UploadForm workspaceId={workspace.id} />
<div className="mt-6 space-y-2">
{docs.map((doc) => (
<div key={doc.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">{doc.name}</p>
<p className="text-sm text-gray-500">
{doc.chunkCount} 个分块 · {doc.status === 'ready' ? '✅ 就绪' : doc.status === 'processing' ? '⏳ 处理中' : '❌ 失败'}
</p>
</div>
<form action={deleteDocument.bind(null, doc.id)}>
<button className="text-red-500 text-sm">删除</button>
</form>
</div>
))}
</div>
</div>
)
}本章小结
- RAG 流程:文档解析 → 分块 → 向量化 → 存储 → 检索 → 注入 Prompt → 生成回答
- 分块策略:按段落分割,500-1000 字符/块,100-200 字符重叠
- 向量化:
text-embedding-3-small,性价比最高 - 存储:pgvector + PostgreSQL,和业务数据同库
- 检索:余弦相似度 + 相关性阈值过滤 + Top-K
- 优化:查询改写 → 混合搜索 → Reranking 三级优化管线
下一章讲 AI Agent 与工具调用。