RAG 知识库功能实现

RAG(Retrieval-Augmented Generation,检索增强生成)是让 AI 基于你自己的数据回答问题的核心技术。纯 LLM 只有训练数据中的知识,无法回答"我们公司的退款政策是什么"这类私有问题。RAG 通过在生成前先检索相关文档片段,把它们注入到 Prompt 中,让模型基于真实数据回答。本章从文档处理管道到检索策略,完整实现一个生产级 RAG 系统。

1. RAG 核心原理

1.1 RAG 的工作流程

用户提问 "退款政策是什么?"
        │
        ▼
① Embedding:将问题转为向量 [0.12, -0.34, ...]
        │
        ▼
② 检索:在向量数据库中查找最相似的文档块(Top-K)
        │
        ▼
③ 拼接:把检索到的文档块放入 Prompt
        │
        ▼
④ 生成:LLM 基于文档内容生成回答
        │
        ▼
⑤ 引用:标注回答来源的文档和页码

这个流程看起来简单,但每一步都有工程细节。RAG 的质量瓶颈通常不在模型本身,而在检索质量——如果检索到的文档片段不相关或不完整,再强的模型也给不出好答案。

1.2 影响 RAG 质量的关键因素

因素影响优化方向
分块策略块太大→检索不精确;块太小→缺失上下文512 tokens + 50 overlap
Embedding 模型模型质量直接决定语义匹配准确度text-embedding-3-small 性价比最高
检索数量(Top-K)K 太小→可能遗漏;K 太大→引入噪声K=5 作为起点,按场景调整
相似度阈值阈值太低→引入不相关内容0.7 以上才纳入上下文
文档解析质量格式丢失、表格损坏→内容失真针对不同文件类型用专门的解析器
查询改写用户问题模糊时检索效果差HyDE 或 Query Rewriting

1.3 朴素 RAG vs 高级 RAG

维度朴素 RAG高级 RAG
检索方式纯向量相似度混合检索(向量 + 关键词 + 重排序)
查询处理直接用原始问题检索查询改写 / 多查询扩展
分块固定大小切分语义分块 / 递归分块
上下文Top-K 文档块带父子关系的上下文窗口
适用场景文档少于 100 篇大型知识库、高精度需求

本章先实现朴素 RAG(覆盖 80% 场景),然后逐步添加高级优化。

2. 文档处理管道

2.1 文档上传流程

用户上传 PDF/Word/TXT
        │
        ▼
① 上传到 S3(复用第 73 章文件存储)
        │
        ▼
② 写入 documents 表,状态 = 'pending'
        │
        ▼
③ 触发异步任务(Inngest)
        │
        ▼
④ 解析文档 → 提取纯文本
        │
        ▼
⑤ 分块(Chunking)
        │
        ▼
⑥ Embedding → 写入 document_chunks(含向量)
        │
        ▼
⑦ 更新 documents 状态 = 'ready'

异步处理是关键设计——文档解析和向量化可能需要几秒到几分钟(取决于文档大小),不能阻塞用户操作。

2.2 文档上传 Action

// lib/actions/knowledge.ts
'use server'
 
export async function uploadDocument(knowledgeBaseId: string, formData: FormData) {
  const { session, membership } = await requirePermission('ai:knowledge:write')
 
  const file = formData.get('file') as File
  if (!file) throw new Error('No file provided')
 
  // 验证文件类型
  const allowedTypes = ['application/pdf', 'text/plain', 'text/markdown',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document']
  if (!allowedTypes.includes(file.type)) {
    throw new Error('Unsupported file type')
  }
 
  // 上传到 S3
  const key = `${membership.tenantId}/knowledge/${knowledgeBaseId}/${nanoid()}/${file.name}`
  const uploadUrl = await createUploadUrl(key, file.type)
  // 客户端直传(或在这里转发——小文件可以服务端中转)
 
  // 写入数据库
  const [doc] = await db.insert(documents).values({
    knowledgeBaseId,
    name: file.name,
    fileKey: key,
    fileSize: file.size,
    mimeType: file.type,
    status: 'pending',
  }).returning()
 
  // 触发异步处理
  await inngest.send({
    name: 'knowledge/document.process',
    data: { documentId: doc.id, knowledgeBaseId },
  })
 
  revalidatePath(`/knowledge/${knowledgeBaseId}`)
  return doc
}

2.3 文档解析

不同格式的文件需要不同的解析器:

// lib/ai/rag/parse.ts
 
export async function parseDocument(fileKey: string, mimeType: string): Promise<string> {
  const fileBuffer = await downloadFromS3(fileKey)
 
  switch (mimeType) {
    case 'text/plain':
    case 'text/markdown':
      return fileBuffer.toString('utf-8')
 
    case 'application/pdf':
      return parsePDF(fileBuffer)
 
    case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
      return parseDocx(fileBuffer)
 
    default:
      throw new Error(`Unsupported mime type: ${mimeType}`)
  }
}
 
// PDF 解析
async function parsePDF(buffer: Buffer): Promise<string> {
  const { default: pdfParse } = await import('pdf-parse')
  const data = await pdfParse(buffer)
  return data.text
}
 
// Word 解析
async function parseDocx(buffer: Buffer): Promise<string> {
  const mammoth = await import('mammoth')
  const result = await mammoth.extractRawText({ buffer })
  return result.value
}

2.4 文档分块(Chunking)

分块是 RAG 质量的关键环节。分块过大,检索时会引入大量无关内容;分块过小,又会丢失上下文。推荐的策略是 递归字符分块 + 重叠

// lib/ai/rag/chunk.ts
 
interface ChunkOptions {
  chunkSize: number      // 每块目标大小(字符数)
  chunkOverlap: number   // 重叠区域
  separators?: string[]  // 分割优先级
}
 
export function splitIntoChunks(
  text: string,
  options: ChunkOptions = { chunkSize: 1000, chunkOverlap: 100 }
): string[] {
  const { chunkSize, chunkOverlap } = options
  const separators = options.separators || ['\n\n', '\n', '. ', ' ', '']
 
  return recursiveSplit(text, separators, chunkSize, chunkOverlap)
}
 
function recursiveSplit(
  text: string,
  separators: string[],
  chunkSize: number,
  overlap: number
): string[] {
  if (text.length <= chunkSize) return [text]
 
  // 找到能分割文本的分隔符
  let separator = ''
  for (const sep of separators) {
    if (text.includes(sep)) {
      separator = sep
      break
    }
  }
 
  // 按分隔符拆分
  const splits = separator ? text.split(separator) : [text]
  const chunks: string[] = []
  let currentChunk = ''
 
  for (const split of splits) {
    const piece = separator ? split + separator : split
 
    if ((currentChunk + piece).length > chunkSize && currentChunk.length > 0) {
      chunks.push(currentChunk.trim())
      // 重叠:保留上一块末尾的内容
      currentChunk = currentChunk.slice(-overlap) + piece
    } else {
      currentChunk += piece
    }
  }
 
  if (currentChunk.trim()) chunks.push(currentChunk.trim())
 
  return chunks
}

分块策略的选择依据:

  • 段落优先:先按 \n\n(段落)分割,保持语义完整性
  • 句子次之:段落太长时按 \n. 分割
  • 重叠保证连续性:100 字符的重叠确保分块边界处的信息不丢失
  • 1000 字符 ≈ 250 tokens:每块约 250 tokens,5 块 Top-K 占 ~1250 tokens,留足空间给对话历史和回答

2.5 Embedding 与入库

// lib/ai/rag/embed.ts
import { embed, embedMany } from 'ai'
import { openai } from '@ai-sdk/openai'
 
const embeddingModel = openai.embedding('text-embedding-3-small')
 
// 单条文本嵌入
export async function embedText(text: string): Promise<number[]> {
  const { embedding } = await embed({
    model: embeddingModel,
    value: text,
  })
  return embedding
}
 
// 批量嵌入(更高效)
export async function embedTexts(texts: string[]): Promise<number[][]> {
  const { embeddings } = await embedMany({
    model: embeddingModel,
    values: texts,
  })
  return embeddings
}

2.6 异步处理函数

// lib/inngest/functions/process-document.ts
import { inngest } from '../client'
 
export const processDocument = inngest.createFunction(
  { id: 'process-document', retries: 3 },
  { event: 'knowledge/document.process' },
  async ({ event, step }) => {
    const { documentId, knowledgeBaseId } = event.data
 
    // 1. 获取文档信息
    const doc = await step.run('get-document', async () => {
      const [d] = await db.select().from(documents).where(eq(documents.id, documentId))
      return d
    })
 
    // 2. 更新状态为处理中
    await step.run('update-status-processing', async () => {
      await db.update(documents)
        .set({ status: 'processing' })
        .where(eq(documents.id, documentId))
    })
 
    // 3. 解析文档
    const text = await step.run('parse-document', async () => {
      return parseDocument(doc.fileKey!, doc.mimeType!)
    })
 
    // 4. 获取知识库配置
    const kb = await step.run('get-kb-config', async () => {
      const [k] = await db.select().from(knowledgeBases)
        .where(eq(knowledgeBases.id, knowledgeBaseId))
      return k
    })
 
    // 5. 分块
    const chunks = await step.run('chunk-text', async () => {
      return splitIntoChunks(text, {
        chunkSize: (kb.chunkSize || 512) * 4,  // tokens → 字符近似
        chunkOverlap: (kb.chunkOverlap || 50) * 4,
      })
    })
 
    // 6. 批量 Embedding(每批 100 条)
    const allEmbeddings: number[][] = []
    for (let i = 0; i < chunks.length; i += 100) {
      const batch = chunks.slice(i, i + 100)
      const embeddings = await step.run(`embed-batch-${i}`, async () => {
        return embedTexts(batch)
      })
      allEmbeddings.push(...embeddings)
    }
 
    // 7. 写入 document_chunks
    await step.run('insert-chunks', async () => {
      const values = chunks.map((content, index) => ({
        documentId,
        content,
        embedding: allEmbeddings[index],
        chunkIndex: index,
        metadata: { documentName: doc.name },
      }))
      await db.insert(documentChunks).values(values)
    })
 
    // 8. 更新文档状态
    await step.run('update-status-ready', async () => {
      await db.update(documents)
        .set({ status: 'ready', chunkCount: chunks.length })
        .where(eq(documents.id, documentId))
 
      await db.update(knowledgeBases)
        .set({
          documentCount: sql`${knowledgeBases.documentCount} + 1`,
        })
        .where(eq(knowledgeBases.id, knowledgeBaseId))
    })
 
    return { chunkCount: chunks.length }
  }
)

每一步用 step.run() 包裹,Inngest 会自动处理重试和幂等性——如果 Embedding 步骤失败,不会重复解析文档。

3. 向量检索

3.1 基础向量检索

// lib/ai/rag/retrieve.ts
import { sql } from 'drizzle-orm'
 
interface RetrievalResult {
  content: string
  similarity: number
  documentName: string
  chunkIndex: number
  documentId: string
}
 
export async function retrieveRelevantChunks(
  query: string,
  knowledgeBaseId: string,
  options: { topK?: number; threshold?: number } = {}
): Promise<RetrievalResult[]> {
  const { topK = 5, threshold = 0.7 } = options
 
  // 1. 将查询转为向量
  const queryEmbedding = await embedText(query)
 
  // 2. pgvector 余弦相似度搜索
  const results = await db.execute(sql`
    SELECT
      dc.content,
      dc.chunk_index,
      dc.document_id,
      dc.metadata->>'documentName' as document_name,
      1 - (dc.embedding <=> ${JSON.stringify(queryEmbedding)}::vector) as similarity
    FROM document_chunks dc
    JOIN documents d ON d.id = dc.document_id
    WHERE d.knowledge_base_id = ${knowledgeBaseId}
      AND d.status = 'ready'
      AND 1 - (dc.embedding <=> ${JSON.stringify(queryEmbedding)}::vector) > ${threshold}
    ORDER BY dc.embedding <=> ${JSON.stringify(queryEmbedding)}::vector
    LIMIT ${topK}
  `)
 
  return results.rows as RetrievalResult[]
}

&lt;=&gt; 是 pgvector 的余弦距离运算符,1 - distance 得到相似度(0-1,越高越相似)。

纯向量检索在精确匹配(如产品名称、错误代码)上表现不佳。混合检索结合向量语义搜索和传统关键词搜索,两者互补:

export async function hybridRetrieve(
  query: string,
  knowledgeBaseId: string,
  options: { topK?: number } = {}
): Promise<RetrievalResult[]> {
  const { topK = 5 } = options
  const queryEmbedding = await embedText(query)
 
  // 同时执行向量搜索和全文搜索
  const results = await db.execute(sql`
    WITH vector_results AS (
      SELECT dc.id, dc.content, dc.document_id, dc.chunk_index, dc.metadata,
        1 - (dc.embedding <=> ${JSON.stringify(queryEmbedding)}::vector) as vector_score
      FROM document_chunks dc
      JOIN documents d ON d.id = dc.document_id
      WHERE d.knowledge_base_id = ${knowledgeBaseId} AND d.status = 'ready'
      ORDER BY dc.embedding <=> ${JSON.stringify(queryEmbedding)}::vector
      LIMIT ${topK * 2}
    ),
    keyword_results AS (
      SELECT dc.id, dc.content, dc.document_id, dc.chunk_index, dc.metadata,
        ts_rank(to_tsvector('english', dc.content), plainto_tsquery('english', ${query})) as keyword_score
      FROM document_chunks dc
      JOIN documents d ON d.id = dc.document_id
      WHERE d.knowledge_base_id = ${knowledgeBaseId} AND d.status = 'ready'
        AND to_tsvector('english', dc.content) @@ plainto_tsquery('english', ${query})
      LIMIT ${topK * 2}
    )
    SELECT DISTINCT ON (id)
      content, document_id, chunk_index,
      metadata->>'documentName' as document_name,
      COALESCE(v.vector_score, 0) * 0.7 + COALESCE(k.keyword_score, 0) * 0.3 as combined_score
    FROM vector_results v
    FULL OUTER JOIN keyword_results k USING (id, content, document_id, chunk_index, metadata)
    ORDER BY id, combined_score DESC
    LIMIT ${topK}
  `)
 
  return results.rows as RetrievalResult[]
}

权重分配 0.7 向量 + 0.3 关键词 是常见起点,可以根据实际效果调整。

3.3 查询改写(Query Rewriting)

用户的问题经常不适合直接检索——太口语化、太简短、或者包含代词。查询改写让 LLM 先优化问题,再拿优化后的问题去检索:

// lib/ai/rag/query-rewrite.ts
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
 
export async function rewriteQuery(
  query: string,
  chatHistory?: string
): Promise<string> {
  const { text } = await generateText({
    model: openai('gpt-4o-mini'),
    system: `You are a search query optimizer. Given a user question (and optional chat history), 
rewrite it into a clear, self-contained search query. 
Rules:
- Resolve pronouns (it, this, that) using chat history
- Expand abbreviations
- Keep it concise (under 50 words)
- Return ONLY the rewritten query`,
    prompt: chatHistory
      ? `Chat history:\n${chatHistory}\n\nLatest question: ${query}`
      : query,
    maxTokens: 100,
  })
 
  return text.trim()
}

例如,对话中用户先问"退款政策是什么",然后问"超过 30 天呢"——查询改写会把第二个问题转化为"超过 30 天的退款政策是什么"。

4. RAG 对话集成

4.1 RAG 增强的对话 API

// app/api/chat/route.ts(RAG 增强版)
export async function POST(req: Request) {
  const session = await getSession()
  if (!session) return new Response('Unauthorized', { status: 401 })
 
  const tenantId = await getCurrentTenantId()
  const { messages: clientMessages, conversationId, knowledgeBaseId } = await req.json()
 
  const lastUserMsg = clientMessages[clientMessages.length - 1]
 
  let ragContext = ''
  let sources: RetrievalResult[] = []
 
  // 如果指定了知识库,执行 RAG 检索
  if (knowledgeBaseId && lastUserMsg.role === 'user') {
    const rewrittenQuery = await rewriteQuery(lastUserMsg.content)
    sources = await retrieveRelevantChunks(rewrittenQuery, knowledgeBaseId)
 
    if (sources.length > 0) {
      ragContext = sources
        .map((s, i) => `[Source ${i + 1}: ${s.documentName}]\n${s.content}`)
        .join('\n\n---\n\n')
    }
  }
 
  const systemPrompt = buildRAGSystemPrompt(ragContext)
 
  const result = streamText({
    model: openai('gpt-4o-mini'),
    system: systemPrompt,
    messages: clientMessages,
    onFinish: async ({ text, usage }) => {
      await saveAssistantMessage(conversationId, text, usage, {
        sources: sources.map(s => ({
          documentName: s.documentName,
          chunkIndex: s.chunkIndex,
          similarity: s.similarity,
        })),
      })
    },
  })
 
  // 将 sources 作为流数据附带返回
  return result.toDataStreamResponse({
    getErrorMessage: (error) => String(error),
  })
}
 
function buildRAGSystemPrompt(context: string): string {
  if (!context) {
    return 'You are a helpful assistant. If you cannot find relevant information, say so honestly.'
  }
 
  return `You are a helpful assistant that answers questions based on the provided documents.
 
IMPORTANT RULES:
- Base your answers ONLY on the provided context. Do not use outside knowledge.
- If the context doesn't contain relevant information, say "I couldn't find relevant information in the documents."
- When citing information, reference the source (e.g., "According to [Source 1]...").
- Be precise and factual. Do not speculate or infer beyond what the documents state.
 
CONTEXT:
${context}`
}

4.2 引用溯源

让用户知道 AI 的回答来自哪个文档的哪个部分,是建立信任的关键。引用信息可以通过 AI SDK 的 Data 通道传递:

// 在流开始前,通过 data 附加 sources
import { createDataStream } from 'ai'
 
const dataStream = createDataStream({
  execute: async (writer) => {
    // 先发送 sources 数据
    writer.writeData({
      type: 'sources',
      sources: sources.map(s => ({
        documentName: s.documentName,
        similarity: Math.round(s.similarity * 100),
        preview: s.content.slice(0, 150) + '...',
      })),
    })
 
    // 然后开始流式生成
    const result = streamText({
      model: openai('gpt-4o-mini'),
      system: systemPrompt,
      messages: clientMessages,
    })
 
    result.mergeIntoDataStream(writer)
  },
})
 
return new Response(dataStream.stream, {
  headers: { 'Content-Type': 'text/event-stream' },
})

客户端通过 useChatdata 字段获取引用信息:

const { messages, data } = useChat({ api: '/api/chat' })
 
// data 中包含 sources 信息
const sources = data?.find(d => d.type === 'sources')?.sources

5. 知识库管理

5.1 知识库 CRUD

// lib/actions/knowledge.ts
 
export async function createKnowledgeBase(data: { name: string; description?: string }) {
  const { membership } = await requirePermission('ai:knowledge:write')
 
  const [kb] = await db.insert(knowledgeBases).values({
    tenantId: membership.tenantId,
    name: data.name,
    description: data.description,
  }).returning()
 
  return kb
}
 
export async function deleteKnowledgeBase(knowledgeBaseId: string) {
  const { membership } = await requirePermission('ai:knowledge:write')
 
  // 验证归属
  const [kb] = await db.select().from(knowledgeBases)
    .where(and(
      eq(knowledgeBases.id, knowledgeBaseId),
      eq(knowledgeBases.tenantId, membership.tenantId),
    ))
  if (!kb) throw new Error('Knowledge base not found')
 
  // 级联删除:documents → document_chunks
  await db.delete(knowledgeBases).where(eq(knowledgeBases.id, knowledgeBaseId))
 
  // 清理 S3 文件(异步)
  await inngest.send({
    name: 'knowledge/cleanup-files',
    data: { tenantId: membership.tenantId, knowledgeBaseId },
  })
}

5.2 文档状态管理

文档从上传到可用有明确的状态流转:

pending → processing → ready
                   ↘ failed

在知识库详情页面显示每个文档的处理状态:

// components/document-status.tsx
const STATUS_MAP = {
  pending: { label: 'Queued', color: 'bg-yellow-100 text-yellow-700' },
  processing: { label: 'Processing', color: 'bg-blue-100 text-blue-700' },
  ready: { label: 'Ready', color: 'bg-green-100 text-green-700' },
  failed: { label: 'Failed', color: 'bg-red-100 text-red-700' },
}
 
export function DocumentStatus({ status }: { status: string }) {
  const config = STATUS_MAP[status as keyof typeof STATUS_MAP]
  return (
    <span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${config.color}`}>
      {config.label}
    </span>
  )
}

5.3 重新处理失败文档

export async function retryDocumentProcessing(documentId: string) {
  const { membership } = await requirePermission('ai:knowledge:write')
 
  // 清除旧的 chunks
  await db.delete(documentChunks).where(eq(documentChunks.documentId, documentId))
 
  // 重置状态
  await db.update(documents)
    .set({ status: 'pending', errorMessage: null, chunkCount: 0 })
    .where(eq(documents.id, documentId))
 
  // 重新触发处理
  const [doc] = await db.select().from(documents).where(eq(documents.id, documentId))
  await inngest.send({
    name: 'knowledge/document.process',
    data: { documentId, knowledgeBaseId: doc.knowledgeBaseId },
  })
}

6. RAG 质量优化

6.1 分块质量评估

分块质量可以通过一个简单的测试衡量:给定一组已知问答对,检查检索到的文档块是否包含正确答案。

// scripts/eval-rag.ts
interface TestCase {
  question: string
  expectedDocumentName: string
  expectedKeywords: string[]
}
 
async function evaluateRAG(knowledgeBaseId: string, testCases: TestCase[]) {
  let hits = 0
 
  for (const tc of testCases) {
    const results = await retrieveRelevantChunks(tc.question, knowledgeBaseId)
 
    const hasCorrectDoc = results.some(r => r.documentName === tc.expectedDocumentName)
    const hasKeywords = tc.expectedKeywords.some(kw =>
      results.some(r => r.content.toLowerCase().includes(kw.toLowerCase()))
    )
 
    if (hasCorrectDoc && hasKeywords) hits++
 
    console.log(`${hasCorrectDoc && hasKeywords ? '✅' : '❌'} ${tc.question}`)
  }
 
  console.log(`\nAccuracy: ${hits}/${testCases.length} (${(hits/testCases.length*100).toFixed(1)}%)`)
}

6.2 上下文窗口扩展

检索到的块可能缺少上下文。一个有效的策略是 Parent-Child 检索——检索到精确的小块后,把它的前后相邻块也纳入上下文:

export async function retrieveWithContext(
  query: string,
  knowledgeBaseId: string
): Promise<RetrievalResult[]> {
  const results = await retrieveRelevantChunks(query, knowledgeBaseId, { topK: 3 })
 
  // 对每个结果,获取前后各一个块
  const expanded: RetrievalResult[] = []
  for (const result of results) {
    const neighbors = await db.select()
      .from(documentChunks)
      .where(and(
        eq(documentChunks.documentId, result.documentId),
        sql`${documentChunks.chunkIndex} BETWEEN ${result.chunkIndex - 1} AND ${result.chunkIndex + 1}`,
      ))
      .orderBy(asc(documentChunks.chunkIndex))
 
    const combined = neighbors.map(n => n.content).join('\n')
    expanded.push({ ...result, content: combined })
  }
 
  return expanded
}

本章小结

  • RAG 原理:检索相关文档块 → 注入 Prompt → 基于文档内容生成回答
  • 质量关键:分块策略 > Embedding 模型 > 检索数量,检索质量是 RAG 效果的天花板
  • 文档管道:上传 → S3 存储 → 异步解析(PDF/Word/TXT) → 分块 → Embedding → 入库
  • 递归分块:段落优先分割,1000 字符 + 100 字符重叠,保持语义完整
  • Inngest 异步:文档处理用 step.run() 步骤化,失败自动重试,不重复处理已完成步骤
  • 向量检索:pgvector 余弦距离,相似度阈值 0.7 过滤噪声
  • 混合检索:0.7 向量 + 0.3 关键词,解决精确匹配不足的问题
  • 查询改写:用 LLM 优化用户问题,解决代词和模糊表述
  • 引用溯源:通过 Data Stream 将 sources 附带给客户端,标注文档名和相似度
  • 质量优化:评估脚本量化检索准确率,Parent-Child 检索扩展上下文