RAG 检索增强生成

大模型的知识有截止日期,也不知道你公司的内部文档。RAG(Retrieval-Augmented Generation)解决这个问题——先从知识库检索相关内容,再把内容注入 Prompt 让 AI 基于事实回答。本章讲完从文档导入到生成回答的完整 RAG 管线。

1. RAG 原理

用户提问 → 向量化 → 在知识库中检索相似内容 → 注入 Prompt → AI 生成回答
                                                    ↓
                              "根据以下参考资料回答用户问题:
                               【检索到的文档片段 1】
                               【检索到的文档片段 2】
                               问题:用户的原始问题"

四个关键步骤:

  1. 文档解析:PDF/Markdown/HTML → 纯文本
  2. 文本分块:长文档切成 500-1000 Token 的小块
  3. 向量化存储:每个块转成向量(Embedding),存入向量数据库
  4. 检索生成:用户提问 → 向量化 → 找最相似的块 → 注入 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 方案选择

方案特点适合场景
pgvectorPostgreSQL 扩展,和业务数据同库中小规模(<100 万向量)
Pinecone托管服务,开箱即用不想管基础设施
Qdrant开源,高性能大规模,需要自托管
TurbopufferServerless 向量数据库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 与工具调用。